Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,11 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
- [x] 18 new ViewConfigPanel interaction tests: collapseAllByDefault, showDescription, clickIntoRecordDetails, addDeleteRecordsInline toggles; sharing visibility conditional hide; navigation width/openNewTab conditional rendering; all 5 rowHeight button clicks; boundary tests (empty actions, long labels, special chars); pageSizeOptions input; densityMode/ARIA live enums; addRecord conditional sub-editor; sharing visibility select
- [x] 8 new schema-driven spec tests: accessibility field ordering, emptyState compound field, switch field defaults, comprehensive visibleWhen predicates (sharing, navigation width, navigation openNewTab)
- [x] All spec fields verified: Appearance/UserActions/Sharing/Accessibility sections 100% covered with UI controls, defaults, ordering, and conditional visibility
- [x] Add `description` field to `NamedListView` protocol interface (spec alignment)
- [x] Add `disabledWhen` predicate to `ConfigField` type — grid-only fields (striped/bordered/wrapHeaders/resizable) disabled for non-grid views
- [x] Add `expandedSections` prop to `ConfigPanelRenderer` for external section collapse control (auto-focus/highlight)
- [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)

**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

Expand Down
83 changes: 82 additions & 1 deletion apps/console/src/__tests__/view-config-schema.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -701,11 +701,16 @@ describe('spec alignment', () => {
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'];
const uiExtensions = ['_source', '_groupBy', '_typeOptions'];
for (const ext of uiExtensions) {
expect(keys).toContain(ext);
}
});

it('description is now a spec-aligned field in NamedListView', () => {
const keys = allFieldKeys();
expect(keys).toContain('description');
});
});

// ── Accessibility section field ordering ─────────────────────────────
Expand Down Expand Up @@ -800,4 +805,80 @@ describe('spec alignment', () => {
expect(field.visibleWhen!({})).toBe(true);
});
});

// ── disabledWhen predicates for grid-only fields ─────────────────────
describe('disabledWhen predicates for grid-only fields', () => {
function buildSchema() {
return buildViewConfigSchema({
t: mockT,
fieldOptions: mockFieldOptions,
objectDef: mockObjectDef,
updateField: mockUpdateField,
filterGroupValue: mockFilterGroup,
sortItemsValue: mockSortItems,
});
}

const gridOnlyFields = ['striped', 'bordered', 'wrapHeaders', 'resizable'];

for (const fieldKey of gridOnlyFields) {
it(`${fieldKey} should have disabledWhen 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();
});

it(`${fieldKey} should be disabled 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);
});

it(`${fieldKey} should not be disabled 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);
});

it(`${fieldKey} should not be disabled 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);
});
}
});

// ── helpText on navigation-dependent fields ──────────────────────────
describe('helpText on navigation-dependent fields', () => {
function buildSchema() {
return buildViewConfigSchema({
t: mockT,
fieldOptions: mockFieldOptions,
objectDef: mockObjectDef,
updateField: mockUpdateField,
filterGroupValue: mockFilterGroup,
sortItemsValue: mockSortItems,
});
}

it('navigation width field has helpText', () => {
const schema = buildSchema();
const section = schema.sections.find(s => s.key === 'pageConfig')!;
const field = section.fields.find(f => f.key === '_navigationWidth')!;
expect(field.helpText).toBeDefined();
expect(typeof field.helpText).toBe('string');
});

it('navigation openNewTab field has helpText', () => {
const schema = buildSchema();
const section = schema.sections.find(s => s.key === 'pageConfig')!;
const field = section.fields.find(f => f.key === '_navigationOpenNewTab')!;
expect(field.helpText).toBeDefined();
expect(typeof field.helpText).toBe('string');
});
});
});
39 changes: 27 additions & 12 deletions apps/console/src/utils/view-config-schema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,7 @@ function buildPageConfigSection(
</ConfigRow>
),
},
// UI extension: description — not in NamedListView spec.
// Protocol suggestion: add optional 'description' to NamedListView.
// spec: NamedListView.description — optional view description
{
key: 'description',
label: t('console.objectView.description'),
Expand Down Expand Up @@ -284,6 +283,7 @@ function buildPageConfigSection(
key: '_navigationWidth',
label: t('console.objectView.navigationWidth'),
type: 'custom',
helpText: t('console.objectView.navigationWidthHint'),
visibleWhen: (draft) => ['drawer', 'modal', 'split'].includes(draft.navigation?.mode || 'page'),
render: (_value, _onChange, draft) => {
const navMode = draft.navigation?.mode || 'page';
Expand All @@ -307,6 +307,7 @@ function buildPageConfigSection(
key: '_navigationOpenNewTab',
label: t('console.objectView.openNewTab'),
type: 'custom',
helpText: t('console.objectView.openNewTabHint'),
visibleWhen: (draft) => ['page', 'new_window'].includes(draft.navigation?.mode || 'page'),
render: (_value, _onChange, draft) => {
const navMode = draft.navigation?.mode || 'page';
Expand Down Expand Up @@ -972,10 +973,12 @@ function buildAppearanceSection(
title: t('console.objectView.appearance'),
collapsible: true,
fields: [
// 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.striped (grid-only)
buildSwitchField('striped', t('console.objectView.striped'), 'toggle-striped', false, true,
(draft) => draft.type != null && draft.type !== 'grid'),
// spec: NamedListView.bordered (grid-only)
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
{
key: 'color',
Expand All @@ -997,8 +1000,9 @@ function buildAppearanceSection(
</ConfigRow>
),
},
// spec: NamedListView.wrapHeaders
buildSwitchField('wrapHeaders', t('console.objectView.wrapHeaders'), 'toggle-wrapHeaders', false, true),
// spec: NamedListView.wrapHeaders (grid-only)
buildSwitchField('wrapHeaders', t('console.objectView.wrapHeaders'), 'toggle-wrapHeaders', false, true,
(draft) => draft.type != null && draft.type !== 'grid'),
// spec: NamedListView.collapseAllByDefault
buildSwitchField('collapseAllByDefault', t('console.objectView.collapseAllByDefault'), 'toggle-collapseAllByDefault', false, true),
// spec: NamedListView.fieldTextColor
Expand All @@ -1024,8 +1028,9 @@ function buildAppearanceSection(
},
// 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.resizable (grid-only)
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',
Expand Down Expand Up @@ -1440,17 +1445,27 @@ function buildAccessibilitySection(
* Build a standard Switch toggle field with custom testId.
* @param defaultOn - if true, treat undefined/absent as enabled (checked = value !== false)
* @param explicitTrue - if true, only check when value === true
* @param disabledWhen - optional predicate to disable the switch based on draft state
*/
function buildSwitchField(key: string, label: string, testId: string, defaultOn = false, explicitTrue = false): ConfigField {
function buildSwitchField(
key: string,
label: string,
testId: string,
defaultOn = false,
explicitTrue = false,
disabledWhen?: (draft: Record<string, any>) => boolean,
): ConfigField {
return {
key,
label,
type: 'custom',
render: (value, onChange) => (
disabledWhen,
render: (value, onChange, draft) => (
<ConfigRow label={label}>
<Switch
data-testid={testId}
checked={explicitTrue ? value === true : (defaultOn ? value !== false : !!value)}
disabled={disabledWhen ? disabledWhen(draft) : false}
onCheckedChange={(checked: boolean) => onChange(checked)}
className="scale-75"
/>
Expand Down
33 changes: 33 additions & 0 deletions packages/components/src/__tests__/config-field-renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,37 @@ describe('ConfigFieldRenderer', () => {
expect(screen.getByText('Add sort')).toBeDefined();
});
});

describe('helpText rendering', () => {
it('should render helpText below field when provided', () => {
const field: ConfigField = {
key: 'width',
label: 'Width',
type: 'input',
helpText: 'Available for drawer, modal, and split modes',
};
render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={defaultDraft} />);
expect(screen.getByText('Available for drawer, modal, and split modes')).toBeDefined();
});

it('should not render helpText paragraph when not provided', () => {
const field: ConfigField = { key: 'name', label: 'Name', type: 'input' };
const { container } = render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={defaultDraft} />);
expect(container.querySelectorAll('p').length).toBe(0);
});

it('should render helpText for custom field type', () => {
const React = require('react');
const field: ConfigField = {
key: 'custom',
label: 'Custom',
type: 'custom',
helpText: 'Custom help text',
render: (value, onChange) => React.createElement('div', { 'data-testid': 'custom-content' }, 'Custom'),
};
render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={defaultDraft} />);
expect(screen.getByText('Custom help text')).toBeDefined();
expect(screen.getByTestId('custom-content')).toBeDefined();
});
});
});
106 changes: 106 additions & 0 deletions packages/components/src/__tests__/config-panel-renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,4 +317,110 @@ describe('ConfigPanelRenderer', () => {
expect(onFieldChange).toHaveBeenCalledWith('name', 'NewName');
});
});

describe('expandedSections prop', () => {
it('should override defaultCollapsed when section is in expandedSections', () => {
render(
<ConfigPanelRenderer
{...defaultProps}
schema={collapsibleSchema}
draft={{ columns: '3', theme: 'dark', source: 'api' }}
expandedSections={['appearance']}
/>,
);
// Appearance is defaultCollapsed=true but expandedSections overrides it
expect(screen.getByText('Theme')).toBeDefined();
});

it('should not affect sections not in expandedSections', () => {
render(
<ConfigPanelRenderer
{...defaultProps}
schema={collapsibleSchema}
draft={{ columns: '3', theme: 'dark', source: 'api' }}
expandedSections={['appearance']}
/>,
);
// Data section is not in expandedSections, should remain expanded (its default)
expect(screen.getByText('Source')).toBeDefined();
});

it('should allow local toggle to still work alongside expandedSections', () => {
render(
<ConfigPanelRenderer
{...defaultProps}
schema={collapsibleSchema}
draft={{ columns: '3', theme: 'dark', source: 'api' }}
expandedSections={['appearance']}
/>,
);
// Appearance is forced expanded by expandedSections
expect(screen.getByText('Theme')).toBeDefined();

// Data section can still be toggled locally
expect(screen.getByText('Source')).toBeDefined();
fireEvent.click(screen.getByTestId('section-header-data'));
expect(screen.queryByText('Source')).toBeNull();
});
});

describe('disabledWhen on fields', () => {
it('should disable input field when disabledWhen returns true', () => {
const schemaWithDisabledWhen: ConfigPanelSchema = {
breadcrumb: ['Test'],
sections: [
{
key: 'sec',
title: 'Section',
fields: [
{
key: 'name',
label: 'Name',
type: 'input',
disabledWhen: (draft) => draft.locked === true,
},
],
},
],
};
render(
<ConfigPanelRenderer
{...defaultProps}
schema={schemaWithDisabledWhen}
draft={{ name: 'Test', locked: true }}
/>,
);
const input = screen.getByTestId('config-field-name');
expect((input as HTMLInputElement).disabled).toBe(true);
});

it('should enable input field when disabledWhen returns false', () => {
const schemaWithDisabledWhen: ConfigPanelSchema = {
breadcrumb: ['Test'],
sections: [
{
key: 'sec',
title: 'Section',
fields: [
{
key: 'name',
label: 'Name',
type: 'input',
disabledWhen: (draft) => draft.locked === true,
},
],
},
],
};
render(
<ConfigPanelRenderer
{...defaultProps}
schema={schemaWithDisabledWhen}
draft={{ name: 'Test', locked: false }}
/>,
);
const input = screen.getByTestId('config-field-name');
expect((input as HTMLInputElement).disabled).toBe(false);
});
});
});
Loading