Skip to content

Commit 9bb0e40

Browse files
authored
Merge pull request #447 from objectstack-ai/copilot/add-toolbar-for-sorting-filtering-search
2 parents 6c26598 + 50d0aed commit 9bb0e40

4 files changed

Lines changed: 219 additions & 180 deletions

File tree

packages/plugin-list/src/ListView.tsx

Lines changed: 170 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import * as React from 'react';
1010
import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay } from '@object-ui/components';
1111
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';
1313
import type { FilterGroup } from '@object-ui/components';
1414
import { ViewSwitcher, ViewType } from './ViewSwitcher';
1515
import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react';
@@ -390,126 +390,181 @@ export const ListView: React.FC<ListViewProps> = ({
390390
}));
391391
}, [objectDef, schema.fields]);
392392

393+
const [searchExpanded, setSearchExpanded] = React.useState(false);
394+
393395
return (
394396
<div className={cn('flex flex-col h-full bg-background', className)}>
395-
{/* Airtable-style Toolbar */}
396-
<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">
397-
<div className="flex items-center gap-2 flex-1 overflow-hidden">
398-
{/* View Switcher on the Left (optional, hidden by default) */}
399-
{showViewSwitcher && (
400-
<div className="flex items-center pr-2 border-r mr-2">
401-
<ViewSwitcher
402-
currentView={currentView}
403-
availableViews={availableViews}
404-
onViewChange={handleViewChange}
405-
/>
406-
</div>
407-
)}
408-
409-
{/* Action Tools */}
410-
<div className="flex items-center gap-1">
411-
<Popover open={showFilters} onOpenChange={setShowFilters}>
412-
<PopoverTrigger asChild>
413-
<Button
414-
variant={hasFilters ? "secondary" : "ghost"}
415-
size="sm"
416-
className={cn(
417-
"h-8 px-2 lg:px-3 text-muted-foreground hover:text-primary",
418-
hasFilters && "text-primary bg-secondary/50"
419-
)}
420-
>
421-
<SlidersHorizontal className="h-4 w-4 mr-2" />
422-
<span className="hidden lg:inline">Filter</span>
423-
{hasFilters && (
424-
<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">
425-
{currentFilters.conditions?.length || 0}
426-
</span>
427-
)}
428-
</Button>
429-
</PopoverTrigger>
430-
<PopoverContent align="start" className="w-[600px] p-4">
431-
<div className="space-y-4">
432-
<div className="flex items-center justify-between border-b pb-2">
433-
<h4 className="font-medium text-sm">Filter Records</h4>
434-
</div>
435-
<FilterBuilder
436-
fields={filterFields}
437-
value={currentFilters}
438-
onChange={(newFilters) => {
439-
console.log('Filter Changed:', newFilters);
440-
setCurrentFilters(newFilters);
441-
// Convert FilterBuilder format to OData $filter string if needed
442-
// For now we just update state and notify listener
443-
// In a real app, this would likely build an OData string
444-
if (onFilterChange) onFilterChange(newFilters);
445-
}}
446-
/>
447-
</div>
448-
</PopoverContent>
449-
</Popover>
450-
451-
<Popover open={showSort} onOpenChange={setShowSort}>
452-
<PopoverTrigger asChild>
453-
<Button
454-
variant={currentSort.length > 0 ? "secondary" : "ghost"}
455-
size="sm"
456-
className={cn(
457-
"h-8 px-2 lg:px-3 text-muted-foreground hover:text-primary",
458-
currentSort.length > 0 && "text-primary bg-secondary/50"
459-
)}
460-
>
461-
<ArrowUpDown className="h-4 w-4 mr-2" />
462-
<span className="hidden lg:inline">Sort</span>
463-
{currentSort.length > 0 && (
464-
<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">
465-
{currentSort.length}
466-
</span>
467-
)}
468-
</Button>
469-
</PopoverTrigger>
470-
<PopoverContent align="start" className="w-[600px] p-4">
471-
<div className="space-y-4">
472-
<div className="flex items-center justify-between border-b pb-2">
473-
<h4 className="font-medium text-sm">Sort Records</h4>
474-
</div>
475-
<SortBuilder
476-
fields={filterFields}
477-
value={currentSort}
478-
onChange={(newSort) => {
479-
console.log('Sort Changed:', newSort);
480-
setCurrentSort(newSort);
481-
if (onSortChange) onSortChange(newSort);
482-
}}
483-
/>
484-
</div>
485-
</PopoverContent>
486-
</Popover>
487-
488-
{/* Future: Group, Color, Height */}
489-
</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+
/>
490405
</div>
406+
)}
491407

492-
{/* Right Actions: Search + New */}
493-
<div className="flex items-center gap-2">
494-
<div className="relative w-40 lg:w-64 transition-all">
495-
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
496-
<Input
497-
placeholder="Find..."
498-
value={searchTerm}
499-
onChange={(e) => handleSearchChange(e.target.value)}
500-
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+
}}
501454
/>
502-
{searchTerm && (
503-
<Button
504-
variant="ghost"
505-
size="sm"
506-
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 hover:bg-muted-foreground/20"
507-
onClick={() => handleSearchChange('')}
508-
>
509-
<X className="h-3 w-3" />
510-
</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"
511479
)}
512-
</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+
)}
513568
</div>
514569
</div>
515570

packages/plugin-list/src/__tests__/ListView.test.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('ListView', () => {
6666
expect(container).toBeTruthy();
6767
});
6868

69-
it('should render search input', () => {
69+
it('should render search button', () => {
7070
const schema: ListViewSchema = {
7171
type: 'list-view',
7272
objectName: 'contacts',
@@ -75,11 +75,11 @@ describe('ListView', () => {
7575
};
7676

7777
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();
8080
});
8181

82-
it('should call onSearchChange when search input changes', () => {
82+
it('should expand search and call onSearchChange when search input changes', () => {
8383
const onSearchChange = vi.fn();
8484
const schema: ListViewSchema = {
8585
type: 'list-view',
@@ -89,8 +89,12 @@ describe('ListView', () => {
8989
};
9090

9191
renderWithProvider(<ListView schema={schema} onSearchChange={onSearchChange} />);
92-
const searchInput = screen.getByPlaceholderText(/find/i);
9392

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);
9498
fireEvent.change(searchInput, { target: { value: 'test' } });
9599
expect(onSearchChange).toHaveBeenCalledWith('test');
96100
});
@@ -196,13 +200,18 @@ describe('ListView', () => {
196200
};
197201

198202
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+
199208
const searchInput = screen.getByPlaceholderText(/find/i) as HTMLInputElement;
200209

201210
// Type in search
202211
fireEvent.change(searchInput, { target: { value: 'test' } });
203212
expect(searchInput.value).toBe('test');
204213

205-
// Find and click clear button
214+
// Find and click clear button (the X button inside the expanded search)
206215
const buttons = screen.getAllByRole('button');
207216
const clearButton = buttons.find(btn =>
208217
btn.querySelector('svg') !== null && searchInput.value !== ''

0 commit comments

Comments
 (0)