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
7 changes: 7 additions & 0 deletions .changeset/13103-heading-select-label.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 <Component schema={schema} />;
}

/** 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(
<ActionProvider>
<RecordContextProvider
objectName={objectSchema.name}
recordId={record.id}
data={record}
objectSchema={objectSchema}
>
<PageHeader schema={{ type: 'page:header' }} />
</RecordContextProvider>
</ActionProvider>,
);
return container.querySelector('h1')?.textContent;
}

afterEach(() => {
cleanup();
});

const actionOptions = [
{ value: 'config_change', label: 'Config change' },
{ value: 'login', label: 'Sign in' },
];

const auditLog = (extra: Record<string, unknown> = {}) => ({
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');
});
});
21 changes: 13 additions & 8 deletions packages/components/src/renderers/layout/containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2593,10 +2593,12 @@ const PageHeaderRenderer: React.FC<any> = ({ 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
Expand Down Expand Up @@ -2658,9 +2660,12 @@ const PageHeaderRenderer: React.FC<any> = ({ 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 =
Expand Down
Loading