Skip to content

Commit a884191

Browse files
Copilothotlong
andcommitted
feat: address gap analysis — Popover typeahead, column sorting, keyboard nav, responsive, lookup_filters
- Convert LookupField Level 1 from Dialog to Popover for inline typeahead (Salesforce pattern) - Add column sorting with $orderby to RecordPickerDialog (clickable headers, aria-sort) - Add keyboard navigation (Arrow keys + Enter/Space) to RecordPickerDialog table - Add responsive dialog width (95vw mobile, max-w-2xl desktop) - Add LookupFilterDef type and lookup_filters to LookupFieldMetadata schema - Add type hint to LookupColumnDef for future getCellRenderer integration - Add 5 new tests for sorting, keyboard nav, responsive layout - Update CHANGELOG.md and lookup.mdx documentation Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 432ad9c commit a884191

7 files changed

Lines changed: 483 additions & 165 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- **RecordPickerDialog Component** (`@object-ui/fields`): New enterprise-grade record selection dialog with multi-column table display, pagination, search, loading/error/empty states, and single/multi-select support. Provides the foundation for Salesforce-style Lookup experience.
13-
- **LookupField Two-Level Interaction** (`@object-ui/fields`): LookupField now supports a "Show All Results" button in the quick-select dialog that opens the full RecordPickerDialog with table view, pagination, and multi-column display.
14-
- **LookupFieldMetadata Schema Enhancement** (`@object-ui/types`): Added `lookup_columns`, `description_field`, `lookup_page_size` properties to `LookupFieldMetadata` for configuring the Record Picker display. New `LookupColumnDef` interface for typed column definitions.
12+
- **RecordPickerDialog Component** (`@object-ui/fields`): New enterprise-grade record selection dialog with multi-column table display, pagination, search, column sorting with `$orderby`, keyboard navigation (Arrow keys + Enter), loading/error/empty states, and single/multi-select support. Responsive layout with mobile-friendly width. Provides the foundation for Salesforce-style Lookup experience.
13+
- **LookupField Popover Typeahead** (`@object-ui/fields`): Level 1 quick-select upgraded from Dialog to Popover for inline typeahead experience (anchored dropdown, not modal). Includes "Show All Results" footer button that opens the full RecordPickerDialog (Level 2).
14+
- **LookupFieldMetadata Schema Enhancement** (`@object-ui/types`): Added `lookup_columns`, `description_field`, `lookup_page_size`, `lookup_filters` to `LookupFieldMetadata`. New `LookupColumnDef` interface with `type` hint for cell formatting. New `LookupFilterDef` interface for base filter configuration.
1515

1616
### Changed
1717

content/docs/fields/lookup.mdx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,11 @@ When more results are available than displayed in the quick-select popup, a **"S
144144
The Record Picker dialog provides:
145145
- **Multi-column table** with configurable columns via `lookup_columns`
146146
- **Search** with debounced server-side querying
147+
- **Column sorting** via clickable headers (sends `$orderby` to DataSource)
147148
- **Pagination** with page-by-page navigation
148-
- **Single/Multi-select** with visual check indicators
149+
- **Keyboard navigation** — Arrow keys to move between rows, Enter/Space to select
150+
- **Single/Multi-select** with visual check indicators and confirmation flow
151+
- **Responsive layout** — Mobile-friendly width (95vw on small screens)
149152
- **Loading, error, and empty states**
150153
- Auto-inferred columns from `reference_field` when `lookup_columns` is not set
151154

@@ -175,15 +178,19 @@ import { LookupCellRenderer } from '@object-ui/fields';
175178

176179
## Features
177180

178-
- **Two-Level Interaction**: Quick-select popup + full Record Picker dialog
179-
- **Record Picker Dialog**: Enterprise-grade table with multi-column, pagination, search
181+
- **Two-Level Interaction**: Popover typeahead (Level 1) + full Record Picker dialog (Level 2)
182+
- **Record Picker Dialog**: Enterprise-grade table with multi-column, pagination, search, sorting
183+
- **Inline Popover**: Level 1 opens as anchored dropdown (non-modal) for fast typeahead
184+
- **Column Sorting**: Clickable column headers with `$orderby` server-side sort
180185
- **Dynamic DataSource Loading**: Automatically fetches records from referenced objects
181186
- **Search**: Debounced type-ahead search with `$search` parameter
182-
- **Multi-Select**: Support for multiple references
183-
- **Keyboard Navigation**: Arrow keys to navigate, Enter to select
187+
- **Multi-Select**: Support for multiple references with confirmation flow
188+
- **Keyboard Navigation**: Arrow keys to navigate rows, Enter to select in both levels
189+
- **Responsive**: Mobile-friendly width, adapts to screen size
184190
- **Loading/Error/Empty States**: Friendly feedback for all states
185191
- **Secondary Field Display**: Show description/subtitle per option
186192
- **Quick-Create Entry**: Optional "Create new" button when no results
187193
- **Configurable Columns**: `lookup_columns` for multi-column picker display
194+
- **Base Filters**: `lookup_filters` to restrict selectable records
188195
- **Pagination**: Page-by-page navigation in Record Picker dialog
189196
- **Backward Compatible**: Falls back to static options when no DataSource

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

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,3 +438,191 @@ describe('LookupField — Show All Results', () => {
438438
});
439439
});
440440
});
441+
442+
// ------------- RecordPickerDialog — Column Sorting -------------
443+
444+
describe('RecordPickerDialog — Column Sorting', () => {
445+
const basePickerProps = {
446+
open: true,
447+
onOpenChange: vi.fn(),
448+
dataSource: mockDataSource as any,
449+
objectName: 'customers',
450+
onSelect: vi.fn(),
451+
};
452+
453+
it('sends $orderby when a column header is clicked', async () => {
454+
mockDataSource.find.mockResolvedValue({
455+
data: [
456+
{ id: '1', name: 'Acme Corp', email: 'acme@test.com' },
457+
{ id: '2', name: 'Beta Inc', email: 'beta@test.com' },
458+
],
459+
total: 2,
460+
});
461+
462+
render(
463+
<RecordPickerDialog
464+
{...basePickerProps}
465+
columns={['name', 'email']}
466+
/>,
467+
);
468+
469+
await waitFor(() => {
470+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
471+
});
472+
473+
// Click the "Name" column header to sort
474+
await act(async () => {
475+
fireEvent.click(screen.getByText('Name'));
476+
});
477+
478+
await waitFor(() => {
479+
expect(mockDataSource.find).toHaveBeenCalledWith('customers', {
480+
$top: 10,
481+
$skip: 0,
482+
$orderby: { name: 'asc' },
483+
});
484+
});
485+
});
486+
487+
it('toggles sort direction on repeated column header click', async () => {
488+
mockDataSource.find.mockResolvedValue({
489+
data: [{ id: '1', name: 'Acme Corp' }],
490+
total: 1,
491+
});
492+
493+
render(
494+
<RecordPickerDialog
495+
{...basePickerProps}
496+
columns={['name']}
497+
/>,
498+
);
499+
500+
await waitFor(() => {
501+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
502+
});
503+
504+
// First click: asc
505+
await act(async () => {
506+
fireEvent.click(screen.getByText('Name'));
507+
});
508+
509+
await waitFor(() => {
510+
expect(mockDataSource.find).toHaveBeenCalledWith('customers', expect.objectContaining({
511+
$orderby: { name: 'asc' },
512+
}));
513+
});
514+
515+
// Second click: desc
516+
await act(async () => {
517+
fireEvent.click(screen.getByText('Name'));
518+
});
519+
520+
await waitFor(() => {
521+
expect(mockDataSource.find).toHaveBeenCalledWith('customers', expect.objectContaining({
522+
$orderby: { name: 'desc' },
523+
}));
524+
});
525+
});
526+
527+
it('renders sort indicators on column headers', async () => {
528+
mockDataSource.find.mockResolvedValue({
529+
data: [{ id: '1', name: 'Acme', email: 'test@test.com' }],
530+
total: 1,
531+
});
532+
533+
render(
534+
<RecordPickerDialog
535+
{...basePickerProps}
536+
columns={['name', 'email']}
537+
/>,
538+
);
539+
540+
await waitFor(() => {
541+
expect(screen.getByText('Acme')).toBeInTheDocument();
542+
});
543+
544+
// Column headers should have aria-sort="none" initially
545+
const nameHeader = screen.getByText('Name').closest('th');
546+
expect(nameHeader).toHaveAttribute('aria-sort', 'none');
547+
548+
// Click to sort
549+
await act(async () => {
550+
fireEvent.click(screen.getByText('Name'));
551+
});
552+
553+
await waitFor(() => {
554+
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
555+
});
556+
});
557+
});
558+
559+
// ------------- RecordPickerDialog — Keyboard Navigation -------------
560+
561+
describe('RecordPickerDialog — Keyboard Navigation', () => {
562+
const basePickerProps = {
563+
open: true,
564+
onOpenChange: vi.fn(),
565+
dataSource: mockDataSource as any,
566+
objectName: 'customers',
567+
onSelect: vi.fn(),
568+
};
569+
570+
it('navigates rows with arrow keys and selects with Enter', async () => {
571+
const onSelect = vi.fn();
572+
const onOpenChange = vi.fn();
573+
574+
mockDataSource.find.mockResolvedValue({
575+
data: [
576+
{ id: '1', name: 'Alpha' },
577+
{ id: '2', name: 'Beta' },
578+
{ id: '3', name: 'Gamma' },
579+
],
580+
total: 3,
581+
});
582+
583+
render(
584+
<RecordPickerDialog
585+
{...basePickerProps}
586+
onSelect={onSelect}
587+
onOpenChange={onOpenChange}
588+
/>,
589+
);
590+
591+
await waitFor(() => {
592+
expect(screen.getByText('Alpha')).toBeInTheDocument();
593+
});
594+
595+
// Focus the table container (has role="grid")
596+
const gridContainer = screen.getByRole('grid');
597+
gridContainer.focus();
598+
599+
// Arrow down twice: -1 → 0 (Alpha) → 1 (Beta)
600+
await act(async () => {
601+
fireEvent.keyDown(gridContainer, { key: 'ArrowDown' });
602+
});
603+
await act(async () => {
604+
fireEvent.keyDown(gridContainer, { key: 'ArrowDown' });
605+
});
606+
607+
// Press Enter to select Beta
608+
await act(async () => {
609+
fireEvent.keyDown(gridContainer, { key: 'Enter' });
610+
});
611+
612+
expect(onSelect).toHaveBeenCalledWith('2');
613+
expect(onOpenChange).toHaveBeenCalledWith(false);
614+
});
615+
616+
it('renders responsive dialog with mobile-friendly width', async () => {
617+
mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
618+
619+
render(<RecordPickerDialog {...basePickerProps} />);
620+
621+
await waitFor(() => {
622+
const dialog = screen.getByTestId('record-picker-dialog');
623+
expect(dialog).toBeInTheDocument();
624+
// Check responsive classes are applied
625+
expect(dialog.className).toContain('w-[95vw]');
626+
});
627+
});
628+
});

0 commit comments

Comments
 (0)