@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,113 @@
|
|
|
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 { ReactionPicker } from '../ReactionPicker';
|
|
13
|
+
import type { Reaction } from '@object-ui/types';
|
|
14
|
+
|
|
15
|
+
const mockReactions: Reaction[] = [
|
|
16
|
+
{ emoji: '👍', count: 3, reacted: true },
|
|
17
|
+
{ emoji: '❤️', count: 1, reacted: false },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe('ReactionPicker', () => {
|
|
21
|
+
it('should render existing reactions with counts', () => {
|
|
22
|
+
render(<ReactionPicker reactions={mockReactions} />);
|
|
23
|
+
expect(screen.getByText('👍')).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByText('3')).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByText('❤️')).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByText('1')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should highlight reacted emoji', () => {
|
|
30
|
+
render(<ReactionPicker reactions={mockReactions} />);
|
|
31
|
+
const thumbs = screen.getByLabelText(/👍 3/);
|
|
32
|
+
expect(thumbs).toHaveClass('bg-primary/10');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should show add reaction button when onToggleReaction provided', () => {
|
|
36
|
+
const onToggle = vi.fn();
|
|
37
|
+
render(<ReactionPicker reactions={[]} onToggleReaction={onToggle} />);
|
|
38
|
+
expect(screen.getByLabelText('Add reaction')).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should not show add button when no onToggleReaction', () => {
|
|
42
|
+
render(<ReactionPicker reactions={[]} />);
|
|
43
|
+
expect(screen.queryByLabelText('Add reaction')).not.toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should call onToggleReaction when clicking existing reaction', () => {
|
|
47
|
+
const onToggle = vi.fn();
|
|
48
|
+
render(<ReactionPicker reactions={mockReactions} onToggleReaction={onToggle} />);
|
|
49
|
+
fireEvent.click(screen.getByLabelText(/👍 3/));
|
|
50
|
+
expect(onToggle).toHaveBeenCalledWith('👍');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should show emoji picker when add button is clicked', () => {
|
|
54
|
+
const onToggle = vi.fn();
|
|
55
|
+
render(<ReactionPicker reactions={[]} onToggleReaction={onToggle} />);
|
|
56
|
+
fireEvent.click(screen.getByLabelText('Add reaction'));
|
|
57
|
+
expect(screen.getByRole('listbox', { name: 'Emoji picker' })).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should call onToggleReaction when emoji is selected from picker', () => {
|
|
61
|
+
const onToggle = vi.fn();
|
|
62
|
+
render(<ReactionPicker reactions={[]} onToggleReaction={onToggle} />);
|
|
63
|
+
fireEvent.click(screen.getByLabelText('Add reaction'));
|
|
64
|
+
// Select first emoji option (👍)
|
|
65
|
+
const options = screen.getAllByRole('option');
|
|
66
|
+
fireEvent.click(options[0]);
|
|
67
|
+
expect(onToggle).toHaveBeenCalledWith('👍');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should disable reaction buttons when no onToggleReaction', () => {
|
|
71
|
+
render(<ReactionPicker reactions={mockReactions} />);
|
|
72
|
+
const thumbsBtn = screen.getByLabelText(/👍 3/);
|
|
73
|
+
expect(thumbsBtn).toBeDisabled();
|
|
74
|
+
const heartBtn = screen.getByLabelText(/❤️ 1/);
|
|
75
|
+
expect(heartBtn).toBeDisabled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should render custom emojiOptions', () => {
|
|
79
|
+
const onToggle = vi.fn();
|
|
80
|
+
const customEmoji = ['🚀', '🔥', '✅'];
|
|
81
|
+
render(
|
|
82
|
+
<ReactionPicker reactions={[]} onToggleReaction={onToggle} emojiOptions={customEmoji} />,
|
|
83
|
+
);
|
|
84
|
+
fireEvent.click(screen.getByLabelText('Add reaction'));
|
|
85
|
+
const options = screen.getAllByRole('option');
|
|
86
|
+
expect(options).toHaveLength(3);
|
|
87
|
+
expect(options[0]).toHaveTextContent('🚀');
|
|
88
|
+
expect(options[1]).toHaveTextContent('🔥');
|
|
89
|
+
expect(options[2]).toHaveTextContent('✅');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should include emoji and count in aria-label', () => {
|
|
93
|
+
render(<ReactionPicker reactions={mockReactions} />);
|
|
94
|
+
expect(screen.getByLabelText('👍 3 reactions')).toBeInTheDocument();
|
|
95
|
+
expect(screen.getByLabelText('❤️ 1 reaction')).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should show non-reacted emoji with bg-muted style', () => {
|
|
99
|
+
render(<ReactionPicker reactions={mockReactions} />);
|
|
100
|
+
const heart = screen.getByLabelText(/❤️ 1/);
|
|
101
|
+
expect(heart).toHaveClass('bg-muted');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should close picker after selecting emoji', () => {
|
|
105
|
+
const onToggle = vi.fn();
|
|
106
|
+
render(<ReactionPicker reactions={[]} onToggleReaction={onToggle} />);
|
|
107
|
+
fireEvent.click(screen.getByLabelText('Add reaction'));
|
|
108
|
+
expect(screen.getByRole('listbox', { name: 'Emoji picker' })).toBeInTheDocument();
|
|
109
|
+
const options = screen.getAllByRole('option');
|
|
110
|
+
fireEvent.click(options[0]);
|
|
111
|
+
expect(screen.queryByRole('listbox', { name: 'Emoji picker' })).not.toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,395 @@
|
|
|
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 { RecordActivityTimeline } from '../RecordActivityTimeline';
|
|
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: 'This looks great!',
|
|
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
|
+
{
|
|
30
|
+
field: 'status',
|
|
31
|
+
fieldLabel: 'Status',
|
|
32
|
+
oldDisplayValue: 'Open',
|
|
33
|
+
newDisplayValue: 'In Progress',
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: '3',
|
|
39
|
+
type: 'task',
|
|
40
|
+
actor: 'Charlie',
|
|
41
|
+
body: 'Follow up with client',
|
|
42
|
+
createdAt: '2026-02-20T12:00:00Z',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: '4',
|
|
46
|
+
type: 'system',
|
|
47
|
+
actor: 'System',
|
|
48
|
+
body: 'Record created automatically',
|
|
49
|
+
createdAt: '2026-02-20T08:00:00Z',
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const allTypeItems: FeedItem[] = [
|
|
54
|
+
{ id: 'c1', type: 'comment', actor: 'A', body: 'comment', createdAt: '2026-02-20T10:00:00Z' },
|
|
55
|
+
{ id: 'fc1', type: 'field_change', actor: 'B', createdAt: '2026-02-20T10:01:00Z', fieldChanges: [{ field: 'x', oldValue: '1', newValue: '2' }] },
|
|
56
|
+
{ id: 't1', type: 'task', actor: 'C', body: 'task', createdAt: '2026-02-20T10:02:00Z' },
|
|
57
|
+
{ id: 'e1', type: 'event', actor: 'D', body: 'event', createdAt: '2026-02-20T10:03:00Z' },
|
|
58
|
+
{ id: 's1', type: 'system', actor: 'E', body: 'system', createdAt: '2026-02-20T10:04:00Z' },
|
|
59
|
+
{ id: 'm1', type: 'email', actor: 'F', body: 'email', createdAt: '2026-02-20T10:05:00Z' },
|
|
60
|
+
{ id: 'p1', type: 'call', actor: 'G', body: 'call', createdAt: '2026-02-20T10:06:00Z' },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
describe('RecordActivityTimeline', () => {
|
|
64
|
+
it('should render activity heading with count', () => {
|
|
65
|
+
render(<RecordActivityTimeline items={mockItems} />);
|
|
66
|
+
expect(screen.getByText('Activity')).toBeInTheDocument();
|
|
67
|
+
expect(screen.getByText('(4)')).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should render actor names', () => {
|
|
71
|
+
render(<RecordActivityTimeline items={mockItems} />);
|
|
72
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
73
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
74
|
+
expect(screen.getByText('Charlie')).toBeInTheDocument();
|
|
75
|
+
expect(screen.getByText('System')).toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should render comment body text', () => {
|
|
79
|
+
render(<RecordActivityTimeline items={mockItems} />);
|
|
80
|
+
expect(screen.getByText('This looks great!')).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should render field change entries', () => {
|
|
84
|
+
render(<RecordActivityTimeline items={mockItems} />);
|
|
85
|
+
expect(screen.getByText('Status')).toBeInTheDocument();
|
|
86
|
+
expect(screen.getByText('Open')).toBeInTheDocument();
|
|
87
|
+
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should show "No activity recorded" when empty', () => {
|
|
91
|
+
render(<RecordActivityTimeline items={[]} />);
|
|
92
|
+
expect(screen.getByText('No activity recorded')).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should not show "No activity recorded" when collapseWhenEmpty is true', () => {
|
|
96
|
+
render(<RecordActivityTimeline items={[]} collapseWhenEmpty />);
|
|
97
|
+
expect(screen.queryByText('No activity recorded')).not.toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should filter to comments only', () => {
|
|
101
|
+
render(
|
|
102
|
+
<RecordActivityTimeline items={mockItems} filterMode="comments_only" />,
|
|
103
|
+
);
|
|
104
|
+
expect(screen.getByText('(1)')).toBeInTheDocument();
|
|
105
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
106
|
+
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should filter to changes only', () => {
|
|
110
|
+
render(
|
|
111
|
+
<RecordActivityTimeline items={mockItems} filterMode="changes_only" />,
|
|
112
|
+
);
|
|
113
|
+
expect(screen.getByText('(1)')).toBeInTheDocument();
|
|
114
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
115
|
+
expect(screen.queryByText('Alice')).not.toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should filter to tasks only', () => {
|
|
119
|
+
render(
|
|
120
|
+
<RecordActivityTimeline items={mockItems} filterMode="tasks_only" />,
|
|
121
|
+
);
|
|
122
|
+
expect(screen.getByText('(1)')).toBeInTheDocument();
|
|
123
|
+
expect(screen.getByText('Charlie')).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should show filter dropdown by default', () => {
|
|
127
|
+
render(<RecordActivityTimeline items={mockItems} />);
|
|
128
|
+
expect(screen.getByLabelText('Filter activity')).toBeInTheDocument();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should hide filter when showFilterToggle is false', () => {
|
|
132
|
+
render(
|
|
133
|
+
<RecordActivityTimeline
|
|
134
|
+
items={mockItems}
|
|
135
|
+
config={{ showFilterToggle: false }}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
expect(screen.queryByLabelText('Filter activity')).not.toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should show comment input when onAddComment provided', () => {
|
|
142
|
+
const onAdd = vi.fn();
|
|
143
|
+
render(
|
|
144
|
+
<RecordActivityTimeline items={[]} onAddComment={onAdd} />,
|
|
145
|
+
);
|
|
146
|
+
expect(screen.getByPlaceholderText(/Leave a comment/)).toBeInTheDocument();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should hide comment input when showCommentInput is false', () => {
|
|
150
|
+
const onAdd = vi.fn();
|
|
151
|
+
render(
|
|
152
|
+
<RecordActivityTimeline
|
|
153
|
+
items={[]}
|
|
154
|
+
onAddComment={onAdd}
|
|
155
|
+
config={{ showCommentInput: false }}
|
|
156
|
+
/>,
|
|
157
|
+
);
|
|
158
|
+
expect(screen.queryByPlaceholderText(/Leave a comment/)).not.toBeInTheDocument();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should call onAddComment when comment is submitted', () => {
|
|
162
|
+
const onAdd = vi.fn().mockResolvedValue(undefined);
|
|
163
|
+
render(
|
|
164
|
+
<RecordActivityTimeline items={[]} onAddComment={onAdd} />,
|
|
165
|
+
);
|
|
166
|
+
fireEvent.change(screen.getByPlaceholderText(/Leave a comment/), {
|
|
167
|
+
target: { value: 'New comment' },
|
|
168
|
+
});
|
|
169
|
+
fireEvent.click(screen.getByLabelText('Submit comment'));
|
|
170
|
+
expect(onAdd).toHaveBeenCalledWith('New comment');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should show Load more button when hasMore is true', () => {
|
|
174
|
+
render(
|
|
175
|
+
<RecordActivityTimeline items={mockItems} hasMore onLoadMore={() => {}} />,
|
|
176
|
+
);
|
|
177
|
+
expect(screen.getByLabelText('Load more activity')).toBeInTheDocument();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should call onLoadMore when Load more is clicked', () => {
|
|
181
|
+
const onLoadMore = vi.fn().mockResolvedValue(undefined);
|
|
182
|
+
render(
|
|
183
|
+
<RecordActivityTimeline items={mockItems} hasMore onLoadMore={onLoadMore} />,
|
|
184
|
+
);
|
|
185
|
+
fireEvent.click(screen.getByLabelText('Load more activity'));
|
|
186
|
+
expect(onLoadMore).toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should render source label when present', () => {
|
|
190
|
+
const items: FeedItem[] = [
|
|
191
|
+
{ id: '1', type: 'comment', actor: 'Alice', body: 'Hi', createdAt: '2026-02-20T10:00:00Z', source: 'email' },
|
|
192
|
+
];
|
|
193
|
+
render(<RecordActivityTimeline items={items} />);
|
|
194
|
+
expect(screen.getByText('via email')).toBeInTheDocument();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should render edited indicator', () => {
|
|
198
|
+
const items: FeedItem[] = [
|
|
199
|
+
{ id: '1', type: 'comment', actor: 'Alice', body: 'Edited', createdAt: '2026-02-20T10:00:00Z', edited: true },
|
|
200
|
+
];
|
|
201
|
+
render(<RecordActivityTimeline items={items} />);
|
|
202
|
+
expect(screen.getByText('(edited)')).toBeInTheDocument();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should render pinned indicator', () => {
|
|
206
|
+
const items: FeedItem[] = [
|
|
207
|
+
{ id: '1', type: 'comment', actor: 'Alice', body: 'Pinned comment', createdAt: '2026-02-20T10:00:00Z', pinned: true },
|
|
208
|
+
];
|
|
209
|
+
render(<RecordActivityTimeline items={items} />);
|
|
210
|
+
expect(screen.getByText('📌 Pinned')).toBeInTheDocument();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should show subscription toggle when configured', () => {
|
|
214
|
+
render(
|
|
215
|
+
<RecordActivityTimeline
|
|
216
|
+
items={[]}
|
|
217
|
+
config={{ showSubscriptionToggle: true }}
|
|
218
|
+
subscription={{ recordId: '1', subscribed: true }}
|
|
219
|
+
onToggleSubscription={() => {}}
|
|
220
|
+
/>,
|
|
221
|
+
);
|
|
222
|
+
expect(screen.getByLabelText('Unsubscribe from notifications')).toBeInTheDocument();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should render all 7 item types', () => {
|
|
226
|
+
render(<RecordActivityTimeline items={allTypeItems} />);
|
|
227
|
+
expect(screen.getByText('A')).toBeInTheDocument();
|
|
228
|
+
expect(screen.getByText('B')).toBeInTheDocument();
|
|
229
|
+
expect(screen.getByText('C')).toBeInTheDocument();
|
|
230
|
+
expect(screen.getByText('D')).toBeInTheDocument();
|
|
231
|
+
expect(screen.getByText('E')).toBeInTheDocument();
|
|
232
|
+
expect(screen.getByText('F')).toBeInTheDocument();
|
|
233
|
+
expect(screen.getByText('G')).toBeInTheDocument();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should render actor avatar when actorAvatarUrl is provided', () => {
|
|
237
|
+
const items: FeedItem[] = [
|
|
238
|
+
{ id: '1', type: 'comment', actor: 'Alice', actorAvatarUrl: 'https://example.com/alice.png', body: 'Hi', createdAt: '2026-02-20T10:00:00Z' },
|
|
239
|
+
];
|
|
240
|
+
render(<RecordActivityTimeline items={items} />);
|
|
241
|
+
const img = screen.getByAltText('Alice');
|
|
242
|
+
expect(img).toBeInTheDocument();
|
|
243
|
+
expect(img).toHaveAttribute('src', 'https://example.com/alice.png');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should submit comment via Ctrl+Enter', () => {
|
|
247
|
+
const onAdd = vi.fn().mockResolvedValue(undefined);
|
|
248
|
+
render(
|
|
249
|
+
<RecordActivityTimeline items={[]} onAddComment={onAdd} />,
|
|
250
|
+
);
|
|
251
|
+
const textarea = screen.getByPlaceholderText(/Leave a comment/);
|
|
252
|
+
fireEvent.change(textarea, { target: { value: 'Ctrl enter test' } });
|
|
253
|
+
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true });
|
|
254
|
+
expect(onAdd).toHaveBeenCalledWith('Ctrl enter test');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should clear input after successful comment submission', async () => {
|
|
258
|
+
const onAdd = vi.fn().mockResolvedValue(undefined);
|
|
259
|
+
render(
|
|
260
|
+
<RecordActivityTimeline items={[]} onAddComment={onAdd} />,
|
|
261
|
+
);
|
|
262
|
+
const textarea = screen.getByPlaceholderText(/Leave a comment/) as HTMLTextAreaElement;
|
|
263
|
+
fireEvent.change(textarea, { target: { value: 'Will be cleared' } });
|
|
264
|
+
fireEvent.click(screen.getByLabelText('Submit comment'));
|
|
265
|
+
await vi.waitFor(() => {
|
|
266
|
+
expect(textarea.value).toBe('');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should disable input and button during submission', async () => {
|
|
271
|
+
let resolveSubmit: () => void;
|
|
272
|
+
const onAdd = vi.fn(() => new Promise<void>((r) => { resolveSubmit = r; }));
|
|
273
|
+
render(
|
|
274
|
+
<RecordActivityTimeline items={[]} onAddComment={onAdd} />,
|
|
275
|
+
);
|
|
276
|
+
const textarea = screen.getByPlaceholderText(/Leave a comment/) as HTMLTextAreaElement;
|
|
277
|
+
fireEvent.change(textarea, { target: { value: 'submitting' } });
|
|
278
|
+
fireEvent.click(screen.getByLabelText('Submit comment'));
|
|
279
|
+
expect(textarea).toBeDisabled();
|
|
280
|
+
expect(screen.getByLabelText('Submit comment')).toBeDisabled();
|
|
281
|
+
resolveSubmit!();
|
|
282
|
+
await vi.waitFor(() => {
|
|
283
|
+
expect(textarea).not.toBeDisabled();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should show loading spinner when loading more', async () => {
|
|
288
|
+
let resolveLoad: () => void;
|
|
289
|
+
const onLoadMore = vi.fn(() => new Promise<void>((r) => { resolveLoad = r; }));
|
|
290
|
+
render(
|
|
291
|
+
<RecordActivityTimeline items={mockItems} hasMore onLoadMore={onLoadMore} />,
|
|
292
|
+
);
|
|
293
|
+
fireEvent.click(screen.getByLabelText('Load more activity'));
|
|
294
|
+
// Loader2 spinner should be present while loading
|
|
295
|
+
expect(screen.getByLabelText('Load more activity')).toBeDisabled();
|
|
296
|
+
resolveLoad!();
|
|
297
|
+
await vi.waitFor(() => {
|
|
298
|
+
expect(screen.getByLabelText('Load more activity')).not.toBeDisabled();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should use controlled filterMode and call onFilterChange', () => {
|
|
303
|
+
const onFilterChange = vi.fn();
|
|
304
|
+
render(
|
|
305
|
+
<RecordActivityTimeline
|
|
306
|
+
items={mockItems}
|
|
307
|
+
filterMode="comments_only"
|
|
308
|
+
onFilterChange={onFilterChange}
|
|
309
|
+
/>,
|
|
310
|
+
);
|
|
311
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
312
|
+
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
|
|
313
|
+
// Change filter via select
|
|
314
|
+
fireEvent.change(screen.getByLabelText('Filter activity'), { target: { value: 'all' } });
|
|
315
|
+
expect(onFilterChange).toHaveBeenCalledWith('all');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should use internal filter state when no controlled filterMode', () => {
|
|
319
|
+
render(
|
|
320
|
+
<RecordActivityTimeline items={mockItems} />,
|
|
321
|
+
);
|
|
322
|
+
// Initially all visible
|
|
323
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
324
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
325
|
+
// Switch to comments_only
|
|
326
|
+
fireEvent.change(screen.getByLabelText('Filter activity'), { target: { value: 'comments_only' } });
|
|
327
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
328
|
+
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should not render comment input when onAddComment is not provided', () => {
|
|
332
|
+
render(<RecordActivityTimeline items={[]} />);
|
|
333
|
+
expect(screen.queryByPlaceholderText(/Leave a comment/)).not.toBeInTheDocument();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should render reactions when enableReactions is true', () => {
|
|
337
|
+
const items: FeedItem[] = [
|
|
338
|
+
{
|
|
339
|
+
id: '1',
|
|
340
|
+
type: 'comment',
|
|
341
|
+
actor: 'Alice',
|
|
342
|
+
body: 'Nice!',
|
|
343
|
+
createdAt: '2026-02-20T10:00:00Z',
|
|
344
|
+
reactions: [{ emoji: '👍', count: 2, reacted: true }],
|
|
345
|
+
},
|
|
346
|
+
];
|
|
347
|
+
render(
|
|
348
|
+
<RecordActivityTimeline items={items} config={{ enableReactions: true }} />,
|
|
349
|
+
);
|
|
350
|
+
expect(screen.getByText('👍')).toBeInTheDocument();
|
|
351
|
+
expect(screen.getByText('2')).toBeInTheDocument();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should not render reactions when enableReactions is false', () => {
|
|
355
|
+
const items: FeedItem[] = [
|
|
356
|
+
{
|
|
357
|
+
id: '1',
|
|
358
|
+
type: 'comment',
|
|
359
|
+
actor: 'Alice',
|
|
360
|
+
body: 'Nice!',
|
|
361
|
+
createdAt: '2026-02-20T10:00:00Z',
|
|
362
|
+
reactions: [{ emoji: '👍', count: 2, reacted: true }],
|
|
363
|
+
},
|
|
364
|
+
];
|
|
365
|
+
render(
|
|
366
|
+
<RecordActivityTimeline items={items} config={{ enableReactions: false }} />,
|
|
367
|
+
);
|
|
368
|
+
expect(screen.queryByText('👍')).not.toBeInTheDocument();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should group items by parentId when enableThreading is true', () => {
|
|
372
|
+
const items: FeedItem[] = [
|
|
373
|
+
{ id: 'p1', type: 'comment', actor: 'Alice', body: 'Root comment', createdAt: '2026-02-20T10:00:00Z', replyCount: 1 },
|
|
374
|
+
{ id: 'r1', type: 'comment', actor: 'Bob', body: 'Reply', createdAt: '2026-02-20T11:00:00Z', parentId: 'p1' },
|
|
375
|
+
];
|
|
376
|
+
render(
|
|
377
|
+
<RecordActivityTimeline items={items} config={{ enableThreading: true }} />,
|
|
378
|
+
);
|
|
379
|
+
// Root comment rendered
|
|
380
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
381
|
+
expect(screen.getByText('Root comment')).toBeInTheDocument();
|
|
382
|
+
// Reply is shown as threaded reply (collapsed)
|
|
383
|
+
expect(screen.getByText('1 reply')).toBeInTheDocument();
|
|
384
|
+
// Reply actor should not be directly visible (collapsed in ThreadedReplies)
|
|
385
|
+
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('should show "via {source}" label', () => {
|
|
389
|
+
const items: FeedItem[] = [
|
|
390
|
+
{ id: '1', type: 'comment', actor: 'Alice', body: 'Hi', createdAt: '2026-02-20T10:00:00Z', source: 'slack' },
|
|
391
|
+
];
|
|
392
|
+
render(<RecordActivityTimeline items={items} />);
|
|
393
|
+
expect(screen.getByText('via slack')).toBeInTheDocument();
|
|
394
|
+
});
|
|
395
|
+
});
|