diff --git a/.changeset/30486-related-list-rail-packaged-actions-served-object-label.md b/.changeset/30486-related-list-rail-packaged-actions-served-object-label.md new file mode 100644 index 00000000000..0d6a591d626 --- /dev/null +++ b/.changeset/30486-related-list-rail-packaged-actions-served-object-label.md @@ -0,0 +1,14 @@ +--- +'@object-ui/core': patch +'@object-ui/plugin-detail': patch +'@object-ui/app-shell': patch +--- + +fix(plugin-detail,app-shell): the related list, the reference rail and the packaged-actions table name a renamed object by the name the server serves + +Clause-②: yes (widening) +Metadata-protocol: none + +- **The surfaces.** A `record:related_list` block and a `record:reference_rail` card with no authored `title` are headed with the related object's served plural label, else its served label, as the server translated it for the session (a `.custom` extension's rename included). They used to ask only the client translation bundle, which still held the base package's name. The bundle is asked only when the server serves neither. +- **Packaged actions.** Setup → Packaged Actions reads the object list in the session's language (`Accept-Language`), not the browser's, and its Object column draws that served label as given. +- **The widening.** The served-object-label rule now lives in `@object-ui/core`, which `@object-ui/plugin-detail` can import: new exports `servedObjectLabel`, `servedObjectPluralLabel`, `metadataText` and the types `ObjectLabelLookup` and `ObjectPluralLabelLookup`. app-shell's internal copy is now a re-export of these, so both packages read one rule. diff --git a/ui/packages/app-shell/src/hooks/servedObjectLabel.ts b/ui/packages/app-shell/src/hooks/servedObjectLabel.ts index 36f73f8f5c1..d43ca34fd11 100644 --- a/ui/packages/app-shell/src/hooks/servedObjectLabel.ts +++ b/ui/packages/app-shell/src/hooks/servedObjectLabel.ts @@ -7,77 +7,21 @@ */ /** - * The served-object-label rule (objectstack#30311, objectstack#30390), as pure - * functions: a label the server served is already translated for the session, - * so it is drawn as given, and the client bundle is asked only when none is - * served. A module of its own, with no provider imports, so every surface that - * names an object can read it without pulling the metadata store into its - * module graph. Internal to app-shell: ⛔ not re-exported from the package entry. + * The served-object-label rule (objectstack#30311, objectstack#30390), for + * app-shell's importers. The rule itself lives in `@object-ui/core` + * (`utils/served-object-label.ts`) since objectstack#30486, so the related + * list and reference rail in `@object-ui/plugin-detail`, which cannot import + * this package, read the very same functions. ⛔ Keep this a re-export: a + * second body here is the drift that move closed. Internal to app-shell: ⛔ not + * re-exported from the package entry. * * @module */ -import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; - -/** - * A metadata label (plain string or inline locale map) as display text, or - * `undefined` when there is none. Shared with `useRecentItemLabel`, which asks - * the same question of a page or a report (objectui#11678). - */ -export function metadataText(label: unknown, language: string | undefined): string | undefined { - const text = resolveInlineI18nLabel(label as Parameters[0], language); - return typeof text === 'string' && text.trim() !== '' ? text : undefined; -} - -/** `''` from a bundle lookup that found nothing and had no metadata to fall back to. */ -function nonEmpty(text: string | undefined): string | undefined { - return typeof text === 'string' && text.trim() !== '' ? text : undefined; -} - -/** `useObjectLabel().objectPluralLabel` — the client bundle's list-title lookup. */ -export type ObjectPluralLabelLookup = (objectDef: { name: string; label: string; pluralLabel?: string }) => string; - -/** `useObjectLabel().objectLabel` — the client bundle's record-noun lookup. */ -export type ObjectLabelLookup = (objectDef: { name: string; label: string }) => string; - -/** - * What a served object's LIST is called (objectstack#30311): its served - * `pluralLabel`, else its served `label`, drawn as given. The server - * translated both, by its label order for an overridden item, so running the - * bundle over them was a second translation pass in which the base package's - * string won a rename back. The bundle's `objectPluralLabel` under the - * object's name is asked only when the definition carries neither, with - * `fallback` (client text no server translated — the object's name, say) as - * the bundle's own last resort; `''` when nothing names it. Shared by every - * surface that titles an object's list — the object page's header, the app - * header, a recent object row, a related list. - */ -export function servedObjectPluralLabel( - objectDef: { name: string; label?: unknown; pluralLabel?: unknown }, - language: string | undefined, - objectPluralLabel: ObjectPluralLabelLookup, - fallback = '', -): string { - // `pluralLabel` is a plain string in the spec, so only a string is one. - const plural = typeof objectDef.pluralLabel === 'string' ? nonEmpty(objectDef.pluralLabel) : undefined; - return plural - ?? metadataText(objectDef.label, language) - ?? objectPluralLabel({ name: objectDef.name, label: fallback }); -} - -/** - * What ONE record of a served object is called — the singular sibling of - * {@link servedObjectPluralLabel} (objectstack#30390): the served `label`, - * drawn as given, since the server already translated it; the bundle's - * `objectLabel` under the object's name only when the definition carries - * none, with `fallback` as the bundle's own last resort. Every record-scoped - * surface reads it — the record page, its form, drawer and preview card. - */ -export function servedObjectLabel( - objectDef: { name: string; label?: unknown }, - language: string | undefined, - objectLabel: ObjectLabelLookup, - fallback = '', -): string { - return metadataText(objectDef.label, language) ?? objectLabel({ name: objectDef.name, label: fallback }); -} +export { + metadataText, + servedObjectLabel, + servedObjectPluralLabel, + type ObjectLabelLookup, + type ObjectPluralLabelLookup, +} from '@object-ui/core'; diff --git a/ui/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx b/ui/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx index 3f325dfbf16..8adec8b97b8 100644 --- a/ui/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx +++ b/ui/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx @@ -38,6 +38,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-li import React from 'react'; import { toast } from 'sonner'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; import { PackagedActionsSection } from './PackagedActionsSection'; vi.mock('sonner', () => ({ toast: { success: vi.fn() } })); @@ -537,3 +538,76 @@ describe('the switch confirms off, says what happened, and names the object', () expect(within(row).getByText('account')).toBeInTheDocument(); }); }); + +/* -------------------------------------------------------------------------- */ +/* 7) The object list is read in the session's language, and its served */ +/* object label is drawn as given (objectstack#30486) */ +/* -------------------------------------------------------------------------- */ + +/** + * The server translates each served object `label` for the request's + * `Accept-Language`, a `.custom` rename included, so the object read carries + * the SESSION's language and the column draws that label as given (the + * served-object-label rule, `servedObjectLabel`), never through the bundle's + * translation of the base package's name. An object served without a label + * shows its machine name alone, as before. + * + * Direction, written before the run: with the read's header dropped and the + * column put back to the bundle-first lookup, the header cell and the rename + * cell RED (no header; `客户`); the control GREEN on both sides. + */ +describe('the object column draws the served label, read in the session language (objectstack#30486)', () => { + function renderIn(lang: 'en' | 'zh-CN') { + const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); + expect(i18n.language).toBe(lang); + // The base package's translation, which a rename must win over. + i18n.addResourceBundle(lang, 'translation', { + crm: { objects: { account: { label: lang === 'zh-CN' ? '客户' : 'Account' } } }, + }, true, true); + render( + + + , + ); + return screen.findByTestId('packaged-action-account-send_invoice'); + } + + const objectReadHeaders = () => + vi + .mocked(fetch) + .mock.calls.filter(([input]) => String(input).includes('/meta/object')) + .map(([, init]) => new Headers((init as RequestInit | undefined)?.headers)); + + it('asks for the object list in the session language', async () => { + await renderIn('zh-CN'); + const headers = objectReadHeaders(); + expect(headers.length).toBeGreaterThan(0); + for (const h of headers) expect(h.get('Accept-Language')).toBe('zh-CN'); + }); + + it('zh-CN: a served rename is drawn as given, not the bundle translation', async () => { + fixture.objects = [ + { ...packagedObject('account', [{ name: 'send_invoice', label: 'Send invoice' }]), label: '往来单位' }, + ]; + const row = await renderIn('zh-CN'); + expect(within(row).getByTestId('packaged-action-object-label')).toHaveTextContent('往来单位'); + expect(within(row).queryByText('客户')).toBeNull(); + }); + + it('en: a served rename is drawn as given', async () => { + fixture.objects = [ + { ...packagedObject('account', [{ name: 'send_invoice', label: 'Send invoice' }]), label: 'Counterparty' }, + ]; + const row = await renderIn('en'); + expect(within(row).getByTestId('packaged-action-object-label')).toHaveTextContent('Counterparty'); + }); + + it('CONTROL: an object served without a label shows only its machine name', async () => { + fixture.objects = [ + { name: 'account', actions: [{ name: 'send_invoice', label: 'Send invoice' }], _packageId: 'com.objectstack.crm', _provenance: 'package' }, + ]; + const row = await renderIn('zh-CN'); + expect(within(row).queryByTestId('packaged-action-object-label')).toBeNull(); + expect(within(row).getByText('account')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx b/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx index 59c50b3fb9d..02bcce1c2b3 100644 --- a/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx +++ b/ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx @@ -94,6 +94,7 @@ import { actionErrorDetail } from '@object-ui/core'; // The LEAF module, for the reason its own header gives: this section joins the // page's graph, and the page is imported eagerly by `services/builtinComponents`. import { apiBase } from '../../utils/apiBase.js'; +import { servedObjectLabel } from '../../hooks/servedObjectLabel.js'; import { envelopeData, envelopeRefused } from './packagedFlows.js'; import { collectPackagedActions, @@ -125,18 +126,25 @@ async function readJson(res: Response): Promise { return res.json().catch(() => null); } -async function getJson(path: string, signal: AbortSignal): Promise { +async function getJson(path: string, signal: AbortSignal, language?: string): Promise { return fetch(`${apiBase()}${path}`, { credentials: 'include', - headers: JSON_HEADERS, + headers: language ? { ...JSON_HEADERS, 'Accept-Language': language } : JSON_HEADERS, cache: 'no-store', signal, }); } -/** The `object` metadata list — every object, for its embedded `actions[]`. */ -async function fetchObjectMetadata(signal: AbortSignal): Promise { - const res = await getJson('/meta/object', signal); +/** + * The `object` metadata list — every object, for its embedded `actions[]` and + * its name. Asked in the SESSION's language (objectstack#30486): the server + * translates each served `label` for the request's `Accept-Language`, a + * `.custom` rename included, so the object column can draw it as given. A + * bare fetch carried the browser's language instead, and a zh-CN session in an + * English browser was served English names. + */ +async function fetchObjectMetadata(signal: AbortSignal, language: string | undefined): Promise { + const res = await getJson('/meta/object', signal, language); if (!res.ok) throw new Error(`object metadata HTTP ${res.status}`); return readMetadataItems(await readJson(res)); } @@ -208,7 +216,7 @@ function rowKey(row: Pick): string { } export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedActionsSectionProps) { - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { objectLabel } = useObjectLabel(); const [rows, setRows] = React.useState(null); @@ -231,7 +239,7 @@ export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedAct (async () => { try { const [objects, standalone, activation] = await Promise.all([ - fetchObjectMetadata(controller.signal), + fetchObjectMetadata(controller.signal, language), fetchStandaloneActionMetadata(controller.signal), fetchActivationLedger(controller.signal), ]); @@ -253,7 +261,7 @@ export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedAct cancelled = true; controller.abort(); }; - }, [nonce, t]); + }, [nonce, t, language]); const clearRefusal = React.useCallback((key: string) => { setRefusals((prev) => { @@ -408,10 +416,16 @@ export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedAct {/* The object's label in the reader's language, over the machine name, which is also the `:object` segment the flip addresses — `global` for an object-less action, - the platform's own spelling, drawn alone. */} + the platform's own spelling, drawn alone. The label is + the one the server served for this session's language, + drawn as given (the served-object-label rule, + objectstack#30486), never re-translated by the client + bundle, whose base-package name would win a rename + back. An object served without a label shows its + machine name alone. */} {row.objectLabel && (
- {objectLabel({ name: row.objectName, label: row.objectLabel })} + {servedObjectLabel({ name: row.objectName, label: row.objectLabel }, language, objectLabel)}
)} {row.objectName} diff --git a/ui/packages/app-shell/src/views/setup/packagedActions.ts b/ui/packages/app-shell/src/views/setup/packagedActions.ts index 1ec9bb07ec7..ed18d538a9b 100644 --- a/ui/packages/app-shell/src/views/setup/packagedActions.ts +++ b/ui/packages/app-shell/src/views/setup/packagedActions.ts @@ -89,8 +89,10 @@ export interface PackagedActionRow { label: string; /** * The owning object's label as `GET /meta/object` serves it, when the - * object is in that list and declares one; the page resolves it in the - * reader's language and keeps the machine name beside it. + * object is in that list and declares one. The section reads that list in + * the session's language, so this is already the reader's name for the + * object, a `.custom` rename included; the page draws it as given + * (objectstack#30486) and keeps the machine name beside it. */ objectLabel?: string; /** Activation as the ledger reports it — `true` when no row says otherwise. */ diff --git a/ui/packages/core/src/index.ts b/ui/packages/core/src/index.ts index c7057822683..ae558e8e3dc 100644 --- a/ui/packages/core/src/index.ts +++ b/ui/packages/core/src/index.ts @@ -185,6 +185,17 @@ export * from './utils/dataset-format.js'; // (objectstack#5473 / objectstack#5665). export * from './utils/dataset-pivot.js'; export * from './utils/record-title.js'; +// The served-object-label rule (objectstack#30311, objectstack#30390): a label +// the server served is drawn as given, and the client bundle is asked only +// when none is served. Homed here by objectstack#30486 so `plugin-detail`'s +// related list and reference rail read the same rule as app-shell's surfaces. +export { + metadataText, + servedObjectLabel, + servedObjectPluralLabel, + type ObjectLabelLookup, + type ObjectPluralLabelLookup, +} from './utils/served-object-label.js'; // The one `colorField` ladder every record view shares (objectui#7243) — the // gantt, calendar and timeline all resolve an authored option colour here // instead of each guessing at the raw stored value. diff --git a/ui/packages/core/src/utils/served-object-label.ts b/ui/packages/core/src/utils/served-object-label.ts new file mode 100644 index 00000000000..648a1ffd7be --- /dev/null +++ b/ui/packages/core/src/utils/served-object-label.ts @@ -0,0 +1,90 @@ +/** + * 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 served-object-label rule (objectstack#30311, objectstack#30390), as pure + * functions: a label the server served is already translated for the session, + * so it is drawn as given, and the client bundle is asked only when none is + * served. + * + * Homed here (objectstack#30486) rather than in `@object-ui/app-shell`, where + * it was written, because `@object-ui/plugin-detail`'s related list and + * reference rail name an object too and cannot import `app-shell` (which + * depends on them). This package is the one both already import, it holds no + * provider, and it already reads `@objectstack/spec/ui` at runtime. app-shell's + * `hooks/servedObjectLabel.ts` re-exports these names for its own importers. + * ⛔ Do not copy the rule into another package: one rule, one home. + * + * @module + */ + +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; + +/** + * A metadata label (plain string or inline locale map) as display text, or + * `undefined` when there is none. Shared with app-shell's `useRecentItemLabel`, + * which asks the same question of a page or a report (objectui#11678). + */ +export function metadataText(label: unknown, language: string | undefined): string | undefined { + const text = resolveInlineI18nLabel(label as Parameters[0], language); + return typeof text === 'string' && text.trim() !== '' ? text : undefined; +} + +/** `''` from a bundle lookup that found nothing and had no metadata to fall back to. */ +function nonEmpty(text: string | undefined): string | undefined { + return typeof text === 'string' && text.trim() !== '' ? text : undefined; +} + +/** `useObjectLabel().objectPluralLabel` — the client bundle's list-title lookup. */ +export type ObjectPluralLabelLookup = (objectDef: { name: string; label: string; pluralLabel?: string }) => string; + +/** `useObjectLabel().objectLabel` — the client bundle's record-noun lookup. */ +export type ObjectLabelLookup = (objectDef: { name: string; label: string }) => string; + +/** + * What a served object's LIST is called (objectstack#30311): its served + * `pluralLabel`, else its served `label`, drawn as given. The server + * translated both, by its label order for an overridden item, so running the + * bundle over them was a second translation pass in which the base package's + * string won a rename back. The bundle's `objectPluralLabel` under the + * object's name is asked only when the definition carries neither, with + * `fallback` (client text no server translated — the object's name, say) as + * the bundle's own last resort; `''` when nothing names it. Shared by every + * surface that titles an object's list — the object page's header, the app + * header, a recent object row, a related list, a reference rail card. + */ +export function servedObjectPluralLabel( + objectDef: { name: string; label?: unknown; pluralLabel?: unknown }, + language: string | undefined, + objectPluralLabel: ObjectPluralLabelLookup, + fallback = '', +): string { + // `pluralLabel` is a plain string in the spec, so only a string is one. + const plural = typeof objectDef.pluralLabel === 'string' ? nonEmpty(objectDef.pluralLabel) : undefined; + return plural + ?? metadataText(objectDef.label, language) + ?? objectPluralLabel({ name: objectDef.name, label: fallback }); +} + +/** + * What ONE record of a served object is called — the singular sibling of + * {@link servedObjectPluralLabel} (objectstack#30390): the served `label`, + * drawn as given, since the server already translated it; the bundle's + * `objectLabel` under the object's name only when the definition carries + * none, with `fallback` as the bundle's own last resort. Every record-scoped + * surface reads it — the record page, its form, drawer and preview card, and + * the packaged-actions table's object column. + */ +export function servedObjectLabel( + objectDef: { name: string; label?: unknown }, + language: string | undefined, + objectLabel: ObjectLabelLookup, + fallback = '', +): string { + return metadataText(objectDef.label, language) ?? objectLabel({ name: objectDef.name, label: fallback }); +} diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.titlePlural-11733.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.titlePlural-11733.test.tsx index 7ce0072ae8d..8b8b9ccc0eb 100644 --- a/ui/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.titlePlural-11733.test.tsx +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.titlePlural-11733.test.tsx @@ -131,3 +131,70 @@ describe('record:reference_rail names untitled entries with the plural (objectui expect(await screen.findByText('笔记')).toBeInTheDocument(); }); }); + +/** + * objectstack#30486 — an untitled card is named by the related object's + * SERVED name. + * + * The rail already reads each related object's schema (for its fields and + * declared name field); that read also carries the `label` and `pluralLabel` + * the server translated for the session, a `.custom` rename included. Both + * names, the card title and the empty line, read them by the served-object-label + * rule (`servedObjectPluralLabel`, `@object-ui/core`), and the bundle answers + * only for an object served without either — the cases above, whose data + * source has no `getObjectSchema` at all. + * + * Direction, written before the run: with both sites put back to the bundle + * alone, the rename cells RED (`Tasks` / `任务清单`, `商机列表`); the + * control, an object served without a label, GREEN on both sides. + */ +describe('record:reference_rail names untitled entries with the served name (objectstack#30486)', () => { + const SERVED: Record> = { + en: { + showcase_task: { label: 'Work item', pluralLabel: 'Work items' }, + showcase_opportunity: { label: 'Deal', pluralLabel: 'Deals' }, + }, + 'zh-CN': { + showcase_task: { label: '工作项', pluralLabel: '工作事项' }, + showcase_opportunity: { label: '交易' }, + }, + }; + + function mountServed(lang: Lang) { + const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); + expect(i18n.language).toBe(lang); + i18n.addResourceBundle(lang, 'translation', BUNDLE[lang], true, true); + const dataSource = { + ...makeDataSource(), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields: {}, ...SERVED[lang][name] })), + }; + return render( + + + + + + + , + ); + } + + it('en: the card and the empty line draw the served renames', async () => { + mountServed('en'); + expect(await screen.findByText('Work items')).toBeInTheDocument(); + expect(await emptyLineNames()).toBe('Deals'); + expect(screen.queryByText('Tasks')).toBeNull(); + }); + + it('zh-CN: the served plural, else the served label, drawn as given', async () => { + mountServed('zh-CN'); + expect(await screen.findByText('工作事项')).toBeInTheDocument(); + expect(await emptyLineNames()).toBe('交易'); + expect(screen.queryByText('任务清单')).toBeNull(); + }); + + it('CONTROL: an object served without a label keeps the bundle name', async () => { + mountServed('zh-CN'); + expect(await screen.findByText('笔记')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.titlePlural-11733.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.titlePlural-11733.test.tsx index 330f0a3bee7..cf277d8df6e 100644 --- a/ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.titlePlural-11733.test.tsx +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.titlePlural-11733.test.tsx @@ -104,3 +104,61 @@ describe('record:related_list heads an untitled list with the plural (objectui#1 expect(await screen.findByText('Showcase Note')).toBeInTheDocument(); }); }); + +/** + * objectstack#30486 — the related object's SERVED name wins over the bundle. + * + * The heading's object rung reads the served-object-label rule + * (`servedObjectPluralLabel`, `@object-ui/core`): the related object's served + * `pluralLabel`, else its served `label`, drawn as given, because the server + * already translated both for the session, a `.custom` rename included. The + * bundle's plural (the cases above, whose data source serves no label) is the + * answer only when nothing is served. The fixtures serve a RENAME the bundle + * does not know, in each language, so a heading that still reads the bundle + * shows the base package's `任务清单` / `Tasks` instead. + * + * Direction, written before the run: with the object rung put back to the + * bundle alone, the two rename cells and the label-only cell RED (`Tasks`, + * `任务清单`, `任务清单`); the bundle cases above stay GREEN on both sides. + */ +describe('record:related_list heads an untitled list with the served name (objectstack#30486)', () => { + const servedIn = (served: Record) => ({ + find: vi.fn(async () => ({ data: [], total: 0 })), + getObjectSchema: vi.fn(async (name: string) => ({ + name, + ...served, + fields: { name: { type: 'text', label: 'Name' } }, + })), + }); + + function mountServed(lang: Lang, served: Record) { + const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); + expect(i18n.language).toBe(lang); + i18n.addResourceBundle(lang, 'translation', BUNDLE[lang], true, true); + return render( + + + + + , + ); + } + + it('en: a served rename heads the list, not the bundle plural', async () => { + mountServed('en', { label: 'Work item', pluralLabel: 'Work items' }); + expect(await screen.findByText('Work items')).toBeInTheDocument(); + expect(screen.queryByText('Tasks')).toBeNull(); + }); + + it('zh-CN: the served translation of the rename heads the list', async () => { + mountServed('zh-CN', { label: '工作项', pluralLabel: '工作事项' }); + expect(await screen.findByText('工作事项')).toBeInTheDocument(); + expect(screen.queryByText('任务清单')).toBeNull(); + }); + + it('a served label with no served plural is drawn as given', async () => { + mountServed('zh-CN', { label: '工作项' }); + expect(await screen.findByText('工作项')).toBeInTheDocument(); + expect(screen.queryByText('任务清单')).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/record-reference-rail.tsx b/ui/packages/plugin-detail/src/renderers/record-reference-rail.tsx index 2b333efe6ba..4e496b45a2b 100644 --- a/ui/packages/plugin-detail/src/renderers/record-reference-rail.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-reference-rail.tsx @@ -72,8 +72,10 @@ import { declaredNameField, isMultiValueRelationship, recordDisplayValueAt, + servedObjectPluralLabel, type FieldContainerLike, } from '@object-ui/core'; +import { useObjectTranslation } from '@object-ui/i18n'; import { ChevronRight } from 'lucide-react'; import type { ReferenceRailEntry } from '@objectstack/spec/ui'; import { useDetailTranslation } from '../useDetailTranslation'; @@ -287,6 +289,7 @@ export const RecordReferenceRailRenderer: React.FC(); const appName = routeParams.appName; @@ -372,6 +375,13 @@ export const RecordReferenceRailRenderer: React.FC>({}); + // The CHILD objects' SERVED names (objectstack#30486), keyed by object name, + // from the same schema read: the `label` and `pluralLabel` the server + // translated for the session, a `.custom` rename included. What titles a + // card with no authored `title`, by the served-object-label rule below. + const [entryObjectNames, setEntryObjectNames] = React.useState< + Record + >({}); // One warning per (object, field) per mounted rail — the link suppression // below is silent on screen by construction, so the developer channel is the // only place it can be said at all. Fired from the effect, never from render. @@ -579,6 +589,7 @@ export const RecordReferenceRailRenderer: React.FC(); const nameFieldFor = new Map(); + const objectNamesFor = new Map(); if (typeof dataSource.getObjectSchema === 'function') { await Promise.all( Array.from(new Set(entries.map((e) => e.objectName))).map(async (name) => { @@ -590,6 +601,8 @@ export const RecordReferenceRailRenderer: React.FC + entry.title || + servedObjectPluralLabel( + { name: entry.objectName, ...entryObjectNames[entry.objectName] }, + language, + i18n?.objectPluralLabel ? i18n.objectPluralLabel : (def) => def.label, + humanize(entry.objectName), + ); + // Stable partition: an entry is "empty" only once it has finished loading // with total === 0. While loading or on error, we render it so users // don't see flicker / disappearing cards. @@ -657,13 +687,7 @@ export const RecordReferenceRailRenderer: React.FC emptyKeys.has(e.objectName)) - .map( - (e) => - e.title || - (i18n?.objectPluralLabel - ? i18n.objectPluralLabel({ name: e.objectName, label: humanize(e.objectName) }) - : humanize(e.objectName)), - ); + .map((e) => entryTitle(e)); return (
@@ -682,11 +706,7 @@ export const RecordReferenceRailRenderer: React.FC diff --git a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx index d6823b2f47a..b626098de9e 100644 --- a/ui/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/ui/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -21,6 +21,7 @@ import { useRecordContext, useResolvedFilter, useSafeFieldLabel, + useSettledSchema, useRelatedRecordActions, type ElementDataSourceMapping, type RelatedRowActionDef, @@ -32,6 +33,7 @@ import { columnIdentity, elementDataSourceBlock, resolveAffordance, + servedObjectPluralLabel, type ElementDataSourceConfig, } from '@object-ui/core'; import type { RecordRelatedListComponentProps } from '@object-ui/types'; @@ -232,20 +234,37 @@ const RecordRelatedListBody: React.FC = ({ const objectName = schema.objectName; + // The related object's SERVED definition (objectstack#30486) — the very + // read `RelatedList` below makes for its columns (same key, same data + // source, so the adapter's metadata cache answers both), taken here for + // the object's name. + const { def: relatedObjectDef } = useSettledSchema<{ label?: unknown; pluralLabel?: unknown }>( + objectName ?? '', + ctx?.dataSource, + ); + // Resolve a human-friendly title: // 1. authored `schema.title` wins — via pickLocalized so inline-i18n // shapes (`{ en, 'zh-CN' }`) resolve instead of rendering "[object Object]" - // 2. translated object PLURAL label via i18n (key `objects.{name}.pluralLabel`), - // else the translated label (key `objects.{name}.label`) — the block + // 2. the related object's list name by the served-object-label rule + // (`servedObjectPluralLabel`, `@object-ui/core`): its served + // `pluralLabel`, else its served `label`, drawn as given — the server + // translated both for the session, a `.custom` rename included — and + // only with neither served, the client bundle's plural (key + // `objects.{name}.pluralLabel`, else `objects.{name}.label`). The block // lists the related object's records, so it is named as that object's // list page is (objectui#11733) - // 3. humanized objectName (e.g. `opportunity_quote` → "Opportunity Quote") + // 3. humanized objectName (e.g. `opportunity_quote` → "Opportunity Quote"), + // the bundle lookup's own last resort // 4. literal `'Related'` as final fallback - const resolvedObjectLabel = objectName && 'objectPluralLabel' in i18n - ? i18n.objectPluralLabel({ name: objectName, label: humanizeLabel(objectName) }) - : objectName - ? humanizeLabel(objectName) - : ''; + const resolvedObjectLabel = objectName + ? servedObjectPluralLabel( + { name: objectName, label: relatedObjectDef?.label, pluralLabel: relatedObjectDef?.pluralLabel }, + language, + 'objectPluralLabel' in i18n ? i18n.objectPluralLabel : (def) => def.label, + humanizeLabel(objectName), + ) + : ''; const title = pickLocalized(schema.title, language) || resolvedObjectLabel || 'Related'; const perms = usePermissions();