Skip to content

Commit 2dd332d

Browse files
fix(plugin-detail): a highlight chip with no translation is captioned by the field's declared label (#26564)
Fixes #26417 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 记录页标题下的高亮条(highlight strip),字段没有翻译条目时,每个小标题不再是空白,而是显示对象定义里声明的字段标签(内联多语言标签按当前语言取),没有声明标签时显示字段名;有翻译或视图里写了标签时仍以它们为准。 - **为什么改:** AI 生成的应用和所有没有 i18n 包的对象,记录页高亮条只显示「陈静 | 周明轩」两个值,看不出哪个是所有者、哪个是客户;详情区对同一批字段早已按「翻译 → 标签 → 字段名」显示,高亮条漏了中间一级。 - **风险与回滚:** 低。只改 `HeaderHighlight.tsx` 的小标题取值一处,有翻译或作者标签的页面渲染不变(测试钉住);回滚即 revert 本 PR。 - **先看:** `ui/packages/plugin-detail/src/HeaderHighlight.tsx` 中 `caption` 的取值;新测试 `record-highlights.declaredCaption-26417.test.tsx`。 - **两分钟验证:** `pnpm --dir ui exec vitest run packages/plugin-detail/src/renderers/__tests__/record-highlights.declaredCaption-26417.test.tsx`,预期 7 个测试全绿。 - **需要你做的:** 无,席位落地。 ## What changes `ui/packages/plugin-detail/src/HeaderHighlight.tsx`: the chip caption was `fieldLabel(objectName, field.name, field.label)`. With no bundle key and no label on the strip entry (every entry the synthesized page hands `record:highlights`, which passes bare names through as `{ name }`), the fallback was `undefined` and the caption rendered as an empty span. The caption now climbs the details body's ladder, the same rungs in the same order: 1. the bundle's translation (`fieldLabel`, unchanged); 2. the strip entry's own label, when the author wrote one; 3. the label the object declares for the field, read with `pickLocalized` in the session language, so an inline locale map never renders as an object; 4. the field name. Rungs 2 and 3 are the details body's `withDeclaredLabel` (`renderers/record-details.tsx`) and rung 4 is `DetailSection`'s `field.label || field.name`. `DetailSection.tsx` and `record-details.tsx` are not touched. The ladder sits in `HeaderHighlight`, not in the `record:highlights` normaliser, because `HeaderHighlight` has two callers: the `record:highlights` renderer and `DetailView`'s `schema.highlightFields`. Both already hand it the object definition, so filling the rung once covers both. It is computed on every render, so a definition that arrives after the record replaces the field-name caption with the declared label. `record-highlights.tsx` gains one comment line that says where a bare name gets its caption. ## Tests New: `ui/packages/plugin-detail/src/renderers/__tests__/record-highlights.declaredCaption-26417.test.tsx`. Each leg first finds the chip by its value, so an empty caption cannot pass as a strip that drew nothing. - Bare names on an object with no bundle (zh): `所有者` / `关联客户` (the card's case). - An inline locale map: `阶段` in zh, `Stage` in en. - No declared label: the field name, never a blank caption. - A bundle entry still wins over the declared label, and an unbundled sibling still reads its declared label. - An authored strip label still wins over the declared label. - Agreement: the details body draws the same two captions for the same fields. - Late definition (the `DetailView` path, `HeaderHighlight` mounted directly): the caption is the field name before the definition arrives and the declared label after. ### Red, then green - Red at `0729de2e` (the test alone, before the fix): `pnpm exec vitest run packages/plugin-detail/src/renderers/__tests__/record-highlights.declaredCaption-26417.test.tsx` from `ui/` under the verify lock, `VERDICT command-exit 1`, Tests 7 failed (7). Four legs failed on an empty caption (received `''`): bare names, the locale map, the field-name fallback and the late definition. The bundle and authored-label legs passed their first assertion (the bundle entry, the authored label) and then failed on the unbundled sibling's empty caption. The agreement leg failed on its harness: with no name field declared, the details body drops `owner` as the record title. The fix commit switches that leg to `customer` and `stage`. - Green at `542908d2` (fix plus `origin/main` merged), from `ui/` under the lock: `pnpm exec vitest run packages/plugin-detail --maxWorkers=2` exit 0, Test Files 310 passed and 1 skipped (311), Tests 2876 passed and 18 skipped (2894). `pnpm --filter @object-ui/plugin-detail type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) exit 0. The new file alone, verbose, Tests 7 passed (7). ### Gates at `542908d2` `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands. 32 exited 0, including `node scripts/check-sdui-manifest.mjs` (manifest present and intact, 106 components; no registry contract changed, so nothing was regenerated). `--ran` reconciliation: 34 derived, 33 run, 1 NOT-MEASURED, 0 UNRUN. - NOT MEASURED, `pnpm check:type-check-debt`: exit 3, PREREQUISITE NOT MET. Its closure build queued on the shared verify lock twice and never got a turn. CI's `Lint & Repo Gates` is the reading. - NOT MEASURED, `pnpm --dir ui exec vitest run scripts/`: exit 1, but the run was killed with SIGKILL before its summary. Two tests had already timed out at about 18.8 s under box contention: `check-hand-rolled-comment-mask.test.ts` ("exits 0 as a process") and `js-comment-mask-returns-interpolation-9424.test.ts` (the type-check:scripts program). Both test `ui/scripts/`, which this diff does not touch. The first one's subject, `node ui/scripts/check-hand-rolled-comment-mask.mjs`, run directly on this tree, exits 0. CI is the reading. ## Acceptance notes - `DetailView`'s own `highlightFields` path (`HeaderHighlight` mounted directly) gets the same caption, because the ladder sits in `HeaderHighlight`. The late-definition leg pins it. - `DetailSection.tsx`, `record-details.tsx` and the locale packs are untouched. No new i18n key. - The caption ladder now exists in two places: `withDeclaredLabel` in `record-details.tsx` (rungs 2 and 3, filled on the entry) and `HeaderHighlight` (computed at render). The agreement leg pins that both give the same text. Extracting one shared helper would mean editing `record-details.tsx`, which is outside this card's file surface. Carrier: none. Noted here only. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7990fb0 commit 2dd332d

4 files changed

Lines changed: 249 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
fix(plugin-detail): a record highlight chip with no translation is captioned by the field's declared label instead of nothing
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
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.
13+
14+
What to do: nothing.

‎ui/packages/plugin-detail/src/HeaderHighlight.tsx‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { EXPANDABLE_FIELD_TYPES, resolveFieldAffordance, isDeadlineSettled } fro
2121
import { usePermissions } from '@object-ui/permissions';
2222
import { getCellRenderer, resolveCellRendererType, useBooleanValueLabel } from '@object-ui/fields';
2323
import { useSafeFieldLabel, useInlineEdit, usePredicateScope } from '@object-ui/react';
24+
import { useObjectTranslation, pickLocalized } from '@object-ui/i18n';
2425
import { Check, X, Pencil } from 'lucide-react';
2526
import { InlineFieldInput } from './InlineFieldInput';
2627
import {
@@ -53,6 +54,9 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
5354
dataSource,
5455
}) => {
5556
const { fieldLabel } = useSafeFieldLabel();
57+
// The session language a declared label's inline locale map is read in,
58+
// the read the details body makes for the same rung (`record-details.tsx`).
59+
const { language } = useObjectTranslation();
5660
const { t } = useDetailTranslation();
5761
// The boolean chip's word (objectui#11689) — the one every read-only boolean
5862
// word in `@object-ui/fields` reads, so this strip cannot say a different
@@ -158,6 +162,23 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
158162
// details body, which reads `DetailView`'s merged record.
159163
const value = stagedRecord ? stagedRecord[field.name] : rawValue;
160164

165+
// The chip's caption climbs the details body's label ladder
166+
// (objectstack#26417): the bundle's translation, then the entry's
167+
// own label, then the label the OBJECT declares, then the field
168+
// name. Rungs 2-3 are `withDeclaredLabel` in `record-details.tsx`
169+
// (an authored label, even an empty one, is never replaced) and
170+
// rung 4 is `DetailSection`'s `field.label || field.name`.
171+
// `record:highlights` hands this strip bare names as `{ name }`, so
172+
// with no bundle entry the caption used to resolve to `undefined`
173+
// and render empty on every object without a bundle, which is every
174+
// AI-built one. Read on every render, so a definition that arrives
175+
// after the record replaces the field-name caption.
176+
const entryLabel =
177+
field.label !== undefined
178+
? field.label
179+
: pickLocalized(objectDefField?.label, language) || undefined;
180+
const caption = fieldLabel(objectName || '', field.name, entryLabel || field.name);
181+
161182
// Field-level editability gate — shares `isComputedFieldType` with
162183
// DetailSection so the strip and the body agree on which highlights
163184
// are editable. Computed types (formula/summary/rollup/auto_number),
@@ -331,7 +352,7 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
331352
)}
332353
>
333354
<span className="text-xs font-medium text-muted-foreground">
334-
{fieldLabel(objectName || '', field.name, field.label)}
355+
{caption}
335356
</span>
336357

337358
{editorActive ? (
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The highlight strip's chip caption climbs the details body's label ladder
11+
* (objectstack#26417).
12+
*
13+
* The card's sighting: an AI-built object with no i18n bundle, whose record
14+
* drawer drew 「陈静 | 周明轩」 under the title with an EMPTY caption over each
15+
* value. The synthesized page hands `record:highlights` bare names, the
16+
* renderer turns each into `{ name }`, and the strip asked
17+
* `fieldLabel(objectName, name, field.label)`: with no bundle key and no
18+
* authored label that is `undefined`, a blank caption.
19+
*
20+
* The details body names the same fields through one ladder — bundle key, then
21+
* the entry's label (the authored one, else the label the OBJECT declares,
22+
* read with `pickLocalized`), then the field name (`withDeclaredLabel` in
23+
* `record-details.tsx`, `field.label || field.name` in `DetailSection`). The
24+
* strip now climbs that same ladder. Every leg reads a chip that is on screen
25+
* (its value is asserted first), so an empty caption cannot pass as a strip
26+
* that drew nothing.
27+
*/
28+
29+
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
30+
import { render, screen, cleanup, within } from '@testing-library/react';
31+
import '@testing-library/jest-dom';
32+
import * as React from 'react';
33+
import { HighlightFieldsProvider, RecordContextProvider } from '@object-ui/react';
34+
import { I18nProvider, createI18n } from '@object-ui/i18n';
35+
import { HeaderHighlight } from '../../HeaderHighlight';
36+
import { RecordDetailsRenderer } from '../record-details';
37+
import { RecordHighlightsRenderer } from '../record-highlights';
38+
39+
const OBJECT = 'home_project';
40+
41+
/** An AI-built object: declared labels, and no bundle entry anywhere. */
42+
const objectSchema = {
43+
name: OBJECT,
44+
label: '家装项目',
45+
fields: {
46+
owner: { type: 'text', label: '所有者' },
47+
customer: { type: 'text', label: '关联客户' },
48+
// An inline locale map, the other spelling a declared label may take.
49+
stage: { type: 'text', label: { en: 'Stage', 'zh-CN': '阶段' } },
50+
},
51+
};
52+
53+
const record = {
54+
id: 'P1',
55+
owner: '陈静',
56+
customer: '周明轩',
57+
stage: 'design-stage',
58+
notes: 'notes-value',
59+
};
60+
61+
type Provider = { language: string } | { instance: ReturnType<typeof createI18n> };
62+
63+
const withI18n = (provider: Provider, node: React.ReactNode) => (
64+
<I18nProvider
65+
{...('instance' in provider
66+
? { instance: provider.instance }
67+
: { config: { defaultLanguage: provider.language, detectBrowserLanguage: false } })}
68+
persistLanguage={false}
69+
>
70+
{node}
71+
</I18nProvider>
72+
);
73+
74+
/** The record page's strip, from the bare names the synthesized page hands it. */
75+
function mountStrip(fields: unknown[], provider: Provider = { language: 'zh' }) {
76+
return render(
77+
withI18n(
78+
provider,
79+
<RecordContextProvider
80+
objectName={OBJECT}
81+
recordId={record.id}
82+
data={record}
83+
objectSchema={objectSchema as never}
84+
>
85+
<HighlightFieldsProvider>
86+
<div data-testid="strip">
87+
<RecordHighlightsRenderer schema={{ fields } as never} />
88+
</div>
89+
</HighlightFieldsProvider>
90+
</RecordContextProvider>,
91+
),
92+
);
93+
}
94+
95+
/** The caption over the chip that shows `value` — the chip's first span. */
96+
function captionOf(value: string): string | null {
97+
const strip = within(screen.getByTestId('strip').querySelector('section') as HTMLElement);
98+
const chip = strip.getByText(value).closest('section > div > div') as HTMLElement | null;
99+
if (!chip) throw new Error(`HARNESS DEAD: no chip holds ${value}`);
100+
return chip.firstElementChild?.textContent ?? null;
101+
}
102+
103+
beforeAll(() => Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }));
104+
105+
beforeEach(() => {
106+
// `useRecordEditable` probes `POST /api/v1/security/explain`; a double keeps
107+
// the socket closed. Its answer is orthogonal to the captions.
108+
vi.stubGlobal('fetch', vi.fn(async () => ({
109+
ok: true,
110+
status: 200,
111+
json: async () => ({ allowed: true }),
112+
text: async () => '{"allowed":true}',
113+
})) as never);
114+
});
115+
116+
afterEach(() => {
117+
cleanup();
118+
vi.unstubAllGlobals();
119+
});
120+
121+
describe('a highlight chip with no bundle entry is captioned by its declared label (objectstack#26417)', () => {
122+
it('bare names on an object with no bundle: each chip reads the label the object declares', () => {
123+
mountStrip(['owner', 'customer']);
124+
expect(captionOf('陈静')).toBe('所有者');
125+
expect(captionOf('周明轩')).toBe('关联客户');
126+
});
127+
128+
it('an inline locale map is read in the session language, never as an object', () => {
129+
mountStrip(['stage'], { language: 'zh' });
130+
expect(captionOf('design-stage')).toBe('阶段');
131+
cleanup();
132+
mountStrip(['stage'], { language: 'en' });
133+
expect(captionOf('design-stage')).toBe('Stage');
134+
});
135+
136+
it('a field the object declares no label for falls back to its name, never a blank caption', () => {
137+
mountStrip(['notes']);
138+
expect(captionOf('notes-value')).toBe('notes');
139+
});
140+
141+
it('a translated label still wins over the declared one', () => {
142+
const instance = createI18n({
143+
defaultLanguage: 'zh',
144+
detectBrowserLanguage: false,
145+
resources: { zh: { app: { fields: { [OBJECT]: { owner: '负责人' } } } } },
146+
});
147+
mountStrip(['owner', 'customer'], { instance });
148+
expect(captionOf('陈静')).toBe('负责人');
149+
// The bundle names one field; the other still climbs to its declared label.
150+
expect(captionOf('周明轩')).toBe('关联客户');
151+
});
152+
153+
it('an authored label on the entry still wins over the declared one', () => {
154+
mountStrip([{ name: 'owner', label: 'Account Owner' }, 'customer']);
155+
expect(captionOf('陈静')).toBe('Account Owner');
156+
expect(captionOf('周明轩')).toBe('关联客户');
157+
});
158+
159+
it('the strip and the details body name the same field the same way', async () => {
160+
// `customer` and `stage`, not `owner`: with no name field declared, the body
161+
// drops the first text field as the record's title (its own dedupe rule).
162+
mountStrip(['customer', 'stage']);
163+
const stripCaptions = [captionOf('周明轩'), captionOf('design-stage')];
164+
expect(stripCaptions).toEqual(['关联客户', '阶段']);
165+
cleanup();
166+
render(
167+
withI18n(
168+
{ language: 'zh' },
169+
<RecordContextProvider
170+
objectName={OBJECT}
171+
recordId={record.id}
172+
data={record}
173+
objectSchema={objectSchema as never}
174+
>
175+
<div data-testid="body">
176+
<RecordDetailsRenderer schema={{ sections: [{ label: 'Main', fields: ['customer', 'stage'] }] } as never} />
177+
</div>
178+
</RecordContextProvider>,
179+
),
180+
);
181+
const body = within(screen.getByTestId('body'));
182+
// Live control: the body drew the record's values.
183+
expect(await body.findByText('周明轩')).toBeInTheDocument();
184+
expect(body.getByText('design-stage')).toBeInTheDocument();
185+
for (const caption of stripCaptions) expect(body.getByText(caption as string)).toBeInTheDocument();
186+
});
187+
});
188+
189+
describe('the caption follows an object definition that arrives after the record (objectstack#26417)', () => {
190+
it('names the field until the definition lands, then reads its declared label', () => {
191+
const strip = (schema: unknown) =>
192+
withI18n(
193+
{ language: 'zh' },
194+
<div data-testid="strip">
195+
<HeaderHighlight
196+
fields={[{ name: 'owner' }] as never}
197+
data={record}
198+
objectName={OBJECT}
199+
objectSchema={schema}
200+
/>
201+
</div>,
202+
);
203+
const { rerender } = render(strip(undefined));
204+
expect(captionOf('陈静')).toBe('owner');
205+
rerender(strip(objectSchema));
206+
expect(captionOf('陈静')).toBe('所有者');
207+
});
208+
});

‎ui/packages/plugin-detail/src/renderers/record-highlights.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ const RecordHighlightsBody: React.FC<RecordHighlightsRendererProps> = ({
106106
// Normalize: accepts either bare strings or { name, label?, type?, readonly? }
107107
// — the four keys the contract's object arm declares, and no fifth.
108108
//
109+
// A bare name carries no `label`, and none is invented here: `HeaderHighlight`
110+
// climbs the details body's label ladder for the caption (the object's
111+
// declared label, then the name; objectstack#26417), so the strip's other
112+
// caller, `DetailView`'s `highlightFields`, gets the same caption.
113+
//
109114
// `readonly` is copied through deliberately: HeaderHighlight's editability
110115
// gate has always consulted `field.readonly`, but this map used to rebuild
111116
// each entry from a fixed four-key list, so an authored `readonly: true` was

0 commit comments

Comments
 (0)