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
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ Full adoption of Cloud namespace, contracts/integration/security/studio modules,
- [x] Implement empty state spec property
- [x] Implement selection and pagination spec alignment
- [x] Implement `quickFilters` and `userFilters` spec properties
- [x] Auto-derive `userFilters` from objectDef (select/multi-select/boolean fields) when not explicitly configured
- [x] Implement `hiddenFields` and `fieldOrder` spec properties
- [x] Implement `emptyState` spec property

Expand Down
35 changes: 33 additions & 2 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,37 @@ export const ListView: React.FC<ListViewProps> = ({
// User Filters State (Airtable Interfaces-style)
const [userFilterConditions, setUserFilterConditions] = React.useState<any[]>([]);

// Auto-derive userFilters from objectDef when not explicitly configured
const resolvedUserFilters = React.useMemo<ListViewSchema['userFilters'] | undefined>(() => {
// If explicitly configured, use as-is
if (schema.userFilters) return schema.userFilters;

// Auto-derive from objectDef for select/multi-select/boolean fields
if (!objectDef?.fields) return undefined;

const FILTERABLE_FIELD_TYPES = new Set(['select', 'multi-select', 'boolean']);
const derivedFields: NonNullable<NonNullable<ListViewSchema['userFilters']>['fields']> = [];

const fieldsEntries: Array<[string, any]> = Array.isArray(objectDef.fields)
? objectDef.fields.map((f: any) => [f.name, f])
: Object.entries(objectDef.fields);

for (const [key, field] of fieldsEntries) {
// Include fields with a filterable type, or fields that have options without an explicit type
if (FILTERABLE_FIELD_TYPES.has(field.type) || (field.options && !field.type)) {
derivedFields.push({
field: key,
label: field.label || key,
type: field.type === 'boolean' ? 'boolean' : field.type === 'multi-select' ? 'multi-select' : 'select',
});
}
Comment on lines +255 to +263
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-derived boolean fields lack the options needed for dropdown filtering. The code adds boolean fields to derivedFields but doesn't ensure they have options. Boolean fields typically don't have an options property in the objectDef, so when resolveFields tries to derive options for these fields, it will find none, resulting in empty dropdowns. Consider either: (1) adding explicit boolean options here during derivation, (2) handling boolean fields specially in resolveFields, or (3) excluding boolean fields from auto-derivation in dropdown mode since they work better in toggle mode.

Copilot uses AI. Check for mistakes.
}

if (derivedFields.length === 0) return undefined;

return { element: 'dropdown', fields: derivedFields };
}, [schema.userFilters, objectDef]);

// Hidden Fields State (initialized from schema)
const [hiddenFields, setHiddenFields] = React.useState<Set<string>>(
() => new Set(schema.hiddenFields || [])
Expand Down Expand Up @@ -970,10 +1001,10 @@ export const ListView: React.FC<ListViewProps> = ({
)}

{/* User Filters Row (Airtable Interfaces-style) */}
{schema.userFilters && (
{resolvedUserFilters && (
<div className="border-b px-2 sm:px-4 py-1 bg-background" data-testid="user-filters">
<UserFilters
config={schema.userFilters}
config={resolvedUserFilters}
objectDef={objectDef}
data={data}
onFilterChange={setUserFilterConditions}
Expand Down
157 changes: 86 additions & 71 deletions packages/plugin-list/src/UserFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

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

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

return (
<div className={cn('flex items-center gap-1 overflow-x-auto', className)} data-testid="user-filters-dropdown">
{resolvedFields.map(f => {
const selected = selectedValues[f.field] || [];
const hasSelection = selected.length > 0;
<SlidersHorizontal className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
{resolvedFields.length === 0 ? (
<span className="text-xs text-muted-foreground" data-testid="user-filters-empty">
No filter fields
</span>
) : (
resolvedFields.map(f => {
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);
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, []);
}}
className="rounded border-input"
/>
{opt.color && (
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: opt.color }}
) : (
<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"
/>
)}
<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>
);
})}
{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>
);
})
)}
<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"
data-testid="user-filters-add"
title="Add filter"
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Add filter" button uses title attribute for tooltip but should also have an aria-label for better screen reader support, especially when the text is hidden on small screens (line 274 uses hidden sm:inline). This ensures the button's purpose is clear to screen reader users even when the text label is not visible. Consider adding aria-label="Add filter" to the button element.

Suggested change
title="Add filter"
title="Add filter"
aria-label="Add filter"

Copilot uses AI. Check for mistakes.
>
<Plus className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Add filter</span>
</button>
Comment on lines +268 to +275
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Add filter" button is rendered but has no onClick handler or functionality. It appears to be a placeholder for future implementation. Consider either: (1) adding a TODO comment indicating this is a placeholder for the filter configuration UI mentioned in the issue, (2) implementing the basic onClick behavior (even if it just shows an alert or console message), or (3) adding a comment in the code explaining that this is intentionally a non-functional visual entry point for now.

Copilot uses AI. Check for mistakes.
</div>
);
}
Expand Down
127 changes: 127 additions & 0 deletions packages/plugin-list/src/__tests__/ListView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -471,4 +471,131 @@ describe('ListView', () => {
});
expect(screen.queryByTestId('record-count-bar')).not.toBeInTheDocument();
});

// ============================================
// Auto-derived User Filters
// ============================================
describe('auto-derived userFilters', () => {
it('should render userFilters when schema.userFilters is explicitly configured', () => {
const schema: ListViewSchema = {
type: 'list-view',
objectName: 'contacts',
viewType: 'grid',
fields: ['name', 'status'],
userFilters: {
element: 'dropdown',
fields: [
{ field: 'status', label: 'Status', options: [{ label: 'Active', value: 'active' }] },
],
},
};

renderWithProvider(<ListView schema={schema} />);
expect(screen.getByTestId('user-filters')).toBeInTheDocument();
expect(screen.getByTestId('user-filters-dropdown')).toBeInTheDocument();
});

it('should auto-derive userFilters from objectDef select/boolean fields', async () => {
const mockDs = {
find: vi.fn().mockResolvedValue([]),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'tasks',
fields: {
name: { type: 'text', label: 'Name' },
status: {
type: 'select',
label: 'Status',
options: [
{ label: 'Open', value: 'open' },
{ label: 'Closed', value: 'closed' },
],
},
is_active: { type: 'boolean', label: 'Active' },
description: { type: 'text', label: 'Description' },
},
}),
};

const schema: ListViewSchema = {
type: 'list-view',
objectName: 'tasks',
viewType: 'grid',
fields: ['name', 'status', 'is_active'],
};

render(
<SchemaRendererProvider dataSource={mockDs}>
<ListView schema={schema} dataSource={mockDs} />
</SchemaRendererProvider>
);

// Wait for objectDef to load and userFilters to render
await vi.waitFor(() => {
expect(screen.getByTestId('user-filters')).toBeInTheDocument();
});
expect(screen.getByTestId('user-filters-dropdown')).toBeInTheDocument();
// Should have badges for status and is_active (select + boolean)
expect(screen.getByTestId('filter-badge-status')).toBeInTheDocument();
expect(screen.getByTestId('filter-badge-is_active')).toBeInTheDocument();
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test expects filter-badge-is_active to render for a boolean field, but doesn't verify that the boolean field's dropdown is functional (i.e., has options to select). Since boolean fields in objectDef typically don't have an options property, this test may be passing while the actual filter dropdown would be empty and non-functional. Consider adding an assertion that verifies the boolean field's dropdown contains usable options, or clicking on the badge to verify the popover opens with options.

Suggested change
expect(screen.getByTestId('filter-badge-is_active')).toBeInTheDocument();
const booleanFilterBadge = screen.getByTestId('filter-badge-is_active');
expect(booleanFilterBadge).toBeInTheDocument();
// Clicking the boolean filter badge should open a dropdown with usable options
fireEvent.click(booleanFilterBadge);
await vi.waitFor(() => {
// We expect at least one option to be available in the opened dropdown
const options = screen.getAllByRole('option');
expect(options.length).toBeGreaterThan(0);
});

Copilot uses AI. Check for mistakes.
});

it('should show Add filter button in userFilters', () => {
const schema: ListViewSchema = {
type: 'list-view',
objectName: 'contacts',
viewType: 'grid',
fields: ['name', 'status'],
userFilters: {
element: 'dropdown',
fields: [
{ field: 'status', label: 'Status', options: [{ label: 'Active', value: 'active' }] },
],
},
};

renderWithProvider(<ListView schema={schema} />);
expect(screen.getByTestId('user-filters-add')).toBeInTheDocument();
});

it('should not render userFilters when objectDef has no filterable fields', async () => {
const mockDs = {
find: vi.fn().mockResolvedValue([]),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({
name: 'notes',
fields: {
title: { type: 'text', label: 'Title' },
body: { type: 'text', label: 'Body' },
},
}),
};

const schema: ListViewSchema = {
type: 'list-view',
objectName: 'notes',
viewType: 'grid',
fields: ['title', 'body'],
};

render(
<SchemaRendererProvider dataSource={mockDs}>
<ListView schema={schema} dataSource={mockDs} />
</SchemaRendererProvider>
);

// Wait for objectDef to load
await vi.waitFor(() => {
expect(mockDs.getObjectSchema).toHaveBeenCalled();
});
// userFilters should not render since no filterable fields
expect(screen.queryByTestId('user-filters')).not.toBeInTheDocument();
});
});
});
Loading