@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.
- package/.turbo/turbo-build.log +45 -8
- package/CHANGELOG.md +11 -0
- package/dist/AddressField-B1iVr404.js +96 -0
- package/dist/AutoNumberField-BxnFqllo.js +8 -0
- package/dist/AvatarField-Duw4xOLZ.js +82 -0
- package/dist/BooleanField-CZ4axVeq.js +37 -0
- package/dist/CodeField-BSz-mk2v.js +21 -0
- package/dist/ColorField-B522ad8m.js +42 -0
- package/dist/CurrencyField-Cwr3_pow.js +43 -0
- package/dist/DateField-DCo6dxud.js +21 -0
- package/dist/DateTimeField-BWfBuANO.js +28 -0
- package/dist/EmailField-CpwbdVCU.js +31 -0
- package/dist/FileField-DVAUAJ8e.js +133 -0
- package/dist/FormulaField-CJkkwIK8.js +9 -0
- package/dist/GeolocationField-DNCKitgo.js +123 -0
- package/dist/GridField-DSblZNfp.js +30 -0
- package/dist/ImageField-DBAlnMon.js +90 -0
- package/dist/LocationField-DsHsXA6R.js +31 -0
- package/dist/LookupField-CsT0QQz2.js +96 -0
- package/dist/MasterDetailField-Db8b7Gqs.js +108 -0
- package/dist/NumberField-0IGp7lcA.js +26 -0
- package/dist/ObjectField-BLApgJtS.js +48 -0
- package/dist/PasswordField-pHKyNlmo.js +38 -0
- package/dist/PercentField-CwgKmlIb.js +63 -0
- package/dist/PhoneField-lKtbYOdN.js +31 -0
- package/dist/QRCodeField-BTTasT3w.js +77 -0
- package/dist/RatingField-De2X-l44.js +47 -0
- package/dist/RichTextField-B5QnvUOr.js +38 -0
- package/dist/SelectField-C9AZRHWu.js +26 -0
- package/dist/SignatureField-BgcEmYzd.js +85 -0
- package/dist/SliderField-BzrttVOY.js +30 -0
- package/dist/SummaryField-ugYPYxjP.js +9 -0
- package/dist/TextAreaField-DSE_CaU6.js +39 -0
- package/dist/TextField-DFQ4T9PR.js +32 -0
- package/dist/TimeField-F0cfmsps.js +21 -0
- package/dist/UrlField-DLXrFIH-.js +33 -0
- package/dist/UserField-PXMmxJY9.js +49 -0
- package/dist/VectorField-CKg9jdGa.js +25 -0
- package/dist/index-qQ1C-yUR.js +59976 -0
- package/dist/index.js +32 -55026
- package/dist/index.umd.cjs +41 -30
- package/dist/plugin-detail.css +1 -1
- package/dist/src/ActivityTimeline.d.ts +20 -0
- package/dist/src/ActivityTimeline.d.ts.map +1 -0
- package/dist/src/CommentAttachment.d.ts +25 -0
- package/dist/src/CommentAttachment.d.ts.map +1 -0
- package/dist/src/CommentInput.d.ts +24 -0
- package/dist/src/CommentInput.d.ts.map +1 -0
- package/dist/src/DetailSection.d.ts +8 -0
- package/dist/src/DetailSection.d.ts.map +1 -1
- package/dist/src/DetailView.d.ts +4 -0
- package/dist/src/DetailView.d.ts.map +1 -1
- package/dist/src/DetailView.stories.d.ts +8 -0
- package/dist/src/DetailView.stories.d.ts.map +1 -1
- package/dist/src/DiffView.d.ts +24 -0
- package/dist/src/DiffView.d.ts.map +1 -0
- package/dist/src/FieldChangeItem.d.ts +21 -0
- package/dist/src/FieldChangeItem.d.ts.map +1 -0
- package/dist/src/HeaderHighlight.d.ts +18 -0
- package/dist/src/HeaderHighlight.d.ts.map +1 -0
- package/dist/src/InlineCreateRelated.d.ts +32 -0
- package/dist/src/InlineCreateRelated.d.ts.map +1 -0
- package/dist/src/MentionAutocomplete.d.ts +43 -0
- package/dist/src/MentionAutocomplete.d.ts.map +1 -0
- package/dist/src/PointInTimeRestore.d.ts +28 -0
- package/dist/src/PointInTimeRestore.d.ts.map +1 -0
- package/dist/src/ReactionPicker.d.ts +25 -0
- package/dist/src/ReactionPicker.d.ts.map +1 -0
- package/dist/src/RecordActivityTimeline.d.ts +49 -0
- package/dist/src/RecordActivityTimeline.d.ts.map +1 -0
- package/dist/src/RecordChatterPanel.d.ts +48 -0
- package/dist/src/RecordChatterPanel.d.ts.map +1 -0
- package/dist/src/RecordComments.d.ts +20 -0
- package/dist/src/RecordComments.d.ts.map +1 -0
- package/dist/src/RecordNavigationEnhanced.d.ts +18 -0
- package/dist/src/RecordNavigationEnhanced.d.ts.map +1 -0
- package/dist/src/RelatedList.d.ts +20 -0
- package/dist/src/RelatedList.d.ts.map +1 -1
- package/dist/src/RelationshipGraph.d.ts +23 -0
- package/dist/src/RelationshipGraph.d.ts.map +1 -0
- package/dist/src/RichTextCommentInput.d.ts +24 -0
- package/dist/src/RichTextCommentInput.d.ts.map +1 -0
- package/dist/src/SectionGroup.d.ts +21 -0
- package/dist/src/SectionGroup.d.ts.map +1 -0
- package/dist/src/SubscriptionToggle.d.ts +22 -0
- package/dist/src/SubscriptionToggle.d.ts.map +1 -0
- package/dist/src/ThreadedReplies.d.ts +26 -0
- package/dist/src/ThreadedReplies.d.ts.map +1 -0
- package/dist/src/autoLayout.d.ts +34 -0
- package/dist/src/autoLayout.d.ts.map +1 -0
- package/dist/src/index.d.ts +40 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/useDetailTranslation.d.ts +34 -0
- package/dist/src/useDetailTranslation.d.ts.map +1 -0
- package/package.json +8 -7
- package/src/ActivityTimeline.tsx +184 -0
- package/src/CommentAttachment.tsx +192 -0
- package/src/CommentInput.tsx +81 -0
- package/src/DetailSection.tsx +81 -10
- package/src/DetailView.stories.tsx +76 -0
- package/src/DetailView.tsx +519 -66
- package/src/DiffView.tsx +231 -0
- package/src/FieldChangeItem.tsx +46 -0
- package/src/HeaderHighlight.tsx +67 -0
- package/src/InlineCreateRelated.tsx +291 -0
- package/src/MentionAutocomplete.tsx +123 -0
- package/src/PointInTimeRestore.tsx +261 -0
- package/src/ReactionPicker.tsx +106 -0
- package/src/RecordActivityTimeline.tsx +429 -0
- package/src/RecordChatterPanel.tsx +202 -0
- package/src/RecordComments.tsx +215 -0
- package/src/RecordNavigationEnhanced.tsx +211 -0
- package/src/RelatedList.tsx +314 -19
- package/src/RelationshipGraph.tsx +286 -0
- package/src/RichTextCommentInput.tsx +348 -0
- package/src/SectionGroup.tsx +101 -0
- package/src/SubscriptionToggle.tsx +60 -0
- package/src/ThreadedReplies.tsx +161 -0
- package/src/__tests__/ActivityTimeline.test.tsx +119 -0
- package/src/__tests__/ActivityTimelineFiltering.test.tsx +143 -0
- package/src/__tests__/CommentInput.test.tsx +57 -0
- package/src/__tests__/DetailSection.test.tsx +320 -0
- package/src/__tests__/DetailView.test.tsx +415 -1
- package/src/__tests__/FieldChangeItem.test.tsx +119 -0
- package/src/__tests__/HeaderHighlight.test.tsx +68 -0
- package/src/__tests__/MentionAutocomplete.test.tsx +97 -0
- package/src/__tests__/ReactionPicker.test.tsx +113 -0
- package/src/__tests__/RecordActivityTimeline.test.tsx +395 -0
- package/src/__tests__/RecordChatterPanel.test.tsx +227 -0
- package/src/__tests__/RecordComments.test.tsx +96 -0
- package/src/__tests__/RecordCommentsPinSearch.test.tsx +133 -0
- package/src/__tests__/RelatedList.test.tsx +160 -0
- package/src/__tests__/SectionGroup.test.tsx +101 -0
- package/src/__tests__/SubscriptionToggle.test.tsx +84 -0
- package/src/__tests__/ThreadedReplies.test.tsx +212 -0
- package/src/__tests__/autoLayout.test.ts +184 -0
- package/src/__tests__/phase12-features.test.tsx +583 -0
- package/src/__tests__/roadmap-features.test.tsx +478 -0
- package/src/autoLayout.ts +111 -0
- package/src/index.tsx +50 -0
- package/src/useDetailTranslation.ts +114 -0
package/src/RelatedList.tsx
CHANGED
|
@@ -7,9 +7,30 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import * as React from 'react';
|
|
10
|
-
import {
|
|
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
|
|
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:
|
|
79
|
-
columns:
|
|
80
|
-
pagination:
|
|
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:
|
|
250
|
+
data: paginatedData,
|
|
87
251
|
};
|
|
88
252
|
default:
|
|
89
253
|
return { type: 'div', children: 'No view configured' };
|
|
90
254
|
}
|
|
91
|
-
}, [type,
|
|
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
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
337
|
+
{t('detail.noRelatedRecords')}
|
|
111
338
|
</div>
|
|
112
339
|
) : (
|
|
113
|
-
|
|
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
|
+
};
|