@object-ui/components 3.1.0 → 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 +11 -11
- package/CHANGELOG.md +9 -0
- package/dist/index.css +1 -1
- package/dist/index.js +9928 -9907
- package/dist/index.umd.cjs +20 -20
- package/dist/src/custom/filter-builder.d.ts +1 -1
- package/dist/src/renderers/action/action-bar.d.ts +2 -0
- package/dist/src/renderers/action/action-button.d.ts +1 -0
- package/package.json +4 -4
- package/src/__tests__/action-bar.test.tsx +34 -0
- package/src/__tests__/filter-builder.test.tsx +409 -0
- package/src/custom/filter-builder.tsx +76 -25
- package/src/renderers/action/action-bar.tsx +25 -6
- package/src/renderers/action/action-button.tsx +17 -6
- package/src/renderers/complex/data-table.tsx +2 -2
|
@@ -7,6 +7,8 @@ export interface ActionBarSchema {
|
|
|
7
7
|
location?: ActionLocation;
|
|
8
8
|
/** Maximum visible inline actions before overflow into "More" menu (default: 3) */
|
|
9
9
|
maxVisible?: number;
|
|
10
|
+
/** Maximum visible inline actions on mobile devices (default: 1). Desktop uses maxVisible instead. */
|
|
11
|
+
mobileMaxVisible?: number;
|
|
10
12
|
/** Visibility condition expression */
|
|
11
13
|
visible?: string;
|
|
12
14
|
/** Layout direction */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/components",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Standard UI component library for Object UI, built with Shadcn UI + Tailwind CSS",
|
|
@@ -69,9 +69,9 @@
|
|
|
69
69
|
"tailwind-merge": "^3.5.0",
|
|
70
70
|
"tailwindcss-animate": "^1.0.7",
|
|
71
71
|
"vaul": "^1.1.2",
|
|
72
|
-
"@object-ui/core": "3.1.
|
|
73
|
-
"@object-ui/react": "3.1.
|
|
74
|
-
"@object-ui/types": "3.1.
|
|
72
|
+
"@object-ui/core": "3.1.1",
|
|
73
|
+
"@object-ui/react": "3.1.1",
|
|
74
|
+
"@object-ui/types": "3.1.1"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -94,6 +94,40 @@ describe('ActionBar (action:bar)', () => {
|
|
|
94
94
|
expect(container.textContent).toContain('Action 1');
|
|
95
95
|
expect(container.textContent).toContain('Action 2');
|
|
96
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
|
+
});
|
|
97
131
|
});
|
|
98
132
|
|
|
99
133
|
describe('overflow', () => {
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
10
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
11
|
+
import { FilterBuilder } from '../custom/filter-builder';
|
|
12
|
+
|
|
13
|
+
// Mock crypto.randomUUID for deterministic test IDs
|
|
14
|
+
let uuidCounter = 0;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
uuidCounter = 0;
|
|
17
|
+
vi.spyOn(crypto, 'randomUUID').mockImplementation(
|
|
18
|
+
() => `test-uuid-${++uuidCounter}` as `${string}-${string}-${string}-${string}-${string}`,
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const selectFields = [
|
|
23
|
+
{
|
|
24
|
+
value: 'status',
|
|
25
|
+
label: 'Status',
|
|
26
|
+
type: 'select',
|
|
27
|
+
options: [
|
|
28
|
+
{ value: 'active', label: 'Active' },
|
|
29
|
+
{ value: 'inactive', label: 'Inactive' },
|
|
30
|
+
{ value: 'pending', label: 'Pending' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
value: 'name',
|
|
35
|
+
label: 'Name',
|
|
36
|
+
type: 'text',
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const lookupFields = [
|
|
41
|
+
{
|
|
42
|
+
value: 'account',
|
|
43
|
+
label: 'Account',
|
|
44
|
+
type: 'lookup',
|
|
45
|
+
options: [
|
|
46
|
+
{ value: 'acme', label: 'Acme Corp' },
|
|
47
|
+
{ value: 'globex', label: 'Globex' },
|
|
48
|
+
{ value: 'soylent', label: 'Soylent Corp' },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
describe('FilterBuilder', () => {
|
|
54
|
+
describe('lookup field type', () => {
|
|
55
|
+
it('renders without error for lookup fields', () => {
|
|
56
|
+
const onChange = vi.fn();
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<FilterBuilder
|
|
59
|
+
fields={lookupFields}
|
|
60
|
+
value={{
|
|
61
|
+
id: 'root',
|
|
62
|
+
logic: 'and',
|
|
63
|
+
conditions: [
|
|
64
|
+
{ id: 'c1', field: 'account', operator: 'equals', value: '' },
|
|
65
|
+
],
|
|
66
|
+
}}
|
|
67
|
+
onChange={onChange}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Should render the filter condition row
|
|
72
|
+
expect(container.querySelector('.space-y-3')).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('renders multi-select checkboxes for lookup field with "in" operator', () => {
|
|
76
|
+
const onChange = vi.fn();
|
|
77
|
+
render(
|
|
78
|
+
<FilterBuilder
|
|
79
|
+
fields={lookupFields}
|
|
80
|
+
value={{
|
|
81
|
+
id: 'root',
|
|
82
|
+
logic: 'and',
|
|
83
|
+
conditions: [
|
|
84
|
+
{ id: 'c1', field: 'account', operator: 'in', value: [] },
|
|
85
|
+
],
|
|
86
|
+
}}
|
|
87
|
+
onChange={onChange}
|
|
88
|
+
/>,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// Should render checkboxes for lookup options
|
|
92
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
93
|
+
expect(checkboxes.length).toBe(3);
|
|
94
|
+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
|
95
|
+
expect(screen.getByText('Globex')).toBeInTheDocument();
|
|
96
|
+
expect(screen.getByText('Soylent Corp')).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('multi-select with in/notIn operator', () => {
|
|
101
|
+
it('renders checkbox list for select field with "in" operator', () => {
|
|
102
|
+
const onChange = vi.fn();
|
|
103
|
+
render(
|
|
104
|
+
<FilterBuilder
|
|
105
|
+
fields={selectFields}
|
|
106
|
+
value={{
|
|
107
|
+
id: 'root',
|
|
108
|
+
logic: 'and',
|
|
109
|
+
conditions: [
|
|
110
|
+
{ id: 'c1', field: 'status', operator: 'in', value: [] },
|
|
111
|
+
],
|
|
112
|
+
}}
|
|
113
|
+
onChange={onChange}
|
|
114
|
+
/>,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Should render checkboxes for all options
|
|
118
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
119
|
+
expect(checkboxes.length).toBe(3);
|
|
120
|
+
expect(screen.getByText('Active')).toBeInTheDocument();
|
|
121
|
+
expect(screen.getByText('Inactive')).toBeInTheDocument();
|
|
122
|
+
expect(screen.getByText('Pending')).toBeInTheDocument();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('checks selected items in multi-select', () => {
|
|
126
|
+
const onChange = vi.fn();
|
|
127
|
+
render(
|
|
128
|
+
<FilterBuilder
|
|
129
|
+
fields={selectFields}
|
|
130
|
+
value={{
|
|
131
|
+
id: 'root',
|
|
132
|
+
logic: 'and',
|
|
133
|
+
conditions: [
|
|
134
|
+
{ id: 'c1', field: 'status', operator: 'in', value: ['active', 'pending'] },
|
|
135
|
+
],
|
|
136
|
+
}}
|
|
137
|
+
onChange={onChange}
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
|
142
|
+
// Active and Pending should be checked
|
|
143
|
+
expect(checkboxes[0]).toBeChecked(); // Active
|
|
144
|
+
expect(checkboxes[1]).not.toBeChecked(); // Inactive
|
|
145
|
+
expect(checkboxes[2]).toBeChecked(); // Pending
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('adds value when checkbox is checked', () => {
|
|
149
|
+
const onChange = vi.fn();
|
|
150
|
+
render(
|
|
151
|
+
<FilterBuilder
|
|
152
|
+
fields={selectFields}
|
|
153
|
+
value={{
|
|
154
|
+
id: 'root',
|
|
155
|
+
logic: 'and',
|
|
156
|
+
conditions: [
|
|
157
|
+
{ id: 'c1', field: 'status', operator: 'in', value: ['active'] },
|
|
158
|
+
],
|
|
159
|
+
}}
|
|
160
|
+
onChange={onChange}
|
|
161
|
+
/>,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Click the unchecked Inactive checkbox
|
|
165
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
166
|
+
fireEvent.click(checkboxes[1]); // Inactive
|
|
167
|
+
|
|
168
|
+
expect(onChange).toHaveBeenCalled();
|
|
169
|
+
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
|
170
|
+
const condition = lastCall.conditions[0];
|
|
171
|
+
expect(condition.value).toEqual(['active', 'inactive']);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('removes value when checkbox is unchecked', () => {
|
|
175
|
+
const onChange = vi.fn();
|
|
176
|
+
render(
|
|
177
|
+
<FilterBuilder
|
|
178
|
+
fields={selectFields}
|
|
179
|
+
value={{
|
|
180
|
+
id: 'root',
|
|
181
|
+
logic: 'and',
|
|
182
|
+
conditions: [
|
|
183
|
+
{ id: 'c1', field: 'status', operator: 'in', value: ['active', 'inactive'] },
|
|
184
|
+
],
|
|
185
|
+
}}
|
|
186
|
+
onChange={onChange}
|
|
187
|
+
/>,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// Click the checked Active checkbox
|
|
191
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
192
|
+
fireEvent.click(checkboxes[0]); // Active
|
|
193
|
+
|
|
194
|
+
expect(onChange).toHaveBeenCalled();
|
|
195
|
+
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
|
196
|
+
const condition = lastCall.conditions[0];
|
|
197
|
+
expect(condition.value).toEqual(['inactive']);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('renders checkbox list for notIn operator', () => {
|
|
201
|
+
const onChange = vi.fn();
|
|
202
|
+
render(
|
|
203
|
+
<FilterBuilder
|
|
204
|
+
fields={selectFields}
|
|
205
|
+
value={{
|
|
206
|
+
id: 'root',
|
|
207
|
+
logic: 'and',
|
|
208
|
+
conditions: [
|
|
209
|
+
{ id: 'c1', field: 'status', operator: 'notIn', value: [] },
|
|
210
|
+
],
|
|
211
|
+
}}
|
|
212
|
+
onChange={onChange}
|
|
213
|
+
/>,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
217
|
+
expect(checkboxes.length).toBe(3);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('does not render checkboxes for equals operator (single select)', () => {
|
|
221
|
+
const onChange = vi.fn();
|
|
222
|
+
render(
|
|
223
|
+
<FilterBuilder
|
|
224
|
+
fields={selectFields}
|
|
225
|
+
value={{
|
|
226
|
+
id: 'root',
|
|
227
|
+
logic: 'and',
|
|
228
|
+
conditions: [
|
|
229
|
+
{ id: 'c1', field: 'status', operator: 'equals', value: '' },
|
|
230
|
+
],
|
|
231
|
+
}}
|
|
232
|
+
onChange={onChange}
|
|
233
|
+
/>,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Should NOT render checkboxes (single select via Select component)
|
|
237
|
+
expect(screen.queryAllByRole('checkbox').length).toBe(0);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('currency/percent/rating fields use number input', () => {
|
|
242
|
+
const numericFields = [
|
|
243
|
+
{ value: 'amount', label: 'Amount', type: 'currency' },
|
|
244
|
+
{ value: 'rate', label: 'Rate', type: 'percent' },
|
|
245
|
+
{ value: 'score', label: 'Score', type: 'rating' },
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
it('renders number input for currency field', () => {
|
|
249
|
+
const onChange = vi.fn();
|
|
250
|
+
render(
|
|
251
|
+
<FilterBuilder
|
|
252
|
+
fields={numericFields}
|
|
253
|
+
value={{
|
|
254
|
+
id: 'root',
|
|
255
|
+
logic: 'and',
|
|
256
|
+
conditions: [
|
|
257
|
+
{ id: 'c1', field: 'amount', operator: 'equals', value: '' },
|
|
258
|
+
],
|
|
259
|
+
}}
|
|
260
|
+
onChange={onChange}
|
|
261
|
+
/>,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const input = screen.getByPlaceholderText('Value') as HTMLInputElement;
|
|
265
|
+
expect(input.type).toBe('number');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('renders number input for percent field', () => {
|
|
269
|
+
const onChange = vi.fn();
|
|
270
|
+
render(
|
|
271
|
+
<FilterBuilder
|
|
272
|
+
fields={numericFields}
|
|
273
|
+
value={{
|
|
274
|
+
id: 'root',
|
|
275
|
+
logic: 'and',
|
|
276
|
+
conditions: [
|
|
277
|
+
{ id: 'c1', field: 'rate', operator: 'equals', value: '' },
|
|
278
|
+
],
|
|
279
|
+
}}
|
|
280
|
+
onChange={onChange}
|
|
281
|
+
/>,
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const input = screen.getByPlaceholderText('Value') as HTMLInputElement;
|
|
285
|
+
expect(input.type).toBe('number');
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('datetime/time fields use appropriate input types', () => {
|
|
290
|
+
const dateTimeFields = [
|
|
291
|
+
{ value: 'created_at', label: 'Created At', type: 'datetime' },
|
|
292
|
+
{ value: 'start_time', label: 'Start Time', type: 'time' },
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
it('renders datetime-local input for datetime field', () => {
|
|
296
|
+
const onChange = vi.fn();
|
|
297
|
+
render(
|
|
298
|
+
<FilterBuilder
|
|
299
|
+
fields={dateTimeFields}
|
|
300
|
+
value={{
|
|
301
|
+
id: 'root',
|
|
302
|
+
logic: 'and',
|
|
303
|
+
conditions: [
|
|
304
|
+
{ id: 'c1', field: 'created_at', operator: 'equals', value: '' },
|
|
305
|
+
],
|
|
306
|
+
}}
|
|
307
|
+
onChange={onChange}
|
|
308
|
+
/>,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const input = screen.getByPlaceholderText('Value') as HTMLInputElement;
|
|
312
|
+
expect(input.type).toBe('datetime-local');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('renders time input for time field', () => {
|
|
316
|
+
const onChange = vi.fn();
|
|
317
|
+
render(
|
|
318
|
+
<FilterBuilder
|
|
319
|
+
fields={dateTimeFields}
|
|
320
|
+
value={{
|
|
321
|
+
id: 'root',
|
|
322
|
+
logic: 'and',
|
|
323
|
+
conditions: [
|
|
324
|
+
{ id: 'c1', field: 'start_time', operator: 'equals', value: '' },
|
|
325
|
+
],
|
|
326
|
+
}}
|
|
327
|
+
onChange={onChange}
|
|
328
|
+
/>,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const input = screen.getByPlaceholderText('Value') as HTMLInputElement;
|
|
332
|
+
expect(input.type).toBe('time');
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('status field uses select operators and dropdown', () => {
|
|
337
|
+
const statusFields = [
|
|
338
|
+
{
|
|
339
|
+
value: 'pipeline',
|
|
340
|
+
label: 'Pipeline Stage',
|
|
341
|
+
type: 'status',
|
|
342
|
+
options: [
|
|
343
|
+
{ value: 'lead', label: 'Lead' },
|
|
344
|
+
{ value: 'qualified', label: 'Qualified' },
|
|
345
|
+
{ value: 'won', label: 'Won' },
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
it('renders multi-select checkboxes for status field with "in" operator', () => {
|
|
351
|
+
const onChange = vi.fn();
|
|
352
|
+
render(
|
|
353
|
+
<FilterBuilder
|
|
354
|
+
fields={statusFields}
|
|
355
|
+
value={{
|
|
356
|
+
id: 'root',
|
|
357
|
+
logic: 'and',
|
|
358
|
+
conditions: [
|
|
359
|
+
{ id: 'c1', field: 'pipeline', operator: 'in', value: [] },
|
|
360
|
+
],
|
|
361
|
+
}}
|
|
362
|
+
onChange={onChange}
|
|
363
|
+
/>,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
367
|
+
expect(checkboxes.length).toBe(3);
|
|
368
|
+
expect(screen.getByText('Lead')).toBeInTheDocument();
|
|
369
|
+
expect(screen.getByText('Qualified')).toBeInTheDocument();
|
|
370
|
+
expect(screen.getByText('Won')).toBeInTheDocument();
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('user/owner field uses lookup operators and dropdown', () => {
|
|
375
|
+
const userFields = [
|
|
376
|
+
{
|
|
377
|
+
value: 'assigned_to',
|
|
378
|
+
label: 'Assigned To',
|
|
379
|
+
type: 'user',
|
|
380
|
+
options: [
|
|
381
|
+
{ value: 'user1', label: 'Alice' },
|
|
382
|
+
{ value: 'user2', label: 'Bob' },
|
|
383
|
+
],
|
|
384
|
+
},
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
it('renders multi-select checkboxes for user field with "in" operator', () => {
|
|
388
|
+
const onChange = vi.fn();
|
|
389
|
+
render(
|
|
390
|
+
<FilterBuilder
|
|
391
|
+
fields={userFields}
|
|
392
|
+
value={{
|
|
393
|
+
id: 'root',
|
|
394
|
+
logic: 'and',
|
|
395
|
+
conditions: [
|
|
396
|
+
{ id: 'c1', field: 'assigned_to', operator: 'in', value: [] },
|
|
397
|
+
],
|
|
398
|
+
}}
|
|
399
|
+
onChange={onChange}
|
|
400
|
+
/>,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const checkboxes = screen.getAllByRole('checkbox');
|
|
404
|
+
expect(checkboxes.length).toBe(2);
|
|
405
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
406
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
});
|
|
@@ -13,6 +13,7 @@ import { X, Plus, Trash2 } from "lucide-react"
|
|
|
13
13
|
|
|
14
14
|
import { cn } from "../lib/utils"
|
|
15
15
|
import { Button } from "../ui/button"
|
|
16
|
+
import { Checkbox } from "../ui/checkbox"
|
|
16
17
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
|
17
18
|
import { Input } from "../ui/input"
|
|
18
19
|
|
|
@@ -20,7 +21,7 @@ export interface FilterCondition {
|
|
|
20
21
|
id: string
|
|
21
22
|
field: string
|
|
22
23
|
operator: string
|
|
23
|
-
value: string | number | boolean
|
|
24
|
+
value: string | number | boolean | (string | number | boolean)[]
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export interface FilterGroup {
|
|
@@ -65,6 +66,23 @@ const numberOperators = ["equals", "notEquals", "greaterThan", "lessThan", "grea
|
|
|
65
66
|
const booleanOperators = ["equals", "notEquals"]
|
|
66
67
|
const dateOperators = ["equals", "notEquals", "before", "after", "between", "isEmpty", "isNotEmpty"]
|
|
67
68
|
const selectOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty"]
|
|
69
|
+
const lookupOperators = ["equals", "notEquals", "in", "notIn", "isEmpty", "isNotEmpty"]
|
|
70
|
+
|
|
71
|
+
/** Field types that share the same operator/input behavior as number (numeric comparison operators, number input) */
|
|
72
|
+
const numberLikeTypes = ["number", "currency", "percent", "rating"]
|
|
73
|
+
/** Field types that share the same operator/input behavior as date (before/after operators, date/datetime/time input) */
|
|
74
|
+
const dateLikeTypes = ["date", "datetime", "time"]
|
|
75
|
+
/** Field types that use select operators (equals/in/notIn) and render dropdown or checkbox list when options provided */
|
|
76
|
+
const selectLikeTypes = ["select", "status"]
|
|
77
|
+
/** Relational/reference field types that use lookup operators (equals/in/notIn) and render dropdown or checkbox list when options provided */
|
|
78
|
+
const lookupLikeTypes = ["lookup", "master_detail", "user", "owner"]
|
|
79
|
+
|
|
80
|
+
/** Normalize a filter value into an array for multi-select scenarios */
|
|
81
|
+
function normalizeToArray(value: FilterCondition["value"]): (string | number | boolean)[] {
|
|
82
|
+
if (Array.isArray(value)) return value
|
|
83
|
+
if (value !== undefined && value !== null && value !== "") return [value as string | number | boolean]
|
|
84
|
+
return []
|
|
85
|
+
}
|
|
68
86
|
|
|
69
87
|
function FilterBuilder({
|
|
70
88
|
fields = [],
|
|
@@ -139,19 +157,22 @@ function FilterBuilder({
|
|
|
139
157
|
const field = fields.find((f) => f.value === fieldValue)
|
|
140
158
|
const fieldType = field?.type || "text"
|
|
141
159
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
160
|
+
if (numberLikeTypes.includes(fieldType)) {
|
|
161
|
+
return defaultOperators.filter((op) => numberOperators.includes(op.value))
|
|
162
|
+
}
|
|
163
|
+
if (fieldType === "boolean") {
|
|
164
|
+
return defaultOperators.filter((op) => booleanOperators.includes(op.value))
|
|
165
|
+
}
|
|
166
|
+
if (dateLikeTypes.includes(fieldType)) {
|
|
167
|
+
return defaultOperators.filter((op) => dateOperators.includes(op.value))
|
|
168
|
+
}
|
|
169
|
+
if (selectLikeTypes.includes(fieldType)) {
|
|
170
|
+
return defaultOperators.filter((op) => selectOperators.includes(op.value))
|
|
171
|
+
}
|
|
172
|
+
if (lookupLikeTypes.includes(fieldType)) {
|
|
173
|
+
return defaultOperators.filter((op) => lookupOperators.includes(op.value))
|
|
154
174
|
}
|
|
175
|
+
return defaultOperators.filter((op) => textOperators.includes(op.value))
|
|
155
176
|
}
|
|
156
177
|
|
|
157
178
|
const needsValueInput = (operator: string) => {
|
|
@@ -162,21 +183,51 @@ function FilterBuilder({
|
|
|
162
183
|
const field = fields.find((f) => f.value === fieldValue)
|
|
163
184
|
const fieldType = field?.type || "text"
|
|
164
185
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
default:
|
|
171
|
-
return "text"
|
|
172
|
-
}
|
|
186
|
+
if (numberLikeTypes.includes(fieldType)) return "number"
|
|
187
|
+
if (fieldType === "date") return "date"
|
|
188
|
+
if (fieldType === "datetime") return "datetime-local"
|
|
189
|
+
if (fieldType === "time") return "time"
|
|
190
|
+
return "text"
|
|
173
191
|
}
|
|
174
192
|
|
|
175
193
|
const renderValueInput = (condition: FilterCondition) => {
|
|
176
194
|
const field = fields.find((f) => f.value === condition.field)
|
|
195
|
+
const isMultiOperator = ["in", "notIn"].includes(condition.operator)
|
|
177
196
|
|
|
178
|
-
// For select fields with options
|
|
179
|
-
if (field?.
|
|
197
|
+
// For select/lookup fields with options and multi-select operator (in/notIn)
|
|
198
|
+
if (field?.options && isMultiOperator) {
|
|
199
|
+
const selectedValues = normalizeToArray(condition.value)
|
|
200
|
+
return (
|
|
201
|
+
<div className="max-h-40 overflow-y-auto space-y-0.5 border rounded-md p-2">
|
|
202
|
+
{field.options.map((opt) => {
|
|
203
|
+
const isChecked = selectedValues.map(String).includes(String(opt.value))
|
|
204
|
+
return (
|
|
205
|
+
<label
|
|
206
|
+
key={opt.value}
|
|
207
|
+
className={cn(
|
|
208
|
+
"flex items-center gap-2 text-sm py-1 px-1.5 rounded cursor-pointer",
|
|
209
|
+
isChecked ? "bg-primary/5 text-primary" : "hover:bg-muted",
|
|
210
|
+
)}
|
|
211
|
+
>
|
|
212
|
+
<Checkbox
|
|
213
|
+
checked={isChecked}
|
|
214
|
+
onCheckedChange={(checked) => {
|
|
215
|
+
const next = checked
|
|
216
|
+
? [...selectedValues, opt.value]
|
|
217
|
+
: selectedValues.filter((v) => String(v) !== String(opt.value))
|
|
218
|
+
updateCondition(condition.id, { value: next })
|
|
219
|
+
}}
|
|
220
|
+
/>
|
|
221
|
+
<span className="truncate">{opt.label}</span>
|
|
222
|
+
</label>
|
|
223
|
+
)
|
|
224
|
+
})}
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// For select/lookup fields with options (single select)
|
|
230
|
+
if (field?.options && (selectLikeTypes.includes(field.type || "") || lookupLikeTypes.includes(field.type || ""))) {
|
|
180
231
|
return (
|
|
181
232
|
<Select
|
|
182
233
|
value={String(condition.value || "")}
|
|
@@ -235,9 +286,9 @@ function FilterBuilder({
|
|
|
235
286
|
const handleValueChange = (newValue: string) => {
|
|
236
287
|
let convertedValue: string | number | boolean = newValue
|
|
237
288
|
|
|
238
|
-
if (field?.type
|
|
289
|
+
if (numberLikeTypes.includes(field?.type || "") && newValue !== "") {
|
|
239
290
|
convertedValue = parseFloat(newValue) || 0
|
|
240
|
-
} else if (field?.type
|
|
291
|
+
} else if (dateLikeTypes.includes(field?.type || "")) {
|
|
241
292
|
convertedValue = newValue // Keep as ISO string
|
|
242
293
|
}
|
|
243
294
|
|