Skip to content

Commit 7f7c184

Browse files
Copilothotlong
andcommitted
feat: add Phase 3 ObjectUI components and Phase 4 Workflow & Automation UI
- Types: workflow.ts with WorkflowDefinition, AutomationRule, ActivityEntry, ViewMode, ChartConfig, PageLayout - Mock data: mock-workflow-data.ts with workflows, automation rules, activities, charts - Hooks: use-workflow.ts with useWorkflowDefinition, useWorkflowStatus, useAutomationRules, useActivities, useChartConfigs - Phase 3 components: MetadataForm, DataGrid, KanbanBoard, ChartWidget, ViewSwitcher, LayoutBuilder - Phase 4 components: WorkflowStatusBadge, ApprovalActions, WorkflowVisualizer, AutomationRulesBuilder, ActivityTimeline - Integration: ViewSwitcher + KanbanBoard in object-list page, WorkflowStatusBadge + ApprovalActions + ActivityTimeline in object-record page Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent c97cf50 commit 7f7c184

18 files changed

Lines changed: 2401 additions & 11 deletions
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* ChartWidget — dashboard chart widget.
3+
*
4+
* Renders various chart types (bar, line, pie, donut, area, number)
5+
* using pure CSS/SVG. No external chart library dependency.
6+
*/
7+
8+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
9+
import type { ChartConfig } from '@/types/workflow';
10+
11+
interface ChartWidgetProps {
12+
config: ChartConfig;
13+
}
14+
15+
const DEFAULT_COLORS = [
16+
'#3b82f6', '#8b5cf6', '#22c55e', '#f59e0b', '#ef4444',
17+
'#06b6d4', '#ec4899', '#14b8a6', '#f97316', '#6366f1',
18+
];
19+
20+
function getColor(index: number, explicit?: string): string {
21+
return explicit ?? DEFAULT_COLORS[index % DEFAULT_COLORS.length];
22+
}
23+
24+
function NumberChart({ config }: { config: ChartConfig }) {
25+
const value = config.data[0]?.value ?? 0;
26+
const label = config.data[0]?.label ?? '';
27+
const formatted = new Intl.NumberFormat().format(value);
28+
return (
29+
<div className="flex flex-col items-center justify-center py-4">
30+
<span className="text-3xl font-bold tracking-tight">{formatted}</span>
31+
{label && <span className="text-sm text-muted-foreground">{label}</span>}
32+
</div>
33+
);
34+
}
35+
36+
function BarChart({ config }: { config: ChartConfig }) {
37+
const maxValue = Math.max(...config.data.map((d) => d.value), 1);
38+
return (
39+
<div className="space-y-2">
40+
{config.data.map((point, i) => (
41+
<div key={point.label} className="flex items-center gap-2">
42+
<span className="w-20 shrink-0 truncate text-xs text-muted-foreground">
43+
{point.label}
44+
</span>
45+
<div className="flex-1">
46+
<div
47+
className="h-6 rounded-sm transition-all"
48+
style={{
49+
width: `${(point.value / maxValue) * 100}%`,
50+
backgroundColor: getColor(i, point.color),
51+
minWidth: '2px',
52+
}}
53+
/>
54+
</div>
55+
<span className="w-10 shrink-0 text-right text-xs font-medium">
56+
{point.value}
57+
</span>
58+
</div>
59+
))}
60+
</div>
61+
);
62+
}
63+
64+
function PieChart({ config, donut = false }: { config: ChartConfig; donut?: boolean }) {
65+
const total = config.data.reduce((sum, d) => sum + d.value, 0) || 1;
66+
const size = 120;
67+
const center = size / 2;
68+
const radius = 50;
69+
const innerRadius = donut ? 30 : 0;
70+
71+
let cumulative = 0;
72+
const slices = config.data.map((point, i) => {
73+
const startAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
74+
cumulative += point.value;
75+
const endAngle = (cumulative / total) * 2 * Math.PI - Math.PI / 2;
76+
const largeArc = point.value / total > 0.5 ? 1 : 0;
77+
const color = getColor(i, point.color);
78+
79+
const x1 = center + radius * Math.cos(startAngle);
80+
const y1 = center + radius * Math.sin(startAngle);
81+
const x2 = center + radius * Math.cos(endAngle);
82+
const y2 = center + radius * Math.sin(endAngle);
83+
84+
if (donut) {
85+
const ix1 = center + innerRadius * Math.cos(startAngle);
86+
const iy1 = center + innerRadius * Math.sin(startAngle);
87+
const ix2 = center + innerRadius * Math.cos(endAngle);
88+
const iy2 = center + innerRadius * Math.sin(endAngle);
89+
90+
return (
91+
<path
92+
key={point.label}
93+
d={`M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} L ${ix2} ${iy2} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${ix1} ${iy1} Z`}
94+
fill={color}
95+
/>
96+
);
97+
}
98+
99+
return (
100+
<path
101+
key={point.label}
102+
d={`M ${center} ${center} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`}
103+
fill={color}
104+
/>
105+
);
106+
});
107+
108+
return (
109+
<div className="flex items-center gap-4">
110+
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label={config.title}>
111+
{slices}
112+
</svg>
113+
<div className="space-y-1">
114+
{config.data.map((point, i) => (
115+
<div key={point.label} className="flex items-center gap-2 text-xs">
116+
<div
117+
className="size-2.5 rounded-full"
118+
style={{ backgroundColor: getColor(i, point.color) }}
119+
/>
120+
<span className="text-muted-foreground">{point.label}</span>
121+
<span className="font-medium">{point.value}</span>
122+
</div>
123+
))}
124+
</div>
125+
</div>
126+
);
127+
}
128+
129+
export function ChartWidget({ config }: ChartWidgetProps) {
130+
return (
131+
<Card data-testid="chart-widget">
132+
<CardHeader className="pb-2">
133+
<CardTitle className="text-sm font-medium">{config.title}</CardTitle>
134+
{config.description && (
135+
<p className="text-xs text-muted-foreground">{config.description}</p>
136+
)}
137+
</CardHeader>
138+
<CardContent>
139+
{config.type === 'number' && <NumberChart config={config} />}
140+
{config.type === 'bar' && <BarChart config={config} />}
141+
{(config.type === 'line' || config.type === 'area') && <BarChart config={config} />}
142+
{config.type === 'pie' && <PieChart config={config} />}
143+
{config.type === 'donut' && <PieChart config={config} donut />}
144+
</CardContent>
145+
</Card>
146+
);
147+
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/**
2+
* DataGrid — advanced data grid with virtual scrolling.
3+
*
4+
* Renders large record sets efficiently using virtualized rows.
5+
* Supports column sorting, resizing indicators, and selection.
6+
*/
7+
8+
import { useState, useCallback, useMemo, useRef } from 'react';
9+
import { Link } from 'react-router-dom';
10+
import { ArrowUp, ArrowDown } from 'lucide-react';
11+
import { FieldRenderer } from '@/components/records/FieldRenderer';
12+
import type { ObjectDefinition, RecordData, ResolvedField } from '@/types/metadata';
13+
import { resolveFields } from '@/types/metadata';
14+
15+
interface DataGridProps {
16+
objectDef: ObjectDefinition;
17+
records: RecordData[];
18+
basePath: string;
19+
/** Height of the grid container in pixels */
20+
height?: number;
21+
/** Height of each row in pixels */
22+
rowHeight?: number;
23+
/** Whether to show row selection checkboxes */
24+
selectable?: boolean;
25+
onSelectionChange?: (selectedIds: string[]) => void;
26+
}
27+
28+
type SortDirection = 'asc' | 'desc';
29+
30+
interface SortState {
31+
column: string;
32+
direction: SortDirection;
33+
}
34+
35+
const ROW_HEIGHT = 40;
36+
const HEADER_HEIGHT = 44;
37+
const OVERSCAN = 5;
38+
39+
export function DataGrid({
40+
objectDef,
41+
records,
42+
basePath,
43+
height = 500,
44+
rowHeight = ROW_HEIGHT,
45+
selectable = false,
46+
onSelectionChange,
47+
}: DataGridProps) {
48+
const [sort, setSort] = useState<SortState | null>(null);
49+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
50+
const [scrollTop, setScrollTop] = useState(0);
51+
const containerRef = useRef<HTMLDivElement>(null);
52+
53+
const allResolved = resolveFields(objectDef.fields, ['id']);
54+
let columns: ResolvedField[];
55+
if (objectDef.listFields) {
56+
const fieldMap = new Map(allResolved.map((f) => [f.name, f]));
57+
columns = objectDef.listFields
58+
.map((name) => fieldMap.get(name))
59+
.filter((f): f is ResolvedField => !!f);
60+
} else {
61+
columns = allResolved.filter((f) => !f.readonly);
62+
}
63+
64+
// Sort records
65+
const sortedRecords = useMemo(() => {
66+
if (!sort) return records;
67+
const sorted = [...records].sort((a, b) => {
68+
const aVal = a[sort.column];
69+
const bVal = b[sort.column];
70+
if (aVal == null && bVal == null) return 0;
71+
if (aVal == null) return 1;
72+
if (bVal == null) return -1;
73+
const comparison = String(aVal).localeCompare(String(bVal), undefined, { numeric: true });
74+
return sort.direction === 'asc' ? comparison : -comparison;
75+
});
76+
return sorted;
77+
}, [records, sort]);
78+
79+
const handleSort = useCallback((column: string) => {
80+
setSort((prev) => {
81+
if (prev?.column === column) {
82+
return prev.direction === 'asc'
83+
? { column, direction: 'desc' }
84+
: null;
85+
}
86+
return { column, direction: 'asc' };
87+
});
88+
}, []);
89+
90+
const handleToggleSelect = useCallback(
91+
(id: string) => {
92+
setSelectedIds((prev) => {
93+
const next = new Set(prev);
94+
if (next.has(id)) next.delete(id);
95+
else next.add(id);
96+
onSelectionChange?.(Array.from(next));
97+
return next;
98+
});
99+
},
100+
[onSelectionChange],
101+
);
102+
103+
const handleSelectAll = useCallback(() => {
104+
setSelectedIds((prev) => {
105+
if (prev.size === sortedRecords.length) {
106+
onSelectionChange?.([]);
107+
return new Set();
108+
}
109+
const allIds = sortedRecords.map((r) => String(r.id ?? ''));
110+
onSelectionChange?.(allIds);
111+
return new Set(allIds);
112+
});
113+
}, [sortedRecords, onSelectionChange]);
114+
115+
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
116+
setScrollTop(e.currentTarget.scrollTop);
117+
}, []);
118+
119+
// Virtual scrolling calculations
120+
const totalHeight = sortedRecords.length * rowHeight;
121+
const visibleCount = Math.ceil((height - HEADER_HEIGHT) / rowHeight);
122+
const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN);
123+
const endIndex = Math.min(sortedRecords.length, startIndex + visibleCount + OVERSCAN * 2);
124+
const visibleRecords = sortedRecords.slice(startIndex, endIndex);
125+
126+
if (records.length === 0) {
127+
return (
128+
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
129+
<p className="text-lg font-medium">
130+
No {(objectDef.pluralLabel ?? objectDef.label ?? 'records').toLowerCase()} yet
131+
</p>
132+
<p className="text-sm text-muted-foreground">
133+
Records will appear here once they are created.
134+
</p>
135+
</div>
136+
);
137+
}
138+
139+
return (
140+
<div className="rounded-md border" data-testid="data-grid">
141+
<div
142+
ref={containerRef}
143+
className="overflow-auto"
144+
style={{ height }}
145+
onScroll={handleScroll}
146+
>
147+
<div style={{ minWidth: `${columns.length * 150}px` }}>
148+
{/* Header */}
149+
<div
150+
className="sticky top-0 z-10 flex border-b bg-muted/50"
151+
style={{ height: HEADER_HEIGHT }}
152+
>
153+
{selectable && (
154+
<div className="flex w-10 shrink-0 items-center justify-center border-r">
155+
<input
156+
type="checkbox"
157+
checked={selectedIds.size === sortedRecords.length && sortedRecords.length > 0}
158+
onChange={handleSelectAll}
159+
className="size-4 rounded"
160+
aria-label="Select all rows"
161+
/>
162+
</div>
163+
)}
164+
{columns.map((col) => (
165+
<div
166+
key={col.name}
167+
className="flex flex-1 cursor-pointer items-center gap-1 px-3 text-sm font-medium text-muted-foreground hover:text-foreground"
168+
style={{ minWidth: 120 }}
169+
onClick={() => handleSort(col.name)}
170+
role="columnheader"
171+
aria-sort={
172+
sort?.column === col.name
173+
? sort.direction === 'asc' ? 'ascending' : 'descending'
174+
: 'none'
175+
}
176+
>
177+
<span>{col.label}</span>
178+
{sort?.column === col.name && (
179+
sort.direction === 'asc' ? <ArrowUp className="size-3" /> : <ArrowDown className="size-3" />
180+
)}
181+
</div>
182+
))}
183+
</div>
184+
185+
{/* Virtual rows */}
186+
<div style={{ height: totalHeight, position: 'relative' }}>
187+
{visibleRecords.map((record, idx) => {
188+
const id = String(record.id ?? '');
189+
const top = (startIndex + idx) * rowHeight;
190+
return (
191+
<div
192+
key={id}
193+
className="absolute flex w-full border-b hover:bg-muted/50"
194+
style={{ height: rowHeight, top }}
195+
>
196+
{selectable && (
197+
<div className="flex w-10 shrink-0 items-center justify-center border-r">
198+
<input
199+
type="checkbox"
200+
checked={selectedIds.has(id)}
201+
onChange={() => handleToggleSelect(id)}
202+
className="size-4 rounded"
203+
aria-label={`Select row ${id}`}
204+
/>
205+
</div>
206+
)}
207+
{columns.map((col, colIdx) => (
208+
<div
209+
key={col.name}
210+
className="flex flex-1 items-center px-3 text-sm"
211+
style={{ minWidth: 120 }}
212+
>
213+
{colIdx === 0 ? (
214+
<Link
215+
to={`${basePath}/${id}`}
216+
className="font-medium text-primary underline-offset-4 hover:underline"
217+
>
218+
<FieldRenderer field={col} value={record[col.name]} />
219+
</Link>
220+
) : (
221+
<FieldRenderer field={col} value={record[col.name]} />
222+
)}
223+
</div>
224+
))}
225+
</div>
226+
);
227+
})}
228+
</div>
229+
</div>
230+
</div>
231+
</div>
232+
);
233+
}

0 commit comments

Comments
 (0)