From 0729de2eaa60198b60b18e3543ee8ca9cfd35d83 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:32:23 +0000 Subject: [PATCH 1/2] test(plugin-detail): pin the highlight chip caption's declared-label rung (red first) Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ...-highlights.declaredCaption-26417.test.tsx | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 ui/packages/plugin-detail/src/renderers/__tests__/record-highlights.declaredCaption-26417.test.tsx diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-highlights.declaredCaption-26417.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-highlights.declaredCaption-26417.test.tsx new file mode 100644 index 00000000000..ccb0df2ecda --- /dev/null +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-highlights.declaredCaption-26417.test.tsx @@ -0,0 +1,204 @@ +/** + * 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 highlight strip's chip caption climbs the details body's label ladder + * (objectstack#26417). + * + * The card's sighting: an AI-built object with no i18n bundle, whose record + * drawer drew 「陈静 | 周明轩」 under the title with an EMPTY caption over each + * value. The synthesized page hands `record:highlights` bare names, the + * renderer turns each into `{ name }`, and the strip asked + * `fieldLabel(objectName, name, field.label)`: with no bundle key and no + * authored label that is `undefined`, a blank caption. + * + * The details body names the same fields through one ladder — bundle key, then + * the entry's label (the authored one, else the label the OBJECT declares, + * read with `pickLocalized`), then the field name (`withDeclaredLabel` in + * `record-details.tsx`, `field.label || field.name` in `DetailSection`). The + * strip now climbs that same ladder. Every leg reads a chip that is on screen + * (its value is asserted first), so an empty caption cannot pass as a strip + * that drew nothing. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { HighlightFieldsProvider, RecordContextProvider } from '@object-ui/react'; +import { I18nProvider, createI18n } from '@object-ui/i18n'; +import { HeaderHighlight } from '../../HeaderHighlight'; +import { RecordDetailsRenderer } from '../record-details'; +import { RecordHighlightsRenderer } from '../record-highlights'; + +const OBJECT = 'home_project'; + +/** An AI-built object: declared labels, and no bundle entry anywhere. */ +const objectSchema = { + name: OBJECT, + label: '家装项目', + fields: { + owner: { type: 'text', label: '所有者' }, + customer: { type: 'text', label: '关联客户' }, + // An inline locale map, the other spelling a declared label may take. + stage: { type: 'text', label: { en: 'Stage', 'zh-CN': '阶段' } }, + }, +}; + +const record = { + id: 'P1', + owner: '陈静', + customer: '周明轩', + stage: 'design-stage', + notes: 'notes-value', +}; + +type Provider = { language: string } | { instance: ReturnType }; + +const withI18n = (provider: Provider, node: React.ReactNode) => ( + + {node} + +); + +/** The record page's strip, from the bare names the synthesized page hands it. */ +function mountStrip(fields: unknown[], provider: Provider = { language: 'zh' }) { + return render( + withI18n( + provider, + + +
+ +
+
+
, + ), + ); +} + +/** The caption over the chip that shows `value` — the chip's first span. */ +function captionOf(value: string): string | null { + const strip = within(screen.getByTestId('strip').querySelector('section') as HTMLElement); + const chip = strip.getByText(value).closest('section > div > div') as HTMLElement | null; + if (!chip) throw new Error(`HARNESS DEAD: no chip holds ${value}`); + return chip.firstElementChild?.textContent ?? null; +} + +beforeAll(() => Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })); + +beforeEach(() => { + // `useRecordEditable` probes `POST /api/v1/security/explain`; a double keeps + // the socket closed. Its answer is orthogonal to the captions. + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ allowed: true }), + text: async () => '{"allowed":true}', + })) as never); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('a highlight chip with no bundle entry is captioned by its declared label (objectstack#26417)', () => { + it('bare names on an object with no bundle: each chip reads the label the object declares', () => { + mountStrip(['owner', 'customer']); + expect(captionOf('陈静')).toBe('所有者'); + expect(captionOf('周明轩')).toBe('关联客户'); + }); + + it('an inline locale map is read in the session language, never as an object', () => { + mountStrip(['stage'], { language: 'zh' }); + expect(captionOf('design-stage')).toBe('阶段'); + cleanup(); + mountStrip(['stage'], { language: 'en' }); + expect(captionOf('design-stage')).toBe('Stage'); + }); + + it('a field the object declares no label for falls back to its name, never a blank caption', () => { + mountStrip(['notes']); + expect(captionOf('notes-value')).toBe('notes'); + }); + + it('a translated label still wins over the declared one', () => { + const instance = createI18n({ + defaultLanguage: 'zh', + detectBrowserLanguage: false, + resources: { zh: { app: { fields: { [OBJECT]: { owner: '负责人' } } } } }, + }); + mountStrip(['owner', 'customer'], { instance }); + expect(captionOf('陈静')).toBe('负责人'); + // The bundle names one field; the other still climbs to its declared label. + expect(captionOf('周明轩')).toBe('关联客户'); + }); + + it('an authored label on the entry still wins over the declared one', () => { + mountStrip([{ name: 'owner', label: 'Account Owner' }, 'customer']); + expect(captionOf('陈静')).toBe('Account Owner'); + expect(captionOf('周明轩')).toBe('关联客户'); + }); + + it('the strip and the details body name the same field the same way', async () => { + mountStrip(['owner', 'customer']); + const stripCaptions = [captionOf('陈静'), captionOf('周明轩')]; + cleanup(); + render( + withI18n( + { language: 'zh' }, + +
+ +
+
, + ), + ); + const body = within(screen.getByTestId('body')); + // Live control: the body drew the record's values. + expect(await body.findByText('陈静')).toBeInTheDocument(); + for (const caption of stripCaptions) expect(body.getByText(caption as string)).toBeInTheDocument(); + }); +}); + +describe('the caption follows an object definition that arrives after the record (objectstack#26417)', () => { + it('names the field until the definition lands, then reads its declared label', () => { + const strip = (schema: unknown) => + withI18n( + { language: 'zh' }, +
+ +
, + ); + const { rerender } = render(strip(undefined)); + expect(captionOf('陈静')).toBe('owner'); + rerender(strip(objectSchema)); + expect(captionOf('陈静')).toBe('所有者'); + }); +}); From eecd138e41546c9181837b5500343d0fe1cd83ed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 06:49:01 +0000 Subject: [PATCH 2/2] fix(plugin-detail): a highlight chip with no translation is captioned by the field's declared label The chip caption now climbs the details body's label ladder: the bundle's translation, then the strip entry's label, then the label the object declares (read in the session language), then the field name. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .changeset/26417-highlight-chip-caption.md | 14 +++++++++++ .../plugin-detail/src/HeaderHighlight.tsx | 23 ++++++++++++++++++- ...-highlights.declaredCaption-26417.test.tsx | 12 ++++++---- .../src/renderers/record-highlights.tsx | 5 ++++ 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 .changeset/26417-highlight-chip-caption.md diff --git a/.changeset/26417-highlight-chip-caption.md b/.changeset/26417-highlight-chip-caption.md new file mode 100644 index 00000000000..a5e7e47ae01 --- /dev/null +++ b/.changeset/26417-highlight-chip-caption.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-detail': patch +--- + +fix(plugin-detail): a record highlight chip with no translation is captioned by the field's declared label instead of nothing + +Clause-②: no +Metadata-protocol: none + +The highlight strip under a record's title drew an empty caption over every chip whose field had no entry in an i18n bundle and no label on the strip entry. That is every chip on a synthesized record page of an object without a bundle, which includes every AI-built app: the strip showed two bare values side by side with nothing saying which field each one was. + +The chip caption now climbs the same ladder the record's details body uses: the bundle's translation, then the strip entry's own label, then the label the object declares for the field (an inline locale map is read in the session language), then the field name. A translated label and an authored strip label still win as before. The caption also updates when the object definition arrives after the record. + +What to do: nothing. diff --git a/ui/packages/plugin-detail/src/HeaderHighlight.tsx b/ui/packages/plugin-detail/src/HeaderHighlight.tsx index 256df4c0625..51341861d68 100644 --- a/ui/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/ui/packages/plugin-detail/src/HeaderHighlight.tsx @@ -21,6 +21,7 @@ import { EXPANDABLE_FIELD_TYPES, resolveFieldAffordance, isDeadlineSettled } fro import { usePermissions } from '@object-ui/permissions'; import { getCellRenderer, resolveCellRendererType, useBooleanValueLabel } from '@object-ui/fields'; import { useSafeFieldLabel, useInlineEdit, usePredicateScope } from '@object-ui/react'; +import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import { Check, X, Pencil } from 'lucide-react'; import { InlineFieldInput } from './InlineFieldInput'; import { @@ -53,6 +54,9 @@ export const HeaderHighlight: React.FC = ({ dataSource, }) => { const { fieldLabel } = useSafeFieldLabel(); + // The session language a declared label's inline locale map is read in, + // the read the details body makes for the same rung (`record-details.tsx`). + const { language } = useObjectTranslation(); const { t } = useDetailTranslation(); // The boolean chip's word (objectui#11689) — the one every read-only boolean // word in `@object-ui/fields` reads, so this strip cannot say a different @@ -158,6 +162,23 @@ export const HeaderHighlight: React.FC = ({ // details body, which reads `DetailView`'s merged record. const value = stagedRecord ? stagedRecord[field.name] : rawValue; + // The chip's caption climbs the details body's label ladder + // (objectstack#26417): the bundle's translation, then the entry's + // own label, then the label the OBJECT declares, then the field + // name. Rungs 2-3 are `withDeclaredLabel` in `record-details.tsx` + // (an authored label, even an empty one, is never replaced) and + // rung 4 is `DetailSection`'s `field.label || field.name`. + // `record:highlights` hands this strip bare names as `{ name }`, so + // with no bundle entry the caption used to resolve to `undefined` + // and render empty on every object without a bundle, which is every + // AI-built one. Read on every render, so a definition that arrives + // after the record replaces the field-name caption. + const entryLabel = + field.label !== undefined + ? field.label + : pickLocalized(objectDefField?.label, language) || undefined; + const caption = fieldLabel(objectName || '', field.name, entryLabel || field.name); + // Field-level editability gate — shares `isComputedFieldType` with // DetailSection so the strip and the body agree on which highlights // are editable. Computed types (formula/summary/rollup/auto_number), @@ -331,7 +352,7 @@ export const HeaderHighlight: React.FC = ({ )} > - {fieldLabel(objectName || '', field.name, field.label)} + {caption} {editorActive ? ( diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-highlights.declaredCaption-26417.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-highlights.declaredCaption-26417.test.tsx index ccb0df2ecda..693c944a20b 100644 --- a/ui/packages/plugin-detail/src/renderers/__tests__/record-highlights.declaredCaption-26417.test.tsx +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-highlights.declaredCaption-26417.test.tsx @@ -157,8 +157,11 @@ describe('a highlight chip with no bundle entry is captioned by its declared lab }); it('the strip and the details body name the same field the same way', async () => { - mountStrip(['owner', 'customer']); - const stripCaptions = [captionOf('陈静'), captionOf('周明轩')]; + // `customer` and `stage`, not `owner`: with no name field declared, the body + // drops the first text field as the record's title (its own dedupe rule). + mountStrip(['customer', 'stage']); + const stripCaptions = [captionOf('周明轩'), captionOf('design-stage')]; + expect(stripCaptions).toEqual(['关联客户', '阶段']); cleanup(); render( withI18n( @@ -170,14 +173,15 @@ describe('a highlight chip with no bundle entry is captioned by its declared lab objectSchema={objectSchema as never} >
- +
, ), ); const body = within(screen.getByTestId('body')); // Live control: the body drew the record's values. - expect(await body.findByText('陈静')).toBeInTheDocument(); + expect(await body.findByText('周明轩')).toBeInTheDocument(); + expect(body.getByText('design-stage')).toBeInTheDocument(); for (const caption of stripCaptions) expect(body.getByText(caption as string)).toBeInTheDocument(); }); }); diff --git a/ui/packages/plugin-detail/src/renderers/record-highlights.tsx b/ui/packages/plugin-detail/src/renderers/record-highlights.tsx index 18becbbee35..7bda49ecfd0 100644 --- a/ui/packages/plugin-detail/src/renderers/record-highlights.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-highlights.tsx @@ -106,6 +106,11 @@ const RecordHighlightsBody: React.FC = ({ // Normalize: accepts either bare strings or { name, label?, type?, readonly? } // — the four keys the contract's object arm declares, and no fifth. // + // A bare name carries no `label`, and none is invented here: `HeaderHighlight` + // climbs the details body's label ladder for the caption (the object's + // declared label, then the name; objectstack#26417), so the strip's other + // caller, `DetailView`'s `highlightFields`, gets the same caption. + // // `readonly` is copied through deliberately: HeaderHighlight's editability // gate has always consulted `field.readonly`, but this map used to rebuild // each entry from a fixed four-key list, so an authored `readonly: true` was