@object-ui/plugin-detail 3.3.0 → 3.3.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 (134) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +21 -1
  3. package/dist/AddressField-LgHnO2Lk.js +98 -0
  4. package/dist/AutoNumberField-xZCrU0eW.js +14 -0
  5. package/dist/{AvatarField-Xuieq0ZI.js → AvatarField-Dy2XGlPz.js} +16 -15
  6. package/dist/{BooleanField-DwfMKknK.js → BooleanField-C0Clfka5.js} +11 -10
  7. package/dist/CodeField-CHUa07B6.js +23 -0
  8. package/dist/ColorField-vxHqEhcS.js +38 -0
  9. package/dist/CurrencyField-DiWjYWDo.js +49 -0
  10. package/dist/DateField-DGaRPM4P.js +22 -0
  11. package/dist/DateTimeField-8QnpsI_h.js +30 -0
  12. package/dist/EmailField-CkVgMbpI.js +26 -0
  13. package/dist/FileField-5UPV7uek.js +149 -0
  14. package/dist/FormulaField-BUgt6-Pi.js +17 -0
  15. package/dist/GeolocationField-D9T_jgG6.js +118 -0
  16. package/dist/GridField-DE_HwiIN.js +49 -0
  17. package/dist/ImageField-Dswnqtzf.js +73 -0
  18. package/dist/LocationField-gjqbE6na.js +36 -0
  19. package/dist/LookupField-BcS3LRKc.js +901 -0
  20. package/dist/{MasterDetailField-B0HTmmD7.js → MasterDetailField-BF6_-X3A.js} +20 -19
  21. package/dist/NumberField-Dj2rYmrS.js +27 -0
  22. package/dist/ObjectField-BymIojwd.js +50 -0
  23. package/dist/{PasswordField-DVTimsc3.js → PasswordField-ED_Xgqz-.js} +8 -7
  24. package/dist/PercentField-D-JKOxKC.js +61 -0
  25. package/dist/PhoneField-DSCaGYq7.js +26 -0
  26. package/dist/QRCodeField-CtcOUapi.js +73 -0
  27. package/dist/{RatingField-rRi_P0N0.js → RatingField-BDnyQFWy.js} +10 -9
  28. package/dist/RichTextField-CH6LVZQA.js +33 -0
  29. package/dist/SelectField-DE4dpkMV.js +36 -0
  30. package/dist/{SignatureField-2CnhcWI0.js → SignatureField-B1wh3f5A.js} +18 -17
  31. package/dist/{SliderField-DEpMVXko.js → SliderField-zoTCKh9n.js} +2 -1
  32. package/dist/SummaryField-BeBVT6VN.js +22 -0
  33. package/dist/TextAreaField-rfUGrRxh.js +37 -0
  34. package/dist/TextField-C_yM7ATQ.js +30 -0
  35. package/dist/TimeField-BcQmBZi9.js +22 -0
  36. package/dist/UrlField-BakaF6NI.js +31 -0
  37. package/dist/UserField-zS7y3eKb.js +76 -0
  38. package/dist/VectorField-CTZ4myDM.js +34 -0
  39. package/dist/index.js +1912 -1728
  40. package/dist/index.umd.cjs +38 -47
  41. package/dist/packages/plugin-detail/src/DetailSection.d.ts.map +1 -1
  42. package/dist/packages/plugin-detail/src/DetailView.d.ts +24 -0
  43. package/dist/packages/plugin-detail/src/DetailView.d.ts.map +1 -1
  44. package/dist/packages/plugin-detail/src/RelatedList.d.ts +8 -0
  45. package/dist/packages/plugin-detail/src/RelatedList.d.ts.map +1 -1
  46. package/dist/packages/plugin-detail/src/useDetailTranslation.d.ts.map +1 -1
  47. package/dist/plugin-detail.css +1 -2
  48. package/dist/rolldown-runtime-DnwLefa7.js +23 -0
  49. package/dist/{src-C56Ly5uG.js → src-DyUKLvMN.js} +18271 -26636
  50. package/dist/{useFieldTranslation-CkxqyB82.js → useFieldTranslation-BRgjC1oq.js} +1 -1
  51. package/package.json +33 -11
  52. package/.turbo/turbo-build.log +0 -64
  53. package/dist/AddressField-CDLSeyNx.js +0 -93
  54. package/dist/AutoNumberField-CtE7suf5.js +0 -14
  55. package/dist/CodeField-CfwgRxx2.js +0 -22
  56. package/dist/ColorField-YKHA7dBD.js +0 -37
  57. package/dist/CurrencyField-tvS3fPAF.js +0 -51
  58. package/dist/DateField-BKqXpkOh.js +0 -21
  59. package/dist/DateTimeField-CR-nJCE7.js +0 -32
  60. package/dist/EmailField-CgvW1Qal.js +0 -28
  61. package/dist/FileField-BVAme2ML.js +0 -151
  62. package/dist/FormulaField-DamJ2VaG.js +0 -14
  63. package/dist/GeolocationField-C99z7ZBM.js +0 -113
  64. package/dist/GridField-C9JbpTx_.js +0 -51
  65. package/dist/ImageField-CDANtgVV.js +0 -75
  66. package/dist/LocationField-ZSyZ0O-h.js +0 -35
  67. package/dist/LookupField-B3hQJt95.js +0 -903
  68. package/dist/LookupField-D00z6gn_.js +0 -2
  69. package/dist/NumberField-DL2QAL7X.js +0 -26
  70. package/dist/ObjectField-JYvUnuRO.js +0 -52
  71. package/dist/PercentField-DjR6BSpw.js +0 -63
  72. package/dist/PhoneField-CX1JL-jp.js +0 -28
  73. package/dist/QRCodeField-CH_1pU6R.js +0 -72
  74. package/dist/RichTextField-CJqLWlrb.js +0 -32
  75. package/dist/SelectField-DGoDoRM_.js +0 -30
  76. package/dist/SelectField-XBVI50AD.js +0 -2
  77. package/dist/SummaryField-7ch9aqAu.js +0 -19
  78. package/dist/TextAreaField-Cmw1oXcw.js +0 -36
  79. package/dist/TextField-OTLa3p51.js +0 -29
  80. package/dist/TimeField-DKPoNWoR.js +0 -21
  81. package/dist/UrlField-CxbmzP9f.js +0 -33
  82. package/dist/UserField-ChvwUkMK.js +0 -78
  83. package/dist/VectorField-BVClL8Vw.js +0 -36
  84. package/src/ActivityTimeline.tsx +0 -184
  85. package/src/CommentAttachment.tsx +0 -194
  86. package/src/CommentInput.tsx +0 -81
  87. package/src/DetailSection.tsx +0 -340
  88. package/src/DetailTabs.tsx +0 -73
  89. package/src/DetailView.stories.tsx +0 -334
  90. package/src/DetailView.tsx +0 -823
  91. package/src/DiffView.tsx +0 -233
  92. package/src/FieldChangeItem.tsx +0 -46
  93. package/src/HeaderHighlight.tsx +0 -88
  94. package/src/InlineCreateRelated.tsx +0 -291
  95. package/src/MentionAutocomplete.tsx +0 -123
  96. package/src/PointInTimeRestore.tsx +0 -261
  97. package/src/ReactionPicker.tsx +0 -106
  98. package/src/RecordActivityTimeline.tsx +0 -433
  99. package/src/RecordChatterPanel.tsx +0 -209
  100. package/src/RecordComments.tsx +0 -217
  101. package/src/RecordNavigationEnhanced.tsx +0 -213
  102. package/src/RelatedList.tsx +0 -413
  103. package/src/RelationshipGraph.tsx +0 -286
  104. package/src/RichTextCommentInput.tsx +0 -350
  105. package/src/SectionGroup.tsx +0 -101
  106. package/src/SubscriptionToggle.tsx +0 -62
  107. package/src/ThreadedReplies.tsx +0 -163
  108. package/src/__tests__/ActivityTimeline.test.tsx +0 -119
  109. package/src/__tests__/ActivityTimelineFiltering.test.tsx +0 -143
  110. package/src/__tests__/CommentInput.test.tsx +0 -57
  111. package/src/__tests__/DetailSection.test.tsx +0 -490
  112. package/src/__tests__/DetailView.test.tsx +0 -694
  113. package/src/__tests__/FieldChangeItem.test.tsx +0 -119
  114. package/src/__tests__/HeaderHighlight.test.tsx +0 -213
  115. package/src/__tests__/MentionAutocomplete.test.tsx +0 -97
  116. package/src/__tests__/ReactionPicker.test.tsx +0 -113
  117. package/src/__tests__/RecordActivityTimeline.test.tsx +0 -395
  118. package/src/__tests__/RecordChatterPanel.test.tsx +0 -265
  119. package/src/__tests__/RecordComments.test.tsx +0 -96
  120. package/src/__tests__/RecordCommentsPinSearch.test.tsx +0 -133
  121. package/src/__tests__/RelatedList.test.tsx +0 -160
  122. package/src/__tests__/SectionGroup.test.tsx +0 -101
  123. package/src/__tests__/SubscriptionToggle.test.tsx +0 -84
  124. package/src/__tests__/ThreadedReplies.test.tsx +0 -212
  125. package/src/__tests__/autoLayout.test.ts +0 -228
  126. package/src/__tests__/phase12-features.test.tsx +0 -583
  127. package/src/__tests__/roadmap-features.test.tsx +0 -478
  128. package/src/autoLayout.ts +0 -128
  129. package/src/index.tsx +0 -149
  130. package/src/useDetailTranslation.ts +0 -183
  131. package/tsconfig.json +0 -18
  132. package/vite.config.ts +0 -57
  133. package/vitest.config.ts +0 -13
  134. package/vitest.setup.ts +0 -1
@@ -1,413 +0,0 @@
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 {
11
- Card,
12
- CardHeader,
13
- CardTitle,
14
- CardContent,
15
- Badge,
16
- Button,
17
- Input,
18
- } from '@object-ui/components';
19
- import { SchemaRenderer } from '@object-ui/react';
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';
34
-
35
- export interface RelatedListProps {
36
- title: string;
37
- type: 'list' | 'grid' | 'table';
38
- api?: string;
39
- data?: any[];
40
- schema?: any;
41
- columns?: any[];
42
- className?: string;
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;
64
- }
65
-
66
- export const RelatedList: React.FC<RelatedListProps> = ({
67
- title,
68
- type,
69
- api,
70
- data = [],
71
- schema,
72
- columns,
73
- className,
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,
85
- }) => {
86
- const [relatedData, setRelatedData] = React.useState(data);
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]);
110
-
111
- React.useEffect(() => {
112
- if (api && !data.length) {
113
- setLoading(true);
114
- if (dataSource && typeof dataSource.find === 'function') {
115
- dataSource.find(api).then((result) => {
116
- const items = Array.isArray(result)
117
- ? result
118
- : Array.isArray((result as any)?.data)
119
- ? (result as any).data
120
- : [];
121
- setRelatedData(items);
122
- setLoading(false);
123
- }).catch((err) => {
124
- console.error('Failed to fetch related data:', err);
125
- setLoading(false);
126
- });
127
- } else {
128
- fetch(api)
129
- .then(res => res.json())
130
- .then(result => {
131
- const items = Array.isArray(result) ? result : (result?.data || []);
132
- setRelatedData(items);
133
- })
134
- .catch(err => {
135
- console.error('Failed to fetch related data:', err);
136
- })
137
- .finally(() => setLoading(false));
138
- }
139
- }
140
- }, [api, data, dataSource]);
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('_') && key !== 'id')
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
-
233
- const viewSchema = React.useMemo(() => {
234
- if (schema) return schema;
235
-
236
- // Auto-generate schema based on type
237
- switch (type) {
238
- case 'grid':
239
- case 'table':
240
- return {
241
- type: 'data-table',
242
- data: paginatedData,
243
- columns: effectiveColumns,
244
- pagination: false, // We handle pagination ourselves
245
- pageSize: effectivePageSize || 10,
246
- };
247
- case 'list':
248
- return {
249
- type: 'data-list',
250
- data: paginatedData,
251
- };
252
- default:
253
- return { type: 'div', children: 'No view configured' };
254
- }
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;
261
-
262
- return (
263
- <Card className={className}>
264
- <CardHeader className={headerClassName} onClick={handleHeaderClick}>
265
- <CardTitle className="flex items-center justify-between">
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>
291
- </CardTitle>
292
- </CardHeader>
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
-
331
- {loading ? (
332
- <div className="flex items-center justify-center py-8 text-muted-foreground">
333
- {t('detail.loading')}
334
- </div>
335
- ) : relatedData.length === 0 ? (
336
- <div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
337
- {t('detail.noRelatedRecords')}
338
- </div>
339
- ) : (
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>
409
- )}
410
- </CardContent>}
411
- </Card>
412
- );
413
- };
@@ -1,286 +0,0 @@
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
- };