@object-ui/plugin-view 0.3.1 → 2.0.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.
@@ -0,0 +1,544 @@
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 { FilterUI } from '../FilterUI';
12
+ import type { FilterUISchema } from '@object-ui/types';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Mock @object-ui/components – provide lightweight stand-ins for Shadcn
16
+ // primitives so tests render without a full component tree.
17
+ // ---------------------------------------------------------------------------
18
+ vi.mock('@object-ui/components', async () => {
19
+ const React = await import('react');
20
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
21
+
22
+ const Button = ({ children, onClick, variant, size, type, ...rest }: any) => (
23
+ <button onClick={onClick} data-variant={variant} data-size={size} type={type} {...rest}>
24
+ {children}
25
+ </button>
26
+ );
27
+
28
+ const Input = ({ value, onChange, placeholder, type, ...rest }: any) => (
29
+ <input
30
+ value={value}
31
+ onChange={onChange}
32
+ placeholder={placeholder}
33
+ type={type}
34
+ data-testid={`input-${type || 'text'}`}
35
+ {...rest}
36
+ />
37
+ );
38
+
39
+ const Label = ({ children, className }: any) => (
40
+ <label className={className}>{children}</label>
41
+ );
42
+
43
+ const Checkbox = ({ checked, onCheckedChange }: any) => (
44
+ <input
45
+ type="checkbox"
46
+ data-testid="checkbox"
47
+ checked={checked}
48
+ onChange={(e: any) => onCheckedChange?.(e.target.checked)}
49
+ />
50
+ );
51
+
52
+ // Share onValueChange from Select to SelectItem via React Context
53
+ const SelectCtx = React.createContext<((v: string) => void) | undefined>(undefined);
54
+
55
+ const Select = ({ children, value, onValueChange }: any) => {
56
+ return (
57
+ <SelectCtx.Provider value={onValueChange}>
58
+ <div data-testid="select-root" data-value={value}>
59
+ {children}
60
+ </div>
61
+ </SelectCtx.Provider>
62
+ );
63
+ };
64
+
65
+ const SelectTrigger = ({ children }: any) => (
66
+ <button data-testid="select-trigger">{children}</button>
67
+ );
68
+
69
+ const SelectValue = ({ placeholder }: any) => (
70
+ <span data-testid="select-value">{placeholder}</span>
71
+ );
72
+
73
+ const SelectContent = ({ children }: any) => (
74
+ <div data-testid="select-content">{children}</div>
75
+ );
76
+
77
+ const SelectItem = ({ children, value }: any) => {
78
+ const onValueChange = React.useContext(SelectCtx);
79
+ return (
80
+ <div
81
+ data-testid="select-item"
82
+ data-value={value}
83
+ role="option"
84
+ onClick={() => onValueChange?.(String(value))}
85
+ >
86
+ {children}
87
+ </div>
88
+ );
89
+ };
90
+
91
+ const Popover = ({ children, open }: any) => (
92
+ <div data-testid="popover" data-open={open}>
93
+ {children}
94
+ </div>
95
+ );
96
+
97
+ const PopoverTrigger = ({ children }: any) => (
98
+ <div data-testid="popover-trigger">{children}</div>
99
+ );
100
+
101
+ const PopoverContent = ({ children }: any) => (
102
+ <div data-testid="popover-content">{children}</div>
103
+ );
104
+
105
+ const Drawer = ({ children, open }: any) => (
106
+ <div data-testid="drawer" data-open={open}>
107
+ {children}
108
+ </div>
109
+ );
110
+
111
+ const DrawerContent = ({ children }: any) => (
112
+ <div data-testid="drawer-content">{children}</div>
113
+ );
114
+
115
+ const DrawerHeader = ({ children }: any) => (
116
+ <div data-testid="drawer-header">{children}</div>
117
+ );
118
+
119
+ const DrawerTitle = ({ children }: any) => (
120
+ <h2 data-testid="drawer-title">{children}</h2>
121
+ );
122
+
123
+ const DrawerDescription = ({ children }: any) => (
124
+ <p data-testid="drawer-description">{children}</p>
125
+ );
126
+
127
+ return {
128
+ cn,
129
+ Button,
130
+ Input,
131
+ Label,
132
+ Checkbox,
133
+ Select,
134
+ SelectTrigger,
135
+ SelectValue,
136
+ SelectContent,
137
+ SelectItem,
138
+ Popover,
139
+ PopoverTrigger,
140
+ PopoverContent,
141
+ Drawer,
142
+ DrawerContent,
143
+ DrawerHeader,
144
+ DrawerTitle,
145
+ DrawerDescription,
146
+ };
147
+ });
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Helpers
151
+ // ---------------------------------------------------------------------------
152
+ const baseFilters: FilterUISchema['filters'] = [
153
+ {
154
+ field: 'status',
155
+ label: 'Status',
156
+ type: 'select',
157
+ options: [
158
+ { label: 'Active', value: 'active' },
159
+ { label: 'Inactive', value: 'inactive' },
160
+ ],
161
+ },
162
+ { field: 'name', label: 'Name', type: 'text' },
163
+ ];
164
+
165
+ const makeSchema = (overrides: Partial<FilterUISchema> = {}): FilterUISchema => ({
166
+ type: 'filter-ui',
167
+ filters: baseFilters,
168
+ ...overrides,
169
+ });
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Tests
173
+ // ---------------------------------------------------------------------------
174
+ describe('FilterUI', () => {
175
+ // -------------------------------------------------------------------------
176
+ // 1. Renders with inline layout
177
+ // -------------------------------------------------------------------------
178
+ describe('inline layout', () => {
179
+ it('renders the filter form inline by default', () => {
180
+ const { container } = render(<FilterUI schema={makeSchema()} />);
181
+
182
+ // Labels should be visible
183
+ expect(screen.getByText('Status')).toBeInTheDocument();
184
+ expect(screen.getByText('Name')).toBeInTheDocument();
185
+
186
+ // No popover or drawer elements
187
+ expect(screen.queryByTestId('popover')).not.toBeInTheDocument();
188
+ expect(screen.queryByTestId('drawer')).not.toBeInTheDocument();
189
+ });
190
+
191
+ it('renders with explicit inline layout', () => {
192
+ render(<FilterUI schema={makeSchema({ layout: 'inline' })} />);
193
+
194
+ expect(screen.getByText('Status')).toBeInTheDocument();
195
+ expect(screen.getByText('Name')).toBeInTheDocument();
196
+ });
197
+ });
198
+
199
+ // -------------------------------------------------------------------------
200
+ // 2. Renders filter fields
201
+ // -------------------------------------------------------------------------
202
+ describe('filter fields', () => {
203
+ it('renders a label for each filter', () => {
204
+ render(<FilterUI schema={makeSchema()} />);
205
+
206
+ expect(screen.getByText('Status')).toBeInTheDocument();
207
+ expect(screen.getByText('Name')).toBeInTheDocument();
208
+ });
209
+
210
+ it('falls back to field name when label is omitted', () => {
211
+ render(
212
+ <FilterUI
213
+ schema={makeSchema({
214
+ filters: [{ field: 'email', type: 'text' }],
215
+ })}
216
+ />,
217
+ );
218
+
219
+ expect(screen.getByText('email')).toBeInTheDocument();
220
+ });
221
+
222
+ it('renders select options for select-type filters', () => {
223
+ render(<FilterUI schema={makeSchema()} />);
224
+
225
+ expect(screen.getByText('Active')).toBeInTheDocument();
226
+ expect(screen.getByText('Inactive')).toBeInTheDocument();
227
+ });
228
+
229
+ it('renders a text input for text-type filters', () => {
230
+ render(
231
+ <FilterUI
232
+ schema={makeSchema({
233
+ filters: [{ field: 'search', label: 'Search', type: 'text' }],
234
+ })}
235
+ />,
236
+ );
237
+
238
+ const input = screen.getByPlaceholderText('Filter by Search');
239
+ expect(input).toBeInTheDocument();
240
+ });
241
+ });
242
+
243
+ // -------------------------------------------------------------------------
244
+ // 3. Handles text filter values
245
+ // -------------------------------------------------------------------------
246
+ describe('text filter values', () => {
247
+ it('calls onChange when a text input is changed', () => {
248
+ const onChange = vi.fn();
249
+ render(
250
+ <FilterUI
251
+ schema={makeSchema({
252
+ filters: [{ field: 'name', label: 'Name', type: 'text' }],
253
+ })}
254
+ onChange={onChange}
255
+ />,
256
+ );
257
+
258
+ const input = screen.getByPlaceholderText('Filter by Name');
259
+ fireEvent.change(input, { target: { value: 'Alice' } });
260
+
261
+ expect(onChange).toHaveBeenCalledWith({ name: 'Alice' });
262
+ });
263
+
264
+ it('renders with pre-set values from schema', () => {
265
+ render(
266
+ <FilterUI
267
+ schema={makeSchema({
268
+ filters: [{ field: 'name', label: 'Name', type: 'text' }],
269
+ values: { name: 'Bob' },
270
+ })}
271
+ />,
272
+ );
273
+
274
+ const input = screen.getByPlaceholderText('Filter by Name') as HTMLInputElement;
275
+ expect(input.value).toBe('Bob');
276
+ });
277
+ });
278
+
279
+ // -------------------------------------------------------------------------
280
+ // 4. Handles select filter values
281
+ // -------------------------------------------------------------------------
282
+ describe('select filter values', () => {
283
+ it('calls onChange when a select value is changed', () => {
284
+ const onChange = vi.fn();
285
+ render(
286
+ <FilterUI
287
+ schema={makeSchema({
288
+ filters: [
289
+ {
290
+ field: 'status',
291
+ label: 'Status',
292
+ type: 'select',
293
+ options: [
294
+ { label: 'Active', value: 'active' },
295
+ { label: 'Inactive', value: 'inactive' },
296
+ ],
297
+ },
298
+ ],
299
+ })}
300
+ onChange={onChange}
301
+ />,
302
+ );
303
+
304
+ const activeOption = screen.getByText('Active');
305
+ fireEvent.click(activeOption);
306
+
307
+ expect(onChange).toHaveBeenCalledWith({ status: 'active' });
308
+ });
309
+
310
+ it('renders with pre-set select value from schema', () => {
311
+ render(
312
+ <FilterUI
313
+ schema={makeSchema({
314
+ values: { status: 'inactive' },
315
+ })}
316
+ />,
317
+ );
318
+
319
+ const selectRoot = screen.getAllByTestId('select-root')[0];
320
+ expect(selectRoot).toHaveAttribute('data-value', 'inactive');
321
+ });
322
+ });
323
+
324
+ // -------------------------------------------------------------------------
325
+ // 5. Renders with popover layout
326
+ // -------------------------------------------------------------------------
327
+ describe('popover layout', () => {
328
+ it('renders a Filters button with popover wrapper', () => {
329
+ render(<FilterUI schema={makeSchema({ layout: 'popover' })} />);
330
+
331
+ expect(screen.getByTestId('popover')).toBeInTheDocument();
332
+ expect(screen.getByText('Filters')).toBeInTheDocument();
333
+ });
334
+
335
+ it('renders the filter form inside popover content', () => {
336
+ render(<FilterUI schema={makeSchema({ layout: 'popover' })} />);
337
+
338
+ const popoverContent = screen.getByTestId('popover-content');
339
+ expect(popoverContent).toBeInTheDocument();
340
+
341
+ // Filter labels should still be rendered within popover
342
+ expect(screen.getByText('Status')).toBeInTheDocument();
343
+ expect(screen.getByText('Name')).toBeInTheDocument();
344
+ });
345
+
346
+ it('shows active filter count badge when filters have values', () => {
347
+ render(
348
+ <FilterUI
349
+ schema={makeSchema({
350
+ layout: 'popover',
351
+ values: { status: 'active' },
352
+ })}
353
+ />,
354
+ );
355
+
356
+ // The active count should be rendered
357
+ expect(screen.getByText('1')).toBeInTheDocument();
358
+ });
359
+ });
360
+
361
+ // -------------------------------------------------------------------------
362
+ // 6. Renders with empty / no filters
363
+ // -------------------------------------------------------------------------
364
+ describe('empty / no filters', () => {
365
+ it('renders without error when filters array is empty', () => {
366
+ render(<FilterUI schema={makeSchema({ filters: [] })} />);
367
+
368
+ // Should not throw; no labels rendered
369
+ expect(screen.queryByText('Status')).not.toBeInTheDocument();
370
+ expect(screen.queryByText('Name')).not.toBeInTheDocument();
371
+ });
372
+
373
+ it('renders without error when values are empty', () => {
374
+ render(<FilterUI schema={makeSchema({ values: {} })} />);
375
+
376
+ expect(screen.getByText('Status')).toBeInTheDocument();
377
+ expect(screen.getByText('Name')).toBeInTheDocument();
378
+ });
379
+
380
+ it('renders without error when values are undefined', () => {
381
+ render(<FilterUI schema={makeSchema()} />);
382
+
383
+ expect(screen.getByText('Status')).toBeInTheDocument();
384
+ });
385
+ });
386
+
387
+ // -------------------------------------------------------------------------
388
+ // 7. isEmptyValue edge cases (tested indirectly via active count badge)
389
+ // -------------------------------------------------------------------------
390
+ describe('isEmptyValue edge cases', () => {
391
+ it('treats null as empty — no active count badge', () => {
392
+ render(
393
+ <FilterUI
394
+ schema={makeSchema({
395
+ layout: 'popover',
396
+ values: { status: null },
397
+ })}
398
+ />,
399
+ );
400
+
401
+ // No badge with count should appear
402
+ expect(screen.queryByText('1')).not.toBeInTheDocument();
403
+ });
404
+
405
+ it('treats undefined as empty — no active count badge', () => {
406
+ render(
407
+ <FilterUI
408
+ schema={makeSchema({
409
+ layout: 'popover',
410
+ values: { status: undefined },
411
+ })}
412
+ />,
413
+ );
414
+
415
+ expect(screen.queryByText('1')).not.toBeInTheDocument();
416
+ });
417
+
418
+ it('treats empty string as empty — no active count badge', () => {
419
+ render(
420
+ <FilterUI
421
+ schema={makeSchema({
422
+ layout: 'popover',
423
+ values: { status: '' },
424
+ })}
425
+ />,
426
+ );
427
+
428
+ expect(screen.queryByText('1')).not.toBeInTheDocument();
429
+ });
430
+
431
+ it('treats empty array as empty — no active count badge', () => {
432
+ render(
433
+ <FilterUI
434
+ schema={makeSchema({
435
+ layout: 'popover',
436
+ values: { tags: [] },
437
+ })}
438
+ />,
439
+ );
440
+
441
+ expect(screen.queryByText('1')).not.toBeInTheDocument();
442
+ });
443
+
444
+ it('counts non-empty values for the active count badge', () => {
445
+ render(
446
+ <FilterUI
447
+ schema={makeSchema({
448
+ layout: 'popover',
449
+ values: { status: 'active', name: 'Alice' },
450
+ })}
451
+ />,
452
+ );
453
+
454
+ expect(screen.getByText('2')).toBeInTheDocument();
455
+ });
456
+ });
457
+
458
+ // -------------------------------------------------------------------------
459
+ // 8. onChange callback
460
+ // -------------------------------------------------------------------------
461
+ describe('onChange callback', () => {
462
+ it('fires onChange immediately when showApply is not set', () => {
463
+ const onChange = vi.fn();
464
+ render(
465
+ <FilterUI
466
+ schema={makeSchema({
467
+ filters: [{ field: 'name', label: 'Name', type: 'text' }],
468
+ })}
469
+ onChange={onChange}
470
+ />,
471
+ );
472
+
473
+ const input = screen.getByPlaceholderText('Filter by Name');
474
+ fireEvent.change(input, { target: { value: 'Test' } });
475
+
476
+ expect(onChange).toHaveBeenCalledTimes(1);
477
+ expect(onChange).toHaveBeenCalledWith({ name: 'Test' });
478
+ });
479
+
480
+ it('defers onChange until Apply is clicked when showApply is true', () => {
481
+ const onChange = vi.fn();
482
+ render(
483
+ <FilterUI
484
+ schema={makeSchema({
485
+ filters: [{ field: 'name', label: 'Name', type: 'text' }],
486
+ showApply: true,
487
+ })}
488
+ onChange={onChange}
489
+ />,
490
+ );
491
+
492
+ const input = screen.getByPlaceholderText('Filter by Name');
493
+ fireEvent.change(input, { target: { value: 'Deferred' } });
494
+
495
+ // onChange should NOT have been called yet
496
+ expect(onChange).not.toHaveBeenCalled();
497
+
498
+ // Click Apply
499
+ fireEvent.click(screen.getByText('Apply'));
500
+ expect(onChange).toHaveBeenCalledTimes(1);
501
+ expect(onChange).toHaveBeenCalledWith({ name: 'Deferred' });
502
+ });
503
+
504
+ it('clears all values when Clear is clicked', () => {
505
+ const onChange = vi.fn();
506
+ render(
507
+ <FilterUI
508
+ schema={makeSchema({
509
+ filters: [{ field: 'name', label: 'Name', type: 'text' }],
510
+ values: { name: 'Existing' },
511
+ showClear: true,
512
+ })}
513
+ onChange={onChange}
514
+ />,
515
+ );
516
+
517
+ fireEvent.click(screen.getByText('Clear'));
518
+ expect(onChange).toHaveBeenCalledWith({});
519
+ });
520
+
521
+ it('dispatches custom window event when schema.onChange is set', () => {
522
+ const spy = vi.fn();
523
+ window.addEventListener('filter:changed', spy);
524
+
525
+ render(
526
+ <FilterUI
527
+ schema={makeSchema({
528
+ filters: [{ field: 'name', label: 'Name', type: 'text' }],
529
+ onChange: 'filter:changed',
530
+ })}
531
+ />,
532
+ );
533
+
534
+ const input = screen.getByPlaceholderText('Filter by Name');
535
+ fireEvent.change(input, { target: { value: 'Event' } });
536
+
537
+ expect(spy).toHaveBeenCalledTimes(1);
538
+ const detail = (spy.mock.calls[0][0] as CustomEvent).detail;
539
+ expect(detail).toEqual({ values: { name: 'Event' } });
540
+
541
+ window.removeEventListener('filter:changed', spy);
542
+ });
543
+ });
544
+ });