Skip to content

Commit 8ad9e41

Browse files
authored
Merge pull request #763 from objectstack-ai/copilot/fix-configpanel-ui-spec
2 parents d2833c5 + 636fcd6 commit 8ad9e41

5 files changed

Lines changed: 624 additions & 9 deletions

File tree

ROADMAP.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
205205
-`ListViewSchema` Zod schema extended with all new properties
206206
- ✅ 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
207207
- ✅ Semantic fix: `editRecordsInline``inlineEdit` field name alignment (i18n keys, data-testid, component label all unified to `inlineEdit`)
208-
- ✅ Semantic fix: `rowHeight` values aligned to full spec — all 5 RowHeight enum values (`compact`/`short`/`medium`/`tall`/`extra_tall`) now supported in NamedListView, ObjectGridSchema, ListViewSchema, Zod schema, and UI
208+
- ✅ Semantic fix: `rowHeight` values aligned to full spec — all 5 RowHeight enum values (`compact`/`short`/`medium`/`tall`/`extra_tall`) now supported in NamedListView, ObjectGridSchema, ListViewSchema, Zod schema, UI, and ObjectGrid rendering (cell classes, cycle toggle, icon mapping)
209209
-`clickIntoRecordDetails` toggle added to UserActions section (NamedListView spec field — previously only implicit via navigation mode)
210210
-**Strict spec-order alignment**: All fields within each section reordered to match NamedListView property declaration order:
211211
- PageConfig: showSort before showFilters; allowExport before navigation (per spec)
@@ -382,7 +382,14 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
382382
- [x] All 122 existing ViewConfigPanel tests pass (test mock updated for ConfigPanelRenderer + useConfigDraft)
383383
- [x] All 23 ObjectView integration tests pass (test ID and title props forwarded)
384384
- [x] 53 new schema-driven tests (utils + schema factory coverage)
385-
- [x] Full affected test suite: 2457 tests across 81 files, all pass
385+
- [x] 14 new ObjectGrid rowHeight tests (all 5 enum values: initialization, cycle, label, toggle visibility)
386+
- [x] Full affected test suite: 2457+ tests across 81+ files, all pass
387+
388+
**Phase 5 — Spec Alignment Completion (Issue #745):**
389+
- [x] ObjectGrid rowHeight: full 5-enum rendering (cellClassName, cycleRowHeight, icon map) — was hardcoded to 3
390+
- [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
391+
- [x] 8 new schema-driven spec tests: accessibility field ordering, emptyState compound field, switch field defaults, comprehensive visibleWhen predicates (sharing, navigation width, navigation openNewTab)
392+
- [x] All spec fields verified: Appearance/UserActions/Sharing/Accessibility sections 100% covered with UI controls, defaults, ordering, and conditional visibility
386393

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

apps/console/src/__tests__/ViewConfigPanel.test.tsx

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2433,4 +2433,341 @@ describe('ViewConfigPanel', () => {
24332433
fireEvent.click(screen.getByTestId('toggle-virtualScroll'));
24342434
expect(onViewUpdate).toHaveBeenCalledWith('virtualScroll', true);
24352435
});
2436+
2437+
// ── Spec alignment: toggle interaction tests for all switch fields ──
2438+
2439+
it('toggles collapseAllByDefault and calls onViewUpdate', () => {
2440+
const onViewUpdate = vi.fn();
2441+
render(
2442+
<ViewConfigPanel
2443+
open={true}
2444+
onClose={vi.fn()}
2445+
activeView={mockActiveView}
2446+
objectDef={mockObjectDef}
2447+
onViewUpdate={onViewUpdate}
2448+
/>
2449+
);
2450+
2451+
fireEvent.click(screen.getByTestId('toggle-collapseAllByDefault'));
2452+
expect(onViewUpdate).toHaveBeenCalledWith('collapseAllByDefault', true);
2453+
});
2454+
2455+
it('toggles showDescription and calls onViewUpdate', () => {
2456+
const onViewUpdate = vi.fn();
2457+
render(
2458+
<ViewConfigPanel
2459+
open={true}
2460+
onClose={vi.fn()}
2461+
activeView={mockActiveView}
2462+
objectDef={mockObjectDef}
2463+
onViewUpdate={onViewUpdate}
2464+
/>
2465+
);
2466+
2467+
fireEvent.click(screen.getByTestId('toggle-showDescription'));
2468+
expect(onViewUpdate).toHaveBeenCalledWith('showDescription', false);
2469+
});
2470+
2471+
it('toggles clickIntoRecordDetails and calls onViewUpdate', () => {
2472+
const onViewUpdate = vi.fn();
2473+
render(
2474+
<ViewConfigPanel
2475+
open={true}
2476+
onClose={vi.fn()}
2477+
activeView={mockActiveView}
2478+
objectDef={mockObjectDef}
2479+
onViewUpdate={onViewUpdate}
2480+
/>
2481+
);
2482+
2483+
fireEvent.click(screen.getByTestId('toggle-clickIntoRecordDetails'));
2484+
expect(onViewUpdate).toHaveBeenCalledWith('clickIntoRecordDetails', false);
2485+
});
2486+
2487+
it('toggles addDeleteRecordsInline and calls onViewUpdate', () => {
2488+
const onViewUpdate = vi.fn();
2489+
render(
2490+
<ViewConfigPanel
2491+
open={true}
2492+
onClose={vi.fn()}
2493+
activeView={mockActiveView}
2494+
objectDef={mockObjectDef}
2495+
onViewUpdate={onViewUpdate}
2496+
/>
2497+
);
2498+
2499+
fireEvent.click(screen.getByTestId('toggle-addDeleteRecordsInline'));
2500+
expect(onViewUpdate).toHaveBeenCalledWith('addDeleteRecordsInline', false);
2501+
});
2502+
2503+
// ── Conditional rendering: sharing visibility hidden when disabled ──
2504+
2505+
it('hides sharing visibility select when sharing is not enabled', () => {
2506+
render(
2507+
<ViewConfigPanel
2508+
open={true}
2509+
onClose={vi.fn()}
2510+
activeView={{ ...mockActiveView, sharing: { enabled: false } }}
2511+
objectDef={mockObjectDef}
2512+
/>
2513+
);
2514+
2515+
expect(screen.getByTestId('toggle-sharing-enabled')).toBeInTheDocument();
2516+
expect(screen.queryByTestId('select-sharing-visibility')).not.toBeInTheDocument();
2517+
});
2518+
2519+
it('hides sharing visibility select when sharing is undefined', () => {
2520+
render(
2521+
<ViewConfigPanel
2522+
open={true}
2523+
onClose={vi.fn()}
2524+
activeView={mockActiveView}
2525+
objectDef={mockObjectDef}
2526+
/>
2527+
);
2528+
2529+
expect(screen.queryByTestId('select-sharing-visibility')).not.toBeInTheDocument();
2530+
});
2531+
2532+
// ── Conditional rendering: navigation width hidden when mode is page ──
2533+
2534+
it('hides navigation width when mode is page', () => {
2535+
render(
2536+
<ViewConfigPanel
2537+
open={true}
2538+
onClose={vi.fn()}
2539+
activeView={{ ...mockActiveView, navigation: { mode: 'page' } }}
2540+
objectDef={mockObjectDef}
2541+
/>
2542+
);
2543+
2544+
expect(screen.queryByTestId('input-navigation-width')).not.toBeInTheDocument();
2545+
});
2546+
2547+
it('hides navigation openNewTab when mode is drawer', () => {
2548+
render(
2549+
<ViewConfigPanel
2550+
open={true}
2551+
onClose={vi.fn()}
2552+
activeView={{ ...mockActiveView, navigation: { mode: 'drawer' } }}
2553+
objectDef={mockObjectDef}
2554+
/>
2555+
);
2556+
2557+
expect(screen.queryByTestId('toggle-navigation-openNewTab')).not.toBeInTheDocument();
2558+
});
2559+
2560+
// ── All 5 rowHeight buttons: click each value ──
2561+
2562+
it('clicks all 5 rowHeight buttons and verifies onViewUpdate for each', () => {
2563+
const onViewUpdate = vi.fn();
2564+
render(
2565+
<ViewConfigPanel
2566+
open={true}
2567+
onClose={vi.fn()}
2568+
activeView={mockActiveView}
2569+
objectDef={mockObjectDef}
2570+
onViewUpdate={onViewUpdate}
2571+
/>
2572+
);
2573+
2574+
const heights = ['compact', 'short', 'medium', 'tall', 'extra_tall'];
2575+
heights.forEach((h) => {
2576+
fireEvent.click(screen.getByTestId(`row-height-${h}`));
2577+
expect(onViewUpdate).toHaveBeenCalledWith('rowHeight', h);
2578+
});
2579+
expect(onViewUpdate).toHaveBeenCalledTimes(heights.length);
2580+
});
2581+
2582+
// ── Boundary: empty actions input ──
2583+
2584+
it('handles empty bulkActions input gracefully', () => {
2585+
const onViewUpdate = vi.fn();
2586+
render(
2587+
<ViewConfigPanel
2588+
open={true}
2589+
onClose={vi.fn()}
2590+
activeView={{ ...mockActiveView, bulkActions: ['delete'] }}
2591+
objectDef={mockObjectDef}
2592+
onViewUpdate={onViewUpdate}
2593+
/>
2594+
);
2595+
2596+
fireEvent.click(screen.getByText('console.objectView.bulkActions'));
2597+
fireEvent.change(screen.getByTestId('input-bulkActions'), { target: { value: '' } });
2598+
expect(onViewUpdate).toHaveBeenCalledWith('bulkActions', []);
2599+
});
2600+
2601+
it('handles empty rowActions input gracefully', () => {
2602+
const onViewUpdate = vi.fn();
2603+
render(
2604+
<ViewConfigPanel
2605+
open={true}
2606+
onClose={vi.fn()}
2607+
activeView={{ ...mockActiveView, rowActions: ['edit'] }}
2608+
objectDef={mockObjectDef}
2609+
onViewUpdate={onViewUpdate}
2610+
/>
2611+
);
2612+
2613+
fireEvent.click(screen.getByText('console.objectView.rowActions'));
2614+
fireEvent.change(screen.getByTestId('input-rowActions'), { target: { value: '' } });
2615+
expect(onViewUpdate).toHaveBeenCalledWith('rowActions', []);
2616+
});
2617+
2618+
// ── Boundary: long label in title input ──
2619+
2620+
it('handles long label value in view title input', () => {
2621+
const longLabel = 'A'.repeat(200);
2622+
const onViewUpdate = vi.fn();
2623+
render(
2624+
<ViewConfigPanel
2625+
open={true}
2626+
onClose={vi.fn()}
2627+
activeView={{ ...mockActiveView, label: longLabel }}
2628+
objectDef={mockObjectDef}
2629+
onViewUpdate={onViewUpdate}
2630+
/>
2631+
);
2632+
2633+
expect(screen.getByTestId('view-title-input')).toHaveValue(longLabel);
2634+
});
2635+
2636+
// ── Boundary: special characters in emptyState fields ──
2637+
2638+
it('handles special characters in emptyState fields', () => {
2639+
const onViewUpdate = vi.fn();
2640+
render(
2641+
<ViewConfigPanel
2642+
open={true}
2643+
onClose={vi.fn()}
2644+
activeView={mockActiveView}
2645+
objectDef={mockObjectDef}
2646+
onViewUpdate={onViewUpdate}
2647+
/>
2648+
);
2649+
2650+
fireEvent.change(screen.getByTestId('input-emptyState-title'), { target: { value: '<script>alert("xss")</script>' } });
2651+
expect(onViewUpdate).toHaveBeenCalledWith('emptyState', expect.objectContaining({
2652+
title: '<script>alert("xss")</script>',
2653+
}));
2654+
});
2655+
2656+
// ── pageSizeOptions input interaction ──
2657+
2658+
it('updates pageSizeOptions via input', () => {
2659+
const onViewUpdate = vi.fn();
2660+
render(
2661+
<ViewConfigPanel
2662+
open={true}
2663+
onClose={vi.fn()}
2664+
activeView={mockActiveView}
2665+
objectDef={mockObjectDef}
2666+
onViewUpdate={onViewUpdate}
2667+
/>
2668+
);
2669+
2670+
const input = screen.getByTestId('input-pagination-pageSizeOptions');
2671+
fireEvent.change(input, { target: { value: '10, 25, 50, 100' } });
2672+
expect(onViewUpdate).toHaveBeenCalledWith('pagination', expect.objectContaining({
2673+
pageSizeOptions: [10, 25, 50, 100],
2674+
}));
2675+
});
2676+
2677+
it('filters invalid pageSizeOptions values (non-positive, NaN)', () => {
2678+
const onViewUpdate = vi.fn();
2679+
render(
2680+
<ViewConfigPanel
2681+
open={true}
2682+
onClose={vi.fn()}
2683+
activeView={mockActiveView}
2684+
objectDef={mockObjectDef}
2685+
onViewUpdate={onViewUpdate}
2686+
/>
2687+
);
2688+
2689+
const input = screen.getByTestId('input-pagination-pageSizeOptions');
2690+
fireEvent.change(input, { target: { value: 'abc, 50, -10, 0' } });
2691+
expect(onViewUpdate).toHaveBeenCalledWith('pagination', expect.objectContaining({
2692+
pageSizeOptions: [50],
2693+
}));
2694+
});
2695+
2696+
// ── Boundary: densityMode enum selection ──
2697+
2698+
it('changes densityMode to all enum values', () => {
2699+
const onViewUpdate = vi.fn();
2700+
render(
2701+
<ViewConfigPanel
2702+
open={true}
2703+
onClose={vi.fn()}
2704+
activeView={mockActiveView}
2705+
objectDef={mockObjectDef}
2706+
onViewUpdate={onViewUpdate}
2707+
/>
2708+
);
2709+
2710+
const select = screen.getByTestId('select-densityMode');
2711+
['compact', 'comfortable', 'spacious'].forEach((mode) => {
2712+
fireEvent.change(select, { target: { value: mode } });
2713+
expect(onViewUpdate).toHaveBeenCalledWith('densityMode', mode);
2714+
});
2715+
});
2716+
2717+
// ── Conditional rendering: addRecord sub-editor hidden when not enabled ──
2718+
2719+
it('hides addRecord sub-editor when addRecordViaForm is false', () => {
2720+
render(
2721+
<ViewConfigPanel
2722+
open={true}
2723+
onClose={vi.fn()}
2724+
activeView={{ ...mockActiveView, addRecordViaForm: false }}
2725+
objectDef={mockObjectDef}
2726+
/>
2727+
);
2728+
2729+
expect(screen.queryByTestId('select-addRecord-position')).not.toBeInTheDocument();
2730+
});
2731+
2732+
// ── Sharing visibility select changes value ──
2733+
2734+
it('changes sharing visibility and calls onViewUpdate', () => {
2735+
const onViewUpdate = vi.fn();
2736+
render(
2737+
<ViewConfigPanel
2738+
open={true}
2739+
onClose={vi.fn()}
2740+
activeView={{ ...mockActiveView, sharing: { enabled: true, visibility: 'private' } }}
2741+
objectDef={mockObjectDef}
2742+
onViewUpdate={onViewUpdate}
2743+
/>
2744+
);
2745+
2746+
fireEvent.change(screen.getByTestId('select-sharing-visibility'), { target: { value: 'organization' } });
2747+
expect(onViewUpdate).toHaveBeenCalledWith('sharing', expect.objectContaining({
2748+
enabled: true,
2749+
visibility: 'organization',
2750+
}));
2751+
});
2752+
2753+
// ── ARIA live select enum ──
2754+
2755+
it('changes ARIA live to all enum values', () => {
2756+
const onViewUpdate = vi.fn();
2757+
render(
2758+
<ViewConfigPanel
2759+
open={true}
2760+
onClose={vi.fn()}
2761+
activeView={mockActiveView}
2762+
objectDef={mockObjectDef}
2763+
onViewUpdate={onViewUpdate}
2764+
/>
2765+
);
2766+
2767+
const select = screen.getByTestId('select-aria-live');
2768+
['polite', 'assertive', 'off'].forEach((mode) => {
2769+
fireEvent.change(select, { target: { value: mode } });
2770+
expect(onViewUpdate).toHaveBeenCalledWith('aria', expect.objectContaining({ live: mode }));
2771+
});
2772+
});
24362773
});

0 commit comments

Comments
 (0)