From 3daa8145c76e81a56171c22ed4dfc7e389bd8c95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:24:38 +0000 Subject: [PATCH 1/5] Initial plan From ab6facb87750837e99a70e1cbb6287aef7304fe2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:36:16 +0000 Subject: [PATCH 2/5] feat(i18n): add navigationWidthHint and openNewTabHint keys to all locales Add navigationWidthHint, openNewTabHint, and their parent keys (navigationMode, navigationWidth, openNewTab) to ar, de, es, fr, ja, ko, pt, and ru locale files. Hint values are kept in English as they are developer-facing. Parent keys include localized translations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/i18n/src/locales/ar.ts | 5 +++++ packages/i18n/src/locales/de.ts | 5 +++++ packages/i18n/src/locales/es.ts | 5 +++++ packages/i18n/src/locales/fr.ts | 5 +++++ packages/i18n/src/locales/ja.ts | 5 +++++ packages/i18n/src/locales/ko.ts | 5 +++++ packages/i18n/src/locales/pt.ts | 5 +++++ packages/i18n/src/locales/ru.ts | 5 +++++ 8 files changed, 40 insertions(+) diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 44ea0667c..95fd1acf0 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -398,6 +398,11 @@ const ar = { inlineEdit: 'تحرير السجلات مباشرة', addDeleteRecordsInline: 'إضافة/حذف السجلات مباشرة', clickIntoRecordDetails: 'انقر لعرض تفاصيل السجل', + navigationMode: 'وضع التنقل', + navigationWidth: 'عرض التنقل', + navigationWidthHint: 'Available for drawer, modal, and split navigation modes', + openNewTab: 'فتح في علامة تبويب جديدة', + openNewTabHint: 'Available for page and new window navigation modes', }, localeSwitcher: { label: 'اللغة', diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index dfd515352..5cc66687c 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -402,6 +402,11 @@ const de = { inlineEdit: 'Datensätze inline bearbeiten', addDeleteRecordsInline: 'Datensätze inline hinzufügen/löschen', clickIntoRecordDetails: 'Klicken für Datensatzdetails', + navigationMode: 'Navigationsmodus', + navigationWidth: 'Navigationsbreite', + navigationWidthHint: 'Available for drawer, modal, and split navigation modes', + openNewTab: 'In neuem Tab öffnen', + openNewTabHint: 'Available for page and new window navigation modes', }, localeSwitcher: { label: 'Sprache', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 2efdeb91d..07fc3d938 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -397,6 +397,11 @@ const es = { inlineEdit: 'Editar registros en línea', addDeleteRecordsInline: 'Agregar/eliminar registros en línea', clickIntoRecordDetails: 'Clic para detalles del registro', + navigationMode: 'Modo de navegación', + navigationWidth: 'Ancho de navegación', + navigationWidthHint: 'Available for drawer, modal, and split navigation modes', + openNewTab: 'Abrir en nueva pestaña', + openNewTabHint: 'Available for page and new window navigation modes', }, localeSwitcher: { label: 'Idioma', diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 777939ff6..a7c8ae895 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -402,6 +402,11 @@ const fr = { inlineEdit: 'Modifier les enregistrements en ligne', addDeleteRecordsInline: 'Ajouter/supprimer des enregistrements en ligne', clickIntoRecordDetails: "Cliquer pour les détails de l'enregistrement", + navigationMode: 'Mode de navigation', + navigationWidth: 'Largeur de navigation', + navigationWidthHint: 'Available for drawer, modal, and split navigation modes', + openNewTab: 'Ouvrir dans un nouvel onglet', + openNewTabHint: 'Available for page and new window navigation modes', }, localeSwitcher: { label: 'Langue', diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index d95a71ee8..e1a8397d5 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -397,6 +397,11 @@ const ja = { inlineEdit: 'インラインでレコードを編集', addDeleteRecordsInline: 'インラインでレコードを追加/削除', clickIntoRecordDetails: 'レコード詳細をクリックで表示', + navigationMode: 'ナビゲーションモード', + navigationWidth: 'ナビゲーション幅', + navigationWidthHint: 'Available for drawer, modal, and split navigation modes', + openNewTab: '新しいタブで開く', + openNewTabHint: 'Available for page and new window navigation modes', }, localeSwitcher: { label: '言語', diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index fcbec2c9d..d92f2608a 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -397,6 +397,11 @@ const ko = { inlineEdit: '인라인으로 레코드 편집', addDeleteRecordsInline: '인라인으로 레코드 추가/삭제', clickIntoRecordDetails: '레코드 상세 보기 클릭', + navigationMode: '탐색 모드', + navigationWidth: '탐색 너비', + navigationWidthHint: 'Available for drawer, modal, and split navigation modes', + openNewTab: '새 탭에서 열기', + openNewTabHint: 'Available for page and new window navigation modes', }, localeSwitcher: { label: '언어', diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 576f8fefc..5ff56d41c 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -397,6 +397,11 @@ const pt = { inlineEdit: 'Editar registros em linha', addDeleteRecordsInline: 'Adicionar/excluir registros em linha', clickIntoRecordDetails: 'Clique para detalhes do registro', + navigationMode: 'Modo de navegação', + navigationWidth: 'Largura de navegação', + navigationWidthHint: 'Available for drawer, modal, and split navigation modes', + openNewTab: 'Abrir em nova aba', + openNewTabHint: 'Available for page and new window navigation modes', }, localeSwitcher: { label: 'Idioma', diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index bc7211021..f5955689d 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -397,6 +397,11 @@ const ru = { inlineEdit: 'Редактировать записи встроенно', addDeleteRecordsInline: 'Добавлять/удалять записи встроенно', clickIntoRecordDetails: 'Нажмите для просмотра деталей записи', + navigationMode: 'Режим навигации', + navigationWidth: 'Ширина навигации', + navigationWidthHint: 'Available for drawer, modal, and split navigation modes', + openNewTab: 'Открыть в новой вкладке', + openNewTabHint: 'Available for page and new window navigation modes', }, localeSwitcher: { label: 'Язык', From 43d7cfcdf9ec7e791d5a5fbd0f973b41fdaff34a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:41:08 +0000 Subject: [PATCH 3/5] feat: ViewConfigPanel improvements - disabledWhen, expandedSections, description spec alignment, helpText hints - Add `description` field to NamedListView interface (protocol alignment) - Add `disabledWhen` predicate to ConfigField type for conditional disabling - Implement `disabledWhen` evaluation in ConfigFieldRenderer for all control types - Add `expandedSections` prop to ConfigPanelRenderer for external section collapse control - Add `helpText` to navigation-dependent fields (width/openNewTab) for UX context - Add `disabledWhen` to grid-only fields (striped/bordered/wrapHeaders/resizable) - Add i18n keys for navigation hint text (all 11 locales) - Add 24 new tests covering expandedSections, disabledWhen, and helpText Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/view-config-schema.test.tsx | 83 +++++++++++++- apps/console/src/utils/view-config-schema.tsx | 39 +++++-- .../__tests__/config-panel-renderer.test.tsx | 106 ++++++++++++++++++ .../src/custom/config-field-renderer.tsx | 17 +-- .../src/custom/config-panel-renderer.tsx | 18 ++- packages/components/src/types/config-panel.ts | 2 + packages/i18n/src/locales/en.ts | 2 + packages/i18n/src/locales/zh.ts | 2 + packages/types/src/objectql.ts | 3 + 9 files changed, 247 insertions(+), 25 deletions(-) diff --git a/apps/console/src/__tests__/view-config-schema.test.tsx b/apps/console/src/__tests__/view-config-schema.test.tsx index 38acf2680..f5bb11dfe 100644 --- a/apps/console/src/__tests__/view-config-schema.test.tsx +++ b/apps/console/src/__tests__/view-config-schema.test.tsx @@ -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 ───────────────────────────── @@ -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'); + }); + }); }); diff --git a/apps/console/src/utils/view-config-schema.tsx b/apps/console/src/utils/view-config-schema.tsx index b59087894..e22acd423 100644 --- a/apps/console/src/utils/view-config-schema.tsx +++ b/apps/console/src/utils/view-config-schema.tsx @@ -123,8 +123,7 @@ function buildPageConfigSection( ), }, - // 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'), @@ -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'; @@ -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'; @@ -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', @@ -997,8 +1000,9 @@ function buildAppearanceSection( ), }, - // 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 @@ -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', @@ -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) => boolean, +): ConfigField { return { key, label, type: 'custom', - render: (value, onChange) => ( + disabledWhen, + render: (value, onChange, draft) => ( onChange(checked)} className="scale-75" /> diff --git a/packages/components/src/__tests__/config-panel-renderer.test.tsx b/packages/components/src/__tests__/config-panel-renderer.test.tsx index 3346b648c..5558e51de 100644 --- a/packages/components/src/__tests__/config-panel-renderer.test.tsx +++ b/packages/components/src/__tests__/config-panel-renderer.test.tsx @@ -317,4 +317,110 @@ describe('ConfigPanelRenderer', () => { expect(onFieldChange).toHaveBeenCalledWith('name', 'NewName'); }); }); + + describe('expandedSections prop', () => { + it('should override defaultCollapsed when section is in expandedSections', () => { + render( + , + ); + // Appearance is defaultCollapsed=true but expandedSections overrides it + expect(screen.getByText('Theme')).toBeDefined(); + }); + + it('should not affect sections not in expandedSections', () => { + render( + , + ); + // 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( + , + ); + // 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( + , + ); + 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( + , + ); + const input = screen.getByTestId('config-field-name'); + expect((input as HTMLInputElement).disabled).toBe(false); + }); + }); }); diff --git a/packages/components/src/custom/config-field-renderer.tsx b/packages/components/src/custom/config-field-renderer.tsx index 9a9e4aa82..37c52839d 100644 --- a/packages/components/src/custom/config-field-renderer.tsx +++ b/packages/components/src/custom/config-field-renderer.tsx @@ -57,6 +57,7 @@ export function ConfigFieldRenderer({ return null; } + const effectiveDisabled = field.disabled || (field.disabledWhen ? field.disabledWhen(draft) : false); const effectiveValue = value ?? field.defaultValue; switch (field.type) { @@ -68,7 +69,7 @@ export function ConfigFieldRenderer({ className="h-7 w-32 text-xs" value={effectiveValue ?? ''} placeholder={field.placeholder} - disabled={field.disabled} + disabled={effectiveDisabled} onChange={(e) => onChange(e.target.value)} /> @@ -80,7 +81,7 @@ export function ConfigFieldRenderer({ onChange(checked)} className="scale-75" /> @@ -93,7 +94,7 @@ export function ConfigFieldRenderer({ onChange(checked)} /> @@ -105,7 +106,7 @@ export function ConfigFieldRenderer({ ); + break; case 'switch': - return ( + content = ( ); + break; case 'checkbox': - return ( + content = ( ); + break; case 'select': - return ( + content = ( ); + break; case 'icon-group': - return ( + content = (
{(field.options ?? []).map((opt) => ( @@ -181,9 +189,10 @@ export function ConfigFieldRenderer({
); + break; case 'field-picker': - return ( + content = ( ); + break; case 'filter': - return ( + content = (
); + break; case 'sort': - return ( + content = (
); + break; case 'custom': if (field.render) { - return <>{field.render(effectiveValue, onChange, draft)}; + content = <>{field.render(effectiveValue, onChange, draft)}; } - return null; + break; default: - return null; + break; } + + if (!content) return null; + + // Wrap with helpText when provided + if (field.helpText) { + return ( +
+ {content} +

{field.helpText}

+
+ ); + } + + return <>{content}; }