@object-ui/plugin-list 0.5.1 → 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')
@@ -272,6 +278,14 @@ export const ListView: React.FC<ListViewProps> = ({
272
278
  onSearchChange?.(value);
273
279
  }, [onSearchChange]);
274
280
 
281
+ // --- NavigationConfig support ---
282
+ const navigation = useNavigationOverlay({
283
+ navigation: schema.navigation,
284
+ objectName: schema.objectName,
285
+ onNavigate: schema.onNavigate,
286
+ onRowClick,
287
+ });
288
+
275
289
  // Generate the appropriate view component schema
276
290
  const viewComponentSchema = React.useMemo(() => {
277
291
  const baseProps = {
@@ -282,6 +296,8 @@ export const ListView: React.FC<ListViewProps> = ({
282
296
  className: "h-full w-full",
283
297
  // Disable internal controls that clash with ListView toolbar
284
298
  showSearch: false,
299
+ // Pass navigation click handler to child views
300
+ onRowClick: navigation.handleClick,
285
301
  };
286
302
 
287
303
  switch (currentView) {
@@ -374,124 +390,181 @@ export const ListView: React.FC<ListViewProps> = ({
374
390
  }));
375
391
  }, [objectDef, schema.fields]);
376
392
 
393
+ const [searchExpanded, setSearchExpanded] = React.useState(false);
394
+
377
395
  return (
378
396
  <div className={cn('flex flex-col h-full bg-background', className)}>
379
- {/* Airtable-style Toolbar */}
380
- <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">
381
- <div className="flex items-center gap-2 flex-1 overflow-hidden">
382
- {/* View Switcher on the Left */}
383
- <div className="flex items-center pr-2 border-r mr-2">
384
- <ViewSwitcher
385
- currentView={currentView}
386
- availableViews={availableViews}
387
- onViewChange={handleViewChange}
388
- />
389
- </div>
390
-
391
- {/* Action Tools */}
392
- <div className="flex items-center gap-1">
393
- <Popover open={showFilters} onOpenChange={setShowFilters}>
394
- <PopoverTrigger asChild>
395
- <Button
396
- variant={hasFilters ? "secondary" : "ghost"}
397
- size="sm"
398
- className={cn(
399
- "h-8 px-2 lg:px-3 text-muted-foreground hover:text-primary",
400
- hasFilters && "text-primary bg-secondary/50"
401
- )}
402
- >
403
- <SlidersHorizontal className="h-4 w-4 mr-2" />
404
- <span className="hidden lg:inline">Filter</span>
405
- {hasFilters && (
406
- <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">
407
- {currentFilters.conditions?.length || 0}
408
- </span>
409
- )}
410
- </Button>
411
- </PopoverTrigger>
412
- <PopoverContent align="start" className="w-[600px] p-4">
413
- <div className="space-y-4">
414
- <div className="flex items-center justify-between border-b pb-2">
415
- <h4 className="font-medium text-sm">Filter Records</h4>
416
- </div>
417
- <FilterBuilder
418
- fields={filterFields}
419
- value={currentFilters}
420
- onChange={(newFilters) => {
421
- console.log('Filter Changed:', newFilters);
422
- setCurrentFilters(newFilters);
423
- // Convert FilterBuilder format to OData $filter string if needed
424
- // For now we just update state and notify listener
425
- // In a real app, this would likely build an OData string
426
- if (onFilterChange) onFilterChange(newFilters);
427
- }}
428
- />
429
- </div>
430
- </PopoverContent>
431
- </Popover>
432
-
433
- <Popover open={showSort} onOpenChange={setShowSort}>
434
- <PopoverTrigger asChild>
435
- <Button
436
- variant={currentSort.length > 0 ? "secondary" : "ghost"}
437
- size="sm"
438
- className={cn(
439
- "h-8 px-2 lg:px-3 text-muted-foreground hover:text-primary",
440
- currentSort.length > 0 && "text-primary bg-secondary/50"
441
- )}
442
- >
443
- <ArrowUpDown className="h-4 w-4 mr-2" />
444
- <span className="hidden lg:inline">Sort</span>
445
- {currentSort.length > 0 && (
446
- <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">
447
- {currentSort.length}
448
- </span>
449
- )}
450
- </Button>
451
- </PopoverTrigger>
452
- <PopoverContent align="start" className="w-[600px] p-4">
453
- <div className="space-y-4">
454
- <div className="flex items-center justify-between border-b pb-2">
455
- <h4 className="font-medium text-sm">Sort Records</h4>
456
- </div>
457
- <SortBuilder
458
- fields={filterFields}
459
- value={currentSort}
460
- onChange={(newSort) => {
461
- console.log('Sort Changed:', newSort);
462
- setCurrentSort(newSort);
463
- if (onSortChange) onSortChange(newSort);
464
- }}
465
- />
466
- </div>
467
- </PopoverContent>
468
- </Popover>
469
-
470
- {/* Future: Group, Color, Height */}
471
- </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
+ />
472
405
  </div>
406
+ )}
407
+
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>
473
421
 
474
- {/* Right Actions: Search + New */}
475
- <div className="flex items-center gap-2">
476
- <div className="relative w-40 lg:w-64 transition-all">
477
- <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
478
- <Input
479
- placeholder="Find..."
480
- value={searchTerm}
481
- onChange={(e) => handleSearchChange(e.target.value)}
482
- className="pl-8 h-8 text-sm bg-muted/50 border-transparent hover:bg-muted focus:bg-background focus:border-input transition-colors"
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
+ }}
483
454
  />
484
- {searchTerm && (
485
- <Button
486
- variant="ghost"
487
- size="sm"
488
- className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 hover:bg-muted-foreground/20"
489
- onClick={() => handleSearchChange('')}
490
- >
491
- <X className="h-3 w-3" />
492
- </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"
493
479
  )}
494
- </div>
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>
487
+ )}
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
+ )}
495
568
  </div>
496
569
  </div>
497
570
 
@@ -507,6 +580,33 @@ export const ListView: React.FC<ListViewProps> = ({
507
580
  loading={loading}
508
581
  />
509
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
+ )}
510
610
  </div>
511
611
  );
512
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,7 +117,7 @@ describe('ListView Persistence', () => {
117
117
  },
118
118
  };
119
119
 
120
- renderWithProvider(<ListView schema={schema} />);
120
+ renderWithProvider(<ListView schema={schema} showViewSwitcher={true} />);
121
121
 
122
122
  // Should respect schema ('grid') because storage persistence is currently disabled
123
123
  const kanbanButton = screen.getByLabelText('Kanban');
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
+ });