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. 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"} 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