Skip to content

Commit 81b7280

Browse files
Copilothotlong
andcommitted
feat: implement Phase 11 L1 (Grid Excellence) and Phase 12 L1 (Record Detail)
Phase 11 L1: - Frozen columns: wire frozenColumns prop through ObjectGrid to data-table with sticky CSS - Row height toggle: compact/medium/tall modes with toolbar button - Row grouping: already implemented (useGroupedData hook) - Conditional row coloring: already implemented (useRowColor hook) - Cell copy: Ctrl+C/Cmd+C to copy cell value to clipboard Phase 12 L1: - Prev/Next navigation: buttons in DetailView header with position indicator - Related records: already exists (RelatedList component) - Comments: new RecordComments component with add/view - Activity timeline: new ActivityTimeline component with field change history Types added: - rowHeight on ObjectGridSchema - frozenColumns on DataTableSchema - CommentEntry and ActivityEntry interfaces - recordNavigation, comments, activities on DetailViewSchema Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 86792c1 commit 81b7280

10 files changed

Lines changed: 556 additions & 41 deletions

File tree

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

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
102102
editable = false,
103103
rowClassName,
104104
className,
105+
frozenColumns = 0,
105106
} = schema;
106107

107108
// Normalize columns to support legacy keys (label/name) from existing JSONs
@@ -456,6 +457,21 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
456457
};
457458

458459
const handleCellKeyDown = (e: React.KeyboardEvent, rowIndex: number, columnKey: string) => {
460+
// Copy cell value with Ctrl+C / Cmd+C
461+
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !editingCell) {
462+
e.preventDefault();
463+
const globalIdx = (currentPage - 1) * pageSize + rowIndex;
464+
const row = sortedData[globalIdx];
465+
if (row) {
466+
const value = row[columnKey];
467+
const text = value != null ? String(value) : '';
468+
navigator.clipboard.writeText(text).catch(() => {
469+
// Fallback for environments without clipboard API
470+
});
471+
}
472+
return;
473+
}
474+
459475
if (!editable) return;
460476

461477
const column = columns.find(col => col.accessorKey === columnKey);
@@ -586,7 +602,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
586602
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
587603
<TableRow>
588604
{selectable && (
589-
<TableHead className="w-12 bg-background">
605+
<TableHead className={cn("w-12 bg-background", frozenColumns > 0 && "sticky left-0 z-20")}>
590606
<Checkbox
591607
checked={allPageRowsSelected ? true : somePageRowsSelected ? 'indeterminate' : false}
592608
onCheckedChange={handleSelectAll}
@@ -597,6 +613,16 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
597613
const columnWidth = columnWidths[col.accessorKey] || col.width;
598614
const isDragging = draggedColumn === index;
599615
const isDragOver = dragOverColumn === index;
616+
const isFrozen = frozenColumns > 0 && index < frozenColumns;
617+
const frozenOffset = isFrozen
618+
? columns.slice(0, index).reduce((sum, c, i) => {
619+
if (i < frozenColumns) {
620+
const w = columnWidths[c.accessorKey] || c.width;
621+
return sum + (typeof w === 'number' ? w : w ? parseInt(String(w), 10) || 150 : 150);
622+
}
623+
return sum;
624+
}, selectable ? 48 : 0)
625+
: undefined;
600626

601627
return (
602628
<TableHead
@@ -608,11 +634,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
608634
isDragOver && 'border-l-2 border-primary',
609635
col.align === 'right' && 'text-right',
610636
col.align === 'center' && 'text-center',
611-
'relative group bg-background'
637+
'relative group bg-background',
638+
isFrozen && 'sticky z-20',
639+
isFrozen && index === frozenColumns - 1 && 'border-r-2 border-border shadow-[2px_0_4px_-2px_rgba(0,0,0,0.1)]',
612640
)}
613641
style={{
614642
width: columnWidth,
615-
minWidth: columnWidth
643+
minWidth: columnWidth,
644+
...(isFrozen && { left: frozenOffset }),
616645
}}
617646
draggable={reorderableColumns}
618647
onDragStart={(e) => handleColumnDragStart(e, index)}
@@ -692,7 +721,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
692721
}}
693722
>
694723
{selectable && (
695-
<TableCell>
724+
<TableCell className={cn(frozenColumns > 0 && "sticky left-0 z-10 bg-background")}>
696725
<Checkbox
697726
checked={isSelected}
698727
onCheckedChange={(checked) => handleSelectRow(rowId, checked as boolean)}
@@ -706,6 +735,16 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
706735
const cellValue = hasPendingChange ? rowChanges[col.accessorKey] : originalValue;
707736
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.columnKey === col.accessorKey;
708737
const isEditable = editable && col.editable !== false;
738+
const isFrozen = frozenColumns > 0 && colIndex < frozenColumns;
739+
const frozenOffset = isFrozen
740+
? columns.slice(0, colIndex).reduce((sum, c, i) => {
741+
if (i < frozenColumns) {
742+
const w = columnWidths[c.accessorKey] || c.width;
743+
return sum + (typeof w === 'number' ? w : w ? parseInt(String(w), 10) || 150 : 150);
744+
}
745+
return sum;
746+
}, selectable ? 48 : 0)
747+
: undefined;
709748

710749
return (
711750
<TableCell
@@ -715,12 +754,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
715754
col.align === 'right' && 'text-right',
716755
col.align === 'center' && 'text-center',
717756
isEditable && !isEditing && "cursor-text hover:bg-muted/50",
718-
hasPendingChange && "font-semibold text-amber-700 dark:text-amber-400"
757+
hasPendingChange && "font-semibold text-amber-700 dark:text-amber-400",
758+
isFrozen && 'sticky z-10 bg-background',
759+
isFrozen && colIndex === frozenColumns - 1 && 'border-r-2 border-border shadow-[2px_0_4px_-2px_rgba(0,0,0,0.1)]',
719760
)}
720761
style={{
721762
width: columnWidth,
722763
minWidth: columnWidth,
723-
maxWidth: columnWidth
764+
maxWidth: columnWidth,
765+
...(isFrozen && { left: frozenOffset }),
724766
}}
725767
onDoubleClick={() => isEditable && startEdit(rowIndex, col.accessorKey)}
726768
onKeyDown={(e) => handleCellKeyDown(e, rowIndex, col.accessorKey)}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import * as React from 'react';
10+
import { cn, Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
11+
import { Activity, Edit, PlusCircle, Trash2, MessageSquare, ArrowRightLeft } from 'lucide-react';
12+
import type { ActivityEntry } from '@object-ui/types';
13+
14+
export interface ActivityTimelineProps {
15+
activities: ActivityEntry[];
16+
className?: string;
17+
}
18+
19+
const ACTIVITY_ICONS: Record<ActivityEntry['type'], React.ElementType> = {
20+
field_change: Edit,
21+
create: PlusCircle,
22+
delete: Trash2,
23+
comment: MessageSquare,
24+
status_change: ArrowRightLeft,
25+
};
26+
27+
const ACTIVITY_COLORS: Record<ActivityEntry['type'], string> = {
28+
field_change: 'bg-blue-100 text-blue-600',
29+
create: 'bg-green-100 text-green-600',
30+
delete: 'bg-red-100 text-red-600',
31+
comment: 'bg-purple-100 text-purple-600',
32+
status_change: 'bg-amber-100 text-amber-600',
33+
};
34+
35+
function formatTimestamp(timestamp: string): string {
36+
try {
37+
const date = new Date(timestamp);
38+
const now = new Date();
39+
const diffMs = now.getTime() - date.getTime();
40+
const diffMins = Math.floor(diffMs / 60000);
41+
42+
if (diffMins < 1) return 'just now';
43+
if (diffMins < 60) return `${diffMins}m ago`;
44+
const diffHours = Math.floor(diffMins / 60);
45+
if (diffHours < 24) return `${diffHours}h ago`;
46+
const diffDays = Math.floor(diffHours / 24);
47+
if (diffDays < 7) return `${diffDays}d ago`;
48+
return date.toLocaleDateString();
49+
} catch {
50+
return timestamp;
51+
}
52+
}
53+
54+
function formatFieldChange(entry: ActivityEntry): string {
55+
if (entry.description) return entry.description;
56+
57+
if (entry.type === 'field_change' && entry.field) {
58+
const fieldLabel = entry.field.charAt(0).toUpperCase() + entry.field.slice(1).replace(/_/g, ' ');
59+
const oldVal = entry.oldValue != null ? String(entry.oldValue) : '(empty)';
60+
const newVal = entry.newValue != null ? String(entry.newValue) : '(empty)';
61+
return `Changed ${fieldLabel} from "${oldVal}" to "${newVal}"`;
62+
}
63+
64+
if (entry.type === 'create') return 'Created this record';
65+
if (entry.type === 'delete') return 'Deleted this record';
66+
if (entry.type === 'status_change' && entry.field) {
67+
const newVal = entry.newValue != null ? String(entry.newValue) : '(empty)';
68+
return `Changed status to "${newVal}"`;
69+
}
70+
71+
return 'Updated record';
72+
}
73+
74+
export const ActivityTimeline: React.FC<ActivityTimelineProps> = ({
75+
activities,
76+
className,
77+
}) => {
78+
return (
79+
<Card className={cn('', className)}>
80+
<CardHeader>
81+
<CardTitle className="flex items-center gap-2 text-base">
82+
<Activity className="h-4 w-4" />
83+
Activity
84+
<span className="text-sm font-normal text-muted-foreground">
85+
({activities.length})
86+
</span>
87+
</CardTitle>
88+
</CardHeader>
89+
<CardContent>
90+
{activities.length === 0 ? (
91+
<p className="text-sm text-muted-foreground text-center py-4">
92+
No activity recorded
93+
</p>
94+
) : (
95+
<div className="relative">
96+
{/* Timeline line */}
97+
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
98+
99+
<div className="space-y-4">
100+
{activities.map((entry) => {
101+
const Icon = ACTIVITY_ICONS[entry.type] || Edit;
102+
const colorClass = ACTIVITY_COLORS[entry.type] || 'bg-gray-100 text-gray-600';
103+
104+
return (
105+
<div key={entry.id} className="flex gap-3 relative">
106+
{/* Icon */}
107+
<div
108+
className={cn(
109+
'shrink-0 h-8 w-8 rounded-full flex items-center justify-center z-10',
110+
colorClass,
111+
)}
112+
>
113+
<Icon className="h-3.5 w-3.5" />
114+
</div>
115+
{/* Content */}
116+
<div className="flex-1 min-w-0 pt-1">
117+
<p className="text-sm">
118+
<span className="font-medium">{entry.user}</span>
119+
{' '}
120+
<span className="text-muted-foreground">
121+
{formatFieldChange(entry)}
122+
</span>
123+
</p>
124+
<p className="text-xs text-muted-foreground mt-0.5">
125+
{formatTimestamp(entry.timestamp)}
126+
</p>
127+
</div>
128+
</div>
129+
);
130+
})}
131+
</div>
132+
</div>
133+
)}
134+
</CardContent>
135+
</Card>
136+
);
137+
};

packages/plugin-detail/src/DetailView.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,14 @@ import {
3333
Star,
3434
StarOff,
3535
Check,
36+
ChevronLeft,
37+
ChevronRight,
3638
} from 'lucide-react';
3739
import { DetailSection } from './DetailSection';
3840
import { DetailTabs } from './DetailTabs';
3941
import { RelatedList } from './RelatedList';
42+
import { RecordComments } from './RecordComments';
43+
import { ActivityTimeline } from './ActivityTimeline';
4044
import { SchemaRenderer } from '@object-ui/react';
4145
import type { DetailViewSchema, DataSource } from '@object-ui/types';
4246

@@ -257,6 +261,53 @@ export const DetailView: React.FC<DetailViewProps> = ({
257261
</div>
258262

259263
<div className="flex flex-wrap items-center gap-1.5 shrink-0 w-full sm:w-auto">
264+
{/* Prev/Next Record Navigation */}
265+
{schema.recordNavigation && (
266+
<div className="flex items-center gap-1 mr-2">
267+
<Tooltip>
268+
<TooltipTrigger asChild>
269+
<Button
270+
variant="outline"
271+
size="icon"
272+
className="h-8 w-8"
273+
disabled={schema.recordNavigation.currentIndex <= 0}
274+
onClick={() => {
275+
const nav = schema.recordNavigation!;
276+
if (nav.currentIndex > 0) {
277+
nav.onNavigate(nav.recordIds[nav.currentIndex - 1]);
278+
}
279+
}}
280+
>
281+
<ChevronLeft className="h-4 w-4" />
282+
</Button>
283+
</TooltipTrigger>
284+
<TooltipContent>Previous record</TooltipContent>
285+
</Tooltip>
286+
<span className="text-xs text-muted-foreground whitespace-nowrap px-1">
287+
{schema.recordNavigation.currentIndex + 1} of {schema.recordNavigation.recordIds.length}
288+
</span>
289+
<Tooltip>
290+
<TooltipTrigger asChild>
291+
<Button
292+
variant="outline"
293+
size="icon"
294+
className="h-8 w-8"
295+
disabled={schema.recordNavigation.currentIndex >= schema.recordNavigation.recordIds.length - 1}
296+
onClick={() => {
297+
const nav = schema.recordNavigation!;
298+
if (nav.currentIndex < nav.recordIds.length - 1) {
299+
nav.onNavigate(nav.recordIds[nav.currentIndex + 1]);
300+
}
301+
}}
302+
>
303+
<ChevronRight className="h-4 w-4" />
304+
</Button>
305+
</TooltipTrigger>
306+
<TooltipContent>Next record</TooltipContent>
307+
</Tooltip>
308+
</div>
309+
)}
310+
260311
{schema.actions?.map((action, index) => (
261312
<SchemaRenderer key={index} schema={action} data={data} />
262313
))}
@@ -413,6 +464,19 @@ export const DetailView: React.FC<DetailViewProps> = ({
413464
</div>
414465
)}
415466

467+
{/* Comments */}
468+
{schema.comments && (
469+
<RecordComments
470+
comments={schema.comments}
471+
onAddComment={schema.onAddComment}
472+
/>
473+
)}
474+
475+
{/* Activity Timeline */}
476+
{schema.activities && schema.activities.length > 0 && (
477+
<ActivityTimeline activities={schema.activities} />
478+
)}
479+
416480
{/* Custom Footer */}
417481
{schema.footer && (
418482
<div>

0 commit comments

Comments
 (0)