Skip to content

Commit e89e4a6

Browse files
Copilothotlong
andcommitted
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>
1 parent d25b8f5 commit e89e4a6

5 files changed

Lines changed: 744 additions & 25 deletions

File tree

packages/fields/src/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,11 @@ registerFieldRenderer('status', SelectCellRenderer);
888888
registerFieldRenderer('user', UserCellRenderer);
889889
registerFieldRenderer('owner', UserCellRenderer);
890890

891+
// Register getCellRenderer in the bridge so RecordPickerDialog can access it
892+
// via LookupField without circular imports.
893+
import { setCellRendererResolver } from './widgets/_cell-renderer-bridge';
894+
setCellRendererResolver(getCellRenderer);
895+
891896

892897

893898
/**

packages/fields/src/record-picker.test.tsx

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,3 +626,318 @@ describe('RecordPickerDialog — Keyboard Navigation', () => {
626626
});
627627
});
628628
});
629+
630+
// ------------- RecordPickerDialog — lookup_filters consumption -------------
631+
632+
describe('RecordPickerDialog — lookup_filters', () => {
633+
const basePickerProps = {
634+
open: true,
635+
onOpenChange: vi.fn(),
636+
dataSource: mockDataSource as any,
637+
objectName: 'customers',
638+
onSelect: vi.fn(),
639+
};
640+
641+
it('injects lookup_filters into $filter on every query', async () => {
642+
mockDataSource.find.mockResolvedValue({
643+
data: [{ id: '1', name: 'Active Customer' }],
644+
total: 1,
645+
});
646+
647+
render(
648+
<RecordPickerDialog
649+
{...basePickerProps}
650+
lookupFilters={[
651+
{ field: 'status', operator: 'eq', value: 'active' },
652+
]}
653+
/>,
654+
);
655+
656+
await waitFor(() => {
657+
expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
658+
$top: 10,
659+
$skip: 0,
660+
$filter: { status: 'active' },
661+
});
662+
});
663+
});
664+
665+
it('supports multiple lookup_filters with different operators', async () => {
666+
mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
667+
668+
render(
669+
<RecordPickerDialog
670+
{...basePickerProps}
671+
lookupFilters={[
672+
{ field: 'status', operator: 'eq', value: 'active' },
673+
{ field: 'category', operator: 'in', value: ['A', 'B'] },
674+
{ field: 'amount', operator: 'gte', value: 100 },
675+
]}
676+
/>,
677+
);
678+
679+
await waitFor(() => {
680+
expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
681+
$top: 10,
682+
$skip: 0,
683+
$filter: {
684+
status: 'active',
685+
category: { $in: ['A', 'B'] },
686+
amount: { $gte: 100 },
687+
},
688+
});
689+
});
690+
});
691+
692+
it('preserves lookup_filters when search query is added', async () => {
693+
mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
694+
695+
render(
696+
<RecordPickerDialog
697+
{...basePickerProps}
698+
lookupFilters={[
699+
{ field: 'status', operator: 'eq', value: 'active' },
700+
]}
701+
/>,
702+
);
703+
704+
await waitFor(() => {
705+
expect(mockDataSource.find).toHaveBeenCalledTimes(1);
706+
});
707+
708+
// Type in search
709+
await act(async () => {
710+
fireEvent.change(screen.getByTestId('record-picker-search'), {
711+
target: { value: 'acme' },
712+
});
713+
});
714+
715+
// Wait for debounce
716+
await waitFor(
717+
() => {
718+
expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
719+
$top: 10,
720+
$skip: 0,
721+
$search: 'acme',
722+
$filter: { status: 'active' },
723+
});
724+
},
725+
{ timeout: 500 },
726+
);
727+
});
728+
});
729+
730+
// ------------- RecordPickerDialog — Cell Type Formatter -------------
731+
732+
describe('RecordPickerDialog — Cell Type Formatter', () => {
733+
const basePickerProps = {
734+
open: true,
735+
onOpenChange: vi.fn(),
736+
dataSource: mockDataSource as any,
737+
objectName: 'items',
738+
onSelect: vi.fn(),
739+
};
740+
741+
it('uses cellRenderer for columns with type defined', async () => {
742+
const mockCellRenderer = vi.fn().mockReturnValue(
743+
({ value }: { value: any }) => <span data-testid="custom-rendered">{`FORMATTED:${value}`}</span>,
744+
);
745+
746+
mockDataSource.find.mockResolvedValue({
747+
data: [{ id: '1', name: 'Widget', amount: 99.5 }],
748+
total: 1,
749+
});
750+
751+
render(
752+
<RecordPickerDialog
753+
{...basePickerProps}
754+
columns={[
755+
{ field: 'name', label: 'Name' },
756+
{ field: 'amount', label: 'Amount', type: 'currency' },
757+
]}
758+
cellRenderer={mockCellRenderer}
759+
/>,
760+
);
761+
762+
await waitFor(() => {
763+
expect(screen.getByText('Widget')).toBeInTheDocument();
764+
});
765+
766+
// cellRenderer should have been called for the 'currency' type
767+
expect(mockCellRenderer).toHaveBeenCalledWith('currency');
768+
769+
// The formatted cell should be in the document
770+
expect(screen.getByTestId('custom-rendered')).toBeInTheDocument();
771+
expect(screen.getByText('FORMATTED:99.5')).toBeInTheDocument();
772+
});
773+
774+
it('falls back to plain text when no cellRenderer is provided', async () => {
775+
mockDataSource.find.mockResolvedValue({
776+
data: [{ id: '1', name: 'Widget', active: true }],
777+
total: 1,
778+
});
779+
780+
render(
781+
<RecordPickerDialog
782+
{...basePickerProps}
783+
columns={[
784+
{ field: 'name' },
785+
{ field: 'active', type: 'boolean' },
786+
]}
787+
/>,
788+
);
789+
790+
await waitFor(() => {
791+
expect(screen.getByText('Widget')).toBeInTheDocument();
792+
// Without cellRenderer, boolean should render as 'Yes'
793+
expect(screen.getByText('Yes')).toBeInTheDocument();
794+
});
795+
});
796+
797+
it('falls back to plain text when column has no type', async () => {
798+
const mockCellRenderer = vi.fn();
799+
800+
mockDataSource.find.mockResolvedValue({
801+
data: [{ id: '1', name: 'Widget' }],
802+
total: 1,
803+
});
804+
805+
render(
806+
<RecordPickerDialog
807+
{...basePickerProps}
808+
columns={[{ field: 'name' }]}
809+
cellRenderer={mockCellRenderer}
810+
/>,
811+
);
812+
813+
await waitFor(() => {
814+
expect(screen.getByText('Widget')).toBeInTheDocument();
815+
});
816+
817+
// cellRenderer should NOT be called for columns without type
818+
expect(mockCellRenderer).not.toHaveBeenCalled();
819+
});
820+
});
821+
822+
// ------------- RecordPickerDialog — FilterUI bar integration -------------
823+
824+
describe('RecordPickerDialog — Filter Bar', () => {
825+
const basePickerProps = {
826+
open: true,
827+
onOpenChange: vi.fn(),
828+
dataSource: mockDataSource as any,
829+
objectName: 'customers',
830+
onSelect: vi.fn(),
831+
};
832+
833+
it('renders filter bar toggle when filterColumns are provided', async () => {
834+
mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
835+
836+
render(
837+
<RecordPickerDialog
838+
{...basePickerProps}
839+
filterColumns={[
840+
{ field: 'status', label: 'Status', type: 'text' },
841+
]}
842+
/>,
843+
);
844+
845+
await waitFor(() => {
846+
expect(screen.getByTestId('record-picker-filter-bar')).toBeInTheDocument();
847+
expect(screen.getByText('Filters')).toBeInTheDocument();
848+
});
849+
});
850+
851+
it('does not render filter bar when no filterColumns', async () => {
852+
mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
853+
854+
render(<RecordPickerDialog {...basePickerProps} />);
855+
856+
await waitFor(() => {
857+
expect(screen.queryByTestId('record-picker-filter-bar')).not.toBeInTheDocument();
858+
});
859+
});
860+
861+
it('opens filter panel on toggle click', async () => {
862+
mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
863+
864+
render(
865+
<RecordPickerDialog
866+
{...basePickerProps}
867+
filterColumns={[
868+
{ field: 'name', label: 'Name', type: 'text' },
869+
]}
870+
/>,
871+
);
872+
873+
await waitFor(() => {
874+
expect(screen.getByTestId('record-picker-filter-bar')).toBeInTheDocument();
875+
});
876+
877+
// Filter panel should not be visible yet
878+
expect(screen.queryByTestId('record-picker-filter-panel')).not.toBeInTheDocument();
879+
880+
// Click Filters button
881+
await act(async () => {
882+
fireEvent.click(screen.getByText('Filters'));
883+
});
884+
885+
expect(screen.getByTestId('record-picker-filter-panel')).toBeInTheDocument();
886+
});
887+
});
888+
889+
// ------------- RecordPickerDialog — Column Resize Handles -------------
890+
891+
describe('RecordPickerDialog — Column Resize', () => {
892+
const basePickerProps = {
893+
open: true,
894+
onOpenChange: vi.fn(),
895+
dataSource: mockDataSource as any,
896+
objectName: 'customers',
897+
onSelect: vi.fn(),
898+
};
899+
900+
it('renders resize handles on column headers', async () => {
901+
mockDataSource.find.mockResolvedValue({
902+
data: [{ id: '1', name: 'Test', email: 'test@test.com' }],
903+
total: 1,
904+
});
905+
906+
render(
907+
<RecordPickerDialog
908+
{...basePickerProps}
909+
columns={['name', 'email']}
910+
/>,
911+
);
912+
913+
await waitFor(() => {
914+
expect(screen.getByText('Test')).toBeInTheDocument();
915+
});
916+
917+
// Resize handles should be present
918+
expect(screen.getByTestId('resize-handle-name')).toBeInTheDocument();
919+
expect(screen.getByTestId('resize-handle-email')).toBeInTheDocument();
920+
});
921+
922+
it('resize handles have col-resize cursor and separator role', async () => {
923+
mockDataSource.find.mockResolvedValue({
924+
data: [{ id: '1', name: 'Test' }],
925+
total: 1,
926+
});
927+
928+
render(
929+
<RecordPickerDialog
930+
{...basePickerProps}
931+
columns={['name']}
932+
/>,
933+
);
934+
935+
await waitFor(() => {
936+
expect(screen.getByText('Test')).toBeInTheDocument();
937+
});
938+
939+
const handle = screen.getByTestId('resize-handle-name');
940+
expect(handle).toHaveAttribute('role', 'separator');
941+
expect(handle.className).toContain('cursor-col-resize');
942+
});
943+
});

packages/fields/src/widgets/LookupField.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-r
1111
import { FieldWidgetProps } from './types';
1212
import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
1313
import { RecordPickerDialog } from './RecordPickerDialog';
14+
import type { RecordPickerFilterColumn } from './RecordPickerDialog';
15+
import { getCellRendererResolver } from './_cell-renderer-bridge';
1416

1517
export interface LookupOption {
1618
value: string | number;
@@ -101,6 +103,27 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
101103
// Enterprise Record Picker configuration
102104
const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns;
103105
const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size;
106+
const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters;
107+
108+
// Derive filter columns from lookup_columns that have type info
109+
const filterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
110+
if (!lookupColumns) return undefined;
111+
const cols: RecordPickerFilterColumn[] = [];
112+
for (const c of lookupColumns) {
113+
if (typeof c === 'object' && c.type) {
114+
const filterType = (['text', 'number', 'select', 'date', 'boolean'] as const)
115+
.find(t => t === c.type || (c.type === 'currency' && t === 'number') || (c.type === 'percent' && t === 'number'));
116+
if (filterType) {
117+
cols.push({
118+
field: c.field,
119+
label: c.label,
120+
type: filterType,
121+
});
122+
}
123+
}
124+
}
125+
return cols.length > 0 ? cols : undefined;
126+
}, [lookupColumns]);
104127

105128
// Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
106129
const ctx = useContext(SchemaRendererContext);
@@ -513,6 +536,9 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
513536
pageSize={lookupPageSize}
514537
value={value}
515538
onSelect={onChange}
539+
lookupFilters={lookupFilters}
540+
cellRenderer={getCellRendererResolver()}
541+
filterColumns={filterColumns}
516542
/>
517543
)}
518544
</div>

0 commit comments

Comments
 (0)