diff --git a/.changeset/13103-heading-select-label.md b/.changeset/13103-heading-select-label.md new file mode 100644 index 0000000000..a9914d74f8 --- /dev/null +++ b/.changeset/13103-heading-select-label.md @@ -0,0 +1,7 @@ +--- +'@object-ui/components': patch +--- + +The record page heading labels a select `nameField` (objectui#13103). When an object's declared `nameField` (or `displayNameField`) is a select field, the record page H1 now shows the value's option label, for example `Config change`, instead of its stored value `config_change`. The heading's declared-pointer rung reads the record through the same option-label mapping its `titleFormat` rung already uses, so one select field reads the same through either rung. A text `nameField`, the order of the heading's rungs and the field-level-security reads are unchanged. + +Nothing is added to the package entry: no export, prop, type member or language-pack key. diff --git a/packages/components/src/__tests__/page-header-title.selectNameField-13103.test.tsx b/packages/components/src/__tests__/page-header-title.selectNameField-13103.test.tsx new file mode 100644 index 0000000000..60efe83fdb --- /dev/null +++ b/packages/components/src/__tests__/page-header-title.selectNameField-13103.test.tsx @@ -0,0 +1,142 @@ +/** + * 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 record page H1's declared-pointer rung labels a select `nameField` + * (objectui#13103). + * + * ## The defect + * + * `page:header`'s first record rung reads the declared `nameField` off the + * record as served, while the `titleFormat` rung one step down reads the + * record through `withOptionLabels`. So one select field headed the page as + * its option label through the template and as its stored value through the + * pointer, and the pointer outranks the template. The card's case: an audit + * row whose object declares `nameField: 'action'` read `config_change`. + * + * ## What is pinned + * + * - THE CARD: a select `nameField` heads the page with its option label, with + * and without a `titleFormat` beside it (the pointer rung answers first). + * - CONTROLS: a text `nameField` heads the page with its value unchanged, even + * when another select field on the object declares that same string as an + * option value; a value no option declares renders raw; the `titleFormat` + * rung still labels a select token. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { ActionProvider, RecordContextProvider } from '@object-ui/react'; +// Registers `page:header` at module scope, NOT inside a `beforeAll` (see +// page-header-title.oneInterpolator-10447.test.tsx). +import '../renderers'; + +function PageHeader({ schema }: { schema: any }) { + const Component = ComponentRegistry.get('page:header'); + if (!Component) throw new Error('page:header not registered'); + // eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered component (stable), not one created during render + return ; +} + +/** Render the real `page:header` for one record and return its H1 text. */ +function h1Of(objectSchema: any, record: any): string | null | undefined { + const { container } = render( + + + + + , + ); + return container.querySelector('h1')?.textContent; +} + +afterEach(() => { + cleanup(); +}); + +const actionOptions = [ + { value: 'config_change', label: 'Config change' }, + { value: 'login', label: 'Sign in' }, +]; + +const auditLog = (extra: Record = {}) => ({ + name: 'audit_log', + label: 'Audit Log', + nameField: 'action', + fields: { + action: { type: 'select', label: 'Action', options: actionOptions }, + actor: { type: 'text', label: 'Actor' }, + }, + ...extra, +}); + +describe('page:header H1 — the declared-pointer rung labels a select nameField (#13103)', () => { + it('THE CARD — a select nameField heads the page with its option label', () => { + const record = { id: 'rec-1', action: 'config_change', actor: 'ada' }; + + expect(h1Of(auditLog(), record)).toBe('Config change'); + }); + + it('the pointer rung still outranks a titleFormat, and answers with the label', () => { + const record = { id: 'rec-2', action: 'login', actor: 'ada' }; + + expect(h1Of(auditLog({ titleFormat: '{actor}' }), record)).toBe('Sign in'); + }); + + it('the array form of `fields` labels the same way', () => { + const record = { id: 'rec-3', action: 'config_change' }; + const schema = auditLog(); + const arrayFields = Object.entries(schema.fields).map(([name, def]) => ({ name, ...def })); + + expect(h1Of({ ...schema, fields: arrayFields }, record)).toBe('Config change'); + }); + + it('CONTROL — a value no option declares renders raw', () => { + const record = { id: 'rec-4', action: 'purge' }; + + expect(h1Of(auditLog(), record)).toBe('purge'); + }); + + it('CONTROL — a text nameField heads the page with its value unchanged', () => { + // `status` declares `config_change` as an option value; the pointer names + // the text field `subject`, whose value is that same string, and it stays. + const schema = { + name: 'ticket', + label: 'Ticket', + nameField: 'subject', + fields: { + subject: { type: 'text', label: 'Subject' }, + status: { type: 'select', label: 'Status', options: actionOptions }, + }, + }; + const record = { id: 'rec-5', subject: 'config_change', status: 'login' }; + + expect(h1Of(schema, record)).toBe('config_change'); + }); + + it('CONTROL — the titleFormat rung still labels a select token', () => { + const schema = { + name: 'ticket', + label: 'Ticket', + titleFormat: '{deal_no} · {status}', + fields: { + deal_no: { type: 'autonumber', label: 'Deal No' }, + status: { type: 'select', label: 'Status', options: actionOptions }, + }, + }; + const record = { id: 'rec-6', deal_no: 'Q3-042', status: 'login' }; + + expect(h1Of(schema, record)).toBe('Q3-042 · Sign in'); + }); +}); diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index 0186071109..c4acc45de2 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -337,8 +337,8 @@ type FieldOptionLabel = (objectName: string, fieldName: string, value: string, f * * The ONE place this file maps an enum value to its label. Two readers call * it: the `{token}` interpolation below (`page:header`'s `title` / - * `subtitle`), and the record copy the `titleFormat` rung hands to core's - * `formatTitleTemplate` ({@link withOptionLabels}, objectui#10447). + * `subtitle`), and the record copy the H1's `titleFormat` and declared-pointer + * rungs read ({@link withOptionLabels}, objectui#10447, objectui#13103). */ const optionLabelFor = ( objectSchema: any, @@ -2593,10 +2593,12 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // unresolved token. The one thing the H1 adds is the translated option // label for a select value, applied to a copy of the record // (`withOptionLabels`) so the formatter's signature does not move. - const interpolatedTitleFormat = formatTitleTemplate( - objSchema?.titleFormat, - withOptionLabels(data, objSchema, fieldOptionLabel, rawObjectName), - ); + // + // The same labelled copy feeds the declared-pointer rung below + // (objectui#13103): a select `nameField` heads the page with its option + // label, exactly as the same field does through the template. + const labelledData = withOptionLabels(data, objSchema, fieldOptionLabel, rawObjectName); + const interpolatedTitleFormat = formatTitleTemplate(objSchema?.titleFormat, labelledData); // Unified resolver (ADR-0079): honours the object's declared // `nameField`/`displayNameField` and falls back to type-aware field // derivation. `deriveFromRecordKeys: false` keeps bare record-key @@ -2658,9 +2660,12 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // WITHOUT a `titleFormat` this rung answers exactly what `unifiedTitle` // answered before it; only the template's rank moved. A blank value here // falls through to the template, just as `getRecordDisplayName` walks from - // a blank step 1+2 to step 3. + // a blank step 1+2 to step 3. It reads the record through the same + // `withOptionLabels` copy the template rung reads (objectui#13103), so a + // select pointer shows its option label, not its stored value; a non- + // select pointer reads the same value it reads on the raw record. const declaredTitle = (() => { - const resolved = recordDisplayValueAt(data, declaredNameField(objSchema)); + const resolved = recordDisplayValueAt(labelledData, declaredNameField(objSchema)); return resolved && !isResolverFloor(resolved) ? resolved : ''; })(); const titleCandidate =