Skip to content

Commit 78d84af

Browse files
authored
Merge pull request #1007 from objectstack-ai/copilot/optimize-record-detail-page
2 parents 5807f56 + 9722431 commit 78d84af

File tree

6 files changed

+196
-23
lines changed

6 files changed

+196
-23
lines changed

packages/components/src/renderers/complex/data-table.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -796,7 +796,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
796796
{col.headerIcon && (
797797
<span className="text-muted-foreground flex-shrink-0">{col.headerIcon}</span>
798798
)}
799-
<span className="text-xs font-normal text-muted-foreground">{col.header}</span>
799+
<span className="text-xs font-normal text-muted-foreground whitespace-nowrap truncate">{col.header}</span>
800800
{sortable && col.sortable !== false && getSortIcon(col.accessorKey)}
801801
</div>
802802
{resizableColumns && col.resizable !== false && (
@@ -858,7 +858,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
858858
key={rowId}
859859
data-state={isSelected ? 'selected' : undefined}
860860
className={cn(
861-
"bg-background border-b border-border hover:bg-muted/30 group/row",
861+
"bg-background border-b border-border hover:bg-muted/50 group/row",
862862
schema.onRowClick && "cursor-pointer",
863863
rowHasChanges && "bg-amber-50 dark:bg-amber-950/20",
864864
rowClassName && rowClassName(row, rowIndex)

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,96 @@ describe('SelectCellRenderer', () => {
205205
expect(container.querySelector('[class*="bg-gray-100"]')).toBeInTheDocument();
206206
});
207207

208+
it('should auto-detect status semantic colors (Paid → green)', () => {
209+
const { container } = render(
210+
<SelectCellRenderer
211+
value="Paid"
212+
field={{ name: 'status', type: 'select', options: [] } as any}
213+
/>
214+
);
215+
expect(container.querySelector('[class*="bg-green-100"]')).toBeInTheDocument();
216+
});
217+
218+
it('should auto-detect status semantic colors (Pending → yellow)', () => {
219+
const { container } = render(
220+
<SelectCellRenderer
221+
value="Pending"
222+
field={{ name: 'status', type: 'select', options: [] } as any}
223+
/>
224+
);
225+
expect(container.querySelector('[class*="bg-yellow-100"]')).toBeInTheDocument();
226+
});
227+
228+
it('should auto-detect status semantic colors (Shipped → blue)', () => {
229+
const { container } = render(
230+
<SelectCellRenderer
231+
value="Shipped"
232+
field={{ name: 'status', type: 'select', options: [] } as any}
233+
/>
234+
);
235+
expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
236+
});
237+
238+
it('should auto-detect status semantic colors (Draft → gray)', () => {
239+
const { container } = render(
240+
<SelectCellRenderer
241+
value="Draft"
242+
field={{ name: 'status', type: 'select', options: [] } as any}
243+
/>
244+
);
245+
expect(container.querySelector('[class*="bg-gray-100"]')).toBeInTheDocument();
246+
});
247+
248+
it('should auto-detect status semantic colors (Cancelled → red)', () => {
249+
const { container } = render(
250+
<SelectCellRenderer
251+
value="Cancelled"
252+
field={{ name: 'status', type: 'select', options: [] } as any}
253+
/>
254+
);
255+
expect(container.querySelector('[class*="bg-red-100"]')).toBeInTheDocument();
256+
});
257+
258+
it('should auto-detect status semantic colors (Delivered → purple)', () => {
259+
const { container } = render(
260+
<SelectCellRenderer
261+
value="Delivered"
262+
field={{ name: 'status', type: 'select', options: [] } as any}
263+
/>
264+
);
265+
expect(container.querySelector('[class*="bg-purple-100"]')).toBeInTheDocument();
266+
});
267+
268+
it('should auto-detect status semantic colors with snake_case (in_progress → blue)', () => {
269+
const { container } = render(
270+
<SelectCellRenderer
271+
value="in_progress"
272+
field={{ name: 'status', type: 'select', options: [] } as any}
273+
/>
274+
);
275+
expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
276+
});
277+
278+
it('should auto-detect status semantic colors with hyphen (in-progress → blue)', () => {
279+
const { container } = render(
280+
<SelectCellRenderer
281+
value="in-progress"
282+
field={{ name: 'status', type: 'select', options: [] } as any}
283+
/>
284+
);
285+
expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
286+
});
287+
288+
it('should auto-detect status semantic colors with spaces (in progress → blue)', () => {
289+
const { container } = render(
290+
<SelectCellRenderer
291+
value="in progress"
292+
field={{ name: 'status', type: 'select', options: [] } as any}
293+
/>
294+
);
295+
expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
296+
});
297+
208298
it('should render dash for null/empty value', () => {
209299
render(
210300
<SelectCellRenderer

packages/fields/src/index.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,15 +368,42 @@ export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactE
368368
);
369369
}
370370

371-
// Priority semantic color mapping (auto-detect from value text)
372-
const PRIORITY_COLOR_MAP: Record<string, string> = {
371+
// Semantic color mapping (auto-detect from value text for priority & status fields)
372+
// Keys use underscore notation; lookup normalizes spaces/hyphens to underscores automatically.
373+
const SEMANTIC_COLOR_MAP: Record<string, string> = {
374+
// Priority values
373375
critical: 'red',
374376
urgent: 'red',
375377
high: 'orange',
376378
medium: 'yellow',
377379
normal: 'blue',
378380
low: 'gray',
379381
none: 'gray',
382+
// Status values
383+
paid: 'green',
384+
completed: 'green',
385+
done: 'green',
386+
active: 'green',
387+
approved: 'green',
388+
resolved: 'green',
389+
pending: 'yellow',
390+
waiting: 'yellow',
391+
on_hold: 'yellow',
392+
shipped: 'blue',
393+
in_progress: 'blue',
394+
open: 'blue',
395+
processing: 'blue',
396+
draft: 'gray',
397+
new: 'gray',
398+
inactive: 'gray',
399+
closed: 'gray',
400+
cancelled: 'red',
401+
canceled: 'red',
402+
rejected: 'red',
403+
failed: 'red',
404+
overdue: 'red',
405+
delivered: 'purple',
406+
archived: 'indigo',
380407
};
381408

382409
// Color to Tailwind class mapping for custom Badge styling
@@ -394,7 +421,7 @@ const BADGE_COLOR_MAP: Record<string, string> = {
394421

395422
function getBadgeColorClasses(color?: string, val?: string): string {
396423
const resolvedColor = color
397-
|| (val ? PRIORITY_COLOR_MAP[String(val).toLowerCase()] : undefined);
424+
|| (val ? SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')] : undefined);
398425
return BADGE_COLOR_MAP[resolvedColor || ''] || 'bg-muted text-muted-foreground border-border';
399426
}
400427

packages/plugin-detail/src/DetailView.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ import { buildExpandFields } from '@object-ui/core';
5353
import type { DetailViewSchema, DataSource } from '@object-ui/types';
5454
import { useDetailTranslation } from './useDetailTranslation';
5555

56+
/** Default page size for related lists in the detail view */
57+
const DEFAULT_RELATED_PAGE_SIZE = 5;
58+
5659
export interface DetailViewProps {
5760
schema: DetailViewSchema;
5861
dataSource?: DataSource;
@@ -694,6 +697,8 @@ export const DetailView: React.FC<DetailViewProps> = ({
694697
columns={related.columns as any}
695698
dataSource={dataSource}
696699
objectName={related.api}
700+
collapsible
701+
pageSize={DEFAULT_RELATED_PAGE_SIZE}
697702
/>
698703
))}
699704
</div>
@@ -777,6 +782,8 @@ export const DetailView: React.FC<DetailViewProps> = ({
777782
columns={related.columns as any}
778783
dataSource={dataSource}
779784
objectName={related.api}
785+
collapsible
786+
pageSize={DEFAULT_RELATED_PAGE_SIZE}
780787
/>
781788
))}
782789
</div>

packages/plugin-detail/src/RelatedList.tsx

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
CardHeader,
1313
CardTitle,
1414
CardContent,
15+
Badge,
1516
Button,
1617
Input,
1718
} from '@object-ui/components';
@@ -24,6 +25,7 @@ import {
2425
ChevronLeft,
2526
ChevronRight,
2627
ArrowUpDown,
28+
ChevronDown,
2729
} from 'lucide-react';
2830
import type { DataSource, FieldMetadata } from '@object-ui/types';
2931
import { getCellRenderer } from '@object-ui/fields';
@@ -55,6 +57,10 @@ export interface RelatedListProps {
5557
sortable?: boolean;
5658
/** Enable text filtering */
5759
filterable?: boolean;
60+
/** Whether the card is collapsible */
61+
collapsible?: boolean;
62+
/** Whether the card starts collapsed (requires collapsible=true) */
63+
defaultCollapsed?: boolean;
5864
}
5965

6066
export const RelatedList: React.FC<RelatedListProps> = ({
@@ -74,6 +80,8 @@ export const RelatedList: React.FC<RelatedListProps> = ({
7480
pageSize,
7581
sortable = false,
7682
filterable = false,
83+
collapsible = false,
84+
defaultCollapsed = false,
7785
}) => {
7886
const [relatedData, setRelatedData] = React.useState(data);
7987
const [loading, setLoading] = React.useState(false);
@@ -82,6 +90,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
8290
const [sortDirection, setSortDirection] = React.useState<'asc' | 'desc'>('asc');
8391
const [filterText, setFilterText] = React.useState('');
8492
const [objectSchema, setObjectSchema] = React.useState<any>(null);
93+
const [collapsed, setCollapsed] = React.useState(defaultCollapsed);
8594
const { t } = useDetailTranslation();
8695
const { fieldLabel: resolveFieldLabel } = useSafeFieldLabel();
8796

@@ -245,39 +254,43 @@ export const RelatedList: React.FC<RelatedListProps> = ({
245254
}
246255
}, [type, paginatedData, effectiveColumns, schema, effectivePageSize]);
247256

248-
const recordCountText = relatedData.length === 1
249-
? t('detail.relatedRecordOne', { count: relatedData.length })
250-
: t('detail.relatedRecords', { count: relatedData.length });
251-
252257
const hasRowActions = !!onRowEdit || !!onRowDelete;
253258

259+
const headerClassName = collapsible ? 'cursor-pointer select-none' : undefined;
260+
const handleHeaderClick = collapsible ? () => setCollapsed((c) => !c) : undefined;
261+
254262
return (
255263
<Card className={className}>
256-
<CardHeader>
264+
<CardHeader className={headerClassName} onClick={handleHeaderClick}>
257265
<CardTitle className="flex items-center justify-between">
258266
<div className="flex items-center gap-2">
267+
{collapsible && (
268+
collapsed
269+
? (<ChevronRight className="h-4 w-4 text-muted-foreground" />)
270+
: (<ChevronDown className="h-4 w-4 text-muted-foreground" />)
271+
)}
259272
<span>{title}</span>
260-
<span className="text-sm font-normal text-muted-foreground">
261-
{recordCountText}
262-
</span>
273+
<Badge variant="secondary" className="text-xs font-normal" aria-label={`${relatedData.length} records`}>
274+
{relatedData.length}
275+
</Badge>
263276
</div>
264277
<div className="flex items-center gap-1">
265278
{onNew && (
266-
<Button variant="ghost" size="sm" onClick={onNew} className="gap-1 h-7 text-xs">
279+
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onNew(); }} className="gap-1 h-7 text-xs">
267280
<Plus className="h-3.5 w-3.5" />
268281
{t('detail.new')}
269282
</Button>
270283
)}
271284
{onViewAll && (
272-
<Button variant="ghost" size="sm" onClick={onViewAll} className="gap-1 h-7 text-xs">
285+
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onViewAll(); }} className="gap-1 h-7 text-xs">
273286
{t('detail.viewAll')}
274287
<ExternalLink className="h-3 w-3" />
275288
</Button>
276289
)}
277290
</div>
278291
</CardTitle>
279292
</CardHeader>
280-
<CardContent>
293+
{!collapsed && <CardContent>
281294
{/* Filter bar */}
282295
{filterable && relatedData.length > 0 && (
283296
<div className="mb-3">
@@ -394,7 +407,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
394407
</Button>
395408
</div>
396409
)}
397-
</CardContent>
410+
</CardContent>}
398411
</Card>
399412
);
400413
};

packages/plugin-detail/src/__tests__/RelatedList.test.tsx

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,23 @@ describe('RelatedList', () => {
1616
expect(screen.getByText('Contacts')).toBeInTheDocument();
1717
});
1818

19-
it('should show record count for empty list', () => {
19+
it('should show record count badge for empty list', () => {
2020
render(<RelatedList title="Contacts" type="table" data={[]} />);
21-
expect(screen.getByText('0 records')).toBeInTheDocument();
21+
expect(screen.getByText('0')).toBeInTheDocument();
2222
});
2323

24-
it('should show singular record count for one item', () => {
24+
it('should show record count badge for one item', () => {
2525
render(<RelatedList title="Contacts" type="table" data={[{ id: 1, name: 'Alice' }]} />);
26-
expect(screen.getByText('1 record')).toBeInTheDocument();
26+
expect(screen.getByText('1')).toBeInTheDocument();
2727
});
2828

29-
it('should show plural record count for multiple items', () => {
29+
it('should show record count badge for multiple items', () => {
3030
const data = [
3131
{ id: 1, name: 'Alice' },
3232
{ id: 2, name: 'Bob' },
3333
];
3434
render(<RelatedList title="Orders" type="table" data={data} />);
35-
expect(screen.getByText('2 records')).toBeInTheDocument();
35+
expect(screen.getByText('2')).toBeInTheDocument();
3636
});
3737

3838
it('should show "No related records found" for empty data', () => {
@@ -121,4 +121,40 @@ describe('RelatedList', () => {
121121

122122
expect(mockDataSource.getObjectSchema).not.toHaveBeenCalled();
123123
});
124+
125+
it('should render collapsed state when collapsible and defaultCollapsed are true', () => {
126+
const data = [{ id: 1, name: 'Alice' }];
127+
render(
128+
<RelatedList title="Contacts" type="table" data={data} collapsible defaultCollapsed />,
129+
);
130+
expect(screen.getByText('Contacts')).toBeInTheDocument();
131+
// Content should be hidden when collapsed
132+
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
133+
});
134+
135+
it('should expand collapsed card when header is clicked', () => {
136+
render(
137+
<RelatedList title="Contacts" type="table" data={[]} collapsible defaultCollapsed />,
138+
);
139+
// Initially collapsed - content should be hidden
140+
expect(screen.queryByText('No related records found')).not.toBeInTheDocument();
141+
// Click the header to expand
142+
fireEvent.click(screen.getByText('Contacts'));
143+
// Content should now be visible
144+
expect(screen.getByText('No related records found')).toBeInTheDocument();
145+
});
146+
147+
it('should show content by default when collapsible is true but defaultCollapsed is false', () => {
148+
render(
149+
<RelatedList title="Contacts" type="table" data={[]} collapsible />,
150+
);
151+
expect(screen.getByText('No related records found')).toBeInTheDocument();
152+
});
153+
154+
it('should show content when collapsible is false (default)', () => {
155+
render(
156+
<RelatedList title="Contacts" type="table" data={[]} />,
157+
);
158+
expect(screen.getByText('No related records found')).toBeInTheDocument();
159+
});
124160
});

0 commit comments

Comments
 (0)