From f40f02464dda3b706a702c6ea4bdd90bc83b8751 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:16:01 +0000 Subject: [PATCH 1/5] Initial plan From e24be5e210a860600b230aaf8bcd7fef29e52de3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:20:57 +0000 Subject: [PATCH 2/5] feat(types): add missing properties to NamedListView to align with ListViewSchema Add 24 new optional properties to the NamedListView interface including: - navigation, selection, pagination configuration - searchableFields, filterableFields, hiddenFields - resizable, densityMode, rowHeight display options - exportOptions, rowActions, bulkActions - sharing, addRecord, conditionalFormatting - quickFilters, showRecordCount, allowPrinting - virtualScroll, emptyState, aria accessibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/types/src/objectql.ts | 98 ++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 648b378f1..7d67db4a8 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1060,6 +1060,104 @@ export interface NamedListView { /** Show field descriptions below headers @default false */ showDescription?: boolean; + + /** Navigation configuration for row click behavior */ + navigation?: ViewNavigationConfig; + + /** Row selection mode */ + selection?: { type: 'none' | 'single' | 'multiple' }; + + /** Pagination configuration */ + pagination?: { pageSize: number; pageSizeOptions?: number[] }; + + /** Fields that support text search */ + searchableFields?: string[]; + + /** Fields available for filter UI */ + filterableFields?: string[]; + + /** Allow column resizing @default false */ + resizable?: boolean; + + /** Density mode for controlling row/item spacing */ + densityMode?: 'compact' | 'comfortable' | 'spacious'; + + /** Row height for list/grid view rows */ + rowHeight?: 'compact' | 'medium' | 'tall'; + + /** Fields to hide from the current view */ + hiddenFields?: string[]; + + /** Export options configuration */ + exportOptions?: { + formats?: Array<'csv' | 'xlsx' | 'json' | 'pdf'>; + maxRecords?: number; + includeHeaders?: boolean; + fileNamePrefix?: string; + }; + + /** Row action identifiers */ + rowActions?: string[]; + + /** Bulk action identifiers */ + bulkActions?: string[]; + + /** View sharing configuration */ + sharing?: { + visibility?: 'private' | 'team' | 'organization' | 'public'; + enabled?: boolean; + }; + + /** Add record configuration */ + addRecord?: { + enabled?: boolean; + position?: string; + mode?: string; + formView?: string; + }; + + /** Conditional formatting rules */ + conditionalFormatting?: Array<{ + field: string; + operator: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than' | 'in'; + value: unknown; + backgroundColor?: string; + textColor?: string; + borderColor?: string; + expression?: string; + }>; + + /** Quick filter buttons for predefined filter presets */ + quickFilters?: Array<{ + id: string; + label: string; + filters: Array; + icon?: string; + defaultActive?: boolean; + }>; + + /** Show total record count @default false */ + showRecordCount?: boolean; + + /** Allow printing the view @default false */ + allowPrinting?: boolean; + + /** Enable virtual scrolling for large datasets @default false */ + virtualScroll?: boolean; + + /** Empty state configuration */ + emptyState?: { + title?: string; + message?: string; + icon?: string; + }; + + /** ARIA attributes for accessibility */ + aria?: { + label?: string; + describedBy?: string; + live?: 'polite' | 'assertive' | 'off'; + }; } /** From 3236de57d7bad986055f1c84729dcdfe45c6ba6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:28:14 +0000 Subject: [PATCH 3/5] feat: add all P0/P1/P2 controls to ViewConfigPanel, update types, i18n, and propagation layers Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- apps/console/src/components/ObjectView.tsx | 21 +- .../src/components/ViewConfigPanel.tsx | 649 +++++++++++++++++- packages/i18n/src/locales/en.ts | 44 ++ packages/i18n/src/locales/zh.ts | 44 ++ packages/plugin-view/src/ObjectView.tsx | 19 + packages/types/src/objectql.ts | 2 +- packages/types/src/zod/objectql.zod.ts | 67 ++ 7 files changed, 826 insertions(+), 20 deletions(-) diff --git a/apps/console/src/components/ObjectView.tsx b/apps/console/src/components/ObjectView.tsx index 3fb9a749f..702c56c61 100644 --- a/apps/console/src/components/ObjectView.tsx +++ b/apps/console/src/components/ObjectView.tsx @@ -299,7 +299,7 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { // Propagate appearance/view-config properties for live preview rowHeight: viewDef.rowHeight ?? listSchema.rowHeight, densityMode: viewDef.densityMode ?? listSchema.densityMode, - inlineEdit: viewDef.editRecordsInline ?? listSchema.inlineEdit, + inlineEdit: viewDef.inlineEdit ?? viewDef.editRecordsInline ?? listSchema.inlineEdit, appearance: viewDef.showDescription != null ? { showDescription: viewDef.showDescription } : listSchema.appearance, @@ -325,6 +325,25 @@ export function ObjectView({ dataSource, objects, onEdit, onRowClick }: any) { fieldTextColor: viewDef.fieldTextColor ?? listSchema.fieldTextColor, prefixField: viewDef.prefixField ?? listSchema.prefixField, showDescription: viewDef.showDescription ?? listSchema.showDescription, + // Propagate new spec properties (P0/P1/P2) + navigation: viewDef.navigation ?? listSchema.navigation, + selection: viewDef.selection ?? listSchema.selection, + pagination: viewDef.pagination ?? listSchema.pagination, + searchableFields: viewDef.searchableFields ?? listSchema.searchableFields, + filterableFields: viewDef.filterableFields ?? listSchema.filterableFields, + resizable: viewDef.resizable ?? listSchema.resizable, + hiddenFields: viewDef.hiddenFields ?? listSchema.hiddenFields, + rowActions: viewDef.rowActions ?? listSchema.rowActions, + bulkActions: viewDef.bulkActions ?? listSchema.bulkActions, + sharing: viewDef.sharing ?? listSchema.sharing, + addRecord: viewDef.addRecord ?? listSchema.addRecord, + conditionalFormatting: viewDef.conditionalFormatting ?? listSchema.conditionalFormatting, + quickFilters: viewDef.quickFilters ?? listSchema.quickFilters, + showRecordCount: viewDef.showRecordCount ?? listSchema.showRecordCount, + allowPrinting: viewDef.allowPrinting ?? listSchema.allowPrinting, + virtualScroll: viewDef.virtualScroll ?? listSchema.virtualScroll, + emptyState: viewDef.emptyState ?? listSchema.emptyState, + aria: viewDef.aria ?? listSchema.aria, // Propagate filter/sort as default filters/sort for data flow ...(viewDef.filter?.length ? { filters: viewDef.filter } : {}), ...(viewDef.sort?.length ? { sort: viewDef.sort } : {}), diff --git a/apps/console/src/components/ViewConfigPanel.tsx b/apps/console/src/components/ViewConfigPanel.tsx index 12576aa20..f76bd00d4 100644 --- a/apps/console/src/components/ViewConfigPanel.tsx +++ b/apps/console/src/components/ViewConfigPanel.tsx @@ -181,12 +181,11 @@ const VIEW_TYPE_LABELS: Record = { /** All available view type keys */ 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 spec: compact/medium/tall */ const ROW_HEIGHT_OPTIONS = [ - { value: 'short', gapClass: 'gap-0' }, + { value: 'compact', gapClass: 'gap-0' }, { value: 'medium', gapClass: 'gap-0.5' }, { value: 'tall', gapClass: 'gap-1' }, - { value: 'extraTall', gapClass: 'gap-1.5' }, ]; /** Editor panel types that can be opened from clickable rows */ @@ -325,8 +324,9 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje const hasColor = draft.showColor !== false; const hasDensity = draft.showDensity !== false; const hasExport = draft.exportOptions != null || draft.allowExport === true; - const hasAddForm = draft.addRecordViaForm === true; + const hasAddForm = draft.addRecordViaForm === true || draft.addRecord?.enabled === true; const hasShowDescription = draft.showDescription !== false; + const navigationMode = draft.navigation?.mode || 'page'; // Derive field options from objectDef for FilterBuilder/SortBuilder const fieldOptions = useMemo(() => { @@ -543,22 +543,123 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje className="scale-75" /> - - updateDraft('clickIntoRecordDetails', checked)} - className="scale-75" - /> + {/* Navigation mode — replaces clickIntoRecordDetails toggle */} + + + + {/* navigation.width — shown for drawer/modal/split */} + {['drawer', 'modal', 'split'].includes(navigationMode) && ( + + ) => + updateDraft('navigation', { ...(draft.navigation || {}), mode: navigationMode, width: e.target.value }) + } + /> + + )} + {/* navigation.openNewTab — shown for page/new_window */} + {['page', 'new_window'].includes(navigationMode) && ( + + + updateDraft('navigation', { ...(draft.navigation || {}), mode: navigationMode, openNewTab: checked }) + } + className="scale-75" + /> + + )} + {/* Selection mode (P0-2) */} + + - + {/* Add Record config (P1-12) — upgraded from simple toggle */} + updateDraft('addRecordViaForm', checked)} + onCheckedChange={(checked: boolean) => { + updateDraft('addRecordViaForm', checked); + updateDraft('addRecord', { ...(draft.addRecord || {}), enabled: checked }); + }} className="scale-75" /> + {hasAddForm && ( + <> + + + + + + + + ) => + updateDraft('addRecord', { ...(draft.addRecord || {}), enabled: true, formView: e.target.value }) + } + /> + + + )} + {/* Export options sub-config (P1-4) */} + {hasExport && ( + <> + +
+ {(['csv', 'xlsx', 'json', 'pdf'] as const).map(fmt => ( + + ))} +
+
+ + ) => + updateDraft('exportOptions', { ...(draft.exportOptions || {}), maxRecords: Number(e.target.value) || undefined }) + } + /> + + + + updateDraft('exportOptions', { ...(draft.exportOptions || {}), includeHeaders: checked }) + } + className="scale-75" + /> + + + ) => + updateDraft('exportOptions', { ...(draft.exportOptions || {}), fileNamePrefix: e.target.value }) + } + /> + + + )} + {/* Show record count (P2-15) */} + + updateDraft('showRecordCount', checked)} + className="scale-75" + /> + + {/* Allow printing (P2-16) */} + + updateDraft('allowPrinting', checked)} + className="scale-75" + /> + {/* ── Data Section — list-level data, filtering, sorting ── */} @@ -717,6 +895,184 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje )} + {/* Pagination (P0-3) */} + + ) => { + const val = Number(e.target.value) || undefined; + updateDraft('pagination', { ...(draft.pagination || {}), pageSize: val }); + }} + /> + + + ) => { + const opts = e.target.value.split(',').map((s: string) => Number(s.trim())).filter((n: number) => !isNaN(n) && n > 0); + updateDraft('pagination', { ...(draft.pagination || {}), pageSizeOptions: opts.length ? opts : undefined }); + }} + /> + + + {/* Searchable fields (P1-5) */} + toggleDataSub('searchableFields')} + /> + {expandedDataSubs.searchableFields && ( +
+ {fieldOptions.map(f => ( + + ))} +
+ )} + + {/* Filterable fields (P1-6) */} + toggleDataSub('filterableFields')} + /> + {expandedDataSubs.filterableFields && ( +
+ {fieldOptions.map(f => ( + + ))} +
+ )} + + {/* Hidden fields (P1-9) */} + toggleDataSub('hiddenFields')} + /> + {expandedDataSubs.hiddenFields && ( +
+ {fieldOptions.map(f => ( + + ))} +
+ )} + + {/* Quick filters (P1-14) */} + toggleDataSub('quickFilters')} + /> + {expandedDataSubs.quickFilters && ( +
+ {(draft.quickFilters || []).map((qf: any, idx: number) => ( +
+ ) => { + const updated = [...(draft.quickFilters || [])]; + updated[idx] = { ...updated[idx], label: e.target.value }; + updateDraft('quickFilters', updated); + }} + /> + { + const updated = [...(draft.quickFilters || [])]; + updated[idx] = { ...updated[idx], defaultActive: checked }; + updateDraft('quickFilters', updated); + }} + className="h-3.5 w-3.5" + /> + +
+ ))} + +
+ )} + + {/* Virtual scroll (P2-17) */} + + updateDraft('virtualScroll', checked)} + className="scale-75" + /> + + {/* Type-Specific Data Fields */} {viewType !== 'grid' && (
@@ -913,11 +1269,11 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje key={opt.value} type="button" role="radio" - aria-checked={(draft.rowHeight || 'short') === opt.value} + aria-checked={(draft.rowHeight || 'compact') === opt.value} aria-label={opt.value} data-testid={`row-height-${opt.value}`} className={`h-7 w-7 rounded border flex items-center justify-center ${ - (draft.rowHeight || 'short') === opt.value + (draft.rowHeight || 'compact') === opt.value ? 'border-primary bg-primary/10 text-primary' : 'border-input text-muted-foreground hover:bg-accent/50' }`} @@ -973,6 +1329,135 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje className="scale-75" /> + {/* Resizable columns (P1-7) */} + + updateDraft('resizable', checked)} + className="scale-75" + /> + + {/* Density mode (P1-8) */} + + + + {/* Conditional formatting (P1-13) */} + toggleDataSub('conditionalFormatting')} + /> + {expandedDataSubs.conditionalFormatting && ( +
+ {(draft.conditionalFormatting || []).map((rule: any, idx: number) => ( +
+ + + ) => { + const updated = [...(draft.conditionalFormatting || [])]; + updated[idx] = { ...updated[idx], value: e.target.value }; + updateDraft('conditionalFormatting', updated); + }} + /> + +
+ ))} + +
+ )} + {/* Empty state (P2-18) */} + + ) => + updateDraft('emptyState', { ...(draft.emptyState || {}), title: e.target.value }) + } + /> + + + ) => + updateDraft('emptyState', { ...(draft.emptyState || {}), message: e.target.value }) + } + /> + + + ) => + updateDraft('emptyState', { ...(draft.emptyState || {}), icon: e.target.value }) + } + /> +
)} @@ -986,11 +1471,12 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje /> {!collapsedSections.userActions && (
+ {/* Semantic fix A: editRecordsInline → inlineEdit */} updateDraft('editRecordsInline', checked)} + checked={draft.inlineEdit !== false} + onCheckedChange={(checked: boolean) => updateDraft('inlineEdit', checked)} className="scale-75" /> @@ -1002,6 +1488,133 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje className="scale-75" /> + {/* Row actions (P1-10) */} + toggleDataSub('rowActions')} + /> + {expandedDataSubs.rowActions && ( +
+ ) => { + const actions = e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean); + updateDraft('rowActions', actions); + }} + /> +
+ )} + {/* Bulk actions (P1-10) */} + toggleDataSub('bulkActions')} + /> + {expandedDataSubs.bulkActions && ( +
+ ) => { + const actions = e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean); + updateDraft('bulkActions', actions); + }} + /> +
+ )} +
+ )} + + {/* Sharing Section (P1-11) — collapsible */} + toggleSection('sharing')} + testId="section-sharing" + /> + {!collapsedSections.sharing && ( +
+ + + updateDraft('sharing', { ...(draft.sharing || {}), enabled: checked }) + } + className="scale-75" + /> + + {draft.sharing?.enabled && ( + + + + )} +
+ )} + + {/* Accessibility Section (P2-19) — collapsible */} + toggleSection('accessibility')} + testId="section-accessibility" + /> + {!collapsedSections.accessibility && ( +
+ + ) => + updateDraft('aria', { ...(draft.aria || {}), label: e.target.value }) + } + /> + + + ) => + updateDraft('aria', { ...(draft.aria || {}), describedBy: e.target.value }) + } + /> + + + +
)} diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7dcd91c0a..54581d302 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -251,6 +251,50 @@ const en = { editRecordsInline: 'Edit records inline', addDeleteRecordsInline: 'Add/delete records inline', clickIntoRecordDetails: 'Click into record details', + navigationMode: 'Navigation mode', + navigationWidth: 'Navigation width', + openNewTab: 'Open in new tab', + selectionMode: 'Selection mode', + selectionNone: 'None', + selectionSingle: 'Single', + selectionMultiple: 'Multiple', + pageSize: 'Page size', + pageSizeOptions: 'Page size options', + exportFormats: 'Export formats', + exportMaxRecords: 'Max records', + exportIncludeHeaders: 'Include headers', + exportFileNamePrefix: 'File name prefix', + searchableFields: 'Searchable fields', + filterableFields: 'Filterable fields', + resizableColumns: 'Resizable columns', + densityCompact: 'Compact', + densityComfortable: 'Comfortable', + densitySpacious: 'Spacious', + densityMode: 'Density mode', + hiddenFields: 'Hidden fields', + rowActions: 'Row actions', + bulkActions: 'Bulk actions', + sharing: 'Sharing', + sharingEnabled: 'Enable sharing', + sharingVisibility: 'Visibility', + addRecordEnabled: 'Enable add record', + addRecordPosition: 'Position', + addRecordMode: 'Mode', + addRecordFormView: 'Form view', + conditionalFormatting: 'Conditional formatting', + addRule: 'Add rule', + quickFilters: 'Quick filters', + addQuickFilter: 'Add quick filter', + showRecordCount: 'Show record count', + allowPrinting: 'Allow printing', + virtualScroll: 'Virtual scroll', + emptyStateTitle: 'Empty state title', + emptyStateMessage: 'Empty state message', + emptyStateIcon: 'Empty state icon', + ariaLabel: 'ARIA label', + ariaDescribedBy: 'ARIA described by', + ariaLive: 'ARIA live', + accessibility: 'Accessibility', }, localeSwitcher: { label: 'Language', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 001136e0f..1b04a6252 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -251,6 +251,50 @@ const zh = { editRecordsInline: '内联编辑记录', addDeleteRecordsInline: '内联添加/删除记录', clickIntoRecordDetails: '点击进入记录详情', + navigationMode: '导航模式', + navigationWidth: '导航宽度', + openNewTab: '在新标签页打开', + selectionMode: '选择模式', + selectionNone: '无', + selectionSingle: '单选', + selectionMultiple: '多选', + pageSize: '每页记录数', + pageSizeOptions: '每页选项', + exportFormats: '导出格式', + exportMaxRecords: '最大记录数', + exportIncludeHeaders: '包含表头', + exportFileNamePrefix: '文件名前缀', + searchableFields: '可搜索字段', + filterableFields: '可筛选字段', + resizableColumns: '可调整列宽', + densityCompact: '紧凑', + densityComfortable: '舒适', + densitySpacious: '宽松', + densityMode: '密度模式', + hiddenFields: '隐藏字段', + rowActions: '行操作', + bulkActions: '批量操作', + sharing: '共享', + sharingEnabled: '启用共享', + sharingVisibility: '可见性', + addRecordEnabled: '启用添加记录', + addRecordPosition: '位置', + addRecordMode: '模式', + addRecordFormView: '表单视图', + conditionalFormatting: '条件格式', + addRule: '添加规则', + quickFilters: '快速筛选', + addQuickFilter: '添加快速筛选', + showRecordCount: '显示记录计数', + allowPrinting: '允许打印', + virtualScroll: '虚拟滚动', + emptyStateTitle: '空状态标题', + emptyStateMessage: '空状态消息', + emptyStateIcon: '空状态图标', + ariaLabel: 'ARIA 标签', + ariaDescribedBy: 'ARIA 描述', + ariaLive: 'ARIA 实时区域', + accessibility: '无障碍', }, localeSwitcher: { label: '语言', diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index f6f36e734..6276e32e6 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -836,6 +836,25 @@ export const ObjectView: React.FC = ({ fieldTextColor: activeView?.fieldTextColor ?? (schema as any).fieldTextColor, prefixField: activeView?.prefixField ?? (schema as any).prefixField, showDescription: activeView?.showDescription ?? (schema as any).showDescription, + // Propagate new spec properties (P0/P1/P2) + navigation: activeView?.navigation ?? (schema as any).navigation, + selection: activeView?.selection ?? (schema as any).selection, + pagination: activeView?.pagination ?? (schema as any).pagination, + searchableFields: activeView?.searchableFields ?? (schema as any).searchableFields, + filterableFields: activeView?.filterableFields ?? (schema as any).filterableFields, + resizable: activeView?.resizable ?? (schema as any).resizable, + hiddenFields: activeView?.hiddenFields ?? (schema as any).hiddenFields, + rowActions: activeView?.rowActions ?? (schema as any).rowActions, + bulkActions: activeView?.bulkActions ?? (schema as any).bulkActions, + sharing: activeView?.sharing ?? (schema as any).sharing, + addRecord: activeView?.addRecord ?? (schema as any).addRecord, + conditionalFormatting: activeView?.conditionalFormatting ?? (schema as any).conditionalFormatting, + quickFilters: activeView?.quickFilters ?? (schema as any).quickFilters, + showRecordCount: activeView?.showRecordCount ?? (schema as any).showRecordCount, + allowPrinting: activeView?.allowPrinting ?? (schema as any).allowPrinting, + virtualScroll: activeView?.virtualScroll ?? (schema as any).virtualScroll, + emptyState: activeView?.emptyState ?? (schema as any).emptyState, + aria: activeView?.aria ?? (schema as any).aria, }, dataSource, onEdit: handleEdit, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 7d67db4a8..a5ec9e33d 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1131,7 +1131,7 @@ export interface NamedListView { quickFilters?: Array<{ id: string; label: string; - filters: Array; + filters: Array; icon?: string; defaultActive?: boolean; }>; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index e34c6d087..2f123ae25 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -269,6 +269,73 @@ export const ListViewSchema = BaseSchema.extend({ fieldTextColor: z.string().optional().describe('Field for custom text color'), prefixField: z.string().optional().describe('Prefix field before title'), showDescription: z.boolean().optional().describe('Show field descriptions'), + navigation: z.object({ + mode: z.enum(['page', 'drawer', 'modal', 'split', 'popover', 'new_window', 'none']), + view: z.string().optional(), + preventNavigation: z.boolean().optional(), + openNewTab: z.boolean().optional(), + width: z.union([z.string(), z.number()]).optional(), + }).optional().describe('Navigation configuration'), + selection: z.object({ + type: z.enum(['none', 'single', 'multiple']), + }).optional().describe('Row selection mode'), + pagination: z.object({ + pageSize: z.number(), + pageSizeOptions: z.array(z.number()).optional(), + }).optional().describe('Pagination configuration'), + searchableFields: z.array(z.string()).optional().describe('Searchable fields'), + 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'), + hiddenFields: z.array(z.string()).optional().describe('Hidden fields'), + exportOptions: z.object({ + formats: z.array(z.enum(['csv', 'xlsx', 'json', 'pdf'])).optional(), + maxRecords: z.number().optional(), + includeHeaders: z.boolean().optional(), + fileNamePrefix: z.string().optional(), + }).optional().describe('Export options'), + rowActions: z.array(z.string()).optional().describe('Row action identifiers'), + bulkActions: z.array(z.string()).optional().describe('Bulk action identifiers'), + sharing: z.object({ + visibility: z.enum(['private', 'team', 'organization', 'public']).optional(), + enabled: z.boolean().optional(), + }).optional().describe('Sharing configuration'), + addRecord: z.object({ + enabled: z.boolean().optional(), + position: z.string().optional(), + mode: z.string().optional(), + formView: z.string().optional(), + }).optional().describe('Add record configuration'), + conditionalFormatting: z.array(z.object({ + field: z.string(), + operator: z.enum(['equals', 'not_equals', 'contains', 'greater_than', 'less_than', 'in']), + value: z.any(), + backgroundColor: z.string().optional(), + textColor: z.string().optional(), + borderColor: z.string().optional(), + expression: z.string().optional(), + })).optional().describe('Conditional formatting rules'), + quickFilters: z.array(z.object({ + id: z.string(), + label: z.string(), + filters: z.array(z.union([z.array(z.any()), z.string()])), + icon: z.string().optional(), + defaultActive: z.boolean().optional(), + })).optional().describe('Quick filter presets'), + showRecordCount: z.boolean().optional().describe('Show total record count'), + allowPrinting: z.boolean().optional().describe('Allow printing'), + virtualScroll: z.boolean().optional().describe('Enable virtual scrolling'), + emptyState: z.object({ + title: z.string().optional(), + message: z.string().optional(), + icon: z.string().optional(), + }).optional().describe('Empty state configuration'), + aria: z.object({ + label: z.string().optional(), + describedBy: z.string().optional(), + live: z.enum(['polite', 'assertive', 'off']).optional(), + }).optional().describe('ARIA attributes'), }); /** From be7c4531c074efb14f55c84ab0cd55287ebb1238 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:33:54 +0000 Subject: [PATCH 4/5] fix: update tests for new ViewConfigPanel controls, update ROADMAP Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 11 +- .../src/__tests__/ViewConfigPanel.test.tsx | 529 +++++++++++++++++- 2 files changed, 522 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7d6107442..fdad2e542 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -175,7 +175,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] Collapsible/expandable sections with chevron toggle - [x] Data section: Sort by (summary), Group by, Prefix field, Fields (count visible) - [x] Appearance section: Color, Field text color, Row height (icon toggle), Wrap headers, Show field descriptions, Collapse all by default -- [x] User actions section: Edit records inline, Add/delete records inline, Click into record details +- [x] User actions section: Edit records inline (→ inlineEdit), Add/delete records inline, Navigation mode (page/drawer/modal/split/popover/new_window/none) - [x] Calendar endDateField support - [x] i18n for all 11 locales (en, zh, ja, de, fr, es, ar, ru, pt, ko) - [ ] **Live preview: ViewConfigPanel changes sync in real-time to all list types (Grid/Kanban/Calendar/Timeline/Gallery/Map)** _(partially complete — see P1.8.1 gap analysis below)_ @@ -189,8 +189,15 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - ✅ `NamedListView` type declares `showSearch`/`showSort`/`showFilters`/`striped`/`bordered`/`color` as first-class properties - ✅ `ListViewSchema` TypeScript interface and Zod schema include `showSearch`/`showSort`/`showFilters`/`color` - ✅ ViewConfigPanel refactored into Page Config (toolbar/shell) and ListView Config (data/appearance) sections + - ✅ `NamedListView` type extended with 24 new properties: navigation, selection, pagination, searchableFields, filterableFields, resizable, densityMode, rowHeight, hiddenFields, exportOptions, rowActions, bulkActions, sharing, addRecord, conditionalFormatting, quickFilters, showRecordCount, allowPrinting, virtualScroll, emptyState, aria + - ✅ `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 + - ✅ Semantic fix: `rowHeight` values aligned to spec (`compact`/`medium`/`tall`) + - ✅ Console ObjectView fullSchema propagates all 18 new spec properties + - ✅ PluginObjectView renderListView schema propagates all 18 new spec properties - ⚠️ No per-view-type integration tests verifying config properties reach non-grid renderers -- [ ] Conditional formatting rules +- [x] Conditional formatting rules (editor in Appearance section) ### P1.8.1 Live Preview — Gap Analysis & Phased Remediation diff --git a/apps/console/src/__tests__/ViewConfigPanel.test.tsx b/apps/console/src/__tests__/ViewConfigPanel.test.tsx index 5ecbe1268..4df1dce60 100644 --- a/apps/console/src/__tests__/ViewConfigPanel.test.tsx +++ b/apps/console/src/__tests__/ViewConfigPanel.test.tsx @@ -442,7 +442,7 @@ describe('ViewConfigPanel', () => { expect(onViewUpdate).toHaveBeenCalledWith('allowExport', true); }); - it('toggles addRecordViaForm via Switch', () => { + it('toggles addRecord enabled via Switch', () => { const onViewUpdate = vi.fn(); render( { /> ); - const formSwitch = screen.getByTestId('toggle-addRecordViaForm'); + const formSwitch = screen.getByTestId('toggle-addRecord-enabled'); fireEvent.click(formSwitch); expect(onViewUpdate).toHaveBeenCalledWith('addRecordViaForm', true); + expect(onViewUpdate).toHaveBeenCalledWith('addRecord', expect.objectContaining({ enabled: true })); }); it('edits view title via inline input', () => { @@ -670,7 +671,7 @@ describe('ViewConfigPanel', () => { ); @@ -679,7 +680,7 @@ describe('ViewConfigPanel', () => { expect(screen.getByTestId('toggle-showFilters')).toHaveAttribute('aria-checked', 'true'); expect(screen.getByTestId('toggle-showSort')).toHaveAttribute('aria-checked', 'false'); expect(screen.getByTestId('toggle-allowExport')).toHaveAttribute('aria-checked', 'false'); - expect(screen.getByTestId('toggle-addRecordViaForm')).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByTestId('toggle-addRecord-enabled')).toHaveAttribute('aria-checked', 'true'); }); // ── Real-time draft propagation tests (issue fix) ── @@ -1197,7 +1198,7 @@ describe('ViewConfigPanel', () => { // ── User actions fields tests ── - it('renders new user action fields: editRecordsInline, addDeleteRecordsInline, clickIntoRecordDetails', () => { + it('renders new user action fields: editRecordsInline, addDeleteRecordsInline, and navigation mode select', () => { render( { expect(screen.getByTestId('toggle-editRecordsInline')).toBeInTheDocument(); expect(screen.getByTestId('toggle-addDeleteRecordsInline')).toBeInTheDocument(); - expect(screen.getByTestId('toggle-clickIntoRecordDetails')).toBeInTheDocument(); + expect(screen.getByTestId('select-navigation-mode')).toBeInTheDocument(); }); - it('toggles editRecordsInline via Switch', () => { + it('toggles editRecordsInline via Switch (maps to inlineEdit)', () => { const onViewUpdate = vi.fn(); render( { ); fireEvent.click(screen.getByTestId('toggle-editRecordsInline')); - expect(onViewUpdate).toHaveBeenCalledWith('editRecordsInline', false); + expect(onViewUpdate).toHaveBeenCalledWith('inlineEdit', false); }); // ── Data section: Group by and Prefix field tests ── @@ -1482,7 +1483,7 @@ describe('ViewConfigPanel', () => { // ── Section Layout Tests: Page vs ListView Config ── - it('renders page-level config items in the Page section (showSearch, showFilters, showSort, clickIntoRecordDetails, addRecordViaForm, allowExport)', () => { + it('renders page-level config items in the Page section (showSearch, showFilters, showSort, navigation, addRecord, allowExport)', () => { render( { expect(screen.getByTestId('toggle-showSearch')).toBeInTheDocument(); expect(screen.getByTestId('toggle-showFilters')).toBeInTheDocument(); expect(screen.getByTestId('toggle-showSort')).toBeInTheDocument(); - expect(screen.getByTestId('toggle-clickIntoRecordDetails')).toBeInTheDocument(); - expect(screen.getByTestId('toggle-addRecordViaForm')).toBeInTheDocument(); + expect(screen.getByTestId('select-navigation-mode')).toBeInTheDocument(); + expect(screen.getByTestId('toggle-addRecord-enabled')).toBeInTheDocument(); expect(screen.getByTestId('toggle-allowExport')).toBeInTheDocument(); }); @@ -1558,12 +1559,12 @@ describe('ViewConfigPanel', () => { fireEvent.click(screen.getByTestId('toggle-showSort')); expect(onViewUpdate).toHaveBeenCalledWith('showSort', false); - // Toggle clickIntoRecordDetails off - fireEvent.click(screen.getByTestId('toggle-clickIntoRecordDetails')); - expect(onViewUpdate).toHaveBeenCalledWith('clickIntoRecordDetails', false); + // Change navigation mode + fireEvent.change(screen.getByTestId('select-navigation-mode'), { target: { value: 'none' } }); + expect(onViewUpdate).toHaveBeenCalledWith('navigation', expect.objectContaining({ mode: 'none' })); - // Toggle addRecordViaForm on - fireEvent.click(screen.getByTestId('toggle-addRecordViaForm')); + // Toggle addRecord enabled on + fireEvent.click(screen.getByTestId('toggle-addRecord-enabled')); expect(onViewUpdate).toHaveBeenCalledWith('addRecordViaForm', true); // Toggle allowExport on (starts unchecked by default) @@ -1643,4 +1644,500 @@ describe('ViewConfigPanel', () => { fireEvent.click(screen.getByTestId('toggle-bordered')); expect(onViewUpdate).toHaveBeenCalledWith('bordered', true); }); + + // ── New spec controls tests (P0/P1/P2) ── + + it('renders navigation mode select with default value "page"', () => { + render( + + ); + + const navSelect = screen.getByTestId('select-navigation-mode'); + expect(navSelect).toBeInTheDocument(); + expect(navSelect).toHaveValue('page'); + }); + + it('shows navigation width input when mode is drawer', () => { + const onViewUpdate = vi.fn(); + render( + + ); + + expect(screen.getByTestId('input-navigation-width')).toBeInTheDocument(); + }); + + it('shows openNewTab toggle when navigation mode is page', () => { + render( + + ); + + expect(screen.getByTestId('toggle-navigation-openNewTab')).toBeInTheDocument(); + }); + + it('changes navigation mode and syncs clickIntoRecordDetails', () => { + const onViewUpdate = vi.fn(); + render( + + ); + + fireEvent.change(screen.getByTestId('select-navigation-mode'), { target: { value: 'none' } }); + expect(onViewUpdate).toHaveBeenCalledWith('navigation', expect.objectContaining({ mode: 'none' })); + expect(onViewUpdate).toHaveBeenCalledWith('clickIntoRecordDetails', false); + }); + + it('renders selection type select', () => { + render( + + ); + + expect(screen.getByTestId('select-selection-type')).toBeInTheDocument(); + }); + + it('changes selection type and calls onViewUpdate', () => { + const onViewUpdate = vi.fn(); + render( + + ); + + fireEvent.change(screen.getByTestId('select-selection-type'), { target: { value: 'single' } }); + expect(onViewUpdate).toHaveBeenCalledWith('selection', { type: 'single' }); + }); + + it('renders pagination pageSize input', () => { + render( + + ); + + expect(screen.getByTestId('input-pagination-pageSize')).toBeInTheDocument(); + }); + + it('updates pagination pageSize via input', () => { + const onViewUpdate = vi.fn(); + render( + + ); + + fireEvent.change(screen.getByTestId('input-pagination-pageSize'), { target: { value: '50' } }); + expect(onViewUpdate).toHaveBeenCalledWith('pagination', expect.objectContaining({ pageSize: 50 })); + }); + + it('shows export sub-config when allowExport is true', () => { + render( + + ); + + expect(screen.getByTestId('export-formats')).toBeInTheDocument(); + expect(screen.getByTestId('input-export-maxRecords')).toBeInTheDocument(); + expect(screen.getByTestId('toggle-export-includeHeaders')).toBeInTheDocument(); + expect(screen.getByTestId('input-export-fileNamePrefix')).toBeInTheDocument(); + }); + + it('renders showRecordCount toggle', () => { + render( + + ); + + expect(screen.getByTestId('toggle-showRecordCount')).toBeInTheDocument(); + }); + + it('renders allowPrinting toggle', () => { + render( + + ); + + expect(screen.getByTestId('toggle-allowPrinting')).toBeInTheDocument(); + }); + + it('renders searchable fields selector when expanded', () => { + render( + + ); + + // Click to expand searchable fields + fireEvent.click(screen.getByText('console.objectView.searchableFields')); + expect(screen.getByTestId('searchable-fields-selector')).toBeInTheDocument(); + expect(screen.getByTestId('searchable-field-name')).toBeInTheDocument(); + }); + + it('updates searchableFields when field checkbox is toggled', () => { + const onViewUpdate = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText('console.objectView.searchableFields')); + fireEvent.click(screen.getByTestId('searchable-field-name')); + expect(onViewUpdate).toHaveBeenCalledWith('searchableFields', ['name']); + }); + + it('renders filterable fields selector when expanded', () => { + render( + + ); + + fireEvent.click(screen.getByText('console.objectView.filterableFields')); + expect(screen.getByTestId('filterable-fields-selector')).toBeInTheDocument(); + }); + + it('renders hidden fields selector when expanded', () => { + render( + + ); + + fireEvent.click(screen.getByText('console.objectView.hiddenFields')); + expect(screen.getByTestId('hidden-fields-selector')).toBeInTheDocument(); + }); + + it('renders virtualScroll toggle in data section', () => { + render( + + ); + + expect(screen.getByTestId('toggle-virtualScroll')).toBeInTheDocument(); + }); + + it('renders resizable toggle in appearance section', () => { + render( + + ); + + expect(screen.getByTestId('toggle-resizable')).toBeInTheDocument(); + }); + + it('toggles resizable and calls onViewUpdate', () => { + const onViewUpdate = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByTestId('toggle-resizable')); + 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'); + }); + + it('renders conditional formatting editor when expanded', () => { + render( + + ); + + fireEvent.click(screen.getByText('console.objectView.conditionalFormatting')); + expect(screen.getByTestId('conditional-formatting-editor')).toBeInTheDocument(); + expect(screen.getByTestId('add-conditional-rule')).toBeInTheDocument(); + }); + + it('adds a conditional formatting rule', () => { + const onViewUpdate = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText('console.objectView.conditionalFormatting')); + fireEvent.click(screen.getByTestId('add-conditional-rule')); + expect(onViewUpdate).toHaveBeenCalledWith('conditionalFormatting', expect.arrayContaining([ + expect.objectContaining({ field: '', operator: 'equals', value: '' }), + ])); + }); + + it('renders quick filters editor when expanded', () => { + render( + + ); + + fireEvent.click(screen.getByText('console.objectView.quickFilters')); + expect(screen.getByTestId('quick-filters-editor')).toBeInTheDocument(); + expect(screen.getByTestId('add-quick-filter')).toBeInTheDocument(); + }); + + it('adds a quick filter', () => { + const onViewUpdate = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText('console.objectView.quickFilters')); + fireEvent.click(screen.getByTestId('add-quick-filter')); + expect(onViewUpdate).toHaveBeenCalledWith('quickFilters', expect.arrayContaining([ + expect.objectContaining({ label: '', filters: [], defaultActive: false }), + ])); + }); + + it('renders empty state inputs in appearance section', () => { + render( + + ); + + expect(screen.getByTestId('input-emptyState-title')).toBeInTheDocument(); + expect(screen.getByTestId('input-emptyState-message')).toBeInTheDocument(); + expect(screen.getByTestId('input-emptyState-icon')).toBeInTheDocument(); + }); + + it('renders sharing section with enabled toggle', () => { + render( + + ); + + expect(screen.getByTestId('toggle-sharing-enabled')).toBeInTheDocument(); + }); + + it('shows sharing visibility select when sharing is enabled', () => { + render( + + ); + + expect(screen.getByTestId('select-sharing-visibility')).toBeInTheDocument(); + expect(screen.getByTestId('select-sharing-visibility')).toHaveValue('team'); + }); + + it('toggles sharing enabled and calls onViewUpdate', () => { + const onViewUpdate = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByTestId('toggle-sharing-enabled')); + expect(onViewUpdate).toHaveBeenCalledWith('sharing', expect.objectContaining({ enabled: true })); + }); + + it('renders accessibility section with ARIA inputs', () => { + render( + + ); + + expect(screen.getByTestId('input-aria-label')).toBeInTheDocument(); + expect(screen.getByTestId('input-aria-describedBy')).toBeInTheDocument(); + expect(screen.getByTestId('select-aria-live')).toBeInTheDocument(); + }); + + it('renders addRecord sub-editor when enabled', () => { + render( + + ); + + expect(screen.getByTestId('select-addRecord-position')).toBeInTheDocument(); + expect(screen.getByTestId('select-addRecord-mode')).toBeInTheDocument(); + expect(screen.getByTestId('input-addRecord-formView')).toBeInTheDocument(); + }); + + it('renders row actions selector when expanded', () => { + render( + + ); + + fireEvent.click(screen.getByText('console.objectView.rowActions')); + expect(screen.getByTestId('row-actions-selector')).toBeInTheDocument(); + }); + + it('renders bulk actions selector when expanded', () => { + render( + + ); + + fireEvent.click(screen.getByText('console.objectView.bulkActions')); + expect(screen.getByTestId('bulk-actions-selector')).toBeInTheDocument(); + }); + + it('renders row height buttons with spec-aligned values (compact/medium/tall)', () => { + render( + + ); + + expect(screen.getByTestId('row-height-compact')).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(); + }); }); From dde4a3764d8d96ffad4c13de665985518cc42d61 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:48:16 +0000 Subject: [PATCH 5/5] refactor: address code review - extract helpers, use crypto.randomUUID, enhance test Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/ViewConfigPanel.test.tsx | 10 +++++++++ .../src/components/ViewConfigPanel.tsx | 22 +++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/console/src/__tests__/ViewConfigPanel.test.tsx b/apps/console/src/__tests__/ViewConfigPanel.test.tsx index 4df1dce60..39c7a3d5f 100644 --- a/apps/console/src/__tests__/ViewConfigPanel.test.tsx +++ b/apps/console/src/__tests__/ViewConfigPanel.test.tsx @@ -2124,12 +2124,14 @@ describe('ViewConfigPanel', () => { }); it('renders row height buttons with spec-aligned values (compact/medium/tall)', () => { + const onViewUpdate = vi.fn(); render( ); @@ -2139,5 +2141,13 @@ describe('ViewConfigPanel', () => { // Old values should not exist expect(screen.queryByTestId('row-height-short')).not.toBeInTheDocument(); expect(screen.queryByTestId('row-height-extraTall')).not.toBeInTheDocument(); + + // Click compact and verify update + fireEvent.click(screen.getByTestId('row-height-compact')); + expect(onViewUpdate).toHaveBeenCalledWith('rowHeight', 'compact'); + + // Click tall and verify update + fireEvent.click(screen.getByTestId('row-height-tall')); + expect(onViewUpdate).toHaveBeenCalledWith('rowHeight', 'tall'); }); }); diff --git a/apps/console/src/components/ViewConfigPanel.tsx b/apps/console/src/components/ViewConfigPanel.tsx index f76bd00d4..c1e336693 100644 --- a/apps/console/src/components/ViewConfigPanel.tsx +++ b/apps/console/src/components/ViewConfigPanel.tsx @@ -182,12 +182,22 @@ const VIEW_TYPE_LABELS: Record = { const VIEW_TYPE_OPTIONS = Object.keys(VIEW_TYPE_LABELS); /** Row height options with Tailwind gap classes for visual icons — aligned with spec: compact/medium/tall */ -const ROW_HEIGHT_OPTIONS = [ +const ROW_HEIGHT_OPTIONS: Array<{ value: string; gapClass: string }> = [ { value: 'compact', gapClass: 'gap-0' }, { value: 'medium', gapClass: 'gap-0.5' }, { value: 'tall', gapClass: 'gap-1' }, ]; +/** Parse comma-separated string to trimmed non-empty string array */ +function parseCommaSeparated(input: string): string[] { + return input.split(',').map(s => s.trim()).filter(Boolean); +} + +/** Parse comma-separated string to positive number array */ +function parseNumberList(input: string): number[] { + return input.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n) && n > 0); +} + /** Editor panel types that can be opened from clickable rows */ export type EditorPanelType = 'columns' | 'filter' | 'sort'; @@ -916,7 +926,7 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje value={(draft.pagination?.pageSizeOptions || []).join(', ')} placeholder="10, 25, 50, 100" onChange={(e: React.ChangeEvent) => { - const opts = e.target.value.split(',').map((s: string) => Number(s.trim())).filter((n: number) => !isNaN(n) && n > 0); + const opts = parseNumberList(e.target.value); updateDraft('pagination', { ...(draft.pagination || {}), pageSizeOptions: opts.length ? opts : undefined }); }} /> @@ -1054,7 +1064,7 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje data-testid="add-quick-filter" className="text-xs text-primary hover:underline" onClick={() => { - const newFilter = { id: `qf_${Date.now()}`, label: '', filters: [], defaultActive: false }; + const newFilter = { id: crypto.randomUUID(), label: '', filters: [], defaultActive: false }; updateDraft('quickFilters', [...(draft.quickFilters || []), newFilter]); }} > @@ -1502,8 +1512,7 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje value={(draft.rowActions || []).join(', ')} placeholder="edit, delete, duplicate" onChange={(e: React.ChangeEvent) => { - const actions = e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean); - updateDraft('rowActions', actions); + updateDraft('rowActions', parseCommaSeparated(e.target.value)); }} /> @@ -1522,8 +1531,7 @@ export function ViewConfigPanel({ open, onClose, mode = 'edit', activeView, obje value={(draft.bulkActions || []).join(', ')} placeholder="delete, export, assign" onChange={(e: React.ChangeEvent) => { - const actions = e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean); - updateDraft('bulkActions', actions); + updateDraft('bulkActions', parseCommaSeparated(e.target.value)); }} />