Skip to content

Commit c980b3e

Browse files
authored
Merge pull request #742 from objectstack-ai/copilot/align-ui-with-spec-protocol
2 parents 105491f + 0ef36bd commit c980b3e

File tree

7 files changed

+494
-284
lines changed

7 files changed

+494
-284
lines changed

ROADMAP.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,16 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
205205
-`ListViewSchema` Zod schema extended with all new properties
206206
- ✅ ViewConfigPanel aligned to full `ListViewSchema` spec: navigation mode, selection, pagination, export sub-config, searchable/filterable/hidden fields, resizable, density mode, row/bulk actions, sharing, addRecord sub-editor, conditional formatting, quick filters, showRecordCount, allowPrinting, virtualScroll, empty state, ARIA accessibility
207207
- ✅ Semantic fix: `editRecordsInline``inlineEdit` field name alignment (i18n keys, data-testid, component label all unified to `inlineEdit`)
208-
- ✅ Semantic fix: `rowHeight` values aligned to spec (`compact`/`medium`/`tall`)
208+
- ✅ Semantic fix: `rowHeight` values aligned to full spec — all 5 RowHeight enum values (`compact`/`short`/`medium`/`tall`/`extra_tall`) now supported in NamedListView, ObjectGridSchema, ListViewSchema, Zod schema, and UI
209+
-`clickIntoRecordDetails` toggle added to UserActions section (NamedListView spec field — previously only implicit via navigation mode)
210+
-**Strict spec-order alignment**: All fields within each section reordered to match NamedListView property declaration order:
211+
- PageConfig: showSort before showFilters; allowExport before navigation (per spec)
212+
- Data: columns → filter → sort (per spec); prefixField after sort
213+
- Appearance: striped/bordered first, then color, wrapHeaders, etc. (per spec)
214+
- UserActions: inlineEdit before clickIntoRecordDetails (per spec)
215+
-**Spec source annotations**: Every field annotated with `// spec: NamedListView.*` or `// UI extension` comment
216+
-**Protocol suggestions documented**: description, _source, _groupBy, _typeOptions identified as UI extensions pending spec addition
217+
-**Comprehensive spec field coverage test**: All 44 NamedListView properties verified mapped to UI fields; field ordering validated per spec
209218
- ✅ i18n keys verified complete for en/zh and all 10 locale files
210219
- ✅ Console ObjectView fullSchema propagates all 18 new spec properties
211220
- ✅ PluginObjectView renderListView schema propagates all 18 new spec properties

apps/console/src/__tests__/ViewConfigPanel.test.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2227,7 +2227,7 @@ describe('ViewConfigPanel', () => {
22272227
expect(screen.getByTestId('bulk-actions-selector')).toBeInTheDocument();
22282228
});
22292229

2230-
it('renders row height buttons with spec-aligned values (compact/medium/tall)', () => {
2230+
it('renders row height buttons with all 5 spec-aligned values', () => {
22312231
const onViewUpdate = vi.fn();
22322232
render(
22332233
<ViewConfigPanel
@@ -2240,11 +2240,10 @@ describe('ViewConfigPanel', () => {
22402240
);
22412241

22422242
expect(screen.getByTestId('row-height-compact')).toBeInTheDocument();
2243+
expect(screen.getByTestId('row-height-short')).toBeInTheDocument();
22432244
expect(screen.getByTestId('row-height-medium')).toBeInTheDocument();
22442245
expect(screen.getByTestId('row-height-tall')).toBeInTheDocument();
2245-
// Old values should not exist
2246-
expect(screen.queryByTestId('row-height-short')).not.toBeInTheDocument();
2247-
expect(screen.queryByTestId('row-height-extraTall')).not.toBeInTheDocument();
2246+
expect(screen.getByTestId('row-height-extra_tall')).toBeInTheDocument();
22482247

22492248
// Click compact and verify update
22502249
fireEvent.click(screen.getByTestId('row-height-compact'));

apps/console/src/__tests__/view-config-schema.test.tsx

Lines changed: 215 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
toSortItems,
4444
SPEC_TO_BUILDER_OP,
4545
BUILDER_TO_SPEC_OP,
46+
ROW_HEIGHT_OPTIONS,
4647
} from '../utils/view-config-utils';
4748

4849
import { buildViewConfigSchema } from '../utils/view-config-schema';
@@ -370,79 +371,116 @@ describe('buildViewConfigSchema', () => {
370371
// ── Page Config Section ─────────────────────────────────────────────
371372

372373
describe('pageConfig section', () => {
373-
it('contains expected field keys', () => {
374+
it('contains expected field keys in spec order', () => {
375+
const schema = buildSchema();
376+
const section = schema.sections.find(s => s.key === 'pageConfig')!;
377+
const fieldKeys = section.fields.map(f => f.key);
378+
// Spec order: label, type, showSearch, showSort, showFilters, showHideFields, showGroup, showColor, showDensity,
379+
// allowExport(_export), navigation, selection, addRecord, showRecordCount, allowPrinting
380+
// description is UI extension (after label)
381+
expect(fieldKeys).toEqual([
382+
'label', 'description', 'type',
383+
'showSearch', 'showSort', 'showFilters', 'showHideFields', 'showGroup', 'showColor', 'showDensity',
384+
'_export',
385+
'_navigationMode', '_navigationWidth', '_navigationOpenNewTab',
386+
'_selectionType',
387+
'_addRecord',
388+
'showRecordCount', 'allowPrinting',
389+
]);
390+
});
391+
392+
it('showSort comes before showFilters per spec', () => {
393+
const schema = buildSchema();
394+
const section = schema.sections.find(s => s.key === 'pageConfig')!;
395+
const fieldKeys = section.fields.map(f => f.key);
396+
expect(fieldKeys.indexOf('showSort')).toBeLessThan(fieldKeys.indexOf('showFilters'));
397+
});
398+
399+
it('_export comes before _navigationMode per spec', () => {
374400
const schema = buildSchema();
375401
const section = schema.sections.find(s => s.key === 'pageConfig')!;
376402
const fieldKeys = section.fields.map(f => f.key);
377-
expect(fieldKeys).toContain('label');
378-
expect(fieldKeys).toContain('description');
379-
expect(fieldKeys).toContain('type');
380-
expect(fieldKeys).toContain('showSearch');
381-
expect(fieldKeys).toContain('showFilters');
382-
expect(fieldKeys).toContain('showSort');
383-
expect(fieldKeys).toContain('_navigationMode');
384-
expect(fieldKeys).toContain('_selectionType');
385-
expect(fieldKeys).toContain('_addRecord');
386-
expect(fieldKeys).toContain('_export');
403+
expect(fieldKeys.indexOf('_export')).toBeLessThan(fieldKeys.indexOf('_navigationMode'));
387404
});
388405
});
389406

390407
// ── Data Section ────────────────────────────────────────────────────
391408

392409
describe('data section', () => {
393-
it('contains expected field keys', () => {
410+
it('contains expected field keys in spec order', () => {
411+
const schema = buildSchema();
412+
const section = schema.sections.find(s => s.key === 'data')!;
413+
const fieldKeys = section.fields.map(f => f.key);
414+
// Spec order: columns, filter, sort, prefixField, pagination, searchableFields, filterableFields,
415+
// hiddenFields, quickFilters, virtualScroll
416+
// _source is UI extension (first), _groupBy is UI extension (after prefixField), _typeOptions is UI extension (last)
417+
expect(fieldKeys).toEqual([
418+
'_source',
419+
'_columns', '_filterBy', '_sortBy',
420+
'prefixField', '_groupBy',
421+
'_pageSize', '_pageSizeOptions',
422+
'_searchableFields', '_filterableFields', '_hiddenFields',
423+
'_quickFilters',
424+
'virtualScroll',
425+
'_typeOptions',
426+
]);
427+
});
428+
429+
it('_columns comes before _filterBy and _sortBy per spec', () => {
394430
const schema = buildSchema();
395431
const section = schema.sections.find(s => s.key === 'data')!;
396432
const fieldKeys = section.fields.map(f => f.key);
397-
expect(fieldKeys).toContain('_source');
398-
expect(fieldKeys).toContain('_sortBy');
399-
expect(fieldKeys).toContain('_groupBy');
400-
expect(fieldKeys).toContain('prefixField');
401-
expect(fieldKeys).toContain('_columns');
402-
expect(fieldKeys).toContain('_filterBy');
403-
expect(fieldKeys).toContain('_pageSize');
404-
expect(fieldKeys).toContain('_pageSizeOptions');
405-
expect(fieldKeys).toContain('_searchableFields');
406-
expect(fieldKeys).toContain('_filterableFields');
407-
expect(fieldKeys).toContain('_hiddenFields');
408-
expect(fieldKeys).toContain('_quickFilters');
409-
expect(fieldKeys).toContain('virtualScroll');
410-
expect(fieldKeys).toContain('_typeOptions');
433+
expect(fieldKeys.indexOf('_columns')).toBeLessThan(fieldKeys.indexOf('_filterBy'));
434+
expect(fieldKeys.indexOf('_filterBy')).toBeLessThan(fieldKeys.indexOf('_sortBy'));
411435
});
412436
});
413437

414438
// ── Appearance Section ──────────────────────────────────────────────
415439

416440
describe('appearance section', () => {
417-
it('contains expected field keys', () => {
441+
it('contains expected field keys in spec order', () => {
442+
const schema = buildSchema();
443+
const section = schema.sections.find(s => s.key === 'appearance')!;
444+
const fieldKeys = section.fields.map(f => f.key);
445+
// Spec order: striped, bordered, color, wrapHeaders, collapseAllByDefault, fieldTextColor,
446+
// showDescription, resizable, densityMode, rowHeight, conditionalFormatting, emptyState
447+
expect(fieldKeys).toEqual([
448+
'striped', 'bordered', 'color',
449+
'wrapHeaders', 'collapseAllByDefault',
450+
'fieldTextColor', 'showDescription',
451+
'resizable', 'densityMode', 'rowHeight',
452+
'_conditionalFormatting', '_emptyState',
453+
]);
454+
});
455+
456+
it('striped and bordered come before color per spec', () => {
418457
const schema = buildSchema();
419458
const section = schema.sections.find(s => s.key === 'appearance')!;
420459
const fieldKeys = section.fields.map(f => f.key);
421-
expect(fieldKeys).toContain('color');
422-
expect(fieldKeys).toContain('fieldTextColor');
423-
expect(fieldKeys).toContain('rowHeight');
424-
expect(fieldKeys).toContain('wrapHeaders');
425-
expect(fieldKeys).toContain('showDescription');
426-
expect(fieldKeys).toContain('striped');
427-
expect(fieldKeys).toContain('bordered');
428-
expect(fieldKeys).toContain('resizable');
429-
expect(fieldKeys).toContain('densityMode');
430-
expect(fieldKeys).toContain('_conditionalFormatting');
431-
expect(fieldKeys).toContain('_emptyState');
460+
expect(fieldKeys.indexOf('striped')).toBeLessThan(fieldKeys.indexOf('color'));
461+
expect(fieldKeys.indexOf('bordered')).toBeLessThan(fieldKeys.indexOf('color'));
432462
});
433463
});
434464

435465
// ── User Actions Section ────────────────────────────────────────────
436466

437467
describe('userActions section', () => {
438-
it('contains expected field keys', () => {
468+
it('contains expected field keys in spec order', () => {
469+
const schema = buildSchema();
470+
const section = schema.sections.find(s => s.key === 'userActions')!;
471+
const fieldKeys = section.fields.map(f => f.key);
472+
// Spec order: inlineEdit, clickIntoRecordDetails, addDeleteRecordsInline, rowActions, bulkActions
473+
expect(fieldKeys).toEqual([
474+
'inlineEdit', 'clickIntoRecordDetails', 'addDeleteRecordsInline',
475+
'_rowActions', '_bulkActions',
476+
]);
477+
});
478+
479+
it('inlineEdit comes before clickIntoRecordDetails per spec', () => {
439480
const schema = buildSchema();
440481
const section = schema.sections.find(s => s.key === 'userActions')!;
441482
const fieldKeys = section.fields.map(f => f.key);
442-
expect(fieldKeys).toContain('inlineEdit');
443-
expect(fieldKeys).toContain('addDeleteRecordsInline');
444-
expect(fieldKeys).toContain('_rowActions');
445-
expect(fieldKeys).toContain('_bulkActions');
483+
expect(fieldKeys.indexOf('inlineEdit')).toBeLessThan(fieldKeys.indexOf('clickIntoRecordDetails'));
446484
});
447485
});
448486

@@ -525,3 +563,137 @@ describe('buildViewConfigSchema', () => {
525563
}
526564
});
527565
});
566+
567+
// ═══════════════════════════════════════════════════════════════════════════
568+
// 3. Spec-alignment validation
569+
// ═══════════════════════════════════════════════════════════════════════════
570+
571+
describe('spec alignment', () => {
572+
// ── ROW_HEIGHT_OPTIONS matches spec RowHeight enum ───────────────────
573+
describe('ROW_HEIGHT_OPTIONS', () => {
574+
it('contains all 5 spec RowHeight values', () => {
575+
const values = ROW_HEIGHT_OPTIONS.map(o => o.value);
576+
expect(values).toEqual(['compact', 'short', 'medium', 'tall', 'extra_tall']);
577+
});
578+
579+
it('each option has a gapClass', () => {
580+
for (const opt of ROW_HEIGHT_OPTIONS) {
581+
expect(opt.gapClass).toBeDefined();
582+
expect(typeof opt.gapClass).toBe('string');
583+
}
584+
});
585+
});
586+
587+
// ── NamedListView field coverage ────────────────────────────────────
588+
describe('NamedListView spec field coverage', () => {
589+
function buildSchema() {
590+
return buildViewConfigSchema({
591+
t: mockT,
592+
fieldOptions: mockFieldOptions,
593+
objectDef: mockObjectDef,
594+
updateField: mockUpdateField,
595+
filterGroupValue: mockFilterGroup,
596+
sortItemsValue: mockSortItems,
597+
});
598+
}
599+
600+
function allFieldKeys() {
601+
const schema = buildSchema();
602+
return schema.sections.flatMap(s => s.fields.map(f => f.key));
603+
}
604+
605+
// Comprehensive: every NamedListView spec property must map to a UI field
606+
it('covers ALL NamedListView spec properties', () => {
607+
const keys = allFieldKeys();
608+
// NamedListView properties → UI field keys mapping
609+
const specPropertyToFieldKey: Record<string, string> = {
610+
label: 'label',
611+
type: 'type',
612+
columns: '_columns',
613+
filter: '_filterBy',
614+
sort: '_sortBy',
615+
showSearch: 'showSearch',
616+
showSort: 'showSort',
617+
showFilters: 'showFilters',
618+
showHideFields: 'showHideFields',
619+
showGroup: 'showGroup',
620+
showColor: 'showColor',
621+
showDensity: 'showDensity',
622+
allowExport: '_export',
623+
striped: 'striped',
624+
bordered: 'bordered',
625+
color: 'color',
626+
inlineEdit: 'inlineEdit',
627+
wrapHeaders: 'wrapHeaders',
628+
clickIntoRecordDetails: 'clickIntoRecordDetails',
629+
addRecordViaForm: '_addRecord', // compound field
630+
addDeleteRecordsInline: 'addDeleteRecordsInline',
631+
collapseAllByDefault: 'collapseAllByDefault',
632+
fieldTextColor: 'fieldTextColor',
633+
prefixField: 'prefixField',
634+
showDescription: 'showDescription',
635+
navigation: '_navigationMode', // compound: mode/width/openNewTab
636+
selection: '_selectionType',
637+
pagination: '_pageSize', // compound: pageSize/pageSizeOptions
638+
searchableFields: '_searchableFields',
639+
filterableFields: '_filterableFields',
640+
resizable: 'resizable',
641+
densityMode: 'densityMode',
642+
rowHeight: 'rowHeight',
643+
hiddenFields: '_hiddenFields',
644+
exportOptions: '_export', // compound with allowExport
645+
rowActions: '_rowActions',
646+
bulkActions: '_bulkActions',
647+
sharing: '_sharingEnabled', // compound: enabled/visibility
648+
addRecord: '_addRecord', // compound with addRecordViaForm
649+
conditionalFormatting: '_conditionalFormatting',
650+
quickFilters: '_quickFilters',
651+
showRecordCount: 'showRecordCount',
652+
allowPrinting: 'allowPrinting',
653+
virtualScroll: 'virtualScroll',
654+
emptyState: '_emptyState',
655+
aria: '_ariaLabel', // compound: label/describedBy/live
656+
};
657+
for (const [specProp, fieldKey] of Object.entries(specPropertyToFieldKey)) {
658+
expect(keys).toContain(fieldKey);
659+
}
660+
});
661+
662+
it('covers all NamedListView toolbar toggles in order', () => {
663+
const schema = buildSchema();
664+
const section = schema.sections.find(s => s.key === 'pageConfig')!;
665+
const keys = section.fields.map(f => f.key);
666+
const toolbarFields = [
667+
'showSearch', 'showSort', 'showFilters',
668+
'showHideFields', 'showGroup', 'showColor', 'showDensity',
669+
];
670+
// All present
671+
for (const field of toolbarFields) {
672+
expect(keys).toContain(field);
673+
}
674+
// Order matches spec
675+
for (let i = 0; i < toolbarFields.length - 1; i++) {
676+
expect(keys.indexOf(toolbarFields[i])).toBeLessThan(keys.indexOf(toolbarFields[i + 1]));
677+
}
678+
});
679+
680+
it('covers all NamedListView boolean toggles in userActions in spec order', () => {
681+
const schema = buildSchema();
682+
const section = schema.sections.find(s => s.key === 'userActions')!;
683+
const keys = section.fields.map(f => f.key);
684+
// Spec order: inlineEdit → clickIntoRecordDetails → addDeleteRecordsInline
685+
expect(keys.indexOf('inlineEdit')).toBeLessThan(keys.indexOf('clickIntoRecordDetails'));
686+
expect(keys.indexOf('clickIntoRecordDetails')).toBeLessThan(keys.indexOf('addDeleteRecordsInline'));
687+
});
688+
689+
// Protocol suggestions: UI fields not in NamedListView spec
690+
it('documents UI extension fields not in NamedListView spec', () => {
691+
const keys = allFieldKeys();
692+
// These fields are UI extensions — documented as protocol suggestions
693+
const uiExtensions = ['description', '_source', '_groupBy', '_typeOptions'];
694+
for (const ext of uiExtensions) {
695+
expect(keys).toContain(ext);
696+
}
697+
});
698+
});
699+
});

0 commit comments

Comments
 (0)