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
14 changes: 14 additions & 0 deletions .changeset/26417-highlight-chip-caption.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 22 additions & 1 deletion ui/packages/plugin-detail/src/HeaderHighlight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -53,6 +54,9 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
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
Expand Down Expand Up @@ -158,6 +162,23 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
// 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),
Expand Down Expand Up @@ -331,7 +352,7 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
)}
>
<span className="text-xs font-medium text-muted-foreground">
{fieldLabel(objectName || '', field.name, field.label)}
{caption}
</span>

{editorActive ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* 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<typeof createI18n> };

const withI18n = (provider: Provider, node: React.ReactNode) => (
<I18nProvider
{...('instance' in provider
? { instance: provider.instance }
: { config: { defaultLanguage: provider.language, detectBrowserLanguage: false } })}
persistLanguage={false}
>
{node}
</I18nProvider>
);

/** 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,
<RecordContextProvider
objectName={OBJECT}
recordId={record.id}
data={record}
objectSchema={objectSchema as never}
>
<HighlightFieldsProvider>
<div data-testid="strip">
<RecordHighlightsRenderer schema={{ fields } as never} />
</div>
</HighlightFieldsProvider>
</RecordContextProvider>,
),
);
}

/** 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 () => {
// `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(
{ language: 'zh' },
<RecordContextProvider
objectName={OBJECT}
recordId={record.id}
data={record}
objectSchema={objectSchema as never}
>
<div data-testid="body">
<RecordDetailsRenderer schema={{ sections: [{ label: 'Main', fields: ['customer', 'stage'] }] } as never} />
</div>
</RecordContextProvider>,
),
);
const body = within(screen.getByTestId('body'));
// Live control: the body drew the record's values.
expect(await body.findByText('周明轩')).toBeInTheDocument();
expect(body.getByText('design-stage')).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' },
<div data-testid="strip">
<HeaderHighlight
fields={[{ name: 'owner' }] as never}
data={record}
objectName={OBJECT}
objectSchema={schema}
/>
</div>,
);
const { rerender } = render(strip(undefined));
expect(captionOf('陈静')).toBe('owner');
rerender(strip(objectSchema));
expect(captionOf('陈静')).toBe('所有者');
});
});
5 changes: 5 additions & 0 deletions ui/packages/plugin-detail/src/renderers/record-highlights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ const RecordHighlightsBody: React.FC<RecordHighlightsRendererProps> = ({
// 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
Expand Down
Loading