@object-ui/plugin-list 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/ListView.tsx CHANGED
@@ -7,12 +7,12 @@
7
7
  */
8
8
 
9
9
  import * as React from 'react';
10
- import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder } from '@object-ui/components';
10
+ import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay } from '@object-ui/components';
11
11
  import type { SortItem } from '@object-ui/components';
12
- import { Search, SlidersHorizontal, ArrowUpDown, X } from 'lucide-react';
12
+ import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Group, Paintbrush, Ruler } from 'lucide-react';
13
13
  import type { FilterGroup } from '@object-ui/components';
14
14
  import { ViewSwitcher, ViewType } from './ViewSwitcher';
15
- import { SchemaRenderer } from '@object-ui/react';
15
+ import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react';
16
16
  import type { ListViewSchema } from '@object-ui/types';
17
17
 
18
18
  export interface ListViewProps {
@@ -22,6 +22,10 @@ export interface ListViewProps {
22
22
  onFilterChange?: (filters: any) => void;
23
23
  onSortChange?: (sort: any) => void;
24
24
  onSearchChange?: (search: string) => void;
25
+ /** Callback when a row/item is clicked (overrides NavigationConfig) */
26
+ onRowClick?: (record: Record<string, unknown>) => void;
27
+ /** Show view type switcher (Grid/Kanban/etc). Default: false (view type is fixed) */
28
+ showViewSwitcher?: boolean;
25
29
  [key: string]: any;
26
30
  }
27
31
 
@@ -65,6 +69,8 @@ export const ListView: React.FC<ListViewProps> = ({
65
69
  onFilterChange,
66
70
  onSortChange,
67
71
  onSearchChange,
72
+ onRowClick,
73
+ showViewSwitcher = false,
68
74
  ...props
69
75
  }) => {
70
76
  // Kernel level default: Ensure viewType is always defined (default to 'grid')
@@ -236,7 +242,15 @@ export const ListView: React.FC<ListViewProps> = ({
236
242
  return views;
237
243
  }, [schema.options, schema.viewType]);
238
244
 
239
- // Load saved view preference
245
+ // Sync view from props
246
+ React.useEffect(() => {
247
+ if (schema.viewType) {
248
+ setCurrentView(schema.viewType as ViewType);
249
+ }
250
+ }, [schema.viewType]);
251
+
252
+ // Load saved view preference (DISABLED: interfering with schema-defined views)
253
+ /*
240
254
  React.useEffect(() => {
241
255
  try {
242
256
  const savedView = localStorage.getItem(storageKey);
@@ -247,6 +261,7 @@ export const ListView: React.FC<ListViewProps> = ({
247
261
  console.warn('Failed to load view preference from localStorage:', error);
248
262
  }
249
263
  }, [storageKey, availableViews]);
264
+ */
250
265
 
251
266
  const handleViewChange = React.useCallback((view: ViewType) => {
252
267
  setCurrentView(view);
@@ -263,6 +278,14 @@ export const ListView: React.FC<ListViewProps> = ({
263
278
  onSearchChange?.(value);
264
279
  }, [onSearchChange]);
265
280
 
281
+ // --- NavigationConfig support ---
282
+ const navigation = useNavigationOverlay({
283
+ navigation: schema.navigation,
284
+ objectName: schema.objectName,
285
+ onNavigate: schema.onNavigate,
286
+ onRowClick,
287
+ });
288
+
266
289
  // Generate the appropriate view component schema
267
290
  const viewComponentSchema = React.useMemo(() => {
268
291
  const baseProps = {
@@ -273,6 +296,8 @@ export const ListView: React.FC<ListViewProps> = ({
273
296
  className: "h-full w-full",
274
297
  // Disable internal controls that clash with ListView toolbar
275
298
  showSearch: false,
299
+ // Pass navigation click handler to child views
300
+ onRowClick: navigation.handleClick,
276
301
  };
277
302
 
278
303
  switch (currentView) {
@@ -365,124 +390,181 @@ export const ListView: React.FC<ListViewProps> = ({
365
390
  }));
366
391
  }, [objectDef, schema.fields]);
367
392
 
393
+ const [searchExpanded, setSearchExpanded] = React.useState(false);
394
+
368
395
  return (
369
396
  <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>
397
+ {/* Airtable-style Toolbar — Row 1: View tabs */}
398
+ {showViewSwitcher && (
399
+ <div className="border-b px-4 py-1 flex items-center bg-background">
400
+ <ViewSwitcher
401
+ currentView={currentView}
402
+ availableViews={availableViews}
403
+ onViewChange={handleViewChange}
404
+ />
463
405
  </div>
406
+ )}
464
407
 
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"
408
+ {/* Airtable-style Toolbar — Row 2: Tool buttons */}
409
+ <div className="border-b px-4 py-1 flex items-center justify-between gap-2 bg-background">
410
+ <div className="flex items-center gap-0.5 overflow-hidden">
411
+ {/* Hide Fields */}
412
+ <Button
413
+ variant="ghost"
414
+ size="sm"
415
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs"
416
+ disabled
417
+ >
418
+ <EyeOff className="h-3.5 w-3.5 mr-1.5" />
419
+ <span className="hidden sm:inline">Hide fields</span>
420
+ </Button>
421
+
422
+ {/* Filter */}
423
+ <Popover open={showFilters} onOpenChange={setShowFilters}>
424
+ <PopoverTrigger asChild>
425
+ <Button
426
+ variant="ghost"
427
+ size="sm"
428
+ className={cn(
429
+ "h-7 px-2 text-muted-foreground hover:text-primary text-xs",
430
+ hasFilters && "text-primary"
431
+ )}
432
+ >
433
+ <SlidersHorizontal className="h-3.5 w-3.5 mr-1.5" />
434
+ <span className="hidden sm:inline">Filter</span>
435
+ {hasFilters && (
436
+ <span className="ml-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px] font-medium text-primary">
437
+ {currentFilters.conditions?.length || 0}
438
+ </span>
439
+ )}
440
+ </Button>
441
+ </PopoverTrigger>
442
+ <PopoverContent align="start" className="w-[600px] p-4">
443
+ <div className="space-y-4">
444
+ <div className="flex items-center justify-between border-b pb-2">
445
+ <h4 className="font-medium text-sm">Filter Records</h4>
446
+ </div>
447
+ <FilterBuilder
448
+ fields={filterFields}
449
+ value={currentFilters}
450
+ onChange={(newFilters) => {
451
+ setCurrentFilters(newFilters);
452
+ if (onFilterChange) onFilterChange(newFilters);
453
+ }}
474
454
  />
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>
455
+ </div>
456
+ </PopoverContent>
457
+ </Popover>
458
+
459
+ {/* Group */}
460
+ <Button
461
+ variant="ghost"
462
+ size="sm"
463
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs"
464
+ disabled
465
+ >
466
+ <Group className="h-3.5 w-3.5 mr-1.5" />
467
+ <span className="hidden sm:inline">Group</span>
468
+ </Button>
469
+
470
+ {/* Sort */}
471
+ <Popover open={showSort} onOpenChange={setShowSort}>
472
+ <PopoverTrigger asChild>
473
+ <Button
474
+ variant="ghost"
475
+ size="sm"
476
+ className={cn(
477
+ "h-7 px-2 text-muted-foreground hover:text-primary text-xs",
478
+ currentSort.length > 0 && "text-primary"
479
+ )}
480
+ >
481
+ <ArrowUpDown className="h-3.5 w-3.5 mr-1.5" />
482
+ <span className="hidden sm:inline">Sort</span>
483
+ {currentSort.length > 0 && (
484
+ <span className="ml-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px] font-medium text-primary">
485
+ {currentSort.length}
486
+ </span>
484
487
  )}
485
- </div>
488
+ </Button>
489
+ </PopoverTrigger>
490
+ <PopoverContent align="start" className="w-[600px] p-4">
491
+ <div className="space-y-4">
492
+ <div className="flex items-center justify-between border-b pb-2">
493
+ <h4 className="font-medium text-sm">Sort Records</h4>
494
+ </div>
495
+ <SortBuilder
496
+ fields={filterFields}
497
+ value={currentSort}
498
+ onChange={(newSort) => {
499
+ setCurrentSort(newSort);
500
+ if (onSortChange) onSortChange(newSort);
501
+ }}
502
+ />
503
+ </div>
504
+ </PopoverContent>
505
+ </Popover>
506
+
507
+ {/* Color */}
508
+ <Button
509
+ variant="ghost"
510
+ size="sm"
511
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs"
512
+ disabled
513
+ >
514
+ <Paintbrush className="h-3.5 w-3.5 mr-1.5" />
515
+ <span className="hidden sm:inline">Color</span>
516
+ </Button>
517
+
518
+ {/* Row Height */}
519
+ <Button
520
+ variant="ghost"
521
+ size="sm"
522
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs hidden lg:flex"
523
+ disabled
524
+ >
525
+ <Ruler className="h-3.5 w-3.5 mr-1.5" />
526
+ <span className="hidden sm:inline">Row height</span>
527
+ </Button>
528
+ </div>
529
+
530
+ {/* Right: Search */}
531
+ <div className="flex items-center gap-1">
532
+ {searchExpanded ? (
533
+ <div className="relative w-48 lg:w-64">
534
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
535
+ <Input
536
+ placeholder="Find..."
537
+ value={searchTerm}
538
+ onChange={(e) => handleSearchChange(e.target.value)}
539
+ className="pl-7 h-7 text-xs bg-muted/50 border-transparent hover:bg-muted focus:bg-background focus:border-input transition-colors"
540
+ autoFocus
541
+ onBlur={() => {
542
+ if (!searchTerm) setSearchExpanded(false);
543
+ }}
544
+ />
545
+ <Button
546
+ variant="ghost"
547
+ size="sm"
548
+ className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-muted-foreground/20"
549
+ onClick={() => {
550
+ handleSearchChange('');
551
+ setSearchExpanded(false);
552
+ }}
553
+ >
554
+ <X className="h-3 w-3" />
555
+ </Button>
556
+ </div>
557
+ ) : (
558
+ <Button
559
+ variant="ghost"
560
+ size="sm"
561
+ className="h-7 px-2 text-muted-foreground hover:text-primary text-xs"
562
+ onClick={() => setSearchExpanded(true)}
563
+ >
564
+ <Search className="h-3.5 w-3.5 mr-1.5" />
565
+ <span className="hidden sm:inline">Search</span>
566
+ </Button>
567
+ )}
486
568
  </div>
487
569
  </div>
488
570
 
@@ -498,6 +580,33 @@ export const ListView: React.FC<ListViewProps> = ({
498
580
  loading={loading}
499
581
  />
500
582
  </div>
583
+
584
+ {/* Navigation Overlay (drawer/modal/popover) */}
585
+ {navigation.isOverlay && (
586
+ <NavigationOverlay
587
+ {...navigation}
588
+ title={
589
+ schema.label
590
+ ? `${schema.label} Detail`
591
+ : schema.objectName
592
+ ? `${schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1)} Detail`
593
+ : 'Record Detail'
594
+ }
595
+ >
596
+ {(record) => (
597
+ <div className="space-y-3">
598
+ {Object.entries(record).map(([key, value]) => (
599
+ <div key={key} className="flex flex-col">
600
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
601
+ {key.replace(/_/g, ' ')}
602
+ </span>
603
+ <span className="text-sm">{String(value ?? '—')}</span>
604
+ </div>
605
+ ))}
606
+ </div>
607
+ )}
608
+ </NavigationOverlay>
609
+ )}
501
610
  </div>
502
611
  );
503
612
  };
@@ -66,7 +66,7 @@ describe('ListView', () => {
66
66
  expect(container).toBeTruthy();
67
67
  });
68
68
 
69
- it('should render search input', () => {
69
+ it('should render search button', () => {
70
70
  const schema: ListViewSchema = {
71
71
  type: 'list-view',
72
72
  objectName: 'contacts',
@@ -75,11 +75,11 @@ describe('ListView', () => {
75
75
  };
76
76
 
77
77
  renderWithProvider(<ListView schema={schema} />);
78
- const searchInput = screen.getByPlaceholderText(/find/i);
79
- expect(searchInput).toBeInTheDocument();
78
+ const searchButton = screen.getByRole('button', { name: /search/i });
79
+ expect(searchButton).toBeInTheDocument();
80
80
  });
81
81
 
82
- it('should call onSearchChange when search input changes', () => {
82
+ it('should expand search and call onSearchChange when search input changes', () => {
83
83
  const onSearchChange = vi.fn();
84
84
  const schema: ListViewSchema = {
85
85
  type: 'list-view',
@@ -89,8 +89,12 @@ describe('ListView', () => {
89
89
  };
90
90
 
91
91
  renderWithProvider(<ListView schema={schema} onSearchChange={onSearchChange} />);
92
- const searchInput = screen.getByPlaceholderText(/find/i);
93
92
 
93
+ // Click search button to expand
94
+ const searchButton = screen.getByRole('button', { name: /search/i });
95
+ fireEvent.click(searchButton);
96
+
97
+ const searchInput = screen.getByPlaceholderText(/find/i);
94
98
  fireEvent.change(searchInput, { target: { value: 'test' } });
95
99
  expect(onSearchChange).toHaveBeenCalledWith('test');
96
100
  });
@@ -108,7 +112,7 @@ describe('ListView', () => {
108
112
  },
109
113
  };
110
114
 
111
- renderWithProvider(<ListView schema={schema} />);
115
+ renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
112
116
 
113
117
  // Find kanban view button and click it
114
118
  // ViewSwitcher uses buttons with aria-label
@@ -196,13 +200,18 @@ describe('ListView', () => {
196
200
  };
197
201
 
198
202
  renderWithProvider(<ListView schema={schema} />);
203
+
204
+ // Click search button to expand search input
205
+ const searchButton = screen.getByRole('button', { name: /search/i });
206
+ fireEvent.click(searchButton);
207
+
199
208
  const searchInput = screen.getByPlaceholderText(/find/i) as HTMLInputElement;
200
209
 
201
210
  // Type in search
202
211
  fireEvent.change(searchInput, { target: { value: 'test' } });
203
212
  expect(searchInput.value).toBe('test');
204
213
 
205
- // Find and click clear button
214
+ // Find and click clear button (the X button inside the expanded search)
206
215
  const buttons = screen.getAllByRole('button');
207
216
  const clearButton = buttons.find(btn =>
208
217
  btn.querySelector('svg') !== null && searchInput.value !== ''
@@ -56,7 +56,7 @@ describe('ListView Persistence', () => {
56
56
  },
57
57
  };
58
58
 
59
- renderWithProvider(<ListView schema={schema} />);
59
+ renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
60
60
 
61
61
  // Simulate changing to kanban view
62
62
  const kanbanButton = screen.getByLabelText('Kanban');
@@ -89,7 +89,7 @@ describe('ListView Persistence', () => {
89
89
  },
90
90
  };
91
91
 
92
- renderWithProvider(<ListView schema={viewB_Schema} />);
92
+ renderWithProvider(<ListView schema={viewB_Schema} showViewSwitcher={true} />);
93
93
 
94
94
  // Should use the schema default 'kanban' (since no storage exists for THIS view id)
95
95
  // It should NOT use 'grid' from the global/default view.
@@ -117,10 +117,13 @@ describe('ListView Persistence', () => {
117
117
  },
118
118
  };
119
119
 
120
- renderWithProvider(<ListView schema={schema} />);
120
+ renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
121
121
 
122
- // Should respect storage ('kanban') over schema ('grid')
122
+ // Should respect schema ('grid') because storage persistence is currently disabled
123
123
  const kanbanButton = screen.getByLabelText('Kanban');
124
- expect(kanbanButton.getAttribute('data-state')).toBe('on');
124
+ expect(kanbanButton.getAttribute('data-state')).toBe('off');
125
+
126
+ const gridButton = screen.getByLabelText('Grid');
127
+ expect(gridButton.getAttribute('data-state')).toBe('on');
125
128
  });
126
129
  });
package/src/index.tsx CHANGED
@@ -45,3 +45,25 @@ ComponentRegistry.register('list-view', ListView, {
45
45
  options: {},
46
46
  }
47
47
  });
48
+
49
+ // Alias for generic view
50
+ ComponentRegistry.register('list', ListView, {
51
+ namespace: 'view',
52
+ category: 'view',
53
+ label: 'List',
54
+ icon: 'LayoutList',
55
+ inputs: [
56
+ { name: 'objectName', type: 'string', label: 'Object Name', required: true },
57
+ { name: 'viewType', type: 'enum', label: 'Default View', enum: [
58
+ { label: 'Grid', value: 'grid' },
59
+ { label: 'List', value: 'list' },
60
+ { label: 'Kanban', value: 'kanban' },
61
+ { label: 'Calendar', value: 'calendar' },
62
+ { label: 'Chart', value: 'chart' }
63
+ ], defaultValue: 'grid' },
64
+ { name: 'fields', type: 'array', label: 'Fields' },
65
+ { name: 'filters', type: 'array', label: 'Filters' },
66
+ { name: 'sort', type: 'array', label: 'Sort' },
67
+ { name: 'options', type: 'object', label: 'View Options' },
68
+ ]
69
+ });