@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,101 @@
|
|
|
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 { SectionGroup } from '../SectionGroup';
|
|
12
|
+
import type { SectionGroup as SectionGroupType } from '@object-ui/types';
|
|
13
|
+
|
|
14
|
+
describe('SectionGroup', () => {
|
|
15
|
+
const baseGroup: SectionGroupType = {
|
|
16
|
+
title: 'Address Information',
|
|
17
|
+
sections: [
|
|
18
|
+
{
|
|
19
|
+
title: 'Billing',
|
|
20
|
+
fields: [
|
|
21
|
+
{ name: 'billingStreet', label: 'Street' },
|
|
22
|
+
{ name: 'billingCity', label: 'City' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
title: 'Shipping',
|
|
27
|
+
fields: [
|
|
28
|
+
{ name: 'shippingStreet', label: 'Street' },
|
|
29
|
+
{ name: 'shippingCity', label: 'City' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const data = {
|
|
36
|
+
billingStreet: '123 Main St',
|
|
37
|
+
billingCity: 'Springfield',
|
|
38
|
+
shippingStreet: '456 Oak Ave',
|
|
39
|
+
shippingCity: 'Shelbyville',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
it('should render group title', () => {
|
|
43
|
+
render(<SectionGroup group={baseGroup} data={data} />);
|
|
44
|
+
expect(screen.getByText('Address Information')).toBeInTheDocument();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should render child section titles', () => {
|
|
48
|
+
render(<SectionGroup group={baseGroup} data={data} />);
|
|
49
|
+
expect(screen.getByText('Billing')).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByText('Shipping')).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should render field values in child sections', () => {
|
|
54
|
+
render(<SectionGroup group={baseGroup} data={data} />);
|
|
55
|
+
expect(screen.getByText('123 Main St')).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByText('Springfield')).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should be collapsible by default', () => {
|
|
60
|
+
render(<SectionGroup group={baseGroup} data={data} />);
|
|
61
|
+
// The group should render a collapsible trigger
|
|
62
|
+
const trigger = screen.getByText('Address Information');
|
|
63
|
+
expect(trigger.closest('[data-state]') || trigger.closest('div')).toBeTruthy();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should start collapsed when defaultCollapsed is true', () => {
|
|
67
|
+
const collapsedGroup = { ...baseGroup, defaultCollapsed: true };
|
|
68
|
+
render(<SectionGroup group={collapsedGroup} data={data} />);
|
|
69
|
+
// Title should still be visible
|
|
70
|
+
expect(screen.getByText('Address Information')).toBeInTheDocument();
|
|
71
|
+
// Child section content should be hidden (in collapsed state)
|
|
72
|
+
expect(screen.queryByText('123 Main St')).not.toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should expand when clicked while collapsed', () => {
|
|
76
|
+
const collapsedGroup = { ...baseGroup, defaultCollapsed: true };
|
|
77
|
+
render(<SectionGroup group={collapsedGroup} data={data} />);
|
|
78
|
+
|
|
79
|
+
// Click the trigger to expand
|
|
80
|
+
fireEvent.click(screen.getByText('Address Information'));
|
|
81
|
+
|
|
82
|
+
// Content should now be visible
|
|
83
|
+
expect(screen.getByText('123 Main St')).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should render description when provided', () => {
|
|
87
|
+
const groupWithDesc = { ...baseGroup, description: 'Billing and shipping addresses', collapsible: false };
|
|
88
|
+
render(<SectionGroup group={groupWithDesc} data={data} />);
|
|
89
|
+
expect(screen.getByText('Billing and shipping addresses')).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should not be collapsible when collapsible is false', () => {
|
|
93
|
+
const nonCollapsible = { ...baseGroup, collapsible: false };
|
|
94
|
+
const { container } = render(<SectionGroup group={nonCollapsible} data={data} />);
|
|
95
|
+
// The top-level group heading should not have a cursor-pointer collapsible trigger
|
|
96
|
+
const heading = screen.getByText('Address Information');
|
|
97
|
+
const parentDiv = heading.closest('div');
|
|
98
|
+
// Non-collapsible group renders a static border-b div, not a CollapsibleTrigger
|
|
99
|
+
expect(parentDiv?.className).not.toContain('cursor-pointer');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
11
|
+
import '@testing-library/jest-dom';
|
|
12
|
+
import { SubscriptionToggle } from '../SubscriptionToggle';
|
|
13
|
+
import type { RecordSubscription } from '@object-ui/types';
|
|
14
|
+
|
|
15
|
+
describe('SubscriptionToggle', () => {
|
|
16
|
+
it('should render subscribed state', () => {
|
|
17
|
+
const sub: RecordSubscription = { recordId: '1', subscribed: true };
|
|
18
|
+
render(<SubscriptionToggle subscription={sub} />);
|
|
19
|
+
const btn = screen.getByRole('button');
|
|
20
|
+
expect(btn).toHaveAttribute('aria-label', 'Unsubscribe from notifications');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should render unsubscribed state', () => {
|
|
24
|
+
const sub: RecordSubscription = { recordId: '1', subscribed: false };
|
|
25
|
+
render(<SubscriptionToggle subscription={sub} />);
|
|
26
|
+
const btn = screen.getByRole('button');
|
|
27
|
+
expect(btn).toHaveAttribute('aria-label', 'Subscribe to notifications');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should call onToggle with new state when clicked', () => {
|
|
31
|
+
const onToggle = vi.fn();
|
|
32
|
+
const sub: RecordSubscription = { recordId: '1', subscribed: false };
|
|
33
|
+
render(<SubscriptionToggle subscription={sub} onToggle={onToggle} />);
|
|
34
|
+
fireEvent.click(screen.getByRole('button'));
|
|
35
|
+
expect(onToggle).toHaveBeenCalledWith(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should call onToggle with false when unsubscribing', () => {
|
|
39
|
+
const onToggle = vi.fn();
|
|
40
|
+
const sub: RecordSubscription = { recordId: '1', subscribed: true };
|
|
41
|
+
render(<SubscriptionToggle subscription={sub} onToggle={onToggle} />);
|
|
42
|
+
fireEvent.click(screen.getByRole('button'));
|
|
43
|
+
expect(onToggle).toHaveBeenCalledWith(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should be disabled when no onToggle provided', () => {
|
|
47
|
+
const sub: RecordSubscription = { recordId: '1', subscribed: true };
|
|
48
|
+
render(<SubscriptionToggle subscription={sub} />);
|
|
49
|
+
expect(screen.getByRole('button')).toBeDisabled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should show title for subscribed state', () => {
|
|
53
|
+
const sub: RecordSubscription = { recordId: '1', subscribed: true };
|
|
54
|
+
render(<SubscriptionToggle subscription={sub} onToggle={() => {}} />);
|
|
55
|
+
expect(screen.getByRole('button')).toHaveAttribute('title', 'Subscribed — click to unsubscribe');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should show title for unsubscribed state', () => {
|
|
59
|
+
const sub: RecordSubscription = { recordId: '1', subscribed: false };
|
|
60
|
+
render(<SubscriptionToggle subscription={sub} onToggle={() => {}} />);
|
|
61
|
+
expect(screen.getByRole('button')).toHaveAttribute('title', 'Subscribe to notifications');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should be disabled during loading after click', async () => {
|
|
65
|
+
let resolveToggle: () => void;
|
|
66
|
+
const onToggle = vi.fn(() => new Promise<void>((r) => { resolveToggle = r; }));
|
|
67
|
+
const sub: RecordSubscription = { recordId: '1', subscribed: false };
|
|
68
|
+
render(<SubscriptionToggle subscription={sub} onToggle={onToggle} />);
|
|
69
|
+
fireEvent.click(screen.getByRole('button'));
|
|
70
|
+
expect(screen.getByRole('button')).toBeDisabled();
|
|
71
|
+
resolveToggle!();
|
|
72
|
+
await vi.waitFor(() => {
|
|
73
|
+
expect(screen.getByRole('button')).not.toBeDisabled();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should update aria-label based on subscribed state', () => {
|
|
78
|
+
const sub: RecordSubscription = { recordId: '1', subscribed: true };
|
|
79
|
+
const { rerender } = render(<SubscriptionToggle subscription={sub} onToggle={() => {}} />);
|
|
80
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Unsubscribe from notifications');
|
|
81
|
+
rerender(<SubscriptionToggle subscription={{ ...sub, subscribed: false }} onToggle={() => {}} />);
|
|
82
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Subscribe to notifications');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
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 { ThreadedReplies } from '../ThreadedReplies';
|
|
13
|
+
import type { FeedItem } from '@object-ui/types';
|
|
14
|
+
|
|
15
|
+
const parentItem: FeedItem = {
|
|
16
|
+
id: 'p1',
|
|
17
|
+
type: 'comment',
|
|
18
|
+
actor: 'Alice',
|
|
19
|
+
body: 'Parent comment',
|
|
20
|
+
createdAt: '2026-02-20T10:00:00Z',
|
|
21
|
+
replyCount: 2,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const mockReplies: FeedItem[] = [
|
|
25
|
+
{
|
|
26
|
+
id: 'r1',
|
|
27
|
+
type: 'comment',
|
|
28
|
+
actor: 'Bob',
|
|
29
|
+
body: 'First reply',
|
|
30
|
+
createdAt: '2026-02-20T11:00:00Z',
|
|
31
|
+
parentId: 'p1',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'r2',
|
|
35
|
+
type: 'comment',
|
|
36
|
+
actor: 'Charlie',
|
|
37
|
+
body: 'Second reply',
|
|
38
|
+
createdAt: '2026-02-20T12:00:00Z',
|
|
39
|
+
parentId: 'p1',
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
describe('ThreadedReplies', () => {
|
|
44
|
+
it('should render reply count toggle', () => {
|
|
45
|
+
render(
|
|
46
|
+
<ThreadedReplies parentItem={parentItem} replies={mockReplies} />,
|
|
47
|
+
);
|
|
48
|
+
expect(screen.getByText('2 replies')).toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should use singular "reply" for one reply', () => {
|
|
52
|
+
render(
|
|
53
|
+
<ThreadedReplies parentItem={parentItem} replies={[mockReplies[0]]} />,
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByText('1 reply')).toBeInTheDocument();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should not render replies by default (collapsed)', () => {
|
|
59
|
+
render(
|
|
60
|
+
<ThreadedReplies parentItem={parentItem} replies={mockReplies} />,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.queryByText('First reply')).not.toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should expand replies when toggle is clicked', () => {
|
|
66
|
+
render(
|
|
67
|
+
<ThreadedReplies parentItem={parentItem} replies={mockReplies} />,
|
|
68
|
+
);
|
|
69
|
+
fireEvent.click(screen.getByText('2 replies'));
|
|
70
|
+
expect(screen.getByText('First reply')).toBeInTheDocument();
|
|
71
|
+
expect(screen.getByText('Second reply')).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should show reply authors', () => {
|
|
75
|
+
render(
|
|
76
|
+
<ThreadedReplies parentItem={parentItem} replies={mockReplies} />,
|
|
77
|
+
);
|
|
78
|
+
fireEvent.click(screen.getByText('2 replies'));
|
|
79
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
80
|
+
expect(screen.getByText('Charlie')).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should show reply input when onAddReply and showReplyInput', () => {
|
|
84
|
+
const onAdd = vi.fn();
|
|
85
|
+
render(
|
|
86
|
+
<ThreadedReplies parentItem={parentItem} replies={[]} onAddReply={onAdd} showReplyInput />,
|
|
87
|
+
);
|
|
88
|
+
expect(screen.getByPlaceholderText('Reply…')).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should call onAddReply with parentId and text', () => {
|
|
92
|
+
const onAdd = vi.fn().mockResolvedValue(undefined);
|
|
93
|
+
render(
|
|
94
|
+
<ThreadedReplies parentItem={parentItem} replies={[]} onAddReply={onAdd} showReplyInput />,
|
|
95
|
+
);
|
|
96
|
+
const input = screen.getByPlaceholderText('Reply…');
|
|
97
|
+
fireEvent.change(input, { target: { value: 'My reply' } });
|
|
98
|
+
fireEvent.click(screen.getByLabelText('Send reply'));
|
|
99
|
+
expect(onAdd).toHaveBeenCalledWith('p1', 'My reply');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should return null when no replies and no reply input', () => {
|
|
103
|
+
const { container } = render(
|
|
104
|
+
<ThreadedReplies parentItem={parentItem} replies={[]} showReplyInput={false} />,
|
|
105
|
+
);
|
|
106
|
+
expect(container.firstChild).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should have aria-expanded=false when collapsed', () => {
|
|
110
|
+
render(
|
|
111
|
+
<ThreadedReplies parentItem={parentItem} replies={mockReplies} />,
|
|
112
|
+
);
|
|
113
|
+
const toggle = screen.getByText('2 replies').closest('button');
|
|
114
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should have aria-expanded=true when expanded', () => {
|
|
118
|
+
render(
|
|
119
|
+
<ThreadedReplies parentItem={parentItem} replies={mockReplies} />,
|
|
120
|
+
);
|
|
121
|
+
fireEvent.click(screen.getByText('2 replies'));
|
|
122
|
+
const toggle = screen.getByText('2 replies').closest('button');
|
|
123
|
+
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should render avatarUrl when provided on reply', () => {
|
|
127
|
+
const repliesWithAvatar: FeedItem[] = [
|
|
128
|
+
{
|
|
129
|
+
id: 'r1',
|
|
130
|
+
type: 'comment',
|
|
131
|
+
actor: 'Bob',
|
|
132
|
+
actorAvatarUrl: 'https://example.com/bob.png',
|
|
133
|
+
body: 'With avatar',
|
|
134
|
+
createdAt: '2026-02-20T11:00:00Z',
|
|
135
|
+
parentId: 'p1',
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
render(
|
|
139
|
+
<ThreadedReplies parentItem={parentItem} replies={repliesWithAvatar} />,
|
|
140
|
+
);
|
|
141
|
+
fireEvent.click(screen.getByText('1 reply'));
|
|
142
|
+
const img = screen.getByAltText('Bob');
|
|
143
|
+
expect(img).toHaveAttribute('src', 'https://example.com/bob.png');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should render first-letter avatar fallback', () => {
|
|
147
|
+
render(
|
|
148
|
+
<ThreadedReplies parentItem={parentItem} replies={mockReplies} />,
|
|
149
|
+
);
|
|
150
|
+
fireEvent.click(screen.getByText('2 replies'));
|
|
151
|
+
// Bob should show 'B' as fallback initial
|
|
152
|
+
expect(screen.getByText('B')).toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should submit reply via Ctrl+Enter', () => {
|
|
156
|
+
const onAdd = vi.fn().mockResolvedValue(undefined);
|
|
157
|
+
render(
|
|
158
|
+
<ThreadedReplies parentItem={parentItem} replies={[]} onAddReply={onAdd} showReplyInput />,
|
|
159
|
+
);
|
|
160
|
+
const input = screen.getByPlaceholderText('Reply…');
|
|
161
|
+
fireEvent.change(input, { target: { value: 'Ctrl reply' } });
|
|
162
|
+
fireEvent.keyDown(input, { key: 'Enter', ctrlKey: true });
|
|
163
|
+
expect(onAdd).toHaveBeenCalledWith('p1', 'Ctrl reply');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should disable Send button when input is empty', () => {
|
|
167
|
+
const onAdd = vi.fn();
|
|
168
|
+
render(
|
|
169
|
+
<ThreadedReplies parentItem={parentItem} replies={[]} onAddReply={onAdd} showReplyInput />,
|
|
170
|
+
);
|
|
171
|
+
expect(screen.getByLabelText('Send reply')).toBeDisabled();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should disable input and button during reply submission', async () => {
|
|
175
|
+
let resolveReply: () => void;
|
|
176
|
+
const onAdd = vi.fn(() => new Promise<void>((r) => { resolveReply = r; }));
|
|
177
|
+
render(
|
|
178
|
+
<ThreadedReplies parentItem={parentItem} replies={[]} onAddReply={onAdd} showReplyInput />,
|
|
179
|
+
);
|
|
180
|
+
const input = screen.getByPlaceholderText('Reply…') as HTMLInputElement;
|
|
181
|
+
fireEvent.change(input, { target: { value: 'submitting reply' } });
|
|
182
|
+
fireEvent.click(screen.getByLabelText('Send reply'));
|
|
183
|
+
expect(input).toBeDisabled();
|
|
184
|
+
expect(screen.getByLabelText('Send reply')).toBeDisabled();
|
|
185
|
+
resolveReply!();
|
|
186
|
+
await vi.waitFor(() => {
|
|
187
|
+
expect(input).not.toBeDisabled();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should clear input after successful reply submission', async () => {
|
|
192
|
+
const onAdd = vi.fn().mockResolvedValue(undefined);
|
|
193
|
+
render(
|
|
194
|
+
<ThreadedReplies parentItem={parentItem} replies={[]} onAddReply={onAdd} showReplyInput />,
|
|
195
|
+
);
|
|
196
|
+
const input = screen.getByPlaceholderText('Reply…') as HTMLInputElement;
|
|
197
|
+
fireEvent.change(input, { target: { value: 'reply text' } });
|
|
198
|
+
fireEvent.click(screen.getByLabelText('Send reply'));
|
|
199
|
+
await vi.waitFor(() => {
|
|
200
|
+
expect(input.value).toBe('');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should render reply body and timestamp when expanded', () => {
|
|
205
|
+
render(
|
|
206
|
+
<ThreadedReplies parentItem={parentItem} replies={mockReplies} />,
|
|
207
|
+
);
|
|
208
|
+
fireEvent.click(screen.getByText('2 replies'));
|
|
209
|
+
expect(screen.getByText('First reply')).toBeInTheDocument();
|
|
210
|
+
expect(screen.getByText('Second reply')).toBeInTheDocument();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
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 } from 'vitest';
|
|
10
|
+
import {
|
|
11
|
+
inferDetailColumns,
|
|
12
|
+
isWideFieldType,
|
|
13
|
+
applyAutoSpan,
|
|
14
|
+
applyDetailAutoLayout,
|
|
15
|
+
} from '../autoLayout';
|
|
16
|
+
|
|
17
|
+
describe('Detail Auto-Layout', () => {
|
|
18
|
+
describe('inferDetailColumns', () => {
|
|
19
|
+
it('should return 1 column for 0 fields', () => {
|
|
20
|
+
expect(inferDetailColumns(0)).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should return 1 column for 1-3 fields', () => {
|
|
24
|
+
expect(inferDetailColumns(1)).toBe(1);
|
|
25
|
+
expect(inferDetailColumns(2)).toBe(1);
|
|
26
|
+
expect(inferDetailColumns(3)).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return 2 columns for 4-10 fields', () => {
|
|
30
|
+
expect(inferDetailColumns(4)).toBe(2);
|
|
31
|
+
expect(inferDetailColumns(7)).toBe(2);
|
|
32
|
+
expect(inferDetailColumns(10)).toBe(2);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should return 3 columns for 11+ fields', () => {
|
|
36
|
+
expect(inferDetailColumns(11)).toBe(3);
|
|
37
|
+
expect(inferDetailColumns(15)).toBe(3);
|
|
38
|
+
expect(inferDetailColumns(50)).toBe(3);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('isWideFieldType', () => {
|
|
43
|
+
it('should identify textarea as wide', () => {
|
|
44
|
+
expect(isWideFieldType('textarea')).toBe(true);
|
|
45
|
+
expect(isWideFieldType('field:textarea')).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should identify markdown as wide', () => {
|
|
49
|
+
expect(isWideFieldType('markdown')).toBe(true);
|
|
50
|
+
expect(isWideFieldType('field:markdown')).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should identify html as wide', () => {
|
|
54
|
+
expect(isWideFieldType('html')).toBe(true);
|
|
55
|
+
expect(isWideFieldType('field:html')).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should identify grid as wide', () => {
|
|
59
|
+
expect(isWideFieldType('grid')).toBe(true);
|
|
60
|
+
expect(isWideFieldType('field:grid')).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should identify rich-text as wide', () => {
|
|
64
|
+
expect(isWideFieldType('rich-text')).toBe(true);
|
|
65
|
+
expect(isWideFieldType('field:rich-text')).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should NOT identify regular types as wide', () => {
|
|
69
|
+
expect(isWideFieldType('text')).toBe(false);
|
|
70
|
+
expect(isWideFieldType('number')).toBe(false);
|
|
71
|
+
expect(isWideFieldType('date')).toBe(false);
|
|
72
|
+
expect(isWideFieldType('select')).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('applyAutoSpan', () => {
|
|
77
|
+
it('should return fields unchanged when columns <= 1', () => {
|
|
78
|
+
const fields = [
|
|
79
|
+
{ name: 'desc', label: 'Description', type: 'textarea' },
|
|
80
|
+
];
|
|
81
|
+
const result = applyAutoSpan(fields, 1);
|
|
82
|
+
expect(result).toEqual(fields);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should set span on wide fields in multi-column layout', () => {
|
|
86
|
+
const fields = [
|
|
87
|
+
{ name: 'name', label: 'Name', type: 'text' },
|
|
88
|
+
{ name: 'desc', label: 'Description', type: 'textarea' },
|
|
89
|
+
];
|
|
90
|
+
const result = applyAutoSpan(fields, 2);
|
|
91
|
+
expect(result[0].span).toBeUndefined();
|
|
92
|
+
expect(result[1].span).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should NOT override user-defined span', () => {
|
|
96
|
+
const fields = [
|
|
97
|
+
{ name: 'desc', label: 'Description', type: 'textarea', span: 1 },
|
|
98
|
+
];
|
|
99
|
+
const result = applyAutoSpan(fields, 2);
|
|
100
|
+
expect(result[0].span).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should handle fields without type', () => {
|
|
104
|
+
const fields = [
|
|
105
|
+
{ name: 'name', label: 'Name' },
|
|
106
|
+
];
|
|
107
|
+
const result = applyAutoSpan(fields, 2);
|
|
108
|
+
expect(result[0].span).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('applyDetailAutoLayout', () => {
|
|
113
|
+
it('should infer 1 column for 3 fields', () => {
|
|
114
|
+
const fields = [
|
|
115
|
+
{ name: 'a', label: 'A' },
|
|
116
|
+
{ name: 'b', label: 'B' },
|
|
117
|
+
{ name: 'c', label: 'C' },
|
|
118
|
+
];
|
|
119
|
+
const result = applyDetailAutoLayout(fields, undefined);
|
|
120
|
+
expect(result.columns).toBe(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should infer 2 columns for 5 fields', () => {
|
|
124
|
+
const fields = Array.from({ length: 5 }, (_, i) => ({
|
|
125
|
+
name: `f${i}`, label: `F${i}`,
|
|
126
|
+
}));
|
|
127
|
+
const result = applyDetailAutoLayout(fields, undefined);
|
|
128
|
+
expect(result.columns).toBe(2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should infer 3 columns for 15 fields', () => {
|
|
132
|
+
const fields = Array.from({ length: 15 }, (_, i) => ({
|
|
133
|
+
name: `f${i}`, label: `F${i}`,
|
|
134
|
+
}));
|
|
135
|
+
const result = applyDetailAutoLayout(fields, undefined);
|
|
136
|
+
expect(result.columns).toBe(3);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should respect explicit columns and still apply auto-span', () => {
|
|
140
|
+
const fields = [
|
|
141
|
+
{ name: 'name', label: 'Name', type: 'text' },
|
|
142
|
+
{ name: 'desc', label: 'Description', type: 'textarea' },
|
|
143
|
+
];
|
|
144
|
+
const result = applyDetailAutoLayout(fields, 2);
|
|
145
|
+
expect(result.columns).toBe(2);
|
|
146
|
+
// Wide field gets auto-span
|
|
147
|
+
expect(result.fields[1].span).toBe(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should not mutate original fields', () => {
|
|
151
|
+
const fields = [
|
|
152
|
+
{ name: 'name', label: 'Name', type: 'text' },
|
|
153
|
+
{ name: 'desc', label: 'Description', type: 'textarea' },
|
|
154
|
+
];
|
|
155
|
+
const original = fields.map(f => ({ ...f }));
|
|
156
|
+
applyDetailAutoLayout(fields, undefined);
|
|
157
|
+
expect(fields).toEqual(original);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should auto-span wide fields in inferred multi-column layout', () => {
|
|
161
|
+
const fields = [
|
|
162
|
+
{ name: 'name', label: 'Name', type: 'text' },
|
|
163
|
+
{ name: 'email', label: 'Email', type: 'text' },
|
|
164
|
+
{ name: 'phone', label: 'Phone', type: 'text' },
|
|
165
|
+
{ name: 'addr', label: 'Address', type: 'text' },
|
|
166
|
+
{ name: 'notes', label: 'Notes', type: 'textarea' },
|
|
167
|
+
];
|
|
168
|
+
const result = applyDetailAutoLayout(fields, undefined);
|
|
169
|
+
expect(result.columns).toBe(2);
|
|
170
|
+
// textarea field should span full row
|
|
171
|
+
expect(result.fields[4].span).toBe(2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should infer columns correctly when schemaColumns is undefined (no regression)', () => {
|
|
175
|
+
// Ensure that removing explicit columns: 2 from RecordDetailView still gives good defaults
|
|
176
|
+
const fields = Array.from({ length: 8 }, (_, i) => ({
|
|
177
|
+
name: `f${i}`, label: `F${i}`, type: 'text',
|
|
178
|
+
}));
|
|
179
|
+
const result = applyDetailAutoLayout(fields, undefined);
|
|
180
|
+
expect(result.columns).toBe(2);
|
|
181
|
+
expect(result.fields.length).toBe(8);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|