diff --git a/.changeset/13116-listview-namefield.md b/.changeset/13116-listview-namefield.md new file mode 100644 index 0000000000..12ccc10236 --- /dev/null +++ b/.changeset/13116-listview-namefield.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-list': patch +--- + +An ungrouped list view now asks for the object's declared `nameField` in its `$select`, so the bulk confirm step lists each record's name rather than `Record #ID` when no column shows that field. The field is fetched, not drawn, and goes through the same known-field and field-permission gates as the list's other harvested fields. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 81f118cf67..ecffd5bc77 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -32,7 +32,7 @@ import { useDensityMode, resolveInlineAriaProps } from '@object-ui/react'; import type { ListViewSchema, ObjectMapConfig } from '@object-ui/types'; import { detectStatusField, isSystemManagedField } from '@object-ui/types'; import { usePullToRefresh } from '@object-ui/mobile'; -import { type ListViewVisualization, resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveAffordance, type SchemaLike, partitionRowsByPredicate, normalizeListViewSchema, isListViewVisualization, rowHeightToDensityMode, mergeFilterNodes, FilterOperatorError, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, applyNonGridRowCeiling, type NonGridCeilingResult, resolveNameField } from '@object-ui/core'; +import { type ListViewVisualization, resolveConditionalFormatting, buildExpandFields, buildExportFileName, resolveAffordance, type SchemaLike, partitionRowsByPredicate, normalizeListViewSchema, isListViewVisualization, rowHeightToDensityMode, mergeFilterNodes, FilterOperatorError, columnIdentity, collectPredicateFieldRefs, collectGroupingFieldRefs, listViewPredicates, PLATFORM_RECORD_COLUMNS, EXPANDABLE_FIELD_TYPES, UNMATERIALIZED_FIELD_TYPES, readObjectSortability, isPlatformSortableField, filterPlatformSortableSort, applyNonGridRowCeiling, type NonGridCeilingResult, resolveNameField, declaredNameField } from '@object-ui/core'; import { useObjectLabel, useSafeFieldLabel, createSafeTranslation, useDisplayLocale, pickLocalized } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the // NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own @@ -3197,6 +3197,21 @@ export const ListView = React.forwardRef(({ const pointerDefs = objectDef?.fields as Record | undefined; for (const c of cols) addSpeculative(pointerDefs?.[c]?.referenceVia); + // [objectui#13116] The object's DECLARED name field. The bulk + // confirm step names each affected record from the rows this fetch + // hands the grid (`BulkActionDialog`, objectui#13107), so a + // `nameField` no column shows arrived absent and the step fell to + // the `Record #ID` floor. Read through `declaredNameField`, the + // resolver's single spelling of the pointer, and asked by name + // whatever its type: the server serves a formula field like any + // other. Fetched, never drawn: it joins the projection, not the + // columns. Through `addSpeculative`, like every binding here: a name + // field the viewer may not read is never requested, and a pointer + // the object does not declare is never sent. `ObjectGrid`'s + // `getSelectFields` carries the same arm for the fetch a grouped + // grid owns. + addSpeculative(declaredNameField(objectDef)); + for (const f of collectPredicateFieldRefs(listViewPredicates({ conditionalFormatting: schema.conditionalFormatting as unknown[] | undefined, /** diff --git a/packages/plugin-list/src/__tests__/ListView.nameFieldSelect-13116.test.tsx b/packages/plugin-list/src/__tests__/ListView.nameFieldSelect-13116.test.tsx new file mode 100644 index 0000000000..240824a8bc --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.nameFieldSelect-13116.test.tsx @@ -0,0 +1,162 @@ +/** + * 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. + */ + +/** + * objectui#13116 — an ungrouped list asks for the object's declared + * `nameField`. + * + * The bulk confirm step (`plugin-grid` `BulkActionDialog`, objectui#13107) + * names each affected record from the rows the grid holds: the declared + * `nameField` value when the row carries it, else the resolver's + * `Record #ID` floor. An ungrouped list's rows come from `ListView`'s own + * fetch, and its `$select` harvest had no arm for `nameField`, so a list whose + * columns omit it fetched rows without it and the step listed `Record #ID`. + * + * A grouped grid that owns its fetch projects through `ObjectGrid`'s + * `getSelectFields`, pinned with the real dialog in plugin-grid's + * `bulkConfirmNameField-13107.test.tsx`. plugin-list does not depend on + * plugin-grid, so a stub grid stands in here and the confirm step's name is + * asked of the rule that step applies, against the rows the grid is handed: + * the declared value via `recordDisplayValueAt(row, declaredNameField(def))`, + * then `getRecordDisplayName`. + * + * What is pinned: + * - a formula `nameField` that is not a column is in `$select`, is not drawn + * as a column, and the rows handed to the grid name each record; + * - control: an object with no `nameField` sends the same `$select` as + * before; + * - control: a `nameField` that is a column is sent once. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { ComponentRegistry, declaredNameField, getRecordDisplayName, recordDisplayValueAt } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { ListView } from '../ListView'; + +const CONTACT = 'crm_contact'; +const NOTE = 'crm_note'; + +const contactDef = { + name: CONTACT, + label: 'Contact', + nameField: 'full_name', + fields: { + id: { name: 'id', type: 'text' }, + first_name: { name: 'first_name', type: 'text', label: 'First Name' }, + last_name: { name: 'last_name', type: 'text', label: 'Last Name' }, + title: { name: 'title', type: 'text', label: 'Title' }, + full_name: { name: 'full_name', type: 'formula', label: 'Full Name', expression: "first_name + ' ' + last_name" }, + }, +}; + +// The same shape with no declared pointer: the control. +const noteDef = { + name: NOTE, + label: 'Note', + fields: { + id: { name: 'id', type: 'text' }, + subject: { name: 'subject', type: 'text', label: 'Subject' }, + body: { name: 'body', type: 'text', label: 'Body' }, + full_name: { name: 'full_name', type: 'text', label: 'Full Name' }, + }, +}; + +const CONTACT_ROWS = [ + { id: 'c1', first_name: 'John', last_name: 'Smith', title: 'VP of Engineering', full_name: 'John Smith' }, + { id: 'c2', first_name: 'Marcus', last_name: 'Reed', title: 'Director of Commercial Systems', full_name: 'Marcus Reed' }, +]; +const NOTE_ROWS = [{ id: 'n1', subject: 'Kickoff', body: 'Agenda', full_name: 'unused' }]; + +let lastGridProps: any = null; +let prevObjectGrid: any; +beforeAll(() => { + prevObjectGrid = ComponentRegistry.get('object-grid'); + ComponentRegistry.register('object-grid', (props: any) => { + lastGridProps = props; + return
; + }); +}); +afterAll(() => { + if (prevObjectGrid) ComponentRegistry.register('object-grid', prevObjectGrid); + else ComponentRegistry.unregister('object-grid'); +}); + +/** Honours `$select` the way a server does: a row carries the selected keys only. */ +const makeDataSource = (def: Record, rows: Array>) => + ({ + find: vi.fn(async (_object: string, params: Record) => { + const select = params?.$select as string[] | undefined; + const data = rows.map((row) => + select ? Object.fromEntries(select.filter((k) => k in row).map((k) => [k, row[k]])) : { ...row }, + ); + return { data, total: data.length }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async (name: string) => (name === def.name ? def : null)), + }) as any; + +async function mount(def: Record, rows: Array>, columns: string[]) { + const dataSource = makeDataSource(def, rows); + render( + + + , + ); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + const raw = (dataSource.find.mock.calls.at(-1)?.[1]?.$select as string[] | undefined) ?? []; + return { raw, select: [...raw].sort() }; +} + +/** The rows the grid was handed, once it has been handed some. */ +async function gridRows(): Promise>> { + await waitFor(() => expect(Array.isArray(lastGridProps?.data) && lastGridProps.data.length > 0).toBe(true)); + return lastGridProps.data; +} + +beforeEach(() => { + vi.clearAllMocks(); + lastGridProps = null; +}); +afterEach(() => { + cleanup(); + lastGridProps = null; +}); + +describe('ListView — an ungrouped list asks for the declared nameField (objectui#13116)', () => { + it('asks for a formula nameField no column shows, does not draw it, and the rows name each record', async () => { + const { select } = await mount(contactDef, CONTACT_ROWS, ['first_name', 'last_name', 'title']); + expect(select).toEqual(['first_name', 'full_name', 'id', 'last_name', 'title']); + + // Fetched, never drawn: the grid's columns are the authored three. + const drawn = (lastGridProps?.columns ?? lastGridProps?.fields ?? []) as unknown[]; + expect(JSON.stringify(drawn)).not.toContain('full_name'); + + // The confirm step's rule: the declared value when the row carries it. + const rows = await gridRows(); + expect(rows.map((row) => recordDisplayValueAt(row, declaredNameField(contactDef)))).toEqual(['John Smith', 'Marcus Reed']); + expect(rows.map((row) => getRecordDisplayName(contactDef, row))).toEqual(['John Smith', 'Marcus Reed']); + }); + + it('control: an object with no nameField sends the same $select as before', async () => { + const { select } = await mount(noteDef, NOTE_ROWS, ['subject', 'body']); + expect(select).toEqual(['body', 'id', 'subject']); + }); + + it('control: a nameField that is a column is sent once', async () => { + const { raw } = await mount(contactDef, CONTACT_ROWS, ['full_name', 'title']); + expect(raw.filter((f) => f === 'full_name')).toHaveLength(1); + expect([...raw].sort()).toEqual(['full_name', 'id', 'title']); + }); +});