Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,10 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
- [x] Separators conditionally rendered only when adjacent groups are visible
- [x] Inline search moved to toolbar left end (`w-48`, Airtable-style)
- [x] Density button: activated state highlight (`bg-primary/10 border border-primary/20`) when density is non-default
- [x] Merged UserFilters row and tool buttons row into single toolbar line — left: field filter badges, right: tool buttons with separator
- [x] Search changed from inline input to icon button + Popover — saves toolbar space, matches Airtable pattern
- [x] UserFilters `maxVisible` prop added — overflow badges collapse into "More" dropdown with Popover
- [x] Toolbar layout uses flex with `min-w-0` overflow handling for responsive behavior

**Platform: ViewTabBar:**
- [x] Tab "•" dot indicator replaced with descriptive badge (`F`/`S`/`FS`) + tooltip showing "Active filters", "Active sort"
Expand All @@ -956,7 +960,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
**Tests:**
- [x] 7 new CRM metadata tests validating column types, widths, rowHeight, conditionalFormatting
- [x] 136 ViewConfigPanel tests updated for defaultCollapsed sections (expand before access)
- [x] 411 ListView + ViewTabBar tests passing
- [x] 411 ListView + ViewTabBar tests passing (255 plugin-list tests including 9 new toolbar/collapse tests)
- [x] 11 AppSidebar tests passing

### P2.5 PWA & Offline (Real Sync)
Expand Down
105 changes: 66 additions & 39 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export const ListView: React.FC<ListViewProps> = ({
(schema.viewType as ViewType)
);
const [searchTerm, setSearchTerm] = React.useState('');
const [showSearchPopover, setShowSearchPopover] = React.useState(false);

// Sort State
const [showSort, setShowSort] = React.useState(false);
Expand Down Expand Up @@ -1016,35 +1017,23 @@ export const ListView: React.FC<ListViewProps> = ({
</div>
)}

{/* Airtable-style Toolbar — Row 2: Tool buttons */}
{/* Airtable-style Toolbar — Merged: UserFilter badges (left) + Tool buttons (right) */}
<div className="border-b px-2 sm:px-4 py-1 flex items-center justify-between gap-1 sm:gap-2 bg-background">
<div className="flex items-center gap-0.5 overflow-x-auto flex-1 min-w-0">
{/* Search (left end — Airtable-style) */}
{toolbarFlags.showSearch && (
<div className="relative w-48 shrink-0">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-7 h-7 text-xs bg-muted/50 border-transparent hover:bg-muted focus:bg-background focus:border-input transition-colors"
/>
{searchTerm && (
<Button
variant="ghost"
size="sm"
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-muted-foreground/20"
onClick={() => handleSearchChange('')}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
)}

{/* --- Separator: Search | Fields --- */}
{toolbarFlags.showSearch && toolbarFlags.showHideFields && (
<div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
{/* User Filters — inline in toolbar (Airtable Interfaces-style) */}
{resolvedUserFilters && (
<>
<div className="shrink-0 min-w-0" data-testid="user-filters">
<UserFilters
config={resolvedUserFilters}
objectDef={objectDef}
data={data}
onFilterChange={setUserFilterConditions}
maxVisible={3}
/>
</div>
<div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
</>
)}

{/* Hide Fields */}
Expand Down Expand Up @@ -1387,6 +1376,56 @@ export const ListView: React.FC<ListViewProps> = ({
<span className="hidden sm:inline">Print</span>
</Button>
)}

{/* --- Separator: Print/Share/Export | Search --- */}
{(() => {
const hasLeftSideItems = schema.allowPrinting || (schema.sharing?.enabled || schema.sharing?.type) || (resolvedExportOptions && schema.allowExport !== false);
return toolbarFlags.showSearch && hasLeftSideItems ? (
<div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
) : null;
})()}

{/* Search (icon button + popover) */}
{toolbarFlags.showSearch && (
<Popover open={showSearchPopover} onOpenChange={setShowSearchPopover}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"h-7 w-7 p-0 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
searchTerm && "bg-primary/10 border border-primary/20 text-primary"
)}
data-testid="search-icon-button"
title="Search"
>
<Search className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-2" data-testid="search-popover">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-7 h-8 text-xs"
autoFocus
/>
{searchTerm && (
<Button
variant="ghost"
size="sm"
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-muted-foreground/20"
onClick={() => handleSearchChange('')}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</PopoverContent>
</Popover>
)}
</div>

{/* Right: Add Record */}
Expand Down Expand Up @@ -1436,18 +1475,6 @@ export const ListView: React.FC<ListViewProps> = ({
</div>
)}

{/* User Filters Row (Airtable Interfaces-style) */}
{resolvedUserFilters && (
<div className="border-b px-2 sm:px-4 py-1 bg-background" data-testid="user-filters">
<UserFilters
config={resolvedUserFilters}
objectDef={objectDef}
data={data}
onFilterChange={setUserFilterConditions}
/>
</div>
)}

{/* View Content */}
<div key={currentView} className="flex-1 min-h-0 bg-background relative overflow-hidden animate-in fade-in-0 duration-200">
{!loading && data.length === 0 ? (
Expand Down
172 changes: 105 additions & 67 deletions packages/plugin-list/src/UserFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface UserFiltersProps {
data?: any[];
/** Callback when filter state changes */
onFilterChange: (filters: any[]) => void;
/** Maximum visible filter badges before collapsing into "More" dropdown (dropdown mode only) */
maxVisible?: number;
className?: string;
}

Expand All @@ -53,6 +55,7 @@ export function UserFilters({
objectDef,
data = [],
onFilterChange,
maxVisible,
className,
}: UserFiltersProps) {
switch (config.element) {
Expand All @@ -63,6 +66,7 @@ export function UserFilters({
objectDef={objectDef}
data={data}
onFilterChange={onFilterChange}
maxVisible={maxVisible}
className={className}
/>
);
Expand Down Expand Up @@ -138,10 +142,11 @@ interface DropdownFiltersProps {
objectDef?: any;
data: any[];
onFilterChange: (filters: any[]) => void;
maxVisible?: number;
className?: string;
}

function DropdownFilters({ fields, objectDef, data, onFilterChange, className }: DropdownFiltersProps) {
function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, className }: DropdownFiltersProps) {
const [selectedValues, setSelectedValues] = React.useState<
Record<string, (string | number | boolean)[]>
>(() => {
Expand Down Expand Up @@ -182,6 +187,89 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, className }:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Split fields into visible and overflow based on maxVisible
const visibleFields = maxVisible !== undefined && maxVisible < resolvedFields.length
? resolvedFields.slice(0, maxVisible)
: resolvedFields;
const overflowFields = maxVisible !== undefined && maxVisible < resolvedFields.length
? resolvedFields.slice(maxVisible)
: [];

const renderBadge = (f: ResolvedField) => {
const selected = selectedValues[f.field] || [];
const hasSelection = selected.length > 0;

return (
<Popover key={f.field}>
<PopoverTrigger asChild>
<button
data-testid={`filter-badge-${f.field}`}
className={cn(
'inline-flex items-center gap-1 rounded-md border h-7 px-2.5 text-xs font-medium transition-colors shrink-0',
hasSelection
? 'border-primary/30 bg-primary/5 text-primary'
: 'border-border bg-background hover:bg-accent text-foreground',
)}
>
<span className="truncate max-w-[100px]">{f.label || f.field}</span>
{hasSelection && (
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px]">
{selected.length}
</span>
)}
{hasSelection ? (
<X
className="h-3 w-3 opacity-60"
data-testid={`filter-clear-${f.field}`}
onClick={e => {
e.stopPropagation();
handleChange(f.field, []);
}}
/>
) : (
<ChevronDown className="h-3 w-3 opacity-60" />
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-2">
<div className="max-h-60 overflow-y-auto space-y-0.5" data-testid={`filter-options-${f.field}`}>
{f.options.map(opt => (
<label
key={String(opt.value)}
className={cn(
'flex items-center gap-2 text-sm py-1.5 px-2 rounded cursor-pointer',
selected.includes(opt.value) ? 'bg-primary/5 text-primary' : 'hover:bg-muted',
)}
>
<input
type="checkbox"
checked={selected.includes(opt.value)}
onChange={() => {
const next = selected.includes(opt.value)
? selected.filter(v => v !== opt.value)
: [...selected, opt.value];
handleChange(f.field, next);
}}
className="rounded border-input"
/>
{opt.color && (
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: opt.color }}
/>
)}
<span className="truncate flex-1">{opt.label}</span>
{opt.count !== undefined && (
<span className="text-xs text-muted-foreground">{opt.count}</span>
)}
</label>
))}
</div>
</PopoverContent>
</Popover>
);
};

return (
<div className={cn('flex items-center gap-1 overflow-x-auto', className)} data-testid="user-filters-dropdown">
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
Expand All @@ -190,80 +278,30 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, className }:
No filter fields
</span>
) : (
resolvedFields.map(f => {
const selected = selectedValues[f.field] || [];
const hasSelection = selected.length > 0;

return (
<Popover key={f.field}>
<>
{visibleFields.map(renderBadge)}
{overflowFields.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button
data-testid={`filter-badge-${f.field}`}
className={cn(
'inline-flex items-center gap-1 rounded-md border h-7 px-2.5 text-xs font-medium transition-colors shrink-0',
hasSelection
? 'border-primary/30 bg-primary/5 text-primary'
: 'border-border bg-background hover:bg-accent text-foreground',
)}
data-testid="user-filters-more"
className="inline-flex items-center gap-1 rounded-md border border-border bg-background hover:bg-accent text-foreground h-7 px-2.5 text-xs font-medium transition-colors shrink-0"
>
<span className="truncate max-w-[100px]">{f.label || f.field}</span>
{hasSelection && (
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px]">
{selected.length}
</span>
)}
{hasSelection ? (
<X
className="h-3 w-3 opacity-60"
data-testid={`filter-clear-${f.field}`}
onClick={e => {
e.stopPropagation();
handleChange(f.field, []);
}}
/>
) : (
<ChevronDown className="h-3 w-3 opacity-60" />
)}
<span>More</span>
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-muted text-[10px] font-medium">
{overflowFields.length}
</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-2">
<div className="max-h-60 overflow-y-auto space-y-0.5" data-testid={`filter-options-${f.field}`}>
{f.options.map(opt => (
<label
key={String(opt.value)}
className={cn(
'flex items-center gap-2 text-sm py-1.5 px-2 rounded cursor-pointer',
selected.includes(opt.value) ? 'bg-primary/5 text-primary' : 'hover:bg-muted',
)}
>
<input
type="checkbox"
checked={selected.includes(opt.value)}
onChange={() => {
const next = selected.includes(opt.value)
? selected.filter(v => v !== opt.value)
: [...selected, opt.value];
handleChange(f.field, next);
}}
className="rounded border-input"
/>
{opt.color && (
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: opt.color }}
/>
)}
<span className="truncate flex-1">{opt.label}</span>
{opt.count !== undefined && (
<span className="text-xs text-muted-foreground">{opt.count}</span>
)}
</label>
))}
<PopoverContent align="start" className="w-64 p-2" data-testid="user-filters-more-content">
<div className="space-y-1">
{overflowFields.map(renderBadge)}
</div>
</PopoverContent>
</Popover>
);
})
)}
</>
)}
<button
className="inline-flex items-center gap-1 h-7 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors shrink-0"
Expand Down
Loading