Skip to content

Commit 5ffc82f

Browse files
Copilothotlong
andcommitted
feat: upgrade config panel UI with progressive disclosure, summary control, section icons, subsections, and improved spacing
- Add 'summary' ControlType with summaryText and onSummaryClick support - Add icon and subsections support to ConfigSection - Update SectionHeader to render icons alongside titles - Add summary field renderer with gear button in ConfigFieldRenderer - Increase section spacing (space-y-0.5 → space-y-1, separator my-1 → my-3) - Add subsection rendering in ConfigPanelRenderer - Set defaultCollapsed: true for toolbar, navigation, records, appearance sections - Update ViewConfigPanel and ObjectView tests to expand collapsed sections - Add tests for summary control, section icons, subsections, spacing Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 246c160 commit 5ffc82f

File tree

8 files changed

+249
-5
lines changed

8 files changed

+249
-5
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,7 @@ describe('ObjectView Component', () => {
475475
expect(screen.getByTestId('view-config-panel')).toBeInTheDocument();
476476

477477
// Toggle showSearch off — our mock Switch fires onCheckedChange with opposite of aria-checked
478+
fireEvent.click(screen.getByTestId('section-header-toolbar')); // Expand toolbar (defaultCollapsed)
478479
const searchSwitch = screen.getByTestId('toggle-showSearch');
479480
fireEvent.click(searchSwitch);
480481

@@ -500,6 +501,7 @@ describe('ObjectView Component', () => {
500501
fireEvent.click(screen.getByText('console.objectView.editView'));
501502

502503
// Toggle showSort off
504+
fireEvent.click(screen.getByTestId('section-header-toolbar')); // Expand toolbar (defaultCollapsed)
503505
const sortSwitch = screen.getByTestId('toggle-showSort');
504506
fireEvent.click(sortSwitch);
505507

@@ -643,6 +645,7 @@ describe('ObjectView Component', () => {
643645
fireEvent.click(screen.getByText('console.objectView.editView'));
644646

645647
// Toggle showSearch off
648+
fireEvent.click(screen.getByTestId('section-header-toolbar')); // Expand toolbar (defaultCollapsed)
646649
const searchSwitch = screen.getByTestId('toggle-showSearch');
647650
fireEvent.click(searchSwitch);
648651

@@ -699,6 +702,7 @@ describe('ObjectView Component', () => {
699702
fireEvent.click(screen.getByText('console.objectView.editView'));
700703

701704
// Change selection mode to 'single'
705+
fireEvent.click(screen.getByTestId('section-header-records')); // Expand records (defaultCollapsed)
702706
const selectionSelect = screen.getByTestId('select-selection-type');
703707
fireEvent.change(selectionSelect, { target: { value: 'single' } });
704708

@@ -722,6 +726,7 @@ describe('ObjectView Component', () => {
722726
fireEvent.click(screen.getByText('console.objectView.editView'));
723727

724728
// Toggle addRecord on
729+
fireEvent.click(screen.getByTestId('section-header-records')); // Expand records (defaultCollapsed)
725730
const addRecordSwitch = screen.getByTestId('toggle-addRecord-enabled');
726731
fireEvent.click(addRecordSwitch);
727732

@@ -746,6 +751,7 @@ describe('ObjectView Component', () => {
746751
fireEvent.click(screen.getByText('console.objectView.editView'));
747752

748753
// Change navigation mode to 'modal'
754+
fireEvent.click(screen.getByTestId('section-header-navigation')); // Expand navigation (defaultCollapsed)
749755
const navSelect = screen.getByTestId('select-navigation-mode');
750756
fireEvent.change(navSelect, { target: { value: 'modal' } });
751757

apps/console/src/utils/view-config-schema.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@ function buildToolbarSection(
345345
title: t('console.objectView.toolbar'),
346346
hint: t('console.objectView.toolbarHint'),
347347
collapsible: true,
348+
defaultCollapsed: true,
348349
fields: [
349350
// Toolbar toggles — ordered per spec: showSearch, showSort, showFilters, showHideFields, showGroup, showColor, showDensity
350351
buildSwitchField('showSearch', t('console.objectView.enableSearch'), 'toggle-showSearch', true), // spec: NamedListView.showSearch
@@ -374,6 +375,7 @@ function buildNavigationSection(
374375
title: t('console.objectView.navigationSection'),
375376
hint: t('console.objectView.navigationHint'),
376377
collapsible: true,
378+
defaultCollapsed: true,
377379
fields: [
378380
// spec: NamedListView.navigation — navigation mode/width/openNewTab
379381
{
@@ -470,6 +472,7 @@ function buildRecordsSection(
470472
title: t('console.objectView.records'),
471473
hint: t('console.objectView.recordsHint'),
472474
collapsible: true,
475+
defaultCollapsed: true,
473476
fields: [
474477
// spec: NamedListView.selection — row selection mode
475478
{
@@ -1224,6 +1227,7 @@ function buildAppearanceSection(
12241227
key: 'appearance',
12251228
title: t('console.objectView.appearance'),
12261229
collapsible: true,
1230+
defaultCollapsed: true,
12271231
fields: [
12281232
// spec: NamedListView.striped (grid-only: row striping is a grid concept)
12291233
buildSwitchField('striped', t('console.objectView.striped'), 'toggle-striped', false, true,

packages/components/src/__tests__/config-panel-renderer.test.tsx

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,4 +423,158 @@ describe('ConfigPanelRenderer', () => {
423423
expect((input as HTMLInputElement).disabled).toBe(false);
424424
});
425425
});
426+
427+
describe('section icons', () => {
428+
it('should render section icon when provided', () => {
429+
const React = require('react');
430+
const schemaWithIcon: ConfigPanelSchema = {
431+
breadcrumb: ['Test'],
432+
sections: [
433+
{
434+
key: 'sec',
435+
title: 'Section With Icon',
436+
icon: React.createElement('span', { 'data-testid': 'section-icon' }, '⚙'),
437+
fields: [{ key: 'x', label: 'X', type: 'input' }],
438+
},
439+
],
440+
};
441+
render(<ConfigPanelRenderer {...defaultProps} schema={schemaWithIcon} />);
442+
expect(screen.getByTestId('section-icon')).toBeDefined();
443+
expect(screen.getByText('Section With Icon')).toBeDefined();
444+
});
445+
});
446+
447+
describe('subsections', () => {
448+
it('should render subsections within a section', () => {
449+
const schemaWithSub: ConfigPanelSchema = {
450+
breadcrumb: ['Test'],
451+
sections: [
452+
{
453+
key: 'parent',
454+
title: 'Parent',
455+
fields: [{ key: 'a', label: 'Field A', type: 'input' }],
456+
subsections: [
457+
{
458+
key: 'child',
459+
title: 'Child Section',
460+
fields: [{ key: 'b', label: 'Field B', type: 'input' }],
461+
},
462+
],
463+
},
464+
],
465+
};
466+
render(<ConfigPanelRenderer {...defaultProps} schema={schemaWithSub} />);
467+
expect(screen.getByText('Parent')).toBeDefined();
468+
expect(screen.getByText('Child Section')).toBeDefined();
469+
expect(screen.getByText('Field A')).toBeDefined();
470+
expect(screen.getByText('Field B')).toBeDefined();
471+
expect(screen.getByTestId('config-subsection-child')).toBeDefined();
472+
});
473+
474+
it('should support collapsible subsections', () => {
475+
const schemaWithCollapsibleSub: ConfigPanelSchema = {
476+
breadcrumb: ['Test'],
477+
sections: [
478+
{
479+
key: 'parent',
480+
title: 'Parent',
481+
fields: [{ key: 'a', label: 'Field A', type: 'input' }],
482+
subsections: [
483+
{
484+
key: 'child',
485+
title: 'Child',
486+
collapsible: true,
487+
defaultCollapsed: true,
488+
fields: [{ key: 'b', label: 'Field B', type: 'input' }],
489+
},
490+
],
491+
},
492+
],
493+
};
494+
render(<ConfigPanelRenderer {...defaultProps} schema={schemaWithCollapsibleSub} />);
495+
// Child is defaultCollapsed, so Field B should not be visible
496+
expect(screen.queryByText('Field B')).toBeNull();
497+
// Click to expand
498+
fireEvent.click(screen.getByTestId('section-header-child'));
499+
expect(screen.getByText('Field B')).toBeDefined();
500+
});
501+
});
502+
503+
describe('summary control type', () => {
504+
it('should render summary field with text and gear icon', () => {
505+
const onSummaryClick = vi.fn();
506+
const schemaWithSummary: ConfigPanelSchema = {
507+
breadcrumb: ['Test'],
508+
sections: [
509+
{
510+
key: 'sec',
511+
title: 'Section',
512+
fields: [
513+
{
514+
key: 'viz',
515+
label: 'Visualizations',
516+
type: 'summary',
517+
summaryText: 'List, Gallery, Kanban',
518+
onSummaryClick,
519+
},
520+
],
521+
},
522+
],
523+
};
524+
render(<ConfigPanelRenderer {...defaultProps} schema={schemaWithSummary} />);
525+
expect(screen.getByText('Visualizations')).toBeDefined();
526+
expect(screen.getByTestId('config-field-viz-text')).toBeDefined();
527+
expect(screen.getByText('List, Gallery, Kanban')).toBeDefined();
528+
expect(screen.getByTestId('config-field-viz-gear')).toBeDefined();
529+
});
530+
531+
it('should call onSummaryClick when summary row is clicked', () => {
532+
const onSummaryClick = vi.fn();
533+
const schemaWithSummary: ConfigPanelSchema = {
534+
breadcrumb: ['Test'],
535+
sections: [
536+
{
537+
key: 'sec',
538+
title: 'Section',
539+
fields: [
540+
{
541+
key: 'viz',
542+
label: 'Viz',
543+
type: 'summary',
544+
summaryText: 'Items',
545+
onSummaryClick,
546+
},
547+
],
548+
},
549+
],
550+
};
551+
render(<ConfigPanelRenderer {...defaultProps} schema={schemaWithSummary} />);
552+
// The ConfigRow wraps in a button when onClick is provided
553+
const row = screen.getByText('Viz').closest('button');
554+
if (row) fireEvent.click(row);
555+
expect(onSummaryClick).toHaveBeenCalledTimes(1);
556+
});
557+
});
558+
559+
describe('increased section spacing', () => {
560+
it('should use space-y-1 for field spacing within sections', () => {
561+
render(<ConfigPanelRenderer {...defaultProps} schema={basicSchema} />);
562+
const section = screen.getByTestId('config-section-basic');
563+
const fieldContainer = section.querySelector('.space-y-1');
564+
expect(fieldContainer).not.toBeNull();
565+
});
566+
567+
it('should use my-3 separator between sections', () => {
568+
render(
569+
<ConfigPanelRenderer
570+
{...defaultProps}
571+
schema={collapsibleSchema}
572+
draft={{ columns: '3', theme: 'dark', source: 'api' }}
573+
/>,
574+
);
575+
const panel = screen.getByTestId('config-panel');
576+
const separators = panel.querySelectorAll('.my-3');
577+
expect(separators.length).toBeGreaterThan(0);
578+
});
579+
});
426580
});

packages/components/src/__tests__/config-primitives.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,16 @@ describe('SectionHeader', () => {
9191
const element = screen.getByTestId('section');
9292
expect(element.className).toContain('custom-class');
9393
});
94+
95+
it('should render icon when provided', () => {
96+
render(<SectionHeader title="Data" icon={<span data-testid="icon">📊</span>} testId="section" />);
97+
expect(screen.getByTestId('icon')).toBeDefined();
98+
expect(screen.getByText('Data')).toBeDefined();
99+
});
100+
101+
it('should render icon alongside collapsible title', () => {
102+
render(<SectionHeader title="Data" icon={<span data-testid="icon">📊</span>} collapsible testId="section" />);
103+
expect(screen.getByTestId('icon')).toBeDefined();
104+
expect(screen.getByTestId('section').tagName).toBe('BUTTON');
105+
});
94106
});

packages/components/src/custom/config-field-renderer.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import * as React from 'react';
10+
import { Settings } from 'lucide-react';
1011
import { Input } from '../ui/input';
1112
import { Switch } from '../ui/switch';
1213
import { Checkbox } from '../ui/checkbox';
@@ -237,6 +238,24 @@ export function ConfigFieldRenderer({
237238
}
238239
break;
239240

241+
case 'summary':
242+
content = (
243+
<ConfigRow
244+
label={field.label}
245+
onClick={field.onSummaryClick}
246+
>
247+
<div className="flex items-center gap-1.5">
248+
<span className="text-xs text-foreground truncate max-w-[120px]" data-testid={`config-field-${field.key}-text`}>
249+
{field.summaryText ?? effectiveValue ?? ''}
250+
</span>
251+
{field.onSummaryClick && (
252+
<Settings className="h-3.5 w-3.5 text-muted-foreground shrink-0" data-testid={`config-field-${field.key}-gear`} />
253+
)}
254+
</div>
255+
</ConfigRow>
256+
);
257+
break;
258+
240259
default:
241260
break;
242261
}

packages/components/src/custom/config-panel-renderer.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,10 @@ export function ConfigPanelRenderer({
219219

220220
return (
221221
<div key={section.key} data-testid={`config-section-${section.key}`}>
222-
{sectionIdx > 0 && <Separator className="my-1" />}
222+
{sectionIdx > 0 && <Separator className="my-3" />}
223223
<SectionHeader
224224
title={section.title}
225+
icon={section.icon}
225226
collapsible={section.collapsible}
226227
collapsed={sectionCollapsed}
227228
onToggle={() => toggleCollapse(section.key, section.defaultCollapsed)}
@@ -233,7 +234,7 @@ export function ConfigPanelRenderer({
233234
</p>
234235
)}
235236
{!sectionCollapsed && (
236-
<div className="space-y-0.5">
237+
<div className="space-y-1">
237238
{section.fields.map((field) => (
238239
<ConfigFieldRenderer
239240
key={field.key}
@@ -244,6 +245,37 @@ export function ConfigPanelRenderer({
244245
objectDef={objectDef}
245246
/>
246247
))}
248+
{section.subsections?.map((sub) => {
249+
if (sub.visibleWhen && !sub.visibleWhen(draft)) return null;
250+
const subCollapsed = isCollapsed(sub.key, sub.defaultCollapsed);
251+
return (
252+
<div key={sub.key} data-testid={`config-subsection-${sub.key}`} className="ml-1">
253+
<SectionHeader
254+
title={sub.title}
255+
icon={sub.icon}
256+
collapsible={sub.collapsible}
257+
collapsed={subCollapsed}
258+
onToggle={() => toggleCollapse(sub.key, sub.defaultCollapsed)}
259+
testId={`section-header-${sub.key}`}
260+
className="pt-2 pb-1"
261+
/>
262+
{!subCollapsed && (
263+
<div className="space-y-1">
264+
{sub.fields.map((field) => (
265+
<ConfigFieldRenderer
266+
key={field.key}
267+
field={field}
268+
value={draft[field.key]}
269+
onChange={(v) => onFieldChange(field.key, v)}
270+
draft={draft}
271+
objectDef={objectDef}
272+
/>
273+
))}
274+
</div>
275+
)}
276+
</div>
277+
);
278+
})}
247279
</div>
248280
)}
249281
</div>

packages/components/src/custom/section-header.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { cn } from "../lib/utils"
1313
export interface SectionHeaderProps {
1414
/** Section heading text */
1515
title: string
16+
/** Icon rendered before the title */
17+
icon?: React.ReactNode
1618
/** Enable collapse/expand toggle */
1719
collapsible?: boolean
1820
/** Current collapsed state */
@@ -31,7 +33,13 @@ export interface SectionHeaderProps {
3133
* Renders as a `<button>` when collapsible, with a chevron icon
3234
* indicating the expand/collapse state. Uses `aria-expanded` for accessibility.
3335
*/
34-
function SectionHeader({ title, collapsible, collapsed, onToggle, testId, className }: SectionHeaderProps) {
36+
function SectionHeader({ title, icon, collapsible, collapsed, onToggle, testId, className }: SectionHeaderProps) {
37+
const titleContent = (
38+
<h3 className="text-xs font-semibold text-foreground uppercase tracking-wider flex items-center gap-1.5">
39+
{icon && <span className="text-muted-foreground shrink-0">{icon}</span>}
40+
{title}
41+
</h3>
42+
)
3543
if (collapsible) {
3644
return (
3745
<button
@@ -41,7 +49,7 @@ function SectionHeader({ title, collapsible, collapsed, onToggle, testId, classN
4149
type="button"
4250
aria-expanded={!collapsed}
4351
>
44-
<h3 className="text-xs font-semibold text-foreground uppercase tracking-wider">{title}</h3>
52+
{titleContent}
4553
{collapsed ? (
4654
<ChevronRight className="h-3 w-3 text-muted-foreground" />
4755
) : (
@@ -52,7 +60,7 @@ function SectionHeader({ title, collapsible, collapsed, onToggle, testId, classN
5260
}
5361
return (
5462
<div className={cn("pt-4 pb-1.5 first:pt-0", className)} data-testid={testId}>
55-
<h3 className="text-xs font-semibold text-foreground uppercase tracking-wider">{title}</h3>
63+
{titleContent}
5664
</div>
5765
)
5866
}

0 commit comments

Comments
 (0)