@object-ui/plugin-detail 3.0.3 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.turbo/turbo-build.log +45 -8
  2. package/CHANGELOG.md +11 -0
  3. package/dist/AddressField-B1iVr404.js +96 -0
  4. package/dist/AutoNumberField-BxnFqllo.js +8 -0
  5. package/dist/AvatarField-Duw4xOLZ.js +82 -0
  6. package/dist/BooleanField-CZ4axVeq.js +37 -0
  7. package/dist/CodeField-BSz-mk2v.js +21 -0
  8. package/dist/ColorField-B522ad8m.js +42 -0
  9. package/dist/CurrencyField-Cwr3_pow.js +43 -0
  10. package/dist/DateField-DCo6dxud.js +21 -0
  11. package/dist/DateTimeField-BWfBuANO.js +28 -0
  12. package/dist/EmailField-CpwbdVCU.js +31 -0
  13. package/dist/FileField-DVAUAJ8e.js +133 -0
  14. package/dist/FormulaField-CJkkwIK8.js +9 -0
  15. package/dist/GeolocationField-DNCKitgo.js +123 -0
  16. package/dist/GridField-DSblZNfp.js +30 -0
  17. package/dist/ImageField-DBAlnMon.js +90 -0
  18. package/dist/LocationField-DsHsXA6R.js +31 -0
  19. package/dist/LookupField-CsT0QQz2.js +96 -0
  20. package/dist/MasterDetailField-Db8b7Gqs.js +108 -0
  21. package/dist/NumberField-0IGp7lcA.js +26 -0
  22. package/dist/ObjectField-BLApgJtS.js +48 -0
  23. package/dist/PasswordField-pHKyNlmo.js +38 -0
  24. package/dist/PercentField-CwgKmlIb.js +63 -0
  25. package/dist/PhoneField-lKtbYOdN.js +31 -0
  26. package/dist/QRCodeField-BTTasT3w.js +77 -0
  27. package/dist/RatingField-De2X-l44.js +47 -0
  28. package/dist/RichTextField-B5QnvUOr.js +38 -0
  29. package/dist/SelectField-C9AZRHWu.js +26 -0
  30. package/dist/SignatureField-BgcEmYzd.js +85 -0
  31. package/dist/SliderField-BzrttVOY.js +30 -0
  32. package/dist/SummaryField-ugYPYxjP.js +9 -0
  33. package/dist/TextAreaField-DSE_CaU6.js +39 -0
  34. package/dist/TextField-DFQ4T9PR.js +32 -0
  35. package/dist/TimeField-F0cfmsps.js +21 -0
  36. package/dist/UrlField-DLXrFIH-.js +33 -0
  37. package/dist/UserField-PXMmxJY9.js +49 -0
  38. package/dist/VectorField-CKg9jdGa.js +25 -0
  39. package/dist/index-qQ1C-yUR.js +59976 -0
  40. package/dist/index.js +32 -55026
  41. package/dist/index.umd.cjs +41 -30
  42. package/dist/plugin-detail.css +1 -1
  43. package/dist/src/ActivityTimeline.d.ts +20 -0
  44. package/dist/src/ActivityTimeline.d.ts.map +1 -0
  45. package/dist/src/CommentAttachment.d.ts +25 -0
  46. package/dist/src/CommentAttachment.d.ts.map +1 -0
  47. package/dist/src/CommentInput.d.ts +24 -0
  48. package/dist/src/CommentInput.d.ts.map +1 -0
  49. package/dist/src/DetailSection.d.ts +8 -0
  50. package/dist/src/DetailSection.d.ts.map +1 -1
  51. package/dist/src/DetailView.d.ts +4 -0
  52. package/dist/src/DetailView.d.ts.map +1 -1
  53. package/dist/src/DetailView.stories.d.ts +8 -0
  54. package/dist/src/DetailView.stories.d.ts.map +1 -1
  55. package/dist/src/DiffView.d.ts +24 -0
  56. package/dist/src/DiffView.d.ts.map +1 -0
  57. package/dist/src/FieldChangeItem.d.ts +21 -0
  58. package/dist/src/FieldChangeItem.d.ts.map +1 -0
  59. package/dist/src/HeaderHighlight.d.ts +18 -0
  60. package/dist/src/HeaderHighlight.d.ts.map +1 -0
  61. package/dist/src/InlineCreateRelated.d.ts +32 -0
  62. package/dist/src/InlineCreateRelated.d.ts.map +1 -0
  63. package/dist/src/MentionAutocomplete.d.ts +43 -0
  64. package/dist/src/MentionAutocomplete.d.ts.map +1 -0
  65. package/dist/src/PointInTimeRestore.d.ts +28 -0
  66. package/dist/src/PointInTimeRestore.d.ts.map +1 -0
  67. package/dist/src/ReactionPicker.d.ts +25 -0
  68. package/dist/src/ReactionPicker.d.ts.map +1 -0
  69. package/dist/src/RecordActivityTimeline.d.ts +49 -0
  70. package/dist/src/RecordActivityTimeline.d.ts.map +1 -0
  71. package/dist/src/RecordChatterPanel.d.ts +48 -0
  72. package/dist/src/RecordChatterPanel.d.ts.map +1 -0
  73. package/dist/src/RecordComments.d.ts +20 -0
  74. package/dist/src/RecordComments.d.ts.map +1 -0
  75. package/dist/src/RecordNavigationEnhanced.d.ts +18 -0
  76. package/dist/src/RecordNavigationEnhanced.d.ts.map +1 -0
  77. package/dist/src/RelatedList.d.ts +20 -0
  78. package/dist/src/RelatedList.d.ts.map +1 -1
  79. package/dist/src/RelationshipGraph.d.ts +23 -0
  80. package/dist/src/RelationshipGraph.d.ts.map +1 -0
  81. package/dist/src/RichTextCommentInput.d.ts +24 -0
  82. package/dist/src/RichTextCommentInput.d.ts.map +1 -0
  83. package/dist/src/SectionGroup.d.ts +21 -0
  84. package/dist/src/SectionGroup.d.ts.map +1 -0
  85. package/dist/src/SubscriptionToggle.d.ts +22 -0
  86. package/dist/src/SubscriptionToggle.d.ts.map +1 -0
  87. package/dist/src/ThreadedReplies.d.ts +26 -0
  88. package/dist/src/ThreadedReplies.d.ts.map +1 -0
  89. package/dist/src/autoLayout.d.ts +34 -0
  90. package/dist/src/autoLayout.d.ts.map +1 -0
  91. package/dist/src/index.d.ts +40 -0
  92. package/dist/src/index.d.ts.map +1 -1
  93. package/dist/src/useDetailTranslation.d.ts +34 -0
  94. package/dist/src/useDetailTranslation.d.ts.map +1 -0
  95. package/package.json +8 -7
  96. package/src/ActivityTimeline.tsx +184 -0
  97. package/src/CommentAttachment.tsx +192 -0
  98. package/src/CommentInput.tsx +81 -0
  99. package/src/DetailSection.tsx +81 -10
  100. package/src/DetailView.stories.tsx +76 -0
  101. package/src/DetailView.tsx +519 -66
  102. package/src/DiffView.tsx +231 -0
  103. package/src/FieldChangeItem.tsx +46 -0
  104. package/src/HeaderHighlight.tsx +67 -0
  105. package/src/InlineCreateRelated.tsx +291 -0
  106. package/src/MentionAutocomplete.tsx +123 -0
  107. package/src/PointInTimeRestore.tsx +261 -0
  108. package/src/ReactionPicker.tsx +106 -0
  109. package/src/RecordActivityTimeline.tsx +429 -0
  110. package/src/RecordChatterPanel.tsx +202 -0
  111. package/src/RecordComments.tsx +215 -0
  112. package/src/RecordNavigationEnhanced.tsx +211 -0
  113. package/src/RelatedList.tsx +314 -19
  114. package/src/RelationshipGraph.tsx +286 -0
  115. package/src/RichTextCommentInput.tsx +348 -0
  116. package/src/SectionGroup.tsx +101 -0
  117. package/src/SubscriptionToggle.tsx +60 -0
  118. package/src/ThreadedReplies.tsx +161 -0
  119. package/src/__tests__/ActivityTimeline.test.tsx +119 -0
  120. package/src/__tests__/ActivityTimelineFiltering.test.tsx +143 -0
  121. package/src/__tests__/CommentInput.test.tsx +57 -0
  122. package/src/__tests__/DetailSection.test.tsx +320 -0
  123. package/src/__tests__/DetailView.test.tsx +415 -1
  124. package/src/__tests__/FieldChangeItem.test.tsx +119 -0
  125. package/src/__tests__/HeaderHighlight.test.tsx +68 -0
  126. package/src/__tests__/MentionAutocomplete.test.tsx +97 -0
  127. package/src/__tests__/ReactionPicker.test.tsx +113 -0
  128. package/src/__tests__/RecordActivityTimeline.test.tsx +395 -0
  129. package/src/__tests__/RecordChatterPanel.test.tsx +227 -0
  130. package/src/__tests__/RecordComments.test.tsx +96 -0
  131. package/src/__tests__/RecordCommentsPinSearch.test.tsx +133 -0
  132. package/src/__tests__/RelatedList.test.tsx +160 -0
  133. package/src/__tests__/SectionGroup.test.tsx +101 -0
  134. package/src/__tests__/SubscriptionToggle.test.tsx +84 -0
  135. package/src/__tests__/ThreadedReplies.test.tsx +212 -0
  136. package/src/__tests__/autoLayout.test.ts +184 -0
  137. package/src/__tests__/phase12-features.test.tsx +583 -0
  138. package/src/__tests__/roadmap-features.test.tsx +478 -0
  139. package/src/autoLayout.ts +111 -0
  140. package/src/index.tsx +50 -0
  141. package/src/useDetailTranslation.ts +114 -0
@@ -7,9 +7,30 @@
7
7
  */
8
8
 
9
9
  import * as React from 'react';
10
- import { Card, CardHeader, CardTitle, CardContent } from '@object-ui/components';
10
+ import {
11
+ Card,
12
+ CardHeader,
13
+ CardTitle,
14
+ CardContent,
15
+ Badge,
16
+ Button,
17
+ Input,
18
+ } from '@object-ui/components';
11
19
  import { SchemaRenderer } from '@object-ui/react';
12
- import type { DataSource } from '@object-ui/types';
20
+ import {
21
+ Plus,
22
+ ExternalLink,
23
+ Edit,
24
+ Trash2,
25
+ ChevronLeft,
26
+ ChevronRight,
27
+ ArrowUpDown,
28
+ ChevronDown,
29
+ } from 'lucide-react';
30
+ import type { DataSource, FieldMetadata } from '@object-ui/types';
31
+ import { getCellRenderer } from '@object-ui/fields';
32
+ import { useDetailTranslation } from './useDetailTranslation';
33
+ import { useSafeFieldLabel } from '@object-ui/react';
13
34
 
14
35
  export interface RelatedListProps {
15
36
  title: string;
@@ -20,6 +41,26 @@ export interface RelatedListProps {
20
41
  columns?: any[];
21
42
  className?: string;
22
43
  dataSource?: DataSource;
44
+ /** Object name for i18n field label resolution */
45
+ objectName?: string;
46
+ /** Callback when "New" button is clicked */
47
+ onNew?: () => void;
48
+ /** Callback when "View All" button is clicked */
49
+ onViewAll?: () => void;
50
+ /** Callback when a row Edit action is clicked */
51
+ onRowEdit?: (row: any) => void;
52
+ /** Callback when a row Delete action is clicked */
53
+ onRowDelete?: (row: any) => void;
54
+ /** Page size for pagination (enables pagination when set) */
55
+ pageSize?: number;
56
+ /** Enable column sorting */
57
+ sortable?: boolean;
58
+ /** Enable text filtering */
59
+ filterable?: boolean;
60
+ /** Whether the card is collapsible */
61
+ collapsible?: boolean;
62
+ /** Whether the card starts collapsed (requires collapsible=true) */
63
+ defaultCollapsed?: boolean;
23
64
  }
24
65
 
25
66
  export const RelatedList: React.FC<RelatedListProps> = ({
@@ -31,14 +72,46 @@ export const RelatedList: React.FC<RelatedListProps> = ({
31
72
  columns,
32
73
  className,
33
74
  dataSource,
75
+ objectName,
76
+ onNew,
77
+ onViewAll,
78
+ onRowEdit,
79
+ onRowDelete,
80
+ pageSize,
81
+ sortable = false,
82
+ filterable = false,
83
+ collapsible = false,
84
+ defaultCollapsed = false,
34
85
  }) => {
35
86
  const [relatedData, setRelatedData] = React.useState(data);
36
87
  const [loading, setLoading] = React.useState(false);
88
+ const [currentPage, setCurrentPage] = React.useState(0);
89
+ const [sortField, setSortField] = React.useState<string | null>(null);
90
+ const [sortDirection, setSortDirection] = React.useState<'asc' | 'desc'>('asc');
91
+ const [filterText, setFilterText] = React.useState('');
92
+ const [objectSchema, setObjectSchema] = React.useState<any>(null);
93
+ const [collapsed, setCollapsed] = React.useState(defaultCollapsed);
94
+ const { t } = useDetailTranslation();
95
+ const { fieldLabel: resolveFieldLabel } = useSafeFieldLabel();
96
+
97
+ // Sync internal state when data prop changes (e.g., parent fetches async data)
98
+ React.useEffect(() => {
99
+ setRelatedData(data);
100
+ }, [data]);
101
+
102
+ // Auto-fetch object schema when api/dataSource available but columns missing
103
+ React.useEffect(() => {
104
+ if (api && dataSource?.getObjectSchema && !columns?.length) {
105
+ dataSource.getObjectSchema(api).then(setObjectSchema).catch((err: unknown) => {
106
+ console.warn(`[RelatedList] Failed to fetch schema for ${api}:`, err);
107
+ });
108
+ }
109
+ }, [api, dataSource, columns]);
37
110
 
38
111
  React.useEffect(() => {
39
112
  if (api && !data.length) {
40
113
  setLoading(true);
41
- if (dataSource) {
114
+ if (dataSource && typeof dataSource.find === 'function') {
42
115
  dataSource.find(api).then((result) => {
43
116
  const items = Array.isArray(result)
44
117
  ? result
@@ -66,6 +139,97 @@ export const RelatedList: React.FC<RelatedListProps> = ({
66
139
  }
67
140
  }, [api, data, dataSource]);
68
141
 
142
+ // Filter data
143
+ const filteredData = React.useMemo(() => {
144
+ if (!filterText) return relatedData;
145
+ const lower = filterText.toLowerCase();
146
+ return relatedData.filter((row) =>
147
+ Object.values(row).some((val) =>
148
+ val !== null && val !== undefined && String(val).toLowerCase().includes(lower)
149
+ )
150
+ );
151
+ }, [relatedData, filterText]);
152
+
153
+ // Sort data
154
+ const sortedData = React.useMemo(() => {
155
+ if (!sortField) return filteredData;
156
+ return [...filteredData].sort((a, b) => {
157
+ const aVal = a[sortField];
158
+ const bVal = b[sortField];
159
+ if (aVal == null && bVal == null) return 0;
160
+ if (aVal == null) return 1;
161
+ if (bVal == null) return -1;
162
+ const cmp = String(aVal).localeCompare(String(bVal), undefined, { numeric: true });
163
+ return sortDirection === 'asc' ? cmp : -cmp;
164
+ });
165
+ }, [filteredData, sortField, sortDirection]);
166
+
167
+ // Paginate data
168
+ const effectivePageSize = pageSize && pageSize > 0 ? pageSize : 0;
169
+ const totalPages = effectivePageSize ? Math.max(1, Math.ceil(sortedData.length / effectivePageSize)) : 1;
170
+ const paginatedData = effectivePageSize
171
+ ? sortedData.slice(currentPage * effectivePageSize, (currentPage + 1) * effectivePageSize)
172
+ : sortedData;
173
+
174
+ // Reset to first page when filter/sort changes
175
+ React.useEffect(() => {
176
+ setCurrentPage(0);
177
+ }, [filterText, sortField, sortDirection]);
178
+
179
+ const handleSort = React.useCallback((field: string) => {
180
+ if (sortField === field) {
181
+ setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
182
+ } else {
183
+ setSortField(field);
184
+ setSortDirection('asc');
185
+ }
186
+ }, [sortField]);
187
+
188
+ const handleDeleteRow = React.useCallback((row: any) => {
189
+ if (window.confirm(t('detail.deleteRowConfirmation'))) {
190
+ onRowDelete?.(row);
191
+ }
192
+ }, [onRowDelete, t]);
193
+
194
+ // Generate effective columns from explicit prop or object schema fields
195
+ const effectiveColumns = React.useMemo(() => {
196
+ if (columns && columns.length > 0) return columns;
197
+ if (!objectSchema?.fields) return [];
198
+ const resolvedObjectName = objectName || api || '';
199
+ return Object.entries(objectSchema.fields)
200
+ .filter(([key]) => !key.startsWith('_'))
201
+ .map(([key, def]: [string, any]) => {
202
+ const col: any = {
203
+ accessorKey: key,
204
+ header: resolveFieldLabel(resolvedObjectName, key, def.label || key),
205
+ };
206
+ // Add type-aware cell renderer for typed fields
207
+ if (def.type) {
208
+ const CellRenderer = getCellRenderer(def.type);
209
+ if (CellRenderer) {
210
+ const fieldMeta: FieldMetadata = {
211
+ name: key,
212
+ label: def.label || key,
213
+ type: def.type,
214
+ ...(def.options && { options: def.options }),
215
+ ...(def.currency && { currency: def.currency }),
216
+ ...(def.precision !== undefined && { precision: def.precision }),
217
+ ...(def.format && { format: def.format }),
218
+ ...((def.reference_to || def.reference) && { reference_to: def.reference_to || def.reference }),
219
+ ...(def.reference_field && { reference_field: def.reference_field }),
220
+ };
221
+ col.cell = (value: any) => {
222
+ if (value === null || value === undefined) {
223
+ return React.createElement('span', { className: 'text-muted-foreground/50 text-xs italic' }, '—');
224
+ }
225
+ return React.createElement(CellRenderer, { value, field: fieldMeta });
226
+ };
227
+ }
228
+ }
229
+ return col;
230
+ });
231
+ }, [columns, objectSchema, objectName, api, resolveFieldLabel]);
232
+
69
233
  const viewSchema = React.useMemo(() => {
70
234
  if (schema) return schema;
71
235
 
@@ -75,44 +239,175 @@ export const RelatedList: React.FC<RelatedListProps> = ({
75
239
  case 'table':
76
240
  return {
77
241
  type: 'data-table',
78
- data: relatedData,
79
- columns: columns || [],
80
- pagination: relatedData.length > 10,
81
- pageSize: 10,
242
+ data: paginatedData,
243
+ columns: effectiveColumns,
244
+ pagination: false, // We handle pagination ourselves
245
+ pageSize: effectivePageSize || 10,
82
246
  };
83
247
  case 'list':
84
248
  return {
85
249
  type: 'data-list',
86
- data: relatedData,
250
+ data: paginatedData,
87
251
  };
88
252
  default:
89
253
  return { type: 'div', children: 'No view configured' };
90
254
  }
91
- }, [type, relatedData, columns, schema]);
255
+ }, [type, paginatedData, effectiveColumns, schema, effectivePageSize]);
256
+
257
+ const hasRowActions = !!onRowEdit || !!onRowDelete;
258
+
259
+ const headerClassName = collapsible ? 'cursor-pointer select-none' : undefined;
260
+ const handleHeaderClick = collapsible ? () => setCollapsed((c) => !c) : undefined;
92
261
 
93
262
  return (
94
263
  <Card className={className}>
95
- <CardHeader>
264
+ <CardHeader className={headerClassName} onClick={handleHeaderClick}>
96
265
  <CardTitle className="flex items-center justify-between">
97
- <span>{title}</span>
98
- <span className="text-sm font-normal text-muted-foreground">
99
- {relatedData.length} record{relatedData.length !== 1 ? 's' : ''}
100
- </span>
266
+ <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
+ )}
272
+ <span>{title}</span>
273
+ <Badge variant="secondary" className="text-xs font-normal" aria-label={`${relatedData.length} records`}>
274
+ {relatedData.length}
275
+ </Badge>
276
+ </div>
277
+ <div className="flex items-center gap-1">
278
+ {onNew && (
279
+ <Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onNew(); }} className="gap-1 h-7 text-xs">
280
+ <Plus className="h-3.5 w-3.5" />
281
+ {t('detail.new')}
282
+ </Button>
283
+ )}
284
+ {onViewAll && (
285
+ <Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onViewAll(); }} className="gap-1 h-7 text-xs">
286
+ {t('detail.viewAll')}
287
+ <ExternalLink className="h-3 w-3" />
288
+ </Button>
289
+ )}
290
+ </div>
101
291
  </CardTitle>
102
292
  </CardHeader>
103
- <CardContent>
293
+ {!collapsed && <CardContent>
294
+ {/* Filter bar */}
295
+ {filterable && relatedData.length > 0 && (
296
+ <div className="mb-3">
297
+ <Input
298
+ placeholder={t('detail.filterPlaceholder')}
299
+ value={filterText}
300
+ onChange={(e) => setFilterText(e.target.value)}
301
+ className="h-8 text-sm"
302
+ />
303
+ </div>
304
+ )}
305
+
306
+ {/* Sortable column headers */}
307
+ {sortable && effectiveColumns && effectiveColumns.length > 0 && relatedData.length > 0 && (
308
+ <div className="flex flex-wrap gap-1 mb-3">
309
+ {effectiveColumns.map((col: any) => {
310
+ const field = col.accessorKey || col.field || col.name;
311
+ if (!field) return null;
312
+ const label = col.header || col.label || field;
313
+ const isActive = sortField === field;
314
+ return (
315
+ <Button
316
+ key={field}
317
+ variant={isActive ? 'secondary' : 'ghost'}
318
+ size="sm"
319
+ className="gap-1 h-7 text-xs"
320
+ onClick={() => handleSort(field)}
321
+ >
322
+ <ArrowUpDown className="h-3 w-3" />
323
+ {label}
324
+ {isActive && (sortDirection === 'asc' ? ' ↑' : ' ↓')}
325
+ </Button>
326
+ );
327
+ })}
328
+ </div>
329
+ )}
330
+
104
331
  {loading ? (
105
332
  <div className="flex items-center justify-center py-8 text-muted-foreground">
106
- Loading...
333
+ {t('detail.loading')}
107
334
  </div>
108
335
  ) : relatedData.length === 0 ? (
109
336
  <div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
110
- No related records found
337
+ {t('detail.noRelatedRecords')}
111
338
  </div>
112
339
  ) : (
113
- <SchemaRenderer schema={viewSchema} />
340
+ <>
341
+ <SchemaRenderer schema={viewSchema} />
342
+
343
+ {/* Row-level actions (rendered as a simple action list below data) */}
344
+ {hasRowActions && paginatedData.length > 0 && (
345
+ <div className="mt-2 space-y-1" data-testid="row-actions">
346
+ {paginatedData.map((row, idx) => (
347
+ <div key={row.id || idx} className="flex items-center justify-between px-2 py-1 text-xs border-b last:border-b-0">
348
+ <span className="truncate text-muted-foreground">
349
+ {row.name || row.title || row.id || `Row ${idx + 1}`}
350
+ </span>
351
+ <div className="flex items-center gap-1">
352
+ {onRowEdit && (
353
+ <Button
354
+ variant="ghost"
355
+ size="sm"
356
+ className="h-6 text-xs gap-1 px-2"
357
+ onClick={() => onRowEdit(row)}
358
+ >
359
+ <Edit className="h-3 w-3" />
360
+ {t('detail.editRow')}
361
+ </Button>
362
+ )}
363
+ {onRowDelete && (
364
+ <Button
365
+ variant="ghost"
366
+ size="sm"
367
+ className="h-6 text-xs gap-1 px-2 text-destructive hover:text-destructive"
368
+ onClick={() => handleDeleteRow(row)}
369
+ >
370
+ <Trash2 className="h-3 w-3" />
371
+ {t('detail.deleteRow')}
372
+ </Button>
373
+ )}
374
+ </div>
375
+ </div>
376
+ ))}
377
+ </div>
378
+ )}
379
+ </>
380
+ )}
381
+
382
+ {/* Pagination controls */}
383
+ {effectivePageSize > 0 && sortedData.length > effectivePageSize && (
384
+ <div className="flex items-center justify-between mt-3 pt-3 border-t">
385
+ <Button
386
+ variant="outline"
387
+ size="sm"
388
+ className="h-7 text-xs gap-1"
389
+ disabled={currentPage === 0}
390
+ onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
391
+ >
392
+ <ChevronLeft className="h-3 w-3" />
393
+ {t('detail.previousPage')}
394
+ </Button>
395
+ <span className="text-xs text-muted-foreground">
396
+ {t('detail.pageOf', { current: currentPage + 1, total: totalPages })}
397
+ </span>
398
+ <Button
399
+ variant="outline"
400
+ size="sm"
401
+ className="h-7 text-xs gap-1"
402
+ disabled={currentPage >= totalPages - 1}
403
+ onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
404
+ >
405
+ {t('detail.nextPage')}
406
+ <ChevronRight className="h-3 w-3" />
407
+ </Button>
408
+ </div>
114
409
  )}
115
- </CardContent>
410
+ </CardContent>}
116
411
  </Card>
117
412
  );
118
413
  };
@@ -0,0 +1,286 @@
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 { Network } from 'lucide-react';
12
+
13
+ export interface GraphNode {
14
+ id: string;
15
+ label: string;
16
+ type?: string;
17
+ relatedRecords?: GraphNode[];
18
+ }
19
+
20
+ export interface RelationshipGraphProps {
21
+ record: GraphNode;
22
+ relatedRecords: GraphNode[];
23
+ levels?: number;
24
+ onNodeClick?: (nodeId: string) => void;
25
+ className?: string;
26
+ }
27
+
28
+ interface LayoutNode {
29
+ id: string;
30
+ label: string;
31
+ type?: string;
32
+ x: number;
33
+ y: number;
34
+ level: number;
35
+ }
36
+
37
+ interface LayoutEdge {
38
+ fromId: string;
39
+ toId: string;
40
+ fromX: number;
41
+ fromY: number;
42
+ toX: number;
43
+ toY: number;
44
+ }
45
+
46
+ const NODE_RADIUS = 28;
47
+ const LEVEL_COLORS = [
48
+ 'fill-primary stroke-primary',
49
+ 'fill-blue-500 stroke-blue-500',
50
+ 'fill-emerald-500 stroke-emerald-500',
51
+ 'fill-amber-500 stroke-amber-500',
52
+ ];
53
+ const LEVEL_TEXT_COLORS = [
54
+ 'fill-primary-foreground',
55
+ 'fill-white',
56
+ 'fill-white',
57
+ 'fill-white',
58
+ ];
59
+
60
+ /** Compute layout positions for nodes in concentric rings. */
61
+ function computeLayout(
62
+ center: GraphNode,
63
+ relatedRecords: GraphNode[],
64
+ levels: number,
65
+ width: number,
66
+ height: number,
67
+ ): { nodes: LayoutNode[]; edges: LayoutEdge[] } {
68
+ const nodes: LayoutNode[] = [];
69
+ const edges: LayoutEdge[] = [];
70
+ const seen = new Set<string>();
71
+
72
+ const cx = width / 2;
73
+ const cy = height / 2;
74
+
75
+ // Center node
76
+ nodes.push({ id: center.id, label: center.label, type: center.type, x: cx, y: cy, level: 0 });
77
+ seen.add(center.id);
78
+
79
+ // Level 1: direct relations
80
+ const ringRadius1 = Math.min(width, height) * 0.32;
81
+ const level1Nodes = relatedRecords.filter((r) => !seen.has(r.id));
82
+
83
+ level1Nodes.forEach((node, i) => {
84
+ const angle = (2 * Math.PI * i) / level1Nodes.length - Math.PI / 2;
85
+ const x = cx + ringRadius1 * Math.cos(angle);
86
+ const y = cy + ringRadius1 * Math.sin(angle);
87
+ nodes.push({ id: node.id, label: node.label, type: node.type, x, y, level: 1 });
88
+ edges.push({ fromId: center.id, toId: node.id, fromX: cx, fromY: cy, toX: x, toY: y });
89
+ seen.add(node.id);
90
+ });
91
+
92
+ // Level 2+: related records of related records
93
+ if (levels >= 2) {
94
+ const ringRadius2 = Math.min(width, height) * 0.46;
95
+ const level2Nodes: { node: GraphNode; parentX: number; parentY: number; parentId: string }[] = [];
96
+
97
+ level1Nodes.forEach((parentNode) => {
98
+ const parentLayoutNode = nodes.find((n) => n.id === parentNode.id);
99
+ if (!parentLayoutNode) return;
100
+ const children = (parentNode.relatedRecords || []).filter((r) => !seen.has(r.id));
101
+ children.forEach((child) => {
102
+ level2Nodes.push({
103
+ node: child,
104
+ parentX: parentLayoutNode.x,
105
+ parentY: parentLayoutNode.y,
106
+ parentId: parentNode.id,
107
+ });
108
+ seen.add(child.id);
109
+ });
110
+ });
111
+
112
+ level2Nodes.forEach((item, i) => {
113
+ const angle = (2 * Math.PI * i) / Math.max(level2Nodes.length, 1) - Math.PI / 2;
114
+ const x = cx + ringRadius2 * Math.cos(angle);
115
+ const y = cy + ringRadius2 * Math.sin(angle);
116
+ nodes.push({
117
+ id: item.node.id,
118
+ label: item.node.label,
119
+ type: item.node.type,
120
+ x,
121
+ y,
122
+ level: 2,
123
+ });
124
+ edges.push({
125
+ fromId: item.parentId,
126
+ toId: item.node.id,
127
+ fromX: item.parentX,
128
+ fromY: item.parentY,
129
+ toX: x,
130
+ toY: y,
131
+ });
132
+ });
133
+ }
134
+
135
+ return { nodes, edges };
136
+ }
137
+
138
+ /** Truncate label to fit inside a node circle. */
139
+ function truncateLabel(label: string, maxLen: number = 6): string {
140
+ if (label.length <= maxLen) return label;
141
+ return label.slice(0, maxLen - 1) + '…';
142
+ }
143
+
144
+ export const RelationshipGraph: React.FC<RelationshipGraphProps> = ({
145
+ record,
146
+ relatedRecords,
147
+ levels = 1,
148
+ onNodeClick,
149
+ className,
150
+ }) => {
151
+ const svgRef = React.useRef<SVGSVGElement>(null);
152
+ const [dimensions, setDimensions] = React.useState({ width: 500, height: 400 });
153
+ const [hoveredNode, setHoveredNode] = React.useState<string | null>(null);
154
+
155
+ // Observe container size
156
+ React.useEffect(() => {
157
+ const svg = svgRef.current;
158
+ if (!svg) return;
159
+ const parent = svg.parentElement;
160
+ if (!parent) return;
161
+
162
+ const observer = new ResizeObserver((entries) => {
163
+ for (const entry of entries) {
164
+ const { width } = entry.contentRect;
165
+ if (width > 0) {
166
+ setDimensions({ width, height: Math.max(300, width * 0.7) });
167
+ }
168
+ }
169
+ });
170
+ observer.observe(parent);
171
+ return () => observer.disconnect();
172
+ }, []);
173
+
174
+ const { nodes, edges } = React.useMemo(
175
+ () => computeLayout(record, relatedRecords, levels, dimensions.width, dimensions.height),
176
+ [record, relatedRecords, levels, dimensions],
177
+ );
178
+
179
+ return (
180
+ <Card className={cn('overflow-hidden', className)}>
181
+ <CardHeader className="pb-2">
182
+ <CardTitle className="flex items-center gap-2 text-base">
183
+ <Network className="h-4 w-4" />
184
+ Relationships
185
+ <span className="text-sm font-normal text-muted-foreground">
186
+ ({relatedRecords.length} related)
187
+ </span>
188
+ </CardTitle>
189
+ </CardHeader>
190
+ <CardContent className="p-0">
191
+ <svg
192
+ ref={svgRef}
193
+ width="100%"
194
+ height={dimensions.height}
195
+ viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
196
+ className="select-none"
197
+ >
198
+ {/* Edges */}
199
+ {edges.map((edge, i) => (
200
+ <line
201
+ key={`edge-${i}`}
202
+ x1={edge.fromX}
203
+ y1={edge.fromY}
204
+ x2={edge.toX}
205
+ y2={edge.toY}
206
+ className="stroke-border"
207
+ strokeWidth={1.5}
208
+ strokeOpacity={0.5}
209
+ />
210
+ ))}
211
+
212
+ {/* Nodes */}
213
+ {nodes.map((node) => {
214
+ const isHovered = hoveredNode === node.id;
215
+ const levelColor = LEVEL_COLORS[Math.min(node.level, LEVEL_COLORS.length - 1)];
216
+ const textColor = LEVEL_TEXT_COLORS[Math.min(node.level, LEVEL_TEXT_COLORS.length - 1)];
217
+ const radius = node.level === 0 ? NODE_RADIUS + 6 : NODE_RADIUS;
218
+
219
+ return (
220
+ <g
221
+ key={node.id}
222
+ className={cn('cursor-pointer transition-transform', onNodeClick && 'hover:opacity-80')}
223
+ onClick={() => onNodeClick?.(node.id)}
224
+ onMouseEnter={() => setHoveredNode(node.id)}
225
+ onMouseLeave={() => setHoveredNode(null)}
226
+ >
227
+ <circle
228
+ cx={node.x}
229
+ cy={node.y}
230
+ r={isHovered ? radius + 3 : radius}
231
+ className={levelColor}
232
+ fillOpacity={node.level === 0 ? 1 : 0.85}
233
+ strokeWidth={2}
234
+ strokeOpacity={0.3}
235
+ />
236
+ <text
237
+ x={node.x}
238
+ y={node.y}
239
+ textAnchor="middle"
240
+ dominantBaseline="central"
241
+ className={cn('text-[10px] font-medium pointer-events-none', textColor)}
242
+ >
243
+ {truncateLabel(node.label)}
244
+ </text>
245
+ {/* Type label below */}
246
+ {node.type && (
247
+ <text
248
+ x={node.x}
249
+ y={node.y + radius + 12}
250
+ textAnchor="middle"
251
+ className="fill-muted-foreground text-[9px] pointer-events-none"
252
+ >
253
+ {node.type}
254
+ </text>
255
+ )}
256
+ {/* Tooltip on hover */}
257
+ {isHovered && (
258
+ <>
259
+ <rect
260
+ x={node.x - 50}
261
+ y={node.y - radius - 28}
262
+ width={100}
263
+ height={20}
264
+ rx={4}
265
+ className="fill-popover stroke-border"
266
+ strokeWidth={1}
267
+ />
268
+ <text
269
+ x={node.x}
270
+ y={node.y - radius - 16}
271
+ textAnchor="middle"
272
+ dominantBaseline="central"
273
+ className="fill-popover-foreground text-[10px] pointer-events-none"
274
+ >
275
+ {node.label}
276
+ </text>
277
+ </>
278
+ )}
279
+ </g>
280
+ );
281
+ })}
282
+ </svg>
283
+ </CardContent>
284
+ </Card>
285
+ );
286
+ };