@object-ui/plugin-detail 3.0.3 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +45 -8
- package/CHANGELOG.md +11 -0
- package/dist/AddressField-B1iVr404.js +96 -0
- package/dist/AutoNumberField-BxnFqllo.js +8 -0
- package/dist/AvatarField-Duw4xOLZ.js +82 -0
- package/dist/BooleanField-CZ4axVeq.js +37 -0
- package/dist/CodeField-BSz-mk2v.js +21 -0
- package/dist/ColorField-B522ad8m.js +42 -0
- package/dist/CurrencyField-Cwr3_pow.js +43 -0
- package/dist/DateField-DCo6dxud.js +21 -0
- package/dist/DateTimeField-BWfBuANO.js +28 -0
- package/dist/EmailField-CpwbdVCU.js +31 -0
- package/dist/FileField-DVAUAJ8e.js +133 -0
- package/dist/FormulaField-CJkkwIK8.js +9 -0
- package/dist/GeolocationField-DNCKitgo.js +123 -0
- package/dist/GridField-DSblZNfp.js +30 -0
- package/dist/ImageField-DBAlnMon.js +90 -0
- package/dist/LocationField-DsHsXA6R.js +31 -0
- package/dist/LookupField-CsT0QQz2.js +96 -0
- package/dist/MasterDetailField-Db8b7Gqs.js +108 -0
- package/dist/NumberField-0IGp7lcA.js +26 -0
- package/dist/ObjectField-BLApgJtS.js +48 -0
- package/dist/PasswordField-pHKyNlmo.js +38 -0
- package/dist/PercentField-CwgKmlIb.js +63 -0
- package/dist/PhoneField-lKtbYOdN.js +31 -0
- package/dist/QRCodeField-BTTasT3w.js +77 -0
- package/dist/RatingField-De2X-l44.js +47 -0
- package/dist/RichTextField-B5QnvUOr.js +38 -0
- package/dist/SelectField-C9AZRHWu.js +26 -0
- package/dist/SignatureField-BgcEmYzd.js +85 -0
- package/dist/SliderField-BzrttVOY.js +30 -0
- package/dist/SummaryField-ugYPYxjP.js +9 -0
- package/dist/TextAreaField-DSE_CaU6.js +39 -0
- package/dist/TextField-DFQ4T9PR.js +32 -0
- package/dist/TimeField-F0cfmsps.js +21 -0
- package/dist/UrlField-DLXrFIH-.js +33 -0
- package/dist/UserField-PXMmxJY9.js +49 -0
- package/dist/VectorField-CKg9jdGa.js +25 -0
- package/dist/index-qQ1C-yUR.js +59976 -0
- package/dist/index.js +32 -55026
- package/dist/index.umd.cjs +41 -30
- package/dist/plugin-detail.css +1 -1
- package/dist/src/ActivityTimeline.d.ts +20 -0
- package/dist/src/ActivityTimeline.d.ts.map +1 -0
- package/dist/src/CommentAttachment.d.ts +25 -0
- package/dist/src/CommentAttachment.d.ts.map +1 -0
- package/dist/src/CommentInput.d.ts +24 -0
- package/dist/src/CommentInput.d.ts.map +1 -0
- package/dist/src/DetailSection.d.ts +8 -0
- package/dist/src/DetailSection.d.ts.map +1 -1
- package/dist/src/DetailView.d.ts +4 -0
- package/dist/src/DetailView.d.ts.map +1 -1
- package/dist/src/DetailView.stories.d.ts +8 -0
- package/dist/src/DetailView.stories.d.ts.map +1 -1
- package/dist/src/DiffView.d.ts +24 -0
- package/dist/src/DiffView.d.ts.map +1 -0
- package/dist/src/FieldChangeItem.d.ts +21 -0
- package/dist/src/FieldChangeItem.d.ts.map +1 -0
- package/dist/src/HeaderHighlight.d.ts +18 -0
- package/dist/src/HeaderHighlight.d.ts.map +1 -0
- package/dist/src/InlineCreateRelated.d.ts +32 -0
- package/dist/src/InlineCreateRelated.d.ts.map +1 -0
- package/dist/src/MentionAutocomplete.d.ts +43 -0
- package/dist/src/MentionAutocomplete.d.ts.map +1 -0
- package/dist/src/PointInTimeRestore.d.ts +28 -0
- package/dist/src/PointInTimeRestore.d.ts.map +1 -0
- package/dist/src/ReactionPicker.d.ts +25 -0
- package/dist/src/ReactionPicker.d.ts.map +1 -0
- package/dist/src/RecordActivityTimeline.d.ts +49 -0
- package/dist/src/RecordActivityTimeline.d.ts.map +1 -0
- package/dist/src/RecordChatterPanel.d.ts +48 -0
- package/dist/src/RecordChatterPanel.d.ts.map +1 -0
- package/dist/src/RecordComments.d.ts +20 -0
- package/dist/src/RecordComments.d.ts.map +1 -0
- package/dist/src/RecordNavigationEnhanced.d.ts +18 -0
- package/dist/src/RecordNavigationEnhanced.d.ts.map +1 -0
- package/dist/src/RelatedList.d.ts +20 -0
- package/dist/src/RelatedList.d.ts.map +1 -1
- package/dist/src/RelationshipGraph.d.ts +23 -0
- package/dist/src/RelationshipGraph.d.ts.map +1 -0
- package/dist/src/RichTextCommentInput.d.ts +24 -0
- package/dist/src/RichTextCommentInput.d.ts.map +1 -0
- package/dist/src/SectionGroup.d.ts +21 -0
- package/dist/src/SectionGroup.d.ts.map +1 -0
- package/dist/src/SubscriptionToggle.d.ts +22 -0
- package/dist/src/SubscriptionToggle.d.ts.map +1 -0
- package/dist/src/ThreadedReplies.d.ts +26 -0
- package/dist/src/ThreadedReplies.d.ts.map +1 -0
- package/dist/src/autoLayout.d.ts +34 -0
- package/dist/src/autoLayout.d.ts.map +1 -0
- package/dist/src/index.d.ts +40 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/useDetailTranslation.d.ts +34 -0
- package/dist/src/useDetailTranslation.d.ts.map +1 -0
- package/package.json +8 -7
- package/src/ActivityTimeline.tsx +184 -0
- package/src/CommentAttachment.tsx +192 -0
- package/src/CommentInput.tsx +81 -0
- package/src/DetailSection.tsx +81 -10
- package/src/DetailView.stories.tsx +76 -0
- package/src/DetailView.tsx +519 -66
- package/src/DiffView.tsx +231 -0
- package/src/FieldChangeItem.tsx +46 -0
- package/src/HeaderHighlight.tsx +67 -0
- package/src/InlineCreateRelated.tsx +291 -0
- package/src/MentionAutocomplete.tsx +123 -0
- package/src/PointInTimeRestore.tsx +261 -0
- package/src/ReactionPicker.tsx +106 -0
- package/src/RecordActivityTimeline.tsx +429 -0
- package/src/RecordChatterPanel.tsx +202 -0
- package/src/RecordComments.tsx +215 -0
- package/src/RecordNavigationEnhanced.tsx +211 -0
- package/src/RelatedList.tsx +314 -19
- package/src/RelationshipGraph.tsx +286 -0
- package/src/RichTextCommentInput.tsx +348 -0
- package/src/SectionGroup.tsx +101 -0
- package/src/SubscriptionToggle.tsx +60 -0
- package/src/ThreadedReplies.tsx +161 -0
- package/src/__tests__/ActivityTimeline.test.tsx +119 -0
- package/src/__tests__/ActivityTimelineFiltering.test.tsx +143 -0
- package/src/__tests__/CommentInput.test.tsx +57 -0
- package/src/__tests__/DetailSection.test.tsx +320 -0
- package/src/__tests__/DetailView.test.tsx +415 -1
- package/src/__tests__/FieldChangeItem.test.tsx +119 -0
- package/src/__tests__/HeaderHighlight.test.tsx +68 -0
- package/src/__tests__/MentionAutocomplete.test.tsx +97 -0
- package/src/__tests__/ReactionPicker.test.tsx +113 -0
- package/src/__tests__/RecordActivityTimeline.test.tsx +395 -0
- package/src/__tests__/RecordChatterPanel.test.tsx +227 -0
- package/src/__tests__/RecordComments.test.tsx +96 -0
- package/src/__tests__/RecordCommentsPinSearch.test.tsx +133 -0
- package/src/__tests__/RelatedList.test.tsx +160 -0
- package/src/__tests__/SectionGroup.test.tsx +101 -0
- package/src/__tests__/SubscriptionToggle.test.tsx +84 -0
- package/src/__tests__/ThreadedReplies.test.tsx +212 -0
- package/src/__tests__/autoLayout.test.ts +184 -0
- package/src/__tests__/phase12-features.test.tsx +583 -0
- package/src/__tests__/roadmap-features.test.tsx +478 -0
- package/src/autoLayout.ts +111 -0
- package/src/index.tsx +50 -0
- package/src/useDetailTranslation.ts +114 -0
|
@@ -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,160 @@
|
|
|
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, waitFor } 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 badge for empty list', () => {
|
|
20
|
+
render(<RelatedList title="Contacts" type="table" data={[]} />);
|
|
21
|
+
expect(screen.getByText('0')).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should show record count badge for one item', () => {
|
|
25
|
+
render(<RelatedList title="Contacts" type="table" data={[{ id: 1, name: 'Alice' }]} />);
|
|
26
|
+
expect(screen.getByText('1')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should show record count badge 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')).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
|
+
|
|
67
|
+
it('should auto-generate columns from object schema when api and dataSource provided but no columns', async () => {
|
|
68
|
+
const mockDataSource = {
|
|
69
|
+
getObjectSchema: vi.fn().mockResolvedValue({
|
|
70
|
+
name: 'order_item',
|
|
71
|
+
fields: {
|
|
72
|
+
product: { type: 'string', label: 'Product' },
|
|
73
|
+
quantity: { type: 'number', label: 'Quantity' },
|
|
74
|
+
_id: { type: 'string', label: 'ID' },
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
find: vi.fn(),
|
|
78
|
+
} as any;
|
|
79
|
+
|
|
80
|
+
const data = [{ product: 'Widget', quantity: 5 }];
|
|
81
|
+
render(
|
|
82
|
+
<RelatedList
|
|
83
|
+
title="Order Items"
|
|
84
|
+
type="table"
|
|
85
|
+
api="order_item"
|
|
86
|
+
data={data}
|
|
87
|
+
dataSource={mockDataSource}
|
|
88
|
+
/>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(mockDataSource.getObjectSchema).toHaveBeenCalledWith('order_item');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Verify columns are generated from schema (excluding _id)
|
|
96
|
+
await waitFor(() => {
|
|
97
|
+
expect(screen.getByText('Product')).toBeInTheDocument();
|
|
98
|
+
expect(screen.getByText('Quantity')).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
// _id should be filtered out
|
|
101
|
+
expect(screen.queryByText('ID')).not.toBeInTheDocument();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should not fetch object schema when explicit columns are provided', () => {
|
|
105
|
+
const mockDataSource = {
|
|
106
|
+
getObjectSchema: vi.fn(),
|
|
107
|
+
find: vi.fn(),
|
|
108
|
+
} as any;
|
|
109
|
+
|
|
110
|
+
const columns = [{ accessorKey: 'name', header: 'Name' }];
|
|
111
|
+
render(
|
|
112
|
+
<RelatedList
|
|
113
|
+
title="Contacts"
|
|
114
|
+
type="table"
|
|
115
|
+
api="contact"
|
|
116
|
+
data={[{ name: 'Alice' }]}
|
|
117
|
+
columns={columns}
|
|
118
|
+
dataSource={mockDataSource}
|
|
119
|
+
/>,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(mockDataSource.getObjectSchema).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should render collapsed state when collapsible and defaultCollapsed are true', () => {
|
|
126
|
+
const data = [{ id: 1, name: 'Alice' }];
|
|
127
|
+
render(
|
|
128
|
+
<RelatedList title="Contacts" type="table" data={data} collapsible defaultCollapsed />,
|
|
129
|
+
);
|
|
130
|
+
expect(screen.getByText('Contacts')).toBeInTheDocument();
|
|
131
|
+
// Content should be hidden when collapsed
|
|
132
|
+
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should expand collapsed card when header is clicked', () => {
|
|
136
|
+
render(
|
|
137
|
+
<RelatedList title="Contacts" type="table" data={[]} collapsible defaultCollapsed />,
|
|
138
|
+
);
|
|
139
|
+
// Initially collapsed - content should be hidden
|
|
140
|
+
expect(screen.queryByText('No related records found')).not.toBeInTheDocument();
|
|
141
|
+
// Click the header to expand
|
|
142
|
+
fireEvent.click(screen.getByText('Contacts'));
|
|
143
|
+
// Content should now be visible
|
|
144
|
+
expect(screen.getByText('No related records found')).toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should show content by default when collapsible is true but defaultCollapsed is false', () => {
|
|
148
|
+
render(
|
|
149
|
+
<RelatedList title="Contacts" type="table" data={[]} collapsible />,
|
|
150
|
+
);
|
|
151
|
+
expect(screen.getByText('No related records found')).toBeInTheDocument();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should show content when collapsible is false (default)', () => {
|
|
155
|
+
render(
|
|
156
|
+
<RelatedList title="Contacts" type="table" data={[]} />,
|
|
157
|
+
);
|
|
158
|
+
expect(screen.getByText('No related records found')).toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
});
|