Skip to content

Commit 8b0fbb8

Browse files
fix(app-shell,fields,plugin-form,plugin-gantt,plugin-grid,components,console): record pages, forms, drawers and preview cards draw a served object label as served (#30475)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKTazKuWjb7e43MEv6kUCBs)_ domain:ui#3 · os-bill · session_01Utp6CbPN5UYMnBaNJPJWfG ## 维护者速读 - 改了什么:记录页(标题、面包屑、相关列表标题、删除与无权限提示)、记录表单页与新建/编辑弹窗、记录抽屉、预览卡、悬停卡、记录指针单元格、对象引用单元格、只读提示、甘特名称列、页头记录栏、表单视图标题、AI 停靠芯片、动态行与报表索引,统一改为直接显示服务端下发的对象名;只有服务端没下发名称时才查前端翻译目录。 - 为什么改:客户在 `.custom` 覆盖里给已安装对象改名后,列表与页头(上一张卡已修)显示新名,但点进记录、表单、抽屉、预览卡仍是基础包的旧译名,同一个东西出现两个名字。 - 风险与回滚:未改名的对象显示不变(服务端对它下发的就是目录译文);英文不变。风险在于某处对象定义不是按会话语言读来的,那里会显示浏览器语言的名字,已逐处核过(唯一一处裸 fetch 的已打包动作表保持原样)。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/hooks/servedObjectLabel.ts`(新增单数 `servedObjectLabel`、复数 helper 加 `fallback` 参数,从 useNavTargetLabel 拆出)、`ui/packages/app-shell/src/views/RecordDetailView.tsx`(11 处)、`ui/packages/app-shell/src/console/AppContent.tsx`(弹窗标题与保存提示)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/hooks/__tests__/servedObjectLabel-30390.test.ts packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx packages/app-shell/src/views/record-preview/RecordPreviewCard.test.tsx`,预期全绿,其中 objectstack#30390 分组断言改名后的对象名胜过目录旧译名。 - 需要你做的:无,席位落地。 Fixes #30390 Clause-②: no Metadata-protocol: none ## What changes Route 1 of the card (per site), ruled by the seat on the Claim: every Console surface that names a served object draws the served label as given, and asks the client catalog (`useObjectLabel`) only when no label is served. The rule is the one PR #30388 applied to the list and header surfaces. - `app-shell`: a singular sibling `servedObjectLabel` next to `servedObjectPluralLabel` in the private `hooks/servedObjectLabel.ts` (re-exported by `hooks/useNavTargetLabel.ts` for its existing importers), and an optional `fallback` argument on both (client text no server translated, such as the object's name, handed to the catalog lookup as its own last resort, exactly as the sites passed it before). Neither is exported from the package entry, so `Clause-②: no`. Sites: `AppContent` (dialog title ×2, save toast, way-back label), `ActivityObjectLabel`, `ConsoleLayout` (dock chip), `ObjectDataPage` (record drawer title), `ObjectView` (delete confirmation, cascade labels, import wizard), `RecordDetailView` (related-list titles ×2, phone back and crumb plurals ×2, access-denied and delete copy ×2, cascade labels, record trail ×2, page title ×2), `RecordFormPage` (title and list link), `appReportIndex` (plural and singular), `RecordPreviewCard`, `recordDrawerStack` (singular and plural), `WidgetRefCellFaces` (object-ref cell; its label comes from the metadata store's object list). - Outside `app-shell`, each package applies the same rule inline with its own served text: `fields` `RecordHoverCard`, `plugin-form` `ObjectFormContainer` (save toast) and `ClosedAffordanceNotice`, `plugin-gantt` name column, `plugin-grid` `RecordPointerCell`, `components` page-header record chrome, and the Console `FormPage` form-view heading. The last two are the same-class sites the seat added to the surface. - One root changeset, `patch` for the seven packages. ## Tests - New pins (an objectstack#30390 describe each): `hooks/__tests__/servedObjectLabel-30390.test.ts` (helper rule, inline locale map, fallback order); `RecordFormPage.listLinkPlural-11733.test.tsx` (title, toast and list link of a renamed object); `RecordDetailView.relatedListTitlePlural-11733.test.tsx` (both related-list producers); `record-preview/RecordPreviewCard.test.tsx` (card heading); `plugin-form/src/closedAffordanceNotice.i18n-11000.test.tsx` (notice naming). Each renamed case has a no-label control that the catalog still answers. - Reverse verification on `54a922c2`: with the pre-change source restored (tree only) the 9 renamed-object cases go RED and every control stays GREEN; restored from `HEAD`, `git diff HEAD` empty. - Consumer sweep on `54a922c2` (every test file named for, or importing, a changed module, plus every zh-CN fixture file that serves objects; 615 files): 595 passed, 1 skipped, 19 `RecordDetailView.*` files failed to load because the record page newly reached the metadata provider through `useNavTargetLabel`'s imports. The helpers moved to `hooks/servedObjectLabel.ts`, a module with no provider imports (`useNavTargetLabel` re-exports them); the 19 plus 9 pin and importer files re-run: `Test Files 28 passed (28)`, `Tests 237 passed (237)`. - Fixtures that served English in a zh-CN session were corrected to serve the zh-CN answer the server would serve, assertions unchanged: `RecordDetailView.recordReadFailure-11902`, `AppContent.createLandingOriginPlural-11733`, `AppContent.recordSaveToastNamesRecord-26172`, `InboxPopover.activityObjectLabel-12212`, `HomeActivity.objectLabel-12238` (the last two test names now say where the label comes from), and the first case of `closedAffordanceNotice.i18n-11000`. ## Acceptance notes - Not changed, on purpose: `useNavTargetLabel`'s draft-label rung (a pending draft's label is client text no server translated); `objectViewPageHeader` (already receives the served plural helper); `plugin-detail` `record-reference-rail` (two sites) and `record-related-list` (no definition in hand, only a humanized name: reported to the seat as out of scope); `PackagedActionsSection` (its object list is read by a bare `fetch` without the session's `Accept-Language`, so its served label is in the browser's language, not the session's; drawing it as given would show English to a zh-CN user with an English browser). ## Verification Gate logs, sweep counts and the reverse leg are in the os-dev report comment on #30390. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CZgb7dMvMK3Biysr7P --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZgb7dMvMK3Biysr7P)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 70c3b1c commit 8b0fbb8

31 files changed

Lines changed: 486 additions & 177 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/fields': patch
4+
'@object-ui/plugin-form': patch
5+
'@object-ui/plugin-gantt': patch
6+
'@object-ui/plugin-grid': patch
7+
'@object-ui/components': patch
8+
'@object-ui/console': patch
9+
---
10+
11+
fix(app-shell,fields,plugin-form,plugin-gantt,plugin-grid,components,console): record pages, forms, drawers and preview cards draw a served object label as served
12+
13+
Clause-②: no
14+
Metadata-protocol: none
15+
16+
- **Now.** The record page (its title, breadcrumbs, related-list titles, delete and access-denied copy), the record form page and the console's create/edit dialog (title, save toast, way back to the list), the record drawer, the record preview card, the hover card over a lookup, a record-pointer cell, an object-ref cell, the closed-form notice, the gantt name column, the page header's record chrome, a form view's heading, the AI dock's surface chip, the Activity row and the report index name an object by the label the server served, as given. The server has already translated it, by its label order for an overridden item, so a `.custom` object extension's rename is kept over the base package's translation. Before, the served label reached the client's catalog lookup only as its fallback, so the base package's translation of the old name won on these surfaces after the list and header were fixed.
17+
- **Unchanged.** When no label is served, the catalog key is asked first and the machine name (or its humanized form) stays the last resort, as before. An unedited object shows the same text as before, because the server serves the catalog's translation for it. Related lists and reference rails that only know an object's name, and the packaged-actions table (its object list is read without the session's language), still look the name up in the catalog. English is unchanged.

‎ui/apps/console/src/components/FormPage.tsx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3497,14 +3497,14 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
34973497
* than by its raw qualified name (`showcase_task.form`). The same
34983498
* rule the public route applies in `loadPublicForm`; resolved here
34993499
* because the session's bundle lives in render, not in the loader.
3500-
* The object's API name stays the last resort, `||` so an empty
3501-
* label cannot blank the heading.
3500+
* The served label is drawn as given — the server localized
3501+
* `/meta/object` — and the bundle is asked only when none is
3502+
* served (objectstack#30390). The object's API name stays the last
3503+
* resort, `||` so an empty label cannot blank the heading.
35023504
*/}
35033505
{loaded.label
3504-
?? objectLabel({
3505-
name: loaded.object,
3506-
label: loaded.objectSchema?.label || loaded.object,
3507-
})}
3506+
?? (pickLocalized(loaded.objectSchema?.label, language)
3507+
|| objectLabel({ name: loaded.object, label: loaded.object }))}
35083508
</h1>
35093509
{/*
35103510
* objectui#8408 — the subtitle slot renders the form's `description`.

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { useSignedInUserLocale } from '../hooks/useUserLocale.js';
4040
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveFormViewChrome, selectRecordFormView, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
4141
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
4242
import { isPhoneViewportNow } from '../hooks/usePhoneViewport.js';
43+
import { servedObjectLabel, servedObjectPluralLabel } from '../hooks/servedObjectLabel.js';
4344
import { RECORD_DRAWER_PARAM, RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js';
4445
import { appAddressSegment, appRouteSegment, canonicalAppPathname, matchAppBySegment } from '../utils/appRoute.js';
4546
import { resolveHref, type NavTemplateContext } from '@object-ui/layout';
@@ -1006,11 +1007,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
10061007
// (`recordSaveMessage` in `@object-ui/i18n`), read through the object being
10071008
// saved.
10081009
const recordSaveMessage = useCallback((saved: any): string => buildRecordSaveMessage(t, {
1009-
label: formObjectDef ? objectLabel(formObjectDef as any) : t('common.record', { defaultValue: 'Record' }),
1010+
label: formObjectDef ? servedObjectLabel(formObjectDef, language, objectLabel) : t('common.record', { defaultValue: 'Record' }),
10101011
objectDef: formObjectDef,
10111012
saved,
10121013
mode: editingRecord ? 'edit' : 'create',
1013-
}), [editingRecord, formObjectDef, objectLabel, t]);
1014+
}), [editingRecord, formObjectDef, objectLabel, language, t]);
10141015

10151016
// objectstack-ai/objectstack#27999 — the open record form reports its own
10161017
// write's advisory warnings: while the overlay is open, the write-warning
@@ -1102,15 +1103,15 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
11021103
const originPath = `${location.pathname}${originQs ? `?${originQs}` : ''}`;
11031104
// The origin is a list of the object's records, so the back link names
11041105
// it with the plural, as ObjectView's row navigation does (objectui#11733).
1105-
const originLabel = objectPluralLabel(currentObjectDef);
1106+
const originLabel = servedObjectPluralLabel(currentObjectDef, language, objectPluralLabel);
11061107
navigate(target.url, {
11071108
replace: true,
11081109
state: originLabel ? { from: { pathname: originPath, label: originLabel } } : undefined,
11091110
});
11101111
return;
11111112
}
11121113
navigate(target.url, { replace: true });
1113-
}, [handleCrudSuccess, recordSaveMessage, isChildFormTask, editingRecord, currentObjectDef, appSegment, location.pathname, navigate, closeRecordForm, objectPluralLabel, formObjectDef?.name, storedRequireMfa, t]);
1114+
}, [handleCrudSuccess, recordSaveMessage, isChildFormTask, editingRecord, currentObjectDef, appSegment, location.pathname, navigate, closeRecordForm, objectPluralLabel, language, formObjectDef?.name, storedRequireMfa, t]);
11141115

11151116
// Track recent items on route change. The hook matches `appName` against the
11161117
// pathname's app segment, so it takes the ROUTE SEGMENT: handed
@@ -1746,8 +1747,8 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
17461747
editingRecord ? 'edit' : 'create',
17471748
),
17481749
title: editingRecord
1749-
? t('form.editTitle', { object: objectLabel(formObjectDef as any) })
1750-
: t('form.createTitle', { object: objectLabel(formObjectDef as any) }),
1750+
? t('form.editTitle', { object: servedObjectLabel(formObjectDef, language, objectLabel) })
1751+
: t('form.createTitle', { object: servedObjectLabel(formObjectDef, language, objectLabel) }),
17511752
...(organizationMfaNotice ? { description: organizationMfaNotice } : {}),
17521753
open: isDialogOpen,
17531754
onOpenChange: (open: boolean) => { if (!open) closeRecordForm(); },

‎ui/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -108,26 +108,38 @@ const PAGE_FIELDS = Object.fromEntries(
108108
Array.from({ length: RECORD_SURFACE_PAGE_THRESHOLD + 1 }, (_, i) => [`f${i}`, { type: 'text', label: `F${i}` }]),
109109
);
110110

111-
const OBJECTS = [
112-
{ name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: PAGE_FIELDS },
113-
{ name: 'showcase_note', label: 'Note', fields: PAGE_FIELDS },
114-
];
111+
/**
112+
* The object definitions as the server serves them in each language: a
113+
* `zh-CN` session holds the `zh-CN` answer (objectstack#30390).
114+
*/
115+
const OBJECTS = {
116+
en: [
117+
{ name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: PAGE_FIELDS },
118+
{ name: 'showcase_note', label: 'Note', fields: PAGE_FIELDS },
119+
],
120+
'zh-CN': [
121+
{ name: 'showcase_project', label: '项目', pluralLabel: '项目清单', fields: PAGE_FIELDS },
122+
{ name: 'showcase_note', label: 'Note', fields: PAGE_FIELDS },
123+
],
124+
};
115125

116126
const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }];
117127

118-
// One value for the whole run: the record route's effects key on these
119-
// members, and a fresh value per render would re-run them without end.
120-
const METADATA = {
128+
// One value per language for the whole run: the record route's effects key
129+
// on these members, and a fresh value per render would re-run them without end.
130+
const metadataOf = (objects: unknown[]) => ({
121131
apps: APPS,
122-
objects: OBJECTS,
132+
objects,
123133
loading: false,
124134
ensureType: undefined,
125135
error: null,
126136
refresh: async () => {},
127-
};
137+
});
138+
const METADATA = { en: metadataOf(OBJECTS.en), 'zh-CN': metadataOf(OBJECTS['zh-CN']) };
139+
const session = { lang: 'en' as keyof typeof METADATA };
128140
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
129141
...(await importOriginal<Record<string, unknown>>()),
130-
useMetadata: () => METADATA,
142+
useMetadata: () => METADATA[session.lang],
131143
}));
132144

133145
const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
@@ -155,6 +167,7 @@ afterEach(cleanup);
155167

156168
/** Create a record of `objectName` from its list; the way-back label its page receives. */
157169
async function originAfterCreate(lang: Lang, objectName: string): Promise<string> {
170+
session.lang = lang;
158171
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
159172
// The instance really booted in the language under test.
160173
expect(i18n.language).toBe(lang);

‎ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveToastNamesRecord-26172.test.tsx‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,12 @@ vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
9090
useAdapter: () => dataSourceStub,
9191
}));
9292

93-
const OBJECTS = [
93+
/** The object definitions in `lang`, as the server serves them (`taskLabel` is the task's served label). */
94+
const objectsOf = (taskLabel: string) => [
9495
// Declares its name field: a saved task is named by `subject`.
9596
{
9697
name: 'showcase_task',
97-
label: 'Task',
98+
label: taskLabel,
9899
nameField: 'subject',
99100
fields: { subject: { type: 'text', label: 'Subject' }, done: { type: 'boolean', label: 'Done' } },
100101
},
@@ -104,18 +105,21 @@ const OBJECTS = [
104105

105106
const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }];
106107

107-
// One value for the whole run: the route's effects key on these members.
108-
const METADATA = {
108+
// One value per language for the whole run: the route's effects key on these
109+
// members. A `zh-CN` session holds the `zh-CN` answer (objectstack#30390).
110+
const metadataOf = (objects: unknown[]) => ({
109111
apps: APPS,
110-
objects: OBJECTS,
112+
objects,
111113
loading: false,
112114
ensureType: undefined,
113115
error: null,
114116
refresh: async () => {},
115-
};
117+
});
118+
const METADATA = { en: metadataOf(objectsOf('Task')), 'zh-CN': metadataOf(objectsOf('任务')) };
119+
const session = { lang: 'en' as keyof typeof METADATA };
116120
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
117121
...(await importOriginal<Record<string, unknown>>()),
118-
useMetadata: () => METADATA,
122+
useMetadata: () => METADATA[session.lang],
119123
}));
120124

121125
const ACTION_RUNNER = { execute, runner: { registerHandler: () => {}, getContext: () => ({}) } };
@@ -164,6 +168,7 @@ afterEach(() => {
164168

165169
/** Mount at `url`, save `saved` through the record-form modal, and wait for its toast. */
166170
async function saveFrom(url: string, saved: Record<string, unknown>, lang: Lang = 'en'): Promise<void> {
171+
session.lang = lang;
167172
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
168173
expect(i18n.language).toBe(lang);
169174
i18n.addResourceBundle(lang, 'translation', BUNDLE[lang], true, true);

‎ui/packages/app-shell/src/console/home/__tests__/HomeActivity.objectLabel-12238.test.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,13 @@ function rows(): ActivityFeedReading['value'] {
5959
/** How many times the card read `objects` — reading it is what loads the type. */
6060
let objectReads = 0;
6161

62-
function metadata() {
62+
/** The definitions as the server serves them in `lang`: a `zh-CN` session holds the `zh-CN` label. */
63+
function metadata(lang: Lang) {
6364
return {
6465
apps: [],
6566
get objects() {
6667
objectReads += 1;
67-
return [{ name: 'crm_lead', label: 'Lead' }];
68+
return [{ name: 'crm_lead', label: lang === 'zh-CN' ? '线索' : 'Lead' }];
6869
},
6970
dashboards: [],
7071
reports: [],
@@ -90,7 +91,7 @@ function renderCard(lang: Lang, value: ActivityFeedReading['value'] = rows()) {
9091
expect(i18n.language).toBe(lang);
9192
render(
9293
<I18nProvider instance={i18n} persistLanguage={false}>
93-
<MetadataCtx.Provider value={metadata() as never}>
94+
<MetadataCtx.Provider value={metadata(lang) as never}>
9495
<HomeActivity activity={{ value, status: 'ready', readable: true }} onViewAll={vi.fn()} t={t} />
9596
</MetadataCtx.Provider>
9697
</I18nProvider>,
@@ -118,7 +119,7 @@ describe("Home's activity card names a row's object by its label (objectui#12238
118119
expect(objectShownFor(LEAD_SUMMARY)).toBe('Lead');
119120
});
120121

121-
it('zh-CN: the label comes from the object translation bundle', () => {
122+
it('zh-CN: the label is the one served in the session language (objectstack#30390)', () => {
122123
renderCard('zh-CN');
123124
expect(objectShownFor(LEAD_SUMMARY)).toBe('线索');
124125
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* objectstack#30390 — the served-object-label rule both helpers apply: a label
3+
* the server served is already translated, so it is drawn as given; the
4+
* bundle lookup runs only when none is served, with the caller's client
5+
* fallback (an object's name, say) as the bundle's own last resort.
6+
*
7+
* The lookups are stand-ins for `useObjectLabel()`'s, answering with the
8+
* bundle's packaged string, so every case says which side won.
9+
*/
10+
import { describe, expect, it } from 'vitest';
11+
import { servedObjectLabel, servedObjectPluralLabel } from '../servedObjectLabel';
12+
13+
/** The base package's catalog entry, as the bundle would answer it. */
14+
const objectLabel = ({ name, label }: { name: string; label: string }) =>
15+
name === 'showcase_project' ? '项目' : label;
16+
const objectPluralLabel = ({ name, label }: { name: string; label: string; pluralLabel?: string }) =>
17+
name === 'showcase_project' ? '项目清单' : label;
18+
19+
describe('servedObjectLabel (objectstack#30390)', () => {
20+
it('draws a served label as given, over the packaged translation', () => {
21+
expect(servedObjectLabel({ name: 'showcase_project', label: '风险投资' }, 'zh-CN', objectLabel)).toBe('风险投资');
22+
});
23+
24+
it('reads an inline locale map in the active language', () => {
25+
const label = { en: 'Venture', 'zh-CN': '风险投资' };
26+
expect(servedObjectLabel({ name: 'showcase_project', label }, 'zh-CN', objectLabel)).toBe('风险投资');
27+
});
28+
29+
it('asks the bundle only when no label is served', () => {
30+
expect(servedObjectLabel({ name: 'showcase_project' }, 'zh-CN', objectLabel)).toBe('项目');
31+
expect(servedObjectLabel({ name: 'showcase_project', label: ' ' }, 'zh-CN', objectLabel)).toBe('项目');
32+
});
33+
34+
it('hands the caller fallback to the bundle as its last resort, never ahead of it', () => {
35+
expect(servedObjectLabel({ name: 'crm_lead' }, 'zh-CN', objectLabel, 'crm_lead')).toBe('crm_lead');
36+
expect(servedObjectLabel({ name: 'crm_lead' }, 'zh-CN', objectLabel)).toBe('');
37+
expect(servedObjectLabel({ name: 'showcase_project' }, 'zh-CN', objectLabel, 'showcase_project')).toBe('项目');
38+
});
39+
});
40+
41+
describe('servedObjectPluralLabel fallback (objectstack#30390)', () => {
42+
it('keeps the served plural, then the served label, ahead of the bundle', () => {
43+
const def = { name: 'showcase_project', label: '风险投资', pluralLabel: '风险投资清单' };
44+
expect(servedObjectPluralLabel(def, 'zh-CN', objectPluralLabel, 'showcase_project')).toBe('风险投资清单');
45+
expect(servedObjectPluralLabel({ ...def, pluralLabel: undefined }, 'zh-CN', objectPluralLabel)).toBe('风险投资');
46+
});
47+
48+
it('hands the caller fallback to the bundle when nothing is served', () => {
49+
expect(servedObjectPluralLabel({ name: 'crm_lead' }, 'zh-CN', objectPluralLabel, 'crm_lead')).toBe('crm_lead');
50+
expect(servedObjectPluralLabel({ name: 'showcase_project' }, 'zh-CN', objectPluralLabel, 'x')).toBe('项目清单');
51+
});
52+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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. 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.
16+
*
17+
* @module
18+
*/
19+
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+
}

0 commit comments

Comments
 (0)