Skip to content

Commit f96e9a0

Browse files
authored
Merge pull request #897 from objectstack-ai/copilot/optimize-listview-toolbar
2 parents 1f8e03c + f2e1da5 commit f96e9a0

File tree

5 files changed

+346
-125
lines changed

5 files changed

+346
-125
lines changed

ROADMAP.md

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

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

962966
### P2.5 PWA & Offline (Real Sync)

packages/plugin-list/src/ListView.tsx

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export const ListView: React.FC<ListViewProps> = ({
287287
(schema.viewType as ViewType)
288288
);
289289
const [searchTerm, setSearchTerm] = React.useState('');
290+
const [showSearchPopover, setShowSearchPopover] = React.useState(false);
290291

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

1019-
{/* Airtable-style Toolbar — Row 2: Tool buttons */}
1020+
{/* Airtable-style Toolbar — Merged: UserFilter badges (left) + Tool buttons (right) */}
10201021
<div className="border-b px-2 sm:px-4 py-1 flex items-center justify-between gap-1 sm:gap-2 bg-background">
10211022
<div className="flex items-center gap-0.5 overflow-x-auto flex-1 min-w-0">
1022-
{/* Search (left end — Airtable-style) */}
1023-
{toolbarFlags.showSearch && (
1024-
<div className="relative w-48 shrink-0">
1025-
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
1026-
<Input
1027-
placeholder="Search..."
1028-
value={searchTerm}
1029-
onChange={(e) => handleSearchChange(e.target.value)}
1030-
className="pl-7 h-7 text-xs bg-muted/50 border-transparent hover:bg-muted focus:bg-background focus:border-input transition-colors"
1031-
/>
1032-
{searchTerm && (
1033-
<Button
1034-
variant="ghost"
1035-
size="sm"
1036-
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-muted-foreground/20"
1037-
onClick={() => handleSearchChange('')}
1038-
>
1039-
<X className="h-3 w-3" />
1040-
</Button>
1041-
)}
1042-
</div>
1043-
)}
1044-
1045-
{/* --- Separator: Search | Fields --- */}
1046-
{toolbarFlags.showSearch && toolbarFlags.showHideFields && (
1047-
<div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1023+
{/* User Filters — inline in toolbar (Airtable Interfaces-style) */}
1024+
{resolvedUserFilters && (
1025+
<>
1026+
<div className="shrink-0 min-w-0" data-testid="user-filters">
1027+
<UserFilters
1028+
config={resolvedUserFilters}
1029+
objectDef={objectDef}
1030+
data={data}
1031+
onFilterChange={setUserFilterConditions}
1032+
maxVisible={3}
1033+
/>
1034+
</div>
1035+
<div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1036+
</>
10481037
)}
10491038

10501039
{/* Hide Fields */}
@@ -1387,6 +1376,56 @@ export const ListView: React.FC<ListViewProps> = ({
13871376
<span className="hidden sm:inline">Print</span>
13881377
</Button>
13891378
)}
1379+
1380+
{/* --- Separator: Print/Share/Export | Search --- */}
1381+
{(() => {
1382+
const hasLeftSideItems = schema.allowPrinting || (schema.sharing?.enabled || schema.sharing?.type) || (resolvedExportOptions && schema.allowExport !== false);
1383+
return toolbarFlags.showSearch && hasLeftSideItems ? (
1384+
<div className="h-4 w-px bg-border/60 mx-0.5 shrink-0" />
1385+
) : null;
1386+
})()}
1387+
1388+
{/* Search (icon button + popover) */}
1389+
{toolbarFlags.showSearch && (
1390+
<Popover open={showSearchPopover} onOpenChange={setShowSearchPopover}>
1391+
<PopoverTrigger asChild>
1392+
<Button
1393+
variant="ghost"
1394+
size="sm"
1395+
className={cn(
1396+
"h-7 w-7 p-0 text-muted-foreground hover:text-primary text-xs transition-colors duration-150",
1397+
searchTerm && "bg-primary/10 border border-primary/20 text-primary"
1398+
)}
1399+
data-testid="search-icon-button"
1400+
title="Search"
1401+
>
1402+
<Search className="h-3.5 w-3.5" />
1403+
</Button>
1404+
</PopoverTrigger>
1405+
<PopoverContent align="end" className="w-64 p-2" data-testid="search-popover">
1406+
<div className="relative">
1407+
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
1408+
<Input
1409+
placeholder="Search..."
1410+
value={searchTerm}
1411+
onChange={(e) => handleSearchChange(e.target.value)}
1412+
className="pl-7 h-8 text-xs"
1413+
autoFocus
1414+
/>
1415+
{searchTerm && (
1416+
<Button
1417+
variant="ghost"
1418+
size="sm"
1419+
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-5 w-5 p-0 hover:bg-muted-foreground/20"
1420+
onClick={() => handleSearchChange('')}
1421+
>
1422+
<X className="h-3 w-3" />
1423+
</Button>
1424+
)}
1425+
</div>
1426+
</PopoverContent>
1427+
</Popover>
1428+
)}
13901429
</div>
13911430

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

1439-
{/* User Filters Row (Airtable Interfaces-style) */}
1440-
{resolvedUserFilters && (
1441-
<div className="border-b px-2 sm:px-4 py-1 bg-background" data-testid="user-filters">
1442-
<UserFilters
1443-
config={resolvedUserFilters}
1444-
objectDef={objectDef}
1445-
data={data}
1446-
onFilterChange={setUserFilterConditions}
1447-
/>
1448-
</div>
1449-
)}
1450-
14511478
{/* View Content */}
14521479
<div key={currentView} className="flex-1 min-h-0 bg-background relative overflow-hidden animate-in fade-in-0 duration-200">
14531480
{!loading && data.length === 0 ? (

packages/plugin-list/src/UserFilters.tsx

Lines changed: 105 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export interface UserFiltersProps {
3737
data?: any[];
3838
/** Callback when filter state changes */
3939
onFilterChange: (filters: any[]) => void;
40+
/** Maximum visible filter badges before collapsing into "More" dropdown (dropdown mode only) */
41+
maxVisible?: number;
4042
className?: string;
4143
}
4244

@@ -53,6 +55,7 @@ export function UserFilters({
5355
objectDef,
5456
data = [],
5557
onFilterChange,
58+
maxVisible,
5659
className,
5760
}: UserFiltersProps) {
5861
switch (config.element) {
@@ -63,6 +66,7 @@ export function UserFilters({
6366
objectDef={objectDef}
6467
data={data}
6568
onFilterChange={onFilterChange}
69+
maxVisible={maxVisible}
6670
className={className}
6771
/>
6872
);
@@ -138,10 +142,11 @@ interface DropdownFiltersProps {
138142
objectDef?: any;
139143
data: any[];
140144
onFilterChange: (filters: any[]) => void;
145+
maxVisible?: number;
141146
className?: string;
142147
}
143148

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

190+
// Split fields into visible and overflow based on maxVisible
191+
const visibleFields = maxVisible !== undefined && maxVisible < resolvedFields.length
192+
? resolvedFields.slice(0, maxVisible)
193+
: resolvedFields;
194+
const overflowFields = maxVisible !== undefined && maxVisible < resolvedFields.length
195+
? resolvedFields.slice(maxVisible)
196+
: [];
197+
198+
const renderBadge = (f: ResolvedField) => {
199+
const selected = selectedValues[f.field] || [];
200+
const hasSelection = selected.length > 0;
201+
202+
return (
203+
<Popover key={f.field}>
204+
<PopoverTrigger asChild>
205+
<button
206+
data-testid={`filter-badge-${f.field}`}
207+
className={cn(
208+
'inline-flex items-center gap-1 rounded-md border h-7 px-2.5 text-xs font-medium transition-colors shrink-0',
209+
hasSelection
210+
? 'border-primary/30 bg-primary/5 text-primary'
211+
: 'border-border bg-background hover:bg-accent text-foreground',
212+
)}
213+
>
214+
<span className="truncate max-w-[100px]">{f.label || f.field}</span>
215+
{hasSelection && (
216+
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px]">
217+
{selected.length}
218+
</span>
219+
)}
220+
{hasSelection ? (
221+
<X
222+
className="h-3 w-3 opacity-60"
223+
data-testid={`filter-clear-${f.field}`}
224+
onClick={e => {
225+
e.stopPropagation();
226+
handleChange(f.field, []);
227+
}}
228+
/>
229+
) : (
230+
<ChevronDown className="h-3 w-3 opacity-60" />
231+
)}
232+
</button>
233+
</PopoverTrigger>
234+
<PopoverContent align="start" className="w-56 p-2">
235+
<div className="max-h-60 overflow-y-auto space-y-0.5" data-testid={`filter-options-${f.field}`}>
236+
{f.options.map(opt => (
237+
<label
238+
key={String(opt.value)}
239+
className={cn(
240+
'flex items-center gap-2 text-sm py-1.5 px-2 rounded cursor-pointer',
241+
selected.includes(opt.value) ? 'bg-primary/5 text-primary' : 'hover:bg-muted',
242+
)}
243+
>
244+
<input
245+
type="checkbox"
246+
checked={selected.includes(opt.value)}
247+
onChange={() => {
248+
const next = selected.includes(opt.value)
249+
? selected.filter(v => v !== opt.value)
250+
: [...selected, opt.value];
251+
handleChange(f.field, next);
252+
}}
253+
className="rounded border-input"
254+
/>
255+
{opt.color && (
256+
<span
257+
className="h-2.5 w-2.5 rounded-full shrink-0"
258+
style={{ backgroundColor: opt.color }}
259+
/>
260+
)}
261+
<span className="truncate flex-1">{opt.label}</span>
262+
{opt.count !== undefined && (
263+
<span className="text-xs text-muted-foreground">{opt.count}</span>
264+
)}
265+
</label>
266+
))}
267+
</div>
268+
</PopoverContent>
269+
</Popover>
270+
);
271+
};
272+
185273
return (
186274
<div className={cn('flex items-center gap-1 overflow-x-auto', className)} data-testid="user-filters-dropdown">
187275
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
@@ -190,80 +278,30 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, className }:
190278
No filter fields
191279
</span>
192280
) : (
193-
resolvedFields.map(f => {
194-
const selected = selectedValues[f.field] || [];
195-
const hasSelection = selected.length > 0;
196-
197-
return (
198-
<Popover key={f.field}>
281+
<>
282+
{visibleFields.map(renderBadge)}
283+
{overflowFields.length > 0 && (
284+
<Popover>
199285
<PopoverTrigger asChild>
200286
<button
201-
data-testid={`filter-badge-${f.field}`}
202-
className={cn(
203-
'inline-flex items-center gap-1 rounded-md border h-7 px-2.5 text-xs font-medium transition-colors shrink-0',
204-
hasSelection
205-
? 'border-primary/30 bg-primary/5 text-primary'
206-
: 'border-border bg-background hover:bg-accent text-foreground',
207-
)}
287+
data-testid="user-filters-more"
288+
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"
208289
>
209-
<span className="truncate max-w-[100px]">{f.label || f.field}</span>
210-
{hasSelection && (
211-
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px]">
212-
{selected.length}
213-
</span>
214-
)}
215-
{hasSelection ? (
216-
<X
217-
className="h-3 w-3 opacity-60"
218-
data-testid={`filter-clear-${f.field}`}
219-
onClick={e => {
220-
e.stopPropagation();
221-
handleChange(f.field, []);
222-
}}
223-
/>
224-
) : (
225-
<ChevronDown className="h-3 w-3 opacity-60" />
226-
)}
290+
<span>More</span>
291+
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-muted text-[10px] font-medium">
292+
{overflowFields.length}
293+
</span>
294+
<ChevronDown className="h-3 w-3 opacity-60" />
227295
</button>
228296
</PopoverTrigger>
229-
<PopoverContent align="start" className="w-56 p-2">
230-
<div className="max-h-60 overflow-y-auto space-y-0.5" data-testid={`filter-options-${f.field}`}>
231-
{f.options.map(opt => (
232-
<label
233-
key={String(opt.value)}
234-
className={cn(
235-
'flex items-center gap-2 text-sm py-1.5 px-2 rounded cursor-pointer',
236-
selected.includes(opt.value) ? 'bg-primary/5 text-primary' : 'hover:bg-muted',
237-
)}
238-
>
239-
<input
240-
type="checkbox"
241-
checked={selected.includes(opt.value)}
242-
onChange={() => {
243-
const next = selected.includes(opt.value)
244-
? selected.filter(v => v !== opt.value)
245-
: [...selected, opt.value];
246-
handleChange(f.field, next);
247-
}}
248-
className="rounded border-input"
249-
/>
250-
{opt.color && (
251-
<span
252-
className="h-2.5 w-2.5 rounded-full shrink-0"
253-
style={{ backgroundColor: opt.color }}
254-
/>
255-
)}
256-
<span className="truncate flex-1">{opt.label}</span>
257-
{opt.count !== undefined && (
258-
<span className="text-xs text-muted-foreground">{opt.count}</span>
259-
)}
260-
</label>
261-
))}
297+
<PopoverContent align="start" className="w-64 p-2" data-testid="user-filters-more-content">
298+
<div className="space-y-1">
299+
{overflowFields.map(renderBadge)}
262300
</div>
263301
</PopoverContent>
264302
</Popover>
265-
);
266-
})
303+
)}
304+
</>
267305
)}
268306
<button
269307
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"

0 commit comments

Comments
 (0)