diff --git a/ROADMAP.md b/ROADMAP.md index a50da4a04..9986f7ca4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,9 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [ ] Advanced lookup: dependent lookups (filter based on other fields) - [ ] Hierarchical lookups (parent-child relationships) - [ ] Lookup result caching +- [x] Lookup field dynamic DataSource loading — popup fetches records via `DataSource.find()` with `$search` debounce, loading/error/empty states +- [x] Lookup field context DataSource — reads DataSource from SchemaRendererContext so forms work without explicit prop +- [x] Lookup field UX polish — arrow key navigation, description field display, quick-create entry, ARIA listbox roles - [ ] Form conditional logic with branching - [ ] Multi-page forms with progress indicator diff --git a/content/docs/fields/lookup.mdx b/content/docs/fields/lookup.mdx index c30c86f43..a8831e4b0 100644 --- a/content/docs/fields/lookup.mdx +++ b/content/docs/fields/lookup.mdx @@ -5,7 +5,7 @@ description: "Reference field for linking to other objects/records" import { ComponentDemo, DemoGrid } from '@/app/components/ComponentDemo'; -The Lookup Field component provides a reference field for creating relationships between objects and records. +The Lookup Field component provides a reference field for creating relationships between objects and records. It supports dynamic data loading from a DataSource with debounced search, loading/error/empty states, keyboard navigation, and optional quick-create entry. ## Basic Usage @@ -62,24 +62,51 @@ interface LookupFieldSchema { // Reference Configuration reference_to: string; // Referenced object/table name reference_field?: string; // Field to display (default: 'name') + description_field?: string; // Secondary field shown below label + id_field?: string; // ID field on records (default: '_id') options?: LookupOption[]; // Available options (if static) - // Data Source (for dynamic lookups) - dataSource?: { - api?: string; // API endpoint - method?: string; // HTTP method - params?: Record; // Query parameters - }; + // Data Source (automatic via SchemaRendererContext, or explicit) + // When a DataSource is available, the popup dynamically loads + // records from the referenced object on open, with debounced search. + dataSource?: DataSource; + + // Quick-create callback (shown when no results found) + onCreateNew?: (searchQuery: string) => void; } interface LookupOption { label: string; // Display label value: string; // Record ID + description?: string; // Secondary text below label _id?: string; // Alternative ID field name?: string; // Alternative label field } ``` +## Dynamic Data Source + +When a `DataSource` is available (via `SchemaRendererContext`, explicit prop, or field config), the Lookup popup **automatically** fetches records from the referenced object: + +```plaintext +// Automatic — DataSource from SchemaRendererContext +// (works out-of-the-box in ObjectForm, DrawerForm, etc.) +{ + type: 'lookup', + name: 'customer', + label: 'Customer', + reference_to: 'customers', + reference_field: 'name', // Display field (default: 'name') + description_field: 'industry', // Optional secondary field +} +``` + +The popup will: +1. Fetch records via `dataSource.find(reference_to, { $top: 50 })` on open +2. Send `$search` queries with 300ms debounce as the user types +3. Show loading spinner, error state with retry, and empty state +4. Display "Showing X of Y" when more records exist than the page size + ## Lookup vs Master-Detail - **Lookup**: Standard reference field, can be deleted independently @@ -104,31 +131,14 @@ import { LookupCellRenderer } from '@object-ui/fields'; - **Parent Records**: Master-detail relationships - **Team Members**: Multi-user references -## Dynamic Data Source - -For lookups that fetch data from an API: - -```plaintext -{ - type: 'lookup', - name: 'customer', - label: 'Customer', - reference_to: 'customers', - dataSource: { - api: '/api/customers', - method: 'GET', - params: { - active: true, - limit: 100 - } - } -} -``` - ## Features -- **Search**: Type-ahead search in options +- **Dynamic DataSource Loading**: Automatically fetches records from referenced objects +- **Search**: Debounced type-ahead search with `$search` parameter - **Multi-Select**: Support for multiple references -- **Lazy Loading**: Load options on demand -- **Relationships**: Create data relationships -- **Cascading**: Support for dependent lookups +- **Keyboard Navigation**: Arrow keys to navigate, Enter to select +- **Loading/Error/Empty States**: Friendly feedback for all states +- **Secondary Field Display**: Show description/subtitle per option +- **Quick-Create Entry**: Optional "Create new" button when no results +- **Pagination Hint**: Shows total count when more results available +- **Backward Compatible**: Falls back to static options when no DataSource diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 1315453b5..2782ceeb1 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -27,6 +27,7 @@ import { Alert, AlertDescription } from '../../ui/alert'; import { AlertCircle, Loader2 } from 'lucide-react'; import { cn } from '../../lib/utils'; import React from 'react'; +import { SchemaRendererContext } from '@object-ui/react'; // Form renderer component - Airtable-style feature-complete form ComponentRegistry.register('form', @@ -57,6 +58,11 @@ ComponentRegistry.register('form', const [isSubmitting, setIsSubmitting] = React.useState(false); const [submitError, setSubmitError] = React.useState(null); + // Read DataSource from SchemaRendererContext and propagate it to field + // widgets as a prop so they can dynamically load related records. + const schemaCtx = React.useContext(SchemaRendererContext); + const contextDataSource = schemaCtx?.dataSource ?? null; + // React to defaultValues changes React.useEffect(() => { form.reset(defaultValues); @@ -313,6 +319,7 @@ ComponentRegistry.register('form', options: fieldProps.options, placeholder: fieldProps.placeholder, disabled: disabled || fieldDisabled || readonly || isSubmitting, + dataSource: contextDataSource, })} {description && ( diff --git a/packages/fields/src/complex-widgets.test.tsx b/packages/fields/src/complex-widgets.test.tsx index 66261ccc5..5c0205b64 100644 --- a/packages/fields/src/complex-widgets.test.tsx +++ b/packages/fields/src/complex-widgets.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { LookupField } from './widgets/LookupField'; import { MasterDetailField } from './widgets/MasterDetailField'; import { GridField } from './widgets/GridField'; @@ -60,6 +60,430 @@ describe('Complex & Relationship Widgets', () => { }); }); + describe('LookupField — Dynamic DataSource', () => { + const mockDataSource = { + find: vi.fn(), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + const dynamicField = { + ...mockField, + label: 'Customer', + reference_to: 'customers', + reference_field: 'name', + } as any; + + const dynamicProps: FieldWidgetProps = { + ...baseProps, + field: dynamicField, + dataSource: mockDataSource, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fetches data from DataSource when dialog opens', async () => { + mockDataSource.find.mockResolvedValue({ + data: [ + { _id: '1', name: 'Acme Corp' }, + { _id: '2', name: 'Beta Inc' }, + ], + total: 2, + }); + + render(); + + // Open dialog + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalledWith('customers', { + $top: 50, + }); + }); + + await waitFor(() => { + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + expect(screen.getByText('Beta Inc')).toBeInTheDocument(); + }); + }); + + it('shows loading state while fetching', async () => { + // Make find never resolve during this test + mockDataSource.find.mockReturnValue(new Promise(() => {})); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText('Loading…')).toBeInTheDocument(); + }); + }); + + it('shows error state with retry on fetch failure', async () => { + mockDataSource.find.mockRejectedValue(new Error('Network error')); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Network error')).toBeInTheDocument(); + expect(screen.getByText('Retry')).toBeInTheDocument(); + }); + + // Click retry + mockDataSource.find.mockResolvedValue({ + data: [{ _id: '1', name: 'Acme Corp' }], + total: 1, + }); + + await act(async () => { + fireEvent.click(screen.getByText('Retry')); + }); + + await waitFor(() => { + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + }); + }); + + it('shows "No options found" when DataSource returns empty', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(screen.getByText('No options found')).toBeInTheDocument(); + }); + }); + + it('sends $search param on search input', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + // Wait for initial load + await waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalledTimes(1); + }); + + // Type in search + await act(async () => { + fireEvent.change(screen.getByPlaceholderText('Search...'), { + target: { value: 'acme' }, + }); + }); + + // Wait for debounced search + await waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalledWith('customers', { + $top: 50, + $search: 'acme', + }); + }, { timeout: 500 }); + }); + + it('selects a dynamically loaded option', async () => { + const onChange = vi.fn(); + mockDataSource.find.mockResolvedValue({ + data: [ + { _id: '1', name: 'Acme Corp' }, + { _id: '2', name: 'Beta Inc' }, + ], + total: 2, + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByText('Acme Corp')); + }); + + expect(onChange).toHaveBeenCalledWith('1'); + }); + + it('falls back to static options when no DataSource', () => { + const staticField = { + ...mockField, + options: [ + { value: 's1', label: 'Static 1' }, + { value: 's2', label: 'Static 2' }, + ], + } as any; + render(); + expect(screen.getByText('Static 1')).toBeInTheDocument(); + }); + + it('shows total count hint when more results available', async () => { + mockDataSource.find.mockResolvedValue({ + data: Array.from({ length: 50 }, (_, i) => ({ + _id: String(i), + name: `Record ${i}`, + })), + total: 200, + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(screen.getByText(/Showing 50 of 200/)).toBeInTheDocument(); + }); + }); + + it('displays description field for options', async () => { + mockDataSource.find.mockResolvedValue({ + data: [ + { _id: '1', name: 'Acme Corp', industry: 'Technology' }, + { _id: '2', name: 'Beta Inc', industry: 'Finance' }, + ], + total: 2, + }); + + const fieldWithDesc = { + ...dynamicField, + description_field: 'industry', + } as any; + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + expect(screen.getByText('Technology')).toBeInTheDocument(); + expect(screen.getByText('Finance')).toBeInTheDocument(); + }); + }); + + it('shows create-new button when no results and onCreateNew is provided', async () => { + mockDataSource.find.mockResolvedValue({ data: [], total: 0 }); + const onCreateNew = vi.fn(); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(screen.getByText('No options found')).toBeInTheDocument(); + expect(screen.getByText('Create new')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByText('Create new')); + }); + + expect(onCreateNew).toHaveBeenCalledWith(''); + }); + + it('navigates options with arrow keys and selects with Enter', async () => { + const onChange = vi.fn(); + mockDataSource.find.mockResolvedValue({ + data: [ + { _id: '1', name: 'Alpha' }, + { _id: '2', name: 'Beta' }, + { _id: '3', name: 'Gamma' }, + ], + total: 3, + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(screen.getByText('Alpha')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Search...'); + + // Arrow down twice: -1 → 0 (Alpha) → 1 (Beta) + await act(async () => { + fireEvent.keyDown(searchInput, { key: 'ArrowDown' }); + }); + await act(async () => { + fireEvent.keyDown(searchInput, { key: 'ArrowDown' }); + }); + + // Press Enter to select + await act(async () => { + fireEvent.keyDown(searchInput, { key: 'Enter' }); + }); + + expect(onChange).toHaveBeenCalledWith('2'); + }); + + it('resolves reference_to from nested field.field (createFieldRenderer wrapper)', async () => { + // Simulates how createFieldRenderer wraps the field: the real metadata + // (reference_to, reference_field, etc.) is nested inside field.field. + const onChange = vi.fn(); + mockDataSource.find.mockResolvedValue({ + data: [ + { _id: 'o1', name: 'Order 001' }, + { _id: 'o2', name: 'Order 002' }, + ], + total: 2, + }); + + const wrappedField = { + name: 'order', + label: 'Order', + // In the wrapper, the actual objectSchema metadata is nested + field: { + name: 'order', + type: 'lookup', + reference_to: 'orders', + reference_field: 'name', + }, + // dataSource lands at the wrapper level + dataSource: mockDataSource, + } as any; + + render( + + ); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalledWith('orders', { $top: 50 }); + }); + + await waitFor(() => { + expect(screen.getByText('Order 001')).toBeInTheDocument(); + expect(screen.getByText('Order 002')).toBeInTheDocument(); + }); + }); + + it('supports ObjectStack "reference" convention (not just "reference_to")', async () => { + // ObjectStack backend uses `reference` instead of `reference_to` + const onChange = vi.fn(); + mockDataSource.find.mockResolvedValue({ + data: [ + { _id: 'a1', name: 'Acme Corp' }, + { _id: 'a2', name: 'Beta Inc' }, + ], + total: 2, + }); + + const wrappedField = { + name: 'account', + label: 'Account', + field: { + name: 'account', + type: 'lookup', + reference: 'account', // ObjectStack convention + }, + dataSource: mockDataSource, + } as any; + + render( + + ); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalledWith('account', { $top: 50 }); + }); + + await waitFor(() => { + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + expect(screen.getByText('Beta Inc')).toBeInTheDocument(); + }); + }); + + it('supports flat "reference" field without wrapper nesting', async () => { + // When field metadata is flat (no field.field nesting) + const onChange = vi.fn(); + mockDataSource.find.mockResolvedValue({ + data: [ + { _id: 'p1', name: 'Product A' }, + ], + total: 1, + }); + + render( + + ); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /Select/i })); + }); + + await waitFor(() => { + expect(mockDataSource.find).toHaveBeenCalledWith('products', { $top: 50 }); + }); + + await waitFor(() => { + expect(screen.getByText('Product A')).toBeInTheDocument(); + }); + }); + }); + describe('MasterDetailField', () => { const items = [ { id: '1', label: 'Item 1' }, diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx index 2c8f66d44..e5e1884f4 100644 --- a/packages/fields/src/widgets/LookupField.tsx +++ b/packages/fields/src/widgets/LookupField.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useContext, useMemo } from 'react'; import { Button, Dialog, @@ -9,65 +9,275 @@ import { Input, Badge } from '@object-ui/components'; -import { Search, X } from 'lucide-react'; +import { Search, X, Loader2, AlertCircle, Plus } from 'lucide-react'; import { FieldWidgetProps } from './types'; +import type { DataSource, QueryParams } from '@object-ui/types'; -interface LookupOption { +export interface LookupOption { value: string | number; label: string; + description?: string; [key: string]: any; } +/** Default page size for lookup data fetching */ +const LOOKUP_PAGE_SIZE = 50; + +/** + * Resolve SchemaRendererContext from @object-ui/react at runtime. + * Uses the same dynamic-require fallback that plugin-view uses to avoid + * a hard dependency on @object-ui/react (which would create a cycle). + */ +const FallbackContext = React.createContext(null); +let SchemaRendererContext: React.Context = FallbackContext; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require('@object-ui/react'); + if (mod.SchemaRendererContext) { + SchemaRendererContext = mod.SchemaRendererContext; + } +} catch { + // @object-ui/react not available — dataSource must be passed via props +} + /** - * Lookup field for selecting related records - * Supports single and multi-select with search + * Map a raw record to a LookupOption using a display field and an id field. + */ +function recordToOption( + record: any, + displayField: string, + idField: string, + descriptionField?: string, +): LookupOption { + const val = record[idField] ?? record._id ?? record.id; + const label = record[displayField] ?? record.label ?? record.name ?? String(val); + const description = descriptionField ? record[descriptionField] : undefined; + return { value: val, label: String(label), description, ...record }; +} + +/** + * Lookup field for selecting related records. + * Supports single and multi-select with search. + * + * When a `dataSource` is provided (either via props, via `field.dataSource`, + * or via SchemaRendererContext), the dialog will dynamically load records + * from the referenced object using `DataSource.find()`. + * Falls back to static `options` when no DataSource is available. */ export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps) { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + // Dynamic data loading state + const [fetchedOptions, setFetchedOptions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [totalCount, setTotalCount] = useState(0); + const debounceTimer = useRef | null>(null); + + // Arrow-key active index (-1 = none) + const [activeIndex, setActiveIndex] = useState(-1); + const listRef = useRef(null); + const lookupField = (field || (props as any).schema) as any; - const options: LookupOption[] = lookupField?.options || []; - const multiple = lookupField.multiple || false; - const displayField = lookupField.display_field || 'label'; - // Filter options based on search - const filteredOptions = options.filter(opt => - opt.label.toLowerCase().includes(searchQuery.toLowerCase()) + // When rendered via createFieldRenderer wrapper the actual objectSchema field + // metadata (reference_to, display_field, etc.) lives at lookupField.field. + // Unwrap it so lookup-specific properties resolve correctly. + // ObjectStack convention uses `reference` while the types use `reference_to`, + // so we check for both property names. + const innerField = lookupField?.field; + const fieldMeta = (innerField && typeof innerField === 'object' && ('reference_to' in innerField || 'reference' in innerField || 'type' in innerField)) + ? innerField + : lookupField; + + const staticOptions: LookupOption[] = fieldMeta?.options || []; + const multiple = fieldMeta?.multiple || false; + const displayField = fieldMeta?.display_field || fieldMeta?.reference_field || 'name'; + const descriptionField: string | undefined = fieldMeta?.description_field; + const idField = fieldMeta?.id_field || '_id'; + // ObjectStack convention uses `reference`; types define `reference_to` — support both + const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference; + + // Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none + const ctx = useContext(SchemaRendererContext); + const contextDataSource = ctx?.dataSource ?? null; + const dataSource: DataSource | null = + (props as any).dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource; + + const hasDataSource = dataSource != null && typeof dataSource.find === 'function' && !!referenceTo; + + // Optional create-new callback + const onCreateNew: ((searchQuery: string) => void) | undefined = + (props as any).onCreateNew ?? lookupField?.onCreateNew; + + // Determine which options to display + const allOptions = hasDataSource ? fetchedOptions : staticOptions; + + // For static options, filter locally based on search + const filteredOptions = useMemo(() => { + if (hasDataSource) return allOptions; + if (!searchQuery) return allOptions; + const q = searchQuery.toLowerCase(); + return allOptions.filter(opt => + opt.label.toLowerCase().includes(q) || + (opt.description && opt.description.toLowerCase().includes(q)) + ); + }, [hasDataSource, allOptions, searchQuery]); + + // Reset active index when options change + useEffect(() => { + setActiveIndex(-1); + }, [filteredOptions.length]); + + // Fetch data from DataSource + const fetchLookupData = useCallback( + async (search?: string) => { + if (!dataSource || !referenceTo) return; + + setLoading(true); + setError(null); + + try { + const params: QueryParams = { + $top: LOOKUP_PAGE_SIZE, + }; + if (search && search.trim()) { + params.$search = search.trim(); + } + + const result = await dataSource.find(referenceTo, params); + const records: any[] = result?.data ?? result ?? []; + const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField)); + + setFetchedOptions(mapped); + setTotalCount(result?.total ?? records.length); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setError(msg); + setFetchedOptions([]); + } finally { + setLoading(false); + } + }, + [dataSource, referenceTo, displayField, idField, descriptionField], + ); + + // Fetch data when dialog opens. + // We intentionally depend only on `isOpen` so the effect fires once per + // open/close transition. `fetchLookupData` is stable-enough via its own + // useCallback deps; including it here would cause spurious re-fetches. + useEffect(() => { + if (isOpen && hasDataSource) { + fetchLookupData(searchQuery || undefined); + } + // Clean up fetched data when dialog closes + if (!isOpen) { + setSearchQuery(''); + setError(null); + setActiveIndex(-1); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + // Debounced search + const handleSearchChange = useCallback( + (query: string) => { + setSearchQuery(query); + + if (!hasDataSource) return; + + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + debounceTimer.current = setTimeout(() => { + fetchLookupData(query || undefined); + }, 300); + }, + [hasDataSource, fetchLookupData], + ); + + // Clean up debounce timer + useEffect(() => { + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, []); + + // Get selected option(s) — check both static and fetched options + const findOption = useCallback( + (v: any): LookupOption | undefined => { + return ( + staticOptions.find(opt => opt.value === v) ?? + fetchedOptions.find(opt => opt.value === v) + ); + }, + [staticOptions, fetchedOptions], ); - // Get selected option(s) const selectedOptions = multiple - ? (Array.isArray(value) ? value : []).map(v => - options.find(opt => opt.value === v) - ).filter(Boolean) - : value ? [options.find(opt => opt.value === value)].filter(Boolean) : []; + ? (Array.isArray(value) ? value : []).map(findOption).filter(Boolean) + : value ? [findOption(value)].filter(Boolean) : []; - const handleSelect = (option: LookupOption) => { - if (multiple) { - const currentValues = Array.isArray(value) ? value : []; - const isSelected = currentValues.includes(option.value); - - if (isSelected) { - onChange(currentValues.filter(v => v !== option.value)); + const handleSelect = useCallback( + (option: LookupOption) => { + if (multiple) { + const currentValues = Array.isArray(value) ? value : []; + const isSelected = currentValues.includes(option.value); + + if (isSelected) { + onChange(currentValues.filter((v: any) => v !== option.value)); + } else { + onChange([...currentValues, option.value]); + } } else { - onChange([...currentValues, option.value]); + onChange(option.value); + setIsOpen(false); } - } else { - onChange(option.value); - setIsOpen(false); - } - }; + }, + [multiple, value, onChange], + ); const handleRemove = (optionValue: any) => { if (multiple) { const currentValues = Array.isArray(value) ? value : []; - onChange(currentValues.filter(v => v !== optionValue)); + onChange(currentValues.filter((v: any) => v !== optionValue)); } else { onChange(null); } }; + // Keyboard handler for the search input — arrow keys + Enter + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex(prev => + prev < filteredOptions.length - 1 ? prev + 1 : prev, + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(prev => (prev > 0 ? prev - 1 : 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filteredOptions.length) { + handleSelect(filteredOptions[activeIndex]); + } + } + }, + [filteredOptions, activeIndex, handleSelect], + ); + + // Scroll active item into view + useEffect(() => { + if (activeIndex >= 0 && listRef.current) { + const el = listRef.current.querySelector(`[data-lookup-index="${activeIndex}"]`); + el?.scrollIntoView({ block: 'nearest' }); + } + }, [activeIndex]); + if (readonly) { if (!selectedOptions.length) { return -; @@ -141,43 +351,134 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel {/* Search input */}
- setSearchQuery(e.target.value)} - className="w-full" - /> - - {/* Options list */} -
- {filteredOptions.length === 0 ? ( -

- No options found -

- ) : ( - filteredOptions.map((option) => { - const isSelected = multiple - ? (Array.isArray(value) ? value : []).includes(option.value) - : value === option.value; - - return ( - - ); - }) +
+ + handleSearchChange(e.target.value)} + onKeyDown={handleSearchKeyDown} + className="w-full pl-9" + /> + {loading && ( + )}
+ + {/* Error state */} + {error && ( +
+ +

{error}

+ +
+ )} + + {/* Loading state (initial load only, not search refinement) */} + {loading && filteredOptions.length === 0 && !error && ( +
+ +

Loading…

+
+ )} + + {/* Options list */} + {!error && !(loading && filteredOptions.length === 0) && ( +
+ {filteredOptions.length === 0 ? ( +
+

+ No options found +

+ {/* Quick-create entry */} + {onCreateNew && ( + + )} +
+ ) : ( + <> + {filteredOptions.map((option, idx) => { + const isSelected = multiple + ? (Array.isArray(value) ? value : []).includes(option.value) + : value === option.value; + const isActive = idx === activeIndex; + + return ( + + ); + })} + {/* Show total count when fetched from DataSource */} + {hasDataSource && totalCount > filteredOptions.length && ( +

+ Showing {filteredOptions.length} of {totalCount} results. Refine your search to find more. +

+ )} + {/* Quick-create entry (below results) */} + {onCreateNew && ( + + )} + + )} +
+ )}