@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.
- package/.turbo/turbo-build.log +184 -6
- package/CHANGELOG.md +16 -0
- package/README.md +58 -0
- package/dist/index.js +1168 -349
- package/dist/index.umd.cjs +2 -2
- package/dist/plugin-view/src/FilterUI.d.ts +16 -0
- package/dist/plugin-view/src/ObjectView.d.ts +85 -5
- package/dist/plugin-view/src/SortUI.d.ts +16 -0
- package/dist/plugin-view/src/ViewSwitcher.d.ts +16 -0
- package/dist/plugin-view/src/__tests__/FilterUI.test.d.ts +8 -0
- package/dist/plugin-view/src/__tests__/ObjectView.test.d.ts +8 -0
- package/dist/plugin-view/src/__tests__/SortUI.test.d.ts +8 -0
- package/dist/plugin-view/src/__tests__/registration.test.d.ts +8 -0
- package/dist/plugin-view/src/index.d.ts +7 -1
- package/package.json +8 -7
- package/src/FilterUI.tsx +317 -0
- package/src/ObjectView.tsx +668 -148
- package/src/SortUI.tsx +210 -0
- package/src/ViewSwitcher.tsx +311 -0
- package/src/__tests__/FilterUI.test.tsx +544 -0
- package/src/__tests__/ObjectView.test.tsx +375 -0
- package/src/__tests__/SortUI.test.tsx +380 -0
- package/src/__tests__/registration.test.tsx +32 -0
- package/src/index.tsx +147 -5
- package/vitest.config.ts +12 -0
- package/vitest.setup.ts +1 -0
package/src/FilterUI.tsx
ADDED
|
@@ -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
|
+
};
|