Skip to content

Commit 19d933f

Browse files
authored
Merge pull request #922 from objectstack-ai/copilot/optimize-detail-view-rendering
2 parents 6c27621 + 96137df commit 19d933f

File tree

14 files changed

+582
-53
lines changed

14 files changed

+582
-53
lines changed

ROADMAP.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ObjectUI Development Roadmap
22

3-
> **Last Updated:** February 27, 2026
3+
> **Last Updated:** March 1, 2026
44
> **Current Version:** v0.5.x
55
> **Spec Version:** @objectstack/spec v3.0.10
66
> **Client Version:** @objectstack/client v3.0.10
@@ -1272,6 +1272,42 @@ All 313 `@object-ui/fields` tests pass.
12721272

12731273
---
12741274

1275+
### DetailView Rendering Optimization (March 2026)
1276+
1277+
> Platform-level DetailView enhancements: auto-grouping from form sections, empty value hiding, smart header with primaryField/summaryFields, responsive breakpoint fix, and activity timeline collapse.
1278+
1279+
**Types (`@object-ui/types`):**
1280+
- [x] `DetailViewSection.hideEmpty?: boolean` — filter null/undefined/empty string fields; hide empty sections
1281+
- [x] `DetailViewSchema.primaryField?: string` — record-level title from data field
1282+
- [x] `DetailViewSchema.summaryFields?: string[]` — render key attributes as Badge in header
1283+
1284+
**DetailSection (`@object-ui/plugin-detail`):**
1285+
- [x] `hideEmpty` filtering: fields with null/undefined/empty string values are removed; section returns null when all fields hidden
1286+
- [x] Responsive breakpoint fix: `sm:grid-cols-2``md:grid-cols-2`, `sm:grid-cols-2 md:grid-cols-3``md:grid-cols-2 lg:grid-cols-3` (correct behavior on iPad+sidebar)
1287+
1288+
**DetailView Header (`@object-ui/plugin-detail`):**
1289+
- [x] Header renders `data[primaryField]` as h1 title (falls back to `schema.title`)
1290+
- [x] `summaryFields` rendered as `<Badge variant="secondary">` next to title
1291+
1292+
**RecordActivityTimeline (`@object-ui/plugin-detail`):**
1293+
- [x] `collapseWhenEmpty` prop: suppress "No activity recorded" message when true, showing only comment input
1294+
1295+
**RecordDetailView (`apps/console`):**
1296+
- [x] Read `objectDef.views?.form?.sections` for section grouping; fallback to flat field list
1297+
- [x] Remove `columns: 2` hardcode — let `autoLayout` infer optimal columns
1298+
- [x] Auto-detect `primaryField` from object fields (name/title)
1299+
1300+
**Field Renderers (`@object-ui/fields`):**
1301+
- [x] `EmailCellRenderer`: mailto link + hover copy-to-clipboard button
1302+
- [x] `PhoneCellRenderer`: tel link with call icon + hover copy-to-clipboard button
1303+
- [x] `BooleanCellRenderer`: warning Badge for active/enabled/verified fields when false (e.g. "Active — Off")
1304+
1305+
**Tests:** 94 plugin-detail tests passing (11 new), 100 field renderer tests passing (12 new) covering hideEmpty filtering, empty section hiding, primaryField/summaryFields rendering, responsive breakpoints, collapseWhenEmpty, autoLayout undefined-columns regression, email copy, phone copy+icon, boolean warning badge.
1306+
1307+
**Storybook:** Added `PrimaryFieldWithBadges` and `HideEmptyFields` stories.
1308+
1309+
---
1310+
12751311
## ⚠️ Risk Management
12761312

12771313
| Risk | Mitigation |

apps/console/src/components/RecordDetailView.tsx

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -198,32 +198,65 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
198198
);
199199
}
200200

201-
const detailSchema: DetailViewSchema = {
202-
type: 'detail-view',
203-
objectName: objectDef.name,
204-
resourceId: recordId,
205-
showBack: true,
206-
onBack: 'history',
207-
showEdit: true,
208-
title: objectDef.label,
209-
sections: [
210-
{
211-
title: 'Details',
212-
fields: Object.keys(objectDef.fields || {}).map(key => {
213-
const fieldDef = objectDef.fields[key];
201+
// Auto-detect primary field: prefer objectDef metadata, then 'name' or 'title' heuristic
202+
const primaryField = objectDef.primaryField
203+
|| Object.keys(objectDef.fields || {}).find(
204+
(key) => key === 'name' || key === 'title'
205+
);
206+
207+
// Build sections: prefer form sections from objectDef, fallback to flat field list
208+
const formSections = objectDef.views?.form?.sections;
209+
const sections = formSections && formSections.length > 0
210+
? formSections.map((sec: any) => ({
211+
title: sec.title,
212+
collapsible: sec.collapsible,
213+
defaultCollapsed: sec.defaultCollapsed,
214+
fields: (sec.fields || []).map((f: any) => {
215+
const fieldName = typeof f === 'string' ? f : f.name;
216+
const fieldDef = objectDef.fields[fieldName];
217+
if (!fieldDef) {
218+
console.warn(`[RecordDetailView] Field "${fieldName}" not found in ${objectDef.name} definition`);
219+
return { name: fieldName, label: fieldName };
220+
}
214221
return {
215-
name: key,
216-
label: fieldDef.label || key,
222+
name: fieldName,
223+
label: fieldDef.label || fieldName,
217224
type: fieldDef.type || 'text',
218225
...(fieldDef.options && { options: fieldDef.options }),
219226
...(fieldDef.reference_to && { reference_to: fieldDef.reference_to }),
220227
...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
221228
...(fieldDef.currency && { currency: fieldDef.currency }),
222229
};
223230
}),
224-
columns: 2,
225-
},
226-
],
231+
}))
232+
: [
233+
{
234+
title: 'Details',
235+
fields: Object.keys(objectDef.fields || {}).map(key => {
236+
const fieldDef = objectDef.fields[key];
237+
return {
238+
name: key,
239+
label: fieldDef.label || key,
240+
type: fieldDef.type || 'text',
241+
...(fieldDef.options && { options: fieldDef.options }),
242+
...(fieldDef.reference_to && { reference_to: fieldDef.reference_to }),
243+
...(fieldDef.reference_field && { reference_field: fieldDef.reference_field }),
244+
...(fieldDef.currency && { currency: fieldDef.currency }),
245+
};
246+
}),
247+
},
248+
];
249+
250+
const detailSchema: DetailViewSchema = {
251+
type: 'detail-view',
252+
objectName: objectDef.name,
253+
resourceId: recordId,
254+
showBack: true,
255+
onBack: 'history',
256+
showEdit: true,
257+
title: objectDef.label,
258+
primaryField,
259+
sections,
227260
};
228261

229262
return (

packages/fields/src/__tests__/boolean-checkbox.test.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ describe('BooleanCellRenderer', () => {
2323
expect(checkbox).toHaveAttribute('data-state', 'checked');
2424
});
2525

26-
it('should render an unchecked checkbox for false values', () => {
26+
it('should render an unchecked checkbox for false values (non-status field)', () => {
2727
render(
2828
<BooleanCellRenderer
2929
value={false}
30-
field={{ name: 'active', type: 'boolean' } as any}
30+
field={{ name: 'flagged', type: 'boolean' } as any}
3131
/>
3232
);
3333

@@ -36,6 +36,19 @@ describe('BooleanCellRenderer', () => {
3636
expect(checkbox).toHaveAttribute('data-state', 'unchecked');
3737
});
3838

39+
it('should render warning badge for active=false', () => {
40+
const { container } = render(
41+
<BooleanCellRenderer
42+
value={false}
43+
field={{ name: 'active', type: 'boolean', label: 'Active' } as any}
44+
/>
45+
);
46+
47+
const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
48+
expect(badge).toBeInTheDocument();
49+
expect(badge?.textContent).toContain('Off');
50+
});
51+
3952
it('should render dash for null/undefined values', () => {
4053
render(
4154
<BooleanCellRenderer

packages/fields/src/__tests__/cell-renderers.test.tsx

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
TextCellRenderer,
1717
DateCellRenderer,
1818
BooleanCellRenderer,
19+
EmailCellRenderer,
20+
PhoneCellRenderer,
1921
PercentCellRenderer,
2022
humanizeLabel,
2123
formatDate,
@@ -365,7 +367,7 @@ describe('BooleanCellRenderer', () => {
365367
render(
366368
<BooleanCellRenderer
367369
value={false}
368-
field={{ name: 'active', type: 'boolean' } as any}
370+
field={{ name: 'flagged', type: 'boolean' } as any}
369371
/>
370372
);
371373
const checkbox = screen.getByRole('checkbox');
@@ -440,6 +442,91 @@ describe('BooleanCellRenderer', () => {
440442
});
441443
});
442444

445+
// =========================================================================
446+
// 4b. BooleanCellRenderer — Warning badge for status fields
447+
// =========================================================================
448+
describe('BooleanCellRenderer warning badge', () => {
449+
it('should render warning badge for active=false', () => {
450+
const { container } = render(
451+
<BooleanCellRenderer
452+
value={false}
453+
field={{ name: 'active', type: 'boolean', label: 'Active' } as any}
454+
/>
455+
);
456+
const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
457+
expect(badge).toBeInTheDocument();
458+
expect(badge?.textContent).toContain('Off');
459+
});
460+
461+
it('should render warning badge for is_enabled=false', () => {
462+
const { container } = render(
463+
<BooleanCellRenderer
464+
value={false}
465+
field={{ name: 'is_enabled', type: 'boolean', label: 'Enabled' } as any}
466+
/>
467+
);
468+
const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
469+
expect(badge).toBeInTheDocument();
470+
});
471+
472+
it('should render normal checkbox for active=true', () => {
473+
render(
474+
<BooleanCellRenderer
475+
value={true}
476+
field={{ name: 'active', type: 'boolean' } as any}
477+
/>
478+
);
479+
const checkbox = screen.getByRole('checkbox');
480+
expect(checkbox).toHaveAttribute('data-state', 'checked');
481+
});
482+
});
483+
484+
// =========================================================================
485+
// 4c. EmailCellRenderer — mailto + copy button
486+
// =========================================================================
487+
describe('EmailCellRenderer', () => {
488+
it('should render mailto link', () => {
489+
render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
490+
const link = screen.getByRole('link');
491+
expect(link).toHaveAttribute('href', 'mailto:test@example.com');
492+
expect(screen.getByText('test@example.com')).toBeInTheDocument();
493+
});
494+
495+
it('should render copy button', () => {
496+
render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
497+
const copyBtn = screen.getByLabelText('Copy email');
498+
expect(copyBtn).toBeInTheDocument();
499+
});
500+
501+
it('should render dash for empty value', () => {
502+
render(<EmailCellRenderer value={null} field={{ name: 'email', type: 'email' } as any} />);
503+
expect(screen.getByText('-')).toBeInTheDocument();
504+
});
505+
});
506+
507+
// =========================================================================
508+
// 4d. PhoneCellRenderer — tel link + call icon + copy
509+
// =========================================================================
510+
describe('PhoneCellRenderer', () => {
511+
it('should render tel link with phone icon', () => {
512+
render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
513+
const link = screen.getByRole('link');
514+
expect(link).toHaveAttribute('href', 'tel:+1-555-1234');
515+
expect(screen.getByText('+1-555-1234')).toBeInTheDocument();
516+
});
517+
518+
it('should render copy button', () => {
519+
render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
520+
const copyBtn = screen.getByLabelText('Copy phone number');
521+
expect(copyBtn).toBeInTheDocument();
522+
});
523+
524+
it('should render dash for empty value', () => {
525+
render(<PhoneCellRenderer value={null} field={{ name: 'phone', type: 'phone' } as any} />);
526+
expect(screen.getByText('-')).toBeInTheDocument();
527+
});
528+
});
529+
443530
// =========================================================================
444531
// 5. formatRelativeDate edge cases
445532
// =========================================================================

0 commit comments

Comments
 (0)