Skip to content

Commit ad2393f

Browse files
fix(core,plugin-detail,app-shell): the related list, reference rail and packaged-actions table draw a served object label as served (#30583)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuEv8Fwf2b7dKNmdsotnksFm)_ Fixes #30486 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:记录页上没写标题的相关列表、引用侧栏卡片,以及「设置 › 打包动作」表的对象列,改为显示服务端下发的对象名(含 `.custom` 扩展里的改名);只有服务端没下发名称时才查前端翻译目录。打包动作读对象列表时带上会话语言。 - 为什么改:客户在 `.custom` 扩展里把对象改名后,这三处仍显示基础包的旧译名(实测:服务端下发 Work items,界面显示 任务 / Tasks),同一个对象出现两个名字;打包动作表读对象时带的是浏览器语言而非会话语言。 - 风险与回滚:未改名的对象显示不变(服务端下发的就是目录译文)。规则从 app-shell 移到 `@object-ui/core`(plugin-detail 引用不到 app-shell),新增 5 个导出,app-shell 原文件改为转出,行为逐字不变。回滚即 revert 本 PR。 - 先看:`ui/packages/core/src/utils/served-object-label.ts`(搬来的规则,函数体未改)、`ui/packages/plugin-detail/src/renderers/record-related-list.tsx`(新增一次与 RelatedList 同键同源的 schema 读取)、`ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx`(`Accept-Language` 与对象列)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-detail/src/renderers/__tests__/record-related-list.titlePlural-11733.test.tsx packages/plugin-detail/src/renderers/__tests__/record-reference-rail.titlePlural-11733.test.tsx packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx`,预期全绿,其中 objectstack#30486 分组断言改名后的对象名胜过目录旧译名。 - 需要你做的:无,席位落地。 ## What changes The served-object-label rule (PR #30388, PR #30475) reaches the three sites the card names. Each was measured on `origin/main` `8e5fabe5` first, live, and all three were still wrong (readings below), so none is reported as already correct. - **Rule home.** `@object-ui/plugin-detail` cannot import `@object-ui/app-shell` (app-shell depends on it), so the rule moves, function bodies unchanged, from app-shell's private `hooks/servedObjectLabel.ts` to `ui/packages/core/src/utils/served-object-label.ts`, a package both already import that holds no provider and already reads `@objectstack/spec/ui` at runtime. Named exports from the core entry: `servedObjectLabel`, `servedObjectPluralLabel`, `metadataText`, `ObjectLabelLookup`, `ObjectPluralLabelLookup` (hence `Clause-②: yes (widening)`). app-shell's file is now a re-export, so its 30-odd importers are untouched. Core over `@object-ui/react` or `@object-ui/i18n`: about 400 app-shell and plugin-detail test files mock those two packages with factories, and core is mocked by none of them. - **Related list** (`record-related-list.tsx`): an untitled block reads the related object's served definition with `useSettledSchema(objectName, ctx.dataSource)`, the same key and data source the inner `RelatedList` already reads for its columns (the adapter's metadata cache answers both), and names the list by `servedObjectPluralLabel`, the bundle plural and then the humanized name as before when nothing is served. - **Reference rail** (`record-reference-rail.tsx`): the rail already reads each related object's schema for its fields and name field; it now also keeps that schema's `label` / `pluralLabel`, and both names (the card title and the empty line) read `servedObjectPluralLabel`. - **Packaged actions** (`PackagedActionsSection.tsx`, `packagedActions.ts` doc only): the `GET /meta/object` read sends `Accept-Language` with the session language (`useObjectTranslation().language`, also an effect dependency), and the object column draws the served label through `servedObjectLabel` instead of the bundle-first `objectLabel`. - One root changeset, `patch` for `@object-ui/core`, `@object-ui/plugin-detail`, `@object-ui/app-shell`. ## Tests - New pins, each an objectstack#30486 top-level `describe` in the component's existing harness file: `record-related-list.titlePlural-11733.test.tsx` (served rename en / zh, served label without plural), `record-reference-rail.titlePlural-11733.test.tsx` (card and empty line en / zh, plus a no-label control), `PackagedActionsSection.test.tsx` (the object read's `Accept-Language`, served rename en / zh, no-label control). The existing bundle cases in those files stay as the controls for the not-served path. - Reverse verification on `65c0b85f`: with the three source files restored to `8e5fabe5` (tree only), `Tests 8 failed | 27 passed (35)`, the 8 being exactly the new served-name cases; restored from `HEAD`, `git diff HEAD` empty. - Targeted run: `Test Files 5 passed (5)`, `Tests 61 passed (61)` (the three files plus `servedObjectLabel-30390.test.ts` and `packagedActions.test.ts`). - Consumer sweep on `688f24a4`: every app-shell test file importing a module that imports `servedObjectLabel` or `useNavTargetLabel` (669 files), plus the whole `plugin-detail` and `core` suites: `Test Files 1274 passed | 1 skipped (1275)`, `Tests 12687 passed | 45 skipped (12732)`. - `type-check` (both tsconfigs) for `@object-ui/core`, `@object-ui/plugin-detail`, `@object-ui/app-shell`: all Done, exit 0. eslint on the 10 changed files: 0 errors (28 existing warnings). ## Acceptance notes - The branch is the card thread's harness-assigned `claude/project-thread-28n2uz`, not `claude/issue-30486-*`. - The live rig names the object through an `object_extension` in `com.example.showcase.custom` (label `Work item`, plural `Work items`, plain strings, so a zh-CN session is served the English rename too). The served list label in zh-CN is `Work items`, the base translation `任务`. - Not changed, observed on the same page: the sidebar navigation entry and the authored tab label still say `任务` / `Tasks`. Both are their own authored labels (an app navigation item, a page tab), not an object-name lookup, so they are outside this rule. - `#30126` row 6 (plugin-detail record-page field labels): no file overlap; this PR touches only the two renderers named above in plugin-detail. ## Verification Live: showcase `objectstack dev --fresh` with the `.custom` rename above and a `.custom` override of `showcase_project_detail` whose Related tab holds an untitled `record:related_list` and an untitled `record:reference_rail` entry for `showcase_task`; Console from `ui/apps/console` vite, 1440 px. Three sessions: en-US browser, zh-CN browser, and a zh-CN session in an en-US browser. | Reading | before `8e5fabe5` | after | |:---|:---|:---| | Related list + rail title, en-US | Tasks, Tasks | Work items, Work items | | Related list + rail title, zh-CN | 任务, 任务 | Work items, Work items | | Packaged actions, `showcase_task` object column, en-US / zh-CN | Task / 任务 | Work item / Work item | | Packaged actions, the section's `/meta/object` `Accept-Language`, zh-CN session in an en-US browser | en-US | zh-CN | | Control: `sys_team` (not renamed), zh-CN session in an en-US browser | 团队 | 团队 | Record page, zh-CN, before and after: ![before zh-CN](https://github.com/objectstack-ai/objectstack/blob/5ebc6c24151041487d35169abcf8a76b3bd84cb8/evidence/30486/before-1-record-related-zh-CN-1440.png?raw=true) ![after zh-CN](https://github.com/objectstack-ai/objectstack/blob/5ebc6c24151041487d35169abcf8a76b3bd84cb8/evidence/30486/after-1-record-related-zh-CN-1440.png?raw=true) Record page, en-US: [before](https://github.com/objectstack-ai/objectstack/blob/5ebc6c24151041487d35169abcf8a76b3bd84cb8/evidence/30486/before-1-record-related-en-US-1440.png?raw=true) · [after](https://github.com/objectstack-ai/objectstack/blob/5ebc6c24151041487d35169abcf8a76b3bd84cb8/evidence/30486/after-1-record-related-en-US-1440.png?raw=true) Packaged actions, zh-CN: [before](https://github.com/objectstack-ai/objectstack/blob/5ebc6c24151041487d35169abcf8a76b3bd84cb8/evidence/30486/before-2-packaged-actions-zh-CN-1440.png?raw=true) · [after](https://github.com/objectstack-ai/objectstack/blob/5ebc6c24151041487d35169abcf8a76b3bd84cb8/evidence/30486/after-2-packaged-actions-zh-CN-1440.png?raw=true). en-US: [before](https://github.com/objectstack-ai/objectstack/blob/5ebc6c24151041487d35169abcf8a76b3bd84cb8/evidence/30486/before-2-packaged-actions-en-US-1440.png?raw=true) · [after](https://github.com/objectstack-ai/objectstack/blob/5ebc6c24151041487d35169abcf8a76b3bd84cb8/evidence/30486/after-2-packaged-actions-en-US-1440.png?raw=true). zh-CN session, en-US browser: [before](https://github.com/objectstack-ai/objectstack/blob/5ebc6c24151041487d35169abcf8a76b3bd84cb8/evidence/30486/before-2-packaged-actions-zh-CN-session-en-browser-1440.png?raw=true) · [after](https://github.com/objectstack-ai/objectstack/blob/5ebc6c24151041487d35169abcf8a76b3bd84cb8/evidence/30486/after-2-packaged-actions-zh-CN-session-en-browser-1440.png?raw=true). The screenshots are committed at `5ebc6c2415` and removed in the next commit, so they never reach `main`. <details><summary>Derived gates (35/35, 0 NOT-MEASURED)</summary> `node scripts/pm/dispatch-gates.mjs --commands` on `0495f150` (the merge of `origin/main`) derived 35 families; all 35 ran locally with exit 0, `check:test-source-alias` after one fix (`6e098057`: the new packaged-actions pin loaded `@object-ui/i18n` inside a test body; now a module-top import). `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. Every family CI runs is read from this PR's check-runs. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgT79N7DN2Gcdsta3mm --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgT79N7DN2Gcdsta3mm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3b8b23c commit ad2393f

11 files changed

Lines changed: 415 additions & 102 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/plugin-detail': patch
4+
'@object-ui/app-shell': patch
5+
---
6+
7+
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
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **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.
13+
- **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.
14+
- **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.

‎ui/packages/app-shell/src/hooks/servedObjectLabel.ts‎

Lines changed: 14 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -7,77 +7,21 @@
77
*/
88

99
/**
10-
* The served-object-label rule (objectstack#30311, objectstack#30390), as pure
11-
* functions: a label the server served is already translated for the session,
12-
* so it is drawn as given, and the client bundle is asked only when none is
13-
* served. A module of its own, with no provider imports, so every surface that
14-
* names an object can read it without pulling the metadata store into its
15-
* module graph. Internal to app-shell: ⛔ not re-exported from the package entry.
10+
* The served-object-label rule (objectstack#30311, objectstack#30390), for
11+
* app-shell's importers. The rule itself lives in `@object-ui/core`
12+
* (`utils/served-object-label.ts`) since objectstack#30486, so the related
13+
* list and reference rail in `@object-ui/plugin-detail`, which cannot import
14+
* this package, read the very same functions. ⛔ Keep this a re-export: a
15+
* second body here is the drift that move closed. Internal to app-shell: ⛔ not
16+
* re-exported from the package entry.
1617
*
1718
* @module
1819
*/
1920

20-
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
21-
22-
/**
23-
* A metadata label (plain string or inline locale map) as display text, or
24-
* `undefined` when there is none. Shared with `useRecentItemLabel`, which asks
25-
* the same question of a page or a report (objectui#11678).
26-
*/
27-
export function metadataText(label: unknown, language: string | undefined): string | undefined {
28-
const text = resolveInlineI18nLabel(label as Parameters<typeof resolveInlineI18nLabel>[0], language);
29-
return typeof text === 'string' && text.trim() !== '' ? text : undefined;
30-
}
31-
32-
/** `''` from a bundle lookup that found nothing and had no metadata to fall back to. */
33-
function nonEmpty(text: string | undefined): string | undefined {
34-
return typeof text === 'string' && text.trim() !== '' ? text : undefined;
35-
}
36-
37-
/** `useObjectLabel().objectPluralLabel` — the client bundle's list-title lookup. */
38-
export type ObjectPluralLabelLookup = (objectDef: { name: string; label: string; pluralLabel?: string }) => string;
39-
40-
/** `useObjectLabel().objectLabel` — the client bundle's record-noun lookup. */
41-
export type ObjectLabelLookup = (objectDef: { name: string; label: string }) => string;
42-
43-
/**
44-
* What a served object's LIST is called (objectstack#30311): its served
45-
* `pluralLabel`, else its served `label`, drawn as given. The server
46-
* translated both, by its label order for an overridden item, so running the
47-
* bundle over them was a second translation pass in which the base package's
48-
* string won a rename back. The bundle's `objectPluralLabel` under the
49-
* object's name is asked only when the definition carries neither, with
50-
* `fallback` (client text no server translated — the object's name, say) as
51-
* the bundle's own last resort; `''` when nothing names it. Shared by every
52-
* surface that titles an object's list — the object page's header, the app
53-
* header, a recent object row, a related list.
54-
*/
55-
export function servedObjectPluralLabel(
56-
objectDef: { name: string; label?: unknown; pluralLabel?: unknown },
57-
language: string | undefined,
58-
objectPluralLabel: ObjectPluralLabelLookup,
59-
fallback = '',
60-
): string {
61-
// `pluralLabel` is a plain string in the spec, so only a string is one.
62-
const plural = typeof objectDef.pluralLabel === 'string' ? nonEmpty(objectDef.pluralLabel) : undefined;
63-
return plural
64-
?? metadataText(objectDef.label, language)
65-
?? objectPluralLabel({ name: objectDef.name, label: fallback });
66-
}
67-
68-
/**
69-
* What ONE record of a served object is called — the singular sibling of
70-
* {@link servedObjectPluralLabel} (objectstack#30390): the served `label`,
71-
* drawn as given, since the server already translated it; the bundle's
72-
* `objectLabel` under the object's name only when the definition carries
73-
* none, with `fallback` as the bundle's own last resort. Every record-scoped
74-
* surface reads it — the record page, its form, drawer and preview card.
75-
*/
76-
export function servedObjectLabel(
77-
objectDef: { name: string; label?: unknown },
78-
language: string | undefined,
79-
objectLabel: ObjectLabelLookup,
80-
fallback = '',
81-
): string {
82-
return metadataText(objectDef.label, language) ?? objectLabel({ name: objectDef.name, label: fallback });
83-
}
21+
export {
22+
metadataText,
23+
servedObjectLabel,
24+
servedObjectPluralLabel,
25+
type ObjectLabelLookup,
26+
type ObjectPluralLabelLookup,
27+
} from '@object-ui/core';

‎ui/packages/app-shell/src/views/setup/PackagedActionsSection.test.tsx‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-li
3838
import React from 'react';
3939

4040
import { toast } from 'sonner';
41+
import { createI18n, I18nProvider } from '@object-ui/i18n';
4142
import { PackagedActionsSection } from './PackagedActionsSection';
4243

4344
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }));
@@ -537,3 +538,76 @@ describe('the switch confirms off, says what happened, and names the object', ()
537538
expect(within(row).getByText('account')).toBeInTheDocument();
538539
});
539540
});
541+
542+
/* -------------------------------------------------------------------------- */
543+
/* 7) The object list is read in the session's language, and its served */
544+
/* object label is drawn as given (objectstack#30486) */
545+
/* -------------------------------------------------------------------------- */
546+
547+
/**
548+
* The server translates each served object `label` for the request's
549+
* `Accept-Language`, a `.custom` rename included, so the object read carries
550+
* the SESSION's language and the column draws that label as given (the
551+
* served-object-label rule, `servedObjectLabel`), never through the bundle's
552+
* translation of the base package's name. An object served without a label
553+
* shows its machine name alone, as before.
554+
*
555+
* Direction, written before the run: with the read's header dropped and the
556+
* column put back to the bundle-first lookup, the header cell and the rename
557+
* cell RED (no header; `客户`); the control GREEN on both sides.
558+
*/
559+
describe('the object column draws the served label, read in the session language (objectstack#30486)', () => {
560+
function renderIn(lang: 'en' | 'zh-CN') {
561+
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
562+
expect(i18n.language).toBe(lang);
563+
// The base package's translation, which a rename must win over.
564+
i18n.addResourceBundle(lang, 'translation', {
565+
crm: { objects: { account: { label: lang === 'zh-CN' ? '客户' : 'Account' } } },
566+
}, true, true);
567+
render(
568+
<I18nProvider instance={i18n}>
569+
<PackagedActionsSection />
570+
</I18nProvider>,
571+
);
572+
return screen.findByTestId('packaged-action-account-send_invoice');
573+
}
574+
575+
const objectReadHeaders = () =>
576+
vi
577+
.mocked(fetch)
578+
.mock.calls.filter(([input]) => String(input).includes('/meta/object'))
579+
.map(([, init]) => new Headers((init as RequestInit | undefined)?.headers));
580+
581+
it('asks for the object list in the session language', async () => {
582+
await renderIn('zh-CN');
583+
const headers = objectReadHeaders();
584+
expect(headers.length).toBeGreaterThan(0);
585+
for (const h of headers) expect(h.get('Accept-Language')).toBe('zh-CN');
586+
});
587+
588+
it('zh-CN: a served rename is drawn as given, not the bundle translation', async () => {
589+
fixture.objects = [
590+
{ ...packagedObject('account', [{ name: 'send_invoice', label: 'Send invoice' }]), label: '往来单位' },
591+
];
592+
const row = await renderIn('zh-CN');
593+
expect(within(row).getByTestId('packaged-action-object-label')).toHaveTextContent('往来单位');
594+
expect(within(row).queryByText('客户')).toBeNull();
595+
});
596+
597+
it('en: a served rename is drawn as given', async () => {
598+
fixture.objects = [
599+
{ ...packagedObject('account', [{ name: 'send_invoice', label: 'Send invoice' }]), label: 'Counterparty' },
600+
];
601+
const row = await renderIn('en');
602+
expect(within(row).getByTestId('packaged-action-object-label')).toHaveTextContent('Counterparty');
603+
});
604+
605+
it('CONTROL: an object served without a label shows only its machine name', async () => {
606+
fixture.objects = [
607+
{ name: 'account', actions: [{ name: 'send_invoice', label: 'Send invoice' }], _packageId: 'com.objectstack.crm', _provenance: 'package' },
608+
];
609+
const row = await renderIn('zh-CN');
610+
expect(within(row).queryByTestId('packaged-action-object-label')).toBeNull();
611+
expect(within(row).getByText('account')).toBeInTheDocument();
612+
});
613+
});

‎ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import { actionErrorDetail } from '@object-ui/core';
9494
// The LEAF module, for the reason its own header gives: this section joins the
9595
// page's graph, and the page is imported eagerly by `services/builtinComponents`.
9696
import { apiBase } from '../../utils/apiBase.js';
97+
import { servedObjectLabel } from '../../hooks/servedObjectLabel.js';
9798
import { envelopeData, envelopeRefused } from './packagedFlows.js';
9899
import {
99100
collectPackagedActions,
@@ -125,18 +126,25 @@ async function readJson(res: Response): Promise<unknown> {
125126
return res.json().catch(() => null);
126127
}
127128

128-
async function getJson(path: string, signal: AbortSignal): Promise<Response> {
129+
async function getJson(path: string, signal: AbortSignal, language?: string): Promise<Response> {
129130
return fetch(`${apiBase()}${path}`, {
130131
credentials: 'include',
131-
headers: JSON_HEADERS,
132+
headers: language ? { ...JSON_HEADERS, 'Accept-Language': language } : JSON_HEADERS,
132133
cache: 'no-store',
133134
signal,
134135
});
135136
}
136137

137-
/** The `object` metadata list — every object, for its embedded `actions[]`. */
138-
async function fetchObjectMetadata(signal: AbortSignal): Promise<unknown[]> {
139-
const res = await getJson('/meta/object', signal);
138+
/**
139+
* The `object` metadata list — every object, for its embedded `actions[]` and
140+
* its name. Asked in the SESSION's language (objectstack#30486): the server
141+
* translates each served `label` for the request's `Accept-Language`, a
142+
* `.custom` rename included, so the object column can draw it as given. A
143+
* bare fetch carried the browser's language instead, and a zh-CN session in an
144+
* English browser was served English names.
145+
*/
146+
async function fetchObjectMetadata(signal: AbortSignal, language: string | undefined): Promise<unknown[]> {
147+
const res = await getJson('/meta/object', signal, language);
140148
if (!res.ok) throw new Error(`object metadata HTTP ${res.status}`);
141149
return readMetadataItems(await readJson(res));
142150
}
@@ -208,7 +216,7 @@ function rowKey(row: Pick<PackagedActionRow, 'name' | 'objectName'>): string {
208216
}
209217

210218
export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedActionsSectionProps) {
211-
const { t } = useObjectTranslation();
219+
const { t, language } = useObjectTranslation();
212220
const { objectLabel } = useObjectLabel();
213221

214222
const [rows, setRows] = React.useState<PackagedActionRow[] | null>(null);
@@ -231,7 +239,7 @@ export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedAct
231239
(async () => {
232240
try {
233241
const [objects, standalone, activation] = await Promise.all([
234-
fetchObjectMetadata(controller.signal),
242+
fetchObjectMetadata(controller.signal, language),
235243
fetchStandaloneActionMetadata(controller.signal),
236244
fetchActivationLedger(controller.signal),
237245
]);
@@ -253,7 +261,7 @@ export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedAct
253261
cancelled = true;
254262
controller.abort();
255263
};
256-
}, [nonce, t]);
264+
}, [nonce, t, language]);
257265

258266
const clearRefusal = React.useCallback((key: string) => {
259267
setRefusals((prev) => {
@@ -408,10 +416,16 @@ export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedAct
408416
{/* The object's label in the reader's language, over the
409417
machine name, which is also the `:object` segment the
410418
flip addresses — `global` for an object-less action,
411-
the platform's own spelling, drawn alone. */}
419+
the platform's own spelling, drawn alone. The label is
420+
the one the server served for this session's language,
421+
drawn as given (the served-object-label rule,
422+
objectstack#30486), never re-translated by the client
423+
bundle, whose base-package name would win a rename
424+
back. An object served without a label shows its
425+
machine name alone. */}
412426
{row.objectLabel && (
413427
<div data-testid="packaged-action-object-label">
414-
{objectLabel({ name: row.objectName, label: row.objectLabel })}
428+
{servedObjectLabel({ name: row.objectName, label: row.objectLabel }, language, objectLabel)}
415429
</div>
416430
)}
417431
<code className="font-mono text-xs text-muted-foreground break-all">{row.objectName}</code>

‎ui/packages/app-shell/src/views/setup/packagedActions.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,10 @@ export interface PackagedActionRow {
8989
label: string;
9090
/**
9191
* The owning object's label as `GET /meta/object` serves it, when the
92-
* object is in that list and declares one; the page resolves it in the
93-
* reader's language and keeps the machine name beside it.
92+
* object is in that list and declares one. The section reads that list in
93+
* the session's language, so this is already the reader's name for the
94+
* object, a `.custom` rename included; the page draws it as given
95+
* (objectstack#30486) and keeps the machine name beside it.
9496
*/
9597
objectLabel?: string;
9698
/** Activation as the ledger reports it — `true` when no row says otherwise. */

‎ui/packages/core/src/index.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,17 @@ export * from './utils/dataset-format.js';
185185
// (objectstack#5473 / objectstack#5665).
186186
export * from './utils/dataset-pivot.js';
187187
export * from './utils/record-title.js';
188+
// The served-object-label rule (objectstack#30311, objectstack#30390): a label
189+
// the server served is drawn as given, and the client bundle is asked only
190+
// when none is served. Homed here by objectstack#30486 so `plugin-detail`'s
191+
// related list and reference rail read the same rule as app-shell's surfaces.
192+
export {
193+
metadataText,
194+
servedObjectLabel,
195+
servedObjectPluralLabel,
196+
type ObjectLabelLookup,
197+
type ObjectPluralLabelLookup,
198+
} from './utils/served-object-label.js';
188199
// The one `colorField` ladder every record view shares (objectui#7243) — the
189200
// gantt, calendar and timeline all resolve an authored option colour here
190201
// instead of each guessing at the raw stored value.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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 served-object-label rule (objectstack#30311, objectstack#30390), as pure
11+
* functions: a label the server served is already translated for the session,
12+
* so it is drawn as given, and the client bundle is asked only when none is
13+
* served.
14+
*
15+
* Homed here (objectstack#30486) rather than in `@object-ui/app-shell`, where
16+
* it was written, because `@object-ui/plugin-detail`'s related list and
17+
* reference rail name an object too and cannot import `app-shell` (which
18+
* depends on them). This package is the one both already import, it holds no
19+
* provider, and it already reads `@objectstack/spec/ui` at runtime. app-shell's
20+
* `hooks/servedObjectLabel.ts` re-exports these names for its own importers.
21+
* ⛔ Do not copy the rule into another package: one rule, one home.
22+
*
23+
* @module
24+
*/
25+
26+
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
27+
28+
/**
29+
* A metadata label (plain string or inline locale map) as display text, or
30+
* `undefined` when there is none. Shared with app-shell's `useRecentItemLabel`,
31+
* which asks the same question of a page or a report (objectui#11678).
32+
*/
33+
export function metadataText(label: unknown, language: string | undefined): string | undefined {
34+
const text = resolveInlineI18nLabel(label as Parameters<typeof resolveInlineI18nLabel>[0], language);
35+
return typeof text === 'string' && text.trim() !== '' ? text : undefined;
36+
}
37+
38+
/** `''` from a bundle lookup that found nothing and had no metadata to fall back to. */
39+
function nonEmpty(text: string | undefined): string | undefined {
40+
return typeof text === 'string' && text.trim() !== '' ? text : undefined;
41+
}
42+
43+
/** `useObjectLabel().objectPluralLabel` — the client bundle's list-title lookup. */
44+
export type ObjectPluralLabelLookup = (objectDef: { name: string; label: string; pluralLabel?: string }) => string;
45+
46+
/** `useObjectLabel().objectLabel` — the client bundle's record-noun lookup. */
47+
export type ObjectLabelLookup = (objectDef: { name: string; label: string }) => string;
48+
49+
/**
50+
* What a served object's LIST is called (objectstack#30311): its served
51+
* `pluralLabel`, else its served `label`, drawn as given. The server
52+
* translated both, by its label order for an overridden item, so running the
53+
* bundle over them was a second translation pass in which the base package's
54+
* string won a rename back. The bundle's `objectPluralLabel` under the
55+
* object's name is asked only when the definition carries neither, with
56+
* `fallback` (client text no server translated — the object's name, say) as
57+
* the bundle's own last resort; `''` when nothing names it. Shared by every
58+
* surface that titles an object's list — the object page's header, the app
59+
* header, a recent object row, a related list, a reference rail card.
60+
*/
61+
export function servedObjectPluralLabel(
62+
objectDef: { name: string; label?: unknown; pluralLabel?: unknown },
63+
language: string | undefined,
64+
objectPluralLabel: ObjectPluralLabelLookup,
65+
fallback = '',
66+
): string {
67+
// `pluralLabel` is a plain string in the spec, so only a string is one.
68+
const plural = typeof objectDef.pluralLabel === 'string' ? nonEmpty(objectDef.pluralLabel) : undefined;
69+
return plural
70+
?? metadataText(objectDef.label, language)
71+
?? objectPluralLabel({ name: objectDef.name, label: fallback });
72+
}
73+
74+
/**
75+
* What ONE record of a served object is called — the singular sibling of
76+
* {@link servedObjectPluralLabel} (objectstack#30390): the served `label`,
77+
* drawn as given, since the server already translated it; the bundle's
78+
* `objectLabel` under the object's name only when the definition carries
79+
* none, with `fallback` as the bundle's own last resort. Every record-scoped
80+
* surface reads it — the record page, its form, drawer and preview card, and
81+
* the packaged-actions table's object column.
82+
*/
83+
export function servedObjectLabel(
84+
objectDef: { name: string; label?: unknown },
85+
language: string | undefined,
86+
objectLabel: ObjectLabelLookup,
87+
fallback = '',
88+
): string {
89+
return metadataText(objectDef.label, language) ?? objectLabel({ name: objectDef.name, label: fallback });
90+
}

0 commit comments

Comments
 (0)