@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/src/SortUI.tsx ADDED
@@ -0,0 +1,210 @@
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
+ Select,
14
+ SelectContent,
15
+ SelectItem,
16
+ SelectTrigger,
17
+ SelectValue,
18
+ SortBuilder,
19
+ } from '@object-ui/components';
20
+ import { cva } from 'class-variance-authority';
21
+ import { ArrowDown, ArrowUp } from 'lucide-react';
22
+ import type { SortItem } from '@object-ui/components';
23
+ import type { SortUISchema } from '@object-ui/types';
24
+
25
+ export type SortUIProps = {
26
+ schema: SortUISchema;
27
+ className?: string;
28
+ onChange?: (sort: SortUISchema['sort']) => void;
29
+ [key: string]: any;
30
+ };
31
+
32
+ type SortEntry = {
33
+ field: string;
34
+ direction: 'asc' | 'desc';
35
+ };
36
+
37
+ const sortContainerVariants = cva('', {
38
+ variants: {
39
+ variant: {
40
+ buttons: 'flex flex-wrap gap-2',
41
+ dropdown: 'flex flex-wrap items-center gap-3',
42
+ builder: 'space-y-3',
43
+ },
44
+ },
45
+ defaultVariants: {
46
+ variant: 'dropdown',
47
+ },
48
+ });
49
+
50
+ const toSortEntries = (sort?: SortUISchema['sort']): SortEntry[] => {
51
+ if (!sort) return [];
52
+ return sort.map(item => ({
53
+ field: item.field,
54
+ direction: item.direction,
55
+ }));
56
+ };
57
+
58
+ const toSortItems = (sort: SortEntry[]): SortItem[] => {
59
+ return sort.map(item => ({
60
+ id: `${item.field}-${item.direction}`,
61
+ field: item.field,
62
+ order: item.direction,
63
+ }));
64
+ };
65
+
66
+ const toSortEntriesFromItems = (items: SortItem[]): SortEntry[] => {
67
+ return items
68
+ .filter(item => item.field)
69
+ .map(item => ({
70
+ field: item.field,
71
+ direction: item.order,
72
+ }));
73
+ };
74
+
75
+ export const SortUI: React.FC<SortUIProps> = ({
76
+ schema,
77
+ className,
78
+ onChange,
79
+ }) => {
80
+ const [sortState, setSortState] = React.useState<SortEntry[]>(() => toSortEntries(schema.sort));
81
+ const [builderItems, setBuilderItems] = React.useState<SortItem[]>(() => toSortItems(toSortEntries(schema.sort)));
82
+
83
+ React.useEffect(() => {
84
+ const entries = toSortEntries(schema.sort);
85
+ setSortState(entries);
86
+ setBuilderItems(toSortItems(entries));
87
+ }, [schema.sort]);
88
+
89
+ const notifyChange = React.useCallback((nextSort: SortEntry[]) => {
90
+ setSortState(nextSort);
91
+ onChange?.(nextSort);
92
+
93
+ if (schema.onChange && typeof window !== 'undefined') {
94
+ window.dispatchEvent(
95
+ new CustomEvent(schema.onChange, {
96
+ detail: { sort: nextSort },
97
+ })
98
+ );
99
+ }
100
+ }, [onChange, schema.onChange]);
101
+
102
+ const handleToggle = React.useCallback((field: string) => {
103
+ const existing = sortState.find(item => item.field === field);
104
+ const multiple = Boolean(schema.multiple);
105
+
106
+ if (!existing) {
107
+ const next = multiple
108
+ ? [...sortState, { field, direction: 'asc' as const }]
109
+ : [{ field, direction: 'asc' as const }];
110
+ notifyChange(next);
111
+ return;
112
+ }
113
+
114
+ if (existing.direction === 'asc') {
115
+ const next = sortState.map(item =>
116
+ item.field === field ? { ...item, direction: 'desc' as const } : item
117
+ );
118
+ notifyChange(next);
119
+ return;
120
+ }
121
+
122
+ const next = sortState.filter(item => item.field !== field);
123
+ notifyChange(next);
124
+ }, [notifyChange, schema.multiple, sortState]);
125
+
126
+ const variant = schema.variant || 'dropdown';
127
+
128
+ if (variant === 'buttons') {
129
+ return (
130
+ <div className={cn(sortContainerVariants({ variant: 'buttons' }), className)}>
131
+ {schema.fields.map(field => {
132
+ const current = sortState.find(item => item.field === field.field);
133
+ const Icon = current?.direction === 'asc' ? ArrowUp : ArrowDown;
134
+ return (
135
+ <Button
136
+ key={field.field}
137
+ type="button"
138
+ variant={current ? 'secondary' : 'outline'}
139
+ size="sm"
140
+ onClick={() => handleToggle(field.field)}
141
+ className="gap-2"
142
+ >
143
+ <span>{field.label || field.field}</span>
144
+ {current && <Icon className="h-3.5 w-3.5" />}
145
+ </Button>
146
+ );
147
+ })}
148
+ </div>
149
+ );
150
+ }
151
+
152
+ if (schema.multiple) {
153
+ return (
154
+ <div className={cn(sortContainerVariants({ variant: 'builder' }), className)}>
155
+ <SortBuilder
156
+ fields={schema.fields.map(field => ({ value: field.field, label: field.label || field.field }))}
157
+ value={builderItems}
158
+ onChange={(items) => {
159
+ setBuilderItems(items);
160
+ notifyChange(toSortEntriesFromItems(items));
161
+ }}
162
+ />
163
+ </div>
164
+ );
165
+ }
166
+
167
+ const singleSort = sortState[0];
168
+
169
+ return (
170
+ <div className={cn(sortContainerVariants({ variant: 'dropdown' }), className)}>
171
+ <Select
172
+ value={singleSort?.field || ''}
173
+ onValueChange={(value) => {
174
+ if (!value) {
175
+ notifyChange([]);
176
+ return;
177
+ }
178
+ notifyChange([{ field: value, direction: singleSort?.direction || 'asc' }]);
179
+ }}
180
+ >
181
+ <SelectTrigger className="w-56">
182
+ <SelectValue placeholder="Select field" />
183
+ </SelectTrigger>
184
+ <SelectContent>
185
+ {schema.fields.map(field => (
186
+ <SelectItem key={field.field} value={field.field}>
187
+ {field.label || field.field}
188
+ </SelectItem>
189
+ ))}
190
+ </SelectContent>
191
+ </Select>
192
+
193
+ <Select
194
+ value={singleSort?.direction || 'asc'}
195
+ onValueChange={(value) => {
196
+ if (!singleSort?.field) return;
197
+ notifyChange([{ field: singleSort.field, direction: value as 'asc' | 'desc' }]);
198
+ }}
199
+ >
200
+ <SelectTrigger className="w-36">
201
+ <SelectValue />
202
+ </SelectTrigger>
203
+ <SelectContent>
204
+ <SelectItem value="asc">Ascending</SelectItem>
205
+ <SelectItem value="desc">Descending</SelectItem>
206
+ </SelectContent>
207
+ </Select>
208
+ </div>
209
+ );
210
+ };
@@ -0,0 +1,311 @@
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
+ Tabs,
14
+ TabsList,
15
+ TabsTrigger,
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from '@object-ui/components';
22
+ import { cva } from 'class-variance-authority';
23
+ import { SchemaRenderer } from '@object-ui/react';
24
+ import type { ViewSwitcherSchema, ViewType } from '@object-ui/types';
25
+ import {
26
+ Activity,
27
+ Calendar,
28
+ FileText,
29
+ Grid,
30
+ LayoutGrid,
31
+ List,
32
+ Map,
33
+ icons,
34
+ type LucideIcon,
35
+ } from 'lucide-react';
36
+
37
+ type ViewSwitcherItem = ViewSwitcherSchema['views'][number];
38
+
39
+ export type ViewSwitcherProps = {
40
+ schema: ViewSwitcherSchema;
41
+ className?: string;
42
+ onViewChange?: (view: ViewType) => void;
43
+ [key: string]: any;
44
+ };
45
+
46
+ const DEFAULT_VIEW_LABELS: Record<ViewType, string> = {
47
+ list: 'List',
48
+ detail: 'Detail',
49
+ grid: 'Grid',
50
+ kanban: 'Kanban',
51
+ calendar: 'Calendar',
52
+ timeline: 'Timeline',
53
+ map: 'Map',
54
+ };
55
+
56
+ const DEFAULT_VIEW_ICONS: Record<ViewType, LucideIcon> = {
57
+ list: List,
58
+ detail: FileText,
59
+ grid: Grid,
60
+ kanban: LayoutGrid,
61
+ calendar: Calendar,
62
+ timeline: Activity,
63
+ map: Map,
64
+ };
65
+
66
+ const viewSwitcherLayout = cva('flex gap-4', {
67
+ variants: {
68
+ position: {
69
+ top: 'flex-col',
70
+ bottom: 'flex-col-reverse',
71
+ left: 'flex-row',
72
+ right: 'flex-row-reverse',
73
+ },
74
+ },
75
+ defaultVariants: {
76
+ position: 'top',
77
+ },
78
+ });
79
+
80
+ const viewSwitcherWidth = cva('w-full', {
81
+ variants: {
82
+ orientation: {
83
+ horizontal: 'w-full',
84
+ vertical: 'w-48',
85
+ },
86
+ },
87
+ defaultVariants: {
88
+ orientation: 'horizontal',
89
+ },
90
+ });
91
+
92
+ const viewSwitcherList = cva('flex gap-2', {
93
+ variants: {
94
+ orientation: {
95
+ horizontal: 'flex-row flex-wrap',
96
+ vertical: 'flex-col',
97
+ },
98
+ },
99
+ defaultVariants: {
100
+ orientation: 'horizontal',
101
+ },
102
+ });
103
+
104
+ const viewSwitcherTabsList = cva('', {
105
+ variants: {
106
+ orientation: {
107
+ horizontal: '',
108
+ vertical: 'flex h-auto flex-col items-stretch',
109
+ },
110
+ },
111
+ defaultVariants: {
112
+ orientation: 'horizontal',
113
+ },
114
+ });
115
+
116
+ function toPascalCase(str: string): string {
117
+ return str
118
+ .split('-')
119
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
120
+ .join('');
121
+ }
122
+
123
+ const iconNameMap: Record<string, string> = {
124
+ Home: 'House',
125
+ };
126
+
127
+ function resolveIcon(name?: string): LucideIcon | null {
128
+ if (!name) return null;
129
+ const iconName = toPascalCase(name);
130
+ const mapped = iconNameMap[iconName] || iconName;
131
+ return (icons as any)[mapped] || null;
132
+ }
133
+
134
+ function getViewLabel(view: ViewSwitcherItem): string {
135
+ if (view.label) return view.label;
136
+ return DEFAULT_VIEW_LABELS[view.type] || view.type;
137
+ }
138
+
139
+ function getViewIcon(view: ViewSwitcherItem): LucideIcon | null {
140
+ if (view.icon) {
141
+ return resolveIcon(view.icon);
142
+ }
143
+ return DEFAULT_VIEW_ICONS[view.type] || null;
144
+ }
145
+
146
+ function getInitialView(schema: ViewSwitcherSchema): ViewType | undefined {
147
+ if (schema.activeView) return schema.activeView;
148
+ if (schema.defaultView) return schema.defaultView;
149
+ return schema.views?.[0]?.type;
150
+ }
151
+
152
+ export const ViewSwitcher: React.FC<ViewSwitcherProps> = ({
153
+ schema,
154
+ className,
155
+ onViewChange,
156
+ ...props
157
+ }) => {
158
+ const storageKey = React.useMemo(() => {
159
+ if (schema.storageKey) return schema.storageKey;
160
+ const idPart = schema.id ? `-${schema.id}` : '';
161
+ return `view-switcher${idPart}`;
162
+ }, [schema.id, schema.storageKey]);
163
+
164
+ const [activeView, setActiveView] = React.useState<ViewType | undefined>(() => getInitialView(schema));
165
+
166
+ React.useEffect(() => {
167
+ if (schema.activeView) {
168
+ setActiveView(schema.activeView);
169
+ return;
170
+ }
171
+
172
+ if (!schema.persistPreference) return;
173
+
174
+ try {
175
+ const saved = localStorage.getItem(storageKey);
176
+ if (saved) {
177
+ const view = schema.views.find(v => v.type === saved)?.type as ViewType | undefined;
178
+ if (view) {
179
+ setActiveView(view);
180
+ }
181
+ }
182
+ } catch {
183
+ // Ignore storage errors
184
+ }
185
+ }, [schema.activeView, schema.persistPreference, schema.views, storageKey]);
186
+
187
+ React.useEffect(() => {
188
+ if (!schema.persistPreference || !activeView || schema.activeView) return;
189
+ try {
190
+ localStorage.setItem(storageKey, activeView);
191
+ } catch {
192
+ // Ignore storage errors
193
+ }
194
+ }, [activeView, schema.activeView, schema.persistPreference, storageKey]);
195
+
196
+ const notifyChange = React.useCallback((nextView: ViewType) => {
197
+ onViewChange?.(nextView);
198
+
199
+ if (schema.onViewChange && typeof window !== 'undefined') {
200
+ window.dispatchEvent(
201
+ new CustomEvent(schema.onViewChange, {
202
+ detail: { view: nextView },
203
+ })
204
+ );
205
+ }
206
+ }, [onViewChange, schema.onViewChange]);
207
+
208
+ const handleViewChange = React.useCallback((nextView: ViewType) => {
209
+ setActiveView(nextView);
210
+ notifyChange(nextView);
211
+ }, [notifyChange]);
212
+
213
+ const currentView = activeView || schema.views?.[0]?.type;
214
+ const currentViewValue = currentView || '';
215
+ const currentViewConfig = schema.views.find(v => v.type === currentView) || schema.views?.[0];
216
+
217
+ const variant = schema.variant || 'tabs';
218
+ const position = schema.position || 'top';
219
+ const isVertical = position === 'left' || position === 'right';
220
+ const orientation = isVertical ? 'vertical' : 'horizontal';
221
+
222
+ const switcher = (
223
+ <div className={cn(viewSwitcherWidth({ orientation }))}>
224
+ {variant === 'dropdown' && (
225
+ <Select value={currentViewValue} onValueChange={(value) => handleViewChange(value as ViewType)}>
226
+ <SelectTrigger className={cn('w-full', isVertical ? 'h-10' : 'h-9')}>
227
+ <SelectValue placeholder="Select view" />
228
+ </SelectTrigger>
229
+ <SelectContent>
230
+ {schema.views.map((view, index) => (
231
+ <SelectItem key={`${view.type}-${index}`} value={view.type}>
232
+ {getViewLabel(view)}
233
+ </SelectItem>
234
+ ))}
235
+ </SelectContent>
236
+ </Select>
237
+ )}
238
+
239
+ {variant === 'buttons' && (
240
+ <div className={cn(viewSwitcherList({ orientation }))}>
241
+ {schema.views.map((view, index) => {
242
+ const isActive = view.type === currentView;
243
+ const Icon = getViewIcon(view);
244
+
245
+ return (
246
+ <Button
247
+ key={`${view.type}-${index}`}
248
+ type="button"
249
+ size="sm"
250
+ variant={isActive ? 'secondary' : 'ghost'}
251
+ className={cn('justify-start gap-2', isVertical ? 'w-full' : '')}
252
+ onClick={() => handleViewChange(view.type)}
253
+ >
254
+ {Icon ? <Icon className="h-4 w-4" /> : null}
255
+ <span>{getViewLabel(view)}</span>
256
+ </Button>
257
+ );
258
+ })}
259
+ </div>
260
+ )}
261
+
262
+ {variant === 'tabs' && (
263
+ <Tabs value={currentViewValue} onValueChange={(value) => handleViewChange(value as ViewType)}>
264
+ <TabsList className={cn(viewSwitcherTabsList({ orientation }))}>
265
+ {schema.views.map((view, index) => {
266
+ const Icon = getViewIcon(view);
267
+ return (
268
+ <TabsTrigger
269
+ key={`${view.type}-${index}`}
270
+ value={view.type}
271
+ className={cn('gap-2', isVertical ? 'justify-start' : '')}
272
+ >
273
+ {Icon ? <Icon className="h-4 w-4" /> : null}
274
+ <span>{getViewLabel(view)}</span>
275
+ </TabsTrigger>
276
+ );
277
+ })}
278
+ </TabsList>
279
+ </Tabs>
280
+ )}
281
+ </div>
282
+ );
283
+
284
+ const viewContent = (() => {
285
+ if (!currentViewConfig?.schema) return null;
286
+
287
+ if (Array.isArray(currentViewConfig.schema)) {
288
+ return (
289
+ <div className="space-y-4">
290
+ {currentViewConfig.schema.map((node, index) => (
291
+ <SchemaRenderer key={`${currentViewConfig.type}-${index}`} schema={node} {...props} />
292
+ ))}
293
+ </div>
294
+ );
295
+ }
296
+
297
+ return <SchemaRenderer schema={currentViewConfig.schema} {...props} />;
298
+ })();
299
+
300
+ return (
301
+ <div
302
+ className={cn(
303
+ viewSwitcherLayout({ position }),
304
+ className
305
+ )}
306
+ >
307
+ <div className={cn('shrink-0', isVertical ? 'flex flex-col' : 'flex')}>{switcher}</div>
308
+ <div className="flex-1 min-w-0">{viewContent}</div>
309
+ </div>
310
+ );
311
+ };