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