@object-ui/plugin-view 0.5.0 → 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,317 @@
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 {
11
+ cn,
12
+ Button,
13
+ Checkbox,
14
+ Drawer,
15
+ DrawerContent,
16
+ DrawerDescription,
17
+ DrawerHeader,
18
+ DrawerTitle,
19
+ Input,
20
+ Label,
21
+ Popover,
22
+ PopoverContent,
23
+ PopoverTrigger,
24
+ Select,
25
+ SelectContent,
26
+ SelectItem,
27
+ SelectTrigger,
28
+ SelectValue,
29
+ } from '@object-ui/components';
30
+ import { cva } from 'class-variance-authority';
31
+ import { SlidersHorizontal, X } from 'lucide-react';
32
+ import type { FilterUISchema } from '@object-ui/types';
33
+
34
+ export type FilterUIProps = {
35
+ schema: FilterUISchema;
36
+ className?: string;
37
+ onChange?: (values: Record<string, any>) => void;
38
+ [key: string]: any;
39
+ };
40
+
41
+ type FilterValue = Record<string, any>;
42
+
43
+ type FilterConfig = FilterUISchema['filters'][number];
44
+
45
+ type DateRangeValue = {
46
+ start?: string;
47
+ end?: string;
48
+ };
49
+
50
+ const filterContainerVariants = cva('flex', {
51
+ variants: {
52
+ layout: {
53
+ inline: 'flex-col space-y-4',
54
+ popover: 'items-center',
55
+ drawer: 'items-center',
56
+ },
57
+ },
58
+ defaultVariants: {
59
+ layout: 'inline',
60
+ },
61
+ });
62
+
63
+ const isEmptyValue = (value: any): boolean => {
64
+ if (value === null || value === undefined || value === '') return true;
65
+ if (Array.isArray(value)) return value.length === 0;
66
+ if (typeof value === 'object') {
67
+ return Object.values(value).every(v => v === null || v === undefined || v === '');
68
+ }
69
+ return false;
70
+ };
71
+
72
+ const getDateRangeValue = (value: any): DateRangeValue => {
73
+ if (Array.isArray(value)) {
74
+ return { start: value[0] || '', end: value[1] || '' };
75
+ }
76
+ if (value && typeof value === 'object') {
77
+ return { start: value.start || '', end: value.end || '' };
78
+ }
79
+ return { start: '', end: '' };
80
+ };
81
+
82
+ export const FilterUI: React.FC<FilterUIProps> = ({
83
+ schema,
84
+ className,
85
+ onChange,
86
+ }) => {
87
+ const [values, setValues] = React.useState<FilterValue>(schema.values || {});
88
+ const [open, setOpen] = React.useState(false);
89
+
90
+ React.useEffect(() => {
91
+ if (schema.values) {
92
+ setValues(schema.values);
93
+ }
94
+ }, [schema.values]);
95
+
96
+ const notifyChange = React.useCallback((nextValues: FilterValue) => {
97
+ onChange?.(nextValues);
98
+
99
+ if (schema.onChange && typeof window !== 'undefined') {
100
+ window.dispatchEvent(
101
+ new CustomEvent(schema.onChange, {
102
+ detail: { values: nextValues },
103
+ })
104
+ );
105
+ }
106
+ }, [onChange, schema.onChange]);
107
+
108
+ const updateValue = React.useCallback((field: string, value: any) => {
109
+ const nextValues = { ...values, [field]: value };
110
+ setValues(nextValues);
111
+
112
+ if (!schema.showApply) {
113
+ notifyChange(nextValues);
114
+ }
115
+ }, [notifyChange, schema.showApply, values]);
116
+
117
+ const clearValues = React.useCallback(() => {
118
+ const nextValues: FilterValue = {};
119
+ setValues(nextValues);
120
+
121
+ if (schema.showApply) {
122
+ notifyChange(nextValues);
123
+ return;
124
+ }
125
+
126
+ notifyChange(nextValues);
127
+ }, [notifyChange, schema.showApply]);
128
+
129
+ const applyValues = React.useCallback(() => {
130
+ notifyChange(values);
131
+ setOpen(false);
132
+ }, [notifyChange, values]);
133
+
134
+ const activeCount = React.useMemo(() => {
135
+ return Object.values(values).filter(value => !isEmptyValue(value)).length;
136
+ }, [values]);
137
+
138
+ const renderInput = (filter: FilterConfig) => {
139
+ const label = filter.label || filter.field;
140
+ const placeholder = filter.placeholder || `Filter by ${label}`;
141
+
142
+ switch (filter.type) {
143
+ case 'number':
144
+ return (
145
+ <Input
146
+ type="number"
147
+ value={values[filter.field] ?? ''}
148
+ placeholder={placeholder}
149
+ onChange={(event) => {
150
+ const raw = event.target.value;
151
+ const parsed = raw === '' ? '' : Number(raw);
152
+ updateValue(filter.field, parsed);
153
+ }}
154
+ />
155
+ );
156
+ case 'select':
157
+ return (
158
+ <Select
159
+ value={values[filter.field] !== undefined ? String(values[filter.field]) : ''}
160
+ onValueChange={(value) => {
161
+ const option = filter.options?.find(opt => String(opt.value) === value);
162
+ updateValue(filter.field, option ? option.value : value);
163
+ }}
164
+ >
165
+ <SelectTrigger>
166
+ <SelectValue placeholder={placeholder} />
167
+ </SelectTrigger>
168
+ <SelectContent>
169
+ {filter.options?.map(option => (
170
+ <SelectItem key={String(option.value)} value={String(option.value)}>
171
+ {option.label}
172
+ </SelectItem>
173
+ ))}
174
+ </SelectContent>
175
+ </Select>
176
+ );
177
+ case 'date':
178
+ return (
179
+ <Input
180
+ type="date"
181
+ value={values[filter.field] ?? ''}
182
+ onChange={(event) => updateValue(filter.field, event.target.value)}
183
+ />
184
+ );
185
+ case 'date-range': {
186
+ const range = getDateRangeValue(values[filter.field]);
187
+ return (
188
+ <div className="flex items-center gap-2">
189
+ <Input
190
+ type="date"
191
+ value={range.start ?? ''}
192
+ onChange={(event) => {
193
+ updateValue(filter.field, { ...range, start: event.target.value });
194
+ }}
195
+ />
196
+ <Input
197
+ type="date"
198
+ value={range.end ?? ''}
199
+ onChange={(event) => {
200
+ updateValue(filter.field, { ...range, end: event.target.value });
201
+ }}
202
+ />
203
+ </div>
204
+ );
205
+ }
206
+ case 'boolean':
207
+ return (
208
+ <div className="flex items-center gap-2">
209
+ <Checkbox
210
+ checked={Boolean(values[filter.field])}
211
+ onCheckedChange={(checked) => updateValue(filter.field, Boolean(checked))}
212
+ />
213
+ <span className="text-sm text-muted-foreground">Enabled</span>
214
+ </div>
215
+ );
216
+ case 'text':
217
+ default:
218
+ return (
219
+ <Input
220
+ value={values[filter.field] ?? ''}
221
+ placeholder={placeholder}
222
+ onChange={(event) => updateValue(filter.field, event.target.value)}
223
+ />
224
+ );
225
+ }
226
+ };
227
+
228
+ const form = (
229
+ <div className="space-y-4">
230
+ <div className="grid gap-4 sm:grid-cols-2">
231
+ {schema.filters.map(filter => (
232
+ <div key={filter.field} className="space-y-2">
233
+ <Label className="text-xs text-muted-foreground">{filter.label || filter.field}</Label>
234
+ {renderInput(filter)}
235
+ </div>
236
+ ))}
237
+ </div>
238
+
239
+ {(schema.showApply || schema.showClear) && (
240
+ <div className="flex items-center justify-end gap-2 border-t pt-3">
241
+ {schema.showClear && (
242
+ <Button type="button" variant="ghost" size="sm" onClick={clearValues}>
243
+ Clear
244
+ </Button>
245
+ )}
246
+ {schema.showApply && (
247
+ <Button type="button" size="sm" onClick={applyValues}>
248
+ Apply
249
+ </Button>
250
+ )}
251
+ </div>
252
+ )}
253
+ </div>
254
+ );
255
+
256
+ const layout = schema.layout || 'inline';
257
+
258
+ if (layout === 'popover') {
259
+ return (
260
+ <div className={cn(filterContainerVariants({ layout }), className)}>
261
+ <Popover open={open} onOpenChange={setOpen}>
262
+ <PopoverTrigger asChild>
263
+ <Button type="button" variant={activeCount > 0 ? 'secondary' : 'outline'} size="sm" className="gap-2">
264
+ <SlidersHorizontal className="h-4 w-4" />
265
+ Filters
266
+ {activeCount > 0 && (
267
+ <span className="inline-flex h-5 min-w-[20px] items-center justify-center rounded-full bg-primary/10 px-1 text-xs font-medium text-primary">
268
+ {activeCount}
269
+ </span>
270
+ )}
271
+ </Button>
272
+ </PopoverTrigger>
273
+ <PopoverContent align="start" className="w-[520px] p-4">
274
+ {form}
275
+ </PopoverContent>
276
+ </Popover>
277
+ </div>
278
+ );
279
+ }
280
+
281
+ if (layout === 'drawer') {
282
+ return (
283
+ <div className={cn(filterContainerVariants({ layout }), className)}>
284
+ <Button type="button" variant={activeCount > 0 ? 'secondary' : 'outline'} size="sm" className="gap-2" onClick={() => setOpen(true)}>
285
+ <SlidersHorizontal className="h-4 w-4" />
286
+ Filters
287
+ {activeCount > 0 && (
288
+ <span className="inline-flex h-5 min-w-[20px] items-center justify-center rounded-full bg-primary/10 px-1 text-xs font-medium text-primary">
289
+ {activeCount}
290
+ </span>
291
+ )}
292
+ </Button>
293
+ <Drawer open={open} onOpenChange={setOpen}>
294
+ <DrawerContent>
295
+ <DrawerHeader>
296
+ <DrawerTitle>Filters</DrawerTitle>
297
+ <DrawerDescription>Refine the data with advanced filters.</DrawerDescription>
298
+ </DrawerHeader>
299
+ <div className="px-4 pb-6">{form}</div>
300
+ </DrawerContent>
301
+ </Drawer>
302
+ </div>
303
+ );
304
+ }
305
+
306
+ return (
307
+ <div className={cn(filterContainerVariants({ layout }), className)}>
308
+ {form}
309
+ {!schema.showApply && schema.showClear && (
310
+ <Button type="button" variant="ghost" size="sm" className="gap-2" onClick={clearValues}>
311
+ <X className="h-3.5 w-3.5" />
312
+ Clear filters
313
+ </Button>
314
+ )}
315
+ </div>
316
+ );
317
+ };