@object-ui/plugin-detail 3.0.2 → 3.1.0

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 (132) hide show
  1. package/.turbo/turbo-build.log +45 -8
  2. package/CHANGELOG.md +9 -0
  3. package/dist/AddressField-C07oUOY6.js +96 -0
  4. package/dist/AutoNumberField-BxnFqllo.js +8 -0
  5. package/dist/AvatarField-VThNABzo.js +82 -0
  6. package/dist/BooleanField-CGHKBzAi.js +37 -0
  7. package/dist/CodeField-Co_muhRR.js +21 -0
  8. package/dist/ColorField-DLid_tFz.js +42 -0
  9. package/dist/CurrencyField-Bw-LqANM.js +43 -0
  10. package/dist/DateField-BNHAzMB2.js +21 -0
  11. package/dist/DateTimeField-DjAyn_DQ.js +28 -0
  12. package/dist/EmailField-xoNcSppb.js +31 -0
  13. package/dist/FileField-DbNJwjU2.js +133 -0
  14. package/dist/FormulaField-CJkkwIK8.js +9 -0
  15. package/dist/GeolocationField-C1AnS6VV.js +123 -0
  16. package/dist/GridField-DATAHIKf.js +30 -0
  17. package/dist/ImageField-CEKJpyJp.js +90 -0
  18. package/dist/LocationField-jDWXjlpx.js +31 -0
  19. package/dist/LookupField-DQ08L9UQ.js +96 -0
  20. package/dist/MasterDetailField-Dbk529Ea.js +108 -0
  21. package/dist/NumberField-BVroN9aV.js +26 -0
  22. package/dist/ObjectField-CT3l_IHW.js +48 -0
  23. package/dist/PasswordField-DweVLEE0.js +38 -0
  24. package/dist/PercentField-ZpWUK97K.js +63 -0
  25. package/dist/PhoneField-mw-9fqZ_.js +31 -0
  26. package/dist/QRCodeField-Cbb9ck59.js +77 -0
  27. package/dist/RatingField-CSqgLS6t.js +47 -0
  28. package/dist/RichTextField-BpfBOd99.js +38 -0
  29. package/dist/SelectField-B9Ei-5jl.js +26 -0
  30. package/dist/SignatureField-DgGpHnQ8.js +85 -0
  31. package/dist/SliderField-C6HvOHd8.js +30 -0
  32. package/dist/SummaryField-ugYPYxjP.js +9 -0
  33. package/dist/TextAreaField-BK3RgzY3.js +39 -0
  34. package/dist/TextField-Bvzx3atT.js +32 -0
  35. package/dist/TimeField-Cuz9-Uai.js +21 -0
  36. package/dist/UrlField-B6XHTV73.js +33 -0
  37. package/dist/UserField-ooTul2d6.js +49 -0
  38. package/dist/VectorField-CKg9jdGa.js +25 -0
  39. package/dist/index-CnlyRfY_.js +59461 -0
  40. package/dist/index.js +30 -55026
  41. package/dist/index.umd.cjs +41 -30
  42. package/dist/plugin-detail.css +1 -1
  43. package/dist/src/ActivityTimeline.d.ts +20 -0
  44. package/dist/src/ActivityTimeline.d.ts.map +1 -0
  45. package/dist/src/CommentAttachment.d.ts +25 -0
  46. package/dist/src/CommentAttachment.d.ts.map +1 -0
  47. package/dist/src/CommentInput.d.ts +24 -0
  48. package/dist/src/CommentInput.d.ts.map +1 -0
  49. package/dist/src/DetailSection.d.ts +6 -0
  50. package/dist/src/DetailSection.d.ts.map +1 -1
  51. package/dist/src/DetailView.d.ts +4 -0
  52. package/dist/src/DetailView.d.ts.map +1 -1
  53. package/dist/src/DetailView.stories.d.ts +8 -0
  54. package/dist/src/DetailView.stories.d.ts.map +1 -1
  55. package/dist/src/DiffView.d.ts +24 -0
  56. package/dist/src/DiffView.d.ts.map +1 -0
  57. package/dist/src/FieldChangeItem.d.ts +21 -0
  58. package/dist/src/FieldChangeItem.d.ts.map +1 -0
  59. package/dist/src/InlineCreateRelated.d.ts +32 -0
  60. package/dist/src/InlineCreateRelated.d.ts.map +1 -0
  61. package/dist/src/MentionAutocomplete.d.ts +43 -0
  62. package/dist/src/MentionAutocomplete.d.ts.map +1 -0
  63. package/dist/src/PointInTimeRestore.d.ts +28 -0
  64. package/dist/src/PointInTimeRestore.d.ts.map +1 -0
  65. package/dist/src/ReactionPicker.d.ts +25 -0
  66. package/dist/src/ReactionPicker.d.ts.map +1 -0
  67. package/dist/src/RecordActivityTimeline.d.ts +49 -0
  68. package/dist/src/RecordActivityTimeline.d.ts.map +1 -0
  69. package/dist/src/RecordChatterPanel.d.ts +48 -0
  70. package/dist/src/RecordChatterPanel.d.ts.map +1 -0
  71. package/dist/src/RecordComments.d.ts +20 -0
  72. package/dist/src/RecordComments.d.ts.map +1 -0
  73. package/dist/src/RecordNavigationEnhanced.d.ts +18 -0
  74. package/dist/src/RecordNavigationEnhanced.d.ts.map +1 -0
  75. package/dist/src/RelatedList.d.ts +4 -0
  76. package/dist/src/RelatedList.d.ts.map +1 -1
  77. package/dist/src/RelationshipGraph.d.ts +23 -0
  78. package/dist/src/RelationshipGraph.d.ts.map +1 -0
  79. package/dist/src/RichTextCommentInput.d.ts +24 -0
  80. package/dist/src/RichTextCommentInput.d.ts.map +1 -0
  81. package/dist/src/SubscriptionToggle.d.ts +22 -0
  82. package/dist/src/SubscriptionToggle.d.ts.map +1 -0
  83. package/dist/src/ThreadedReplies.d.ts +26 -0
  84. package/dist/src/ThreadedReplies.d.ts.map +1 -0
  85. package/dist/src/autoLayout.d.ts +34 -0
  86. package/dist/src/autoLayout.d.ts.map +1 -0
  87. package/dist/src/index.d.ts +36 -0
  88. package/dist/src/index.d.ts.map +1 -1
  89. package/dist/src/useDetailTranslation.d.ts +34 -0
  90. package/dist/src/useDetailTranslation.d.ts.map +1 -0
  91. package/package.json +8 -7
  92. package/src/ActivityTimeline.tsx +184 -0
  93. package/src/CommentAttachment.tsx +192 -0
  94. package/src/CommentInput.tsx +81 -0
  95. package/src/DetailSection.tsx +74 -9
  96. package/src/DetailView.stories.tsx +76 -0
  97. package/src/DetailView.tsx +270 -27
  98. package/src/DiffView.tsx +231 -0
  99. package/src/FieldChangeItem.tsx +46 -0
  100. package/src/InlineCreateRelated.tsx +291 -0
  101. package/src/MentionAutocomplete.tsx +123 -0
  102. package/src/PointInTimeRestore.tsx +261 -0
  103. package/src/ReactionPicker.tsx +106 -0
  104. package/src/RecordActivityTimeline.tsx +429 -0
  105. package/src/RecordChatterPanel.tsx +202 -0
  106. package/src/RecordComments.tsx +215 -0
  107. package/src/RecordNavigationEnhanced.tsx +211 -0
  108. package/src/RelatedList.tsx +37 -8
  109. package/src/RelationshipGraph.tsx +286 -0
  110. package/src/RichTextCommentInput.tsx +348 -0
  111. package/src/SubscriptionToggle.tsx +60 -0
  112. package/src/ThreadedReplies.tsx +161 -0
  113. package/src/__tests__/ActivityTimeline.test.tsx +119 -0
  114. package/src/__tests__/ActivityTimelineFiltering.test.tsx +143 -0
  115. package/src/__tests__/CommentInput.test.tsx +57 -0
  116. package/src/__tests__/DetailSection.test.tsx +320 -0
  117. package/src/__tests__/DetailView.test.tsx +415 -1
  118. package/src/__tests__/FieldChangeItem.test.tsx +119 -0
  119. package/src/__tests__/MentionAutocomplete.test.tsx +97 -0
  120. package/src/__tests__/ReactionPicker.test.tsx +113 -0
  121. package/src/__tests__/RecordActivityTimeline.test.tsx +395 -0
  122. package/src/__tests__/RecordChatterPanel.test.tsx +227 -0
  123. package/src/__tests__/RecordComments.test.tsx +96 -0
  124. package/src/__tests__/RecordCommentsPinSearch.test.tsx +133 -0
  125. package/src/__tests__/RelatedList.test.tsx +66 -0
  126. package/src/__tests__/SubscriptionToggle.test.tsx +84 -0
  127. package/src/__tests__/ThreadedReplies.test.tsx +212 -0
  128. package/src/__tests__/autoLayout.test.ts +184 -0
  129. package/src/__tests__/phase12-features.test.tsx +583 -0
  130. package/src/autoLayout.ts +111 -0
  131. package/src/index.tsx +46 -0
  132. package/src/useDetailTranslation.ts +103 -0
@@ -0,0 +1,583 @@
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 { describe, it, expect, vi, beforeEach } from 'vitest';
10
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
11
+ import '@testing-library/jest-dom';
12
+ import { InlineCreateRelated } from '../InlineCreateRelated';
13
+ import type { RelatedFieldDefinition, RelatedRecordOption } from '../InlineCreateRelated';
14
+ import { RichTextCommentInput } from '../RichTextCommentInput';
15
+ import type { MentionSuggestion } from '../RichTextCommentInput';
16
+ import { DiffView } from '../DiffView';
17
+ import { RecordNavigationEnhanced } from '../RecordNavigationEnhanced';
18
+ import { RelationshipGraph } from '../RelationshipGraph';
19
+ import type { GraphNode } from '../RelationshipGraph';
20
+ import { CommentAttachment } from '../CommentAttachment';
21
+ import type { Attachment } from '../CommentAttachment';
22
+ import { PointInTimeRestore } from '../PointInTimeRestore';
23
+ import type { RevisionEntry } from '../PointInTimeRestore';
24
+
25
+ /* ------------------------------------------------------------------ */
26
+ /* InlineCreateRelated */
27
+ /* ------------------------------------------------------------------ */
28
+
29
+ describe('InlineCreateRelated', () => {
30
+ const fields: RelatedFieldDefinition[] = [
31
+ { name: 'name', label: 'Name', type: 'string', required: true },
32
+ { name: 'amount', label: 'Amount', type: 'number' },
33
+ ];
34
+
35
+ const existingRecords: RelatedRecordOption[] = [
36
+ { id: 'r1', label: 'Record Alpha', description: 'First record' },
37
+ { id: 'r2', label: 'Record Beta', description: 'Second record' },
38
+ ];
39
+
40
+ it('renders create and link buttons', () => {
41
+ render(
42
+ <InlineCreateRelated
43
+ objectName="Contact"
44
+ relationshipField="accountId"
45
+ fields={fields}
46
+ onCreateRecord={vi.fn()}
47
+ onLinkRecord={vi.fn()}
48
+ existingRecords={existingRecords}
49
+ />,
50
+ );
51
+ expect(screen.getByText('New Contact')).toBeInTheDocument();
52
+ expect(screen.getByText('Link Existing')).toBeInTheDocument();
53
+ });
54
+
55
+ it('switches between create and link tabs', () => {
56
+ render(
57
+ <InlineCreateRelated
58
+ objectName="Contact"
59
+ relationshipField="accountId"
60
+ fields={fields}
61
+ onCreateRecord={vi.fn()}
62
+ onLinkRecord={vi.fn()}
63
+ existingRecords={existingRecords}
64
+ />,
65
+ );
66
+
67
+ // Open via the Link Existing button so we start in "link" tab
68
+ fireEvent.click(screen.getByText('Link Existing'));
69
+
70
+ // Both tab triggers should be rendered
71
+ const tabTriggers = screen.getAllByRole('tab');
72
+ expect(tabTriggers.length).toBe(2);
73
+ expect(screen.getByText('Create New')).toBeInTheDocument();
74
+ expect(screen.getByText('Link Existing')).toBeInTheDocument();
75
+ });
76
+
77
+ it('form submission calls onCreateRecord', async () => {
78
+ const onCreateRecord = vi.fn().mockResolvedValue(undefined);
79
+ render(
80
+ <InlineCreateRelated
81
+ objectName="Contact"
82
+ relationshipField="accountId"
83
+ fields={fields}
84
+ onCreateRecord={onCreateRecord}
85
+ existingRecords={existingRecords}
86
+ />,
87
+ );
88
+
89
+ fireEvent.click(screen.getByText('New Contact'));
90
+
91
+ const nameInput = screen.getByPlaceholderText('Enter name');
92
+ fireEvent.change(nameInput, { target: { value: 'John Doe' } });
93
+
94
+ const createBtn = screen.getByRole('button', { name: 'Create' });
95
+ fireEvent.click(createBtn);
96
+
97
+ await waitFor(() => {
98
+ expect(onCreateRecord).toHaveBeenCalledWith(
99
+ expect.objectContaining({ name: 'John Doe', accountId: true }),
100
+ );
101
+ });
102
+ });
103
+
104
+ it('search filters results in the link tab', () => {
105
+ render(
106
+ <InlineCreateRelated
107
+ objectName="Contact"
108
+ relationshipField="accountId"
109
+ fields={fields}
110
+ onLinkRecord={vi.fn()}
111
+ existingRecords={existingRecords}
112
+ />,
113
+ );
114
+
115
+ fireEvent.click(screen.getByText('Link Existing'));
116
+
117
+ const searchInput = screen.getByPlaceholderText('Search Contact…');
118
+ fireEvent.change(searchInput, { target: { value: 'Alpha' } });
119
+
120
+ expect(screen.getByText('Record Alpha')).toBeInTheDocument();
121
+ expect(screen.queryByText('Record Beta')).not.toBeInTheDocument();
122
+ });
123
+
124
+ it('link button calls onLinkRecord', async () => {
125
+ const onLinkRecord = vi.fn().mockResolvedValue(undefined);
126
+ render(
127
+ <InlineCreateRelated
128
+ objectName="Contact"
129
+ relationshipField="accountId"
130
+ fields={fields}
131
+ onLinkRecord={onLinkRecord}
132
+ existingRecords={existingRecords}
133
+ />,
134
+ );
135
+
136
+ fireEvent.click(screen.getByText('Link Existing'));
137
+ fireEvent.click(screen.getByText('Record Alpha'));
138
+
139
+ await waitFor(() => {
140
+ expect(onLinkRecord).toHaveBeenCalledWith('r1');
141
+ });
142
+ });
143
+ });
144
+
145
+ /* ------------------------------------------------------------------ */
146
+ /* RichTextCommentInput */
147
+ /* ------------------------------------------------------------------ */
148
+
149
+ describe('RichTextCommentInput', () => {
150
+ const mentionSuggestions: MentionSuggestion[] = [
151
+ { id: 'u1', label: 'alice' },
152
+ { id: 'u2', label: 'bob' },
153
+ ];
154
+
155
+ it('renders textarea and toolbar', () => {
156
+ render(<RichTextCommentInput value="" onChange={vi.fn()} />);
157
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
158
+ expect(screen.getByTitle('Bold (Ctrl+B)')).toBeInTheDocument();
159
+ expect(screen.getByTitle('Italic (Ctrl+I)')).toBeInTheDocument();
160
+ });
161
+
162
+ it('bold button inserts markdown bold markers', () => {
163
+ const onChange = vi.fn();
164
+ render(<RichTextCommentInput value="" onChange={onChange} />);
165
+
166
+ fireEvent.click(screen.getByTitle('Bold (Ctrl+B)'));
167
+ expect(onChange).toHaveBeenCalledWith('****');
168
+ });
169
+
170
+ it('@ triggers mention suggestions', () => {
171
+ const onChange = vi.fn();
172
+ const { rerender } = render(
173
+ <RichTextCommentInput
174
+ value=""
175
+ onChange={onChange}
176
+ mentionSuggestions={mentionSuggestions}
177
+ />,
178
+ );
179
+
180
+ // Simulate typing "@" in the textarea
181
+ const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
182
+ fireEvent.change(textarea, { target: { value: '@', selectionStart: 1 } });
183
+
184
+ // Re-render with updated value to show mention dropdown
185
+ rerender(
186
+ <RichTextCommentInput
187
+ value="@"
188
+ onChange={onChange}
189
+ mentionSuggestions={mentionSuggestions}
190
+ />,
191
+ );
192
+
193
+ // The mention trigger happens through the @ button as well
194
+ fireEvent.click(screen.getByTitle('Mention someone'));
195
+ expect(onChange).toHaveBeenCalledWith('@');
196
+ });
197
+
198
+ it('preview toggle shows rendered markdown', () => {
199
+ render(
200
+ <RichTextCommentInput
201
+ value="**bold text**"
202
+ onChange={vi.fn()}
203
+ />,
204
+ );
205
+
206
+ // Click the preview button
207
+ fireEvent.click(screen.getByTitle('Preview'));
208
+
209
+ // In preview mode, markdown is rendered to HTML
210
+ expect(screen.getByText('bold text')).toBeInTheDocument();
211
+ // The textarea should no longer be present
212
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
213
+ });
214
+
215
+ it('submit calls onSubmit with value', () => {
216
+ const onSubmit = vi.fn();
217
+ render(
218
+ <RichTextCommentInput
219
+ value="Hello world"
220
+ onChange={vi.fn()}
221
+ onSubmit={onSubmit}
222
+ />,
223
+ );
224
+
225
+ fireEvent.click(screen.getByTitle('Submit (Ctrl+Enter)'));
226
+ expect(onSubmit).toHaveBeenCalled();
227
+ });
228
+ });
229
+
230
+ /* ------------------------------------------------------------------ */
231
+ /* DiffView */
232
+ /* ------------------------------------------------------------------ */
233
+
234
+ describe('DiffView', () => {
235
+ it('renders old and new values', () => {
236
+ render(
237
+ <DiffView oldValue="hello" newValue="world" fieldName="greeting" />,
238
+ );
239
+ expect(screen.getByText('greeting')).toBeInTheDocument();
240
+ expect(screen.getByText('hello')).toBeInTheDocument();
241
+ expect(screen.getByText('world')).toBeInTheDocument();
242
+ });
243
+
244
+ it('shows added content in green', () => {
245
+ const { container } = render(
246
+ <DiffView oldValue="" newValue="new line" fieldName="field" />,
247
+ );
248
+ const addedLine = container.querySelector('.border-l-green-500');
249
+ expect(addedLine).toBeInTheDocument();
250
+ });
251
+
252
+ it('shows removed content in red', () => {
253
+ const { container } = render(
254
+ <DiffView oldValue="old line" newValue="" fieldName="field" />,
255
+ );
256
+ const removedLine = container.querySelector('.border-l-red-500');
257
+ expect(removedLine).toBeInTheDocument();
258
+ });
259
+
260
+ it('handles number diffs', () => {
261
+ render(
262
+ <DiffView oldValue={42} newValue={100} fieldName="count" fieldType="number" />,
263
+ );
264
+ expect(screen.getByText('42')).toBeInTheDocument();
265
+ expect(screen.getByText('100')).toBeInTheDocument();
266
+ });
267
+
268
+ it('toggles between unified and side-by-side', () => {
269
+ const { container } = render(
270
+ <DiffView oldValue="old" newValue="new" fieldName="field" />,
271
+ );
272
+
273
+ // Click side-by-side button
274
+ fireEvent.click(screen.getByTitle('Side-by-side diff'));
275
+
276
+ // Side-by-side view shows Previous/Current headers
277
+ expect(screen.getByText('Previous')).toBeInTheDocument();
278
+ expect(screen.getByText('Current')).toBeInTheDocument();
279
+
280
+ // Switch back to unified
281
+ fireEvent.click(screen.getByTitle('Unified diff'));
282
+ expect(screen.queryByText('Previous')).not.toBeInTheDocument();
283
+ });
284
+ });
285
+
286
+ /* ------------------------------------------------------------------ */
287
+ /* RecordNavigationEnhanced */
288
+ /* ------------------------------------------------------------------ */
289
+
290
+ describe('RecordNavigationEnhanced', () => {
291
+ const recordIds = ['a', 'b', 'c', 'd', 'e'];
292
+
293
+ it('renders first/prev/next/last buttons', () => {
294
+ render(
295
+ <RecordNavigationEnhanced
296
+ currentIndex={2}
297
+ totalRecords={5}
298
+ recordIds={recordIds}
299
+ onNavigate={vi.fn()}
300
+ />,
301
+ );
302
+ expect(screen.getByTitle('First record (Home)')).toBeInTheDocument();
303
+ expect(screen.getByTitle('Previous record (←)')).toBeInTheDocument();
304
+ expect(screen.getByTitle('Next record (→)')).toBeInTheDocument();
305
+ expect(screen.getByTitle('Last record (End)')).toBeInTheDocument();
306
+ });
307
+
308
+ it('shows position indicator (e.g. "3 of 5")', () => {
309
+ render(
310
+ <RecordNavigationEnhanced
311
+ currentIndex={2}
312
+ totalRecords={25}
313
+ recordIds={Array.from({ length: 25 }, (_, i) => `r${i}`)}
314
+ onNavigate={vi.fn()}
315
+ />,
316
+ );
317
+ expect(screen.getByText('3 of 25')).toBeInTheDocument();
318
+ });
319
+
320
+ it('disables first/prev at start', () => {
321
+ render(
322
+ <RecordNavigationEnhanced
323
+ currentIndex={0}
324
+ totalRecords={5}
325
+ recordIds={recordIds}
326
+ onNavigate={vi.fn()}
327
+ />,
328
+ );
329
+ expect(screen.getByTitle('First record (Home)')).toBeDisabled();
330
+ expect(screen.getByTitle('Previous record (←)')).toBeDisabled();
331
+ });
332
+
333
+ it('disables next/last at end', () => {
334
+ render(
335
+ <RecordNavigationEnhanced
336
+ currentIndex={4}
337
+ totalRecords={5}
338
+ recordIds={recordIds}
339
+ onNavigate={vi.fn()}
340
+ />,
341
+ );
342
+ expect(screen.getByTitle('Next record (→)')).toBeDisabled();
343
+ expect(screen.getByTitle('Last record (End)')).toBeDisabled();
344
+ });
345
+
346
+ it('search input filters', () => {
347
+ const onSearch = vi.fn();
348
+ render(
349
+ <RecordNavigationEnhanced
350
+ currentIndex={2}
351
+ totalRecords={5}
352
+ recordIds={recordIds}
353
+ onNavigate={vi.fn()}
354
+ onSearch={onSearch}
355
+ />,
356
+ );
357
+
358
+ // Toggle search open
359
+ fireEvent.click(screen.getByTitle('Search while navigating'));
360
+
361
+ const input = screen.getByPlaceholderText('Search records…');
362
+ fireEvent.change(input, { target: { value: 'test' } });
363
+ expect(onSearch).toHaveBeenCalledWith('test');
364
+ });
365
+ });
366
+
367
+ /* ------------------------------------------------------------------ */
368
+ /* RelationshipGraph */
369
+ /* ------------------------------------------------------------------ */
370
+
371
+ describe('RelationshipGraph', () => {
372
+ const centerNode: GraphNode = { id: 'center', label: 'Account', type: 'Account' };
373
+ const relatedNodes: GraphNode[] = [
374
+ { id: 'n1', label: 'Contact', type: 'Contact' },
375
+ { id: 'n2', label: 'Opport', type: 'Opportunity' },
376
+ ];
377
+
378
+ it('renders SVG element', () => {
379
+ const { container } = render(
380
+ <RelationshipGraph record={centerNode} relatedRecords={relatedNodes} />,
381
+ );
382
+ expect(container.querySelector('svg')).toBeInTheDocument();
383
+ });
384
+
385
+ it('shows central record node', () => {
386
+ render(
387
+ <RelationshipGraph record={centerNode} relatedRecords={relatedNodes} />,
388
+ );
389
+ expect(screen.getByText('Account')).toBeInTheDocument();
390
+ });
391
+
392
+ it('shows related record nodes', () => {
393
+ render(
394
+ <RelationshipGraph record={centerNode} relatedRecords={relatedNodes} />,
395
+ );
396
+ expect(screen.getByText('Conta…')).toBeInTheDocument();
397
+ expect(screen.getByText('Opport')).toBeInTheDocument();
398
+ });
399
+
400
+ it('click on node calls onNodeClick', () => {
401
+ const onNodeClick = vi.fn();
402
+ render(
403
+ <RelationshipGraph
404
+ record={centerNode}
405
+ relatedRecords={relatedNodes}
406
+ onNodeClick={onNodeClick}
407
+ />,
408
+ );
409
+
410
+ // Click on the center node's circle group — use the text as proxy
411
+ const nodeText = screen.getByText('Account');
412
+ // Click on the parent <g> element
413
+ fireEvent.click(nodeText.closest('g')!);
414
+ expect(onNodeClick).toHaveBeenCalledWith('center');
415
+ });
416
+ });
417
+
418
+ /* ------------------------------------------------------------------ */
419
+ /* CommentAttachment */
420
+ /* ------------------------------------------------------------------ */
421
+
422
+ describe('CommentAttachment', () => {
423
+ const attachments: Attachment[] = [
424
+ {
425
+ id: 'a1',
426
+ name: 'screenshot.png',
427
+ size: 204800,
428
+ type: 'image/png',
429
+ thumbnailUrl: 'https://example.com/thumb.png',
430
+ },
431
+ {
432
+ id: 'a2',
433
+ name: 'report.pdf',
434
+ size: 1048576,
435
+ type: 'application/pdf',
436
+ },
437
+ {
438
+ id: 'a3',
439
+ name: 'data.zip',
440
+ size: 512,
441
+ type: 'application/zip',
442
+ },
443
+ ];
444
+
445
+ it('renders attachment list', () => {
446
+ render(<CommentAttachment attachments={attachments} />);
447
+ expect(screen.getByText('3 attachments')).toBeInTheDocument();
448
+ expect(screen.getByText('screenshot.png')).toBeInTheDocument();
449
+ expect(screen.getByText('report.pdf')).toBeInTheDocument();
450
+ expect(screen.getByText('data.zip')).toBeInTheDocument();
451
+ });
452
+
453
+ it('shows image thumbnails', () => {
454
+ render(<CommentAttachment attachments={attachments} />);
455
+ const img = screen.getByAltText('screenshot.png');
456
+ expect(img).toBeInTheDocument();
457
+ expect(img).toHaveAttribute('src', 'https://example.com/thumb.png');
458
+ });
459
+
460
+ it('shows file icons for non-images', () => {
461
+ const { container } = render(
462
+ <CommentAttachment attachments={[attachments[1]]} />,
463
+ );
464
+ // Non-image attachments render a div with an icon, not an <img>
465
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
466
+ expect(screen.getByText('report.pdf')).toBeInTheDocument();
467
+ });
468
+
469
+ it('displays file sizes', () => {
470
+ render(<CommentAttachment attachments={attachments} />);
471
+ expect(screen.getByText('200.0 KB')).toBeInTheDocument();
472
+ expect(screen.getByText('1.0 MB')).toBeInTheDocument();
473
+ expect(screen.getByText('512 B')).toBeInTheDocument();
474
+ });
475
+ });
476
+
477
+ /* ------------------------------------------------------------------ */
478
+ /* PointInTimeRestore */
479
+ /* ------------------------------------------------------------------ */
480
+
481
+ describe('PointInTimeRestore', () => {
482
+ const revisions: RevisionEntry[] = [
483
+ {
484
+ id: 'rev1',
485
+ timestamp: '2024-01-15T10:00:00Z',
486
+ user: 'Alice',
487
+ changes: [
488
+ { field: 'status', oldValue: 'Draft', newValue: 'Active' },
489
+ ],
490
+ snapshot: { status: 'Active', name: 'Test Record' },
491
+ },
492
+ {
493
+ id: 'rev2',
494
+ timestamp: '2024-01-14T08:00:00Z',
495
+ user: 'Bob',
496
+ changes: [
497
+ { field: 'name', oldValue: 'Old Name', newValue: 'Test Record' },
498
+ { field: 'amount', oldValue: 100, newValue: 200 },
499
+ ],
500
+ snapshot: { status: 'Draft', name: 'Test Record' },
501
+ },
502
+ ];
503
+
504
+ it('renders revision timeline', () => {
505
+ render(
506
+ <PointInTimeRestore
507
+ recordId="rec1"
508
+ revisions={revisions}
509
+ />,
510
+ );
511
+ expect(screen.getByText('Revision History')).toBeInTheDocument();
512
+ expect(screen.getByText('(2)')).toBeInTheDocument();
513
+ expect(screen.getByText('Alice')).toBeInTheDocument();
514
+ expect(screen.getByText('Bob')).toBeInTheDocument();
515
+ });
516
+
517
+ it('shows revision details on selection', () => {
518
+ render(
519
+ <PointInTimeRestore
520
+ recordId="rec1"
521
+ revisions={revisions}
522
+ onRestore={vi.fn()}
523
+ />,
524
+ );
525
+
526
+ // Click on Alice's revision
527
+ fireEvent.click(screen.getByText('Alice'));
528
+
529
+ // Preview panel should show field changes
530
+ expect(screen.getByText('Revision Preview')).toBeInTheDocument();
531
+ const statusElements = screen.getAllByText('status');
532
+ expect(statusElements.length).toBeGreaterThanOrEqual(1);
533
+ expect(screen.getByText('Draft')).toBeInTheDocument();
534
+ expect(screen.getAllByText('Active').length).toBeGreaterThanOrEqual(1);
535
+ });
536
+
537
+ it('restore button appears with confirmation', async () => {
538
+ const onRestore = vi.fn().mockResolvedValue(undefined);
539
+ render(
540
+ <PointInTimeRestore
541
+ recordId="rec1"
542
+ revisions={revisions}
543
+ onRestore={onRestore}
544
+ />,
545
+ );
546
+
547
+ // Select a revision
548
+ fireEvent.click(screen.getByText('Alice'));
549
+
550
+ // Click the restore button
551
+ fireEvent.click(screen.getByText('Restore to this point'));
552
+
553
+ // Confirmation prompt appears
554
+ expect(screen.getByText('Confirm Restore')).toBeInTheDocument();
555
+
556
+ // Confirm the restore
557
+ fireEvent.click(screen.getByText('Confirm Restore'));
558
+
559
+ await waitFor(() => {
560
+ expect(onRestore).toHaveBeenCalledWith('rev1', { status: 'Active', name: 'Test Record' });
561
+ });
562
+ });
563
+
564
+ it('calls onRestore with selected revision', async () => {
565
+ const onRestore = vi.fn().mockResolvedValue(undefined);
566
+ render(
567
+ <PointInTimeRestore
568
+ recordId="rec1"
569
+ revisions={revisions}
570
+ onRestore={onRestore}
571
+ />,
572
+ );
573
+
574
+ // Select Bob's revision
575
+ fireEvent.click(screen.getByText('Bob'));
576
+ fireEvent.click(screen.getByText('Restore to this point'));
577
+ fireEvent.click(screen.getByText('Confirm Restore'));
578
+
579
+ await waitFor(() => {
580
+ expect(onRestore).toHaveBeenCalledWith('rev2', { status: 'Draft', name: 'Test Record' });
581
+ });
582
+ });
583
+ });
@@ -0,0 +1,111 @@
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
+ /**
10
+ * Auto-Layout for DetailView
11
+ *
12
+ * Provides intelligent, zero-configuration default layout for detail sections.
13
+ * When the user has not explicitly set columns on a section, this module
14
+ * infers optimal column count based on the number of fields.
15
+ *
16
+ * Priority: User configuration > Auto-layout inference
17
+ *
18
+ * Column rules for detail views (wider thresholds than forms):
19
+ * - 0-3 fields → 1 column
20
+ * - 4-10 fields → 2 columns
21
+ * - 11+ fields → 3 columns
22
+ */
23
+
24
+ import type { DetailViewField } from '@object-ui/types';
25
+
26
+ /** Field types that should span full width in multi-column layouts */
27
+ const WIDE_FIELD_TYPES = new Set([
28
+ 'textarea',
29
+ 'markdown',
30
+ 'html',
31
+ 'grid',
32
+ 'rich-text',
33
+ 'field:textarea',
34
+ 'field:markdown',
35
+ 'field:html',
36
+ 'field:grid',
37
+ 'field:rich-text',
38
+ ]);
39
+
40
+ /**
41
+ * Check if a field type is "wide" (should span full row in multi-column layout).
42
+ */
43
+ export function isWideFieldType(type: string): boolean {
44
+ return WIDE_FIELD_TYPES.has(type);
45
+ }
46
+
47
+ /**
48
+ * Infer optimal number of columns for a detail section based on field count.
49
+ *
50
+ * Rules:
51
+ * - 0-3 fields → 1 column
52
+ * - 4-10 fields → 2 columns
53
+ * - 11+ fields → 3 columns
54
+ */
55
+ export function inferDetailColumns(fieldCount: number): number {
56
+ if (fieldCount <= 3) return 1;
57
+ if (fieldCount <= 10) return 2;
58
+ return 3;
59
+ }
60
+
61
+ /**
62
+ * Apply auto span to wide fields so they span the full row.
63
+ * Only sets span if the field does not already have one explicitly set.
64
+ *
65
+ * @returns A new array of fields with span applied where needed.
66
+ */
67
+ export function applyAutoSpan(
68
+ fields: DetailViewField[],
69
+ columns: number
70
+ ): DetailViewField[] {
71
+ if (columns <= 1) return fields;
72
+
73
+ return fields.map((field) => {
74
+ // User-defined span takes priority
75
+ if (field.span !== undefined) return field;
76
+
77
+ // Wide field types should span full row
78
+ if (field.type && isWideFieldType(field.type)) {
79
+ return { ...field, span: columns };
80
+ }
81
+
82
+ return field;
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Main auto-layout orchestrator for detail sections.
88
+ * Applies intelligent defaults only when the user has not explicitly configured columns.
89
+ *
90
+ * @param fields - The section fields
91
+ * @param schemaColumns - User-provided columns (from DetailViewSection or DetailViewSchema)
92
+ * @returns Object with processed fields and inferred columns
93
+ */
94
+ export function applyDetailAutoLayout(
95
+ fields: DetailViewField[],
96
+ schemaColumns: number | undefined
97
+ ): { fields: DetailViewField[]; columns: number } {
98
+ // If user explicitly set columns, respect it but still apply auto span
99
+ if (schemaColumns !== undefined) {
100
+ const processed = applyAutoSpan(fields, schemaColumns);
101
+ return { fields: processed, columns: schemaColumns };
102
+ }
103
+
104
+ // Infer columns from field count
105
+ const columns = inferDetailColumns(fields.length);
106
+
107
+ // Apply auto span for wide fields
108
+ const processed = applyAutoSpan(fields, columns);
109
+
110
+ return { fields: processed, columns };
111
+ }