Skip to content

Commit c4e18e3

Browse files
authored
Merge pull request #594 from objectstack-ai/copilot/optimize-listview-ui
2 parents 6dff2bb + 7489880 commit c4e18e3

11 files changed

Lines changed: 326 additions & 11 deletions

File tree

ROADMAP.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,13 @@ Each plugin view must work seamlessly from 320px (small phone) to 2560px (ultraw
363363
- [x] Mobile card view Stage colored badge (green/red/yellow/blue by pipeline stage)
364364
- [x] Mobile card view skeleton loading placeholders during async data fetch
365365
- [x] CRM example Opportunity stage field with color options
366+
- [x] Airtable-style record count status bar (`{n} records`) in ListView
367+
- [x] Airtable-style "+ Add record" row (showAddRow / onAddRecord) in data-table
368+
- [x] Airtable-style compound cells with prefix badge configuration (ListColumn.prefix)
369+
- [x] Airtable-style datetime split display (date + muted time for created_at/updated_at fields)
370+
- [x] Airtable-style row refinement (pure white bg, border-border/50, hover:bg-muted/30)
371+
- [x] Airtable-style inline sort arrows (smaller h-3 icons, hidden until hover, colored when active)
372+
- [x] Airtable-style column header type icons (Type/Hash/Calendar/Clock/CheckSquare/User/Tag)
366373

367374
##### ObjectKanban (`plugin-kanban`)
368375
- [x] Stack columns vertically on mobile with horizontal swipe navigation between columns

packages/components/src/renderers/complex/__tests__/data-table.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,20 @@ describe('Data Table Component', () => {
5757
expect(config?.defaultProps?.exportable).toBe(true);
5858
expect(config?.defaultProps?.rowActions).toBe(true);
5959
});
60+
61+
it('should have showAddRow and onAddRecord properties in schema', () => {
62+
const config = ComponentRegistry.getConfig('data-table');
63+
expect(config).toBeDefined();
64+
// Verify the DataTableSchema type supports add-record properties
65+
// by checking that the component accepts these props without error
66+
const testSchema: import('@object-ui/types').DataTableSchema = {
67+
type: 'data-table',
68+
columns: [],
69+
data: [],
70+
showAddRow: true,
71+
onAddRecord: () => {},
72+
};
73+
expect(testSchema.showAddRow).toBe(true);
74+
expect(typeof testSchema.onAddRecord).toBe('function');
75+
});
6076
});

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
GripVertical,
4747
Save,
4848
X,
49+
Plus,
4950
} from 'lucide-react';
5051

5152
type SortDirection = 'asc' | 'desc' | null;
@@ -104,6 +105,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
104105
className,
105106
frozenColumns = 0,
106107
showRowNumbers = false,
108+
showAddRow = false,
107109
} = schema;
108110

109111
// Normalize columns to support legacy keys (label/name) from existing JSONs
@@ -265,12 +267,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
265267

266268
const getSortIcon = (columnKey: string) => {
267269
if (sortColumn !== columnKey) {
268-
return <ChevronsUpDown className="h-4 w-4 ml-1 opacity-50" />;
270+
return <ChevronsUpDown className="h-3 w-3 ml-0.5 opacity-0 group-hover:opacity-50 transition-opacity" />;
269271
}
270272
if (sortDirection === 'asc') {
271-
return <ChevronUp className="h-4 w-4 ml-1" />;
273+
return <ChevronUp className="h-3 w-3 ml-0.5 text-primary" />;
272274
}
273-
return <ChevronDown className="h-4 w-4 ml-1" />;
275+
return <ChevronDown className="h-3 w-3 ml-0.5 text-primary" />;
274276
};
275277

276278
// Column resizing handlers
@@ -664,6 +666,9 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
664666
{reorderableColumns && (
665667
<GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing flex-shrink-0" />
666668
)}
669+
{col.headerIcon && (
670+
<span className="text-muted-foreground flex-shrink-0">{col.headerIcon}</span>
671+
)}
667672
<span>{col.header}</span>
668673
{sortable && col.sortable !== false && getSortIcon(col.accessorKey)}
669674
</div>
@@ -711,6 +716,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
711716
key={rowId}
712717
data-state={isSelected ? 'selected' : undefined}
713718
className={cn(
719+
"bg-background border-b border-border/50 hover:bg-muted/30",
714720
schema.onRowClick && "cursor-pointer",
715721
rowHasChanges && "bg-amber-50 dark:bg-amber-950/20",
716722
rowClassName && rowClassName(row, rowIndex)
@@ -845,6 +851,24 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
845851
</TableRow>
846852
);
847853
})}
854+
{/* Add record row (Airtable-style) */}
855+
{showAddRow && (
856+
<TableRow
857+
className="hover:bg-muted/30 cursor-pointer border-b border-border/50"
858+
data-testid="add-record-row"
859+
onClick={() => schema.onAddRecord?.()}
860+
>
861+
<TableCell
862+
colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)}
863+
className="h-9 px-3 py-1.5"
864+
>
865+
<span className="flex items-center gap-1.5 text-muted-foreground text-sm hover:text-foreground transition-colors">
866+
<Plus className="h-3.5 w-3.5" />
867+
Add record
868+
</span>
869+
</TableCell>
870+
</TableRow>
871+
)}
848872
{/* Filler rows to maintain height consistency */}
849873
{paginatedData.length > 0 && Array.from({ length: Math.max(0, pageSize - paginatedData.length) }).map((_, i) => (
850874
<TableRow key={`empty-${i}`} className="hover:bg-transparent">
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* DateTimeCellRenderer Tests
3+
*
4+
* Tests for the Airtable-style split date/time cell renderer.
5+
*/
6+
import { describe, it, expect } from 'vitest';
7+
import { render, screen } from '@testing-library/react';
8+
import '@testing-library/jest-dom';
9+
import React from 'react';
10+
import { DateTimeCellRenderer } from '../index';
11+
12+
describe('DateTimeCellRenderer', () => {
13+
it('should render date and time separately', () => {
14+
render(
15+
<DateTimeCellRenderer
16+
value="2026-02-18T12:57:00.000Z"
17+
field={{ name: 'created_at', type: 'datetime' } as any}
18+
/>
19+
);
20+
// Date part should be visible
21+
expect(screen.getByText('2/18/2026')).toBeInTheDocument();
22+
// Time part should be in a muted span
23+
const container = screen.getByText('2/18/2026').closest('span');
24+
expect(container).toBeInTheDocument();
25+
});
26+
27+
it('should show dash for null value', () => {
28+
const { container } = render(
29+
<DateTimeCellRenderer
30+
value={null}
31+
field={{ name: 'created_at', type: 'datetime' } as any}
32+
/>
33+
);
34+
expect(container.textContent).toBe('-');
35+
});
36+
37+
it('should show dash for invalid date', () => {
38+
const { container } = render(
39+
<DateTimeCellRenderer
40+
value="not-a-date"
41+
field={{ name: 'created_at', type: 'datetime' } as any}
42+
/>
43+
);
44+
expect(container.textContent).toBe('-');
45+
});
46+
});

packages/fields/src/index.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,30 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
207207
}
208208

209209
/**
210-
* DateTime field cell renderer
210+
* DateTime field cell renderer (Airtable-style with date and time visually separated)
211211
*/
212212
export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
213-
const formatted = formatDateTime(value);
214-
215-
return <span className="tabular-nums text-sm">{formatted}</span>;
213+
if (!value) return <span className="text-muted-foreground">-</span>;
214+
const date = typeof value === 'string' ? new Date(value) : value;
215+
if (isNaN(date.getTime())) return <span className="text-muted-foreground">-</span>;
216+
217+
const datePart = date.toLocaleDateString('en-US', {
218+
month: 'numeric',
219+
day: 'numeric',
220+
year: 'numeric',
221+
});
222+
const timePart = date.toLocaleTimeString('en-US', {
223+
hour: 'numeric',
224+
minute: '2-digit',
225+
hour12: true,
226+
}).toLowerCase();
227+
228+
return (
229+
<span className="tabular-nums text-sm whitespace-nowrap">
230+
<span>{datePart}</span>
231+
<span className="ml-2 text-muted-foreground">{timePart}</span>
232+
</span>
233+
);
216234
}
217235

218236
/**

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
3232
} from '@object-ui/components';
3333
import { usePullToRefresh } from '@object-ui/mobile';
34-
import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown, Download, Rows3, Rows4, AlignJustify } from 'lucide-react';
34+
import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown, Download, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock } from 'lucide-react';
3535
import { useRowColor } from './useRowColor';
3636
import { useGroupedData } from './useGroupedData';
3737

@@ -47,6 +47,7 @@ export interface ObjectGridProps {
4747
onRowSave?: (rowIndex: number, changes: Record<string, any>, row: any) => void | Promise<void>;
4848
onBatchSave?: (changes: Array<{ rowIndex: number; changes: Record<string, any>; row: any }>) => void | Promise<void>;
4949
onRowSelect?: (selectedRows: any[]) => void;
50+
onAddRecord?: () => void;
5051
}
5152

5253
/**
@@ -115,6 +116,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
115116
onCellChange,
116117
onRowSave,
117118
onBatchSave,
119+
onAddRecord,
118120
...rest
119121
}) => {
120122
const [data, setData] = useState<any[]>([]);
@@ -342,6 +344,23 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
342344
const { groups, isGrouped, toggleGroup } = useGroupedData(schema.grouping, data);
343345

344346
const generateColumns = useCallback(() => {
347+
// Map field type to column header icon (Airtable-style)
348+
const getTypeIcon = (fieldType: string | null): React.ReactNode => {
349+
if (!fieldType) return <Type className="h-3.5 w-3.5" />;
350+
const iconMap: Record<string, React.ReactNode> = {
351+
text: <Type className="h-3.5 w-3.5" />,
352+
number: <Hash className="h-3.5 w-3.5" />,
353+
currency: <Hash className="h-3.5 w-3.5" />,
354+
percent: <Hash className="h-3.5 w-3.5" />,
355+
date: <Calendar className="h-3.5 w-3.5" />,
356+
datetime: <Clock className="h-3.5 w-3.5" />,
357+
boolean: <CheckSquare className="h-3.5 w-3.5" />,
358+
user: <User className="h-3.5 w-3.5" />,
359+
select: <Tag className="h-3.5 w-3.5" />,
360+
};
361+
return iconMap[fieldType] || <Type className="h-3.5 w-3.5" />;
362+
};
363+
345364
// Auto-infer column type from field name and data values (Airtable-style)
346365
const inferColumnType = (col: ListColumn): string | null => {
347366
if (col.type) return col.type; // Explicit type takes priority
@@ -354,6 +373,12 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
354373
return 'boolean';
355374
}
356375

376+
// Infer datetime fields (fields with time component: created_time, modified_time, *_at patterns)
377+
const datetimePatterns = ['created_time', 'modified_time', 'updated_time', 'created_at', 'updated_at', 'modified_at', 'last_login', 'logged_at'];
378+
if (datetimePatterns.some(p => fieldLower === p || fieldLower.endsWith(`_${p}`))) {
379+
return 'datetime';
380+
}
381+
357382
// Infer date fields from name patterns
358383
const datePatterns = ['date', 'due', 'created', 'updated', 'deadline', 'start', 'end', 'expires'];
359384
if (datePatterns.some(p => fieldLower.includes(p))) {
@@ -489,6 +514,27 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
489514
);
490515
}
491516

517+
// Wrap with prefix compound cell renderer (Airtable-style: [Badge] Text in same cell)
518+
const prefixConfig = (col as any).prefix;
519+
if (prefixConfig?.field) {
520+
const baseCellRenderer = cellRenderer;
521+
const PrefixRenderer = prefixConfig.type === 'badge' ? getCellRenderer('select') : null;
522+
cellRenderer = (value: any, row: any) => {
523+
const prefixValue = row[prefixConfig.field];
524+
const prefixEl = prefixValue != null && prefixValue !== ''
525+
? PrefixRenderer
526+
? <PrefixRenderer value={prefixValue} field={{ name: prefixConfig.field, type: 'select' } as any} />
527+
: <span className="text-muted-foreground text-xs mr-1.5">{String(prefixValue)}</span>
528+
: null;
529+
return (
530+
<span className="flex items-center gap-1.5">
531+
{prefixEl}
532+
{baseCellRenderer(value, row)}
533+
</span>
534+
);
535+
};
536+
}
537+
492538
// Auto-infer alignment from field type if not explicitly set
493539
const numericTypes = ['number', 'currency', 'percent'];
494540
const effectiveType = inferredType || col.type;
@@ -500,6 +546,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
500546
return {
501547
header,
502548
accessorKey: col.field,
549+
headerIcon: getTypeIcon(inferredType),
503550
...(!isEssential && { className: 'hidden sm:table-cell' }),
504551
...(col.width && { width: col.width }),
505552
...(inferredAlign && { align: inferredAlign }),
@@ -743,6 +790,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
743790
? 'px-3 py-2.5 text-sm'
744791
: 'px-3 py-1.5 text-[13px] leading-normal',
745792
showRowNumbers: true,
793+
showAddRow: !!operations?.create,
794+
onAddRecord: onAddRecord,
746795
rowClassName: schema.rowColor ? (row: any, _idx: number) => getRowClassName(row) : undefined,
747796
frozenColumns: schema.frozenColumns ?? 1,
748797
onSelectionChange: onRowSelect,

0 commit comments

Comments
 (0)