@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.
@@ -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
+ });