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..9f9d2148 100644
--- a/packages/fields/src/record-picker.test.tsx
+++ b/packages/fields/src/record-picker.test.tsx
@@ -626,3 +626,522 @@ 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');
+ });
+});
+
+// ------------- 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/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx
index 4be196e9..b14192f8 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;
@@ -54,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.
@@ -101,6 +124,26 @@ 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 = mapFieldTypeToFilterType(c.type);
+ 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 +556,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..ff9d4a17 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,89 @@ import {
ArrowUpDown,
ArrowUp,
ArrowDown,
+ SlidersHorizontal,
+ X,
} 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.
+ * Compatible with FilterUISchema.filters entries for easy bridging.
+ */
+export interface RecordPickerFilterColumn {
+ field: string;
+ label?: string;
+ type: 'text' | 'number' | 'select' | 'date' | 'boolean';
+ 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.
@@ -51,6 +135,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 +232,60 @@ 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[];
+
+ /**
+ * 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;
}
/**
@@ -104,6 +309,11 @@ export function RecordPickerDialog({
pageSize = DEFAULT_PAGE_SIZE,
value,
onSelect,
+ lookupFilters,
+ cellRenderer,
+ filterColumns,
+ renderFilterBar,
+ renderGrid,
}: RecordPickerDialogProps) {
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false);
@@ -124,6 +334,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 +351,63 @@ 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 => {
+ // Infer filter input type from value type first, then fall back to operator
+ let type: RecordPickerFilterColumn['type'] = 'text';
+ 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 array values (in/notIn), derive selectable options
+ ...(Array.isArray(f.value) ? {
+ 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 };
+ }),
+ } : {}),
+ };
+ });
+ }
+ 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 = effectiveFilterColumns?.length
+ ? filterValuesToRecord(filterValues, effectiveFilterColumns)
+ : {};
+ const combined = { ...baseFilter, ...userFilter };
+ return Object.keys(combined).length > 0 ? combined : undefined;
+ }, [lookupFilters, effectiveFilterColumns, 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, customFilter?: Record) => {
if (!dataSource || !objectName) return;
setLoading(true);
@@ -154,6 +424,11 @@ export function RecordPickerDialog({
if (sort) {
params.$orderby = { [sort.field]: sort.direction };
}
+ // Inject filters (lookup_filters + filter bar values)
+ const effectiveFilter = customFilter !== undefined ? customFilter : mergedFilter;
+ if (effectiveFilter && Object.keys(effectiveFilter).length > 0) {
+ params.$filter = effectiveFilter;
+ }
const result = await dataSource.find(objectName, params);
const data: any[] = result?.data ?? result ?? [];
@@ -169,7 +444,7 @@ export function RecordPickerDialog({
setLoading(false);
}
},
- [dataSource, objectName, pageSize],
+ [dataSource, objectName, pageSize, mergedFilter],
);
// Build current sort object for passing to fetchRecords
@@ -178,7 +453,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 +467,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 +612,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 +636,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 +648,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 (