@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.
- package/.turbo/turbo-build.log +45 -8
- package/CHANGELOG.md +9 -0
- package/dist/AddressField-C07oUOY6.js +96 -0
- package/dist/AutoNumberField-BxnFqllo.js +8 -0
- package/dist/AvatarField-VThNABzo.js +82 -0
- package/dist/BooleanField-CGHKBzAi.js +37 -0
- package/dist/CodeField-Co_muhRR.js +21 -0
- package/dist/ColorField-DLid_tFz.js +42 -0
- package/dist/CurrencyField-Bw-LqANM.js +43 -0
- package/dist/DateField-BNHAzMB2.js +21 -0
- package/dist/DateTimeField-DjAyn_DQ.js +28 -0
- package/dist/EmailField-xoNcSppb.js +31 -0
- package/dist/FileField-DbNJwjU2.js +133 -0
- package/dist/FormulaField-CJkkwIK8.js +9 -0
- package/dist/GeolocationField-C1AnS6VV.js +123 -0
- package/dist/GridField-DATAHIKf.js +30 -0
- package/dist/ImageField-CEKJpyJp.js +90 -0
- package/dist/LocationField-jDWXjlpx.js +31 -0
- package/dist/LookupField-DQ08L9UQ.js +96 -0
- package/dist/MasterDetailField-Dbk529Ea.js +108 -0
- package/dist/NumberField-BVroN9aV.js +26 -0
- package/dist/ObjectField-CT3l_IHW.js +48 -0
- package/dist/PasswordField-DweVLEE0.js +38 -0
- package/dist/PercentField-ZpWUK97K.js +63 -0
- package/dist/PhoneField-mw-9fqZ_.js +31 -0
- package/dist/QRCodeField-Cbb9ck59.js +77 -0
- package/dist/RatingField-CSqgLS6t.js +47 -0
- package/dist/RichTextField-BpfBOd99.js +38 -0
- package/dist/SelectField-B9Ei-5jl.js +26 -0
- package/dist/SignatureField-DgGpHnQ8.js +85 -0
- package/dist/SliderField-C6HvOHd8.js +30 -0
- package/dist/SummaryField-ugYPYxjP.js +9 -0
- package/dist/TextAreaField-BK3RgzY3.js +39 -0
- package/dist/TextField-Bvzx3atT.js +32 -0
- package/dist/TimeField-Cuz9-Uai.js +21 -0
- package/dist/UrlField-B6XHTV73.js +33 -0
- package/dist/UserField-ooTul2d6.js +49 -0
- package/dist/VectorField-CKg9jdGa.js +25 -0
- package/dist/index-CnlyRfY_.js +59461 -0
- package/dist/index.js +30 -55026
- package/dist/index.umd.cjs +41 -30
- package/dist/plugin-detail.css +1 -1
- package/dist/src/ActivityTimeline.d.ts +20 -0
- package/dist/src/ActivityTimeline.d.ts.map +1 -0
- package/dist/src/CommentAttachment.d.ts +25 -0
- package/dist/src/CommentAttachment.d.ts.map +1 -0
- package/dist/src/CommentInput.d.ts +24 -0
- package/dist/src/CommentInput.d.ts.map +1 -0
- package/dist/src/DetailSection.d.ts +6 -0
- package/dist/src/DetailSection.d.ts.map +1 -1
- package/dist/src/DetailView.d.ts +4 -0
- package/dist/src/DetailView.d.ts.map +1 -1
- package/dist/src/DetailView.stories.d.ts +8 -0
- package/dist/src/DetailView.stories.d.ts.map +1 -1
- package/dist/src/DiffView.d.ts +24 -0
- package/dist/src/DiffView.d.ts.map +1 -0
- package/dist/src/FieldChangeItem.d.ts +21 -0
- package/dist/src/FieldChangeItem.d.ts.map +1 -0
- package/dist/src/InlineCreateRelated.d.ts +32 -0
- package/dist/src/InlineCreateRelated.d.ts.map +1 -0
- package/dist/src/MentionAutocomplete.d.ts +43 -0
- package/dist/src/MentionAutocomplete.d.ts.map +1 -0
- package/dist/src/PointInTimeRestore.d.ts +28 -0
- package/dist/src/PointInTimeRestore.d.ts.map +1 -0
- package/dist/src/ReactionPicker.d.ts +25 -0
- package/dist/src/ReactionPicker.d.ts.map +1 -0
- package/dist/src/RecordActivityTimeline.d.ts +49 -0
- package/dist/src/RecordActivityTimeline.d.ts.map +1 -0
- package/dist/src/RecordChatterPanel.d.ts +48 -0
- package/dist/src/RecordChatterPanel.d.ts.map +1 -0
- package/dist/src/RecordComments.d.ts +20 -0
- package/dist/src/RecordComments.d.ts.map +1 -0
- package/dist/src/RecordNavigationEnhanced.d.ts +18 -0
- package/dist/src/RecordNavigationEnhanced.d.ts.map +1 -0
- package/dist/src/RelatedList.d.ts +4 -0
- package/dist/src/RelatedList.d.ts.map +1 -1
- package/dist/src/RelationshipGraph.d.ts +23 -0
- package/dist/src/RelationshipGraph.d.ts.map +1 -0
- package/dist/src/RichTextCommentInput.d.ts +24 -0
- package/dist/src/RichTextCommentInput.d.ts.map +1 -0
- package/dist/src/SubscriptionToggle.d.ts +22 -0
- package/dist/src/SubscriptionToggle.d.ts.map +1 -0
- package/dist/src/ThreadedReplies.d.ts +26 -0
- package/dist/src/ThreadedReplies.d.ts.map +1 -0
- package/dist/src/autoLayout.d.ts +34 -0
- package/dist/src/autoLayout.d.ts.map +1 -0
- package/dist/src/index.d.ts +36 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/useDetailTranslation.d.ts +34 -0
- package/dist/src/useDetailTranslation.d.ts.map +1 -0
- package/package.json +8 -7
- package/src/ActivityTimeline.tsx +184 -0
- package/src/CommentAttachment.tsx +192 -0
- package/src/CommentInput.tsx +81 -0
- package/src/DetailSection.tsx +74 -9
- package/src/DetailView.stories.tsx +76 -0
- package/src/DetailView.tsx +270 -27
- package/src/DiffView.tsx +231 -0
- package/src/FieldChangeItem.tsx +46 -0
- package/src/InlineCreateRelated.tsx +291 -0
- package/src/MentionAutocomplete.tsx +123 -0
- package/src/PointInTimeRestore.tsx +261 -0
- package/src/ReactionPicker.tsx +106 -0
- package/src/RecordActivityTimeline.tsx +429 -0
- package/src/RecordChatterPanel.tsx +202 -0
- package/src/RecordComments.tsx +215 -0
- package/src/RecordNavigationEnhanced.tsx +211 -0
- package/src/RelatedList.tsx +37 -8
- package/src/RelationshipGraph.tsx +286 -0
- package/src/RichTextCommentInput.tsx +348 -0
- package/src/SubscriptionToggle.tsx +60 -0
- package/src/ThreadedReplies.tsx +161 -0
- package/src/__tests__/ActivityTimeline.test.tsx +119 -0
- package/src/__tests__/ActivityTimelineFiltering.test.tsx +143 -0
- package/src/__tests__/CommentInput.test.tsx +57 -0
- package/src/__tests__/DetailSection.test.tsx +320 -0
- package/src/__tests__/DetailView.test.tsx +415 -1
- package/src/__tests__/FieldChangeItem.test.tsx +119 -0
- package/src/__tests__/MentionAutocomplete.test.tsx +97 -0
- package/src/__tests__/ReactionPicker.test.tsx +113 -0
- package/src/__tests__/RecordActivityTimeline.test.tsx +395 -0
- package/src/__tests__/RecordChatterPanel.test.tsx +227 -0
- package/src/__tests__/RecordComments.test.tsx +96 -0
- package/src/__tests__/RecordCommentsPinSearch.test.tsx +133 -0
- package/src/__tests__/RelatedList.test.tsx +66 -0
- package/src/__tests__/SubscriptionToggle.test.tsx +84 -0
- package/src/__tests__/ThreadedReplies.test.tsx +212 -0
- package/src/__tests__/autoLayout.test.ts +184 -0
- package/src/__tests__/phase12-features.test.tsx +583 -0
- package/src/autoLayout.ts +111 -0
- package/src/index.tsx +46 -0
- 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
|
+
});
|