Skip to content

Commit 79e205e

Browse files
Copilothotlong
andcommitted
feat: semantic status colors, collapsible RelatedList, and DataTable header nowrap
- Expand PRIORITY_COLOR_MAP to SEMANTIC_COLOR_MAP with common status values (paid→green, pending→yellow, shipped→blue, draft→gray, cancelled→red, etc.) - Add whitespace-nowrap truncate to DataTable header text for responsive design - Add collapsible/defaultCollapsed props to RelatedList component - Add tests for semantic color auto-detection and collapsible behavior Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent e92bd9f commit 79e205e

5 files changed

Lines changed: 155 additions & 9 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
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 && (

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,76 @@ 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+
208278
it('should render dash for null/empty value', () => {
209279
render(
210280
<SelectCellRenderer

packages/fields/src/index.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,15 +368,41 @@ 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+
const SEMANTIC_COLOR_MAP: Record<string, string> = {
373+
// Priority values
373374
critical: 'red',
374375
urgent: 'red',
375376
high: 'orange',
376377
medium: 'yellow',
377378
normal: 'blue',
378379
low: 'gray',
379380
none: 'gray',
381+
// Status values
382+
paid: 'green',
383+
completed: 'green',
384+
done: 'green',
385+
active: 'green',
386+
approved: 'green',
387+
resolved: 'green',
388+
pending: 'yellow',
389+
waiting: 'yellow',
390+
on_hold: 'yellow',
391+
shipped: 'blue',
392+
in_progress: 'blue',
393+
open: 'blue',
394+
processing: 'blue',
395+
draft: 'gray',
396+
new: 'gray',
397+
inactive: 'gray',
398+
closed: 'gray',
399+
cancelled: 'red',
400+
canceled: 'red',
401+
rejected: 'red',
402+
failed: 'red',
403+
overdue: 'red',
404+
delivered: 'purple',
405+
archived: 'indigo',
380406
};
381407

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

395421
function getBadgeColorClasses(color?: string, val?: string): string {
396422
const resolvedColor = color
397-
|| (val ? PRIORITY_COLOR_MAP[String(val).toLowerCase()] : undefined);
423+
|| (val ? SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')] : undefined);
398424
return BADGE_COLOR_MAP[resolvedColor || ''] || 'bg-muted text-muted-foreground border-border';
399425
}
400426

packages/plugin-detail/src/RelatedList.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
ChevronLeft,
2525
ChevronRight,
2626
ArrowUpDown,
27+
ChevronDown,
28+
ChevronUp,
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

@@ -253,31 +262,36 @@ export const RelatedList: React.FC<RelatedListProps> = ({
253262

254263
return (
255264
<Card className={className}>
256-
<CardHeader>
265+
<CardHeader className={collapsible ? 'cursor-pointer select-none' : undefined} onClick={collapsible ? () => setCollapsed((c) => !c) : undefined}>
257266
<CardTitle className="flex items-center justify-between">
258267
<div className="flex items-center gap-2">
268+
{collapsible && (
269+
collapsed
270+
? <ChevronRight className="h-4 w-4 text-muted-foreground" />
271+
: <ChevronDown className="h-4 w-4 text-muted-foreground" />
272+
)}
259273
<span>{title}</span>
260274
<span className="text-sm font-normal text-muted-foreground">
261275
{recordCountText}
262276
</span>
263277
</div>
264278
<div className="flex items-center gap-1">
265279
{onNew && (
266-
<Button variant="ghost" size="sm" onClick={onNew} className="gap-1 h-7 text-xs">
280+
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onNew(); }} className="gap-1 h-7 text-xs">
267281
<Plus className="h-3.5 w-3.5" />
268282
{t('detail.new')}
269283
</Button>
270284
)}
271285
{onViewAll && (
272-
<Button variant="ghost" size="sm" onClick={onViewAll} className="gap-1 h-7 text-xs">
286+
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onViewAll(); }} className="gap-1 h-7 text-xs">
273287
{t('detail.viewAll')}
274288
<ExternalLink className="h-3 w-3" />
275289
</Button>
276290
)}
277291
</div>
278292
</CardTitle>
279293
</CardHeader>
280-
<CardContent>
294+
{!collapsed && <CardContent>
281295
{/* Filter bar */}
282296
{filterable && relatedData.length > 0 && (
283297
<div className="mb-3">
@@ -394,7 +408,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
394408
</Button>
395409
</div>
396410
)}
397-
</CardContent>
411+
</CardContent>}
398412
</Card>
399413
);
400414
};

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)