diff --git a/ROADMAP.md b/ROADMAP.md index 83a7ce801..172babd38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,7 +205,16 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - ✅ `ListViewSchema` Zod schema extended with all new properties - ✅ 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 - ✅ Semantic fix: `editRecordsInline` → `inlineEdit` field name alignment (i18n keys, data-testid, component label all unified to `inlineEdit`) - - ✅ Semantic fix: `rowHeight` values aligned to spec (`compact`/`medium`/`tall`) + - ✅ 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 + - ✅ `clickIntoRecordDetails` toggle added to UserActions section (NamedListView spec field — previously only implicit via navigation mode) + - ✅ **Strict spec-order alignment**: All fields within each section reordered to match NamedListView property declaration order: + - PageConfig: showSort before showFilters; allowExport before navigation (per spec) + - Data: columns → filter → sort (per spec); prefixField after sort + - Appearance: striped/bordered first, then color, wrapHeaders, etc. (per spec) + - UserActions: inlineEdit before clickIntoRecordDetails (per spec) + - ✅ **Spec source annotations**: Every field annotated with `// spec: NamedListView.*` or `// UI extension` comment + - ✅ **Protocol suggestions documented**: description, _source, _groupBy, _typeOptions identified as UI extensions pending spec addition + - ✅ **Comprehensive spec field coverage test**: All 44 NamedListView properties verified mapped to UI fields; field ordering validated per spec - ✅ i18n keys verified complete for en/zh and all 10 locale files - ✅ Console ObjectView fullSchema propagates all 18 new spec properties - ✅ PluginObjectView renderListView schema propagates all 18 new spec properties diff --git a/apps/console/src/__tests__/ViewConfigPanel.test.tsx b/apps/console/src/__tests__/ViewConfigPanel.test.tsx index a1bf93827..ddab0c34c 100644 --- a/apps/console/src/__tests__/ViewConfigPanel.test.tsx +++ b/apps/console/src/__tests__/ViewConfigPanel.test.tsx @@ -2227,7 +2227,7 @@ describe('ViewConfigPanel', () => { expect(screen.getByTestId('bulk-actions-selector')).toBeInTheDocument(); }); - it('renders row height buttons with spec-aligned values (compact/medium/tall)', () => { + it('renders row height buttons with all 5 spec-aligned values', () => { const onViewUpdate = vi.fn(); render( { ); expect(screen.getByTestId('row-height-compact')).toBeInTheDocument(); + expect(screen.getByTestId('row-height-short')).toBeInTheDocument(); expect(screen.getByTestId('row-height-medium')).toBeInTheDocument(); expect(screen.getByTestId('row-height-tall')).toBeInTheDocument(); - // Old values should not exist - expect(screen.queryByTestId('row-height-short')).not.toBeInTheDocument(); - expect(screen.queryByTestId('row-height-extraTall')).not.toBeInTheDocument(); + expect(screen.getByTestId('row-height-extra_tall')).toBeInTheDocument(); // Click compact and verify update fireEvent.click(screen.getByTestId('row-height-compact')); diff --git a/apps/console/src/__tests__/view-config-schema.test.tsx b/apps/console/src/__tests__/view-config-schema.test.tsx index 806b8b864..89391aea8 100644 --- a/apps/console/src/__tests__/view-config-schema.test.tsx +++ b/apps/console/src/__tests__/view-config-schema.test.tsx @@ -43,6 +43,7 @@ import { toSortItems, SPEC_TO_BUILDER_OP, BUILDER_TO_SPEC_OP, + ROW_HEIGHT_OPTIONS, } from '../utils/view-config-utils'; import { buildViewConfigSchema } from '../utils/view-config-schema'; @@ -370,79 +371,116 @@ describe('buildViewConfigSchema', () => { // ── Page Config Section ───────────────────────────────────────────── describe('pageConfig section', () => { - it('contains expected field keys', () => { + it('contains expected field keys in spec order', () => { + const schema = buildSchema(); + const section = schema.sections.find(s => s.key === 'pageConfig')!; + const fieldKeys = section.fields.map(f => f.key); + // Spec order: label, type, showSearch, showSort, showFilters, showHideFields, showGroup, showColor, showDensity, + // allowExport(_export), navigation, selection, addRecord, showRecordCount, allowPrinting + // description is UI extension (after label) + expect(fieldKeys).toEqual([ + 'label', 'description', 'type', + 'showSearch', 'showSort', 'showFilters', 'showHideFields', 'showGroup', 'showColor', 'showDensity', + '_export', + '_navigationMode', '_navigationWidth', '_navigationOpenNewTab', + '_selectionType', + '_addRecord', + 'showRecordCount', 'allowPrinting', + ]); + }); + + it('showSort comes before showFilters per spec', () => { + const schema = buildSchema(); + const section = schema.sections.find(s => s.key === 'pageConfig')!; + const fieldKeys = section.fields.map(f => f.key); + expect(fieldKeys.indexOf('showSort')).toBeLessThan(fieldKeys.indexOf('showFilters')); + }); + + it('_export comes before _navigationMode per spec', () => { const schema = buildSchema(); const section = schema.sections.find(s => s.key === 'pageConfig')!; const fieldKeys = section.fields.map(f => f.key); - expect(fieldKeys).toContain('label'); - expect(fieldKeys).toContain('description'); - expect(fieldKeys).toContain('type'); - expect(fieldKeys).toContain('showSearch'); - expect(fieldKeys).toContain('showFilters'); - expect(fieldKeys).toContain('showSort'); - expect(fieldKeys).toContain('_navigationMode'); - expect(fieldKeys).toContain('_selectionType'); - expect(fieldKeys).toContain('_addRecord'); - expect(fieldKeys).toContain('_export'); + expect(fieldKeys.indexOf('_export')).toBeLessThan(fieldKeys.indexOf('_navigationMode')); }); }); // ── Data Section ──────────────────────────────────────────────────── describe('data section', () => { - it('contains expected field keys', () => { + it('contains expected field keys in spec order', () => { + const schema = buildSchema(); + const section = schema.sections.find(s => s.key === 'data')!; + const fieldKeys = section.fields.map(f => f.key); + // Spec order: columns, filter, sort, prefixField, pagination, searchableFields, filterableFields, + // hiddenFields, quickFilters, virtualScroll + // _source is UI extension (first), _groupBy is UI extension (after prefixField), _typeOptions is UI extension (last) + expect(fieldKeys).toEqual([ + '_source', + '_columns', '_filterBy', '_sortBy', + 'prefixField', '_groupBy', + '_pageSize', '_pageSizeOptions', + '_searchableFields', '_filterableFields', '_hiddenFields', + '_quickFilters', + 'virtualScroll', + '_typeOptions', + ]); + }); + + it('_columns comes before _filterBy and _sortBy per spec', () => { const schema = buildSchema(); const section = schema.sections.find(s => s.key === 'data')!; const fieldKeys = section.fields.map(f => f.key); - expect(fieldKeys).toContain('_source'); - expect(fieldKeys).toContain('_sortBy'); - expect(fieldKeys).toContain('_groupBy'); - expect(fieldKeys).toContain('prefixField'); - expect(fieldKeys).toContain('_columns'); - expect(fieldKeys).toContain('_filterBy'); - expect(fieldKeys).toContain('_pageSize'); - expect(fieldKeys).toContain('_pageSizeOptions'); - expect(fieldKeys).toContain('_searchableFields'); - expect(fieldKeys).toContain('_filterableFields'); - expect(fieldKeys).toContain('_hiddenFields'); - expect(fieldKeys).toContain('_quickFilters'); - expect(fieldKeys).toContain('virtualScroll'); - expect(fieldKeys).toContain('_typeOptions'); + expect(fieldKeys.indexOf('_columns')).toBeLessThan(fieldKeys.indexOf('_filterBy')); + expect(fieldKeys.indexOf('_filterBy')).toBeLessThan(fieldKeys.indexOf('_sortBy')); }); }); // ── Appearance Section ────────────────────────────────────────────── describe('appearance section', () => { - it('contains expected field keys', () => { + it('contains expected field keys in spec order', () => { + const schema = buildSchema(); + const section = schema.sections.find(s => s.key === 'appearance')!; + const fieldKeys = section.fields.map(f => f.key); + // Spec order: striped, bordered, color, wrapHeaders, collapseAllByDefault, fieldTextColor, + // showDescription, resizable, densityMode, rowHeight, conditionalFormatting, emptyState + expect(fieldKeys).toEqual([ + 'striped', 'bordered', 'color', + 'wrapHeaders', 'collapseAllByDefault', + 'fieldTextColor', 'showDescription', + 'resizable', 'densityMode', 'rowHeight', + '_conditionalFormatting', '_emptyState', + ]); + }); + + it('striped and bordered come before color per spec', () => { const schema = buildSchema(); const section = schema.sections.find(s => s.key === 'appearance')!; const fieldKeys = section.fields.map(f => f.key); - expect(fieldKeys).toContain('color'); - expect(fieldKeys).toContain('fieldTextColor'); - expect(fieldKeys).toContain('rowHeight'); - expect(fieldKeys).toContain('wrapHeaders'); - expect(fieldKeys).toContain('showDescription'); - expect(fieldKeys).toContain('striped'); - expect(fieldKeys).toContain('bordered'); - expect(fieldKeys).toContain('resizable'); - expect(fieldKeys).toContain('densityMode'); - expect(fieldKeys).toContain('_conditionalFormatting'); - expect(fieldKeys).toContain('_emptyState'); + expect(fieldKeys.indexOf('striped')).toBeLessThan(fieldKeys.indexOf('color')); + expect(fieldKeys.indexOf('bordered')).toBeLessThan(fieldKeys.indexOf('color')); }); }); // ── User Actions Section ──────────────────────────────────────────── describe('userActions section', () => { - it('contains expected field keys', () => { + it('contains expected field keys in spec order', () => { + const schema = buildSchema(); + const section = schema.sections.find(s => s.key === 'userActions')!; + const fieldKeys = section.fields.map(f => f.key); + // Spec order: inlineEdit, clickIntoRecordDetails, addDeleteRecordsInline, rowActions, bulkActions + expect(fieldKeys).toEqual([ + 'inlineEdit', 'clickIntoRecordDetails', 'addDeleteRecordsInline', + '_rowActions', '_bulkActions', + ]); + }); + + it('inlineEdit comes before clickIntoRecordDetails per spec', () => { const schema = buildSchema(); const section = schema.sections.find(s => s.key === 'userActions')!; const fieldKeys = section.fields.map(f => f.key); - expect(fieldKeys).toContain('inlineEdit'); - expect(fieldKeys).toContain('addDeleteRecordsInline'); - expect(fieldKeys).toContain('_rowActions'); - expect(fieldKeys).toContain('_bulkActions'); + expect(fieldKeys.indexOf('inlineEdit')).toBeLessThan(fieldKeys.indexOf('clickIntoRecordDetails')); }); }); @@ -525,3 +563,137 @@ describe('buildViewConfigSchema', () => { } }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. Spec-alignment validation +// ═══════════════════════════════════════════════════════════════════════════ + +describe('spec alignment', () => { + // ── ROW_HEIGHT_OPTIONS matches spec RowHeight enum ─────────────────── + describe('ROW_HEIGHT_OPTIONS', () => { + it('contains all 5 spec RowHeight values', () => { + const values = ROW_HEIGHT_OPTIONS.map(o => o.value); + expect(values).toEqual(['compact', 'short', 'medium', 'tall', 'extra_tall']); + }); + + it('each option has a gapClass', () => { + for (const opt of ROW_HEIGHT_OPTIONS) { + expect(opt.gapClass).toBeDefined(); + expect(typeof opt.gapClass).toBe('string'); + } + }); + }); + + // ── NamedListView field coverage ──────────────────────────────────── + describe('NamedListView spec field coverage', () => { + function buildSchema() { + return buildViewConfigSchema({ + t: mockT, + fieldOptions: mockFieldOptions, + objectDef: mockObjectDef, + updateField: mockUpdateField, + filterGroupValue: mockFilterGroup, + sortItemsValue: mockSortItems, + }); + } + + function allFieldKeys() { + const schema = buildSchema(); + return schema.sections.flatMap(s => s.fields.map(f => f.key)); + } + + // Comprehensive: every NamedListView spec property must map to a UI field + it('covers ALL NamedListView spec properties', () => { + const keys = allFieldKeys(); + // NamedListView properties → UI field keys mapping + const specPropertyToFieldKey: Record = { + label: 'label', + type: 'type', + columns: '_columns', + filter: '_filterBy', + sort: '_sortBy', + showSearch: 'showSearch', + showSort: 'showSort', + showFilters: 'showFilters', + showHideFields: 'showHideFields', + showGroup: 'showGroup', + showColor: 'showColor', + showDensity: 'showDensity', + allowExport: '_export', + striped: 'striped', + bordered: 'bordered', + color: 'color', + inlineEdit: 'inlineEdit', + wrapHeaders: 'wrapHeaders', + clickIntoRecordDetails: 'clickIntoRecordDetails', + addRecordViaForm: '_addRecord', // compound field + addDeleteRecordsInline: 'addDeleteRecordsInline', + collapseAllByDefault: 'collapseAllByDefault', + fieldTextColor: 'fieldTextColor', + prefixField: 'prefixField', + showDescription: 'showDescription', + navigation: '_navigationMode', // compound: mode/width/openNewTab + selection: '_selectionType', + pagination: '_pageSize', // compound: pageSize/pageSizeOptions + searchableFields: '_searchableFields', + filterableFields: '_filterableFields', + resizable: 'resizable', + densityMode: 'densityMode', + rowHeight: 'rowHeight', + hiddenFields: '_hiddenFields', + exportOptions: '_export', // compound with allowExport + rowActions: '_rowActions', + bulkActions: '_bulkActions', + sharing: '_sharingEnabled', // compound: enabled/visibility + addRecord: '_addRecord', // compound with addRecordViaForm + conditionalFormatting: '_conditionalFormatting', + quickFilters: '_quickFilters', + showRecordCount: 'showRecordCount', + allowPrinting: 'allowPrinting', + virtualScroll: 'virtualScroll', + emptyState: '_emptyState', + aria: '_ariaLabel', // compound: label/describedBy/live + }; + for (const [specProp, fieldKey] of Object.entries(specPropertyToFieldKey)) { + expect(keys).toContain(fieldKey); + } + }); + + it('covers all NamedListView toolbar toggles in order', () => { + const schema = buildSchema(); + const section = schema.sections.find(s => s.key === 'pageConfig')!; + const keys = section.fields.map(f => f.key); + const toolbarFields = [ + 'showSearch', 'showSort', 'showFilters', + 'showHideFields', 'showGroup', 'showColor', 'showDensity', + ]; + // All present + for (const field of toolbarFields) { + expect(keys).toContain(field); + } + // Order matches spec + for (let i = 0; i < toolbarFields.length - 1; i++) { + expect(keys.indexOf(toolbarFields[i])).toBeLessThan(keys.indexOf(toolbarFields[i + 1])); + } + }); + + it('covers all NamedListView boolean toggles in userActions in spec order', () => { + const schema = buildSchema(); + const section = schema.sections.find(s => s.key === 'userActions')!; + const keys = section.fields.map(f => f.key); + // Spec order: inlineEdit → clickIntoRecordDetails → addDeleteRecordsInline + expect(keys.indexOf('inlineEdit')).toBeLessThan(keys.indexOf('clickIntoRecordDetails')); + expect(keys.indexOf('clickIntoRecordDetails')).toBeLessThan(keys.indexOf('addDeleteRecordsInline')); + }); + + // Protocol suggestions: UI fields not in NamedListView spec + it('documents UI extension fields not in NamedListView spec', () => { + const keys = allFieldKeys(); + // These fields are UI extensions — documented as protocol suggestions + const uiExtensions = ['description', '_source', '_groupBy', '_typeOptions']; + for (const ext of uiExtensions) { + expect(keys).toContain(ext); + } + }); + }); +}); diff --git a/apps/console/src/utils/view-config-schema.tsx b/apps/console/src/utils/view-config-schema.tsx index 4e2ca9e7f..e8a8108e5 100644 --- a/apps/console/src/utils/view-config-schema.tsx +++ b/apps/console/src/utils/view-config-schema.tsx @@ -3,6 +3,12 @@ * * Builds a ConfigPanelSchema that describes the entire ViewConfigPanel * structure declaratively. Complex widgets use type='custom' render functions. + * + * Field ordering within each section strictly follows the NamedListView + * interface property declaration order in packages/types/src/objectql.ts. + * Each field is annotated with its spec source (e.g. "spec: NamedListView.label"). + * Fields not present in the spec are annotated as "UI extension" with a + * protocol suggestion. */ import React from 'react'; @@ -101,7 +107,7 @@ function buildPageConfigSection( title: t('console.objectView.page'), hint: t('console.objectView.pageConfigHint'), fields: [ - // Title + // spec: NamedListView.label — required view display label { key: 'label', label: t('console.objectView.title'), @@ -117,7 +123,8 @@ function buildPageConfigSection( ), }, - // Description + // UI extension: description — not in NamedListView spec. + // Protocol suggestion: add optional 'description' to NamedListView. { key: 'description', label: t('console.objectView.description'), @@ -134,7 +141,7 @@ function buildPageConfigSection( ), }, - // View type + // spec: NamedListView.type — view type enum { key: 'type', label: t('console.objectView.viewType'), @@ -154,15 +161,94 @@ function buildPageConfigSection( ), }, - // Toolbar toggles - buildSwitchField('showSearch', t('console.objectView.enableSearch'), 'toggle-showSearch', true), - buildSwitchField('showFilters', t('console.objectView.enableFilter'), 'toggle-showFilters', true), - buildSwitchField('showSort', t('console.objectView.enableSort'), 'toggle-showSort', true), - buildSwitchField('showHideFields', t('console.objectView.enableHideFields'), 'toggle-showHideFields', true), - buildSwitchField('showGroup', t('console.objectView.enableGroup'), 'toggle-showGroup', true), - buildSwitchField('showColor', t('console.objectView.enableColor'), 'toggle-showColor', true), - buildSwitchField('showDensity', t('console.objectView.enableDensity'), 'toggle-showDensity', true), - // Navigation mode + // Toolbar toggles — ordered per spec: showSearch, showSort, showFilters, showHideFields, showGroup, showColor, showDensity + buildSwitchField('showSearch', t('console.objectView.enableSearch'), 'toggle-showSearch', true), // spec: NamedListView.showSearch + buildSwitchField('showSort', t('console.objectView.enableSort'), 'toggle-showSort', true), // spec: NamedListView.showSort + buildSwitchField('showFilters', t('console.objectView.enableFilter'), 'toggle-showFilters', true), // spec: NamedListView.showFilters + buildSwitchField('showHideFields', t('console.objectView.enableHideFields'), 'toggle-showHideFields', true), // spec: NamedListView.showHideFields + buildSwitchField('showGroup', t('console.objectView.enableGroup'), 'toggle-showGroup', true), // spec: NamedListView.showGroup + buildSwitchField('showColor', t('console.objectView.enableColor'), 'toggle-showColor', true), // spec: NamedListView.showColor + buildSwitchField('showDensity', t('console.objectView.enableDensity'), 'toggle-showDensity', true), // spec: NamedListView.showDensity + // spec: NamedListView.allowExport + NamedListView.exportOptions — export toggle + sub-config + { + key: '_export', + label: t('console.objectView.allowExport'), + type: 'custom', + render: (_value, _onChange, draft) => { + const hasExport = draft.exportOptions != null || draft.allowExport === true; + return ( + <> + + updateField('allowExport', checked)} + className="scale-75" + /> + + {hasExport && ( + <> + +
+ {(['csv', 'xlsx', 'json', 'pdf'] as const).map(fmt => ( + + ))} +
+
+ + ) => + updateField('exportOptions', { ...(draft.exportOptions || {}), maxRecords: Number(e.target.value) || undefined }) + } + /> + + + + updateField('exportOptions', { ...(draft.exportOptions || {}), includeHeaders: checked }) + } + className="scale-75" + /> + + + ) => + updateField('exportOptions', { ...(draft.exportOptions || {}), fileNamePrefix: e.target.value }) + } + /> + + + )} + + ); + }, + }, + // spec: NamedListView.navigation — navigation mode/width/openNewTab { key: '_navigationMode', label: t('console.objectView.navigationMode'), @@ -193,7 +279,7 @@ function buildPageConfigSection( ); }, }, - // Navigation width (visible for drawer/modal/split) + // spec: NamedListView.navigation.width (visible for drawer/modal/split) { key: '_navigationWidth', label: t('console.objectView.navigationWidth'), @@ -216,7 +302,7 @@ function buildPageConfigSection( ); }, }, - // Navigation openNewTab (visible for page/new_window) + // spec: NamedListView.navigation.openNewTab (visible for page/new_window) { key: '_navigationOpenNewTab', label: t('console.objectView.openNewTab'), @@ -238,7 +324,7 @@ function buildPageConfigSection( ); }, }, - // Selection mode + // spec: NamedListView.selection — row selection mode { key: '_selectionType', label: t('console.objectView.selectionMode'), @@ -260,7 +346,7 @@ function buildPageConfigSection( ), }, - // Add Record config + // spec: NamedListView.addRecordViaForm + NamedListView.addRecord — add record config { key: '_addRecord', label: t('console.objectView.addRecordEnabled'), @@ -325,88 +411,9 @@ function buildPageConfigSection( ); }, }, - // Allow Export + sub-config - { - key: '_export', - label: t('console.objectView.allowExport'), - type: 'custom', - render: (_value, _onChange, draft) => { - const hasExport = draft.exportOptions != null || draft.allowExport === true; - return ( - <> - - updateField('allowExport', checked)} - className="scale-75" - /> - - {hasExport && ( - <> - -
- {(['csv', 'xlsx', 'json', 'pdf'] as const).map(fmt => ( - - ))} -
-
- - ) => - updateField('exportOptions', { ...(draft.exportOptions || {}), maxRecords: Number(e.target.value) || undefined }) - } - /> - - - - updateField('exportOptions', { ...(draft.exportOptions || {}), includeHeaders: checked }) - } - className="scale-75" - /> - - - ) => - updateField('exportOptions', { ...(draft.exportOptions || {}), fileNamePrefix: e.target.value }) - } - /> - - - )} - - ); - }, - }, - // Show record count + // spec: NamedListView.showRecordCount buildSwitchField('showRecordCount', t('console.objectView.showRecordCount'), 'toggle-showRecordCount', false, true), - // Allow printing + // spec: NamedListView.allowPrinting buildSwitchField('allowPrinting', t('console.objectView.allowPrinting'), 'toggle-allowPrinting', false, true), ], }; @@ -431,7 +438,7 @@ function buildDataSection( hint: t('console.objectView.listConfigHint'), collapsible: true, fields: [ - // Source (read-only) + // UI extension: read-only source display — not a NamedListView property. { key: '_source', label: t('console.objectView.source'), @@ -440,94 +447,7 @@ function buildDataSection( ), }, - // Sort by — expandable - { - key: '_sortBy', - label: t('console.objectView.sortBy'), - type: 'custom', - render: (_value, _onChange, draft) => { - const sortCount = Array.isArray(draft.sort) ? draft.sort.filter((s: any) => s.field).length : 0; - const sortSummary = sortCount > 0 - ? t('console.objectView.sortsCount', { count: sortCount }) - : t('console.objectView.none'); - return ( - ( - - )} - > -
- ({ value: f.value, label: f.label }))} - value={sortItemsValue} - onChange={(items: SortItem[]) => { - const sortArr = items.map(s => ({ id: s.id, field: s.field, order: s.order })); - updateField('sort', sortArr); - }} - className="[&_button]:h-7 [&_button]:text-xs" - /> -
-
- ); - }, - }, - // Group by - { - key: '_groupBy', - label: t('console.objectView.groupBy'), - type: 'custom', - render: (_value, _onChange, draft) => { - const viewType = draft.type || 'grid'; - const groupByValue = draft.kanban?.groupByField || draft.kanban?.groupField || draft.groupBy || ''; - return ( - - - - ); - }, - }, - // Prefix field - { - key: 'prefixField', - label: t('console.objectView.prefixField'), - type: 'custom', - render: (value, onChange) => ( - - - - ), - }, - // Fields / Columns selector + // spec: NamedListView.columns — fields/columns selector (expandable) { key: '_columns', label: t('console.objectView.fields'), @@ -626,7 +546,7 @@ function buildDataSection( ); }, }, - // Filter by — expandable + // spec: NamedListView.filter — filter conditions (expandable) { key: '_filterBy', label: t('console.objectView.filterBy'), @@ -665,7 +585,94 @@ function buildDataSection( ); }, }, - // Pagination — pageSize + // spec: NamedListView.sort — sort configuration (expandable) + { + key: '_sortBy', + label: t('console.objectView.sortBy'), + type: 'custom', + render: (_value, _onChange, draft) => { + const sortCount = Array.isArray(draft.sort) ? draft.sort.filter((s: any) => s.field).length : 0; + const sortSummary = sortCount > 0 + ? t('console.objectView.sortsCount', { count: sortCount }) + : t('console.objectView.none'); + return ( + ( + + )} + > +
+ ({ value: f.value, label: f.label }))} + value={sortItemsValue} + onChange={(items: SortItem[]) => { + const sortArr = items.map(s => ({ id: s.id, field: s.field, order: s.order })); + updateField('sort', sortArr); + }} + className="[&_button]:h-7 [&_button]:text-xs" + /> +
+
+ ); + }, + }, + // spec: NamedListView.prefixField + { + key: 'prefixField', + label: t('console.objectView.prefixField'), + type: 'custom', + render: (value, onChange) => ( + + + + ), + }, + // UI extension: groupBy — not in NamedListView spec. Protocol suggestion: add 'groupBy' to NamedListView. + { + key: '_groupBy', + label: t('console.objectView.groupBy'), + type: 'custom', + render: (_value, _onChange, draft) => { + const viewType = draft.type || 'grid'; + const groupByValue = draft.kanban?.groupByField || draft.kanban?.groupField || draft.groupBy || ''; + return ( + + + + ); + }, + }, + // spec: NamedListView.pagination.pageSize { key: '_pageSize', label: t('console.objectView.pageSize'), @@ -686,7 +693,7 @@ function buildDataSection(
), }, - // Pagination — pageSizeOptions + // spec: NamedListView.pagination.pageSizeOptions { key: '_pageSizeOptions', label: t('console.objectView.pageSizeOptions'), @@ -706,13 +713,13 @@ function buildDataSection( ), }, - // Searchable fields + // spec: NamedListView.searchableFields buildFieldMultiSelect('searchableFields', t('console.objectView.searchableFields'), 'searchable-fields-selector', 'searchable-field', fieldOptions, updateField, 'selected'), - // Filterable fields + // spec: NamedListView.filterableFields buildFieldMultiSelect('filterableFields', t('console.objectView.filterableFields'), 'filterable-fields-selector', 'filterable-field', fieldOptions, updateField, 'selected'), - // Hidden fields + // spec: NamedListView.hiddenFields buildFieldMultiSelect('hiddenFields', t('console.objectView.hiddenFields'), 'hidden-fields-selector', 'hidden-field', fieldOptions, updateField, 'hidden'), - // Quick filters + // spec: NamedListView.quickFilters { key: '_quickFilters', label: t('console.objectView.quickFilters'), @@ -780,9 +787,9 @@ function buildDataSection( ); }, }, - // Virtual scroll + // spec: NamedListView.virtualScroll buildSwitchField('virtualScroll', t('console.objectView.virtualScroll'), 'toggle-virtualScroll', false, true), - // Type-Specific Data Fields + // UI extension: type-specific options — maps to NamedListView kanban/calendar/gantt/gallery/timeline/map sub-configs { key: '_typeOptions', label: 'Type-specific options', @@ -965,7 +972,11 @@ function buildAppearanceSection( title: t('console.objectView.appearance'), collapsible: true, fields: [ - // Color field select + // spec: NamedListView.striped + buildSwitchField('striped', t('console.objectView.striped'), 'toggle-striped', false, true), + // spec: NamedListView.bordered + buildSwitchField('bordered', t('console.objectView.bordered'), 'toggle-bordered', false, true), + // spec: NamedListView.color — field for row/card coloring { key: 'color', label: t('console.objectView.color'), @@ -986,7 +997,11 @@ function buildAppearanceSection( ), }, - // Field text color + // spec: NamedListView.wrapHeaders + buildSwitchField('wrapHeaders', t('console.objectView.wrapHeaders'), 'toggle-wrapHeaders', false, true), + // spec: NamedListView.collapseAllByDefault + buildSwitchField('collapseAllByDefault', t('console.objectView.collapseAllByDefault'), 'toggle-collapseAllByDefault', false, true), + // spec: NamedListView.fieldTextColor { key: 'fieldTextColor', label: t('console.objectView.fieldTextColor'), @@ -1007,7 +1022,31 @@ function buildAppearanceSection( ), }, - // Row height (icon-group) + // spec: NamedListView.showDescription + buildSwitchField('showDescription', t('console.objectView.showFieldDescriptions'), 'toggle-showDescription', true), + // spec: NamedListView.resizable + buildSwitchField('resizable', t('console.objectView.resizableColumns'), 'toggle-resizable', false, true), + // spec: NamedListView.densityMode — compact/comfortable/spacious + { + key: 'densityMode', + label: t('console.objectView.densityMode'), + type: 'custom', + render: (value, onChange) => ( + + + + ), + }, + // spec: NamedListView.rowHeight — 5-value enum: compact/short/medium/tall/extra_tall { key: 'rowHeight', label: t('console.objectView.rowHeight'), @@ -1042,34 +1081,7 @@ function buildAppearanceSection( ), }, - // Toggles - buildSwitchField('wrapHeaders', t('console.objectView.wrapHeaders'), 'toggle-wrapHeaders', false, true), - buildSwitchField('showDescription', t('console.objectView.showFieldDescriptions'), 'toggle-showDescription', true), - buildSwitchField('collapseAllByDefault', t('console.objectView.collapseAllByDefault'), 'toggle-collapseAllByDefault', false, true), - buildSwitchField('striped', t('console.objectView.striped'), 'toggle-striped', false, true), - buildSwitchField('bordered', t('console.objectView.bordered'), 'toggle-bordered', false, true), - buildSwitchField('resizable', t('console.objectView.resizableColumns'), 'toggle-resizable', false, true), - // Density mode - { - key: 'densityMode', - label: t('console.objectView.densityMode'), - type: 'custom', - render: (value, onChange) => ( - - - - ), - }, - // Conditional formatting + // spec: NamedListView.conditionalFormatting { key: '_conditionalFormatting', label: t('console.objectView.conditionalFormatting'), @@ -1157,7 +1169,7 @@ function buildAppearanceSection( ); }, }, - // Empty state + // spec: NamedListView.emptyState { key: '_emptyState', label: 'Empty state', @@ -1214,9 +1226,13 @@ function buildUserActionsSection( title: t('console.objectView.userActions'), collapsible: true, fields: [ + // spec: NamedListView.inlineEdit buildSwitchField('inlineEdit', t('console.objectView.inlineEdit'), 'toggle-inlineEdit', true), + // spec: NamedListView.clickIntoRecordDetails + buildSwitchField('clickIntoRecordDetails', t('console.objectView.clickIntoRecordDetails'), 'toggle-clickIntoRecordDetails', true), + // spec: NamedListView.addDeleteRecordsInline buildSwitchField('addDeleteRecordsInline', t('console.objectView.addDeleteRecordsInline'), 'toggle-addDeleteRecordsInline', true), - // Row actions + // spec: NamedListView.rowActions { key: '_rowActions', label: t('console.objectView.rowActions'), @@ -1247,7 +1263,7 @@ function buildUserActionsSection( ); }, }, - // Bulk actions + // spec: NamedListView.bulkActions { key: '_bulkActions', label: t('console.objectView.bulkActions'), @@ -1295,6 +1311,7 @@ function buildSharingSection( title: t('console.objectView.sharing'), collapsible: true, fields: [ + // spec: NamedListView.sharing.enabled { key: '_sharingEnabled', label: t('console.objectView.sharingEnabled'), @@ -1312,6 +1329,7 @@ function buildSharingSection( ), }, + // spec: NamedListView.sharing.visibility { key: '_sharingVisibility', label: t('console.objectView.sharingVisibility'), @@ -1352,6 +1370,7 @@ function buildAccessibilitySection( title: t('console.objectView.accessibility'), collapsible: true, fields: [ + // spec: NamedListView.aria.label { key: '_ariaLabel', label: t('console.objectView.ariaLabel'), @@ -1369,6 +1388,7 @@ function buildAccessibilitySection( ), }, + // spec: NamedListView.aria.describedBy { key: '_ariaDescribedBy', label: t('console.objectView.ariaDescribedBy'), @@ -1386,6 +1406,7 @@ function buildAccessibilitySection( ), }, + // spec: NamedListView.aria.live — polite/assertive/off { key: '_ariaLive', label: t('console.objectView.ariaLive'), diff --git a/apps/console/src/utils/view-config-utils.ts b/apps/console/src/utils/view-config-utils.ts index 89f70be31..ec6ec1abb 100644 --- a/apps/console/src/utils/view-config-utils.ts +++ b/apps/console/src/utils/view-config-utils.ts @@ -186,11 +186,16 @@ export const VIEW_TYPE_LABELS: Record = { /** All available view type keys */ export const VIEW_TYPE_OPTIONS = Object.keys(VIEW_TYPE_LABELS); -/** Row height options with Tailwind gap classes for visual icons */ +/** + * Row height options with Tailwind gap classes for visual icons. + * Aligned with @objectstack/spec RowHeight enum — all 5 values. + */ export const ROW_HEIGHT_OPTIONS: Array<{ value: string; gapClass: string }> = [ { value: 'compact', gapClass: 'gap-0' }, + { value: 'short', gapClass: 'gap-px' }, { value: 'medium', gapClass: 'gap-0.5' }, { value: 'tall', gapClass: 'gap-1' }, + { value: 'extra_tall', gapClass: 'gap-1.5' }, ]; // --------------------------------------------------------------------------- diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index ebc31b745..5b93c0f7e 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -416,11 +416,12 @@ export interface ObjectGridSchema extends BaseSchema { frozenColumns?: number; /** - * Row height preset for the grid - * Controls the density of grid rows + * Row height preset for the grid. + * Controls the density of grid rows. + * Aligned with @objectstack/spec RowHeight enum. * @default 'medium' */ - rowHeight?: 'compact' | 'medium' | 'tall'; + rowHeight?: 'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'; /** * Export options configuration for exporting grid data. @@ -1082,8 +1083,11 @@ export interface NamedListView { /** Density mode for controlling row/item spacing */ densityMode?: 'compact' | 'comfortable' | 'spacious'; - /** Row height for list/grid view rows */ - rowHeight?: 'compact' | 'medium' | 'tall'; + /** + * Row height for list/grid view rows. + * Aligned with @objectstack/spec RowHeight enum. + */ + rowHeight?: 'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'; /** Fields to hide from the current view */ hiddenFields?: string[]; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 2f123ae25..13380da40 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -287,7 +287,7 @@ export const ListViewSchema = BaseSchema.extend({ filterableFields: z.array(z.string()).optional().describe('Filterable fields'), resizable: z.boolean().optional().describe('Allow column resizing'), densityMode: z.enum(['compact', 'comfortable', 'spacious']).optional().describe('Density mode'), - rowHeight: z.enum(['compact', 'medium', 'tall']).optional().describe('Row height'), + rowHeight: z.enum(['compact', 'short', 'medium', 'tall', 'extra_tall']).optional().describe('Row height'), hiddenFields: z.array(z.string()).optional().describe('Hidden fields'), exportOptions: z.object({ formats: z.array(z.enum(['csv', 'xlsx', 'json', 'pdf'])).optional(),