diff --git a/ROADMAP.md b/ROADMAP.md index 83f9aa698..cce82c546 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -402,9 +402,9 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind **Phase 2 — Schema Factory (All Sections):** - [x] Page Config section: label, description, viewType, toolbar toggles (7 switches), navigation mode/width/openNewTab, selection, addRecord sub-editor, export + sub-config, showRecordCount, allowPrinting -- [x] Data section: source, sortBy (expandable), groupBy, prefixField, columns selector (expandable w/ reorder), filterBy (expandable), pagination, searchable/filterable/hidden fields (expandable), quickFilters (expandable), virtualScroll, type-specific options (kanban/calendar/map/gallery/timeline/gantt) -- [x] Appearance section: color, fieldTextColor, rowHeight (icon group), wrapHeaders, showDescription, collapseAllByDefault, striped, bordered, resizable, densityMode, conditionalFormatting (expandable), emptyState (title/message/icon) -- [x] User Actions section: inlineEdit, addDeleteRecordsInline, rowActions (expandable), bulkActions (expandable) +- [x] Data section: source, sortBy (expandable), groupBy (grid/gallery), columns selector (expandable w/ reorder), filterBy (expandable), pagination, searchable/filterable/hidden fields (expandable), quickFilters (expandable), virtualScroll (grid only), type-specific options (kanban/calendar/map/gallery/timeline/gantt) +- [x] Appearance section: color (grid/calendar/timeline/gantt), rowHeight (icon group, grid only), wrapHeaders (grid only), showDescription, striped/bordered (grid only), resizable (grid only), conditionalFormatting (expandable, grid/kanban), emptyState (title/message/icon) +- [x] User Actions section: inlineEdit (grid only), addDeleteRecordsInline (grid only), rowActions/bulkActions (expandable, grid/kanban) - [x] Sharing section: sharingEnabled, sharingVisibility (visibleWhen: sharing.enabled) - [x] Accessibility section: ariaLabel, ariaDescribedBy, ariaLive - [x] `ExpandableWidget` component for hook-safe expandable sub-sections within custom render functions @@ -433,6 +433,23 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] Add `helpText` to navigation-dependent fields (width/openNewTab) with i18n hints (all 11 locales) - [x] 24 new tests: expandedSections override (3), disabledWhen evaluation (2), grid-only disabledWhen predicates (16), helpText validation (2), description spec alignment (1) +**Phase 6 — Config Panel Cleanup (Invalid Items Fix):** +- [x] Remove `densityMode` field from appearance section (redundant with `rowHeight` which provides finer 5-value granularity) +- [x] Remove `prefixField` from data section (not consumed by any runtime renderer) +- [x] Remove `collapseAllByDefault` from appearance section (not consumed by any runtime renderer) +- [x] Remove `fieldTextColor` from appearance section (not consumed by any runtime renderer) +- [x] Remove `clickIntoRecordDetails` from userActions section (controlled implicitly via navigation mode, not directly consumed) +- [x] Add view-type-aware `visibleWhen` to toolbar toggles: `showGroup` (grid/kanban/gallery), `showColor` (grid/calendar/timeline/gantt), `showDensity` (grid only), `showRecordCount` (grid only), `allowPrinting` (grid only) +- [x] Add view-type-aware `visibleWhen` to data fields: `_groupBy` (grid/gallery — kanban uses dedicated type-specific option), `virtualScroll` (grid only) +- [x] Add view-type-aware `visibleWhen` to appearance fields: `striped`/`bordered`/`wrapHeaders`/`resizable`/`rowHeight` (grid only, changed from disabledWhen to visibleWhen), `color` (grid/calendar/timeline/gantt), `conditionalFormatting` (grid/kanban) +- [x] Add view-type-aware `visibleWhen` to userActions fields: `inlineEdit`/`addDeleteRecordsInline` (grid only), `rowActions`/`bulkActions` (grid/kanban) +- [x] Correct `searchableFields`/`filterableFields`/`quickFilters`/`showDescription` to universal (all view types) — data fetch/toolbar features not view-specific +- [x] Extend `buildSwitchField` and `buildFieldMultiSelect` helpers to accept `visibleWhen` parameter +- [x] Define semantic predicates: `supportsGrouping`, `supportsColorField`, `supportsConditionalFormatting`, `supportsRowActions`, `supportsGenericGroupBy` +- [x] 103 schema tests pass (updated field key lists, visibleWhen predicates for all view types, removed field verification) +- [x] 136 ViewConfigPanel interaction tests pass (removed tests for deleted fields) +- [x] 10 config-sync integration tests pass + **Code Reduction:** ~1655 lines imperative → ~170 lines declarative wrapper + ~1100 lines schema factory + ~180 lines shared utils = **>50% net reduction in component code** with significantly improved maintainability ### P1.9 Console — Content Area Layout & Responsiveness diff --git a/apps/console/src/__tests__/ViewConfigPanel.test.tsx b/apps/console/src/__tests__/ViewConfigPanel.test.tsx index 9a833250f..36b7b048a 100644 --- a/apps/console/src/__tests__/ViewConfigPanel.test.tsx +++ b/apps/console/src/__tests__/ViewConfigPanel.test.tsx @@ -1250,21 +1250,19 @@ describe('ViewConfigPanel', () => { // ── Appearance fields tests ── - it('renders new appearance fields: color, fieldTextColor, rowHeight, wrapHeaders, collapseAllByDefault', () => { + it('renders new appearance fields: color, rowHeight, wrapHeaders', () => { render( ); expect(screen.getByTestId('appearance-color')).toBeInTheDocument(); - expect(screen.getByTestId('appearance-fieldTextColor')).toBeInTheDocument(); expect(screen.getByTestId('appearance-rowHeight')).toBeInTheDocument(); expect(screen.getByTestId('toggle-wrapHeaders')).toBeInTheDocument(); - expect(screen.getByTestId('toggle-collapseAllByDefault')).toBeInTheDocument(); }); it('changes row height via icon buttons', () => { @@ -1346,16 +1344,15 @@ describe('ViewConfigPanel', () => { ); expect(screen.getByTestId('data-groupBy')).toBeInTheDocument(); - expect(screen.getByTestId('data-prefixField')).toBeInTheDocument(); }); - it('changes groupBy and propagates to kanban type option', () => { + it('changes groupBy for grid view', () => { const onViewUpdate = vi.fn(); render( @@ -1365,7 +1362,6 @@ describe('ViewConfigPanel', () => { fireEvent.change(groupBySelect, { target: { value: 'stage' } }); expect(onViewUpdate).toHaveBeenCalledWith('groupBy', 'stage'); - expect(onViewUpdate).toHaveBeenCalledWith('kanban', expect.objectContaining({ groupByField: 'stage' })); }); // ── Calendar endDateField test ── @@ -2014,34 +2010,7 @@ describe('ViewConfigPanel', () => { expect(onViewUpdate).toHaveBeenCalledWith('resizable', true); }); - it('renders density mode select in appearance section', () => { - render( - - ); - - expect(screen.getByTestId('select-densityMode')).toBeInTheDocument(); - }); - - it('changes densityMode and calls onViewUpdate', () => { - const onViewUpdate = vi.fn(); - render( - - ); - - fireEvent.change(screen.getByTestId('select-densityMode'), { target: { value: 'compact' } }); - expect(onViewUpdate).toHaveBeenCalledWith('densityMode', 'compact'); - }); + // densityMode tests removed — densityMode field was removed (redundant with rowHeight) it('renders conditional formatting editor when expanded', () => { render( @@ -2436,22 +2405,6 @@ describe('ViewConfigPanel', () => { // ── Spec alignment: toggle interaction tests for all switch fields ── - it('toggles collapseAllByDefault and calls onViewUpdate', () => { - const onViewUpdate = vi.fn(); - render( - - ); - - fireEvent.click(screen.getByTestId('toggle-collapseAllByDefault')); - expect(onViewUpdate).toHaveBeenCalledWith('collapseAllByDefault', true); - }); - it('toggles showDescription and calls onViewUpdate', () => { const onViewUpdate = vi.fn(); render( @@ -2468,22 +2421,6 @@ describe('ViewConfigPanel', () => { expect(onViewUpdate).toHaveBeenCalledWith('showDescription', false); }); - it('toggles clickIntoRecordDetails and calls onViewUpdate', () => { - const onViewUpdate = vi.fn(); - render( - - ); - - fireEvent.click(screen.getByTestId('toggle-clickIntoRecordDetails')); - expect(onViewUpdate).toHaveBeenCalledWith('clickIntoRecordDetails', false); - }); - it('toggles addDeleteRecordsInline and calls onViewUpdate', () => { const onViewUpdate = vi.fn(); render( @@ -2693,26 +2630,7 @@ describe('ViewConfigPanel', () => { })); }); - // ── Boundary: densityMode enum selection ── - - it('changes densityMode to all enum values', () => { - const onViewUpdate = vi.fn(); - render( - - ); - - const select = screen.getByTestId('select-densityMode'); - ['compact', 'comfortable', 'spacious'].forEach((mode) => { - fireEvent.change(select, { target: { value: mode } }); - expect(onViewUpdate).toHaveBeenCalledWith('densityMode', mode); - }); - }); + // densityMode enum test removed — densityMode field was removed (redundant with rowHeight) // ── Conditional rendering: addRecord sub-editor hidden when not enabled ── diff --git a/apps/console/src/__tests__/view-config-schema.test.tsx b/apps/console/src/__tests__/view-config-schema.test.tsx index f5bb11dfe..163410401 100644 --- a/apps/console/src/__tests__/view-config-schema.test.tsx +++ b/apps/console/src/__tests__/view-config-schema.test.tsx @@ -411,13 +411,14 @@ describe('buildViewConfigSchema', () => { 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, + // Spec order: columns, filter, sort, pagination, searchableFields, filterableFields, // hiddenFields, quickFilters, virtualScroll - // _source is UI extension (first), _groupBy is UI extension (after prefixField), _typeOptions is UI extension (last) + // _source is UI extension (first), _groupBy is UI extension (after sort), _typeOptions is UI extension (last) + // NOTE: prefixField removed — not consumed by runtime expect(fieldKeys).toEqual([ '_source', '_columns', '_filterBy', '_sortBy', - 'prefixField', '_groupBy', + '_groupBy', '_pageSize', '_pageSizeOptions', '_searchableFields', '_filterableFields', '_hiddenFields', '_quickFilters', @@ -442,13 +443,15 @@ describe('buildViewConfigSchema', () => { 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 + // Spec order: striped, bordered, color, wrapHeaders, + // showDescription, resizable, rowHeight, conditionalFormatting, emptyState + // NOTE: collapseAllByDefault removed — not consumed by runtime + // NOTE: fieldTextColor removed — not consumed by runtime + // NOTE: densityMode removed (redundant with rowHeight) expect(fieldKeys).toEqual([ 'striped', 'bordered', 'color', - 'wrapHeaders', 'collapseAllByDefault', - 'fieldTextColor', 'showDescription', - 'resizable', 'densityMode', 'rowHeight', + 'wrapHeaders', 'showDescription', + 'resizable', 'rowHeight', '_conditionalFormatting', '_emptyState', ]); }); @@ -469,18 +472,19 @@ describe('buildViewConfigSchema', () => { 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 + // Spec order: inlineEdit, addDeleteRecordsInline, rowActions, bulkActions + // NOTE: clickIntoRecordDetails removed — controlled via navigation mode expect(fieldKeys).toEqual([ - 'inlineEdit', 'clickIntoRecordDetails', 'addDeleteRecordsInline', + 'inlineEdit', 'addDeleteRecordsInline', '_rowActions', '_bulkActions', ]); }); - it('inlineEdit comes before clickIntoRecordDetails per spec', () => { + it('inlineEdit comes before addDeleteRecordsInline per spec', () => { const schema = buildSchema(); const section = schema.sections.find(s => s.key === 'userActions')!; const fieldKeys = section.fields.map(f => f.key); - expect(fieldKeys.indexOf('inlineEdit')).toBeLessThan(fieldKeys.indexOf('clickIntoRecordDetails')); + expect(fieldKeys.indexOf('inlineEdit')).toBeLessThan(fieldKeys.indexOf('addDeleteRecordsInline')); }); }); @@ -636,12 +640,12 @@ describe('spec alignment', () => { color: 'color', inlineEdit: 'inlineEdit', wrapHeaders: 'wrapHeaders', - clickIntoRecordDetails: 'clickIntoRecordDetails', + // clickIntoRecordDetails removed — controlled via navigation mode addRecordViaForm: '_addRecord', // compound field addDeleteRecordsInline: 'addDeleteRecordsInline', - collapseAllByDefault: 'collapseAllByDefault', - fieldTextColor: 'fieldTextColor', - prefixField: 'prefixField', + // collapseAllByDefault removed — not consumed by runtime + // fieldTextColor removed — not consumed by runtime + // prefixField removed — not consumed by runtime showDescription: 'showDescription', navigation: '_navigationMode', // compound: mode/width/openNewTab selection: '_selectionType', @@ -649,7 +653,7 @@ describe('spec alignment', () => { searchableFields: '_searchableFields', filterableFields: '_filterableFields', resizable: 'resizable', - densityMode: 'densityMode', + // densityMode removed — redundant with rowHeight rowHeight: 'rowHeight', hiddenFields: '_hiddenFields', exportOptions: '_export', // compound with allowExport @@ -692,9 +696,9 @@ describe('spec alignment', () => { 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')); + // Spec order: inlineEdit → addDeleteRecordsInline + // NOTE: clickIntoRecordDetails removed — controlled via navigation mode + expect(keys.indexOf('inlineEdit')).toBeLessThan(keys.indexOf('addDeleteRecordsInline')); }); // Protocol suggestions: UI fields not in NamedListView spec @@ -743,14 +747,6 @@ describe('spec alignment', () => { return section.fields.find(f => f.key === fieldKey)!; } - it('collapseAllByDefault is an explicitTrue switch field', () => { - // explicitTrue fields only show checked when value === true - const field = findField('appearance', 'collapseAllByDefault'); - expect(field.render).toBeDefined(); - expect(field.type).toBe('custom'); - expect(field.key).toBe('collapseAllByDefault'); - }); - it('showDescription is a defaultOn switch field', () => { const field = findField('appearance', 'showDescription'); expect(field.render).toBeDefined(); @@ -764,6 +760,13 @@ describe('spec alignment', () => { expect(field.type).toBe('custom'); expect(field.key).toBe('inlineEdit'); }); + + it('densityMode field was removed (redundant with rowHeight)', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'appearance')!; + const field = section.fields.find(f => f.key === 'densityMode'); + expect(field).toBeUndefined(); + }); }); // ── All conditional visibleWhen predicates ─────────────────────────── @@ -804,10 +807,146 @@ describe('spec alignment', () => { // defaults to page mode when navigation is undefined → visible expect(field.visibleWhen!({})).toBe(true); }); + + // ── Toolbar toggle visibility by view type ────────────────────── + it('showGroup visible for grid (default), kanban, and gallery, hidden for others', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'pageConfig')!; + const field = section.fields.find(f => f.key === 'showGroup')!; + expect(field.visibleWhen).toBeDefined(); + expect(field.visibleWhen!({})).toBe(true); // default = grid + expect(field.visibleWhen!({ type: 'grid' })).toBe(true); + expect(field.visibleWhen!({ type: 'kanban' })).toBe(true); + expect(field.visibleWhen!({ type: 'gallery' })).toBe(true); + expect(field.visibleWhen!({ type: 'calendar' })).toBe(false); + expect(field.visibleWhen!({ type: 'timeline' })).toBe(false); + expect(field.visibleWhen!({ type: 'gantt' })).toBe(false); + expect(field.visibleWhen!({ type: 'map' })).toBe(false); + }); + + it('showColor visible for grid (default), calendar, timeline, gantt, hidden for others', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'pageConfig')!; + const field = section.fields.find(f => f.key === 'showColor')!; + expect(field.visibleWhen).toBeDefined(); + expect(field.visibleWhen!({})).toBe(true); // default = grid + expect(field.visibleWhen!({ type: 'grid' })).toBe(true); + expect(field.visibleWhen!({ type: 'calendar' })).toBe(true); + expect(field.visibleWhen!({ type: 'timeline' })).toBe(true); + expect(field.visibleWhen!({ type: 'gantt' })).toBe(true); + expect(field.visibleWhen!({ type: 'kanban' })).toBe(false); + expect(field.visibleWhen!({ type: 'gallery' })).toBe(false); + expect(field.visibleWhen!({ type: 'map' })).toBe(false); + }); + + it('showDensity visible only for grid (default), hidden for others', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'pageConfig')!; + const field = section.fields.find(f => f.key === 'showDensity')!; + expect(field.visibleWhen).toBeDefined(); + expect(field.visibleWhen!({})).toBe(true); // default = grid + expect(field.visibleWhen!({ type: 'grid' })).toBe(true); + expect(field.visibleWhen!({ type: 'kanban' })).toBe(false); + expect(field.visibleWhen!({ type: 'calendar' })).toBe(false); + }); + + // ── Data section visibility by view type ──────────────────────── + it('_groupBy visible for grid (default) and gallery, hidden for others', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'data')!; + const field = section.fields.find(f => f.key === '_groupBy')!; + expect(field.visibleWhen).toBeDefined(); + expect(field.visibleWhen!({})).toBe(true); // default = grid + expect(field.visibleWhen!({ type: 'grid' })).toBe(true); + expect(field.visibleWhen!({ type: 'gallery' })).toBe(true); + expect(field.visibleWhen!({ type: 'kanban' })).toBe(false); // kanban uses dedicated groupByField + expect(field.visibleWhen!({ type: 'calendar' })).toBe(false); + expect(field.visibleWhen!({ type: 'timeline' })).toBe(false); + expect(field.visibleWhen!({ type: 'gantt' })).toBe(false); + expect(field.visibleWhen!({ type: 'map' })).toBe(false); + }); + + it('searchableFields is universal (no visibleWhen)', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'data')!; + const field = section.fields.find(f => f.key === '_searchableFields')!; + expect(field.visibleWhen).toBeUndefined(); + }); + + it('filterableFields is universal (no visibleWhen)', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'data')!; + const field = section.fields.find(f => f.key === '_filterableFields')!; + expect(field.visibleWhen).toBeUndefined(); + }); + + it('quickFilters is universal (no visibleWhen)', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'data')!; + const field = section.fields.find(f => f.key === '_quickFilters')!; + expect(field.visibleWhen).toBeUndefined(); + }); + + it('virtualScroll visible only for grid', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'data')!; + const field = section.fields.find(f => f.key === 'virtualScroll')!; + expect(field.visibleWhen).toBeDefined(); + expect(field.visibleWhen!({})).toBe(true); + expect(field.visibleWhen!({ type: 'grid' })).toBe(true); + expect(field.visibleWhen!({ type: 'kanban' })).toBe(false); + }); + + // ── Appearance section visibility by view type / state ────────── + it('color visible for grid (default), calendar, timeline, gantt, hidden for others', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'appearance')!; + const field = section.fields.find(f => f.key === 'color')!; + expect(field.visibleWhen).toBeDefined(); + expect(field.visibleWhen!({})).toBe(true); // default = grid + expect(field.visibleWhen!({ type: 'grid' })).toBe(true); + expect(field.visibleWhen!({ type: 'calendar' })).toBe(true); + expect(field.visibleWhen!({ type: 'timeline' })).toBe(true); + expect(field.visibleWhen!({ type: 'gantt' })).toBe(true); + expect(field.visibleWhen!({ type: 'kanban' })).toBe(false); + expect(field.visibleWhen!({ type: 'gallery' })).toBe(false); + expect(field.visibleWhen!({ type: 'map' })).toBe(false); + }); + + it('showDescription is universal (no visibleWhen)', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'appearance')!; + const field = section.fields.find(f => f.key === 'showDescription')!; + expect(field.visibleWhen).toBeUndefined(); + }); + + it('rowHeight visible only for grid', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'appearance')!; + const field = section.fields.find(f => f.key === 'rowHeight')!; + expect(field.visibleWhen).toBeDefined(); + expect(field.visibleWhen!({})).toBe(true); + expect(field.visibleWhen!({ type: 'grid' })).toBe(true); + expect(field.visibleWhen!({ type: 'kanban' })).toBe(false); + }); + + it('conditionalFormatting visible for grid (default) and kanban, hidden for others', () => { + const schema = buildSpecSchema(); + const section = schema.sections.find(s => s.key === 'appearance')!; + const field = section.fields.find(f => f.key === '_conditionalFormatting')!; + expect(field.visibleWhen).toBeDefined(); + expect(field.visibleWhen!({})).toBe(true); + expect(field.visibleWhen!({ type: 'grid' })).toBe(true); + expect(field.visibleWhen!({ type: 'kanban' })).toBe(true); + expect(field.visibleWhen!({ type: 'calendar' })).toBe(false); + expect(field.visibleWhen!({ type: 'gallery' })).toBe(false); + expect(field.visibleWhen!({ type: 'timeline' })).toBe(false); + expect(field.visibleWhen!({ type: 'map' })).toBe(false); + }); }); - // ── disabledWhen predicates for grid-only fields ───────────────────── - describe('disabledWhen predicates for grid-only fields', () => { + // ── visibleWhen predicates for grid-only appearance fields ────────── + describe('visibleWhen predicates for grid-only appearance fields', () => { function buildSchema() { return buildViewConfigSchema({ t: mockT, @@ -822,32 +961,32 @@ describe('spec alignment', () => { const gridOnlyFields = ['striped', 'bordered', 'wrapHeaders', 'resizable']; for (const fieldKey of gridOnlyFields) { - it(`${fieldKey} should have disabledWhen predicate`, () => { + it(`${fieldKey} should have visibleWhen predicate`, () => { const schema = buildSchema(); const section = schema.sections.find(s => s.key === 'appearance')!; const field = section.fields.find(f => f.key === fieldKey)!; - expect(field.disabledWhen).toBeDefined(); + expect(field.visibleWhen).toBeDefined(); }); - it(`${fieldKey} should be disabled when view type is kanban`, () => { + it(`${fieldKey} should be hidden when view type is kanban`, () => { const schema = buildSchema(); const section = schema.sections.find(s => s.key === 'appearance')!; const field = section.fields.find(f => f.key === fieldKey)!; - expect(field.disabledWhen!({ type: 'kanban' })).toBe(true); + expect(field.visibleWhen!({ type: 'kanban' })).toBe(false); }); - it(`${fieldKey} should not be disabled when view type is grid`, () => { + it(`${fieldKey} should be visible when view type is grid`, () => { const schema = buildSchema(); const section = schema.sections.find(s => s.key === 'appearance')!; const field = section.fields.find(f => f.key === fieldKey)!; - expect(field.disabledWhen!({ type: 'grid' })).toBe(false); + expect(field.visibleWhen!({ type: 'grid' })).toBe(true); }); - it(`${fieldKey} should not be disabled when view type is undefined (default grid)`, () => { + it(`${fieldKey} should be visible when view type is undefined (default grid)`, () => { const schema = buildSchema(); const section = schema.sections.find(s => s.key === 'appearance')!; const field = section.fields.find(f => f.key === fieldKey)!; - expect(field.disabledWhen!({})).toBe(false); + expect(field.visibleWhen!({})).toBe(true); }); } }); diff --git a/apps/console/src/utils/view-config-schema.tsx b/apps/console/src/utils/view-config-schema.tsx index e22acd423..de4b38b35 100644 --- a/apps/console/src/utils/view-config-schema.tsx +++ b/apps/console/src/utils/view-config-schema.tsx @@ -59,6 +59,28 @@ export interface ViewSchemaFactoryOptions { sortItemsValue: SortItem[]; } +// --------------------------------------------------------------------------- +// View-type visibility predicates +// --------------------------------------------------------------------------- + +/** True when the view type is grid (or unset, since grid is the default) */ +const isGridView = (draft: Record) => draft.type == null || draft.type === 'grid'; + +/** True for views that support the Group toolbar button (grid/kanban/gallery have grouping support) */ +const supportsGrouping = (draft: Record) => draft.type == null || ['grid', 'kanban', 'gallery'].includes(draft.type); + +/** True for views where the color field is consumed at runtime (grid, calendar, timeline, gantt) */ +const supportsColorField = (draft: Record) => draft.type == null || ['grid', 'calendar', 'timeline', 'gantt'].includes(draft.type); + +/** True for views that support conditional formatting (grid, kanban) */ +const supportsConditionalFormatting = (draft: Record) => draft.type == null || ['grid', 'kanban'].includes(draft.type); + +/** True for views that support row/bulk actions (grid, kanban) */ +const supportsRowActions = (draft: Record) => draft.type == null || ['grid', 'kanban'].includes(draft.type); + +/** True for views that support generic groupBy (grid, gallery — kanban has dedicated groupByField) */ +const supportsGenericGroupBy = (draft: Record) => draft.type == null || ['grid', 'gallery'].includes(draft.type); + // --------------------------------------------------------------------------- // Schema factory // --------------------------------------------------------------------------- @@ -165,9 +187,12 @@ function buildPageConfigSection( 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 + buildSwitchField('showGroup', t('console.objectView.enableGroup'), 'toggle-showGroup', true, // spec: NamedListView.showGroup + false, undefined, supportsGrouping), + buildSwitchField('showColor', t('console.objectView.enableColor'), 'toggle-showColor', true, // spec: NamedListView.showColor + false, undefined, supportsColorField), + buildSwitchField('showDensity', t('console.objectView.enableDensity'), 'toggle-showDensity', true, // spec: NamedListView.showDensity + false, undefined, isGridView), // spec: NamedListView.allowExport + NamedListView.exportOptions — export toggle + sub-config { key: '_export', @@ -412,10 +437,12 @@ function buildPageConfigSection( ); }, }, - // spec: NamedListView.showRecordCount - buildSwitchField('showRecordCount', t('console.objectView.showRecordCount'), 'toggle-showRecordCount', false, true), - // spec: NamedListView.allowPrinting - buildSwitchField('allowPrinting', t('console.objectView.allowPrinting'), 'toggle-allowPrinting', false, true), + // spec: NamedListView.showRecordCount (grid-only: record count bar is a grid feature) + buildSwitchField('showRecordCount', t('console.objectView.showRecordCount'), 'toggle-showRecordCount', false, true, + undefined, isGridView), + // spec: NamedListView.allowPrinting (grid-only: print renders grid table) + buildSwitchField('allowPrinting', t('console.objectView.allowPrinting'), 'toggle-allowPrinting', false, true, + undefined, isGridView), ], }; } @@ -621,32 +648,13 @@ function buildDataSection( ); }, }, - // 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. + // NOTE: prefixField removed — not consumed by any runtime renderer + // UI extension: groupBy — visible for grid/gallery (kanban has dedicated type-specific option). { key: '_groupBy', label: t('console.objectView.groupBy'), type: 'custom', + visibleWhen: supportsGenericGroupBy, render: (_value, _onChange, draft) => { const viewType = draft.type || 'grid'; const groupByValue = draft.kanban?.groupByField || draft.kanban?.groupField || draft.groupBy || ''; @@ -714,13 +722,13 @@ function buildDataSection( ), }, - // spec: NamedListView.searchableFields + // spec: NamedListView.searchableFields (universal: search applies at data fetch level for all views) buildFieldMultiSelect('searchableFields', t('console.objectView.searchableFields'), 'searchable-fields-selector', 'searchable-field', fieldOptions, updateField, 'selected'), - // spec: NamedListView.filterableFields + // spec: NamedListView.filterableFields (universal: filter field whitelisting applies to all views) buildFieldMultiSelect('filterableFields', t('console.objectView.filterableFields'), 'filterable-fields-selector', 'filterable-field', fieldOptions, updateField, 'selected'), // spec: NamedListView.hiddenFields buildFieldMultiSelect('hiddenFields', t('console.objectView.hiddenFields'), 'hidden-fields-selector', 'hidden-field', fieldOptions, updateField, 'hidden'), - // spec: NamedListView.quickFilters + // spec: NamedListView.quickFilters (universal: quick filter buttons render in toolbar for all views) { key: '_quickFilters', label: t('console.objectView.quickFilters'), @@ -788,8 +796,9 @@ function buildDataSection( ); }, }, - // spec: NamedListView.virtualScroll - buildSwitchField('virtualScroll', t('console.objectView.virtualScroll'), 'toggle-virtualScroll', false, true), + // spec: NamedListView.virtualScroll (grid-only: virtual scrolling applies to grid row rendering) + buildSwitchField('virtualScroll', t('console.objectView.virtualScroll'), 'toggle-virtualScroll', false, true, + undefined, isGridView), // UI extension: type-specific options — maps to NamedListView kanban/calendar/gantt/gallery/timeline/map sub-configs { key: '_typeOptions', @@ -973,17 +982,18 @@ function buildAppearanceSection( title: t('console.objectView.appearance'), collapsible: true, fields: [ - // spec: NamedListView.striped (grid-only) + // spec: NamedListView.striped (grid-only: row striping is a grid concept) buildSwitchField('striped', t('console.objectView.striped'), 'toggle-striped', false, true, - (draft) => draft.type != null && draft.type !== 'grid'), - // spec: NamedListView.bordered (grid-only) + undefined, isGridView), + // spec: NamedListView.bordered (grid-only: borders are a grid concept) buildSwitchField('bordered', t('console.objectView.bordered'), 'toggle-bordered', false, true, - (draft) => draft.type != null && draft.type !== 'grid'), - // spec: NamedListView.color — field for row/card coloring + undefined, isGridView), + // spec: NamedListView.color — field for row/card coloring (grid, calendar, timeline, gantt) { key: 'color', label: t('console.objectView.color'), type: 'custom', + visibleWhen: supportsColorField, render: (value, onChange) => ( ) => onChange(e.target.value)} - > - - {fieldOptions.map(f => ( - - ))} - - - ), - }, - // spec: NamedListView.showDescription + undefined, isGridView), + // NOTE: collapseAllByDefault removed — not consumed by any runtime renderer + // NOTE: fieldTextColor removed — not consumed by any runtime renderer + // spec: NamedListView.showDescription (universal: view description shown below toolbar for all views) buildSwitchField('showDescription', t('console.objectView.showFieldDescriptions'), 'toggle-showDescription', true), - // spec: NamedListView.resizable (grid-only) + // spec: NamedListView.resizable (grid-only: column resizing is a grid concept) buildSwitchField('resizable', t('console.objectView.resizableColumns'), 'toggle-resizable', false, true, - (draft) => draft.type != null && draft.type !== 'grid'), - // 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 + undefined, isGridView), + // NOTE: densityMode removed — redundant with rowHeight which provides finer 5-value granularity + // spec: NamedListView.rowHeight — 5-value enum: compact/short/medium/tall/extra_tall (grid-only) { key: 'rowHeight', label: t('console.objectView.rowHeight'), type: 'custom', + visibleWhen: isGridView, render: (value, onChange) => (
@@ -1086,11 +1057,12 @@ function buildAppearanceSection( ), }, - // spec: NamedListView.conditionalFormatting + // spec: NamedListView.conditionalFormatting (grid/kanban: both process conditional formatting rules) { key: '_conditionalFormatting', label: t('console.objectView.conditionalFormatting'), type: 'custom', + visibleWhen: supportsConditionalFormatting, render: (_value, _onChange, draft) => { return ( { return ( { return ( ) => boolean, + visibleWhen?: (draft: Record) => boolean, ): ConfigField { return { key, label, type: 'custom', disabledWhen, + visibleWhen, render: (value, onChange, draft) => ( ) => boolean, ): ConfigField { return { key: `_${key}`, label, type: 'custom', + visibleWhen, render: (_value, _onChange, draft) => { return (