@object-ui/plugin-list 0.5.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 +18 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +53502 -0
- package/dist/index.umd.cjs +95 -0
- package/dist/plugin-list.css +1 -0
- package/dist/src/ListView.d.ts +21 -0
- package/dist/src/ListView.d.ts.map +1 -0
- package/dist/src/ObjectGallery.d.ts +2 -0
- package/dist/src/ObjectGallery.d.ts.map +1 -0
- package/dist/src/ViewSwitcher.d.ts +17 -0
- package/dist/src/ViewSwitcher.d.ts.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -0
- package/package.json +53 -0
- package/src/ListView.tsx +503 -0
- package/src/ObjectGallery.tsx +111 -0
- package/src/ViewSwitcher.tsx +92 -0
- package/src/__tests__/ListView.test.tsx +215 -0
- package/src/__tests__/ListViewPersistence.test.tsx +126 -0
- package/src/index.tsx +47 -0
- package/src/registration.test.tsx +9 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +56 -0
- package/vitest.config.ts +13 -0
- package/vitest.setup.ts +1 -0
package/src/ListView.tsx
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
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, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder } from '@object-ui/components';
|
|
11
|
+
import type { SortItem } from '@object-ui/components';
|
|
12
|
+
import { Search, SlidersHorizontal, ArrowUpDown, X } from 'lucide-react';
|
|
13
|
+
import type { FilterGroup } from '@object-ui/components';
|
|
14
|
+
import { ViewSwitcher, ViewType } from './ViewSwitcher';
|
|
15
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
16
|
+
import type { ListViewSchema } from '@object-ui/types';
|
|
17
|
+
|
|
18
|
+
export interface ListViewProps {
|
|
19
|
+
schema: ListViewSchema;
|
|
20
|
+
className?: string;
|
|
21
|
+
onViewChange?: (view: ViewType) => void;
|
|
22
|
+
onFilterChange?: (filters: any) => void;
|
|
23
|
+
onSortChange?: (sort: any) => void;
|
|
24
|
+
onSearchChange?: (search: string) => void;
|
|
25
|
+
[key: string]: any;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Helper to convert FilterBuilder group to ObjectStack AST
|
|
29
|
+
function mapOperator(op: string) {
|
|
30
|
+
switch (op) {
|
|
31
|
+
case 'equals': return '=';
|
|
32
|
+
case 'notEquals': return '!=';
|
|
33
|
+
case 'contains': return 'contains';
|
|
34
|
+
case 'notContains': return 'notcontains';
|
|
35
|
+
case 'greaterThan': return '>';
|
|
36
|
+
case 'greaterOrEqual': return '>=';
|
|
37
|
+
case 'lessThan': return '<';
|
|
38
|
+
case 'lessOrEqual': return '<=';
|
|
39
|
+
case 'in': return 'in';
|
|
40
|
+
case 'notIn': return 'not in';
|
|
41
|
+
case 'before': return '<';
|
|
42
|
+
case 'after': return '>';
|
|
43
|
+
default: return '=';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function convertFilterGroupToAST(group: FilterGroup): any[] {
|
|
48
|
+
if (!group || !group.conditions || group.conditions.length === 0) return [];
|
|
49
|
+
|
|
50
|
+
const conditions = group.conditions.map(c => {
|
|
51
|
+
if (c.operator === 'isEmpty') return [c.field, '=', null];
|
|
52
|
+
if (c.operator === 'isNotEmpty') return [c.field, '!=', null];
|
|
53
|
+
return [c.field, mapOperator(c.operator), c.value];
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (conditions.length === 1) return conditions[0];
|
|
57
|
+
|
|
58
|
+
return [group.logic, ...conditions];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const ListView: React.FC<ListViewProps> = ({
|
|
62
|
+
schema: propSchema,
|
|
63
|
+
className,
|
|
64
|
+
onViewChange,
|
|
65
|
+
onFilterChange,
|
|
66
|
+
onSortChange,
|
|
67
|
+
onSearchChange,
|
|
68
|
+
...props
|
|
69
|
+
}) => {
|
|
70
|
+
// Kernel level default: Ensure viewType is always defined (default to 'grid')
|
|
71
|
+
const schema = React.useMemo(() => ({
|
|
72
|
+
...propSchema,
|
|
73
|
+
viewType: propSchema.viewType || 'grid'
|
|
74
|
+
}), [propSchema]);
|
|
75
|
+
|
|
76
|
+
const [currentView, setCurrentView] = React.useState<ViewType>(
|
|
77
|
+
(schema.viewType as ViewType)
|
|
78
|
+
);
|
|
79
|
+
const [searchTerm, setSearchTerm] = React.useState('');
|
|
80
|
+
|
|
81
|
+
// Sort State
|
|
82
|
+
const [showSort, setShowSort] = React.useState(false);
|
|
83
|
+
const [currentSort, setCurrentSort] = React.useState<SortItem[]>(() => {
|
|
84
|
+
if (schema.sort && schema.sort.length > 0) {
|
|
85
|
+
return schema.sort.map(s => ({
|
|
86
|
+
id: crypto.randomUUID(),
|
|
87
|
+
field: s.field,
|
|
88
|
+
order: (s.order as 'asc' | 'desc') || 'asc'
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
return [];
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const [showFilters, setShowFilters] = React.useState(false);
|
|
95
|
+
|
|
96
|
+
const [currentFilters, setCurrentFilters] = React.useState<FilterGroup>({
|
|
97
|
+
id: 'root',
|
|
98
|
+
logic: 'and',
|
|
99
|
+
conditions: []
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Data State
|
|
103
|
+
const dataSource = props.dataSource;
|
|
104
|
+
const [data, setData] = React.useState<any[]>([]);
|
|
105
|
+
const [loading, setLoading] = React.useState(false);
|
|
106
|
+
const [objectDef, setObjectDef] = React.useState<any>(null);
|
|
107
|
+
|
|
108
|
+
const storageKey = React.useMemo(() => {
|
|
109
|
+
return schema.id
|
|
110
|
+
? `listview-${schema.objectName}-${schema.id}-view`
|
|
111
|
+
: `listview-${schema.objectName}-view`;
|
|
112
|
+
}, [schema.objectName, schema.id]);
|
|
113
|
+
|
|
114
|
+
// Fetch object definition
|
|
115
|
+
React.useEffect(() => {
|
|
116
|
+
let isMounted = true;
|
|
117
|
+
const fetchObjectDef = async () => {
|
|
118
|
+
if (!dataSource || !schema.objectName) return;
|
|
119
|
+
try {
|
|
120
|
+
const def = await dataSource.getObjectSchema(schema.objectName);
|
|
121
|
+
if (isMounted) {
|
|
122
|
+
setObjectDef(def);
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.warn("Failed to fetch object schema for ListView:", err);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
fetchObjectDef();
|
|
129
|
+
return () => { isMounted = false; };
|
|
130
|
+
}, [schema.objectName, dataSource]);
|
|
131
|
+
|
|
132
|
+
// Fetch data effect
|
|
133
|
+
React.useEffect(() => {
|
|
134
|
+
let isMounted = true;
|
|
135
|
+
|
|
136
|
+
const fetchData = async () => {
|
|
137
|
+
if (!dataSource || !schema.objectName) return;
|
|
138
|
+
|
|
139
|
+
setLoading(true);
|
|
140
|
+
try {
|
|
141
|
+
// Construct filter
|
|
142
|
+
let finalFilter: any = [];
|
|
143
|
+
const baseFilter = schema.filters || [];
|
|
144
|
+
const userFilter = convertFilterGroupToAST(currentFilters);
|
|
145
|
+
|
|
146
|
+
// Merge base filters and user filters
|
|
147
|
+
if (baseFilter.length > 0 && userFilter.length > 0) {
|
|
148
|
+
finalFilter = ['and', baseFilter, userFilter];
|
|
149
|
+
} else if (userFilter.length > 0) {
|
|
150
|
+
finalFilter = userFilter;
|
|
151
|
+
} else {
|
|
152
|
+
finalFilter = baseFilter;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Convert sort to query format
|
|
156
|
+
// Use array format to ensure order is preserved (Object keys are not guaranteed ordered)
|
|
157
|
+
const sort: any = currentSort.length > 0
|
|
158
|
+
? currentSort
|
|
159
|
+
.filter(item => item.field) // Ensure field is selected
|
|
160
|
+
.map(item => ({ field: item.field, order: item.order }))
|
|
161
|
+
: undefined;
|
|
162
|
+
|
|
163
|
+
const results = await dataSource.find(schema.objectName, {
|
|
164
|
+
$filter: finalFilter,
|
|
165
|
+
$orderby: sort,
|
|
166
|
+
$top: 100 // Default pagination limit
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
let items: any[] = [];
|
|
170
|
+
if (Array.isArray(results)) {
|
|
171
|
+
items = results;
|
|
172
|
+
} else if (results && typeof results === 'object') {
|
|
173
|
+
if (Array.isArray((results as any).data)) {
|
|
174
|
+
items = (results as any).data;
|
|
175
|
+
} else if (Array.isArray((results as any).value)) {
|
|
176
|
+
items = (results as any).value;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (isMounted) {
|
|
181
|
+
setData(items);
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
console.error("ListView data fetch error:", err);
|
|
185
|
+
} finally {
|
|
186
|
+
if (isMounted) setLoading(false);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
fetchData();
|
|
191
|
+
|
|
192
|
+
return () => { isMounted = false; };
|
|
193
|
+
}, [schema.objectName, dataSource, schema.filters, currentSort, currentFilters]); // Re-fetch on filter/sort change
|
|
194
|
+
|
|
195
|
+
// Available view types based on schema configuration
|
|
196
|
+
const availableViews = React.useMemo(() => {
|
|
197
|
+
const views: ViewType[] = ['grid'];
|
|
198
|
+
|
|
199
|
+
// Check for Kanban capabilities
|
|
200
|
+
if (schema.options?.kanban?.groupField) {
|
|
201
|
+
views.push('kanban');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Check for Gallery capabilities
|
|
205
|
+
if (schema.options?.gallery?.imageField) {
|
|
206
|
+
views.push('gallery');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Check for Calendar capabilities
|
|
210
|
+
if (schema.options?.calendar?.startDateField) {
|
|
211
|
+
views.push('calendar');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Check for Timeline capabilities
|
|
215
|
+
if (schema.options?.timeline?.dateField || schema.options?.calendar?.startDateField) {
|
|
216
|
+
views.push('timeline');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Check for Gantt capabilities
|
|
220
|
+
if (schema.options?.gantt?.startDateField) {
|
|
221
|
+
views.push('gantt');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check for Map capabilities
|
|
225
|
+
if (schema.options?.map?.locationField || (schema.options?.map?.latitudeField && schema.options?.map?.longitudeField)) {
|
|
226
|
+
views.push('map');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Always allow switching back to the viewType defined in schema if it's one of the supported types
|
|
230
|
+
// This ensures that if a view is configured as "map", the map button is shown even if we missed the options check above
|
|
231
|
+
if (schema.viewType && !views.includes(schema.viewType as ViewType) &&
|
|
232
|
+
['grid', 'kanban', 'calendar', 'timeline', 'gantt', 'map', 'gallery'].includes(schema.viewType)) {
|
|
233
|
+
views.push(schema.viewType as ViewType);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return views;
|
|
237
|
+
}, [schema.options, schema.viewType]);
|
|
238
|
+
|
|
239
|
+
// Load saved view preference
|
|
240
|
+
React.useEffect(() => {
|
|
241
|
+
try {
|
|
242
|
+
const savedView = localStorage.getItem(storageKey);
|
|
243
|
+
if (savedView && ['grid', 'kanban', 'calendar', 'timeline', 'gantt', 'map', 'gallery'].includes(savedView) && availableViews.includes(savedView as ViewType)) {
|
|
244
|
+
setCurrentView(savedView as ViewType);
|
|
245
|
+
}
|
|
246
|
+
} catch (error) {
|
|
247
|
+
console.warn('Failed to load view preference from localStorage:', error);
|
|
248
|
+
}
|
|
249
|
+
}, [storageKey, availableViews]);
|
|
250
|
+
|
|
251
|
+
const handleViewChange = React.useCallback((view: ViewType) => {
|
|
252
|
+
setCurrentView(view);
|
|
253
|
+
try {
|
|
254
|
+
localStorage.setItem(storageKey, view);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.warn('Failed to save view preference to localStorage:', error);
|
|
257
|
+
}
|
|
258
|
+
onViewChange?.(view);
|
|
259
|
+
}, [storageKey, onViewChange]);
|
|
260
|
+
|
|
261
|
+
const handleSearchChange = React.useCallback((value: string) => {
|
|
262
|
+
setSearchTerm(value);
|
|
263
|
+
onSearchChange?.(value);
|
|
264
|
+
}, [onSearchChange]);
|
|
265
|
+
|
|
266
|
+
// Generate the appropriate view component schema
|
|
267
|
+
const viewComponentSchema = React.useMemo(() => {
|
|
268
|
+
const baseProps = {
|
|
269
|
+
objectName: schema.objectName,
|
|
270
|
+
fields: schema.fields,
|
|
271
|
+
filters: schema.filters,
|
|
272
|
+
sort: currentSort,
|
|
273
|
+
className: "h-full w-full",
|
|
274
|
+
// Disable internal controls that clash with ListView toolbar
|
|
275
|
+
showSearch: false,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
switch (currentView) {
|
|
279
|
+
case 'grid':
|
|
280
|
+
return {
|
|
281
|
+
type: 'object-grid',
|
|
282
|
+
...baseProps,
|
|
283
|
+
columns: schema.fields,
|
|
284
|
+
...(schema.options?.grid || {}),
|
|
285
|
+
};
|
|
286
|
+
case 'kanban':
|
|
287
|
+
return {
|
|
288
|
+
type: 'object-kanban',
|
|
289
|
+
...baseProps,
|
|
290
|
+
groupBy: schema.options?.kanban?.groupField || 'status',
|
|
291
|
+
groupField: schema.options?.kanban?.groupField || 'status',
|
|
292
|
+
titleField: schema.options?.kanban?.titleField || 'name',
|
|
293
|
+
cardFields: schema.fields || [],
|
|
294
|
+
...(schema.options?.kanban || {}),
|
|
295
|
+
};
|
|
296
|
+
case 'calendar':
|
|
297
|
+
return {
|
|
298
|
+
type: 'object-calendar',
|
|
299
|
+
...baseProps,
|
|
300
|
+
startDateField: schema.options?.calendar?.startDateField || 'start_date',
|
|
301
|
+
endDateField: schema.options?.calendar?.endDateField || 'end_date',
|
|
302
|
+
titleField: schema.options?.calendar?.titleField || 'name',
|
|
303
|
+
...(schema.options?.calendar || {}),
|
|
304
|
+
};
|
|
305
|
+
case 'gallery':
|
|
306
|
+
return {
|
|
307
|
+
type: 'object-gallery',
|
|
308
|
+
...baseProps,
|
|
309
|
+
imageField: schema.options?.gallery?.imageField,
|
|
310
|
+
titleField: schema.options?.gallery?.titleField || 'name',
|
|
311
|
+
subtitleField: schema.options?.gallery?.subtitleField,
|
|
312
|
+
...(schema.options?.gallery || {}),
|
|
313
|
+
};
|
|
314
|
+
case 'timeline':
|
|
315
|
+
return {
|
|
316
|
+
type: 'object-timeline',
|
|
317
|
+
...baseProps,
|
|
318
|
+
dateField: schema.options?.timeline?.dateField || 'created_at',
|
|
319
|
+
titleField: schema.options?.timeline?.titleField || 'name',
|
|
320
|
+
...(schema.options?.timeline || {}),
|
|
321
|
+
};
|
|
322
|
+
case 'gantt':
|
|
323
|
+
return {
|
|
324
|
+
type: 'object-gantt',
|
|
325
|
+
...baseProps,
|
|
326
|
+
startDateField: schema.options?.gantt?.startDateField || 'start_date',
|
|
327
|
+
endDateField: schema.options?.gantt?.endDateField || 'end_date',
|
|
328
|
+
progressField: schema.options?.gantt?.progressField || 'progress',
|
|
329
|
+
dependenciesField: schema.options?.gantt?.dependenciesField || 'dependencies',
|
|
330
|
+
...(schema.options?.gantt || {}),
|
|
331
|
+
};
|
|
332
|
+
case 'map':
|
|
333
|
+
return {
|
|
334
|
+
type: 'object-map',
|
|
335
|
+
...baseProps,
|
|
336
|
+
locationField: schema.options?.map?.locationField || 'location',
|
|
337
|
+
...(schema.options?.map || {}),
|
|
338
|
+
};
|
|
339
|
+
default:
|
|
340
|
+
return baseProps;
|
|
341
|
+
}
|
|
342
|
+
}, [currentView, schema, currentSort]);
|
|
343
|
+
|
|
344
|
+
const hasFilters = currentFilters.conditions && currentFilters.conditions.length > 0;
|
|
345
|
+
|
|
346
|
+
const filterFields = React.useMemo(() => {
|
|
347
|
+
if (!objectDef?.fields) {
|
|
348
|
+
// Fallback to schema fields if objectDef not loaded yet
|
|
349
|
+
return (schema.fields || []).map((f: any) => {
|
|
350
|
+
if (typeof f === 'string') return { value: f, label: f, type: 'text' };
|
|
351
|
+
return {
|
|
352
|
+
value: f.name || f.fieldName,
|
|
353
|
+
label: f.label || f.name,
|
|
354
|
+
type: f.type || 'text',
|
|
355
|
+
options: f.options
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return Object.entries(objectDef.fields).map(([key, field]: [string, any]) => ({
|
|
361
|
+
value: key,
|
|
362
|
+
label: field.label || key,
|
|
363
|
+
type: field.type || 'text',
|
|
364
|
+
options: field.options
|
|
365
|
+
}));
|
|
366
|
+
}, [objectDef, schema.fields]);
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<div className={cn('flex flex-col h-full bg-background', className)}>
|
|
370
|
+
{/* Airtable-style Toolbar */}
|
|
371
|
+
<div className="border-b px-4 py-2 flex items-center justify-between gap-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
372
|
+
<div className="flex items-center gap-2 flex-1 overflow-hidden">
|
|
373
|
+
{/* View Switcher on the Left */}
|
|
374
|
+
<div className="flex items-center pr-2 border-r mr-2">
|
|
375
|
+
<ViewSwitcher
|
|
376
|
+
currentView={currentView}
|
|
377
|
+
availableViews={availableViews}
|
|
378
|
+
onViewChange={handleViewChange}
|
|
379
|
+
/>
|
|
380
|
+
</div>
|
|
381
|
+
|
|
382
|
+
{/* Action Tools */}
|
|
383
|
+
<div className="flex items-center gap-1">
|
|
384
|
+
<Popover open={showFilters} onOpenChange={setShowFilters}>
|
|
385
|
+
<PopoverTrigger asChild>
|
|
386
|
+
<Button
|
|
387
|
+
variant={hasFilters ? "secondary" : "ghost"}
|
|
388
|
+
size="sm"
|
|
389
|
+
className={cn(
|
|
390
|
+
"h-8 px-2 lg:px-3 text-muted-foreground hover:text-primary",
|
|
391
|
+
hasFilters && "text-primary bg-secondary/50"
|
|
392
|
+
)}
|
|
393
|
+
>
|
|
394
|
+
<SlidersHorizontal className="h-4 w-4 mr-2" />
|
|
395
|
+
<span className="hidden lg:inline">Filter</span>
|
|
396
|
+
{hasFilters && (
|
|
397
|
+
<span className="ml-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary/10 text-[10px] font-medium text-primary">
|
|
398
|
+
{currentFilters.conditions?.length || 0}
|
|
399
|
+
</span>
|
|
400
|
+
)}
|
|
401
|
+
</Button>
|
|
402
|
+
</PopoverTrigger>
|
|
403
|
+
<PopoverContent align="start" className="w-[600px] p-4">
|
|
404
|
+
<div className="space-y-4">
|
|
405
|
+
<div className="flex items-center justify-between border-b pb-2">
|
|
406
|
+
<h4 className="font-medium text-sm">Filter Records</h4>
|
|
407
|
+
</div>
|
|
408
|
+
<FilterBuilder
|
|
409
|
+
fields={filterFields}
|
|
410
|
+
value={currentFilters}
|
|
411
|
+
onChange={(newFilters) => {
|
|
412
|
+
console.log('Filter Changed:', newFilters);
|
|
413
|
+
setCurrentFilters(newFilters);
|
|
414
|
+
// Convert FilterBuilder format to OData $filter string if needed
|
|
415
|
+
// For now we just update state and notify listener
|
|
416
|
+
// In a real app, this would likely build an OData string
|
|
417
|
+
if (onFilterChange) onFilterChange(newFilters);
|
|
418
|
+
}}
|
|
419
|
+
/>
|
|
420
|
+
</div>
|
|
421
|
+
</PopoverContent>
|
|
422
|
+
</Popover>
|
|
423
|
+
|
|
424
|
+
<Popover open={showSort} onOpenChange={setShowSort}>
|
|
425
|
+
<PopoverTrigger asChild>
|
|
426
|
+
<Button
|
|
427
|
+
variant={currentSort.length > 0 ? "secondary" : "ghost"}
|
|
428
|
+
size="sm"
|
|
429
|
+
className={cn(
|
|
430
|
+
"h-8 px-2 lg:px-3 text-muted-foreground hover:text-primary",
|
|
431
|
+
currentSort.length > 0 && "text-primary bg-secondary/50"
|
|
432
|
+
)}
|
|
433
|
+
>
|
|
434
|
+
<ArrowUpDown className="h-4 w-4 mr-2" />
|
|
435
|
+
<span className="hidden lg:inline">Sort</span>
|
|
436
|
+
{currentSort.length > 0 && (
|
|
437
|
+
<span className="ml-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary/10 text-[10px] font-medium text-primary">
|
|
438
|
+
{currentSort.length}
|
|
439
|
+
</span>
|
|
440
|
+
)}
|
|
441
|
+
</Button>
|
|
442
|
+
</PopoverTrigger>
|
|
443
|
+
<PopoverContent align="start" className="w-[600px] p-4">
|
|
444
|
+
<div className="space-y-4">
|
|
445
|
+
<div className="flex items-center justify-between border-b pb-2">
|
|
446
|
+
<h4 className="font-medium text-sm">Sort Records</h4>
|
|
447
|
+
</div>
|
|
448
|
+
<SortBuilder
|
|
449
|
+
fields={filterFields}
|
|
450
|
+
value={currentSort}
|
|
451
|
+
onChange={(newSort) => {
|
|
452
|
+
console.log('Sort Changed:', newSort);
|
|
453
|
+
setCurrentSort(newSort);
|
|
454
|
+
if (onSortChange) onSortChange(newSort);
|
|
455
|
+
}}
|
|
456
|
+
/>
|
|
457
|
+
</div>
|
|
458
|
+
</PopoverContent>
|
|
459
|
+
</Popover>
|
|
460
|
+
|
|
461
|
+
{/* Future: Group, Color, Height */}
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
{/* Right Actions: Search + New */}
|
|
466
|
+
<div className="flex items-center gap-2">
|
|
467
|
+
<div className="relative w-40 lg:w-64 transition-all">
|
|
468
|
+
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
469
|
+
<Input
|
|
470
|
+
placeholder="Find..."
|
|
471
|
+
value={searchTerm}
|
|
472
|
+
onChange={(e) => handleSearchChange(e.target.value)}
|
|
473
|
+
className="pl-8 h-8 text-sm bg-muted/50 border-transparent hover:bg-muted focus:bg-background focus:border-input transition-colors"
|
|
474
|
+
/>
|
|
475
|
+
{searchTerm && (
|
|
476
|
+
<Button
|
|
477
|
+
variant="ghost"
|
|
478
|
+
size="sm"
|
|
479
|
+
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 hover:bg-muted-foreground/20"
|
|
480
|
+
onClick={() => handleSearchChange('')}
|
|
481
|
+
>
|
|
482
|
+
<X className="h-3 w-3" />
|
|
483
|
+
</Button>
|
|
484
|
+
)}
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
{/* Filters Panel - Removed as it is now in Popover */}
|
|
491
|
+
|
|
492
|
+
{/* View Content */}
|
|
493
|
+
<div className="flex-1 min-h-0 bg-background relative overflow-hidden">
|
|
494
|
+
<SchemaRenderer
|
|
495
|
+
schema={viewComponentSchema}
|
|
496
|
+
{...props}
|
|
497
|
+
data={data}
|
|
498
|
+
loading={loading}
|
|
499
|
+
/>
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
503
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
|
|
2
|
+
import React, { useState, useEffect } from 'react';
|
|
3
|
+
import { useDataScope, useSchemaContext } from '@object-ui/react';
|
|
4
|
+
import { ComponentRegistry } from '@object-ui/core';
|
|
5
|
+
|
|
6
|
+
// Utility for class merging (assuming it's available in plugin context,
|
|
7
|
+
// usually provided by @object-ui/components or utils, but here I'll just use string concat if not imported)
|
|
8
|
+
// Actually @object-ui/components exports cn
|
|
9
|
+
import { cn } from '@object-ui/components';
|
|
10
|
+
|
|
11
|
+
export const ObjectGallery = (props: any) => {
|
|
12
|
+
const { schema } = props;
|
|
13
|
+
const context = useSchemaContext();
|
|
14
|
+
const dataSource = props.dataSource || context.dataSource;
|
|
15
|
+
const boundData = useDataScope(schema.bind);
|
|
16
|
+
|
|
17
|
+
const [fetchedData, setFetchedData] = useState<any[]>([]);
|
|
18
|
+
const [loading, setLoading] = useState(false);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let isMounted = true;
|
|
22
|
+
|
|
23
|
+
// Use data prop if available (from ListView)
|
|
24
|
+
if ((props.data && Array.isArray(props.data))) {
|
|
25
|
+
setFetchedData(props.data);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const fetchData = async () => {
|
|
30
|
+
if (!dataSource || !schema.objectName) return;
|
|
31
|
+
if (isMounted) setLoading(true);
|
|
32
|
+
try {
|
|
33
|
+
// Apply filtering?
|
|
34
|
+
const results = await dataSource.find(schema.objectName, {
|
|
35
|
+
$filter: schema.filter
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let data: any[] = [];
|
|
39
|
+
if (Array.isArray(results)) {
|
|
40
|
+
data = results;
|
|
41
|
+
} else if (results && typeof results === 'object') {
|
|
42
|
+
if (Array.isArray((results as any).value)) {
|
|
43
|
+
data = (results as any).value;
|
|
44
|
+
} else if (Array.isArray((results as any).data)) {
|
|
45
|
+
data = (results as any).data;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (isMounted) {
|
|
50
|
+
setFetchedData(data);
|
|
51
|
+
}
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.error('[ObjectGallery] Fetch error:', e);
|
|
54
|
+
} finally {
|
|
55
|
+
if (isMounted) setLoading(false);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (schema.objectName && !boundData && !schema.data && !props.data) {
|
|
60
|
+
fetchData();
|
|
61
|
+
}
|
|
62
|
+
return () => { isMounted = false; };
|
|
63
|
+
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data]);
|
|
64
|
+
|
|
65
|
+
const items = props.data || boundData || schema.data || fetchedData || [];
|
|
66
|
+
|
|
67
|
+
// Config
|
|
68
|
+
const imageField = schema.imageField || 'image';
|
|
69
|
+
const titleField = schema.titleField || 'name';
|
|
70
|
+
const subtitleField = schema.subtitleField;
|
|
71
|
+
|
|
72
|
+
if (loading && !items.length) return <div className="p-4 text-sm text-muted-foreground">Loading Gallery...</div>;
|
|
73
|
+
if (!items.length) return <div className="p-4 text-sm text-muted-foreground">No items to display</div>;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className={cn("grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 p-4", schema.className)}>
|
|
77
|
+
{items.map((item: any, i: number) => (
|
|
78
|
+
<div key={item._id || i} className="group relative border rounded-lg overflow-hidden bg-card text-card-foreground shadow-sm hover:shadow-md transition-all">
|
|
79
|
+
<div className="aspect-square w-full overflow-hidden bg-muted relative">
|
|
80
|
+
{item[imageField] ? (
|
|
81
|
+
<img
|
|
82
|
+
src={item[imageField]}
|
|
83
|
+
alt={item[titleField]}
|
|
84
|
+
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
|
85
|
+
onError={(e) => {
|
|
86
|
+
(e.target as HTMLImageElement).src = `https://placehold.co/400x400?text=${encodeURIComponent(item[titleField]?.[0] || '?')}`;
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
) : (
|
|
90
|
+
<div className="flex h-full w-full items-center justify-center bg-secondary/50 text-muted-foreground">
|
|
91
|
+
<span className="text-4xl font-light opacity-20">{item[titleField]?.[0]?.toUpperCase()}</span>
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
<div className="p-3 border-t">
|
|
96
|
+
<h3 className="font-medium truncate text-sm" title={item[titleField]}>{item[titleField] || 'Untitled'}</h3>
|
|
97
|
+
{subtitleField && (
|
|
98
|
+
<p className="text-xs text-muted-foreground truncate mt-1">{item[subtitleField]}</p>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
))}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
ComponentRegistry.register('object-gallery', ObjectGallery, {
|
|
108
|
+
namespace: 'plugin-list',
|
|
109
|
+
label: 'Gallery View',
|
|
110
|
+
category: 'view'
|
|
111
|
+
});
|