From 645b3ad0321a7dcae4187c07abcc9168a45b9631 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 01:53:02 +0000 Subject: [PATCH 1/3] Initial plan From 5ca9dd6b91d6695e951f10b62c3753fd0398b7e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:00:54 +0000 Subject: [PATCH 2/3] feat: add userFilters (dropdown/tabs/toggle) to ListView - Add userFilters type definition to ListViewSchema in @object-ui/types - Add Zod validation schema for userFilters configuration - Create UserFilters component with 3 rendering modes (dropdown/tabs/toggle) - Integrate UserFilters into ListView toolbar with data refetch - Export UserFilters from plugin-list - Add 25 unit tests for UserFilters component - Add 5 Zod validation tests for userFilters schema - Update ROADMAP.md to mark userFilters as completed Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 1 + packages/plugin-list/src/ListView.tsx | 21 +- packages/plugin-list/src/UserFilters.tsx | 410 ++++++++++++++++++ .../src/__tests__/UserFilters.test.tsx | 368 ++++++++++++++++ packages/plugin-list/src/index.tsx | 2 + .../src/__tests__/phase2-schemas.test.ts | 84 ++++ packages/types/src/objectql.ts | 58 +++ packages/types/src/zod/objectql.zod.ts | 44 ++ 8 files changed, 986 insertions(+), 2 deletions(-) create mode 100644 packages/plugin-list/src/UserFilters.tsx create mode 100644 packages/plugin-list/src/__tests__/UserFilters.test.tsx diff --git a/ROADMAP.md b/ROADMAP.md index d8dd527c4..87ef3e187 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -160,6 +160,7 @@ All 4 phases complete across 5 designers (Page, View, DataModel, Process, Report - [x] Implement `emptyState` spec property (custom no-data UI — critical for UX) - [x] Implement `hiddenFields` and `fieldOrder` spec properties (view customization) - [x] Implement `quickFilters` spec property (predefined filter buttons) +- [x] Implement `userFilters` spec property — Airtable Interfaces-style user filters (dropdown / tabs / toggle modes) ### P1. Spec Compliance — UI-Facing 📐 diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 3f3f2cd4a..7b0fead72 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -12,6 +12,7 @@ import type { SortItem } from '@object-ui/components'; import { Search, SlidersHorizontal, ArrowUpDown, X, EyeOff, Group, Paintbrush, Ruler, Inbox, Download, AlignJustify, Share2, icons, type LucideIcon } from 'lucide-react'; import type { FilterGroup } from '@object-ui/components'; import { ViewSwitcher, ViewType } from './ViewSwitcher'; +import { UserFilters } from './UserFilters'; import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react'; import { useDensityMode } from '@object-ui/react'; import type { ListViewSchema } from '@object-ui/types'; @@ -233,6 +234,9 @@ export const ListView: React.FC = ({ return defaults; }); + // User Filters State (Airtable Interfaces-style) + const [userFilterConditions, setUserFilterConditions] = React.useState([]); + // Hidden Fields State (initialized from schema) const [hiddenFields, setHiddenFields] = React.useState>( () => new Set(schema.hiddenFields || []) @@ -314,11 +318,12 @@ export const ListView: React.FC = ({ }); } - // Merge base filters, user filters, and quick filters + // Merge base filters, user filters, quick filters, and user filter bar conditions const allFilters = [ ...(baseFilter.length > 0 ? [baseFilter] : []), ...(userFilter.length > 0 ? [userFilter] : []), ...quickFilterConditions, + ...userFilterConditions, ]; if (allFilters.length > 1) { @@ -365,7 +370,7 @@ export const ListView: React.FC = ({ fetchData(); return () => { isMounted = false; }; - }, [schema.objectName, dataSource, schema.filters, currentSort, currentFilters, activeQuickFilters, refreshKey]); // Re-fetch on filter/sort change + }, [schema.objectName, dataSource, schema.filters, currentSort, currentFilters, activeQuickFilters, userFilterConditions, refreshKey]); // Re-fetch on filter/sort change // Available view types based on schema configuration const availableViews = React.useMemo(() => { @@ -964,6 +969,18 @@ export const ListView: React.FC = ({ )} + {/* User Filters Row (Airtable Interfaces-style) */} + {schema.userFilters && ( +
+ +
+ )} + {/* View Content */}
{!loading && data.length === 0 ? ( diff --git a/packages/plugin-list/src/UserFilters.tsx b/packages/plugin-list/src/UserFilters.tsx new file mode 100644 index 000000000..bed8618eb --- /dev/null +++ b/packages/plugin-list/src/UserFilters.tsx @@ -0,0 +1,410 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as React from 'react'; +import { cn, Button, Popover, PopoverContent, PopoverTrigger } from '@object-ui/components'; +import { ChevronDown, X, Plus } from 'lucide-react'; +import type { ListViewSchema } from '@object-ui/types'; + +/** Resolved option with optional count */ +interface ResolvedOption { + label: string; + value: string | number | boolean; + color?: string; + count?: number; +} + +/** Resolved field with options derived from objectDef when not provided */ +interface ResolvedField { + field: string; + label?: string; + type?: string; + options: ResolvedOption[]; + showCount?: boolean; + defaultValues?: (string | number | boolean)[]; +} + +export interface UserFiltersProps { + config: NonNullable; + /** Object definition for auto-deriving field options */ + objectDef?: any; + /** Current data for computing counts */ + data?: any[]; + /** Callback when filter state changes */ + onFilterChange: (filters: any[]) => void; + className?: string; +} + +/** + * UserFilters — Airtable Interfaces-style filter bar. + * + * Renders one of three modes based on `config.element`: + * - **dropdown**: field-level dropdown selector badges + * - **tabs**: named filter preset tab bar + * - **toggle**: on/off toggle buttons per field + */ +export function UserFilters({ + config, + objectDef, + data = [], + onFilterChange, + className, +}: UserFiltersProps) { + switch (config.element) { + case 'dropdown': + return ( + + ); + case 'tabs': + return ( + + ); + case 'toggle': + return ( + + ); + default: + return null; + } +} + +// ============================================ +// Shared helper — resolve field options +// ============================================ +function resolveFields( + fields: NonNullable['fields']>, + objectDef: any, + data: any[], +): ResolvedField[] { + return fields.map(f => { + let options: ResolvedOption[] = f.options ? [...f.options] : []; + if (options.length === 0 && objectDef?.fields) { + const fieldDef = + Array.isArray(objectDef.fields) + ? objectDef.fields.find((fd: any) => fd.name === f.field) + : objectDef.fields[f.field]; + if (fieldDef?.options) { + if (Array.isArray(fieldDef.options)) { + options = fieldDef.options.map((o: any) => ({ + label: o.label ?? String(o.value ?? o), + value: o.value ?? o, + color: o.color, + })); + } else { + options = Object.entries(fieldDef.options).map(([value, meta]) => ({ + label: (meta as any)?.label || value, + value, + color: (meta as any)?.color, + })); + } + } + } + if (f.showCount && data.length > 0) { + options = options.map(opt => ({ + ...opt, + count: data.filter(row => row[f.field] === opt.value).length, + })); + } + return { ...f, options }; + }); +} + +// ============================================ +// Dropdown Mode +// ============================================ +interface DropdownFiltersProps { + fields: NonNullable['fields']>; + objectDef?: any; + data: any[]; + onFilterChange: (filters: any[]) => void; + className?: string; +} + +function DropdownFilters({ fields, objectDef, data, onFilterChange, className }: DropdownFiltersProps) { + const [selectedValues, setSelectedValues] = React.useState< + Record + >(() => { + const init: Record = {}; + fields.forEach(f => { + if (f.defaultValues && f.defaultValues.length > 0) { + init[f.field] = f.defaultValues; + } + }); + return init; + }); + + const resolvedFields = React.useMemo( + () => resolveFields(fields, objectDef, data), + [fields, objectDef, data], + ); + + const emitFilters = React.useCallback( + (next: Record) => { + const conditions = Object.entries(next) + .filter(([, v]) => v.length > 0) + .map(([field, values]) => [field, 'in', values]); + onFilterChange(conditions); + }, + [onFilterChange], + ); + + const handleChange = (field: string, values: (string | number | boolean)[]) => { + const next = { ...selectedValues, [field]: values }; + setSelectedValues(next); + emitFilters(next); + }; + + // Emit default filters on mount + React.useEffect(() => { + const hasDefaults = Object.values(selectedValues).some(v => v.length > 0); + if (hasDefaults) emitFilters(selectedValues); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ {resolvedFields.map(f => { + const selected = selectedValues[f.field] || []; + const hasSelection = selected.length > 0; + + return ( + + + + + +
+ {f.options.map(opt => ( + + ))} +
+
+
+ ); + })} +
+ ); +} + +// ============================================ +// Tabs Mode +// ============================================ +interface TabFiltersProps { + tabs: NonNullable['tabs']>; + showAllRecords?: boolean; + allowAddTab?: boolean; + onFilterChange: (filters: any[]) => void; + className?: string; +} + +function TabFilters({ tabs, showAllRecords, allowAddTab, onFilterChange, className }: TabFiltersProps) { + const [activeTab, setActiveTab] = React.useState(() => { + const defaultTab = tabs.find(t => t.default); + return defaultTab?.id || (showAllRecords ? '__all__' : tabs[0]?.id || ''); + }); + + const handleTabChange = React.useCallback( + (tabId: string) => { + setActiveTab(tabId); + if (tabId === '__all__') { + onFilterChange([]); + } else { + const tab = tabs.find(t => t.id === tabId); + onFilterChange(tab?.filters || []); + } + }, + [tabs, onFilterChange], + ); + + const allTabs = React.useMemo(() => { + const result = [...tabs]; + if (showAllRecords) { + result.push({ id: '__all__', label: 'All records', filters: [] }); + } + return result; + }, [tabs, showAllRecords]); + + // Emit default tab filters on mount + React.useEffect(() => { + const defaultTab = tabs.find(t => t.default); + if (defaultTab) { + onFilterChange(defaultTab.filters || []); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ {allTabs.map(tab => ( + + ))} + {allowAddTab && ( + + )} +
+ ); +} + +// ============================================ +// Toggle Mode +// ============================================ +interface ToggleFiltersProps { + fields: NonNullable['fields']>; + objectDef?: any; + onFilterChange: (filters: any[]) => void; + className?: string; +} + +function ToggleFilters({ fields, objectDef: _objectDef, onFilterChange, className }: ToggleFiltersProps) { + const [activeToggles, setActiveToggles] = React.useState>(() => { + const defaults = new Set(); + fields.forEach(f => { + if (f.defaultValues && f.defaultValues.length > 0) defaults.add(f.field); + }); + return defaults; + }); + + const emitFilters = React.useCallback( + (active: Set) => { + const conditions = Array.from(active).map(fieldName => { + const fieldDef = fields.find(fd => fd.field === fieldName); + return fieldDef?.defaultValues + ? [fieldName, 'in', fieldDef.defaultValues] + : [fieldName, '!=', null]; + }); + onFilterChange(conditions); + }, + [fields, onFilterChange], + ); + + const handleToggle = (field: string) => { + setActiveToggles(prev => { + const next = new Set(prev); + if (next.has(field)) next.delete(field); + else next.add(field); + emitFilters(next); + return next; + }); + }; + + // Emit default filters on mount + React.useEffect(() => { + if (activeToggles.size > 0) emitFilters(activeToggles); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+ {fields.map(f => { + const isActive = activeToggles.has(f.field); + return ( + + ); + })} +
+ ); +} diff --git a/packages/plugin-list/src/__tests__/UserFilters.test.tsx b/packages/plugin-list/src/__tests__/UserFilters.test.tsx new file mode 100644 index 000000000..54f0e091a --- /dev/null +++ b/packages/plugin-list/src/__tests__/UserFilters.test.tsx @@ -0,0 +1,368 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { UserFilters } from '../UserFilters'; + +describe('UserFilters', () => { + // ============================================ + // Dropdown Mode + // ============================================ + describe('Dropdown mode', () => { + const dropdownConfig = { + element: 'dropdown' as const, + fields: [ + { + field: 'status', + label: 'Status', + type: 'multi-select' as const, + options: [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + ], + }, + { + field: 'priority', + label: 'Priority', + type: 'multi-select' as const, + options: [ + { label: 'High', value: 'high', color: '#dc2626' }, + { label: 'Low', value: 'low', color: '#2563eb' }, + ], + }, + ], + }; + + it('renders field badges with labels', () => { + const onChange = vi.fn(); + render(); + + expect(screen.getByTestId('user-filters-dropdown')).toBeInTheDocument(); + expect(screen.getByTestId('filter-badge-status')).toBeInTheDocument(); + expect(screen.getByTestId('filter-badge-priority')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Priority')).toBeInTheDocument(); + }); + + it('opens popover and shows options on click', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('filter-badge-status')); + expect(screen.getByTestId('filter-options-status')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Inactive')).toBeInTheDocument(); + }); + + it('selects option and emits filter change', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('filter-badge-status')); + fireEvent.click(screen.getByText('Active')); + + expect(onChange).toHaveBeenCalledWith([['status', 'in', ['active']]]); + }); + + it('supports multi-select — selecting multiple options', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('filter-badge-status')); + fireEvent.click(screen.getByText('Active')); + fireEvent.click(screen.getByText('Inactive')); + + expect(onChange).toHaveBeenLastCalledWith([['status', 'in', ['active', 'inactive']]]); + }); + + it('shows count badge when options are selected', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('filter-badge-status')); + fireEvent.click(screen.getByText('Active')); + + // Count badge should show "1" + const badge = screen.getByTestId('filter-badge-status'); + expect(badge.textContent).toContain('1'); + }); + + it('clears filter when X is clicked', () => { + const onChange = vi.fn(); + render(); + + // Select an option first + fireEvent.click(screen.getByTestId('filter-badge-status')); + fireEvent.click(screen.getByText('Active')); + + // Click the clear button + fireEvent.click(screen.getByTestId('filter-clear-status')); + expect(onChange).toHaveBeenLastCalledWith([]); + }); + + it('shows record count per option when showCount is true', () => { + const config = { + element: 'dropdown' as const, + fields: [{ + field: 'status', + label: 'Status', + showCount: true, + options: [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + ], + }], + }; + const data = [ + { status: 'active' }, + { status: 'active' }, + { status: 'inactive' }, + ]; + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('filter-badge-status')); + // The options list should show counts + const optionsContainer = screen.getByTestId('filter-options-status'); + expect(optionsContainer.textContent).toContain('2'); + expect(optionsContainer.textContent).toContain('1'); + }); + + it('renders color dots for options with color', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('filter-badge-priority')); + // Color dots should be rendered as span elements + const optionsContainer = screen.getByTestId('filter-options-priority'); + const colorDots = optionsContainer.querySelectorAll('span[style]'); + expect(colorDots.length).toBeGreaterThanOrEqual(2); + }); + + it('auto-derives options from objectDef when not provided', () => { + const config = { + element: 'dropdown' as const, + fields: [{ field: 'status', label: 'Status' }], + }; + const objectDef = { + fields: [ + { + name: 'status', + options: [ + { label: 'Open', value: 'open' }, + { label: 'Closed', value: 'closed' }, + ], + }, + ], + }; + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId('filter-badge-status')); + expect(screen.getByText('Open')).toBeInTheDocument(); + expect(screen.getByText('Closed')).toBeInTheDocument(); + }); + + it('applies defaultValues on mount', () => { + const config = { + element: 'dropdown' as const, + fields: [{ + field: 'status', + label: 'Status', + options: [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + ], + defaultValues: ['active'] as (string | number | boolean)[], + }], + }; + const onChange = vi.fn(); + render(); + + // Should emit default filter on mount + expect(onChange).toHaveBeenCalledWith([['status', 'in', ['active']]]); + }); + }); + + // ============================================ + // Tabs Mode + // ============================================ + describe('Tabs mode', () => { + const tabsConfig = { + element: 'tabs' as const, + showAllRecords: true, + allowAddTab: true, + tabs: [ + { id: 'tab-1', label: 'Active', filters: [['status', '=', 'active']], default: true }, + { id: 'tab-2', label: 'My Items', filters: [['owner', '=', '$currentUser']] }, + ], + }; + + it('renders tab bar with tab labels', () => { + const onChange = vi.fn(); + render(); + + expect(screen.getByTestId('user-filters-tabs')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('My Items')).toBeInTheDocument(); + }); + + it('renders "All records" tab when showAllRecords is true', () => { + const onChange = vi.fn(); + render(); + + expect(screen.getByText('All records')).toBeInTheDocument(); + }); + + it('renders add tab button when allowAddTab is true', () => { + const onChange = vi.fn(); + render(); + + expect(screen.getByTestId('filter-tab-add')).toBeInTheDocument(); + }); + + it('switches filters on tab click', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByText('My Items')); + expect(onChange).toHaveBeenCalledWith([['owner', '=', '$currentUser']]); + }); + + it('clears filters when "All records" tab is clicked', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByText('All records')); + expect(onChange).toHaveBeenCalledWith([]); + }); + + it('emits default tab filters on mount', () => { + const onChange = vi.fn(); + render(); + + // Default tab (tab-1) should emit its filters on mount + expect(onChange).toHaveBeenCalledWith([['status', '=', 'active']]); + }); + + it('hides "All records" tab when showAllRecords is false', () => { + const config = { + ...tabsConfig, + showAllRecords: false, + }; + const onChange = vi.fn(); + render(); + + expect(screen.queryByText('All records')).not.toBeInTheDocument(); + }); + + it('hides add button when allowAddTab is not set', () => { + const config = { + ...tabsConfig, + allowAddTab: false, + }; + const onChange = vi.fn(); + render(); + + expect(screen.queryByTestId('filter-tab-add')).not.toBeInTheDocument(); + }); + }); + + // ============================================ + // Toggle Mode + // ============================================ + describe('Toggle mode', () => { + const toggleConfig = { + element: 'toggle' as const, + fields: [ + { field: 'is_active', label: 'Active Only' }, + { field: 'is_vip', label: 'VIP' }, + ], + }; + + it('renders toggle buttons with labels', () => { + const onChange = vi.fn(); + render(); + + expect(screen.getByTestId('user-filters-toggle')).toBeInTheDocument(); + expect(screen.getByTestId('filter-toggle-is_active')).toBeInTheDocument(); + expect(screen.getByTestId('filter-toggle-is_vip')).toBeInTheDocument(); + expect(screen.getByText('Active Only')).toBeInTheDocument(); + expect(screen.getByText('VIP')).toBeInTheDocument(); + }); + + it('toggles button active state on click', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Active Only')); + expect(onChange).toHaveBeenCalledWith([['is_active', '!=', null]]); + + // Click again to deactivate + fireEvent.click(screen.getByText('Active Only')); + expect(onChange).toHaveBeenLastCalledWith([]); + }); + + it('supports multiple active toggles', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Active Only')); + fireEvent.click(screen.getByText('VIP')); + + // Both should be active, producing two filter conditions + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; + expect(lastCall).toHaveLength(2); + }); + + it('uses defaultValues for filter condition when provided', () => { + const config = { + element: 'toggle' as const, + fields: [ + { + field: 'status', + label: 'Active', + defaultValues: ['active', 'pending'] as (string | number | boolean)[], + }, + ], + }; + const onChange = vi.fn(); + render(); + + // Should emit default filter on mount + expect(onChange).toHaveBeenCalledWith([['status', 'in', ['active', 'pending']]]); + }); + }); + + // ============================================ + // Edge Cases + // ============================================ + describe('Edge cases', () => { + it('returns null for unknown element type', () => { + const config = { element: 'unknown' as any }; + const onChange = vi.fn(); + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('renders empty dropdown when no fields provided', () => { + const config = { element: 'dropdown' as const }; + const onChange = vi.fn(); + render(); + expect(screen.getByTestId('user-filters-dropdown')).toBeInTheDocument(); + }); + + it('renders empty tabs when no tabs provided', () => { + const config = { element: 'tabs' as const, showAllRecords: false }; + const onChange = vi.fn(); + render(); + expect(screen.getByTestId('user-filters-tabs')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index 5b523c5f3..b5b11a6d2 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -12,6 +12,8 @@ import { ViewSwitcher } from './ViewSwitcher'; import { ObjectGallery } from './ObjectGallery'; export { ListView, ViewSwitcher, ObjectGallery }; +export { UserFilters } from './UserFilters'; +export type { UserFiltersProps } from './UserFilters'; export { evaluateConditionalFormatting } from './ListView'; export type { ListViewProps } from './ListView'; export type { ObjectGalleryProps } from './ObjectGallery'; diff --git a/packages/types/src/__tests__/phase2-schemas.test.ts b/packages/types/src/__tests__/phase2-schemas.test.ts index d94e1119e..bc6657d11 100644 --- a/packages/types/src/__tests__/phase2-schemas.test.ts +++ b/packages/types/src/__tests__/phase2-schemas.test.ts @@ -27,6 +27,7 @@ import { FilterUISchema, SortUISchema, AnyComponentSchema, + ListViewSchema, } from '../zod/index.zod'; describe('Phase 2: AppSchema Zod Validation', () => { @@ -632,3 +633,86 @@ describe('Phase 2: AnyComponentSchema Union Type', () => { }); }); }); + +describe('ListViewSchema userFilters Zod Validation', () => { + it('should validate dropdown mode userFilters', () => { + const schema = { + type: 'list-view', + objectName: 'accounts', + userFilters: { + element: 'dropdown', + fields: [ + { + field: 'status', + label: 'Status', + type: 'multi-select', + showCount: true, + options: [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive', color: '#dc2626' }, + ], + defaultValues: ['active'], + }, + ], + }, + }; + const result = ListViewSchema.safeParse(schema); + expect(result.success).toBe(true); + }); + + it('should validate tabs mode userFilters', () => { + const schema = { + type: 'list-view', + objectName: 'accounts', + userFilters: { + element: 'tabs', + showAllRecords: true, + allowAddTab: true, + tabs: [ + { id: 'tab-1', label: 'Active', filters: [['status', '=', 'active']], default: true }, + { id: 'tab-2', label: 'My Items', filters: [['owner', '=', '$currentUser']] }, + ], + }, + }; + const result = ListViewSchema.safeParse(schema); + expect(result.success).toBe(true); + }); + + it('should validate toggle mode userFilters', () => { + const schema = { + type: 'list-view', + objectName: 'accounts', + userFilters: { + element: 'toggle', + fields: [ + { field: 'is_active', label: 'Active Only' }, + { field: 'is_vip', label: 'VIP', defaultValues: [true] }, + ], + }, + }; + const result = ListViewSchema.safeParse(schema); + expect(result.success).toBe(true); + }); + + it('should reject invalid element type', () => { + const schema = { + type: 'list-view', + objectName: 'accounts', + userFilters: { + element: 'invalid', + }, + }; + const result = ListViewSchema.safeParse(schema); + expect(result.success).toBe(false); + }); + + it('should validate ListViewSchema without userFilters (backward compat)', () => { + const schema = { + type: 'list-view', + objectName: 'accounts', + fields: ['name', 'email'], + }; + const result = ListViewSchema.safeParse(schema); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 06b73a9eb..fbab15f3d 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1202,6 +1202,64 @@ export interface ListViewSchema extends BaseSchema { /** Whether sharing controls are shown in the toolbar */ enabled?: boolean; }; + + /** + * User Filters Configuration (Airtable Interfaces-style). + * + * Supports three display modes configured by `element`: + * - 'dropdown': Each field renders as a dropdown selector badge (e.g., "Status ∨") + * - 'tabs': Named filter presets rendered as a tab bar (e.g., "Tab | my customers | All records") + * - 'toggle': Each filter field renders as an on/off toggle button + */ + userFilters?: { + /** UI element type for displaying filters */ + element: 'dropdown' | 'tabs' | 'toggle'; + + /** + * Field-level filter definitions (used by 'dropdown' and 'toggle' modes). + * Each field appears as an independent filter control in the toolbar. + */ + fields?: Array<{ + /** Field name to filter on */ + field: string; + /** Display label (defaults to field label from objectDef) */ + label?: string; + /** Filter input type */ + type?: 'select' | 'multi-select' | 'boolean' | 'date-range' | 'text'; + /** Static options (overrides auto-derived from objectDef) */ + options?: Array<{ + label: string; + value: string | number | boolean; + color?: string; + }>; + /** Show record count per option */ + showCount?: boolean; + /** Default selected values */ + defaultValues?: (string | number | boolean)[]; + }>; + + /** + * Named filter presets (used by 'tabs' mode). + * Each tab represents a pre-configured filter combination. + */ + tabs?: Array<{ + /** Unique tab identifier */ + id: string; + /** Tab display label */ + label: string; + /** Filter conditions to apply when this tab is active */ + filters: Array; + /** Icon name (Lucide icon identifier) */ + icon?: string; + /** Whether this is the default active tab */ + default?: boolean; + }>; + + /** Allow users to add new filter tabs at runtime */ + allowAddTab?: boolean; + /** Show "All records" tab (tabs mode) */ + showAllRecords?: boolean; + }; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 835b1cae2..68ee40997 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -193,6 +193,49 @@ export const ObjectViewSchema = BaseSchema.extend({ }).optional().describe('Enabled operations'), }); +/** + * User Filters — field-level filter option + */ +const UserFilterOptionSchema = z.object({ + label: z.string().describe('Option display label'), + value: z.union([z.string(), z.number(), z.boolean()]).describe('Option value'), + color: z.string().optional().describe('Option badge color'), +}); + +/** + * User Filters — field-level filter definition (dropdown & toggle modes) + */ +const UserFilterFieldSchema = z.object({ + field: z.string().describe('Field name to filter on'), + label: z.string().optional().describe('Display label'), + type: z.enum(['select', 'multi-select', 'boolean', 'date-range', 'text']).optional().describe('Filter input type'), + options: z.array(UserFilterOptionSchema).optional().describe('Static options'), + showCount: z.boolean().optional().describe('Show record count per option'), + defaultValues: z.array(z.union([z.string(), z.number(), z.boolean()])).optional().describe('Default selected values'), +}); + +/** + * User Filters — tab preset definition (tabs mode) + */ +const UserFilterTabSchema = z.object({ + id: z.string().describe('Unique tab identifier'), + label: z.string().describe('Tab display label'), + filters: z.array(z.union([z.array(z.any()), z.string()])).describe('Filter conditions'), + icon: z.string().optional().describe('Icon name'), + default: z.boolean().optional().describe('Default active tab'), +}); + +/** + * User Filters Configuration Schema (Airtable Interfaces-style) + */ +const UserFiltersSchema = z.object({ + element: z.enum(['dropdown', 'tabs', 'toggle']).describe('UI element type'), + fields: z.array(UserFilterFieldSchema).optional().describe('Field-level filters'), + tabs: z.array(UserFilterTabSchema).optional().describe('Named filter presets'), + allowAddTab: z.boolean().optional().describe('Allow adding new tabs'), + showAllRecords: z.boolean().optional().describe('Show All records tab'), +}); + /** * ListView Schema */ @@ -204,6 +247,7 @@ export const ListViewSchema = BaseSchema.extend({ filters: z.array(z.union([z.array(z.any()), z.string()])).optional().describe('Filter conditions'), sort: z.array(SortConfigSchema).optional().describe('Sort order'), options: z.record(z.string(), z.any()).optional().describe('Component overrides'), + userFilters: UserFiltersSchema.optional().describe('User filters configuration'), }); /** From 2210308627a38f0b9562a9a1bd1babfa8ca75c8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:03:21 +0000 Subject: [PATCH 3/3] refactor: remove unused objectDef param from ToggleFilters Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/plugin-list/src/UserFilters.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/plugin-list/src/UserFilters.tsx b/packages/plugin-list/src/UserFilters.tsx index bed8618eb..f41cabe87 100644 --- a/packages/plugin-list/src/UserFilters.tsx +++ b/packages/plugin-list/src/UserFilters.tsx @@ -80,7 +80,6 @@ export function UserFilters({ return ( @@ -345,12 +344,11 @@ function TabFilters({ tabs, showAllRecords, allowAddTab, onFilterChange, classNa // ============================================ interface ToggleFiltersProps { fields: NonNullable['fields']>; - objectDef?: any; onFilterChange: (filters: any[]) => void; className?: string; } -function ToggleFilters({ fields, objectDef: _objectDef, onFilterChange, className }: ToggleFiltersProps) { +function ToggleFilters({ fields, onFilterChange, className }: ToggleFiltersProps) { const [activeToggles, setActiveToggles] = React.useState>(() => { const defaults = new Set(); fields.forEach(f => {