Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/25860-lookup-subtitle-name-field.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/fields.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/fields","sources":96,"sha256":"89537e9f86037ddb8d0c6ce1a9fbcfbc8d756fd38b1a990c2c5ccc277a359d1a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/fields","sources":96,"sha256":"a80484e00e8bc69907a6efbec0af09bd7eb0d1a78b057a4f5092b90432d921cc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Original file line number Diff line number Diff line change
@@ -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<string, { type: string; label: string }> = {
title: { type: 'text', label: 'Customer Name' },
phone: { type: 'phone', label: 'Phone' },
source: { type: 'text', label: 'Source' },
code: { type: 'text', label: 'Code' },
};

const ROWS: Record<string, unknown>[] = [
{ 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<string, unknown>) {
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<void> {
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<string, unknown>,
field: Record<string, unknown> = {},
): Promise<Array<{ title: string; preview: string[] }>> {
const ds = makeBackend(decl);
render(
<LookupField
value={undefined}
onChange={() => {}}
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<HTMLElement>('[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']);
});
});
19 changes: 17 additions & 2 deletions ui/packages/fields/src/widgets/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<LookupColumnDef[]>(() => {
const cols: LookupColumnDef[] = [];
const seen = new Set<string>([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);
Expand All @@ -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
Expand Down
Loading