Skip to content

Commit e115e92

Browse files
authored
Merge pull request #652 from objectstack-ai/copilot/fix-userfilters-rendering-issue
2 parents cfb5606 + 08076f3 commit e115e92

File tree

6 files changed

+304
-526
lines changed

6 files changed

+304
-526
lines changed

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ Full adoption of Cloud namespace, contracts/integration/security/studio modules,
216216
- [x] Implement empty state spec property
217217
- [x] Implement selection and pagination spec alignment
218218
- [x] Implement `quickFilters` and `userFilters` spec properties
219+
- [x] Auto-derive `userFilters` from objectDef (select/multi-select/boolean fields) when not explicitly configured
219220
- [x] Implement `hiddenFields` and `fieldOrder` spec properties
220221
- [x] Implement `emptyState` spec property
221222

packages/plugin-list/src/ListView.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,37 @@ export const ListView: React.FC<ListViewProps> = ({
237237
// User Filters State (Airtable Interfaces-style)
238238
const [userFilterConditions, setUserFilterConditions] = React.useState<any[]>([]);
239239

240+
// Auto-derive userFilters from objectDef when not explicitly configured
241+
const resolvedUserFilters = React.useMemo<ListViewSchema['userFilters'] | undefined>(() => {
242+
// If explicitly configured, use as-is
243+
if (schema.userFilters) return schema.userFilters;
244+
245+
// Auto-derive from objectDef for select/multi-select/boolean fields
246+
if (!objectDef?.fields) return undefined;
247+
248+
const FILTERABLE_FIELD_TYPES = new Set(['select', 'multi-select', 'boolean']);
249+
const derivedFields: NonNullable<NonNullable<ListViewSchema['userFilters']>['fields']> = [];
250+
251+
const fieldsEntries: Array<[string, any]> = Array.isArray(objectDef.fields)
252+
? objectDef.fields.map((f: any) => [f.name, f])
253+
: Object.entries(objectDef.fields);
254+
255+
for (const [key, field] of fieldsEntries) {
256+
// Include fields with a filterable type, or fields that have options without an explicit type
257+
if (FILTERABLE_FIELD_TYPES.has(field.type) || (field.options && !field.type)) {
258+
derivedFields.push({
259+
field: key,
260+
label: field.label || key,
261+
type: field.type === 'boolean' ? 'boolean' : field.type === 'multi-select' ? 'multi-select' : 'select',
262+
});
263+
}
264+
}
265+
266+
if (derivedFields.length === 0) return undefined;
267+
268+
return { element: 'dropdown', fields: derivedFields };
269+
}, [schema.userFilters, objectDef]);
270+
240271
// Hidden Fields State (initialized from schema)
241272
const [hiddenFields, setHiddenFields] = React.useState<Set<string>>(
242273
() => new Set(schema.hiddenFields || [])
@@ -970,10 +1001,10 @@ export const ListView: React.FC<ListViewProps> = ({
9701001
)}
9711002

9721003
{/* User Filters Row (Airtable Interfaces-style) */}
973-
{schema.userFilters && (
1004+
{resolvedUserFilters && (
9741005
<div className="border-b px-2 sm:px-4 py-1 bg-background" data-testid="user-filters">
9751006
<UserFilters
976-
config={schema.userFilters}
1007+
config={resolvedUserFilters}
9771008
objectDef={objectDef}
9781009
data={data}
9791010
onFilterChange={setUserFilterConditions}

packages/plugin-list/src/UserFilters.tsx

Lines changed: 86 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import * as React from 'react';
1010
import { cn, Button, Popover, PopoverContent, PopoverTrigger } from '@object-ui/components';
11-
import { ChevronDown, X, Plus } from 'lucide-react';
11+
import { ChevronDown, X, Plus, SlidersHorizontal } from 'lucide-react';
1212
import type { ListViewSchema } from '@object-ui/types';
1313

1414
/** Resolved option with optional count */
@@ -184,80 +184,95 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, className }:
184184

185185
return (
186186
<div className={cn('flex items-center gap-1 overflow-x-auto', className)} data-testid="user-filters-dropdown">
187-
{resolvedFields.map(f => {
188-
const selected = selectedValues[f.field] || [];
189-
const hasSelection = selected.length > 0;
187+
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
188+
{resolvedFields.length === 0 ? (
189+
<span className="text-xs text-muted-foreground" data-testid="user-filters-empty">
190+
No filter fields
191+
</span>
192+
) : (
193+
resolvedFields.map(f => {
194+
const selected = selectedValues[f.field] || [];
195+
const hasSelection = selected.length > 0;
190196

191-
return (
192-
<Popover key={f.field}>
193-
<PopoverTrigger asChild>
194-
<button
195-
data-testid={`filter-badge-${f.field}`}
196-
className={cn(
197-
'inline-flex items-center gap-1 rounded-md border h-7 px-2.5 text-xs font-medium transition-colors shrink-0',
198-
hasSelection
199-
? 'border-primary/30 bg-primary/5 text-primary'
200-
: 'border-border bg-background hover:bg-accent text-foreground',
201-
)}
202-
>
203-
<span className="truncate max-w-[100px]">{f.label || f.field}</span>
204-
{hasSelection && (
205-
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-primary/10 text-[10px]">
206-
{selected.length}
207-
</span>
208-
)}
209-
{hasSelection ? (
210-
<X
211-
className="h-3 w-3 opacity-60"
212-
data-testid={`filter-clear-${f.field}`}
213-
onClick={e => {
214-
e.stopPropagation();
215-
handleChange(f.field, []);
216-
}}
217-
/>
218-
) : (
219-
<ChevronDown className="h-3 w-3 opacity-60" />
220-
)}
221-
</button>
222-
</PopoverTrigger>
223-
<PopoverContent align="start" className="w-56 p-2">
224-
<div className="max-h-60 overflow-y-auto space-y-0.5" data-testid={`filter-options-${f.field}`}>
225-
{f.options.map(opt => (
226-
<label
227-
key={String(opt.value)}
228-
className={cn(
229-
'flex items-center gap-2 text-sm py-1.5 px-2 rounded cursor-pointer',
230-
selected.includes(opt.value) ? 'bg-primary/5 text-primary' : 'hover:bg-muted',
231-
)}
232-
>
233-
<input
234-
type="checkbox"
235-
checked={selected.includes(opt.value)}
236-
onChange={() => {
237-
const next = selected.includes(opt.value)
238-
? selected.filter(v => v !== opt.value)
239-
: [...selected, opt.value];
240-
handleChange(f.field, next);
197+
return (
198+
<Popover key={f.field}>
199+
<PopoverTrigger asChild>
200+
<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+
)}
208+
>
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, []);
241222
}}
242-
className="rounded border-input"
243223
/>
244-
{opt.color && (
245-
<span
246-
className="h-2.5 w-2.5 rounded-full shrink-0"
247-
style={{ backgroundColor: opt.color }}
224+
) : (
225+
<ChevronDown className="h-3 w-3 opacity-60" />
226+
)}
227+
</button>
228+
</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"
248249
/>
249-
)}
250-
<span className="truncate flex-1">{opt.label}</span>
251-
{opt.count !== undefined && (
252-
<span className="text-xs text-muted-foreground">{opt.count}</span>
253-
)}
254-
</label>
255-
))}
256-
</div>
257-
</PopoverContent>
258-
</Popover>
259-
);
260-
})}
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+
))}
262+
</div>
263+
</PopoverContent>
264+
</Popover>
265+
);
266+
})
267+
)}
268+
<button
269+
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"
270+
data-testid="user-filters-add"
271+
title="Add filter"
272+
>
273+
<Plus className="h-3.5 w-3.5" />
274+
<span className="hidden sm:inline">Add filter</span>
275+
</button>
261276
</div>
262277
);
263278
}

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

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,4 +471,131 @@ describe('ListView', () => {
471471
});
472472
expect(screen.queryByTestId('record-count-bar')).not.toBeInTheDocument();
473473
});
474+
475+
// ============================================
476+
// Auto-derived User Filters
477+
// ============================================
478+
describe('auto-derived userFilters', () => {
479+
it('should render userFilters when schema.userFilters is explicitly configured', () => {
480+
const schema: ListViewSchema = {
481+
type: 'list-view',
482+
objectName: 'contacts',
483+
viewType: 'grid',
484+
fields: ['name', 'status'],
485+
userFilters: {
486+
element: 'dropdown',
487+
fields: [
488+
{ field: 'status', label: 'Status', options: [{ label: 'Active', value: 'active' }] },
489+
],
490+
},
491+
};
492+
493+
renderWithProvider(<ListView schema={schema} />);
494+
expect(screen.getByTestId('user-filters')).toBeInTheDocument();
495+
expect(screen.getByTestId('user-filters-dropdown')).toBeInTheDocument();
496+
});
497+
498+
it('should auto-derive userFilters from objectDef select/boolean fields', async () => {
499+
const mockDs = {
500+
find: vi.fn().mockResolvedValue([]),
501+
findOne: vi.fn(),
502+
create: vi.fn(),
503+
update: vi.fn(),
504+
delete: vi.fn(),
505+
getObjectSchema: vi.fn().mockResolvedValue({
506+
name: 'tasks',
507+
fields: {
508+
name: { type: 'text', label: 'Name' },
509+
status: {
510+
type: 'select',
511+
label: 'Status',
512+
options: [
513+
{ label: 'Open', value: 'open' },
514+
{ label: 'Closed', value: 'closed' },
515+
],
516+
},
517+
is_active: { type: 'boolean', label: 'Active' },
518+
description: { type: 'text', label: 'Description' },
519+
},
520+
}),
521+
};
522+
523+
const schema: ListViewSchema = {
524+
type: 'list-view',
525+
objectName: 'tasks',
526+
viewType: 'grid',
527+
fields: ['name', 'status', 'is_active'],
528+
};
529+
530+
render(
531+
<SchemaRendererProvider dataSource={mockDs}>
532+
<ListView schema={schema} dataSource={mockDs} />
533+
</SchemaRendererProvider>
534+
);
535+
536+
// Wait for objectDef to load and userFilters to render
537+
await vi.waitFor(() => {
538+
expect(screen.getByTestId('user-filters')).toBeInTheDocument();
539+
});
540+
expect(screen.getByTestId('user-filters-dropdown')).toBeInTheDocument();
541+
// Should have badges for status and is_active (select + boolean)
542+
expect(screen.getByTestId('filter-badge-status')).toBeInTheDocument();
543+
expect(screen.getByTestId('filter-badge-is_active')).toBeInTheDocument();
544+
});
545+
546+
it('should show Add filter button in userFilters', () => {
547+
const schema: ListViewSchema = {
548+
type: 'list-view',
549+
objectName: 'contacts',
550+
viewType: 'grid',
551+
fields: ['name', 'status'],
552+
userFilters: {
553+
element: 'dropdown',
554+
fields: [
555+
{ field: 'status', label: 'Status', options: [{ label: 'Active', value: 'active' }] },
556+
],
557+
},
558+
};
559+
560+
renderWithProvider(<ListView schema={schema} />);
561+
expect(screen.getByTestId('user-filters-add')).toBeInTheDocument();
562+
});
563+
564+
it('should not render userFilters when objectDef has no filterable fields', async () => {
565+
const mockDs = {
566+
find: vi.fn().mockResolvedValue([]),
567+
findOne: vi.fn(),
568+
create: vi.fn(),
569+
update: vi.fn(),
570+
delete: vi.fn(),
571+
getObjectSchema: vi.fn().mockResolvedValue({
572+
name: 'notes',
573+
fields: {
574+
title: { type: 'text', label: 'Title' },
575+
body: { type: 'text', label: 'Body' },
576+
},
577+
}),
578+
};
579+
580+
const schema: ListViewSchema = {
581+
type: 'list-view',
582+
objectName: 'notes',
583+
viewType: 'grid',
584+
fields: ['title', 'body'],
585+
};
586+
587+
render(
588+
<SchemaRendererProvider dataSource={mockDs}>
589+
<ListView schema={schema} dataSource={mockDs} />
590+
</SchemaRendererProvider>
591+
);
592+
593+
// Wait for objectDef to load
594+
await vi.waitFor(() => {
595+
expect(mockDs.getObjectSchema).toHaveBeenCalled();
596+
});
597+
// userFilters should not render since no filterable fields
598+
expect(screen.queryByTestId('user-filters')).not.toBeInTheDocument();
599+
});
600+
});
474601
});

0 commit comments

Comments
 (0)