Skip to content

Commit 54bc4ee

Browse files
Copilothotlong
andcommitted
feat: align sharing/exportOptions/pagination protocol with spec format
- Update Zod validators: sharing accepts type/lockedBy, exportOptions accepts string[] union - Add sharing adapter in spec-bridge: normalize spec format to ObjectUI format - Add comprehensive unit tests for all three features (types, bridge, ListView) - Update ROADMAP.md with completion details Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent f2dd28c commit 54bc4ee

File tree

7 files changed

+335
-9
lines changed

7 files changed

+335
-9
lines changed

ROADMAP.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -703,8 +703,9 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
703703
**P2 — Advanced Features:**
704704
- [x] `rowActions`: Row-level dropdown action menu per row in ObjectGrid. `schema.rowActions` string array items rendered as dropdown menu items, dispatched via `executeAction`.
705705
- [x] `bulkActions`: Bulk action bar rendered in ListView when rows are selected and `schema.bulkActions` is configured. Fires `onBulkAction` callback with action name and selected rows.
706-
- [x] `sharing` schema reconciliation: Supports both ObjectUI `{ visibility, enabled }` and spec `{ type: personal/collaborative, lockedBy }` models. Share button renders when either `enabled: true` or `type` is set.
707-
- [x] `pagination.pageSizeOptions` backend integration: Page size selector is now a controlled component that dynamically updates `effectivePageSize`, triggering data re-fetch.
706+
- [x] `sharing` schema reconciliation: Supports both ObjectUI `{ visibility, enabled }` and spec `{ type: personal/collaborative, lockedBy }` models. Share button renders when either `enabled: true` or `type` is set. Zod validator updated with `type` and `lockedBy` fields. Bridge normalizes spec format: `type: personal``visibility: private`, `type: collaborative``visibility: team`, auto-sets `enabled: true`.
707+
- [x] `exportOptions` schema reconciliation: Zod validator updated to accept both spec `string[]` format and ObjectUI object format via `z.union()`. ListView normalizes string[] to `{ formats }` at render time.
708+
- [x] `pagination.pageSizeOptions` backend integration: Page size selector is now a controlled component that dynamically updates `effectivePageSize`, triggering data re-fetch. `onPageSizeChange` callback fires on selection. Full test coverage for selector rendering, option enumeration, and data reload.
708709

709710
### P2.5 PWA & Offline (Real Sync)
710711

packages/plugin-list/src/__tests__/Export.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,25 @@ describe('ListView Export', () => {
5555
expect(exportButton).toBeInTheDocument();
5656
});
5757

58+
it('should render export button with spec string[] format', () => {
59+
const schema: ListViewSchema = {
60+
type: 'list-view',
61+
objectName: 'contacts',
62+
viewType: 'grid',
63+
fields: ['name', 'email'],
64+
exportOptions: ['csv', 'xlsx'],
65+
};
66+
67+
renderWithProvider(<ListView schema={schema} />);
68+
const exportButton = screen.getByRole('button', { name: /export/i });
69+
expect(exportButton).toBeInTheDocument();
70+
71+
// Click to open the popover and verify formats
72+
fireEvent.click(exportButton);
73+
expect(screen.getByRole('button', { name: /export as csv/i })).toBeInTheDocument();
74+
expect(screen.getByRole('button', { name: /export as xlsx/i })).toBeInTheDocument();
75+
});
76+
5877
it('should handle export with complex object fields in CSV safely', async () => {
5978
const mockItems = [
6079
{ _id: '1', name: 'Alice', tags: ['admin', 'user'], metadata: { role: 'lead' } },

packages/plugin-list/src/__tests__/ListView.test.tsx

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,5 +1805,126 @@ describe('ListView', () => {
18051805
const selector = screen.getByTestId('page-size-selector');
18061806
expect(selector).toHaveValue('25');
18071807
});
1808+
1809+
it('should re-fetch data when page size changes', async () => {
1810+
const mockItems = [
1811+
{ _id: '1', name: 'Alice', email: 'alice@test.com' },
1812+
{ _id: '2', name: 'Bob', email: 'bob@test.com' },
1813+
];
1814+
mockDataSource.find.mockResolvedValue(mockItems);
1815+
1816+
const onPageSizeChange = vi.fn();
1817+
const schema: ListViewSchema = {
1818+
type: 'list-view',
1819+
objectName: 'contacts',
1820+
viewType: 'grid',
1821+
fields: ['name', 'email'],
1822+
pagination: { pageSize: 25, pageSizeOptions: [10, 25, 50, 100] },
1823+
};
1824+
1825+
renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} onPageSizeChange={onPageSizeChange} />);
1826+
1827+
await vi.waitFor(() => {
1828+
expect(screen.getByTestId('page-size-selector')).toBeInTheDocument();
1829+
});
1830+
1831+
const fetchCountBefore = mockDataSource.find.mock.calls.length;
1832+
1833+
// Change page size to 50
1834+
const selector = screen.getByTestId('page-size-selector');
1835+
fireEvent.change(selector, { target: { value: '50' } });
1836+
1837+
expect(onPageSizeChange).toHaveBeenCalledWith(50);
1838+
1839+
// Data should be re-fetched with the new page size
1840+
await vi.waitFor(() => {
1841+
expect(mockDataSource.find.mock.calls.length).toBeGreaterThan(fetchCountBefore);
1842+
});
1843+
});
1844+
1845+
it('should render all page size options in the selector', async () => {
1846+
const mockItems = [
1847+
{ _id: '1', name: 'Alice', email: 'alice@test.com' },
1848+
];
1849+
mockDataSource.find.mockResolvedValue(mockItems);
1850+
1851+
const schema: ListViewSchema = {
1852+
type: 'list-view',
1853+
objectName: 'contacts',
1854+
viewType: 'grid',
1855+
fields: ['name', 'email'],
1856+
pagination: { pageSize: 10, pageSizeOptions: [10, 25, 50, 100] },
1857+
};
1858+
1859+
renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
1860+
1861+
await vi.waitFor(() => {
1862+
expect(screen.getByTestId('page-size-selector')).toBeInTheDocument();
1863+
});
1864+
1865+
const options = screen.getByTestId('page-size-selector').querySelectorAll('option');
1866+
expect(options).toHaveLength(4);
1867+
expect(options[0]).toHaveValue('10');
1868+
expect(options[1]).toHaveValue('25');
1869+
expect(options[2]).toHaveValue('50');
1870+
expect(options[3]).toHaveValue('100');
1871+
});
1872+
1873+
it('should not render page size selector when pageSizeOptions is not configured', async () => {
1874+
const mockItems = [
1875+
{ _id: '1', name: 'Alice', email: 'alice@test.com' },
1876+
];
1877+
mockDataSource.find.mockResolvedValue(mockItems);
1878+
1879+
const schema: ListViewSchema = {
1880+
type: 'list-view',
1881+
objectName: 'contacts',
1882+
viewType: 'grid',
1883+
fields: ['name', 'email'],
1884+
pagination: { pageSize: 25 },
1885+
};
1886+
1887+
renderWithProvider(<ListView schema={schema} dataSource={mockDataSource} />);
1888+
1889+
await vi.waitFor(() => {
1890+
expect(screen.getByTestId('record-count-bar')).toBeInTheDocument();
1891+
});
1892+
1893+
expect(screen.queryByTestId('page-size-selector')).not.toBeInTheDocument();
1894+
});
1895+
});
1896+
1897+
// ============================
1898+
// sharing spec format — additional tests
1899+
// ============================
1900+
describe('sharing spec format — additional', () => {
1901+
it('should render share button with spec personal type', () => {
1902+
const schema: ListViewSchema = {
1903+
type: 'list-view',
1904+
objectName: 'contacts',
1905+
viewType: 'grid',
1906+
fields: ['name', 'email'],
1907+
sharing: { type: 'personal' },
1908+
};
1909+
1910+
renderWithProvider(<ListView schema={schema} />);
1911+
const shareButton = screen.getByTestId('share-button');
1912+
expect(shareButton).toBeInTheDocument();
1913+
});
1914+
1915+
it('should display lockedBy in sharing tooltip when set', () => {
1916+
const schema: ListViewSchema = {
1917+
type: 'list-view',
1918+
objectName: 'contacts',
1919+
viewType: 'grid',
1920+
fields: ['name', 'email'],
1921+
sharing: { type: 'collaborative', lockedBy: 'admin@example.com' },
1922+
};
1923+
1924+
renderWithProvider(<ListView schema={schema} />);
1925+
const shareButton = screen.getByTestId('share-button');
1926+
expect(shareButton).toBeInTheDocument();
1927+
expect(shareButton).toHaveAttribute('title', 'Sharing: collaborative');
1928+
});
18081929
});
18091930
});

packages/react/src/spec-bridge/__tests__/P1SpecBridge.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,4 +766,90 @@ describe('P1 SpecBridge Protocol Alignment', () => {
766766
expect(qf.defaultActive).toBe(true);
767767
});
768768
});
769+
770+
// ========================================================================
771+
// P2 Sharing / ExportOptions / Pagination Protocol Alignment
772+
// ========================================================================
773+
describe('P2 sharing/exportOptions/pagination alignment', () => {
774+
it('should normalize spec sharing { type: personal } to { visibility: private, enabled: true }', () => {
775+
const bridge = new SpecBridge();
776+
const node = bridge.transformListView({
777+
name: 'personal_view',
778+
sharing: { type: 'personal', lockedBy: 'admin@example.com' },
779+
});
780+
781+
expect(node.sharing).toBeDefined();
782+
expect(node.sharing.visibility).toBe('private');
783+
expect(node.sharing.enabled).toBe(true);
784+
expect(node.sharing.type).toBe('personal');
785+
expect(node.sharing.lockedBy).toBe('admin@example.com');
786+
});
787+
788+
it('should normalize spec sharing { type: collaborative } to { visibility: team, enabled: true }', () => {
789+
const bridge = new SpecBridge();
790+
const node = bridge.transformListView({
791+
name: 'collab_view',
792+
sharing: { type: 'collaborative' },
793+
});
794+
795+
expect(node.sharing.visibility).toBe('team');
796+
expect(node.sharing.enabled).toBe(true);
797+
expect(node.sharing.type).toBe('collaborative');
798+
});
799+
800+
it('should preserve ObjectUI sharing format without overriding', () => {
801+
const bridge = new SpecBridge();
802+
const node = bridge.transformListView({
803+
name: 'objectui_view',
804+
sharing: { visibility: 'organization', enabled: true },
805+
});
806+
807+
expect(node.sharing.visibility).toBe('organization');
808+
expect(node.sharing.enabled).toBe(true);
809+
expect(node.sharing.type).toBeUndefined();
810+
});
811+
812+
it('should not override explicit visibility when type is set', () => {
813+
const bridge = new SpecBridge();
814+
const node = bridge.transformListView({
815+
name: 'mixed_view',
816+
sharing: { type: 'collaborative', visibility: 'public' },
817+
});
818+
819+
expect(node.sharing.visibility).toBe('public');
820+
expect(node.sharing.type).toBe('collaborative');
821+
});
822+
823+
it('should pass through exportOptions string[] format', () => {
824+
const bridge = new SpecBridge();
825+
const node = bridge.transformListView({
826+
name: 'export_spec',
827+
exportOptions: ['csv', 'xlsx'],
828+
});
829+
830+
expect(node.exportOptions).toEqual(['csv', 'xlsx']);
831+
});
832+
833+
it('should pass through exportOptions object format', () => {
834+
const bridge = new SpecBridge();
835+
const node = bridge.transformListView({
836+
name: 'export_obj',
837+
exportOptions: { formats: ['csv', 'json'], maxRecords: 5000 },
838+
});
839+
840+
expect(node.exportOptions.formats).toEqual(['csv', 'json']);
841+
expect(node.exportOptions.maxRecords).toBe(5000);
842+
});
843+
844+
it('should pass through pagination with pageSizeOptions', () => {
845+
const bridge = new SpecBridge();
846+
const node = bridge.transformListView({
847+
name: 'paginated',
848+
pagination: { pageSize: 25, pageSizeOptions: [10, 25, 50, 100] },
849+
});
850+
851+
expect(node.pagination.pageSize).toBe(25);
852+
expect(node.pagination.pageSizeOptions).toEqual([10, 25, 50, 100]);
853+
});
854+
});
769855
});

packages/react/src/spec-bridge/bridges/list-view.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,17 @@ export const bridgeListView: BridgeFn<ListViewSpec> = (
168168

169169
// P1.6 — i18n & ARIA
170170
if (spec.aria) node.aria = spec.aria;
171-
if (spec.sharing) node.sharing = spec.sharing;
171+
if (spec.sharing) {
172+
// Normalize spec sharing format: map type → visibility, set enabled = true
173+
const sharing: Record<string, any> = { ...spec.sharing };
174+
if (sharing.type && !sharing.visibility) {
175+
sharing.visibility = sharing.type === 'collaborative' ? 'team' : 'private';
176+
}
177+
if (sharing.type && sharing.enabled == null) {
178+
sharing.enabled = true;
179+
}
180+
node.sharing = sharing;
181+
}
172182
if (spec.hiddenFields) node.hiddenFields = spec.hiddenFields;
173183
if (spec.fieldOrder) node.fieldOrder = spec.fieldOrder;
174184
if (spec.description) node.description = spec.description;

packages/types/src/__tests__/p1-spec-alignment.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,90 @@ describe('P1.1 ListView Spec Alignment', () => {
144144
expect(schema.virtualScroll).toBe(true);
145145
expect(schema.rowSpecActions).toEqual(['edit', 'delete']);
146146
});
147+
148+
// P2: Sharing / ExportOptions / Pagination protocol alignment tests
149+
it('should accept sharing in spec format { type, lockedBy }', () => {
150+
const schema: ListViewSchema = {
151+
type: 'list-view',
152+
objectName: 'Account',
153+
sharing: {
154+
type: 'collaborative',
155+
lockedBy: 'admin@example.com',
156+
},
157+
};
158+
expect(schema.sharing?.type).toBe('collaborative');
159+
expect(schema.sharing?.lockedBy).toBe('admin@example.com');
160+
});
161+
162+
it('should accept sharing in ObjectUI format { visibility, enabled }', () => {
163+
const schema: ListViewSchema = {
164+
type: 'list-view',
165+
objectName: 'Account',
166+
sharing: {
167+
visibility: 'team',
168+
enabled: true,
169+
},
170+
};
171+
expect(schema.sharing?.visibility).toBe('team');
172+
expect(schema.sharing?.enabled).toBe(true);
173+
});
174+
175+
it('should accept sharing with both spec and ObjectUI fields merged', () => {
176+
const schema: ListViewSchema = {
177+
type: 'list-view',
178+
objectName: 'Account',
179+
sharing: {
180+
type: 'personal',
181+
visibility: 'private',
182+
enabled: true,
183+
lockedBy: 'user@example.com',
184+
},
185+
};
186+
expect(schema.sharing?.type).toBe('personal');
187+
expect(schema.sharing?.visibility).toBe('private');
188+
expect(schema.sharing?.enabled).toBe(true);
189+
expect(schema.sharing?.lockedBy).toBe('user@example.com');
190+
});
191+
192+
it('should accept exportOptions as spec string[] format', () => {
193+
const schema: ListViewSchema = {
194+
type: 'list-view',
195+
objectName: 'Account',
196+
exportOptions: ['csv', 'xlsx'],
197+
};
198+
expect(Array.isArray(schema.exportOptions)).toBe(true);
199+
expect(schema.exportOptions).toEqual(['csv', 'xlsx']);
200+
});
201+
202+
it('should accept exportOptions as ObjectUI object format', () => {
203+
const schema: ListViewSchema = {
204+
type: 'list-view',
205+
objectName: 'Account',
206+
exportOptions: {
207+
formats: ['csv', 'json', 'pdf'],
208+
maxRecords: 5000,
209+
includeHeaders: true,
210+
fileNamePrefix: 'accounts_export',
211+
},
212+
};
213+
expect(Array.isArray(schema.exportOptions)).toBe(false);
214+
const opts = schema.exportOptions as { formats?: string[]; maxRecords?: number };
215+
expect(opts.formats).toEqual(['csv', 'json', 'pdf']);
216+
expect(opts.maxRecords).toBe(5000);
217+
});
218+
219+
it('should accept pagination with pageSizeOptions', () => {
220+
const schema: ListViewSchema = {
221+
type: 'list-view',
222+
objectName: 'Account',
223+
pagination: {
224+
pageSize: 25,
225+
pageSizeOptions: [10, 25, 50, 100],
226+
},
227+
};
228+
expect(schema.pagination?.pageSize).toBe(25);
229+
expect(schema.pagination?.pageSizeOptions).toEqual([10, 25, 50, 100]);
230+
});
147231
});
148232

149233
// ============================================================================

packages/types/src/zod/objectql.zod.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -297,17 +297,22 @@ export const ListViewSchema = BaseSchema.extend({
297297
densityMode: z.enum(['compact', 'comfortable', 'spacious']).optional().describe('Density mode'),
298298
rowHeight: z.enum(['compact', 'short', 'medium', 'tall', 'extra_tall']).optional().describe('Row height'),
299299
hiddenFields: z.array(z.string()).optional().describe('Hidden fields'),
300-
exportOptions: z.object({
301-
formats: z.array(z.enum(['csv', 'xlsx', 'json', 'pdf'])).optional(),
302-
maxRecords: z.number().optional(),
303-
includeHeaders: z.boolean().optional(),
304-
fileNamePrefix: z.string().optional(),
305-
}).optional().describe('Export options'),
300+
exportOptions: z.union([
301+
z.array(z.enum(['csv', 'xlsx', 'json', 'pdf'])),
302+
z.object({
303+
formats: z.array(z.enum(['csv', 'xlsx', 'json', 'pdf'])).optional(),
304+
maxRecords: z.number().optional(),
305+
includeHeaders: z.boolean().optional(),
306+
fileNamePrefix: z.string().optional(),
307+
}),
308+
]).optional().describe('Export options'),
306309
rowActions: z.array(z.string()).optional().describe('Row action identifiers'),
307310
bulkActions: z.array(z.string()).optional().describe('Bulk action identifiers'),
308311
sharing: z.object({
309312
visibility: z.enum(['private', 'team', 'organization', 'public']).optional(),
310313
enabled: z.boolean().optional(),
314+
type: z.enum(['personal', 'collaborative']).optional(),
315+
lockedBy: z.string().optional(),
311316
}).optional().describe('Sharing configuration'),
312317
addRecord: z.object({
313318
enabled: z.boolean().optional(),

0 commit comments

Comments
 (0)