From d25b8f592ca1582558067f324295cb465c7e221c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:49:56 +0000 Subject: [PATCH 1/6] Initial plan From e89e4a6b6787e87d8746a66fcfda3117512b8131 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:02:09 +0000 Subject: [PATCH 2/6] feat(fields): implement RecordPicker lookup_filters, cell formatter, filter bar, column resize - Add lookupFilters prop to RecordPickerDialog for injecting base filters into $filter - Add cellRenderer prop for type-aware cell formatting (currency, date, select, boolean) - Add filterColumns prop for inline filter bar with text/number/select/date/boolean inputs - Add column resize handles with drag-to-resize support - Create _cell-renderer-bridge.ts to break circular dependency for getCellRenderer - LookupField now passes lookup_filters, getCellRenderer, and derived filterColumns - Add 11 comprehensive tests for all new features Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/fields/src/index.tsx | 5 + packages/fields/src/record-picker.test.tsx | 315 ++++++++++++++ packages/fields/src/widgets/LookupField.tsx | 26 ++ .../fields/src/widgets/RecordPickerDialog.tsx | 404 ++++++++++++++++-- .../src/widgets/_cell-renderer-bridge.ts | 19 + 5 files changed, 744 insertions(+), 25 deletions(-) create mode 100644 packages/fields/src/widgets/_cell-renderer-bridge.ts diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 76a57b7a..9181a779 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -888,6 +888,11 @@ registerFieldRenderer('status', SelectCellRenderer); registerFieldRenderer('user', UserCellRenderer); registerFieldRenderer('owner', UserCellRenderer); +// Register getCellRenderer in the bridge so RecordPickerDialog can access it +// via LookupField without circular imports. +import { setCellRendererResolver } from './widgets/_cell-renderer-bridge'; +setCellRendererResolver(getCellRenderer); + /** diff --git a/packages/fields/src/record-picker.test.tsx b/packages/fields/src/record-picker.test.tsx index deb5f0ef..ac5f0d72 100644 --- a/packages/fields/src/record-picker.test.tsx +++ b/packages/fields/src/record-picker.test.tsx @@ -626,3 +626,318 @@ describe('RecordPickerDialog — Keyboard Navigation', () => { }); }); }); + +// ------------- RecordPickerDialog — lookup_filters consumption ------------- + +describe('RecordPickerDialog — lookup_filters', () => { + const basePickerProps = { + open: true, + onOpenChange: vi.fn(), + dataSource: mockDataSource as any, + objectName: 'customers', + onSelect: vi.fn(), + }; + + it('injects lookup_filters into $filter on every query', async () => { + mockDataSource.find.mockResolvedValue({ + data: [{ id: '1', name: 'Active Customer' }], + total: 1, + }); + + render( + , + ); + + await waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalledWith('customers', { + $top: 10, + $skip: 0, + $filter: { status: 'active' }, + }); + }); + }); + + it('supports multiple lookup_filters with different operators', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + + render( + , + ); + + await waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalledWith('customers', { + $top: 10, + $skip: 0, + $filter: { + status: 'active', + category: { $in: ['A', 'B'] }, + amount: { $gte: 100 }, + }, + }); + }); + }); + + it('preserves lookup_filters when search query is added', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + + render( + , + ); + + await waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalledTimes(1); + }); + + // Type in search + await act(async () => { + fireEvent.change(screen.getByTestId('record-picker-search'), { + target: { value: 'acme' }, + }); + }); + + // Wait for debounce + await waitFor( + () => { + expect(mockDataSource.find).toHaveBeenCalledWith('customers', { + $top: 10, + $skip: 0, + $search: 'acme', + $filter: { status: 'active' }, + }); + }, + { timeout: 500 }, + ); + }); +}); + +// ------------- RecordPickerDialog — Cell Type Formatter ------------- + +describe('RecordPickerDialog — Cell Type Formatter', () => { + const basePickerProps = { + open: true, + onOpenChange: vi.fn(), + dataSource: mockDataSource as any, + objectName: 'items', + onSelect: vi.fn(), + }; + + it('uses cellRenderer for columns with type defined', async () => { + const mockCellRenderer = vi.fn().mockReturnValue( + ({ value }: { value: any }) => {`FORMATTED:${value}`}, + ); + + mockDataSource.find.mockResolvedValue({ + data: [{ id: '1', name: 'Widget', amount: 99.5 }], + total: 1, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + // cellRenderer should have been called for the 'currency' type + expect(mockCellRenderer).toHaveBeenCalledWith('currency'); + + // The formatted cell should be in the document + expect(screen.getByTestId('custom-rendered')).toBeInTheDocument(); + expect(screen.getByText('FORMATTED:99.5')).toBeInTheDocument(); + }); + + it('falls back to plain text when no cellRenderer is provided', async () => { + mockDataSource.find.mockResolvedValue({ + data: [{ id: '1', name: 'Widget', active: true }], + total: 1, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + // Without cellRenderer, boolean should render as 'Yes' + expect(screen.getByText('Yes')).toBeInTheDocument(); + }); + }); + + it('falls back to plain text when column has no type', async () => { + const mockCellRenderer = vi.fn(); + + mockDataSource.find.mockResolvedValue({ + data: [{ id: '1', name: 'Widget' }], + total: 1, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Widget')).toBeInTheDocument(); + }); + + // cellRenderer should NOT be called for columns without type + expect(mockCellRenderer).not.toHaveBeenCalled(); + }); +}); + +// ------------- RecordPickerDialog — FilterUI bar integration ------------- + +describe('RecordPickerDialog — Filter Bar', () => { + const basePickerProps = { + open: true, + onOpenChange: vi.fn(), + dataSource: mockDataSource as any, + objectName: 'customers', + onSelect: vi.fn(), + }; + + it('renders filter bar toggle when filterColumns are provided', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('record-picker-filter-bar')).toBeInTheDocument(); + expect(screen.getByText('Filters')).toBeInTheDocument(); + }); + }); + + it('does not render filter bar when no filterColumns', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('record-picker-filter-bar')).not.toBeInTheDocument(); + }); + }); + + it('opens filter panel on toggle click', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('record-picker-filter-bar')).toBeInTheDocument(); + }); + + // Filter panel should not be visible yet + expect(screen.queryByTestId('record-picker-filter-panel')).not.toBeInTheDocument(); + + // Click Filters button + await act(async () => { + fireEvent.click(screen.getByText('Filters')); + }); + + expect(screen.getByTestId('record-picker-filter-panel')).toBeInTheDocument(); + }); +}); + +// ------------- RecordPickerDialog — Column Resize Handles ------------- + +describe('RecordPickerDialog — Column Resize', () => { + const basePickerProps = { + open: true, + onOpenChange: vi.fn(), + dataSource: mockDataSource as any, + objectName: 'customers', + onSelect: vi.fn(), + }; + + it('renders resize handles on column headers', async () => { + mockDataSource.find.mockResolvedValue({ + data: [{ id: '1', name: 'Test', email: 'test@test.com' }], + total: 1, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + // Resize handles should be present + expect(screen.getByTestId('resize-handle-name')).toBeInTheDocument(); + expect(screen.getByTestId('resize-handle-email')).toBeInTheDocument(); + }); + + it('resize handles have col-resize cursor and separator role', async () => { + mockDataSource.find.mockResolvedValue({ + data: [{ id: '1', name: 'Test' }], + total: 1, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('Test')).toBeInTheDocument(); + }); + + const handle = screen.getByTestId('resize-handle-name'); + expect(handle).toHaveAttribute('role', 'separator'); + expect(handle.className).toContain('cursor-col-resize'); + }); +}); diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index 4be196e9..a479de5a 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -11,6 +11,8 @@ import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-r import { FieldWidgetProps } from './types'; import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types'; import { RecordPickerDialog } from './RecordPickerDialog'; +import type { RecordPickerFilterColumn } from './RecordPickerDialog'; +import { getCellRendererResolver } from './_cell-renderer-bridge'; export interface LookupOption { value: string | number; @@ -101,6 +103,27 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel // Enterprise Record Picker configuration const lookupColumns: Array | undefined = fieldMeta?.lookup_columns; const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size; + const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters; + + // Derive filter columns from lookup_columns that have type info + const filterColumns = useMemo(() => { + if (!lookupColumns) return undefined; + const cols: RecordPickerFilterColumn[] = []; + for (const c of lookupColumns) { + if (typeof c === 'object' && c.type) { + const filterType = (['text', 'number', 'select', 'date', 'boolean'] as const) + .find(t => t === c.type || (c.type === 'currency' && t === 'number') || (c.type === 'percent' && t === 'number')); + if (filterType) { + cols.push({ + field: c.field, + label: c.label, + type: filterType, + }); + } + } + } + return cols.length > 0 ? cols : undefined; + }, [lookupColumns]); // Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none const ctx = useContext(SchemaRendererContext); @@ -513,6 +536,9 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel pageSize={lookupPageSize} value={value} onSelect={onChange} + lookupFilters={lookupFilters} + cellRenderer={getCellRendererResolver()} + filterColumns={filterColumns} /> )} diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index aeb9345b..5a5ef5b3 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -8,6 +8,13 @@ import { DialogTitle, DialogFooter, Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Checkbox, Table, TableHeader, TableBody, @@ -26,12 +33,35 @@ import { ArrowUpDown, ArrowUp, ArrowDown, + SlidersHorizontal, + X, + GripVertical, } from 'lucide-react'; -import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types'; +import type { DataSource, QueryParams, LookupColumnDef, LookupFilterDef } from '@object-ui/types'; /** Default page size for the Record Picker dialog */ const DEFAULT_PAGE_SIZE = 10; +/** Minimum column width when resizing (px) */ +const MIN_COL_WIDTH = 60; + +/** + * Cell renderer function signature — matches getCellRenderer from @object-ui/fields. + * Accepts a field type and returns a React component that renders a formatted cell. + */ +export type CellRendererResolver = (fieldType: string) => React.FC<{ value: any; field: any }>; + +/** + * Filter column definition used by the inline filter bar. + * A subset of LookupColumnDef enriched with filter-specific metadata. + */ +export interface RecordPickerFilterColumn { + field: string; + label?: string; + type: 'text' | 'number' | 'select' | 'date' | 'boolean'; + options?: Array<{ label: string; value: any }>; +} + /** * Normalise a lookup_columns entry (string | LookupColumnDef) into a * concrete LookupColumnDef object. @@ -51,6 +81,73 @@ function fieldToLabel(field: string): string { .replace(/\b\w/g, c => c.toUpperCase()); } +/** + * Convert LookupFilterDef[] to a Record compatible with + * QueryParams.$filter. Supports operator mapping for eq/ne/gt/lt/gte/lte/ + * contains/in/notIn. + */ +function lookupFiltersToRecord( + filters: LookupFilterDef[], +): Record { + const result: Record = {}; + for (const f of filters) { + switch (f.operator) { + case 'eq': + result[f.field] = f.value; + break; + case 'ne': + result[f.field] = { $ne: f.value }; + break; + case 'gt': + result[f.field] = { $gt: f.value }; + break; + case 'lt': + result[f.field] = { $lt: f.value }; + break; + case 'gte': + result[f.field] = { $gte: f.value }; + break; + case 'lte': + result[f.field] = { $lte: f.value }; + break; + case 'contains': + result[f.field] = { $contains: f.value }; + break; + case 'in': + result[f.field] = { $in: f.value }; + break; + case 'notIn': + result[f.field] = { $nin: f.value }; + break; + } + } + return result; +} + +/** + * Convert user-entered filter bar values into a $filter Record. + * Each key is a field name, each value the user-entered value. + * Empty/null values are ignored. + */ +function filterValuesToRecord( + values: Record, + filterColumns: RecordPickerFilterColumn[], +): Record { + const result: Record = {}; + for (const col of filterColumns) { + const v = values[col.field]; + if (v === undefined || v === null || v === '') continue; + if (col.type === 'boolean') { + result[col.field] = Boolean(v); + } else if (col.type === 'text') { + result[col.field] = { $contains: v }; + } else { + result[col.field] = v; + } + } + return result; +} + export interface RecordPickerDialogProps { /** Whether the dialog is open */ open: boolean; @@ -81,6 +178,28 @@ export interface RecordPickerDialogProps { value?: any; /** Called when selection changes */ onSelect: (value: any) => void; + + /** + * Base filters applied to every query. + * Converted from LookupFieldMetadata.lookup_filters. + * Restricts which records are selectable (e.g. only active records). + */ + lookupFilters?: LookupFilterDef[]; + + /** + * Cell renderer resolver function. + * When provided, columns with a `type` property will be rendered using the + * resolved cell renderer (e.g. badges for select, formatted currency, etc.). + * Typically pass `getCellRenderer` from @object-ui/fields. + */ + cellRenderer?: CellRendererResolver; + + /** + * Filter bar column definitions. + * When provided, shows an inline filter bar below the search input. + * Columns can include type-specific inputs (text, number, select, date, boolean). + */ + filterColumns?: RecordPickerFilterColumn[]; } /** @@ -104,6 +223,9 @@ export function RecordPickerDialog({ pageSize = DEFAULT_PAGE_SIZE, value, onSelect, + lookupFilters, + cellRenderer, + filterColumns, }: RecordPickerDialogProps) { const [records, setRecords] = useState([]); const [loading, setLoading] = useState(false); @@ -124,6 +246,14 @@ export function RecordPickerDialog({ const [focusedRow, setFocusedRow] = useState(-1); const tableBodyRef = useRef(null); + // Filter bar state + const [filterBarOpen, setFilterBarOpen] = useState(false); + const [filterValues, setFilterValues] = useState>({}); + + // Column resize state: widths keyed by field name + const [columnWidths, setColumnWidths] = useState>({}); + const resizeRef = useRef<{ field: string; startX: number; startWidth: number } | null>(null); + // Resolved columns const resolvedColumns = useMemo(() => { if (columnsProp && columnsProp.length > 0) { @@ -133,11 +263,23 @@ export function RecordPickerDialog({ return [{ field: displayField, label: fieldToLabel(displayField) }]; }, [columnsProp, displayField]); + // Merge base lookup_filters with user filter bar values + const mergedFilter = useMemo | undefined>(() => { + const baseFilter = lookupFilters?.length + ? lookupFiltersToRecord(lookupFilters) + : {}; + const userFilter = filterColumns?.length + ? filterValuesToRecord(filterValues, filterColumns) + : {}; + const combined = { ...baseFilter, ...userFilter }; + return Object.keys(combined).length > 0 ? combined : undefined; + }, [lookupFilters, filterColumns, filterValues]); + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); // Fetch records const fetchRecords = useCallback( - async (search?: string, page = 1, sort?: { field: string; direction: 'asc' | 'desc' } | null) => { + async (search?: string, page = 1, sort?: { field: string; direction: 'asc' | 'desc' } | null, filterOverride?: Record) => { if (!dataSource || !objectName) return; setLoading(true); @@ -154,6 +296,11 @@ export function RecordPickerDialog({ if (sort) { params.$orderby = { [sort.field]: sort.direction }; } + // Inject filters (lookup_filters + filter bar values) + const activeFilter = filterOverride !== undefined ? filterOverride : mergedFilter; + if (activeFilter && Object.keys(activeFilter).length > 0) { + params.$filter = activeFilter; + } const result = await dataSource.find(objectName, params); const data: any[] = result?.data ?? result ?? []; @@ -169,7 +316,7 @@ export function RecordPickerDialog({ setLoading(false); } }, - [dataSource, objectName, pageSize], + [dataSource, objectName, pageSize, mergedFilter], ); // Build current sort object for passing to fetchRecords @@ -178,7 +325,7 @@ export function RecordPickerDialog({ [sortField, sortDirection], ); - // Fetch when dialog opens, page changes, or sort changes + // Fetch when dialog opens, page changes, sort changes, or filters change useEffect(() => { if (open) { fetchRecords(searchQuery || undefined, currentPage, currentSort); @@ -192,13 +339,16 @@ export function RecordPickerDialog({ setSortField(null); setSortDirection('asc'); setFocusedRow(-1); + setFilterBarOpen(false); + setFilterValues({}); + setColumnWidths({}); // Reset pending selection to match current value setPendingSelection(new Set( multiple ? (Array.isArray(value) ? value : []) : [], )); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, currentPage, currentSort]); + }, [open, currentPage, currentSort, mergedFilter]); // Initialize pending selection when dialog opens useEffect(() => { @@ -334,9 +484,19 @@ export function RecordPickerDialog({ } }, [focusedRow]); - // Get display value for a cell - const getCellValue = useCallback((record: any, field: string): string => { - const val = record[field]; + // Get display value for a cell — type-aware rendering when cellRenderer is provided + const renderCellContent = useCallback((record: any, col: LookupColumnDef): React.ReactNode => { + const val = record[col.field]; + + // Use type-aware renderer when column type and resolver are available + if (col.type && cellRenderer) { + const Renderer = cellRenderer(col.type); + if (Renderer) { + return ; + } + } + + // Fallback: plain text formatting if (val === null || val === undefined) return ''; if (typeof val === 'object') { // Handle MongoDB types / expanded references @@ -348,7 +508,7 @@ export function RecordPickerDialog({ } if (typeof val === 'boolean') return val ? 'Yes' : 'No'; return String(val); - }, []); + }, [cellRenderer]); // Render sort indicator for a column const renderSortIcon = useCallback((field: string) => { @@ -360,6 +520,140 @@ export function RecordPickerDialog({ : ; }, [sortField, sortDirection]); + // Column resize: mouse-down on drag handle + const handleResizeStart = useCallback( + (e: React.MouseEvent, field: string, currentWidth: number) => { + e.preventDefault(); + e.stopPropagation(); + resizeRef.current = { field, startX: e.clientX, startWidth: currentWidth }; + + const handleMouseMove = (moveEvt: MouseEvent) => { + if (!resizeRef.current) return; + const delta = moveEvt.clientX - resizeRef.current.startX; + const newWidth = Math.max(MIN_COL_WIDTH, resizeRef.current.startWidth + delta); + setColumnWidths(prev => ({ ...prev, [resizeRef.current!.field]: newWidth })); + }; + + const handleMouseUp = () => { + resizeRef.current = null; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, + [], + ); + + // Filter bar: update a single field value + const handleFilterChange = useCallback( + (field: string, val: any) => { + setFilterValues(prev => ({ ...prev, [field]: val })); + setCurrentPage(1); + }, + [], + ); + + // Filter bar: clear all filter values + const handleFilterClear = useCallback(() => { + setFilterValues({}); + setCurrentPage(1); + }, []); + + // Active filter count for badge + const activeFilterCount = useMemo( + () => Object.values(filterValues).filter(v => v !== undefined && v !== null && v !== '').length, + [filterValues], + ); + + // Render a single filter bar input + const renderFilterInput = useCallback( + (col: RecordPickerFilterColumn) => { + const val = filterValues[col.field]; + const label = col.label || fieldToLabel(col.field); + + switch (col.type) { + case 'select': + return ( +
+ + +
+ ); + case 'number': + return ( +
+ + { + const raw = e.target.value; + handleFilterChange(col.field, raw === '' ? '' : Number(raw)); + }} + /> +
+ ); + case 'date': + return ( +
+ + handleFilterChange(col.field, e.target.value)} + /> +
+ ); + case 'boolean': + return ( +
+ +
+ handleFilterChange(col.field, Boolean(checked))} + /> + Yes +
+
+ ); + case 'text': + default: + return ( +
+ + handleFilterChange(col.field, e.target.value)} + /> +
+ ); + } + }, + [filterValues, handleFilterChange], + ); + return ( + {/* Filter bar (inline) */} + {filterColumns && filterColumns.length > 0 && ( + <> +
+ + {activeFilterCount > 0 && ( + + )} +
+ {filterBarOpen && ( +
+ {filterColumns.map(col => ( +
{renderFilterInput(col)}
+ ))} +
+ )} + + + )} + {/* Error state */} {error && (
@@ -433,26 +770,43 @@ export function RecordPickerDialog({ role="grid" aria-label="Records" > - +
0 ? { tableLayout: 'fixed' } : undefined}> {multiple && ( )} - {resolvedColumns.map(col => ( - handleSort(col.field)} - aria-sort={sortField === col.field ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'} - > - - {col.label || fieldToLabel(col.field)} - {renderSortIcon(col.field)} - - - ))} + {resolvedColumns.map(col => { + const w = columnWidths[col.field]; + const styleWidth = w ? { width: `${w}px`, minWidth: `${w}px` } : col.width ? { width: col.width } : undefined; + return ( + handleSort(col.field)} + aria-sort={sortField === col.field ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'} + > + + {col.label || fieldToLabel(col.field)} + {renderSortIcon(col.field)} + + {/* Column resize handle */} + { + const th = e.currentTarget.parentElement; + const rect = th?.getBoundingClientRect(); + handleResizeStart(e, col.field, rect?.width ?? 100); + }} + onClick={e => e.stopPropagation()} + data-testid={`resize-handle-${col.field}`} + /> + + ); + })} @@ -481,7 +835,7 @@ export function RecordPickerDialog({ )} {resolvedColumns.map(col => ( - {getCellValue(record, col.field)} + {renderCellContent(record, col)} ))} diff --git a/packages/fields/src/widgets/_cell-renderer-bridge.ts b/packages/fields/src/widgets/_cell-renderer-bridge.ts new file mode 100644 index 00000000..6ee8ad2c --- /dev/null +++ b/packages/fields/src/widgets/_cell-renderer-bridge.ts @@ -0,0 +1,19 @@ +/** + * Internal bridge module to break the circular dependency between + * index.tsx (defines getCellRenderer) and RecordPickerDialog.tsx + * (consumes it). + * + * index.tsx registers the resolver via setCellRendererResolver(). + * LookupField reads it via getCellRendererResolver() to pass as a prop. + */ +import type { CellRendererResolver } from './RecordPickerDialog'; + +let _resolver: CellRendererResolver | undefined; + +export function setCellRendererResolver(resolver: CellRendererResolver): void { + _resolver = resolver; +} + +export function getCellRendererResolver(): CellRendererResolver | undefined { + return _resolver; +} From 7441d433e9c7b0d122a301a0363a08c21b91c4cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:05:07 +0000 Subject: [PATCH 3/6] refactor: address code review - rename variables, extract mapFieldTypeToFilterType helper Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/fields/src/widgets/LookupField.tsx | 24 +++++++++++++++++-- .../fields/src/widgets/RecordPickerDialog.tsx | 8 +++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index a479de5a..b14192f8 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -56,6 +56,27 @@ function recordToOption( return { value: val, label: String(label), description, ...record }; } +/** + * Map a LookupColumnDef.type to a filter input type for the filter bar. + * Returns undefined if the field type is not filterable. + */ +function mapFieldTypeToFilterType( + fieldType: string, +): RecordPickerFilterColumn['type'] | undefined { + const mapping: Record = { + text: 'text', + number: 'number', + currency: 'number', + percent: 'number', + select: 'select', + status: 'select', + date: 'date', + datetime: 'date', + boolean: 'boolean', + }; + return mapping[fieldType]; +} + /** * Lookup field for selecting related records. * Supports single and multi-select with search. @@ -111,8 +132,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel const cols: RecordPickerFilterColumn[] = []; for (const c of lookupColumns) { if (typeof c === 'object' && c.type) { - const filterType = (['text', 'number', 'select', 'date', 'boolean'] as const) - .find(t => t === c.type || (c.type === 'currency' && t === 'number') || (c.type === 'percent' && t === 'number')); + const filterType = mapFieldTypeToFilterType(c.type); if (filterType) { cols.push({ field: c.field, diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index 5a5ef5b3..f54457c2 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -279,7 +279,7 @@ export function RecordPickerDialog({ // Fetch records const fetchRecords = useCallback( - async (search?: string, page = 1, sort?: { field: string; direction: 'asc' | 'desc' } | null, filterOverride?: Record) => { + async (search?: string, page = 1, sort?: { field: string; direction: 'asc' | 'desc' } | null, customFilter?: Record) => { if (!dataSource || !objectName) return; setLoading(true); @@ -297,9 +297,9 @@ export function RecordPickerDialog({ params.$orderby = { [sort.field]: sort.direction }; } // Inject filters (lookup_filters + filter bar values) - const activeFilter = filterOverride !== undefined ? filterOverride : mergedFilter; - if (activeFilter && Object.keys(activeFilter).length > 0) { - params.$filter = activeFilter; + const effectiveFilter = customFilter !== undefined ? customFilter : mergedFilter; + if (effectiveFilter && Object.keys(effectiveFilter).length > 0) { + params.$filter = effectiveFilter; } const result = await dataSource.find(objectName, params); From a08a1042e31fd6bf64432a1d61e326ddd69b5c19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:08:01 +0000 Subject: [PATCH 4/6] chore: remove unused GripVertical import Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/fields/src/widgets/RecordPickerDialog.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index f54457c2..91d1344e 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -35,7 +35,6 @@ import { ArrowDown, SlidersHorizontal, X, - GripVertical, } from 'lucide-react'; import type { DataSource, QueryParams, LookupColumnDef, LookupFilterDef } from '@object-ui/types'; From d8e08bcff2f7d9bff33d96e0682f015ecf4bccee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:31:28 +0000 Subject: [PATCH 5/6] feat(fields): add renderFilterBar/renderGrid slots, auto-generate filters from lookupFilters - Add renderFilterBar slot prop for FilterUI injection from plugin-view - Add renderGrid slot prop for ObjectGrid injection from plugin-grid - Auto-generate filterColumns from lookupFilters when no explicit ones given - Export RecordPickerFilterBarProps and RecordPickerGridSlotProps interfaces - Add 6 new tests for slot props and auto-generation - Total: 37 record-picker tests, 412 fields tests passing Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/fields/src/record-picker.test.tsx | 204 +++++++ .../fields/src/widgets/RecordPickerDialog.tsx | 497 ++++++++++++------ 2 files changed, 534 insertions(+), 167 deletions(-) diff --git a/packages/fields/src/record-picker.test.tsx b/packages/fields/src/record-picker.test.tsx index ac5f0d72..9f9d2148 100644 --- a/packages/fields/src/record-picker.test.tsx +++ b/packages/fields/src/record-picker.test.tsx @@ -941,3 +941,207 @@ describe('RecordPickerDialog — Column Resize', () => { expect(handle.className).toContain('cursor-col-resize'); }); }); + +// ------------- RecordPickerDialog — renderFilterBar slot (FilterUI integration) --- + +describe('RecordPickerDialog — renderFilterBar slot', () => { + const basePickerProps = { + open: true, + onOpenChange: vi.fn(), + dataSource: mockDataSource as any, + objectName: 'customers', + onSelect: vi.fn(), + }; + + it('calls renderFilterBar with correct props when provided', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + + const renderFilterBar = vi.fn().mockReturnValue( +
Custom FilterUI
, + ); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('custom-filter-bar')).toBeInTheDocument(); + }); + + // renderFilterBar should have been called with FilterBarProps + expect(renderFilterBar).toHaveBeenCalledWith( + expect.objectContaining({ + filterColumns: expect.arrayContaining([ + expect.objectContaining({ field: 'status', type: 'select' }), + ]), + values: {}, + onChange: expect.any(Function), + onClear: expect.any(Function), + activeCount: 0, + }), + ); + }); + + it('hides built-in filter bar when renderFilterBar is provided', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + + render( +
External
} + />, + ); + + await waitFor(() => { + expect(screen.getByTestId('external-filter')).toBeInTheDocument(); + }); + + // Built-in filter panel toggle button should NOT be present + expect(screen.queryByTestId('record-picker-filter-panel')).not.toBeInTheDocument(); + }); +}); + +// ------------- RecordPickerDialog — renderGrid slot (ObjectGrid reuse) --- + +describe('RecordPickerDialog — renderGrid slot', () => { + const basePickerProps = { + open: true, + onOpenChange: vi.fn(), + dataSource: mockDataSource as any, + objectName: 'customers', + onSelect: vi.fn(), + }; + + it('renders external grid component via renderGrid slot', async () => { + mockDataSource.find.mockResolvedValue({ + data: [{ id: '1', name: 'Acme Corp' }], + total: 1, + }); + + const renderGrid = vi.fn().mockReturnValue( +
Custom ObjectGrid
, + ); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('record-picker-grid-slot')).toBeInTheDocument(); + expect(screen.getByTestId('custom-grid')).toBeInTheDocument(); + }); + + // renderGrid should have been called with grid slot props + expect(renderGrid).toHaveBeenCalledWith( + expect.objectContaining({ + columns: expect.arrayContaining([ + expect.objectContaining({ field: 'name' }), + ]), + records: expect.arrayContaining([ + expect.objectContaining({ id: '1', name: 'Acme Corp' }), + ]), + loading: false, + totalCount: 1, + currentPage: 1, + pageSize: 10, + sortField: null, + sortDirection: 'asc', + onSort: expect.any(Function), + onPageChange: expect.any(Function), + onRowClick: expect.any(Function), + isSelected: expect.any(Function), + multiple: false, + idField: 'id', + }), + ); + }); + + it('hides built-in table when renderGrid is provided', async () => { + mockDataSource.find.mockResolvedValue({ + data: [{ id: '1', name: 'Acme Corp' }], + total: 1, + }); + + render( +
Custom Grid
} + />, + ); + + await waitFor(() => { + expect(screen.getByText('Custom Grid')).toBeInTheDocument(); + }); + + // Built-in table should NOT be present + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + // Built-in pagination should NOT be present + expect(screen.queryByTestId('record-picker-pagination')).not.toBeInTheDocument(); + }); +}); + +// ------------- RecordPickerDialog — Auto-generated filterColumns from lookupFilters --- + +describe('RecordPickerDialog — Auto-generated filter bar from lookupFilters', () => { + const basePickerProps = { + open: true, + onOpenChange: vi.fn(), + dataSource: mockDataSource as any, + objectName: 'customers', + onSelect: vi.fn(), + }; + + it('auto-generates filter bar from lookupFilters when no filterColumns given', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + + render( + , + ); + + await waitFor(() => { + // Filter bar should appear because lookupFilters auto-generate filterColumns + expect(screen.getByTestId('record-picker-filter-bar')).toBeInTheDocument(); + }); + }); + + it('prefers explicit filterColumns over auto-generated ones', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + + const renderFilterBar = vi.fn().mockReturnValue(
Filters
); + + render( + , + ); + + await waitFor(() => { + expect(renderFilterBar).toHaveBeenCalled(); + }); + + // Should use the explicit filterColumns, not auto-generated ones + const calledProps = renderFilterBar.mock.calls[0][0]; + expect(calledProps.filterColumns[0].field).toBe('custom_field'); + }); +}); diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index 91d1344e..6f54b55a 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -53,6 +53,7 @@ export type CellRendererResolver = (fieldType: string) => React.FC<{ value: any; /** * Filter column definition used by the inline filter bar. * A subset of LookupColumnDef enriched with filter-specific metadata. + * Compatible with FilterUISchema.filters entries for easy bridging. */ export interface RecordPickerFilterColumn { field: string; @@ -61,6 +62,60 @@ export interface RecordPickerFilterColumn { options?: Array<{ label: string; value: any }>; } +/** + * Props passed to the custom filter bar renderer (renderFilterBar slot). + * Allows plugging in FilterUI or any custom component. + */ +export interface RecordPickerFilterBarProps { + /** Filter column definitions describing each filterable field */ + filterColumns: RecordPickerFilterColumn[]; + /** Current filter values keyed by field name */ + values: Record; + /** Called when a single filter value changes */ + onChange: (field: string, value: any) => void; + /** Clear all filter values */ + onClear: () => void; + /** Number of actively applied filters */ + activeCount: number; +} + +/** + * Props passed to the custom grid renderer (renderGrid slot). + * Allows plugging in ObjectGrid or any compatible table component. + */ +export interface RecordPickerGridSlotProps { + /** Resolved column definitions */ + columns: LookupColumnDef[]; + /** Current page of records */ + records: any[]; + /** Whether data is loading */ + loading: boolean; + /** Total record count across all pages */ + totalCount: number; + /** Current page number (1-based) */ + currentPage: number; + /** Records per page */ + pageSize: number; + /** Current sort field, null if unsorted */ + sortField: string | null; + /** Current sort direction */ + sortDirection: 'asc' | 'desc'; + /** Called when a column header is clicked to sort */ + onSort: (field: string) => void; + /** Called when page changes */ + onPageChange: (page: number) => void; + /** Called when a row is clicked */ + onRowClick: (record: any) => void; + /** Check if a record is selected */ + isSelected: (record: any) => boolean; + /** Whether multiple selection is enabled */ + multiple: boolean; + /** Record ID field name */ + idField: string; + /** Cell renderer resolver */ + cellRenderer?: CellRendererResolver; +} + /** * Normalise a lookup_columns entry (string | LookupColumnDef) into a * concrete LookupColumnDef object. @@ -199,6 +254,38 @@ export interface RecordPickerDialogProps { * Columns can include type-specific inputs (text, number, select, date, boolean). */ filterColumns?: RecordPickerFilterColumn[]; + + /** + * Custom filter bar renderer slot. + * When provided, replaces the built-in filter bar with a custom component + * (e.g. FilterUI from @object-ui/plugin-view). + * Receives filter state and callbacks via RecordPickerFilterBarProps. + * + * @example + * renderFilterBar={(props) => ( + * Object.entries(values).forEach(([k, v]) => props.onChange(k, v))} + * /> + * )} + */ + renderFilterBar?: (props: RecordPickerFilterBarProps) => React.ReactNode; + + /** + * Custom grid renderer slot. + * When provided, replaces the built-in table with a custom grid component + * (e.g. ObjectGrid from @object-ui/plugin-grid). + * Receives data, columns, and interaction callbacks via RecordPickerGridSlotProps. + * + * @example + * renderGrid={(props) => ( + * + * )} + */ + renderGrid?: (props: RecordPickerGridSlotProps) => React.ReactNode; } /** @@ -225,6 +312,8 @@ export function RecordPickerDialog({ lookupFilters, cellRenderer, filterColumns, + renderFilterBar, + renderGrid, }: RecordPickerDialogProps) { const [records, setRecords] = useState([]); const [loading, setLoading] = useState(false); @@ -262,17 +351,48 @@ export function RecordPickerDialog({ return [{ field: displayField, label: fieldToLabel(displayField) }]; }, [columnsProp, displayField]); + // Auto-generate filter columns from lookupFilters when no explicit filterColumns given. + // Each LookupFilterDef becomes a filterable field with inferred type. + const effectiveFilterColumns = useMemo(() => { + if (filterColumns && filterColumns.length > 0) return filterColumns; + // Auto-derive from lookupFilters: each filter entry becomes a filterable field + if (lookupFilters && lookupFilters.length > 0) { + return lookupFilters.map(f => { + let type: RecordPickerFilterColumn['type'] = 'text'; + if (f.operator === 'gt' || f.operator === 'lt' || f.operator === 'gte' || f.operator === 'lte') { + type = 'number'; + } else if (f.operator === 'in' || f.operator === 'notIn') { + type = 'select'; + } else if (typeof f.value === 'boolean') { + type = 'boolean'; + } else if (typeof f.value === 'number') { + type = 'number'; + } + return { + field: f.field, + label: fieldToLabel(f.field), + type, + // For 'in' filters, derive options from the value array + ...(Array.isArray(f.value) ? { + options: (f.value as any[]).map(v => ({ label: String(v), value: v })), + } : {}), + }; + }); + } + return undefined; + }, [filterColumns, lookupFilters]); + // Merge base lookup_filters with user filter bar values const mergedFilter = useMemo | undefined>(() => { const baseFilter = lookupFilters?.length ? lookupFiltersToRecord(lookupFilters) : {}; - const userFilter = filterColumns?.length - ? filterValuesToRecord(filterValues, filterColumns) + const userFilter = effectiveFilterColumns?.length + ? filterValuesToRecord(filterValues, effectiveFilterColumns) : {}; const combined = { ...baseFilter, ...userFilter }; return Object.keys(combined).length > 0 ? combined : undefined; - }, [lookupFilters, filterColumns, filterValues]); + }, [lookupFilters, effectiveFilterColumns, filterValues]); const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); @@ -686,44 +806,60 @@ export function RecordPickerDialog({ - {/* Filter bar (inline) */} - {filterColumns && filterColumns.length > 0 && ( + {/* Filter bar (inline) — supports external FilterUI via renderFilterBar slot */} + {effectiveFilterColumns && effectiveFilterColumns.length > 0 && ( <> -
- - {activeFilterCount > 0 && ( - - )} -
- {filterBarOpen && ( -
- {filterColumns.map(col => ( -
{renderFilterInput(col)}
- ))} + {renderFilterBar ? ( + /* External filter bar (e.g. FilterUI from plugin-view) */ +
+ {renderFilterBar({ + filterColumns: effectiveFilterColumns, + values: filterValues, + onChange: handleFilterChange, + onClear: handleFilterClear, + activeCount: activeFilterCount, + })}
+ ) : ( + /* Built-in filter bar (default) */ + <> +
+ + {activeFilterCount > 0 && ( + + )} +
+ {filterBarOpen && ( +
+ {effectiveFilterColumns.map(col => ( +
{renderFilterInput(col)}
+ ))} +
+ )} + )} @@ -745,142 +881,169 @@ export function RecordPickerDialog({
)} - {/* Loading state (initial) */} - {loading && records.length === 0 && !error && ( -
- -

Loading…

+ {/* Grid area — external ObjectGrid via renderGrid slot, or built-in table */} + {renderGrid ? ( + /* External grid component (e.g. ObjectGrid from plugin-grid) */ +
+ {renderGrid({ + columns: resolvedColumns, + records, + loading, + totalCount, + currentPage, + pageSize, + sortField, + sortDirection, + onSort: handleSort, + onPageChange: setCurrentPage, + onRowClick: handleRowClick, + isSelected, + multiple, + idField, + cellRenderer, + })}
- )} + ) : ( + /* Built-in table (default) */ + <> + {/* Loading state (initial) */} + {loading && records.length === 0 && !error && ( +
+ +

Loading…

+
+ )} - {/* Empty state */} - {!loading && !error && records.length === 0 && ( -
-

No records found

-
- )} + {/* Empty state */} + {!loading && !error && records.length === 0 && ( +
+

No records found

+
+ )} - {/* Table */} - {!error && records.length > 0 && ( -
-
0 ? { tableLayout: 'fixed' } : undefined}> - - - {multiple && ( - - )} - {resolvedColumns.map(col => { - const w = columnWidths[col.field]; - const styleWidth = w ? { width: `${w}px`, minWidth: `${w}px` } : col.width ? { width: col.width } : undefined; - return ( - handleSort(col.field)} - aria-sort={sortField === col.field ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'} - > - - {col.label || fieldToLabel(col.field)} - {renderSortIcon(col.field)} - - {/* Column resize handle */} - { - const th = e.currentTarget.parentElement; - const rect = th?.getBoundingClientRect(); - handleResizeStart(e, col.field, rect?.width ?? 100); - }} - onClick={e => e.stopPropagation()} - data-testid={`resize-handle-${col.field}`} - /> - - ); - })} - - - - {records.map((record, idx) => { - const rid = getRecordId(record); - const selected = isSelected(record); - const focused = idx === focusedRow; - - return ( - handleRowClick(record)} - data-testid={`record-row-${rid}`} - aria-selected={selected} - > + {/* Table */} + {!error && records.length > 0 && ( +
+
0 ? { tableLayout: 'fixed' } : undefined}> + + {multiple && ( - - {selected && } - + )} - {resolvedColumns.map(col => ( - - {renderCellContent(record, col)} - - ))} + {resolvedColumns.map(col => { + const w = columnWidths[col.field]; + const styleWidth = w ? { width: `${w}px`, minWidth: `${w}px` } : col.width ? { width: col.width } : undefined; + return ( + handleSort(col.field)} + aria-sort={sortField === col.field ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'} + > + + {col.label || fieldToLabel(col.field)} + {renderSortIcon(col.field)} + + {/* Column resize handle */} + { + const th = e.currentTarget.parentElement; + const rect = th?.getBoundingClientRect(); + handleResizeStart(e, col.field, rect?.width ?? 100); + }} + onClick={e => e.stopPropagation()} + data-testid={`resize-handle-${col.field}`} + /> + + ); + })} - ); - })} - -
-
- )} + + + {records.map((record, idx) => { + const rid = getRecordId(record); + const selected = isSelected(record); + const focused = idx === focusedRow; + + return ( + handleRowClick(record)} + data-testid={`record-row-${rid}`} + aria-selected={selected} + > + {multiple && ( + + {selected && } + + )} + {resolvedColumns.map(col => ( + + {renderCellContent(record, col)} + + ))} + + ); + })} + + + + )} - {/* Pagination */} - {!error && totalCount > 0 && ( - <> - -
- - {totalCount} {totalCount === 1 ? 'record' : 'records'} - {totalPages > 1 && ` · Page ${currentPage} of ${totalPages}`} - - {totalPages > 1 && ( -
- - + {/* Pagination */} + {!error && totalCount > 0 && ( + <> + +
+ + {totalCount} {totalCount === 1 ? 'record' : 'records'} + {totalPages > 1 && ` · Page ${currentPage} of ${totalPages}`} + + {totalPages > 1 && ( +
+ + +
+ )}
- )} -
+ + )} )} From 85c77f9ab3ae16eace2b80e818e2937e103c290a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 10:34:37 +0000 Subject: [PATCH 6/6] fix: improve filter type inference order and option label generation Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../fields/src/widgets/RecordPickerDialog.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/fields/src/widgets/RecordPickerDialog.tsx b/packages/fields/src/widgets/RecordPickerDialog.tsx index 6f54b55a..ff9d4a17 100644 --- a/packages/fields/src/widgets/RecordPickerDialog.tsx +++ b/packages/fields/src/widgets/RecordPickerDialog.tsx @@ -358,23 +358,32 @@ export function RecordPickerDialog({ // Auto-derive from lookupFilters: each filter entry becomes a filterable field if (lookupFilters && lookupFilters.length > 0) { return lookupFilters.map(f => { + // Infer filter input type from value type first, then fall back to operator let type: RecordPickerFilterColumn['type'] = 'text'; - if (f.operator === 'gt' || f.operator === 'lt' || f.operator === 'gte' || f.operator === 'lte') { - type = 'number'; - } else if (f.operator === 'in' || f.operator === 'notIn') { - type = 'select'; - } else if (typeof f.value === 'boolean') { + if (typeof f.value === 'boolean') { type = 'boolean'; + } else if (Array.isArray(f.value)) { + type = 'select'; } else if (typeof f.value === 'number') { type = 'number'; + } else if (f.operator === 'gt' || f.operator === 'lt' || f.operator === 'gte' || f.operator === 'lte') { + type = 'number'; + } else if (f.operator === 'in' || f.operator === 'notIn') { + type = 'select'; } return { field: f.field, label: fieldToLabel(f.field), type, - // For 'in' filters, derive options from the value array + // For array values (in/notIn), derive selectable options ...(Array.isArray(f.value) ? { - options: (f.value as any[]).map(v => ({ label: String(v), value: v })), + options: (f.value as any[]).map(v => { + if (v != null && typeof v === 'object') { + const obj = v as Record; + return { label: String(obj.name || obj.label || obj.title || v), value: v }; + } + return { label: String(v), value: v }; + }), } : {}), }; });