From fa19c944b05ffdebdef5d4947b6e96d1e4ec3c4a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:00:31 +0000 Subject: [PATCH 1/5] fix(fields): lookup subtitle skips the referenced object's name field the title reads The quick-picker's preview line skipped only the widget's 'name' guess, so an object whose name field is anything else repeated the option's title as the subtitle's first item. previewColumns now also skips the resolved name field when the lookup declares no displayField, unless a titleFormat template titles the record. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ...okupField.subtitleNameField-25860.test.tsx | 128 ++++++++++++++++++ .../fields/src/widgets/LookupField.tsx | 19 ++- 2 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 ui/packages/fields/src/widgets/LookupField.subtitleNameField-25860.test.tsx diff --git a/ui/packages/fields/src/widgets/LookupField.subtitleNameField-25860.test.tsx b/ui/packages/fields/src/widgets/LookupField.subtitleNameField-25860.test.tsx new file mode 100644 index 00000000000..a8d035007d3 --- /dev/null +++ b/ui/packages/fields/src/widgets/LookupField.subtitleNameField-25860.test.tsx @@ -0,0 +1,128 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The quick-picker's subtitle never repeats the field the option's title reads. + * + * A lookup that declares no `displayField` titles each option through the + * referenced object's own name field (`getRecordDisplayName`), while the + * subtitle skipped only the widget's `'name'` guess. On an object whose name + * field is anything else — `title`, `customer_name`, every AI-built object — + * the subtitle's first, widest slot repeated the title and pushed the field + * that tells two records apart (the phone number) into truncation. + * + * The subtitle now skips the field the title resolved from: the declared + * `displayField`, else the object's resolved name field — unless a + * `titleFormat` template, not a field, titles the record. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, act, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { LookupField } from './LookupField'; + +const FIELDS: Record = { + title: { type: 'text', label: 'Customer Name' }, + phone: { type: 'phone', label: 'Phone' }, + source: { type: 'text', label: 'Source' }, + code: { type: 'text', label: 'Code' }, +}; + +const ROWS: Record[] = [ + { id: 'c1', title: 'Wang Jing', phone: '18624570001', source: 'Store', code: 'C-001' }, + { id: 'c2', title: 'Zhou Mingxuan', phone: '18624570002', source: 'Web', code: 'C-002' }, +]; + +function makeBackend(decl: Record) { + return { + find: vi.fn(async () => ({ data: ROWS.map((r) => ({ ...r })), total: ROWS.length })), + findOne: vi.fn(async (_o: string, id: string) => ROWS.find((r) => r.id === id) ?? null), + getObjectSchema: vi.fn(async (objectName: string) => ({ name: objectName, fields: FIELDS, ...decl })), + } as any; +} + +async function settle(): Promise { + for (let i = 0; i < 6; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +/** + * Open the dropdown and read, per option, its title and the fields its + * subtitle previews, in order. + */ +async function openPicker( + decl: Record, + field: Record = {}, +): Promise> { + const ds = makeBackend(decl); + render( + {}} + dataSource={ds} + field={{ reference: 'customer', ...field } as never} + />, + ); + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalledWith('customer')); + await settle(); + await act(async () => { + fireEvent.click(screen.getByTestId('lookup-trigger')); + }); + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2)); + await settle(); + return screen.getAllByRole('option').map((el) => ({ + title: el.getAttribute('title') ?? '', + preview: Array.from(el.querySelectorAll('[data-lookup-preview]')).map( + (v) => v.dataset.lookupPreview ?? '', + ), + })); +} + +afterEach(() => { + cleanup(); + try { + localStorage.clear(); + } catch { + /* no storage in this environment */ + } +}); + +describe('LookupField subtitle skips the field the title reads', () => { + it("the object declares nameField 'title': the subtitle starts with the phone, not the title again", async () => { + const options = await openPicker({ nameField: 'title' }); + expect(options.map((o) => o.title)).toEqual(['Wang Jing', 'Zhou Mingxuan']); + for (const o of options) expect(o.preview).toEqual(['phone', 'source']); + }); + + it("no nameField declared: the derived name field 'title' is skipped the same way", async () => { + const options = await openPicker({}); + expect(options.map((o) => o.title)).toEqual(['Wang Jing', 'Zhou Mingxuan']); + for (const o of options) expect(o.preview).toEqual(['phone', 'source']); + }); + + it('an explicitly authored descriptionField still leads the subtitle', async () => { + const options = await openPicker({ nameField: 'title' }, { descriptionField: 'source' }); + for (const o of options) expect(o.preview).toEqual(['source', 'phone']); + }); + + it("control: a declared displayField titles the option, so the object's name field stays in the subtitle", async () => { + const options = await openPicker({ nameField: 'title' }, { displayField: 'code' }); + expect(options.map((o) => o.title)).toEqual(['C-001', 'C-002']); + for (const o of options) expect(o.preview).toEqual(['title', 'phone', 'source']); + }); + + it('control: a titleFormat template titles the record, so the derived field stays in the subtitle', async () => { + const options = await openPicker({ titleFormat: '{code} / {source}' }); + expect(options.map((o) => o.title)).toEqual(['C-001 / Store', 'C-002 / Web']); + for (const o of options) expect(o.preview).toEqual(['title', 'phone', 'source']); + }); +}); diff --git a/ui/packages/fields/src/widgets/LookupField.tsx b/ui/packages/fields/src/widgets/LookupField.tsx index 3d50125b12d..04c7cbb215e 100644 --- a/ui/packages/fields/src/widgets/LookupField.tsx +++ b/ui/packages/fields/src/widgets/LookupField.tsx @@ -19,7 +19,7 @@ import { PeoplePicker } from './PeoplePicker.js'; import { useRecordQuery } from './useRecordQuery.js'; import { objectResolvesSearch } from './searchOffer.js'; import { deriveLookupColumns } from './deriveLookupColumns.js'; -import { buildExpandFields, getRecordDisplayName, mergeFilterNodes, resolveAffordance, toPredicateRecord, withoutDeniedFields } from '@object-ui/core'; +import { buildExpandFields, declaredNameField, getRecordDisplayName, mergeFilterNodes, resolveAffordance, resolveNameField, toPredicateRecord, withoutDeniedFields } from '@object-ui/core'; import { getRecentLookupIds, pushRecentLookupId } from './recentLookups.js'; import { getPersonInitials } from './personDisplay.js'; import { getCellRendererResolver } from './_cell-renderer-bridge.js'; @@ -581,10 +581,25 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro * still leads (the author picked that column to be the subtitle), followed by * every non-display picker column. Both halves render through * `renderLookupColumnValue`, the picker's own renderer. + * + * "Non-display" means not the field the option's TITLE reads. A declared + * `displayField` is that field. Without one, `displayField` is only the + * `'name'` guess, and the title comes from `getRecordDisplayName`'s object + * ladder (`recordToOption`): the declared name field, else a `titleFormat` + * template, else the derived title field. So the referenced object's + * resolved name field is skipped too, unless a template rather than a field + * titles the record. Without this an object whose name field is not + * literally `name` (`title`, `customer_name`, every AI-built object) spent + * the subtitle's first slot repeating the title. */ const previewColumns = useMemo(() => { const cols: LookupColumnDef[] = []; const seen = new Set([displayField]); + if (!declaredDisplayField && refObjectSchema) { + const titledByTemplate = !!refObjectSchema.titleFormat && !declaredNameField(refObjectSchema); + const nameField = titledByTemplate ? undefined : resolveNameField(refObjectSchema); + if (nameField) seen.add(nameField); + } if (descriptionField) { cols.push({ field: descriptionField }); seen.add(descriptionField); @@ -596,7 +611,7 @@ export function LookupField({ value, onChange, field, readonly, error: fieldErro cols.push(col); } return cols; - }, [descriptionField, pickerColumns, displayField]); + }, [descriptionField, pickerColumns, displayField, declaredDisplayField, refObjectSchema]); /** * Field descriptors for the previewed columns — the SAME builder the picker From 0fde3d9744aeab96f7b7f16d56fa0b3f7d105e68 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:10:14 +0000 Subject: [PATCH 2/5] chore(changeset): lookup subtitle skips the name field Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .changeset/25860-lookup-subtitle-name-field.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/25860-lookup-subtitle-name-field.md diff --git a/.changeset/25860-lookup-subtitle-name-field.md b/.changeset/25860-lookup-subtitle-name-field.md new file mode 100644 index 00000000000..a2efef83328 --- /dev/null +++ b/.changeset/25860-lookup-subtitle-name-field.md @@ -0,0 +1,14 @@ +--- +'@object-ui/fields': patch +--- + +fix(fields): a lookup's option subtitle no longer repeats the record name when the referenced object's name field is not `name` + +Clause-②: no +Metadata-protocol: none + +The quick-picker shows each candidate as its title over a subtitle of the referenced object's other columns. A lookup that declares no `displayField` titles the option through the referenced object's name field (`nameField`, else the derived title field), but the subtitle skipped only a field literally called `name`. On an object whose name field is `title`, `customer_name` or any other name (every AI-built object), the subtitle's first and widest slot repeated the title and pushed the column that tells two records apart, such as a phone number, into truncation. + +The subtitle now also skips the referenced object's resolved name field when the lookup declares no `displayField`. Unchanged: a declared `displayField` is the field skipped, so the object's name field stays in the subtitle; an object titled by a `titleFormat` template keeps every field in the subtitle; an explicitly authored `descriptionField` still leads the subtitle. + +What to do: nothing. From a200f71d0b85c4f5e215d1c55267001d578e4359 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:00:40 +0000 Subject: [PATCH 3/5] chore(sdui): re-record the registry source digest Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 5d728022f56..0b07d26c8ff 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "7734fe6bc3763fe8318db624b9e0dd4c48b2dc7b15bf2a3fb1f78e46bb8d959e" + "registrySourcesSha256": "2b90a00577c5f7169232e6a586bb51786e6989784797864e780a22598a3c8105" } } From dcee0130d85e9ca97211cd1016d22e38be6a5da1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:13:27 +0000 Subject: [PATCH 4/5] chore(sdui): re-record the registry source digest on the merged tree Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index f65f470a705..46c4bbb5233 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063" + "registrySourcesSha256": "d71fa5f15902de39fbbb2eb54b8ee93092d8f6dc236c8bce36c1a46a879ac4d8" } } From dadc26bd0ebcc894e260d07543a9c81f4236b90a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 14:00:07 +0000 Subject: [PATCH 5/5] chore(sdui): re-record the fields registry source digest on the merged tree Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- scripts/sdui-manifest.sources/fields.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.sources/fields.json b/scripts/sdui-manifest.sources/fields.json index 5434b657f1f..db4b65ba697 100644 --- a/scripts/sdui-manifest.sources/fields.json +++ b/scripts/sdui-manifest.sources/fields.json @@ -1 +1 @@ -{"package":"ui/packages/fields","sources":96,"sha256":"89537e9f86037ddb8d0c6ce1a9fbcfbc8d756fd38b1a990c2c5ccc277a359d1a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/fields","sources":96,"sha256":"a80484e00e8bc69907a6efbec0af09bd7eb0d1a78b057a4f5092b90432d921cc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}