@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,227 @@
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 } from 'vitest';
10
+ import { render, screen, fireEvent } from '@testing-library/react';
11
+ import '@testing-library/jest-dom';
12
+ import { RecordChatterPanel } from '../RecordChatterPanel';
13
+ import type { FeedItem } from '@object-ui/types';
14
+
15
+ const mockItems: FeedItem[] = [
16
+ {
17
+ id: '1',
18
+ type: 'comment',
19
+ actor: 'Alice',
20
+ body: 'Hello from chatter',
21
+ createdAt: '2026-02-20T10:00:00Z',
22
+ },
23
+ {
24
+ id: '2',
25
+ type: 'field_change',
26
+ actor: 'Bob',
27
+ createdAt: '2026-02-20T11:00:00Z',
28
+ fieldChanges: [
29
+ { field: 'priority', fieldLabel: 'Priority', oldValue: 'low', newValue: 'high' },
30
+ ],
31
+ },
32
+ ];
33
+
34
+ describe('RecordChatterPanel', () => {
35
+ describe('sidebar mode (right)', () => {
36
+ it('should render Discussion header in sidebar mode', () => {
37
+ render(
38
+ <RecordChatterPanel config={{ position: 'right' }} items={mockItems} />,
39
+ );
40
+ expect(screen.getByText('Discussion')).toBeInTheDocument();
41
+ });
42
+
43
+ it('should render activity items', () => {
44
+ render(
45
+ <RecordChatterPanel config={{ position: 'right' }} items={mockItems} />,
46
+ );
47
+ expect(screen.getByText('Alice')).toBeInTheDocument();
48
+ expect(screen.getByText('Hello from chatter')).toBeInTheDocument();
49
+ });
50
+
51
+ it('should collapse when close button is clicked', () => {
52
+ render(
53
+ <RecordChatterPanel
54
+ config={{ position: 'right', collapsible: true }}
55
+ items={mockItems}
56
+ />,
57
+ );
58
+ // Initially expanded (not defaultCollapsed)
59
+ expect(screen.getByText('Discussion')).toBeInTheDocument();
60
+ fireEvent.click(screen.getByLabelText('Close discussion panel'));
61
+ // Now collapsed — show expand button
62
+ expect(screen.getByLabelText('Open discussion panel')).toBeInTheDocument();
63
+ });
64
+
65
+ it('should start collapsed when defaultCollapsed is true', () => {
66
+ render(
67
+ <RecordChatterPanel
68
+ config={{ position: 'right', collapsible: true, defaultCollapsed: true }}
69
+ items={mockItems}
70
+ />,
71
+ );
72
+ expect(screen.getByLabelText('Open discussion panel')).toBeInTheDocument();
73
+ expect(screen.queryByText('Discussion')).not.toBeInTheDocument();
74
+ });
75
+
76
+ it('should expand from collapsed state', () => {
77
+ render(
78
+ <RecordChatterPanel
79
+ config={{ position: 'right', collapsible: true, defaultCollapsed: true }}
80
+ items={mockItems}
81
+ />,
82
+ );
83
+ fireEvent.click(screen.getByLabelText('Open discussion panel'));
84
+ expect(screen.getByText('Discussion')).toBeInTheDocument();
85
+ });
86
+ });
87
+
88
+ describe('inline mode (bottom)', () => {
89
+ it('should render timeline in inline mode', () => {
90
+ render(
91
+ <RecordChatterPanel
92
+ config={{ position: 'bottom', collapsible: false }}
93
+ items={mockItems}
94
+ />,
95
+ );
96
+ expect(screen.getByText('Activity')).toBeInTheDocument();
97
+ expect(screen.getByText('Alice')).toBeInTheDocument();
98
+ });
99
+
100
+ it('should show/hide discussion toggle in inline collapsible mode', () => {
101
+ render(
102
+ <RecordChatterPanel
103
+ config={{ position: 'bottom', collapsible: true, defaultCollapsed: true }}
104
+ items={mockItems}
105
+ />,
106
+ );
107
+ expect(screen.getByLabelText('Show discussion')).toBeInTheDocument();
108
+ fireEvent.click(screen.getByLabelText('Show discussion'));
109
+ expect(screen.getByText('Activity')).toBeInTheDocument();
110
+ });
111
+ });
112
+
113
+ describe('default config', () => {
114
+ it('should default to right position', () => {
115
+ render(<RecordChatterPanel items={mockItems} />);
116
+ expect(screen.getByText('Discussion')).toBeInTheDocument();
117
+ });
118
+
119
+ it('should pass feed config to embedded timeline', () => {
120
+ render(
121
+ <RecordChatterPanel
122
+ config={{ feed: { showFilterToggle: false } }}
123
+ items={mockItems}
124
+ />,
125
+ );
126
+ expect(screen.queryByLabelText('Filter activity')).not.toBeInTheDocument();
127
+ });
128
+ });
129
+
130
+ describe('left sidebar mode', () => {
131
+ it('should render with border-r in left position', () => {
132
+ const { container } = render(
133
+ <RecordChatterPanel config={{ position: 'left' }} items={mockItems} />,
134
+ );
135
+ const panel = container.firstChild as HTMLElement;
136
+ expect(panel).toHaveClass('border-r');
137
+ });
138
+ });
139
+
140
+ describe('right sidebar width', () => {
141
+ it('should apply configured width via style', () => {
142
+ const { container } = render(
143
+ <RecordChatterPanel config={{ position: 'right', width: '400px' }} items={mockItems} />,
144
+ );
145
+ const panel = container.firstChild as HTMLElement;
146
+ expect(panel.style.width).toBe('400px');
147
+ });
148
+ });
149
+
150
+ describe('collapsible=false', () => {
151
+ it('should not show collapse button when collapsible is false', () => {
152
+ render(
153
+ <RecordChatterPanel
154
+ config={{ position: 'right', collapsible: false }}
155
+ items={mockItems}
156
+ />,
157
+ );
158
+ expect(screen.getByText('Discussion')).toBeInTheDocument();
159
+ expect(screen.queryByLabelText('Close discussion panel')).not.toBeInTheDocument();
160
+ });
161
+ });
162
+
163
+ describe('sidebar timeline styling', () => {
164
+ it('should pass border-0 shadow-none to embedded timeline', () => {
165
+ const { container } = render(
166
+ <RecordChatterPanel config={{ position: 'right' }} items={mockItems} />,
167
+ );
168
+ // The RecordActivityTimeline renders a Card; in sidebar mode it gets border-0 shadow-none
169
+ const card = container.querySelector('.border-0.shadow-none');
170
+ expect(card).toBeInTheDocument();
171
+ });
172
+ });
173
+
174
+ describe('callback passthrough', () => {
175
+ it('should forward onAddComment to embedded timeline', () => {
176
+ const onAddComment = vi.fn().mockResolvedValue(undefined);
177
+ render(
178
+ <RecordChatterPanel
179
+ config={{ position: 'right' }}
180
+ items={[]}
181
+ onAddComment={onAddComment}
182
+ />,
183
+ );
184
+ expect(screen.getByPlaceholderText(/Leave a comment/)).toBeInTheDocument();
185
+ });
186
+ });
187
+
188
+ describe('inline collapsible buttons', () => {
189
+ it('should show "Show Discussion (N)" when collapsed inline', () => {
190
+ render(
191
+ <RecordChatterPanel
192
+ config={{ position: 'bottom', collapsible: true, defaultCollapsed: true }}
193
+ items={mockItems}
194
+ />,
195
+ );
196
+ expect(screen.getByText('Show Discussion (2)')).toBeInTheDocument();
197
+ });
198
+
199
+ it('should show "Hide discussion" button when expanded inline', () => {
200
+ render(
201
+ <RecordChatterPanel
202
+ config={{ position: 'bottom', collapsible: true }}
203
+ items={mockItems}
204
+ />,
205
+ );
206
+ expect(screen.getByLabelText('Hide discussion')).toBeInTheDocument();
207
+ });
208
+
209
+ it('should toggle between collapsed and expanded inline', () => {
210
+ render(
211
+ <RecordChatterPanel
212
+ config={{ position: 'bottom', collapsible: true, defaultCollapsed: true }}
213
+ items={mockItems}
214
+ />,
215
+ );
216
+ // Collapsed
217
+ expect(screen.getByLabelText('Show discussion')).toBeInTheDocument();
218
+ fireEvent.click(screen.getByLabelText('Show discussion'));
219
+ // Expanded
220
+ expect(screen.getByText('Activity')).toBeInTheDocument();
221
+ // Click hide
222
+ fireEvent.click(screen.getByLabelText('Hide discussion'));
223
+ // Collapsed again
224
+ expect(screen.getByLabelText('Show discussion')).toBeInTheDocument();
225
+ });
226
+ });
227
+ });
@@ -0,0 +1,96 @@
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 } from 'vitest';
10
+ import { render, screen, fireEvent } from '@testing-library/react';
11
+ import '@testing-library/jest-dom';
12
+ import { RecordComments } from '../RecordComments';
13
+ import type { CommentEntry } from '@object-ui/types';
14
+
15
+ const mockComments: CommentEntry[] = [
16
+ {
17
+ id: '1',
18
+ text: 'This is the first comment',
19
+ author: 'Alice',
20
+ createdAt: '2026-02-16T08:00:00Z',
21
+ },
22
+ {
23
+ id: '2',
24
+ text: 'Second comment here',
25
+ author: 'Bob',
26
+ avatarUrl: 'https://example.com/bob.jpg',
27
+ createdAt: '2026-02-16T09:00:00Z',
28
+ },
29
+ ];
30
+
31
+ describe('RecordComments', () => {
32
+ it('should render comments heading with count', () => {
33
+ render(<RecordComments comments={mockComments} />);
34
+ expect(screen.getByText('Comments')).toBeInTheDocument();
35
+ expect(screen.getByText('(2)')).toBeInTheDocument();
36
+ });
37
+
38
+ it('should render comment authors and text', () => {
39
+ render(<RecordComments comments={mockComments} />);
40
+ expect(screen.getByText('Alice')).toBeInTheDocument();
41
+ expect(screen.getByText('This is the first comment')).toBeInTheDocument();
42
+ expect(screen.getByText('Bob')).toBeInTheDocument();
43
+ expect(screen.getByText('Second comment here')).toBeInTheDocument();
44
+ });
45
+
46
+ it('should show "No comments yet" when empty', () => {
47
+ render(<RecordComments comments={[]} />);
48
+ expect(screen.getByText('No comments yet')).toBeInTheDocument();
49
+ });
50
+
51
+ it('should render comment input when onAddComment is provided', () => {
52
+ const onAdd = vi.fn();
53
+ render(<RecordComments comments={[]} onAddComment={onAdd} />);
54
+ const textarea = screen.getByPlaceholderText(/Add a comment/);
55
+ expect(textarea).toBeInTheDocument();
56
+ });
57
+
58
+ it('should not render comment input when onAddComment is not provided', () => {
59
+ render(<RecordComments comments={[]} />);
60
+ const textarea = screen.queryByPlaceholderText(/Add a comment/);
61
+ expect(textarea).not.toBeInTheDocument();
62
+ });
63
+
64
+ it('should call onAddComment when submit button is clicked', async () => {
65
+ const onAdd = vi.fn().mockResolvedValue(undefined);
66
+ render(<RecordComments comments={[]} onAddComment={onAdd} />);
67
+
68
+ const textarea = screen.getByPlaceholderText(/Add a comment/);
69
+ fireEvent.change(textarea, { target: { value: 'New comment' } });
70
+
71
+ const submitButton = screen.getByRole('button');
72
+ fireEvent.click(submitButton);
73
+
74
+ expect(onAdd).toHaveBeenCalledWith('New comment');
75
+ });
76
+
77
+ it('should disable submit button when textarea is empty', () => {
78
+ const onAdd = vi.fn();
79
+ render(<RecordComments comments={[]} onAddComment={onAdd} />);
80
+ const submitButton = screen.getByRole('button');
81
+ expect(submitButton).toBeDisabled();
82
+ });
83
+
84
+ it('should render avatar initials when no avatarUrl', () => {
85
+ render(<RecordComments comments={[mockComments[0]]} />);
86
+ // Alice's initial
87
+ expect(screen.getByText('A')).toBeInTheDocument();
88
+ });
89
+
90
+ it('should render avatar image when avatarUrl is provided', () => {
91
+ render(<RecordComments comments={[mockComments[1]]} />);
92
+ const img = screen.getByAltText('Bob');
93
+ expect(img).toBeInTheDocument();
94
+ expect(img).toHaveAttribute('src', 'https://example.com/bob.jpg');
95
+ });
96
+ });
@@ -0,0 +1,133 @@
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 } from 'vitest';
10
+ import { render, screen, fireEvent } from '@testing-library/react';
11
+ import '@testing-library/jest-dom';
12
+ import { RecordComments } from '../RecordComments';
13
+ import type { CommentEntry } from '@object-ui/types';
14
+
15
+ const mockComments: CommentEntry[] = [
16
+ {
17
+ id: '1',
18
+ text: 'First comment about the project',
19
+ author: 'Alice',
20
+ createdAt: '2026-02-16T08:00:00Z',
21
+ },
22
+ {
23
+ id: '2',
24
+ text: 'Second comment on this record',
25
+ author: 'Bob',
26
+ avatarUrl: 'https://example.com/bob.jpg',
27
+ createdAt: '2026-02-16T09:00:00Z',
28
+ pinned: true,
29
+ },
30
+ {
31
+ id: '3',
32
+ text: 'Third comment here',
33
+ author: 'Charlie',
34
+ createdAt: '2026-02-16T10:00:00Z',
35
+ },
36
+ ];
37
+
38
+ describe('RecordComments - Pinning', () => {
39
+ it('shows "Pinned" label on pinned comments', () => {
40
+ render(<RecordComments comments={mockComments} />);
41
+ expect(screen.getByText('Pinned')).toBeInTheDocument();
42
+ });
43
+
44
+ it('sorts pinned comments to the top', () => {
45
+ const { container } = render(<RecordComments comments={mockComments} />);
46
+ // Bob (pinned) should appear first
47
+ const authors = container.querySelectorAll('.text-sm.font-medium');
48
+ const authorTexts = Array.from(authors).map(el => el.textContent);
49
+ expect(authorTexts[0]).toBe('Bob');
50
+ });
51
+
52
+ it('renders pin/unpin button when onTogglePin is provided', () => {
53
+ const onTogglePin = vi.fn();
54
+ render(<RecordComments comments={mockComments} onTogglePin={onTogglePin} />);
55
+
56
+ // Should show "Unpin" for Bob (pinned) and "Pin" for others
57
+ expect(screen.getByText('Unpin')).toBeInTheDocument();
58
+ expect(screen.getAllByText('Pin')).toHaveLength(2);
59
+ });
60
+
61
+ it('does not render pin button when onTogglePin is not provided', () => {
62
+ render(<RecordComments comments={mockComments} />);
63
+ expect(screen.queryByText('Unpin')).not.toBeInTheDocument();
64
+ expect(screen.queryAllByText('Pin')).toHaveLength(0);
65
+ });
66
+
67
+ it('calls onTogglePin when pin button is clicked', () => {
68
+ const onTogglePin = vi.fn();
69
+ render(<RecordComments comments={mockComments} onTogglePin={onTogglePin} />);
70
+
71
+ const unpinBtn = screen.getByText('Unpin');
72
+ fireEvent.click(unpinBtn);
73
+
74
+ expect(onTogglePin).toHaveBeenCalledWith('2');
75
+ });
76
+ });
77
+
78
+ describe('RecordComments - Search', () => {
79
+ it('does not render search input when searchable is false/not provided', () => {
80
+ render(<RecordComments comments={mockComments} />);
81
+ expect(screen.queryByLabelText('Search comments')).not.toBeInTheDocument();
82
+ });
83
+
84
+ it('renders search input when searchable is true', () => {
85
+ render(<RecordComments comments={mockComments} searchable />);
86
+ expect(screen.getByLabelText('Search comments')).toBeInTheDocument();
87
+ });
88
+
89
+ it('filters comments by text when searching', () => {
90
+ render(<RecordComments comments={mockComments} searchable />);
91
+
92
+ const searchInput = screen.getByLabelText('Search comments');
93
+ fireEvent.change(searchInput, { target: { value: 'project' } });
94
+
95
+ expect(screen.getByText('First comment about the project')).toBeInTheDocument();
96
+ expect(screen.queryByText('Second comment on this record')).not.toBeInTheDocument();
97
+ expect(screen.queryByText('Third comment here')).not.toBeInTheDocument();
98
+ });
99
+
100
+ it('filters comments by author when searching', () => {
101
+ render(<RecordComments comments={mockComments} searchable />);
102
+
103
+ const searchInput = screen.getByLabelText('Search comments');
104
+ fireEvent.change(searchInput, { target: { value: 'Charlie' } });
105
+
106
+ expect(screen.getByText('Third comment here')).toBeInTheDocument();
107
+ expect(screen.queryByText('First comment about the project')).not.toBeInTheDocument();
108
+ });
109
+
110
+ it('shows "No matching comments" when search has no results', () => {
111
+ render(<RecordComments comments={mockComments} searchable />);
112
+
113
+ const searchInput = screen.getByLabelText('Search comments');
114
+ fireEvent.change(searchInput, { target: { value: 'zzzznonexistent' } });
115
+
116
+ expect(screen.getByText('No matching comments')).toBeInTheDocument();
117
+ });
118
+
119
+ it('clears search when clear button is clicked', () => {
120
+ render(<RecordComments comments={mockComments} searchable />);
121
+
122
+ const searchInput = screen.getByLabelText('Search comments');
123
+ fireEvent.change(searchInput, { target: { value: 'project' } });
124
+
125
+ const clearBtn = screen.getByLabelText('Clear search');
126
+ fireEvent.click(clearBtn);
127
+
128
+ // All comments should be visible again
129
+ expect(screen.getByText('First comment about the project')).toBeInTheDocument();
130
+ expect(screen.getByText('Second comment on this record')).toBeInTheDocument();
131
+ expect(screen.getByText('Third comment here')).toBeInTheDocument();
132
+ });
133
+ });
@@ -0,0 +1,66 @@
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 } from 'vitest';
10
+ import { render, screen, fireEvent } from '@testing-library/react';
11
+ import { RelatedList } from '../RelatedList';
12
+
13
+ describe('RelatedList', () => {
14
+ it('should render title', () => {
15
+ render(<RelatedList title="Contacts" type="table" data={[]} />);
16
+ expect(screen.getByText('Contacts')).toBeInTheDocument();
17
+ });
18
+
19
+ it('should show record count for empty list', () => {
20
+ render(<RelatedList title="Contacts" type="table" data={[]} />);
21
+ expect(screen.getByText('0 records')).toBeInTheDocument();
22
+ });
23
+
24
+ it('should show singular record count for one item', () => {
25
+ render(<RelatedList title="Contacts" type="table" data={[{ id: 1, name: 'Alice' }]} />);
26
+ expect(screen.getByText('1 record')).toBeInTheDocument();
27
+ });
28
+
29
+ it('should show plural record count for multiple items', () => {
30
+ const data = [
31
+ { id: 1, name: 'Alice' },
32
+ { id: 2, name: 'Bob' },
33
+ ];
34
+ render(<RelatedList title="Orders" type="table" data={data} />);
35
+ expect(screen.getByText('2 records')).toBeInTheDocument();
36
+ });
37
+
38
+ it('should show "No related records found" for empty data', () => {
39
+ render(<RelatedList title="Contacts" type="table" data={[]} />);
40
+ expect(screen.getByText('No related records found')).toBeInTheDocument();
41
+ });
42
+
43
+ it('should render New button when onNew callback is provided', () => {
44
+ const onNew = vi.fn();
45
+ render(<RelatedList title="Contacts" type="table" data={[]} onNew={onNew} />);
46
+ const newButton = screen.getByText('New');
47
+ expect(newButton).toBeInTheDocument();
48
+ fireEvent.click(newButton);
49
+ expect(onNew).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it('should render View All button when onViewAll callback is provided', () => {
53
+ const onViewAll = vi.fn();
54
+ render(<RelatedList title="Contacts" type="table" data={[]} onViewAll={onViewAll} />);
55
+ const viewAllButton = screen.getByText('View All');
56
+ expect(viewAllButton).toBeInTheDocument();
57
+ fireEvent.click(viewAllButton);
58
+ expect(onViewAll).toHaveBeenCalledTimes(1);
59
+ });
60
+
61
+ it('should not render New or View All buttons when callbacks are not provided', () => {
62
+ render(<RelatedList title="Contacts" type="table" data={[]} />);
63
+ expect(screen.queryByText('New')).not.toBeInTheDocument();
64
+ expect(screen.queryByText('View All')).not.toBeInTheDocument();
65
+ });
66
+ });
@@ -0,0 +1,84 @@
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 } from 'vitest';
10
+ import { render, screen, fireEvent } from '@testing-library/react';
11
+ import '@testing-library/jest-dom';
12
+ import { SubscriptionToggle } from '../SubscriptionToggle';
13
+ import type { RecordSubscription } from '@object-ui/types';
14
+
15
+ describe('SubscriptionToggle', () => {
16
+ it('should render subscribed state', () => {
17
+ const sub: RecordSubscription = { recordId: '1', subscribed: true };
18
+ render(<SubscriptionToggle subscription={sub} />);
19
+ const btn = screen.getByRole('button');
20
+ expect(btn).toHaveAttribute('aria-label', 'Unsubscribe from notifications');
21
+ });
22
+
23
+ it('should render unsubscribed state', () => {
24
+ const sub: RecordSubscription = { recordId: '1', subscribed: false };
25
+ render(<SubscriptionToggle subscription={sub} />);
26
+ const btn = screen.getByRole('button');
27
+ expect(btn).toHaveAttribute('aria-label', 'Subscribe to notifications');
28
+ });
29
+
30
+ it('should call onToggle with new state when clicked', () => {
31
+ const onToggle = vi.fn();
32
+ const sub: RecordSubscription = { recordId: '1', subscribed: false };
33
+ render(<SubscriptionToggle subscription={sub} onToggle={onToggle} />);
34
+ fireEvent.click(screen.getByRole('button'));
35
+ expect(onToggle).toHaveBeenCalledWith(true);
36
+ });
37
+
38
+ it('should call onToggle with false when unsubscribing', () => {
39
+ const onToggle = vi.fn();
40
+ const sub: RecordSubscription = { recordId: '1', subscribed: true };
41
+ render(<SubscriptionToggle subscription={sub} onToggle={onToggle} />);
42
+ fireEvent.click(screen.getByRole('button'));
43
+ expect(onToggle).toHaveBeenCalledWith(false);
44
+ });
45
+
46
+ it('should be disabled when no onToggle provided', () => {
47
+ const sub: RecordSubscription = { recordId: '1', subscribed: true };
48
+ render(<SubscriptionToggle subscription={sub} />);
49
+ expect(screen.getByRole('button')).toBeDisabled();
50
+ });
51
+
52
+ it('should show title for subscribed state', () => {
53
+ const sub: RecordSubscription = { recordId: '1', subscribed: true };
54
+ render(<SubscriptionToggle subscription={sub} onToggle={() => {}} />);
55
+ expect(screen.getByRole('button')).toHaveAttribute('title', 'Subscribed — click to unsubscribe');
56
+ });
57
+
58
+ it('should show title for unsubscribed state', () => {
59
+ const sub: RecordSubscription = { recordId: '1', subscribed: false };
60
+ render(<SubscriptionToggle subscription={sub} onToggle={() => {}} />);
61
+ expect(screen.getByRole('button')).toHaveAttribute('title', 'Subscribe to notifications');
62
+ });
63
+
64
+ it('should be disabled during loading after click', async () => {
65
+ let resolveToggle: () => void;
66
+ const onToggle = vi.fn(() => new Promise<void>((r) => { resolveToggle = r; }));
67
+ const sub: RecordSubscription = { recordId: '1', subscribed: false };
68
+ render(<SubscriptionToggle subscription={sub} onToggle={onToggle} />);
69
+ fireEvent.click(screen.getByRole('button'));
70
+ expect(screen.getByRole('button')).toBeDisabled();
71
+ resolveToggle!();
72
+ await vi.waitFor(() => {
73
+ expect(screen.getByRole('button')).not.toBeDisabled();
74
+ });
75
+ });
76
+
77
+ it('should update aria-label based on subscribed state', () => {
78
+ const sub: RecordSubscription = { recordId: '1', subscribed: true };
79
+ const { rerender } = render(<SubscriptionToggle subscription={sub} onToggle={() => {}} />);
80
+ expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Unsubscribe from notifications');
81
+ rerender(<SubscriptionToggle subscription={{ ...sub, subscribed: false }} onToggle={() => {}} />);
82
+ expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Subscribe to notifications');
83
+ });
84
+ });