@object-ui/plugin-detail 3.1.5 → 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 (209) hide show
  1. package/CHANGELOG.md +31 -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-YGj51ozd.js → AvatarField-Dy2XGlPz.js} +16 -15
  6. package/dist/{BooleanField-CaA898Tk.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-I1A9oEGC.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-DBtluGJ1.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-B_Mnr63i.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-CddhEK9u.js → SignatureField-B1wh3f5A.js} +18 -17
  31. package/dist/{SliderField-Df5hMzNc.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.d.ts +1 -1
  40. package/dist/index.js +1741 -1504
  41. package/dist/index.umd.cjs +43 -51
  42. package/dist/packages/plugin-detail/src/ActivityTimeline.d.ts.map +1 -0
  43. package/dist/packages/plugin-detail/src/CommentAttachment.d.ts.map +1 -0
  44. package/dist/packages/plugin-detail/src/CommentInput.d.ts.map +1 -0
  45. package/dist/packages/plugin-detail/src/DetailSection.d.ts.map +1 -0
  46. package/dist/packages/plugin-detail/src/DetailTabs.d.ts.map +1 -0
  47. package/dist/packages/plugin-detail/src/DetailView.d.ts +47 -0
  48. package/dist/packages/plugin-detail/src/DetailView.d.ts.map +1 -0
  49. package/dist/packages/plugin-detail/src/DetailView.stories.d.ts.map +1 -0
  50. package/dist/packages/plugin-detail/src/DiffView.d.ts.map +1 -0
  51. package/dist/packages/plugin-detail/src/FieldChangeItem.d.ts.map +1 -0
  52. package/dist/packages/plugin-detail/src/HeaderHighlight.d.ts.map +1 -0
  53. package/dist/packages/plugin-detail/src/InlineCreateRelated.d.ts.map +1 -0
  54. package/dist/packages/plugin-detail/src/MentionAutocomplete.d.ts.map +1 -0
  55. package/dist/packages/plugin-detail/src/PointInTimeRestore.d.ts.map +1 -0
  56. package/dist/packages/plugin-detail/src/ReactionPicker.d.ts.map +1 -0
  57. package/dist/packages/plugin-detail/src/RecordActivityTimeline.d.ts.map +1 -0
  58. package/dist/packages/plugin-detail/src/RecordChatterPanel.d.ts.map +1 -0
  59. package/dist/packages/plugin-detail/src/RecordComments.d.ts.map +1 -0
  60. package/dist/packages/plugin-detail/src/RecordNavigationEnhanced.d.ts.map +1 -0
  61. package/dist/{src → packages/plugin-detail/src}/RelatedList.d.ts +8 -0
  62. package/dist/packages/plugin-detail/src/RelatedList.d.ts.map +1 -0
  63. package/dist/packages/plugin-detail/src/RelationshipGraph.d.ts.map +1 -0
  64. package/dist/packages/plugin-detail/src/RichTextCommentInput.d.ts.map +1 -0
  65. package/dist/packages/plugin-detail/src/SectionGroup.d.ts.map +1 -0
  66. package/dist/packages/plugin-detail/src/SubscriptionToggle.d.ts.map +1 -0
  67. package/dist/packages/plugin-detail/src/ThreadedReplies.d.ts.map +1 -0
  68. package/dist/packages/plugin-detail/src/autoLayout.d.ts.map +1 -0
  69. package/dist/packages/plugin-detail/src/index.d.ts.map +1 -0
  70. package/dist/packages/plugin-detail/src/useDetailTranslation.d.ts.map +1 -0
  71. package/dist/plugin-detail.css +1 -2
  72. package/dist/rolldown-runtime-DnwLefa7.js +23 -0
  73. package/dist/{src-CXr1-vVl.js → src-DyUKLvMN.js} +29788 -37711
  74. package/dist/useFieldTranslation-BRgjC1oq.js +9 -0
  75. package/package.json +34 -12
  76. package/.turbo/turbo-build.log +0 -61
  77. package/dist/AddressField-DBkEyMcG.js +0 -93
  78. package/dist/AutoNumberField-Baa191z-.js +0 -14
  79. package/dist/CodeField-BU51nl1L.js +0 -22
  80. package/dist/ColorField-Cnf6ZM7c.js +0 -37
  81. package/dist/CurrencyField-Wg-XOId2.js +0 -51
  82. package/dist/DateField-Cth1ky_m.js +0 -21
  83. package/dist/DateTimeField-B0m6FhHL.js +0 -32
  84. package/dist/EmailField-Do7qT_L_.js +0 -28
  85. package/dist/FileField-aRJAdbQb.js +0 -151
  86. package/dist/FormulaField-DTMkagFx.js +0 -14
  87. package/dist/GeolocationField-RqpHWTEv.js +0 -113
  88. package/dist/GridField-D4IH0cpo.js +0 -51
  89. package/dist/ImageField-BYCFajjr.js +0 -75
  90. package/dist/LocationField-Bi_ew9sd.js +0 -35
  91. package/dist/LookupField-BjwlDPtt.js +0 -902
  92. package/dist/NumberField-D_NucQlp.js +0 -26
  93. package/dist/ObjectField-CG-LaM65.js +0 -52
  94. package/dist/PercentField-B6sO_J3i.js +0 -63
  95. package/dist/PhoneField-CcQAWwR6.js +0 -28
  96. package/dist/QRCodeField-CEjWs-J5.js +0 -72
  97. package/dist/RichTextField-qOEJl5Ai.js +0 -32
  98. package/dist/SelectField-C8hWu3gm.js +0 -30
  99. package/dist/SummaryField-DgiFm-Cr.js +0 -19
  100. package/dist/TextAreaField-DuriTqsD.js +0 -36
  101. package/dist/TextField-CGNSl7RU.js +0 -29
  102. package/dist/TimeField-YO58ctFg.js +0 -21
  103. package/dist/UrlField-1-BMM1jn.js +0 -33
  104. package/dist/UserField-B6GqxP_S.js +0 -78
  105. package/dist/VectorField-BkEjbSt0.js +0 -36
  106. package/dist/src/ActivityTimeline.d.ts.map +0 -1
  107. package/dist/src/CommentAttachment.d.ts.map +0 -1
  108. package/dist/src/CommentInput.d.ts.map +0 -1
  109. package/dist/src/DetailSection.d.ts.map +0 -1
  110. package/dist/src/DetailTabs.d.ts.map +0 -1
  111. package/dist/src/DetailView.d.ts +0 -23
  112. package/dist/src/DetailView.d.ts.map +0 -1
  113. package/dist/src/DetailView.stories.d.ts.map +0 -1
  114. package/dist/src/DiffView.d.ts.map +0 -1
  115. package/dist/src/FieldChangeItem.d.ts.map +0 -1
  116. package/dist/src/HeaderHighlight.d.ts.map +0 -1
  117. package/dist/src/InlineCreateRelated.d.ts.map +0 -1
  118. package/dist/src/MentionAutocomplete.d.ts.map +0 -1
  119. package/dist/src/PointInTimeRestore.d.ts.map +0 -1
  120. package/dist/src/ReactionPicker.d.ts.map +0 -1
  121. package/dist/src/RecordActivityTimeline.d.ts.map +0 -1
  122. package/dist/src/RecordChatterPanel.d.ts.map +0 -1
  123. package/dist/src/RecordComments.d.ts.map +0 -1
  124. package/dist/src/RecordNavigationEnhanced.d.ts.map +0 -1
  125. package/dist/src/RelatedList.d.ts.map +0 -1
  126. package/dist/src/RelationshipGraph.d.ts.map +0 -1
  127. package/dist/src/RichTextCommentInput.d.ts.map +0 -1
  128. package/dist/src/SectionGroup.d.ts.map +0 -1
  129. package/dist/src/SubscriptionToggle.d.ts.map +0 -1
  130. package/dist/src/ThreadedReplies.d.ts.map +0 -1
  131. package/dist/src/autoLayout.d.ts.map +0 -1
  132. package/dist/src/index.d.ts.map +0 -1
  133. package/dist/src/useDetailTranslation.d.ts.map +0 -1
  134. package/src/ActivityTimeline.tsx +0 -184
  135. package/src/CommentAttachment.tsx +0 -192
  136. package/src/CommentInput.tsx +0 -81
  137. package/src/DetailSection.tsx +0 -340
  138. package/src/DetailTabs.tsx +0 -73
  139. package/src/DetailView.stories.tsx +0 -334
  140. package/src/DetailView.tsx +0 -823
  141. package/src/DiffView.tsx +0 -231
  142. package/src/FieldChangeItem.tsx +0 -46
  143. package/src/HeaderHighlight.tsx +0 -88
  144. package/src/InlineCreateRelated.tsx +0 -291
  145. package/src/MentionAutocomplete.tsx +0 -123
  146. package/src/PointInTimeRestore.tsx +0 -261
  147. package/src/ReactionPicker.tsx +0 -106
  148. package/src/RecordActivityTimeline.tsx +0 -429
  149. package/src/RecordChatterPanel.tsx +0 -207
  150. package/src/RecordComments.tsx +0 -215
  151. package/src/RecordNavigationEnhanced.tsx +0 -211
  152. package/src/RelatedList.tsx +0 -413
  153. package/src/RelationshipGraph.tsx +0 -286
  154. package/src/RichTextCommentInput.tsx +0 -348
  155. package/src/SectionGroup.tsx +0 -101
  156. package/src/SubscriptionToggle.tsx +0 -60
  157. package/src/ThreadedReplies.tsx +0 -161
  158. package/src/__tests__/ActivityTimeline.test.tsx +0 -119
  159. package/src/__tests__/ActivityTimelineFiltering.test.tsx +0 -143
  160. package/src/__tests__/CommentInput.test.tsx +0 -57
  161. package/src/__tests__/DetailSection.test.tsx +0 -490
  162. package/src/__tests__/DetailView.test.tsx +0 -694
  163. package/src/__tests__/FieldChangeItem.test.tsx +0 -119
  164. package/src/__tests__/HeaderHighlight.test.tsx +0 -213
  165. package/src/__tests__/MentionAutocomplete.test.tsx +0 -97
  166. package/src/__tests__/ReactionPicker.test.tsx +0 -113
  167. package/src/__tests__/RecordActivityTimeline.test.tsx +0 -395
  168. package/src/__tests__/RecordChatterPanel.test.tsx +0 -265
  169. package/src/__tests__/RecordComments.test.tsx +0 -96
  170. package/src/__tests__/RecordCommentsPinSearch.test.tsx +0 -133
  171. package/src/__tests__/RelatedList.test.tsx +0 -160
  172. package/src/__tests__/SectionGroup.test.tsx +0 -101
  173. package/src/__tests__/SubscriptionToggle.test.tsx +0 -84
  174. package/src/__tests__/ThreadedReplies.test.tsx +0 -212
  175. package/src/__tests__/autoLayout.test.ts +0 -228
  176. package/src/__tests__/phase12-features.test.tsx +0 -583
  177. package/src/__tests__/roadmap-features.test.tsx +0 -478
  178. package/src/autoLayout.ts +0 -128
  179. package/src/index.tsx +0 -149
  180. package/src/useDetailTranslation.ts +0 -114
  181. package/tsconfig.json +0 -18
  182. package/vite.config.ts +0 -56
  183. package/vitest.config.ts +0 -13
  184. package/vitest.setup.ts +0 -1
  185. /package/dist/{src → packages/plugin-detail/src}/ActivityTimeline.d.ts +0 -0
  186. /package/dist/{src → packages/plugin-detail/src}/CommentAttachment.d.ts +0 -0
  187. /package/dist/{src → packages/plugin-detail/src}/CommentInput.d.ts +0 -0
  188. /package/dist/{src → packages/plugin-detail/src}/DetailSection.d.ts +0 -0
  189. /package/dist/{src → packages/plugin-detail/src}/DetailTabs.d.ts +0 -0
  190. /package/dist/{src → packages/plugin-detail/src}/DetailView.stories.d.ts +0 -0
  191. /package/dist/{src → packages/plugin-detail/src}/DiffView.d.ts +0 -0
  192. /package/dist/{src → packages/plugin-detail/src}/FieldChangeItem.d.ts +0 -0
  193. /package/dist/{src → packages/plugin-detail/src}/HeaderHighlight.d.ts +0 -0
  194. /package/dist/{src → packages/plugin-detail/src}/InlineCreateRelated.d.ts +0 -0
  195. /package/dist/{src → packages/plugin-detail/src}/MentionAutocomplete.d.ts +0 -0
  196. /package/dist/{src → packages/plugin-detail/src}/PointInTimeRestore.d.ts +0 -0
  197. /package/dist/{src → packages/plugin-detail/src}/ReactionPicker.d.ts +0 -0
  198. /package/dist/{src → packages/plugin-detail/src}/RecordActivityTimeline.d.ts +0 -0
  199. /package/dist/{src → packages/plugin-detail/src}/RecordChatterPanel.d.ts +0 -0
  200. /package/dist/{src → packages/plugin-detail/src}/RecordComments.d.ts +0 -0
  201. /package/dist/{src → packages/plugin-detail/src}/RecordNavigationEnhanced.d.ts +0 -0
  202. /package/dist/{src → packages/plugin-detail/src}/RelationshipGraph.d.ts +0 -0
  203. /package/dist/{src → packages/plugin-detail/src}/RichTextCommentInput.d.ts +0 -0
  204. /package/dist/{src → packages/plugin-detail/src}/SectionGroup.d.ts +0 -0
  205. /package/dist/{src → packages/plugin-detail/src}/SubscriptionToggle.d.ts +0 -0
  206. /package/dist/{src → packages/plugin-detail/src}/ThreadedReplies.d.ts +0 -0
  207. /package/dist/{src → packages/plugin-detail/src}/autoLayout.d.ts +0 -0
  208. /package/dist/{src → packages/plugin-detail/src}/index.d.ts +0 -0
  209. /package/dist/{src → packages/plugin-detail/src}/useDetailTranslation.d.ts +0 -0
@@ -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
- };