@object-ui/components 3.0.3 → 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 +12 -12
- package/dist/index.css +1 -1
- package/dist/index.js +24701 -22929
- package/dist/index.umd.cjs +37 -37
- package/dist/src/custom/config-field-renderer.d.ts +21 -0
- package/dist/src/custom/config-panel-renderer.d.ts +81 -0
- package/dist/src/custom/config-row.d.ts +27 -0
- package/dist/src/custom/index.d.ts +5 -0
- package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
- package/dist/src/custom/navigation-overlay.d.ts +8 -0
- package/dist/src/custom/section-header.d.ts +31 -0
- package/dist/src/debug/DebugPanel.d.ts +39 -0
- package/dist/src/debug/index.d.ts +9 -0
- package/dist/src/hooks/use-config-draft.d.ts +46 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/renderers/action/action-bar.d.ts +23 -0
- package/dist/src/types/config-panel.d.ts +92 -0
- package/dist/src/ui/sheet.d.ts +2 -0
- package/dist/src/ui/sidebar.d.ts +4 -0
- package/package.json +17 -17
- package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
- package/src/__tests__/action-bar.test.tsx +172 -0
- package/src/__tests__/config-field-renderer.test.tsx +307 -0
- package/src/__tests__/config-panel-renderer.test.tsx +580 -0
- package/src/__tests__/config-primitives.test.tsx +106 -0
- package/src/__tests__/mobile-accessibility.test.tsx +120 -0
- package/src/__tests__/navigation-overlay.test.tsx +97 -0
- package/src/__tests__/use-config-draft.test.tsx +295 -0
- package/src/custom/config-field-renderer.tsx +276 -0
- package/src/custom/config-panel-renderer.tsx +306 -0
- package/src/custom/config-row.tsx +50 -0
- package/src/custom/index.ts +5 -0
- package/src/custom/mobile-dialog-content.tsx +67 -0
- package/src/custom/navigation-overlay.tsx +42 -4
- package/src/custom/section-header.tsx +68 -0
- package/src/debug/DebugPanel.tsx +313 -0
- package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
- package/src/debug/index.ts +10 -0
- package/src/hooks/use-config-draft.ts +127 -0
- package/src/index.css +4 -0
- package/src/index.ts +15 -0
- package/src/renderers/action/action-bar.tsx +202 -0
- package/src/renderers/action/index.ts +1 -0
- package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
- package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
- package/src/renderers/complex/data-table.tsx +346 -43
- package/src/renderers/data-display/breadcrumb.tsx +3 -2
- package/src/renderers/form/form.tsx +4 -4
- package/src/renderers/navigation/header-bar.tsx +69 -10
- package/src/stories/ConfigPanel.stories.tsx +232 -0
- package/src/types/config-panel.ts +101 -0
- package/src/ui/dialog.tsx +20 -3
- package/src/ui/sheet.tsx +6 -3
- package/src/ui/sidebar.tsx +93 -9
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Mobile Accessibility Audit — axe-core checks at mobile viewport widths.
|
|
11
|
+
*
|
|
12
|
+
* Verifies that common UI patterns (buttons, forms, navigation, dialogs,
|
|
13
|
+
* tables) remain WCAG 2.1 AA compliant when rendered in a narrow container
|
|
14
|
+
* that simulates a mobile viewport. Part of P3 Mobile Testing & QA.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import { render } from '@testing-library/react';
|
|
19
|
+
import { axe } from 'vitest-axe';
|
|
20
|
+
import React from 'react';
|
|
21
|
+
|
|
22
|
+
async function expectNoViolations(container: HTMLElement) {
|
|
23
|
+
const results = await axe(container);
|
|
24
|
+
const violations = (results as any).violations || [];
|
|
25
|
+
if (violations.length > 0) {
|
|
26
|
+
const messages = violations.map(
|
|
27
|
+
(v: any) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} instance(s))`,
|
|
28
|
+
);
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Expected no accessibility violations, but found ${violations.length}:\n${messages.join('\n')}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MOBILE_WIDTH = 375;
|
|
36
|
+
|
|
37
|
+
describe('Mobile Accessibility Audit', () => {
|
|
38
|
+
it('should have no axe violations for Button at mobile viewport', async () => {
|
|
39
|
+
const { container } = render(
|
|
40
|
+
<div style={{ width: `${MOBILE_WIDTH}px` }}>
|
|
41
|
+
<button type="button">Click me</button>
|
|
42
|
+
</div>,
|
|
43
|
+
);
|
|
44
|
+
await expectNoViolations(container);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should have no axe violations for form inputs at mobile viewport', async () => {
|
|
48
|
+
const { container } = render(
|
|
49
|
+
<div style={{ width: `${MOBILE_WIDTH}px` }}>
|
|
50
|
+
<form>
|
|
51
|
+
<label htmlFor="name">Name</label>
|
|
52
|
+
<input id="name" type="text" />
|
|
53
|
+
<label htmlFor="email">Email</label>
|
|
54
|
+
<input id="email" type="email" />
|
|
55
|
+
<button type="submit">Submit</button>
|
|
56
|
+
</form>
|
|
57
|
+
</div>,
|
|
58
|
+
);
|
|
59
|
+
await expectNoViolations(container);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should have no axe violations for navigation at mobile viewport', async () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<div style={{ width: `${MOBILE_WIDTH}px` }}>
|
|
65
|
+
<nav aria-label="Main navigation">
|
|
66
|
+
<ul>
|
|
67
|
+
<li>
|
|
68
|
+
<a href="/">Home</a>
|
|
69
|
+
</li>
|
|
70
|
+
<li>
|
|
71
|
+
<a href="/about">About</a>
|
|
72
|
+
</li>
|
|
73
|
+
</ul>
|
|
74
|
+
</nav>
|
|
75
|
+
</div>,
|
|
76
|
+
);
|
|
77
|
+
await expectNoViolations(container);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should have no axe violations for dialog at mobile viewport', async () => {
|
|
81
|
+
const { container } = render(
|
|
82
|
+
<div style={{ width: `${MOBILE_WIDTH}px` }}>
|
|
83
|
+
<div role="dialog" aria-label="Confirmation" aria-modal="true">
|
|
84
|
+
<h2>Confirm Action</h2>
|
|
85
|
+
<p>Are you sure?</p>
|
|
86
|
+
<button type="button">Yes</button>
|
|
87
|
+
<button type="button">No</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>,
|
|
90
|
+
);
|
|
91
|
+
await expectNoViolations(container);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should have no axe violations for data table at mobile viewport', async () => {
|
|
95
|
+
const { container } = render(
|
|
96
|
+
<div style={{ width: `${MOBILE_WIDTH}px`, overflow: 'auto' }}>
|
|
97
|
+
<table>
|
|
98
|
+
<caption>Sample data</caption>
|
|
99
|
+
<thead>
|
|
100
|
+
<tr>
|
|
101
|
+
<th>Name</th>
|
|
102
|
+
<th>Value</th>
|
|
103
|
+
</tr>
|
|
104
|
+
</thead>
|
|
105
|
+
<tbody>
|
|
106
|
+
<tr>
|
|
107
|
+
<td>Item 1</td>
|
|
108
|
+
<td>100</td>
|
|
109
|
+
</tr>
|
|
110
|
+
<tr>
|
|
111
|
+
<td>Item 2</td>
|
|
112
|
+
<td>200</td>
|
|
113
|
+
</tr>
|
|
114
|
+
</tbody>
|
|
115
|
+
</table>
|
|
116
|
+
</div>,
|
|
117
|
+
);
|
|
118
|
+
await expectNoViolations(container);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -219,6 +219,31 @@ describe('NavigationOverlay', () => {
|
|
|
219
219
|
);
|
|
220
220
|
expect(screen.getByText('Quick View')).toBeInTheDocument();
|
|
221
221
|
});
|
|
222
|
+
|
|
223
|
+
it('should render fallback dialog when no popoverTrigger is provided', () => {
|
|
224
|
+
render(
|
|
225
|
+
<NavigationOverlay
|
|
226
|
+
{...createProps({
|
|
227
|
+
mode: 'popover',
|
|
228
|
+
title: 'Preview',
|
|
229
|
+
})}
|
|
230
|
+
/>
|
|
231
|
+
);
|
|
232
|
+
expect(screen.getByText('Preview')).toBeInTheDocument();
|
|
233
|
+
expect(screen.getByText('Test Record')).toBeInTheDocument();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should not render fallback dialog when closed and no popoverTrigger', () => {
|
|
237
|
+
const { container } = render(
|
|
238
|
+
<NavigationOverlay
|
|
239
|
+
{...createProps({
|
|
240
|
+
mode: 'popover',
|
|
241
|
+
isOpen: false,
|
|
242
|
+
})}
|
|
243
|
+
/>
|
|
244
|
+
);
|
|
245
|
+
expect(container.innerHTML).toBe('');
|
|
246
|
+
});
|
|
222
247
|
});
|
|
223
248
|
|
|
224
249
|
// ============================================================
|
|
@@ -270,4 +295,76 @@ describe('NavigationOverlay', () => {
|
|
|
270
295
|
expect(screen.getByTestId('status')).toHaveTextContent('active');
|
|
271
296
|
});
|
|
272
297
|
});
|
|
298
|
+
|
|
299
|
+
// ============================================================
|
|
300
|
+
// renderView support
|
|
301
|
+
// ============================================================
|
|
302
|
+
|
|
303
|
+
describe('renderView support', () => {
|
|
304
|
+
it('should use renderView when both renderView and view are provided', () => {
|
|
305
|
+
render(
|
|
306
|
+
<NavigationOverlay
|
|
307
|
+
{...createProps({
|
|
308
|
+
mode: 'drawer',
|
|
309
|
+
view: 'contact-detail',
|
|
310
|
+
renderView: (record, viewName) => (
|
|
311
|
+
<div data-testid="custom-view">
|
|
312
|
+
<span data-testid="view-name">{viewName}</span>
|
|
313
|
+
<span data-testid="record-name">{String(record.name)}</span>
|
|
314
|
+
</div>
|
|
315
|
+
),
|
|
316
|
+
})}
|
|
317
|
+
/>
|
|
318
|
+
);
|
|
319
|
+
expect(screen.getByTestId('custom-view')).toBeInTheDocument();
|
|
320
|
+
expect(screen.getByTestId('view-name')).toHaveTextContent('contact-detail');
|
|
321
|
+
expect(screen.getByTestId('record-name')).toHaveTextContent('Test Record');
|
|
322
|
+
// Children should NOT be rendered
|
|
323
|
+
expect(screen.queryByTestId('record-content')).not.toBeInTheDocument();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should fallback to children when renderView is provided but view is not', () => {
|
|
327
|
+
render(
|
|
328
|
+
<NavigationOverlay
|
|
329
|
+
{...createProps({
|
|
330
|
+
mode: 'drawer',
|
|
331
|
+
renderView: (_record, _viewName) => (
|
|
332
|
+
<div data-testid="custom-view">Should not appear</div>
|
|
333
|
+
),
|
|
334
|
+
})}
|
|
335
|
+
/>
|
|
336
|
+
);
|
|
337
|
+
// Children should be rendered since view is undefined
|
|
338
|
+
expect(screen.getByTestId('record-content')).toBeInTheDocument();
|
|
339
|
+
expect(screen.queryByTestId('custom-view')).not.toBeInTheDocument();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should fallback to children when view is provided but renderView is not', () => {
|
|
343
|
+
render(
|
|
344
|
+
<NavigationOverlay
|
|
345
|
+
{...createProps({
|
|
346
|
+
mode: 'drawer',
|
|
347
|
+
view: 'contact-detail',
|
|
348
|
+
})}
|
|
349
|
+
/>
|
|
350
|
+
);
|
|
351
|
+
// Children should be rendered since renderView is undefined
|
|
352
|
+
expect(screen.getByTestId('record-content')).toBeInTheDocument();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should use renderView in modal mode', () => {
|
|
356
|
+
render(
|
|
357
|
+
<NavigationOverlay
|
|
358
|
+
{...createProps({
|
|
359
|
+
mode: 'modal',
|
|
360
|
+
view: 'edit-form',
|
|
361
|
+
renderView: (record, viewName) => (
|
|
362
|
+
<div data-testid="modal-custom-view">{viewName}: {String(record.name)}</div>
|
|
363
|
+
),
|
|
364
|
+
})}
|
|
365
|
+
/>
|
|
366
|
+
);
|
|
367
|
+
expect(screen.getByTestId('modal-custom-view')).toHaveTextContent('edit-form: Test Record');
|
|
368
|
+
});
|
|
369
|
+
});
|
|
273
370
|
});
|
|
@@ -0,0 +1,295 @@
|
|
|
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 { renderHook, act } from '@testing-library/react';
|
|
11
|
+
import { useConfigDraft } from '../hooks/use-config-draft';
|
|
12
|
+
|
|
13
|
+
describe('useConfigDraft', () => {
|
|
14
|
+
const source = { name: 'Test', columns: 3, gap: 4, enabled: true };
|
|
15
|
+
|
|
16
|
+
it('should initialize draft from source', () => {
|
|
17
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
18
|
+
expect(result.current.draft).toEqual(source);
|
|
19
|
+
expect(result.current.isDirty).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should start dirty in create mode', () => {
|
|
23
|
+
const { result } = renderHook(() =>
|
|
24
|
+
useConfigDraft(source, { mode: 'create' }),
|
|
25
|
+
);
|
|
26
|
+
expect(result.current.isDirty).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should start clean in edit mode', () => {
|
|
30
|
+
const { result } = renderHook(() =>
|
|
31
|
+
useConfigDraft(source, { mode: 'edit' }),
|
|
32
|
+
);
|
|
33
|
+
expect(result.current.isDirty).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should update a single field and mark dirty', () => {
|
|
37
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
38
|
+
act(() => {
|
|
39
|
+
result.current.updateField('columns', 6);
|
|
40
|
+
});
|
|
41
|
+
expect(result.current.draft.columns).toBe(6);
|
|
42
|
+
expect(result.current.isDirty).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should preserve other fields when updating one', () => {
|
|
46
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
47
|
+
act(() => {
|
|
48
|
+
result.current.updateField('gap', 8);
|
|
49
|
+
});
|
|
50
|
+
expect(result.current.draft.name).toBe('Test');
|
|
51
|
+
expect(result.current.draft.columns).toBe(3);
|
|
52
|
+
expect(result.current.draft.gap).toBe(8);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should discard changes and revert to source', () => {
|
|
56
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
57
|
+
act(() => {
|
|
58
|
+
result.current.updateField('name', 'Modified');
|
|
59
|
+
result.current.updateField('columns', 12);
|
|
60
|
+
});
|
|
61
|
+
expect(result.current.isDirty).toBe(true);
|
|
62
|
+
|
|
63
|
+
act(() => {
|
|
64
|
+
result.current.discard();
|
|
65
|
+
});
|
|
66
|
+
expect(result.current.draft).toEqual(source);
|
|
67
|
+
expect(result.current.isDirty).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should call onUpdate callback when field changes', () => {
|
|
71
|
+
const onUpdate = vi.fn();
|
|
72
|
+
const { result } = renderHook(() =>
|
|
73
|
+
useConfigDraft(source, { onUpdate }),
|
|
74
|
+
);
|
|
75
|
+
act(() => {
|
|
76
|
+
result.current.updateField('gap', 10);
|
|
77
|
+
});
|
|
78
|
+
expect(onUpdate).toHaveBeenCalledWith('gap', 10);
|
|
79
|
+
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should reset draft when source changes', () => {
|
|
83
|
+
const source1 = { name: 'A', value: 1 };
|
|
84
|
+
const source2 = { name: 'B', value: 2 };
|
|
85
|
+
|
|
86
|
+
const { result, rerender } = renderHook(
|
|
87
|
+
({ src }) => useConfigDraft(src),
|
|
88
|
+
{ initialProps: { src: source1 } },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
act(() => {
|
|
92
|
+
result.current.updateField('name', 'Modified');
|
|
93
|
+
});
|
|
94
|
+
expect(result.current.isDirty).toBe(true);
|
|
95
|
+
|
|
96
|
+
rerender({ src: source2 });
|
|
97
|
+
expect(result.current.draft).toEqual(source2);
|
|
98
|
+
expect(result.current.isDirty).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should handle setDraft for advanced use cases', () => {
|
|
102
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
103
|
+
act(() => {
|
|
104
|
+
result.current.setDraft({ ...source, name: 'Advanced', extra: 42 });
|
|
105
|
+
});
|
|
106
|
+
expect(result.current.draft.name).toBe('Advanced');
|
|
107
|
+
expect(result.current.draft.extra).toBe(42);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should not share reference with source', () => {
|
|
111
|
+
const mutableSource = { name: 'Original' };
|
|
112
|
+
const { result } = renderHook(() => useConfigDraft(mutableSource));
|
|
113
|
+
expect(result.current.draft).not.toBe(mutableSource);
|
|
114
|
+
expect(result.current.draft).toEqual(mutableSource);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── Undo / Redo ──────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
describe('undo/redo', () => {
|
|
120
|
+
it('should start with canUndo=false and canRedo=false', () => {
|
|
121
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
122
|
+
expect(result.current.canUndo).toBe(false);
|
|
123
|
+
expect(result.current.canRedo).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should enable undo after a field update', () => {
|
|
127
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
128
|
+
act(() => {
|
|
129
|
+
result.current.updateField('name', 'Changed');
|
|
130
|
+
});
|
|
131
|
+
expect(result.current.canUndo).toBe(true);
|
|
132
|
+
expect(result.current.canRedo).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should undo to previous state', () => {
|
|
136
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
137
|
+
act(() => {
|
|
138
|
+
result.current.updateField('name', 'Changed');
|
|
139
|
+
});
|
|
140
|
+
expect(result.current.draft.name).toBe('Changed');
|
|
141
|
+
|
|
142
|
+
act(() => {
|
|
143
|
+
result.current.undo();
|
|
144
|
+
});
|
|
145
|
+
expect(result.current.draft.name).toBe('Test');
|
|
146
|
+
expect(result.current.canUndo).toBe(false);
|
|
147
|
+
expect(result.current.canRedo).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should redo after undo', () => {
|
|
151
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
152
|
+
act(() => {
|
|
153
|
+
result.current.updateField('name', 'Changed');
|
|
154
|
+
});
|
|
155
|
+
act(() => {
|
|
156
|
+
result.current.undo();
|
|
157
|
+
});
|
|
158
|
+
act(() => {
|
|
159
|
+
result.current.redo();
|
|
160
|
+
});
|
|
161
|
+
expect(result.current.draft.name).toBe('Changed');
|
|
162
|
+
expect(result.current.canUndo).toBe(true);
|
|
163
|
+
expect(result.current.canRedo).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should clear redo stack when a new change is made after undo', () => {
|
|
167
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
168
|
+
act(() => {
|
|
169
|
+
result.current.updateField('name', 'First');
|
|
170
|
+
});
|
|
171
|
+
act(() => {
|
|
172
|
+
result.current.updateField('name', 'Second');
|
|
173
|
+
});
|
|
174
|
+
act(() => {
|
|
175
|
+
result.current.undo();
|
|
176
|
+
});
|
|
177
|
+
expect(result.current.canRedo).toBe(true);
|
|
178
|
+
|
|
179
|
+
act(() => {
|
|
180
|
+
result.current.updateField('name', 'Third');
|
|
181
|
+
});
|
|
182
|
+
expect(result.current.canRedo).toBe(false);
|
|
183
|
+
expect(result.current.draft.name).toBe('Third');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should support multiple undo steps', () => {
|
|
187
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
188
|
+
act(() => {
|
|
189
|
+
result.current.updateField('name', 'A');
|
|
190
|
+
});
|
|
191
|
+
act(() => {
|
|
192
|
+
result.current.updateField('name', 'B');
|
|
193
|
+
});
|
|
194
|
+
act(() => {
|
|
195
|
+
result.current.updateField('name', 'C');
|
|
196
|
+
});
|
|
197
|
+
expect(result.current.draft.name).toBe('C');
|
|
198
|
+
|
|
199
|
+
act(() => {
|
|
200
|
+
result.current.undo();
|
|
201
|
+
});
|
|
202
|
+
expect(result.current.draft.name).toBe('B');
|
|
203
|
+
|
|
204
|
+
act(() => {
|
|
205
|
+
result.current.undo();
|
|
206
|
+
});
|
|
207
|
+
expect(result.current.draft.name).toBe('A');
|
|
208
|
+
|
|
209
|
+
act(() => {
|
|
210
|
+
result.current.undo();
|
|
211
|
+
});
|
|
212
|
+
expect(result.current.draft.name).toBe('Test');
|
|
213
|
+
expect(result.current.canUndo).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should do nothing when undoing with empty history', () => {
|
|
217
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
218
|
+
act(() => {
|
|
219
|
+
result.current.undo();
|
|
220
|
+
});
|
|
221
|
+
expect(result.current.draft).toEqual(source);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should do nothing when redoing with empty future', () => {
|
|
225
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
226
|
+
act(() => {
|
|
227
|
+
result.current.redo();
|
|
228
|
+
});
|
|
229
|
+
expect(result.current.draft).toEqual(source);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should clear history on discard', () => {
|
|
233
|
+
const { result } = renderHook(() => useConfigDraft(source));
|
|
234
|
+
act(() => {
|
|
235
|
+
result.current.updateField('name', 'Changed');
|
|
236
|
+
});
|
|
237
|
+
expect(result.current.canUndo).toBe(true);
|
|
238
|
+
|
|
239
|
+
act(() => {
|
|
240
|
+
result.current.discard();
|
|
241
|
+
});
|
|
242
|
+
expect(result.current.canUndo).toBe(false);
|
|
243
|
+
expect(result.current.canRedo).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should clear history when source changes', () => {
|
|
247
|
+
const source1 = { name: 'A', value: 1 };
|
|
248
|
+
const source2 = { name: 'B', value: 2 };
|
|
249
|
+
const { result, rerender } = renderHook(
|
|
250
|
+
({ src }) => useConfigDraft(src),
|
|
251
|
+
{ initialProps: { src: source1 } },
|
|
252
|
+
);
|
|
253
|
+
act(() => {
|
|
254
|
+
result.current.updateField('name', 'Modified');
|
|
255
|
+
});
|
|
256
|
+
expect(result.current.canUndo).toBe(true);
|
|
257
|
+
|
|
258
|
+
rerender({ src: source2 });
|
|
259
|
+
expect(result.current.canUndo).toBe(false);
|
|
260
|
+
expect(result.current.canRedo).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should respect maxHistory limit', () => {
|
|
264
|
+
const { result } = renderHook(() =>
|
|
265
|
+
useConfigDraft(source, { maxHistory: 3 }),
|
|
266
|
+
);
|
|
267
|
+
act(() => {
|
|
268
|
+
result.current.updateField('name', 'A');
|
|
269
|
+
});
|
|
270
|
+
act(() => {
|
|
271
|
+
result.current.updateField('name', 'B');
|
|
272
|
+
});
|
|
273
|
+
act(() => {
|
|
274
|
+
result.current.updateField('name', 'C');
|
|
275
|
+
});
|
|
276
|
+
act(() => {
|
|
277
|
+
result.current.updateField('name', 'D');
|
|
278
|
+
});
|
|
279
|
+
// maxHistory=3, so only 3 undo steps (not 4)
|
|
280
|
+
act(() => {
|
|
281
|
+
result.current.undo();
|
|
282
|
+
});
|
|
283
|
+
expect(result.current.draft.name).toBe('C');
|
|
284
|
+
act(() => {
|
|
285
|
+
result.current.undo();
|
|
286
|
+
});
|
|
287
|
+
expect(result.current.draft.name).toBe('B');
|
|
288
|
+
act(() => {
|
|
289
|
+
result.current.undo();
|
|
290
|
+
});
|
|
291
|
+
// Should stop here — oldest entry was trimmed
|
|
292
|
+
expect(result.current.canUndo).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
});
|