@object-ui/plugin-list 3.0.2 → 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.
@@ -0,0 +1,461 @@
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 * as React from 'react';
10
+ import { cn, Button, Popover, PopoverContent, PopoverTrigger } from '@object-ui/components';
11
+ import { ChevronDown, X, Plus, SlidersHorizontal } from 'lucide-react';
12
+ import type { ListViewSchema } from '@object-ui/types';
13
+
14
+ /** Resolved option with optional count */
15
+ interface ResolvedOption {
16
+ label: string;
17
+ value: string | number | boolean;
18
+ color?: string;
19
+ count?: number;
20
+ }
21
+
22
+ /** Resolved field with options derived from objectDef when not provided */
23
+ interface ResolvedField {
24
+ field: string;
25
+ label?: string;
26
+ type?: string;
27
+ options: ResolvedOption[];
28
+ showCount?: boolean;
29
+ defaultValues?: (string | number | boolean)[];
30
+ }
31
+
32
+ export interface UserFiltersProps {
33
+ config: NonNullable<ListViewSchema['userFilters']>;
34
+ /** Object definition for auto-deriving field options */
35
+ objectDef?: any;
36
+ /** Current data for computing counts */
37
+ data?: any[];
38
+ /** Callback when filter state changes */
39
+ onFilterChange: (filters: any[]) => void;
40
+ /** Maximum visible filter badges before collapsing into "More" dropdown (dropdown mode only) */
41
+ maxVisible?: number;
42
+ className?: string;
43
+ }
44
+
45
+ /**
46
+ * UserFilters — Airtable Interfaces-style filter bar.
47
+ *
48
+ * Renders one of three modes based on `config.element`:
49
+ * - **dropdown**: field-level dropdown selector badges
50
+ * - **tabs**: named filter preset tab bar
51
+ * - **toggle**: on/off toggle buttons per field
52
+ */
53
+ export function UserFilters({
54
+ config,
55
+ objectDef,
56
+ data = [],
57
+ onFilterChange,
58
+ maxVisible,
59
+ className,
60
+ }: UserFiltersProps) {
61
+ switch (config.element) {
62
+ case 'dropdown':
63
+ return (
64
+ <DropdownFilters
65
+ fields={config.fields || []}
66
+ objectDef={objectDef}
67
+ data={data}
68
+ onFilterChange={onFilterChange}
69
+ maxVisible={maxVisible}
70
+ className={className}
71
+ />
72
+ );
73
+ case 'tabs':
74
+ return (
75
+ <TabFilters
76
+ tabs={config.tabs || []}
77
+ showAllRecords={config.showAllRecords !== false}
78
+ allowAddTab={config.allowAddTab}
79
+ onFilterChange={onFilterChange}
80
+ className={className}
81
+ />
82
+ );
83
+ case 'toggle':
84
+ return (
85
+ <ToggleFilters
86
+ fields={config.fields || []}
87
+ onFilterChange={onFilterChange}
88
+ className={className}
89
+ />
90
+ );
91
+ default:
92
+ return null;
93
+ }
94
+ }
95
+
96
+ // ============================================
97
+ // Shared helper — resolve field options
98
+ // ============================================
99
+ function resolveFields(
100
+ fields: NonNullable<NonNullable<ListViewSchema['userFilters']>['fields']>,
101
+ objectDef: any,
102
+ data: any[],
103
+ ): ResolvedField[] {
104
+ return fields.map(f => {
105
+ let options: ResolvedOption[] = f.options ? [...f.options] : [];
106
+ if (options.length === 0 && objectDef?.fields) {
107
+ const fieldDef =
108
+ Array.isArray(objectDef.fields)
109
+ ? objectDef.fields.find((fd: any) => fd.name === f.field)
110
+ : objectDef.fields[f.field];
111
+ if (fieldDef?.options) {
112
+ if (Array.isArray(fieldDef.options)) {
113
+ options = fieldDef.options.map((o: any) => ({
114
+ label: o.label ?? String(o.value ?? o),
115
+ value: o.value ?? o,
116
+ color: o.color,
117
+ }));
118
+ } else {
119
+ options = Object.entries(fieldDef.options).map(([value, meta]) => ({
120
+ label: (meta as any)?.label || value,
121
+ value,
122
+ color: (meta as any)?.color,
123
+ }));
124
+ }
125
+ }
126
+ }
127
+ if (f.showCount && data.length > 0) {
128
+ options = options.map(opt => ({
129
+ ...opt,
130
+ count: data.filter(row => row[f.field] === opt.value).length,
131
+ }));
132
+ }
133
+ return { ...f, options };
134
+ });
135
+ }
136
+
137
+ // ============================================
138
+ // Dropdown Mode
139
+ // ============================================
140
+ interface DropdownFiltersProps {
141
+ fields: NonNullable<NonNullable<ListViewSchema['userFilters']>['fields']>;
142
+ objectDef?: any;
143
+ data: any[];
144
+ onFilterChange: (filters: any[]) => void;
145
+ maxVisible?: number;
146
+ className?: string;
147
+ }
148
+
149
+ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, className }: DropdownFiltersProps) {
150
+ const [selectedValues, setSelectedValues] = React.useState<
151
+ Record<string, (string | number | boolean)[]>
152
+ >(() => {
153
+ const init: Record<string, (string | number | boolean)[]> = {};
154
+ fields.forEach(f => {
155
+ if (f.defaultValues && f.defaultValues.length > 0) {
156
+ init[f.field] = f.defaultValues;
157
+ }
158
+ });
159
+ return init;
160
+ });
161
+
162
+ const resolvedFields = React.useMemo(
163
+ () => resolveFields(fields, objectDef, data),
164
+ [fields, objectDef, data],
165
+ );
166
+
167
+ const emitFilters = React.useCallback(
168
+ (next: Record<string, (string | number | boolean)[]>) => {
169
+ const conditions = Object.entries(next)
170
+ .filter(([, v]) => v.length > 0)
171
+ .map(([field, values]) => [field, 'in', values]);
172
+ onFilterChange(conditions);
173
+ },
174
+ [onFilterChange],
175
+ );
176
+
177
+ const handleChange = (field: string, values: (string | number | boolean)[]) => {
178
+ const next = { ...selectedValues, [field]: values };
179
+ setSelectedValues(next);
180
+ emitFilters(next);
181
+ };
182
+
183
+ // Emit default filters on mount
184
+ React.useEffect(() => {
185
+ const hasDefaults = Object.values(selectedValues).some(v => v.length > 0);
186
+ if (hasDefaults) emitFilters(selectedValues);
187
+ // eslint-disable-next-line react-hooks/exhaustive-deps
188
+ }, []);
189
+
190
+ // Split fields into visible and overflow based on maxVisible
191
+ const visibleFields = maxVisible !== undefined && maxVisible < resolvedFields.length
192
+ ? resolvedFields.slice(0, maxVisible)
193
+ : resolvedFields;
194
+ const overflowFields = maxVisible !== undefined && maxVisible < resolvedFields.length
195
+ ? resolvedFields.slice(maxVisible)
196
+ : [];
197
+
198
+ const renderBadge = (f: ResolvedField) => {
199
+ const selected = selectedValues[f.field] || [];
200
+ const hasSelection = selected.length > 0;
201
+
202
+ return (
203
+ <Popover key={f.field}>
204
+ <PopoverTrigger asChild>
205
+ <button
206
+ data-testid={`filter-badge-${f.field}`}
207
+ className={cn(
208
+ 'inline-flex items-center gap-1 rounded-full border h-7 px-2.5 text-xs font-medium transition-colors shrink-0',
209
+ hasSelection
210
+ ? 'border-primary/30 bg-primary/5 text-primary'
211
+ : 'border-border bg-background hover:bg-accent text-foreground',
212
+ )}
213
+ >
214
+ <span className="truncate max-w-[100px]">{f.label || f.field}</span>
215
+ {hasSelection && (
216
+ <span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px]">
217
+ {selected.length}
218
+ </span>
219
+ )}
220
+ {hasSelection ? (
221
+ <X
222
+ className="h-3 w-3 opacity-60"
223
+ data-testid={`filter-clear-${f.field}`}
224
+ onClick={e => {
225
+ e.stopPropagation();
226
+ handleChange(f.field, []);
227
+ }}
228
+ />
229
+ ) : (
230
+ <ChevronDown className="h-3 w-3 opacity-60" />
231
+ )}
232
+ </button>
233
+ </PopoverTrigger>
234
+ <PopoverContent align="start" className="w-56 p-2">
235
+ <div className="max-h-60 overflow-y-auto space-y-0.5" data-testid={`filter-options-${f.field}`}>
236
+ {f.options.map(opt => (
237
+ <label
238
+ key={String(opt.value)}
239
+ className={cn(
240
+ 'flex items-center gap-2 text-sm py-1.5 px-2 rounded cursor-pointer',
241
+ selected.includes(opt.value) ? 'bg-primary/5 text-primary' : 'hover:bg-muted',
242
+ )}
243
+ >
244
+ <input
245
+ type="checkbox"
246
+ checked={selected.includes(opt.value)}
247
+ onChange={() => {
248
+ const next = selected.includes(opt.value)
249
+ ? selected.filter(v => v !== opt.value)
250
+ : [...selected, opt.value];
251
+ handleChange(f.field, next);
252
+ }}
253
+ className="rounded border-input"
254
+ />
255
+ {opt.color && (
256
+ <span
257
+ className="h-2.5 w-2.5 rounded-full shrink-0"
258
+ style={{ backgroundColor: opt.color }}
259
+ />
260
+ )}
261
+ <span className="truncate flex-1">{opt.label}</span>
262
+ {opt.count !== undefined && (
263
+ <span className="text-xs text-muted-foreground">{opt.count}</span>
264
+ )}
265
+ </label>
266
+ ))}
267
+ </div>
268
+ </PopoverContent>
269
+ </Popover>
270
+ );
271
+ };
272
+
273
+ return (
274
+ <div className={cn('flex items-center gap-1 overflow-x-auto', className)} data-testid="user-filters-dropdown">
275
+ <SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
276
+ {resolvedFields.length === 0 ? (
277
+ <span className="text-xs text-muted-foreground" data-testid="user-filters-empty">
278
+ No filter fields
279
+ </span>
280
+ ) : (
281
+ <>
282
+ {visibleFields.map(renderBadge)}
283
+ {overflowFields.length > 0 && (
284
+ <Popover>
285
+ <PopoverTrigger asChild>
286
+ <button
287
+ data-testid="user-filters-more"
288
+ className="inline-flex items-center gap-1 rounded-full border border-border bg-background hover:bg-accent text-foreground h-7 px-2.5 text-xs font-medium transition-colors shrink-0"
289
+ >
290
+ <span>More</span>
291
+ <span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-muted text-[10px] font-medium">
292
+ {overflowFields.length}
293
+ </span>
294
+ <ChevronDown className="h-3 w-3 opacity-60" />
295
+ </button>
296
+ </PopoverTrigger>
297
+ <PopoverContent align="start" className="w-64 p-2" data-testid="user-filters-more-content">
298
+ <div className="space-y-1">
299
+ {overflowFields.map(renderBadge)}
300
+ </div>
301
+ </PopoverContent>
302
+ </Popover>
303
+ )}
304
+ </>
305
+ )}
306
+ <button
307
+ className="inline-flex items-center gap-1 h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors shrink-0"
308
+ data-testid="user-filters-add"
309
+ title="Add filter"
310
+ >
311
+ <Plus className="h-3.5 w-3.5" />
312
+ <span className="hidden sm:inline">Add filter</span>
313
+ </button>
314
+ </div>
315
+ );
316
+ }
317
+
318
+ // ============================================
319
+ // Tabs Mode
320
+ // ============================================
321
+ interface TabFiltersProps {
322
+ tabs: NonNullable<NonNullable<ListViewSchema['userFilters']>['tabs']>;
323
+ showAllRecords?: boolean;
324
+ allowAddTab?: boolean;
325
+ onFilterChange: (filters: any[]) => void;
326
+ className?: string;
327
+ }
328
+
329
+ function TabFilters({ tabs, showAllRecords, allowAddTab, onFilterChange, className }: TabFiltersProps) {
330
+ const [activeTab, setActiveTab] = React.useState<string>(() => {
331
+ const defaultTab = tabs.find(t => t.default);
332
+ return defaultTab?.id || (showAllRecords ? '__all__' : tabs[0]?.id || '');
333
+ });
334
+
335
+ const handleTabChange = React.useCallback(
336
+ (tabId: string) => {
337
+ setActiveTab(tabId);
338
+ if (tabId === '__all__') {
339
+ onFilterChange([]);
340
+ } else {
341
+ const tab = tabs.find(t => t.id === tabId);
342
+ onFilterChange(tab?.filters || []);
343
+ }
344
+ },
345
+ [tabs, onFilterChange],
346
+ );
347
+
348
+ const allTabs = React.useMemo(() => {
349
+ const result = [...tabs];
350
+ if (showAllRecords) {
351
+ result.push({ id: '__all__', label: 'All records', filters: [] });
352
+ }
353
+ return result;
354
+ }, [tabs, showAllRecords]);
355
+
356
+ // Emit default tab filters on mount
357
+ React.useEffect(() => {
358
+ const defaultTab = tabs.find(t => t.default);
359
+ if (defaultTab) {
360
+ onFilterChange(defaultTab.filters || []);
361
+ }
362
+ // eslint-disable-next-line react-hooks/exhaustive-deps
363
+ }, []);
364
+
365
+ return (
366
+ <div className={cn('flex items-center gap-0.5 overflow-x-auto', className)} data-testid="user-filters-tabs">
367
+ {allTabs.map(tab => (
368
+ <button
369
+ key={tab.id}
370
+ data-testid={`filter-tab-${tab.id}`}
371
+ onClick={() => handleTabChange(tab.id)}
372
+ className={cn(
373
+ 'inline-flex items-center h-7 px-3 text-xs font-medium rounded-md transition-colors shrink-0',
374
+ activeTab === tab.id
375
+ ? 'bg-primary text-primary-foreground'
376
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted',
377
+ )}
378
+ >
379
+ {tab.label}
380
+ </button>
381
+ ))}
382
+ {allowAddTab && (
383
+ <button
384
+ className="inline-flex items-center justify-center h-7 w-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted shrink-0"
385
+ data-testid="filter-tab-add"
386
+ title="Add filter tab"
387
+ >
388
+ <Plus className="h-3.5 w-3.5" />
389
+ </button>
390
+ )}
391
+ </div>
392
+ );
393
+ }
394
+
395
+ // ============================================
396
+ // Toggle Mode
397
+ // ============================================
398
+ interface ToggleFiltersProps {
399
+ fields: NonNullable<NonNullable<ListViewSchema['userFilters']>['fields']>;
400
+ onFilterChange: (filters: any[]) => void;
401
+ className?: string;
402
+ }
403
+
404
+ function ToggleFilters({ fields, onFilterChange, className }: ToggleFiltersProps) {
405
+ const [activeToggles, setActiveToggles] = React.useState<Set<string>>(() => {
406
+ const defaults = new Set<string>();
407
+ fields.forEach(f => {
408
+ if (f.defaultValues && f.defaultValues.length > 0) defaults.add(f.field);
409
+ });
410
+ return defaults;
411
+ });
412
+
413
+ const emitFilters = React.useCallback(
414
+ (active: Set<string>) => {
415
+ const conditions = Array.from(active).map(fieldName => {
416
+ const fieldDef = fields.find(fd => fd.field === fieldName);
417
+ return fieldDef?.defaultValues
418
+ ? [fieldName, 'in', fieldDef.defaultValues]
419
+ : [fieldName, '!=', null];
420
+ });
421
+ onFilterChange(conditions);
422
+ },
423
+ [fields, onFilterChange],
424
+ );
425
+
426
+ const handleToggle = (field: string) => {
427
+ setActiveToggles(prev => {
428
+ const next = new Set(prev);
429
+ if (next.has(field)) next.delete(field);
430
+ else next.add(field);
431
+ emitFilters(next);
432
+ return next;
433
+ });
434
+ };
435
+
436
+ // Emit default filters on mount
437
+ React.useEffect(() => {
438
+ if (activeToggles.size > 0) emitFilters(activeToggles);
439
+ // eslint-disable-next-line react-hooks/exhaustive-deps
440
+ }, []);
441
+
442
+ return (
443
+ <div className={cn('flex items-center gap-1 overflow-x-auto', className)} data-testid="user-filters-toggle">
444
+ {fields.map(f => {
445
+ const isActive = activeToggles.has(f.field);
446
+ return (
447
+ <Button
448
+ key={f.field}
449
+ variant={isActive ? 'default' : 'outline'}
450
+ size="sm"
451
+ className="h-7 px-3 text-xs shrink-0"
452
+ data-testid={`filter-toggle-${f.field}`}
453
+ onClick={() => handleToggle(f.field)}
454
+ >
455
+ {f.label || f.field}
456
+ </Button>
457
+ );
458
+ })}
459
+ </div>
460
+ );
461
+ }