@object-ui/components 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 +12 -12
- package/CHANGELOG.md +9 -0
- package/dist/index.css +1 -1
- package/dist/index.js +24932 -23139
- 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/filter-builder.d.ts +1 -1
- 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 +25 -0
- package/dist/src/renderers/action/action-button.d.ts +1 -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 +206 -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__/filter-builder.test.tsx +409 -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/filter-builder.tsx +76 -25
- 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 +221 -0
- package/src/renderers/action/action-button.tsx +17 -6
- 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,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for ActionBar (action:bar) renderer
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { render, screen } from '@testing-library/react';
|
|
7
|
+
import { ComponentRegistry } from '@object-ui/core';
|
|
8
|
+
import { renderComponent, validateComponentRegistration } from './test-utils';
|
|
9
|
+
|
|
10
|
+
// Ensure action renderers are loaded (side-effect imports via vitest.setup.tsx)
|
|
11
|
+
|
|
12
|
+
describe('ActionBar (action:bar)', () => {
|
|
13
|
+
describe('registration', () => {
|
|
14
|
+
it('is registered in ComponentRegistry', () => {
|
|
15
|
+
const reg = validateComponentRegistration('action:bar');
|
|
16
|
+
expect(reg.isRegistered).toBe(true);
|
|
17
|
+
expect(reg.hasRenderer).toBe(true);
|
|
18
|
+
expect(reg.hasLabel).toBe(true);
|
|
19
|
+
expect(reg.hasInputs).toBe(true);
|
|
20
|
+
expect(reg.hasDefaultProps).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('rendering', () => {
|
|
25
|
+
it('renders nothing when actions array is empty', () => {
|
|
26
|
+
const { container } = renderComponent({
|
|
27
|
+
type: 'action:bar',
|
|
28
|
+
actions: [],
|
|
29
|
+
});
|
|
30
|
+
expect(container.innerHTML).toBe('');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders action buttons for provided actions', () => {
|
|
34
|
+
const { container } = renderComponent({
|
|
35
|
+
type: 'action:bar',
|
|
36
|
+
actions: [
|
|
37
|
+
{ name: 'save', label: 'Save', type: 'script', component: 'action:button' },
|
|
38
|
+
{ name: 'cancel', label: 'Cancel', type: 'script', component: 'action:button' },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
expect(container.textContent).toContain('Save');
|
|
42
|
+
expect(container.textContent).toContain('Cancel');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders with role="toolbar" and aria-label', () => {
|
|
46
|
+
const { container } = renderComponent({
|
|
47
|
+
type: 'action:bar',
|
|
48
|
+
actions: [
|
|
49
|
+
{ name: 'test', label: 'Test', type: 'script' },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
const toolbar = container.querySelector('[role="toolbar"]');
|
|
53
|
+
expect(toolbar).toBeTruthy();
|
|
54
|
+
expect(toolbar?.getAttribute('aria-label')).toBe('Actions');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('filters actions by location', () => {
|
|
58
|
+
const { container } = renderComponent({
|
|
59
|
+
type: 'action:bar',
|
|
60
|
+
location: 'list_toolbar',
|
|
61
|
+
actions: [
|
|
62
|
+
{ name: 'toolbar_action', label: 'Toolbar Action', type: 'script', locations: ['list_toolbar'] },
|
|
63
|
+
{ name: 'header_action', label: 'Header Action', type: 'script', locations: ['record_header'] },
|
|
64
|
+
{ name: 'both_action', label: 'Both Action', type: 'script', locations: ['list_toolbar', 'record_header'] },
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
expect(container.textContent).toContain('Toolbar Action');
|
|
68
|
+
expect(container.textContent).not.toContain('Header Action');
|
|
69
|
+
expect(container.textContent).toContain('Both Action');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('shows actions without locations when filtering by location', () => {
|
|
73
|
+
const { container } = renderComponent({
|
|
74
|
+
type: 'action:bar',
|
|
75
|
+
location: 'record_header',
|
|
76
|
+
actions: [
|
|
77
|
+
{ name: 'no_loc', label: 'No Location', type: 'script' },
|
|
78
|
+
{ name: 'has_loc', label: 'Has Location', type: 'script', locations: ['list_toolbar'] },
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
// Action without locations should show in any location
|
|
82
|
+
expect(container.textContent).toContain('No Location');
|
|
83
|
+
expect(container.textContent).not.toContain('Has Location');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('renders all actions when no location filter is set', () => {
|
|
87
|
+
const { container } = renderComponent({
|
|
88
|
+
type: 'action:bar',
|
|
89
|
+
actions: [
|
|
90
|
+
{ name: 'a1', label: 'Action 1', type: 'script', locations: ['list_toolbar'] },
|
|
91
|
+
{ name: 'a2', label: 'Action 2', type: 'script', locations: ['record_header'] },
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
expect(container.textContent).toContain('Action 1');
|
|
95
|
+
expect(container.textContent).toContain('Action 2');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('deduplicates actions by name', () => {
|
|
99
|
+
const { container } = renderComponent({
|
|
100
|
+
type: 'action:bar',
|
|
101
|
+
actions: [
|
|
102
|
+
{ name: 'change_status', label: 'Change Status', type: 'script', component: 'action:button' },
|
|
103
|
+
{ name: 'assign_user', label: 'Assign User', type: 'script', component: 'action:button' },
|
|
104
|
+
{ name: 'change_status', label: 'Change Status', type: 'script', component: 'action:button' },
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
const toolbar = container.querySelector('[role="toolbar"]');
|
|
108
|
+
expect(toolbar).toBeTruthy();
|
|
109
|
+
// Should only render 2 actions (duplicates removed)
|
|
110
|
+
expect(toolbar!.children.length).toBe(2);
|
|
111
|
+
expect(container.textContent).toContain('Change Status');
|
|
112
|
+
expect(container.textContent).toContain('Assign User');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('deduplicates actions after location filtering', () => {
|
|
116
|
+
const { container } = renderComponent({
|
|
117
|
+
type: 'action:bar',
|
|
118
|
+
location: 'record_header',
|
|
119
|
+
actions: [
|
|
120
|
+
{ name: 'change_status', label: 'Change Status', type: 'script', locations: ['record_header'] },
|
|
121
|
+
{ name: 'assign_user', label: 'Assign User', type: 'script', locations: ['record_header'] },
|
|
122
|
+
{ name: 'change_status', label: 'Change Status', type: 'script', locations: ['record_header', 'record_more'] },
|
|
123
|
+
{ name: 'assign_user', label: 'Assign User', type: 'script', locations: ['record_header'] },
|
|
124
|
+
],
|
|
125
|
+
});
|
|
126
|
+
const toolbar = container.querySelector('[role="toolbar"]');
|
|
127
|
+
expect(toolbar).toBeTruthy();
|
|
128
|
+
// Should only render 2 unique actions
|
|
129
|
+
expect(toolbar!.children.length).toBe(2);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('overflow', () => {
|
|
134
|
+
it('groups excess actions into overflow menu when maxVisible is exceeded', () => {
|
|
135
|
+
const { container } = renderComponent({
|
|
136
|
+
type: 'action:bar',
|
|
137
|
+
maxVisible: 2,
|
|
138
|
+
actions: [
|
|
139
|
+
{ name: 'a1', label: 'Action 1', type: 'script' },
|
|
140
|
+
{ name: 'a2', label: 'Action 2', type: 'script' },
|
|
141
|
+
{ name: 'a3', label: 'Action 3', type: 'script' },
|
|
142
|
+
{ name: 'a4', label: 'Action 4', type: 'script' },
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
// First 2 should be visible as buttons
|
|
146
|
+
expect(container.textContent).toContain('Action 1');
|
|
147
|
+
expect(container.textContent).toContain('Action 2');
|
|
148
|
+
// Remaining 2 should be in a dropdown (rendered as action:menu trigger)
|
|
149
|
+
const toolbar = container.querySelector('[role="toolbar"]');
|
|
150
|
+
expect(toolbar).toBeTruthy();
|
|
151
|
+
// There should be 3 children: 2 inline buttons + 1 menu trigger
|
|
152
|
+
const children = toolbar!.children;
|
|
153
|
+
expect(children.length).toBe(3);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('does not show overflow when actions fit within maxVisible', () => {
|
|
157
|
+
const { container } = renderComponent({
|
|
158
|
+
type: 'action:bar',
|
|
159
|
+
maxVisible: 5,
|
|
160
|
+
actions: [
|
|
161
|
+
{ name: 'a1', label: 'Action 1', type: 'script' },
|
|
162
|
+
{ name: 'a2', label: 'Action 2', type: 'script' },
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
const toolbar = container.querySelector('[role="toolbar"]');
|
|
166
|
+
expect(toolbar!.children.length).toBe(2);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('styling', () => {
|
|
171
|
+
it('applies custom className', () => {
|
|
172
|
+
const { container } = renderComponent({
|
|
173
|
+
type: 'action:bar',
|
|
174
|
+
className: 'my-custom-bar',
|
|
175
|
+
actions: [
|
|
176
|
+
{ name: 'test', label: 'Test', type: 'script' },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
const toolbar = container.querySelector('[role="toolbar"]');
|
|
180
|
+
expect(toolbar?.className).toContain('my-custom-bar');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('supports vertical direction', () => {
|
|
184
|
+
const { container } = renderComponent({
|
|
185
|
+
type: 'action:bar',
|
|
186
|
+
direction: 'vertical',
|
|
187
|
+
actions: [
|
|
188
|
+
{ name: 'test', label: 'Test', type: 'script' },
|
|
189
|
+
],
|
|
190
|
+
});
|
|
191
|
+
const toolbar = container.querySelector('[role="toolbar"]');
|
|
192
|
+
expect(toolbar?.className).toContain('flex-col');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('defaults to horizontal direction', () => {
|
|
196
|
+
const { container } = renderComponent({
|
|
197
|
+
type: 'action:bar',
|
|
198
|
+
actions: [
|
|
199
|
+
{ name: 'test', label: 'Test', type: 'script' },
|
|
200
|
+
],
|
|
201
|
+
});
|
|
202
|
+
const toolbar = container.querySelector('[role="toolbar"]');
|
|
203
|
+
expect(toolbar?.className).toContain('flex-row');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
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 { ConfigFieldRenderer } from '../custom/config-field-renderer';
|
|
12
|
+
import type { ConfigField } from '../types/config-panel';
|
|
13
|
+
|
|
14
|
+
const defaultDraft = { name: 'Test', enabled: true, theme: 'dark', count: 5 };
|
|
15
|
+
|
|
16
|
+
describe('ConfigFieldRenderer', () => {
|
|
17
|
+
describe('input type', () => {
|
|
18
|
+
const field: ConfigField = { key: 'name', label: 'Name', type: 'input', placeholder: 'Enter name' };
|
|
19
|
+
|
|
20
|
+
it('should render input with label', () => {
|
|
21
|
+
render(<ConfigFieldRenderer field={field} value="Test" onChange={vi.fn()} draft={defaultDraft} />);
|
|
22
|
+
expect(screen.getByText('Name')).toBeDefined();
|
|
23
|
+
expect(screen.getByTestId('config-field-name')).toBeDefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should display current value', () => {
|
|
27
|
+
render(<ConfigFieldRenderer field={field} value="Hello" onChange={vi.fn()} draft={defaultDraft} />);
|
|
28
|
+
expect((screen.getByTestId('config-field-name') as HTMLInputElement).value).toBe('Hello');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should call onChange on input', () => {
|
|
32
|
+
const onChange = vi.fn();
|
|
33
|
+
render(<ConfigFieldRenderer field={field} value="" onChange={onChange} draft={defaultDraft} />);
|
|
34
|
+
fireEvent.change(screen.getByTestId('config-field-name'), { target: { value: 'New' } });
|
|
35
|
+
expect(onChange).toHaveBeenCalledWith('New');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should use defaultValue when value is undefined', () => {
|
|
39
|
+
const fieldWithDefault: ConfigField = { key: 'name', label: 'Name', type: 'input', defaultValue: 'Default' };
|
|
40
|
+
render(<ConfigFieldRenderer field={fieldWithDefault} value={undefined} onChange={vi.fn()} draft={defaultDraft} />);
|
|
41
|
+
expect((screen.getByTestId('config-field-name') as HTMLInputElement).value).toBe('Default');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('switch type', () => {
|
|
46
|
+
const field: ConfigField = { key: 'enabled', label: 'Enabled', type: 'switch' };
|
|
47
|
+
|
|
48
|
+
it('should render switch with label', () => {
|
|
49
|
+
render(<ConfigFieldRenderer field={field} value={true} onChange={vi.fn()} draft={defaultDraft} />);
|
|
50
|
+
expect(screen.getByText('Enabled')).toBeDefined();
|
|
51
|
+
expect(screen.getByTestId('config-field-enabled')).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should toggle on click', () => {
|
|
55
|
+
const onChange = vi.fn();
|
|
56
|
+
render(<ConfigFieldRenderer field={field} value={false} onChange={onChange} draft={defaultDraft} />);
|
|
57
|
+
fireEvent.click(screen.getByTestId('config-field-enabled'));
|
|
58
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('checkbox type', () => {
|
|
63
|
+
const field: ConfigField = { key: 'checked', label: 'Active', type: 'checkbox' };
|
|
64
|
+
|
|
65
|
+
it('should render checkbox with label', () => {
|
|
66
|
+
render(<ConfigFieldRenderer field={field} value={false} onChange={vi.fn()} draft={defaultDraft} />);
|
|
67
|
+
expect(screen.getByText('Active')).toBeDefined();
|
|
68
|
+
expect(screen.getByTestId('config-field-checked')).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should toggle on click', () => {
|
|
72
|
+
const onChange = vi.fn();
|
|
73
|
+
render(<ConfigFieldRenderer field={field} value={false} onChange={onChange} draft={defaultDraft} />);
|
|
74
|
+
fireEvent.click(screen.getByTestId('config-field-checked'));
|
|
75
|
+
expect(onChange).toHaveBeenCalledWith(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('select type', () => {
|
|
80
|
+
const field: ConfigField = {
|
|
81
|
+
key: 'theme',
|
|
82
|
+
label: 'Theme',
|
|
83
|
+
type: 'select',
|
|
84
|
+
options: [
|
|
85
|
+
{ value: 'light', label: 'Light' },
|
|
86
|
+
{ value: 'dark', label: 'Dark' },
|
|
87
|
+
{ value: 'auto', label: 'Auto' },
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
it('should render select trigger with label', () => {
|
|
92
|
+
render(<ConfigFieldRenderer field={field} value="dark" onChange={vi.fn()} draft={defaultDraft} />);
|
|
93
|
+
expect(screen.getByText('Theme')).toBeDefined();
|
|
94
|
+
expect(screen.getByTestId('config-field-theme')).toBeDefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('slider type', () => {
|
|
99
|
+
const field: ConfigField = {
|
|
100
|
+
key: 'count',
|
|
101
|
+
label: 'Count',
|
|
102
|
+
type: 'slider',
|
|
103
|
+
min: 1,
|
|
104
|
+
max: 10,
|
|
105
|
+
step: 1,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
it('should render slider with label and value display', () => {
|
|
109
|
+
render(<ConfigFieldRenderer field={field} value={5} onChange={vi.fn()} draft={defaultDraft} />);
|
|
110
|
+
expect(screen.getByText('Count')).toBeDefined();
|
|
111
|
+
expect(screen.getByText('5')).toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('color type', () => {
|
|
116
|
+
const field: ConfigField = { key: 'bgColor', label: 'Background', type: 'color' };
|
|
117
|
+
|
|
118
|
+
it('should render color picker', () => {
|
|
119
|
+
render(<ConfigFieldRenderer field={field} value="#ff0000" onChange={vi.fn()} draft={defaultDraft} />);
|
|
120
|
+
expect(screen.getByText('Background')).toBeDefined();
|
|
121
|
+
const input = screen.getByTestId('config-field-bgColor') as HTMLInputElement;
|
|
122
|
+
expect(input.type).toBe('color');
|
|
123
|
+
expect(input.value).toBe('#ff0000');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should call onChange on color change', () => {
|
|
127
|
+
const onChange = vi.fn();
|
|
128
|
+
render(<ConfigFieldRenderer field={field} value="#ff0000" onChange={onChange} draft={defaultDraft} />);
|
|
129
|
+
fireEvent.change(screen.getByTestId('config-field-bgColor'), { target: { value: '#00ff00' } });
|
|
130
|
+
expect(onChange).toHaveBeenCalledWith('#00ff00');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('icon-group type', () => {
|
|
135
|
+
const field: ConfigField = {
|
|
136
|
+
key: 'size',
|
|
137
|
+
label: 'Size',
|
|
138
|
+
type: 'icon-group',
|
|
139
|
+
options: [
|
|
140
|
+
{ value: 'sm', label: 'Small' },
|
|
141
|
+
{ value: 'md', label: 'Medium' },
|
|
142
|
+
{ value: 'lg', label: 'Large' },
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
it('should render icon group buttons', () => {
|
|
147
|
+
render(<ConfigFieldRenderer field={field} value="md" onChange={vi.fn()} draft={defaultDraft} />);
|
|
148
|
+
expect(screen.getByText('Size')).toBeDefined();
|
|
149
|
+
expect(screen.getByTitle('Small')).toBeDefined();
|
|
150
|
+
expect(screen.getByTitle('Medium')).toBeDefined();
|
|
151
|
+
expect(screen.getByTitle('Large')).toBeDefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should call onChange when button clicked', () => {
|
|
155
|
+
const onChange = vi.fn();
|
|
156
|
+
render(<ConfigFieldRenderer field={field} value="sm" onChange={onChange} draft={defaultDraft} />);
|
|
157
|
+
fireEvent.click(screen.getByTitle('Large'));
|
|
158
|
+
expect(onChange).toHaveBeenCalledWith('lg');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('custom type', () => {
|
|
163
|
+
it('should render custom content via render prop', () => {
|
|
164
|
+
const field: ConfigField = {
|
|
165
|
+
key: 'custom',
|
|
166
|
+
label: 'Custom',
|
|
167
|
+
type: 'custom',
|
|
168
|
+
render: (value, onChange) => (
|
|
169
|
+
<div data-testid="custom-render">
|
|
170
|
+
<span>Custom: {value}</span>
|
|
171
|
+
<button onClick={() => onChange('updated')}>Update</button>
|
|
172
|
+
</div>
|
|
173
|
+
),
|
|
174
|
+
};
|
|
175
|
+
const onChange = vi.fn();
|
|
176
|
+
render(<ConfigFieldRenderer field={field} value="initial" onChange={onChange} draft={defaultDraft} />);
|
|
177
|
+
expect(screen.getByTestId('custom-render')).toBeDefined();
|
|
178
|
+
expect(screen.getByText('Custom: initial')).toBeDefined();
|
|
179
|
+
fireEvent.click(screen.getByText('Update'));
|
|
180
|
+
expect(onChange).toHaveBeenCalledWith('updated');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should return null for custom type without render', () => {
|
|
184
|
+
const field: ConfigField = { key: 'empty', label: 'Empty', type: 'custom' };
|
|
185
|
+
const { container } = render(<ConfigFieldRenderer field={field} value={null} onChange={vi.fn()} draft={defaultDraft} />);
|
|
186
|
+
expect(container.innerHTML).toBe('');
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('visibility', () => {
|
|
191
|
+
it('should hide field when visibleWhen returns false', () => {
|
|
192
|
+
const field: ConfigField = {
|
|
193
|
+
key: 'hidden',
|
|
194
|
+
label: 'Hidden',
|
|
195
|
+
type: 'input',
|
|
196
|
+
visibleWhen: () => false,
|
|
197
|
+
};
|
|
198
|
+
const { container } = render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={defaultDraft} />);
|
|
199
|
+
expect(container.innerHTML).toBe('');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should show field when visibleWhen returns true', () => {
|
|
203
|
+
const field: ConfigField = {
|
|
204
|
+
key: 'visible',
|
|
205
|
+
label: 'Visible',
|
|
206
|
+
type: 'input',
|
|
207
|
+
visibleWhen: (draft) => draft.enabled === true,
|
|
208
|
+
};
|
|
209
|
+
render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={defaultDraft} />);
|
|
210
|
+
expect(screen.getByText('Visible')).toBeDefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should evaluate visibleWhen against draft', () => {
|
|
214
|
+
const field: ConfigField = {
|
|
215
|
+
key: 'conditional',
|
|
216
|
+
label: 'Conditional',
|
|
217
|
+
type: 'input',
|
|
218
|
+
visibleWhen: (draft) => draft.theme === 'dark',
|
|
219
|
+
};
|
|
220
|
+
const { container: hidden } = render(
|
|
221
|
+
<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={{ ...defaultDraft, theme: 'light' }} />,
|
|
222
|
+
);
|
|
223
|
+
expect(hidden.innerHTML).toBe('');
|
|
224
|
+
|
|
225
|
+
render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={{ ...defaultDraft, theme: 'dark' }} />);
|
|
226
|
+
expect(screen.getByText('Conditional')).toBeDefined();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('field-picker type', () => {
|
|
231
|
+
it('should render as clickable config row', () => {
|
|
232
|
+
const field: ConfigField = { key: 'field', label: 'Select Field', type: 'field-picker', placeholder: 'Choose...' };
|
|
233
|
+
render(<ConfigFieldRenderer field={field} value={undefined} onChange={vi.fn()} draft={defaultDraft} />);
|
|
234
|
+
expect(screen.getByText('Select Field')).toBeDefined();
|
|
235
|
+
expect(screen.getByText('Choose...')).toBeDefined();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('filter/sort types', () => {
|
|
240
|
+
it('should render filter with FilterBuilder', () => {
|
|
241
|
+
const field: ConfigField = {
|
|
242
|
+
key: 'filter',
|
|
243
|
+
label: 'Filters',
|
|
244
|
+
type: 'filter',
|
|
245
|
+
fields: [
|
|
246
|
+
{ value: 'name', label: 'Name' },
|
|
247
|
+
{ value: 'status', label: 'Status' },
|
|
248
|
+
],
|
|
249
|
+
};
|
|
250
|
+
render(<ConfigFieldRenderer field={field} value={undefined} onChange={vi.fn()} draft={defaultDraft} />);
|
|
251
|
+
expect(screen.getByText('Filters')).toBeDefined();
|
|
252
|
+
expect(screen.getByTestId('config-field-filter')).toBeDefined();
|
|
253
|
+
// FilterBuilder renders 'Where' label and 'Add filter' button
|
|
254
|
+
expect(screen.getByText('Add filter')).toBeDefined();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should render sort with SortBuilder', () => {
|
|
258
|
+
const field: ConfigField = {
|
|
259
|
+
key: 'sort',
|
|
260
|
+
label: 'Sorting',
|
|
261
|
+
type: 'sort',
|
|
262
|
+
fields: [
|
|
263
|
+
{ value: 'name', label: 'Name' },
|
|
264
|
+
{ value: 'date', label: 'Date' },
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
render(<ConfigFieldRenderer field={field} value={undefined} onChange={vi.fn()} draft={defaultDraft} />);
|
|
268
|
+
expect(screen.getByText('Sorting')).toBeDefined();
|
|
269
|
+
expect(screen.getByTestId('config-field-sort')).toBeDefined();
|
|
270
|
+
// SortBuilder renders 'Add sort' button
|
|
271
|
+
expect(screen.getByText('Add sort')).toBeDefined();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('helpText rendering', () => {
|
|
276
|
+
it('should render helpText below field when provided', () => {
|
|
277
|
+
const field: ConfigField = {
|
|
278
|
+
key: 'width',
|
|
279
|
+
label: 'Width',
|
|
280
|
+
type: 'input',
|
|
281
|
+
helpText: 'Available for drawer, modal, and split modes',
|
|
282
|
+
};
|
|
283
|
+
render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={defaultDraft} />);
|
|
284
|
+
expect(screen.getByText('Available for drawer, modal, and split modes')).toBeDefined();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should not render helpText paragraph when not provided', () => {
|
|
288
|
+
const field: ConfigField = { key: 'name', label: 'Name', type: 'input' };
|
|
289
|
+
const { container } = render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={defaultDraft} />);
|
|
290
|
+
expect(container.querySelectorAll('p').length).toBe(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should render helpText for custom field type', () => {
|
|
294
|
+
const React = require('react');
|
|
295
|
+
const field: ConfigField = {
|
|
296
|
+
key: 'custom',
|
|
297
|
+
label: 'Custom',
|
|
298
|
+
type: 'custom',
|
|
299
|
+
helpText: 'Custom help text',
|
|
300
|
+
render: (value, onChange) => React.createElement('div', { 'data-testid': 'custom-content' }, 'Custom'),
|
|
301
|
+
};
|
|
302
|
+
render(<ConfigFieldRenderer field={field} value="" onChange={vi.fn()} draft={defaultDraft} />);
|
|
303
|
+
expect(screen.getByText('Custom help text')).toBeDefined();
|
|
304
|
+
expect(screen.getByTestId('custom-content')).toBeDefined();
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
});
|