Skip to content

Commit 94f85c7

Browse files
authored
Merge pull request #1095 from objectstack-ai/copilot/fix-popup-selection-feedback
2 parents e9e3143 + 1ae9948 commit 94f85c7

4 files changed

Lines changed: 291 additions & 6 deletions

File tree

CHANGELOG.md

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

1010
### Added
1111

12+
- **Lookup Field Selection Display Fix** (`@object-ui/fields`): Fixed a bug where selecting a record from the RecordPickerDialog (Level 2 popup) produced no visible feedback in the LookupField. The root cause: `findOption` only searched static and popover-fetched options, which did not include records loaded by the dialog. Added `onSelectRecords` callback to `RecordPickerDialogProps` that returns full record objects alongside IDs. LookupField now caches selected records from the dialog and displays their labels/badges correctly. Both single-select and multi-select modes are supported. Includes a `selectedRecordsMap` ref in RecordPickerDialog that persists selected record data across page navigation for multi-select scenarios.
13+
1214
- **RecordPickerDialog UI/UX Overhaul** (`@object-ui/fields`): Major enterprise-grade improvements referencing mainstream low-code platforms:
1315
- **Skeleton Loading Screen**: Replaced simple spinner with a table-shaped skeleton screen during initial data load, matching the column layout for a polished loading experience.
1416
- **Sticky Table Header**: Table header now sticks to the top during vertical scroll, keeping column labels visible at all times.

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

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1451,3 +1451,246 @@ describe('RecordPickerDialog — Loading Overlay', () => {
14511451
});
14521452
});
14531453
});
1454+
1455+
// ------------- RecordPickerDialog — onSelectRecords callback -------------
1456+
1457+
describe('RecordPickerDialog — onSelectRecords callback', () => {
1458+
const basePickerProps = {
1459+
open: true,
1460+
onOpenChange: vi.fn(),
1461+
dataSource: mockDataSource as any,
1462+
objectName: 'customers',
1463+
onSelect: vi.fn(),
1464+
};
1465+
1466+
it('calls onSelectRecords with the full record on single-select', async () => {
1467+
const onSelect = vi.fn();
1468+
const onSelectRecords = vi.fn();
1469+
const onOpenChange = vi.fn();
1470+
1471+
mockDataSource.find.mockResolvedValue({
1472+
data: [
1473+
{ id: '1', name: 'Acme Corp', email: 'acme@test.com' },
1474+
{ id: '2', name: 'Beta Inc', email: 'beta@test.com' },
1475+
],
1476+
total: 2,
1477+
});
1478+
1479+
render(
1480+
<RecordPickerDialog
1481+
{...basePickerProps}
1482+
onSelect={onSelect}
1483+
onSelectRecords={onSelectRecords}
1484+
onOpenChange={onOpenChange}
1485+
/>,
1486+
);
1487+
1488+
await waitFor(() => {
1489+
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
1490+
});
1491+
1492+
await act(async () => {
1493+
fireEvent.click(screen.getByTestId('record-row-1'));
1494+
});
1495+
1496+
expect(onSelect).toHaveBeenCalledWith('1');
1497+
expect(onSelectRecords).toHaveBeenCalledWith([
1498+
expect.objectContaining({ id: '1', name: 'Acme Corp' }),
1499+
]);
1500+
expect(onOpenChange).toHaveBeenCalledWith(false);
1501+
});
1502+
1503+
it('calls onSelectRecords with full records on multi-select confirm', async () => {
1504+
const onSelect = vi.fn();
1505+
const onSelectRecords = vi.fn();
1506+
const onOpenChange = vi.fn();
1507+
1508+
mockDataSource.find.mockResolvedValue({
1509+
data: [
1510+
{ id: '1', name: 'Alpha' },
1511+
{ id: '2', name: 'Beta' },
1512+
{ id: '3', name: 'Gamma' },
1513+
],
1514+
total: 3,
1515+
});
1516+
1517+
render(
1518+
<RecordPickerDialog
1519+
{...basePickerProps}
1520+
multiple
1521+
onSelect={onSelect}
1522+
onSelectRecords={onSelectRecords}
1523+
onOpenChange={onOpenChange}
1524+
/>,
1525+
);
1526+
1527+
await waitFor(() => {
1528+
expect(screen.getByText('Alpha')).toBeInTheDocument();
1529+
});
1530+
1531+
// Select two records
1532+
await act(async () => {
1533+
fireEvent.click(screen.getByTestId('record-row-1'));
1534+
});
1535+
await act(async () => {
1536+
fireEvent.click(screen.getByTestId('record-row-3'));
1537+
});
1538+
1539+
// Click confirm
1540+
await act(async () => {
1541+
fireEvent.click(screen.getByText('Confirm'));
1542+
});
1543+
1544+
expect(onSelect).toHaveBeenCalledWith(expect.arrayContaining(['1', '3']));
1545+
expect(onSelectRecords).toHaveBeenCalledWith(
1546+
expect.arrayContaining([
1547+
expect.objectContaining({ id: '1', name: 'Alpha' }),
1548+
expect.objectContaining({ id: '3', name: 'Gamma' }),
1549+
]),
1550+
);
1551+
expect(onOpenChange).toHaveBeenCalledWith(false);
1552+
});
1553+
});
1554+
1555+
// ------------- LookupField — Selection Display After RecordPickerDialog -------------
1556+
1557+
describe('LookupField — Selection Display After RecordPickerDialog', () => {
1558+
const mockField = {
1559+
name: 'customer',
1560+
label: 'Customer',
1561+
reference_to: 'customers',
1562+
reference_field: 'name',
1563+
} as any;
1564+
1565+
it('displays selected record label after single-select in RecordPickerDialog', async () => {
1566+
const onChange = vi.fn();
1567+
let currentValue: any = undefined;
1568+
1569+
mockDataSource.find.mockResolvedValue({
1570+
data: Array.from({ length: 50 }, (_, i) => ({
1571+
id: String(i + 1),
1572+
name: `Customer ${i + 1}`,
1573+
})),
1574+
total: 200,
1575+
});
1576+
1577+
const { rerender } = render(
1578+
<LookupField
1579+
field={mockField}
1580+
value={currentValue}
1581+
onChange={onChange}
1582+
readonly={false}
1583+
dataSource={mockDataSource as any}
1584+
/>,
1585+
);
1586+
1587+
// Open RecordPickerDialog via "Browse All" button
1588+
await act(async () => {
1589+
fireEvent.click(screen.getByTestId('browse-all-records'));
1590+
});
1591+
1592+
await waitFor(() => {
1593+
expect(screen.getByTestId('record-picker-dialog')).toBeInTheDocument();
1594+
});
1595+
1596+
// Wait for records to load
1597+
await waitFor(() => {
1598+
expect(screen.getByText('Customer 1')).toBeInTheDocument();
1599+
});
1600+
1601+
// Select a record (single-select — should immediately close)
1602+
await act(async () => {
1603+
fireEvent.click(screen.getByTestId('record-row-1'));
1604+
});
1605+
1606+
expect(onChange).toHaveBeenCalledWith('1');
1607+
1608+
// Simulate parent re-rendering with the new value
1609+
currentValue = '1';
1610+
rerender(
1611+
<LookupField
1612+
field={mockField}
1613+
value={currentValue}
1614+
onChange={onChange}
1615+
readonly={false}
1616+
dataSource={mockDataSource as any}
1617+
/>,
1618+
);
1619+
1620+
// The selected record's name should be visible as a badge
1621+
await waitFor(() => {
1622+
expect(screen.getByText('Customer 1')).toBeInTheDocument();
1623+
});
1624+
});
1625+
1626+
it('displays selected record badges after multi-select in RecordPickerDialog', async () => {
1627+
const multiField = { ...mockField, multiple: true } as any;
1628+
const onChange = vi.fn();
1629+
let currentValue: any = [];
1630+
1631+
mockDataSource.find.mockResolvedValue({
1632+
data: Array.from({ length: 50 }, (_, i) => ({
1633+
id: String(i + 1),
1634+
name: `Customer ${i + 1}`,
1635+
})),
1636+
total: 200,
1637+
});
1638+
1639+
const { rerender } = render(
1640+
<LookupField
1641+
field={multiField}
1642+
value={currentValue}
1643+
onChange={onChange}
1644+
readonly={false}
1645+
dataSource={mockDataSource as any}
1646+
/>,
1647+
);
1648+
1649+
// Open RecordPickerDialog via "Browse All" button
1650+
await act(async () => {
1651+
fireEvent.click(screen.getByTestId('browse-all-records'));
1652+
});
1653+
1654+
await waitFor(() => {
1655+
expect(screen.getByTestId('record-picker-dialog')).toBeInTheDocument();
1656+
});
1657+
1658+
// Wait for records to load
1659+
await waitFor(() => {
1660+
expect(screen.getByText('Customer 1')).toBeInTheDocument();
1661+
});
1662+
1663+
// Select two records (multi-select — needs confirm)
1664+
await act(async () => {
1665+
fireEvent.click(screen.getByTestId('record-row-1'));
1666+
});
1667+
await act(async () => {
1668+
fireEvent.click(screen.getByTestId('record-row-3'));
1669+
});
1670+
1671+
// Click confirm
1672+
await act(async () => {
1673+
fireEvent.click(screen.getByText('Confirm'));
1674+
});
1675+
1676+
expect(onChange).toHaveBeenCalledWith(expect.arrayContaining(['1', '3']));
1677+
1678+
// Simulate parent re-rendering with the new value
1679+
currentValue = ['1', '3'];
1680+
rerender(
1681+
<LookupField
1682+
field={multiField}
1683+
value={currentValue}
1684+
onChange={onChange}
1685+
readonly={false}
1686+
dataSource={mockDataSource as any}
1687+
/>,
1688+
);
1689+
1690+
// Both selected records should be visible as badges
1691+
await waitFor(() => {
1692+
expect(screen.getByText('Customer 1')).toBeInTheDocument();
1693+
expect(screen.getByText('Customer 3')).toBeInTheDocument();
1694+
});
1695+
});
1696+
});

packages/fields/src/widgets/LookupField.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
9797
const [totalCount, setTotalCount] = useState(0);
9898
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
9999

100+
// Records selected via RecordPickerDialog (Level 2).
101+
// Stored as LookupOption so that findOption can resolve display labels
102+
// even when the record wasn't part of the Level 1 popover fetch.
103+
const [pickerResolvedRecords, setPickerResolvedRecords] = useState<LookupOption[]>([]);
104+
100105
// Arrow-key active index (-1 = none)
101106
const [activeIndex, setActiveIndex] = useState(-1);
102107
const listRef = useRef<HTMLDivElement>(null);
@@ -255,15 +260,16 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
255260
};
256261
}, []);
257262

258-
// Get selected option(s) — check both static and fetched options
263+
// Get selected option(s) — check static, fetched, and picker-resolved options
259264
const findOption = useCallback(
260265
(v: any): LookupOption | undefined => {
261266
return (
262267
staticOptions.find(opt => opt.value === v) ??
263-
fetchedOptions.find(opt => opt.value === v)
268+
fetchedOptions.find(opt => opt.value === v) ??
269+
pickerResolvedRecords.find(opt => opt.value === v)
264270
);
265271
},
266-
[staticOptions, fetchedOptions],
272+
[staticOptions, fetchedOptions, pickerResolvedRecords],
267273
);
268274

269275
const selectedOptions = multiple
@@ -298,6 +304,16 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
298304
}
299305
};
300306

307+
// Callback from RecordPickerDialog — caches selected records so that
308+
// findOption can resolve display labels after the dialog closes.
309+
const handlePickerSelectRecords = useCallback(
310+
(records: any[]) => {
311+
const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField));
312+
setPickerResolvedRecords(mapped);
313+
},
314+
[displayField, idField, descriptionField],
315+
);
316+
301317
// Keyboard handler for the search input — arrow keys + Enter
302318
const handleSearchKeyDown = useCallback(
303319
(e: React.KeyboardEvent) => {
@@ -574,6 +590,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
574590
pageSize={lookupPageSize}
575591
value={value}
576592
onSelect={onChange}
593+
onSelectRecords={handlePickerSelectRecords}
577594
lookupFilters={lookupFilters}
578595
cellRenderer={getCellRendererResolver()}
579596
filterColumns={filterColumns}

packages/fields/src/widgets/RecordPickerDialog.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,13 @@ export interface RecordPickerDialogProps {
237237
/** Called when selection changes */
238238
onSelect: (value: any) => void;
239239

240+
/**
241+
* Called with the full record objects corresponding to the selected value(s).
242+
* Useful for parent components (e.g. LookupField) that need display data
243+
* (labels, descriptions) for the selected records beyond just their IDs.
244+
*/
245+
onSelectRecords?: (records: any[]) => void;
246+
240247
/**
241248
* Base filters applied to every query.
242249
* Converted from LookupFieldMetadata.lookup_filters.
@@ -313,6 +320,7 @@ export function RecordPickerDialog({
313320
pageSize = DEFAULT_PAGE_SIZE,
314321
value,
315322
onSelect,
323+
onSelectRecords,
316324
lookupFilters,
317325
cellRenderer,
318326
filterColumns,
@@ -334,6 +342,11 @@ export function RecordPickerDialog({
334342
// For multi-select, track pending selections before confirming
335343
const [pendingSelection, setPendingSelection] = useState<Set<any>>(new Set());
336344

345+
// Cache selected record objects across page navigations (multi-select).
346+
// When the user toggles a row the full record is stored here so that
347+
// onSelectRecords can return complete objects even after paging away.
348+
const selectedRecordsMap = useRef<Map<any, any>>(new Map());
349+
337350
// Keyboard navigation: focused row index
338351
const [focusedRow, setFocusedRow] = useState(-1);
339352
const tableBodyRef = useRef<HTMLTableSectionElement>(null);
@@ -479,6 +492,7 @@ export function RecordPickerDialog({
479492
setPendingSelection(new Set(
480493
multiple ? (Array.isArray(value) ? value : []) : [],
481494
));
495+
selectedRecordsMap.current.clear();
482496
}
483497
// Intentionally depends only on `open` — `multiple` and `value` are
484498
// captured at close-time and don't need to trigger resets while closed.
@@ -570,25 +584,34 @@ export function RecordPickerDialog({
570584
const next = new Set(prev);
571585
if (next.has(rid)) {
572586
next.delete(rid);
587+
selectedRecordsMap.current.delete(rid);
573588
} else {
574589
next.add(rid);
590+
selectedRecordsMap.current.set(rid, record);
575591
}
576592
return next;
577593
});
578594
} else {
579595
// Single select — immediately close
580596
onSelect(rid);
597+
onSelectRecords?.([record]);
581598
onOpenChange(false);
582599
}
583600
},
584-
[multiple, getRecordId, onSelect, onOpenChange],
601+
[multiple, getRecordId, onSelect, onSelectRecords, onOpenChange],
585602
);
586603

587604
// Confirm multi-select
588605
const handleConfirm = useCallback(() => {
589-
onSelect(Array.from(pendingSelection));
606+
const ids = Array.from(pendingSelection);
607+
onSelect(ids);
608+
// Build the full record array from the cache for the caller
609+
const selectedRecords = ids
610+
.map(id => selectedRecordsMap.current.get(id))
611+
.filter(Boolean);
612+
onSelectRecords?.(selectedRecords);
590613
onOpenChange(false);
591-
}, [pendingSelection, onSelect, onOpenChange]);
614+
}, [pendingSelection, onSelect, onSelectRecords, onOpenChange]);
592615

593616
// Page navigation
594617
const handlePrevPage = useCallback(() => {

0 commit comments

Comments
 (0)