-
Notifications
You must be signed in to change notification settings - Fork 2
fix: Auto-derive userFilters from objectDef when not explicitly configured #652
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 */ | ||||||||
|
|
@@ -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" | ||||||||
|
||||||||
| title="Add filter" | |
| title="Add filter" | |
| aria-label="Add filter" |
Copilot
AI
Feb 20, 2026
There was a problem hiding this comment.
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.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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(); | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| 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); | |
| }); |
There was a problem hiding this comment.
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
optionsproperty in the objectDef, so whenresolveFieldstries 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 inresolveFields, or (3) excluding boolean fields from auto-derivation in dropdown mode since they work better in toggle mode.