diff --git a/.changeset/30390-served-object-label-record-surfaces.md b/.changeset/30390-served-object-label-record-surfaces.md new file mode 100644 index 00000000000..84d2d98215b --- /dev/null +++ b/.changeset/30390-served-object-label-record-surfaces.md @@ -0,0 +1,17 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/fields': patch +'@object-ui/plugin-form': patch +'@object-ui/plugin-gantt': patch +'@object-ui/plugin-grid': patch +'@object-ui/components': patch +'@object-ui/console': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +- **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. +- **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. diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index b87161cb49f..830a344674f 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -3497,14 +3497,14 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { * than by its raw qualified name (`showcase_task.form`). The same * rule the public route applies in `loadPublicForm`; resolved here * because the session's bundle lives in render, not in the loader. - * The object's API name stays the last resort, `||` so an empty - * label cannot blank the heading. + * The served label is drawn as given — the server localized + * `/meta/object` — and the bundle is asked only when none is + * served (objectstack#30390). The object's API name stays the last + * resort, `||` so an empty label cannot blank the heading. */} {loaded.label - ?? objectLabel({ - name: loaded.object, - label: loaded.objectSchema?.label || loaded.object, - })} + ?? (pickLocalized(loaded.objectSchema?.label, language) + || objectLabel({ name: loaded.object, label: loaded.object }))} {/* * objectui#8408 — the subtitle slot renders the form's `description`. diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 47fca35be0a..b19678572e0 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -40,6 +40,7 @@ import { useSignedInUserLocale } from '../hooks/useUserLocale.js'; import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveFormViewChrome, selectRecordFormView, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js'; import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view'; import { isPhoneViewportNow } from '../hooks/usePhoneViewport.js'; +import { servedObjectLabel, servedObjectPluralLabel } from '../hooks/servedObjectLabel.js'; import { RECORD_DRAWER_PARAM, RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js'; import { appAddressSegment, appRouteSegment, canonicalAppPathname, matchAppBySegment } from '../utils/appRoute.js'; import { resolveHref, type NavTemplateContext } from '@object-ui/layout'; @@ -1006,11 +1007,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = // (`recordSaveMessage` in `@object-ui/i18n`), read through the object being // saved. const recordSaveMessage = useCallback((saved: any): string => buildRecordSaveMessage(t, { - label: formObjectDef ? objectLabel(formObjectDef as any) : t('common.record', { defaultValue: 'Record' }), + label: formObjectDef ? servedObjectLabel(formObjectDef, language, objectLabel) : t('common.record', { defaultValue: 'Record' }), objectDef: formObjectDef, saved, mode: editingRecord ? 'edit' : 'create', - }), [editingRecord, formObjectDef, objectLabel, t]); + }), [editingRecord, formObjectDef, objectLabel, language, t]); // objectstack-ai/objectstack#27999 — the open record form reports its own // write's advisory warnings: while the overlay is open, the write-warning @@ -1102,7 +1103,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = const originPath = `${location.pathname}${originQs ? `?${originQs}` : ''}`; // The origin is a list of the object's records, so the back link names // it with the plural, as ObjectView's row navigation does (objectui#11733). - const originLabel = objectPluralLabel(currentObjectDef); + const originLabel = servedObjectPluralLabel(currentObjectDef, language, objectPluralLabel); navigate(target.url, { replace: true, state: originLabel ? { from: { pathname: originPath, label: originLabel } } : undefined, @@ -1110,7 +1111,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = return; } navigate(target.url, { replace: true }); - }, [handleCrudSuccess, recordSaveMessage, isChildFormTask, editingRecord, currentObjectDef, appSegment, location.pathname, navigate, closeRecordForm, objectPluralLabel, formObjectDef?.name, storedRequireMfa, t]); + }, [handleCrudSuccess, recordSaveMessage, isChildFormTask, editingRecord, currentObjectDef, appSegment, location.pathname, navigate, closeRecordForm, objectPluralLabel, language, formObjectDef?.name, storedRequireMfa, t]); // Track recent items on route change. The hook matches `appName` against the // pathname's app segment, so it takes the ROUTE SEGMENT: handed @@ -1746,8 +1747,8 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = editingRecord ? 'edit' : 'create', ), title: editingRecord - ? t('form.editTitle', { object: objectLabel(formObjectDef as any) }) - : t('form.createTitle', { object: objectLabel(formObjectDef as any) }), + ? t('form.editTitle', { object: servedObjectLabel(formObjectDef, language, objectLabel) }) + : t('form.createTitle', { object: servedObjectLabel(formObjectDef, language, objectLabel) }), ...(organizationMfaNotice ? { description: organizationMfaNotice } : {}), open: isDialogOpen, onOpenChange: (open: boolean) => { if (!open) closeRecordForm(); }, diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx index 6ced0223b9b..8d12ccc8736 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.createLandingOriginPlural-11733.test.tsx @@ -108,26 +108,38 @@ const PAGE_FIELDS = Object.fromEntries( Array.from({ length: RECORD_SURFACE_PAGE_THRESHOLD + 1 }, (_, i) => [`f${i}`, { type: 'text', label: `F${i}` }]), ); -const OBJECTS = [ - { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: PAGE_FIELDS }, - { name: 'showcase_note', label: 'Note', fields: PAGE_FIELDS }, -]; +/** + * The object definitions as the server serves them in each language: a + * `zh-CN` session holds the `zh-CN` answer (objectstack#30390). + */ +const OBJECTS = { + en: [ + { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: PAGE_FIELDS }, + { name: 'showcase_note', label: 'Note', fields: PAGE_FIELDS }, + ], + 'zh-CN': [ + { name: 'showcase_project', label: '项目', pluralLabel: '项目清单', fields: PAGE_FIELDS }, + { name: 'showcase_note', label: 'Note', fields: PAGE_FIELDS }, + ], +}; const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }]; -// One value for the whole run: the record route's effects key on these -// members, and a fresh value per render would re-run them without end. -const METADATA = { +// One value per language for the whole run: the record route's effects key +// on these members, and a fresh value per render would re-run them without end. +const metadataOf = (objects: unknown[]) => ({ apps: APPS, - objects: OBJECTS, + objects, loading: false, ensureType: undefined, error: null, refresh: async () => {}, -}; +}); +const METADATA = { en: metadataOf(OBJECTS.en), 'zh-CN': metadataOf(OBJECTS['zh-CN']) }; +const session = { lang: 'en' as keyof typeof METADATA }; vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ ...(await importOriginal>()), - useMetadata: () => METADATA, + useMetadata: () => METADATA[session.lang], })); const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } }; @@ -155,6 +167,7 @@ afterEach(cleanup); /** Create a record of `objectName` from its list; the way-back label its page receives. */ async function originAfterCreate(lang: Lang, objectName: string): Promise { + session.lang = lang; const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); // The instance really booted in the language under test. expect(i18n.language).toBe(lang); diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveToastNamesRecord-26172.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveToastNamesRecord-26172.test.tsx index 17cc5fb4e62..3f6cdbb623f 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveToastNamesRecord-26172.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.recordSaveToastNamesRecord-26172.test.tsx @@ -90,11 +90,12 @@ vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ useAdapter: () => dataSourceStub, })); -const OBJECTS = [ +/** The object definitions in `lang`, as the server serves them (`taskLabel` is the task's served label). */ +const objectsOf = (taskLabel: string) => [ // Declares its name field: a saved task is named by `subject`. { name: 'showcase_task', - label: 'Task', + label: taskLabel, nameField: 'subject', fields: { subject: { type: 'text', label: 'Subject' }, done: { type: 'boolean', label: 'Done' } }, }, @@ -104,18 +105,21 @@ const OBJECTS = [ const APPS = [{ name: 'crm', label: 'CRM', isDefault: true, navigation: [] }]; -// One value for the whole run: the route's effects key on these members. -const METADATA = { +// One value per language for the whole run: the route's effects key on these +// members. A `zh-CN` session holds the `zh-CN` answer (objectstack#30390). +const metadataOf = (objects: unknown[]) => ({ apps: APPS, - objects: OBJECTS, + objects, loading: false, ensureType: undefined, error: null, refresh: async () => {}, -}; +}); +const METADATA = { en: metadataOf(objectsOf('Task')), 'zh-CN': metadataOf(objectsOf('任务')) }; +const session = { lang: 'en' as keyof typeof METADATA }; vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ ...(await importOriginal>()), - useMetadata: () => METADATA, + useMetadata: () => METADATA[session.lang], })); const ACTION_RUNNER = { execute, runner: { registerHandler: () => {}, getContext: () => ({}) } }; @@ -164,6 +168,7 @@ afterEach(() => { /** Mount at `url`, save `saved` through the record-form modal, and wait for its toast. */ async function saveFrom(url: string, saved: Record, lang: Lang = 'en'): Promise { + session.lang = lang; const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); expect(i18n.language).toBe(lang); i18n.addResourceBundle(lang, 'translation', BUNDLE[lang], true, true); diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomeActivity.objectLabel-12238.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomeActivity.objectLabel-12238.test.tsx index c79e3556c99..aded73967f6 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomeActivity.objectLabel-12238.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomeActivity.objectLabel-12238.test.tsx @@ -59,12 +59,13 @@ function rows(): ActivityFeedReading['value'] { /** How many times the card read `objects` — reading it is what loads the type. */ let objectReads = 0; -function metadata() { +/** The definitions as the server serves them in `lang`: a `zh-CN` session holds the `zh-CN` label. */ +function metadata(lang: Lang) { return { apps: [], get objects() { objectReads += 1; - return [{ name: 'crm_lead', label: 'Lead' }]; + return [{ name: 'crm_lead', label: lang === 'zh-CN' ? '线索' : 'Lead' }]; }, dashboards: [], reports: [], @@ -90,7 +91,7 @@ function renderCard(lang: Lang, value: ActivityFeedReading['value'] = rows()) { expect(i18n.language).toBe(lang); render( - + , @@ -118,7 +119,7 @@ describe("Home's activity card names a row's object by its label (objectui#12238 expect(objectShownFor(LEAD_SUMMARY)).toBe('Lead'); }); - it('zh-CN: the label comes from the object translation bundle', () => { + it('zh-CN: the label is the one served in the session language (objectstack#30390)', () => { renderCard('zh-CN'); expect(objectShownFor(LEAD_SUMMARY)).toBe('线索'); }); diff --git a/ui/packages/app-shell/src/hooks/__tests__/servedObjectLabel-30390.test.ts b/ui/packages/app-shell/src/hooks/__tests__/servedObjectLabel-30390.test.ts new file mode 100644 index 00000000000..e6290db2a5e --- /dev/null +++ b/ui/packages/app-shell/src/hooks/__tests__/servedObjectLabel-30390.test.ts @@ -0,0 +1,52 @@ +/** + * objectstack#30390 — the served-object-label rule both helpers apply: a label + * the server served is already translated, so it is drawn as given; the + * bundle lookup runs only when none is served, with the caller's client + * fallback (an object's name, say) as the bundle's own last resort. + * + * The lookups are stand-ins for `useObjectLabel()`'s, answering with the + * bundle's packaged string, so every case says which side won. + */ +import { describe, expect, it } from 'vitest'; +import { servedObjectLabel, servedObjectPluralLabel } from '../servedObjectLabel'; + +/** The base package's catalog entry, as the bundle would answer it. */ +const objectLabel = ({ name, label }: { name: string; label: string }) => + name === 'showcase_project' ? '项目' : label; +const objectPluralLabel = ({ name, label }: { name: string; label: string; pluralLabel?: string }) => + name === 'showcase_project' ? '项目清单' : label; + +describe('servedObjectLabel (objectstack#30390)', () => { + it('draws a served label as given, over the packaged translation', () => { + expect(servedObjectLabel({ name: 'showcase_project', label: '风险投资' }, 'zh-CN', objectLabel)).toBe('风险投资'); + }); + + it('reads an inline locale map in the active language', () => { + const label = { en: 'Venture', 'zh-CN': '风险投资' }; + expect(servedObjectLabel({ name: 'showcase_project', label }, 'zh-CN', objectLabel)).toBe('风险投资'); + }); + + it('asks the bundle only when no label is served', () => { + expect(servedObjectLabel({ name: 'showcase_project' }, 'zh-CN', objectLabel)).toBe('项目'); + expect(servedObjectLabel({ name: 'showcase_project', label: ' ' }, 'zh-CN', objectLabel)).toBe('项目'); + }); + + it('hands the caller fallback to the bundle as its last resort, never ahead of it', () => { + expect(servedObjectLabel({ name: 'crm_lead' }, 'zh-CN', objectLabel, 'crm_lead')).toBe('crm_lead'); + expect(servedObjectLabel({ name: 'crm_lead' }, 'zh-CN', objectLabel)).toBe(''); + expect(servedObjectLabel({ name: 'showcase_project' }, 'zh-CN', objectLabel, 'showcase_project')).toBe('项目'); + }); +}); + +describe('servedObjectPluralLabel fallback (objectstack#30390)', () => { + it('keeps the served plural, then the served label, ahead of the bundle', () => { + const def = { name: 'showcase_project', label: '风险投资', pluralLabel: '风险投资清单' }; + expect(servedObjectPluralLabel(def, 'zh-CN', objectPluralLabel, 'showcase_project')).toBe('风险投资清单'); + expect(servedObjectPluralLabel({ ...def, pluralLabel: undefined }, 'zh-CN', objectPluralLabel)).toBe('风险投资'); + }); + + it('hands the caller fallback to the bundle when nothing is served', () => { + expect(servedObjectPluralLabel({ name: 'crm_lead' }, 'zh-CN', objectPluralLabel, 'crm_lead')).toBe('crm_lead'); + expect(servedObjectPluralLabel({ name: 'showcase_project' }, 'zh-CN', objectPluralLabel, 'x')).toBe('项目清单'); + }); +}); diff --git a/ui/packages/app-shell/src/hooks/servedObjectLabel.ts b/ui/packages/app-shell/src/hooks/servedObjectLabel.ts new file mode 100644 index 00000000000..36f73f8f5c1 --- /dev/null +++ b/ui/packages/app-shell/src/hooks/servedObjectLabel.ts @@ -0,0 +1,83 @@ +/** + * 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. 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. + * + * @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 }); +} diff --git a/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts index b3c47beafbc..fdaecaa3894 100644 --- a/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts +++ b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts @@ -71,7 +71,6 @@ import { createContext, useContext, useMemo } from 'react'; import { canonicalMetaUrlType } from '@objectstack/spec/shared'; -import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import { resolveViewId } from '@object-ui/core'; import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import type { NavLabelTarget, NavTargetLabelResolver } from '@object-ui/layout'; @@ -79,45 +78,21 @@ import { useMetadata } from '../providers/MetadataProvider.js'; import { bundledFirst, useBundledReads } from '../providers/bundledReads.js'; import { readObjectsByName, useReadItemsByName } from './useObjectPresence.js'; import { isServedView } from './useServedViewItems.js'; +import { metadataText } from './servedObjectLabel.js'; -/** - * 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; -} +export { + metadataText, + servedObjectLabel, + servedObjectPluralLabel, + type ObjectLabelLookup, + type ObjectPluralLabelLookup, +} from './servedObjectLabel.js'; /** `''` 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; - -/** - * 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; `''` 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. - */ -export function servedObjectPluralLabel( - objectDef: { name: string; label?: unknown; pluralLabel?: unknown }, - language: string | undefined, - objectPluralLabel: ObjectPluralLabelLookup, -): 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: '' }); -} - /** The two members this hook reads off a cached metadata record. */ export interface LabelledRecord { name?: unknown; diff --git a/ui/packages/app-shell/src/layout/ActivityObjectLabel.tsx b/ui/packages/app-shell/src/layout/ActivityObjectLabel.tsx index 32bfe312dfe..d4e2bcdd347 100644 --- a/ui/packages/app-shell/src/layout/ActivityObjectLabel.tsx +++ b/ui/packages/app-shell/src/layout/ActivityObjectLabel.tsx @@ -11,7 +11,7 @@ */ import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; -import { findByName, metadataText } from '../hooks/useNavTargetLabel.js'; +import { findByName, servedObjectLabel } from '../hooks/useNavTargetLabel.js'; import { useMetadata } from '../providers/MetadataProvider.js'; import { readObjectsByName } from '../hooks/useObjectPresence.js'; @@ -29,8 +29,9 @@ function nonEmpty(text: string | undefined): string | undefined { * `… · sys_user_preference`. The console resolves the name; the row stays as * the server wrote it. The label is read the way `useNavTargetLabel` reads an * object's: the definition's `label` from `useMetadata().objects` (an inline - * locale map read in the active language), passed through `useObjectLabel`'s - * bundle lookup (`{ns}.objects..label`). + * locale map read in the active language), drawn as served since the server + * translated it (objectstack#30390); `useObjectLabel`'s bundle lookup + * (`{ns}.objects..label`) only when the definition carries none. * * objectstack#29121 — the definition is read by name (`readObjectsByName`) * while nothing has loaded the whole `object` list, so a few rows on Home ask @@ -53,6 +54,6 @@ export function ActivityObjectLabel({ objectName }: { objectName: string }) { const metadata = useMetadata(); const { objectLabel } = useObjectLabel(); const { language } = useObjectTranslation(); - const text = metadataText(findByName(readObjectsByName(metadata, [objectName]), objectName)?.label, language); - return <>{nonEmpty(objectLabel({ name: objectName, label: text ?? '' })) ?? objectName}; + const def = findByName(readObjectsByName(metadata, [objectName]), objectName); + return <>{nonEmpty(servedObjectLabel({ name: objectName, label: def?.label }, language, objectLabel)) ?? objectName}; } diff --git a/ui/packages/app-shell/src/layout/ConsoleLayout.tsx b/ui/packages/app-shell/src/layout/ConsoleLayout.tsx index bf8812770df..ae8796c28d4 100644 --- a/ui/packages/app-shell/src/layout/ConsoleLayout.tsx +++ b/ui/packages/app-shell/src/layout/ConsoleLayout.tsx @@ -35,6 +35,7 @@ import { usePhoneNavDrawerHistory } from './usePhoneNavDrawerHistory.js'; import { usePhoneViewport } from '../hooks/usePhoneViewport.js'; import { primeConsoleSidebarOpen } from './consoleSidebarRail.js'; import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js'; +import { servedObjectLabel } from '../hooks/servedObjectLabel.js'; import { useNavigationContext } from '../context/NavigationContext.js'; import { CommandPaletteProvider } from '../context/CommandPaletteProvider.js'; import { confirmUnsavedLeave } from '../chrome/unsavedEdits.js'; @@ -204,15 +205,17 @@ export function ConsoleLayout({ [location.pathname, hostRoutePaths], ); // The chip's label for an object surface: the object's own translated - // label, the one its page shows. Display only; it never enters - // `context.surface`. No definition in hand → the chip reads `type · name`. + // label, the one its page shows: the served label as given, the bundle only + // when none is served. Display only; it never enters `context.surface`. No + // definition in hand → the chip reads `type · name`. const { objectLabel } = useObjectLabel(); + const { language } = useObjectTranslation(); const dockSurfaceLabel = useMemo(() => { const artifact = dockSurface?.artifact; if (artifact?.type !== 'object') return undefined; const def = (objects as ConsoleObject[] | undefined)?.find((o) => o?.name === artifact.name); - return def ? objectLabel({ name: def.name, label: def.label ?? '' }) || undefined : undefined; - }, [dockSurface, objects, objectLabel]); + return def ? servedObjectLabel(def, language, objectLabel) || undefined : undefined; + }, [dockSurface, objects, objectLabel, language]); // "/ai = the dock maximized": record where we maximized FROM at click time, // so the page's collapse-to-dock returns exactly here (history-back could // land on a prior /ai URL after in-page conversation switches). diff --git a/ui/packages/app-shell/src/layout/__tests__/InboxPopover.activityObjectLabel-12212.test.tsx b/ui/packages/app-shell/src/layout/__tests__/InboxPopover.activityObjectLabel-12212.test.tsx index 1e7423d9d7e..db040d03148 100644 --- a/ui/packages/app-shell/src/layout/__tests__/InboxPopover.activityObjectLabel-12212.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/InboxPopover.activityObjectLabel-12212.test.tsx @@ -52,13 +52,14 @@ function activities(): ActivityItem[] { /** How many times the popover tree read `objects` — reading it is what loads the type. */ let objectReads = 0; -function metadata() { +/** The definitions as the server serves them in `lang`: a `zh-CN` session holds the `zh-CN` label. */ +function metadata(lang: Lang) { return { apps: [], get objects() { objectReads += 1; return [ - { name: 'crm_lead', label: 'Lead' }, + { name: 'crm_lead', label: lang === 'zh-CN' ? '线索' : 'Lead' }, { name: 'sys_user_preference', label: 'User Preference' }, ]; }, @@ -87,7 +88,7 @@ function renderBell(lang: Lang) { render( - + { + it('zh-CN: the label is the one served in the session language (objectstack#30390)', () => { renderBell('zh-CN'); openActivity(); expect(objectShownFor(LEAD_SUMMARY)).toBe('线索'); diff --git a/ui/packages/app-shell/src/views/ObjectDataPage.tsx b/ui/packages/app-shell/src/views/ObjectDataPage.tsx index c2bd5762169..0c59b6ddcf0 100644 --- a/ui/packages/app-shell/src/views/ObjectDataPage.tsx +++ b/ui/packages/app-shell/src/views/ObjectDataPage.tsx @@ -78,7 +78,7 @@ import { PREVIEW_QUERY_VALUE, } from '../preview/PreviewModeContext.js'; import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; -import { servedObjectPluralLabel } from '../hooks/useNavTargetLabel.js'; +import { servedObjectLabel, servedObjectPluralLabel } from '../hooks/servedObjectLabel.js'; import type { RowCrudPredicates } from '../utils/crudAffordances.js'; /** Field types the auto-derived user-filter bar offers as dropdowns. */ @@ -709,7 +709,7 @@ export function ObjectDataPage({ dataSource, objects }: any) { { if (!open) closeRecordDrawer(); }} - title={objectLabel(objectDef)} + title={servedObjectLabel(objectDef, language, objectLabel)} > {(record: any) => ( objectLabel(objects.find((o: any) => o.name === name) ?? { name, label: name }), - [objects, objectLabel], + (name: string) => + servedObjectLabel(objects.find((o: any) => o.name === name) ?? { name }, language, objectLabel, name), + [objects, objectLabel, language], ); const actions = useObjectActions({ objectName: objectDef.name, // The label this page shows (its header and breadcrumb read the same - // `objectLabel`), so the delete confirmation that names the record + // label), so the delete confirmation that names the record // (objectui#11695) and the delete toasts read it in the session's - // language; with no translation bundle it is `objectDef.label`. - objectLabel: objectLabel(objectDef), + // language: the served label as given, the bundle only when none is + // served (objectstack#30390). + objectLabel: servedObjectLabel(objectDef, language, objectLabel), objectDef, dataSource, onEdit, @@ -1256,7 +1258,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co open={showImport} onOpenChange={setShowImport} objectName={objectDef.name} - objectLabel={objectLabel(objectDef)} + objectLabel={servedObjectLabel(objectDef, language, objectLabel)} fields={identityImportEnabled // Identity import has a curated target set — the generic // writable-field filter below would drop phone_number diff --git a/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx index 95d4ad900f6..2edf5c2a6b4 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.recordReadFailure-11902.test.tsx @@ -141,14 +141,17 @@ const NOT_FOUND_ZH = '未找到记录'; /** Open the record page; returns the adapter's two record reads, spied, to count the page's reads. */ async function openRecordPage(config: I18nConfig = EN) { + // The definition as the server serves it in the session's language: a `zh` + // session holds the `zh` label (objectstack#30390). + const def = config === ZH ? { ...objectDef, label: '发票' } : objectDef; const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch: transport, autoReconnect: false }); await ds.connect(); // Object metadata is the metadata service's business, not this file's. - (ds as unknown as { getObjectSchema: () => Promise }).getObjectSchema = async () => objectDef; + (ds as unknown as { getObjectSchema: () => Promise }).getObjectSchema = async () => def; const findOne = vi.spyOn(ds, 'findOne'); const getRecordPage = vi.spyOn(ds, 'getRecordPage'); const metadata = { - objects: [objectDef], pages: [], loading: false, error: null, + objects: [def], pages: [], loading: false, error: null, refresh: async () => {}, invalidate: () => {}, ensureType: async () => [], getItem: async () => null, getItemsByType: () => [], } as never; @@ -158,7 +161,7 @@ async function openRecordPage(config: I18nConfig = EN) { {}} objectNameOverride={OBJECT} recordIdOverride={REC} diff --git a/ui/packages/app-shell/src/views/RecordDetailView.relatedListTitlePlural-11733.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.relatedListTitlePlural-11733.test.tsx index 411fec875d3..b0bcf99fdbd 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.relatedListTitlePlural-11733.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.relatedListTitlePlural-11733.test.tsx @@ -86,7 +86,7 @@ const BASE_FIELDS = { name: { type: 'text', label: 'Name' }, }; -const OBJECTS = [ +const OBJECTS_EN = [ { name: PARENT, label: 'Project', pluralLabel: 'Projects', managedBy: 'platform', fields: BASE_FIELDS }, // One relationship to the parent: the page resolves the title. { @@ -117,6 +117,21 @@ const OBJECTS = [ }, ]; +/** `zh-CN`'s served labels for `name`, as the server translates them (objectstack#30390). */ +const SERVED_ZH: Record = { + showcase_task: { label: '任务', pluralLabel: '任务清单' }, + showcase_opportunity: { label: '商机', pluralLabel: '商机列表' }, +}; + +/** + * The object definitions as the server serves them in `lang`: a `zh-CN` + * session holds the `zh-CN` answer, and `served` stands in for a rename. + */ +function objectsFor(lang: Lang, served: Record = {}) { + const labels = { ...(lang === 'zh-CN' ? SERVED_ZH : {}), ...served }; + return OBJECTS_EN.map((o) => ({ ...o, ...labels[o.name] })); +} + /** The catalog as the console loads it: `zh-CN` translates both scalars. */ const BUNDLE: Record> = { en: {}, @@ -140,13 +155,13 @@ function makeDataSource() { } as never; } -function renderPage(lang: Lang) { +function renderPage(lang: Lang, objects: Array> = objectsFor(lang)) { const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); // The instance really booted in the language under test. expect(i18n.language).toBe(lang); i18n.addResourceBundle(lang, 'translation', BUNDLE[lang], true, true); const metadata = { - objects: OBJECTS, + objects, pages: [], loading: false, error: null, @@ -162,7 +177,7 @@ function renderPage(lang: Lang) { {}} objectNameOverride={PARENT} recordIdOverride={RECORD_ID} @@ -223,3 +238,25 @@ describe('RecordDetailView — related-list titles read the plural (objectui#117 expect(await drawn('Note')).toBe(true); }); }); + +/** + * objectstack#30390 — a renamed child object titles its related list with the + * served plural, in both producers, never the packaged catalog string. + * Directions, written before the run: RED before the change (`任务清单`, + * `商机列表 · …`), GREEN after. + */ +describe('RecordDetailView — a renamed child titles its related list as served (objectstack#30390)', () => { + it('zh-CN: the served plurals win over the packaged translation', async () => { + renderPage( + 'zh-CN', + objectsFor('zh-CN', { + showcase_task: { label: '工单', pluralLabel: '工单清单' }, + showcase_opportunity: { label: '线索', pluralLabel: '线索列表' }, + }), + ); + expect(await drawn('工单清单')).toBe(true); + expect(await drawn('线索列表 · Primary Project')).toBe(true); + expect(absent('任务清单')).toBe(true); + expect(absent('商机列表 · Primary Project')).toBe(true); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 82925ea2dc1..0c251c23f34 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -97,6 +97,7 @@ import { import { shortcutKeycaps, useAdvertiseShortcut, type AdvertisedShortcut, type ShortcutChord, type ShortcutLabelTranslate } from '../chrome/advertisedShortcuts.js'; import { nativeShare } from '@object-ui/mobile'; import { useActionModal } from '../hooks/useActionModal.js'; +import { servedObjectLabel, servedObjectPluralLabel } from '../hooks/servedObjectLabel.js'; import { resolveCascadeDeleteLabels } from '../hooks/useObjectActions.js'; import { useRecentItems } from '../hooks/useRecentItems.js'; import { useObjectPresence } from '../hooks/useObjectPresence.js'; @@ -2275,9 +2276,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // A related list is a list of the child's records, so the multi-FK // title it composes names it with the plural, as the single-FK title // below does (objectui#11733). - listLabel: (child) => objectPluralLabel({ name: child.name, label: child.label || child.name, pluralLabel: child.pluralLabel }), + listLabel: (child) => servedObjectPluralLabel(child, language, objectPluralLabel, child.name), }), - [objectDef, objects, canOnObject, permissionsLoaded, objectPluralLabel], + [objectDef, objects, canOnObject, permissionsLoaded, objectPluralLabel, language], ); // [objectui#12244] A write to a CHILD re-reads this record, in place. @@ -4076,7 +4077,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // records, so it is named as that object's list page is (objectui#11733). const localizedTitle = titleOverride || (childObjectDef - ? objectPluralLabel({ name: childObjectDef.name, label: childObjectDef.label || childLabel, pluralLabel: childObjectDef.pluralLabel }) + ? servedObjectPluralLabel(childObjectDef, language, objectPluralLabel, childLabel) : childLabel); return { title: localizedTitle, @@ -4149,7 +4150,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // the panel's headline is the pending one. (The decision actions no longer // ride this list at all — objectui#3055 moved them to the declared-action // bar, which reads the pending row directly.) - }, [objectDef?.name, childRelations, t, objectPluralLabel, objects, approvals.available, approvals.pendingRequest, approvals.requests, user?.id]); + }, [objectDef?.name, childRelations, t, objectPluralLabel, language, objects, approvals.available, approvals.pendingRequest, approvals.requests, user?.id]); // objectstack#30344 — whether this record page shows the record's field // history: the history gate, AND this reader's audit read answered. A read // the server refuses (or that fails) answers nothing, so the stream offers @@ -4217,7 +4218,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const phoneBackClass = 'inline-flex min-h-[44px] min-w-0 items-center gap-0.5 rounded-md py-1 pr-2 text-sm font-medium text-foreground transition-colors hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'; const phoneBack = phonePage ? (() => { - const plural = objectDef ? objectPluralLabel(objectDef) : objectName; + const plural = objectDef ? servedObjectPluralLabel(objectDef, language, objectPluralLabel) : objectName; const parent = pageTrail.length > 0 ? pageTrail[pageTrail.length - 1] : undefined; const label = parent ? parent.t || shortRecordId(parent.i) @@ -4382,7 +4383,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri {t('empty.recordAccessDenied', { - object: objectLabel({ name: objectName!, label: objectDef?.label || objectName! }), + object: servedObjectLabel({ name: objectName!, label: objectDef?.label }, language, objectLabel, objectName!), defaultValue: 'You don’t have access to {{object}} records', })} @@ -4643,14 +4644,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const deleteDeps = { objectName: objectName!, t, - label: objectLabel({ name: objectName!, label: objectDef?.label || objectName! }), + label: servedObjectLabel({ name: objectName!, label: objectDef?.label }, language, objectLabel, objectName!), objectDef, }; const deleteTarget = (readablePageRecord as Record | null | undefined) ?? { id: pureRecordId }; // objectstack#28472 — and names the objects the delete cascades into. const cascadeLabels = await resolveCascadeDeleteLabels(dataSource, objectName!, (name) => - objectLabel(objects.find((o: any) => o.name === name) ?? { name, label: name })); + servedObjectLabel(objects.find((o: any) => o.name === name) ?? { name }, language, objectLabel, name)); const copy = recordDelete.confirmCopy({ ...deleteDeps, cascadeLabels }, { record: deleteTarget }); // objectui#11001 — asked through this page's own confirm runtime, // the in-app `ActionConfirmDialog` the list view's delete asks @@ -4830,14 +4831,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri return ( - {entryDef ? objectLabel(entryDef) : entry.o}{' '} + {entryDef ? servedObjectLabel(entryDef, language, objectLabel) : entry.o}{' '} {entry.t || shortRecordId(entry.i)} ); })} - {objectDef ? objectLabel(objectDef) : objectName} + {objectDef ? servedObjectLabel(objectDef, language, objectLabel) : objectName} ) : ( @@ -4847,7 +4848,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri data-testid="record-page-crumb" > {(() => { - const plural = objectDef ? objectPluralLabel(objectDef) : objectName; + const plural = objectDef ? servedObjectPluralLabel(objectDef, language, objectPluralLabel) : objectName; const objectCrumbClass = cn(crumbClass, 'shrink-0'); if (pageReturn.toList) { return ( @@ -4896,7 +4897,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const entryDef = objectDefOf(entry.o); const face = ( <> - {entryDef ? objectLabel(entryDef) : entry.o}{' '} + {entryDef ? servedObjectLabel(entryDef, language, objectLabel) : entry.o}{' '} {entry.t || shortRecordId(entry.i)} ); @@ -4914,7 +4915,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ); })} - {objectDef ? objectLabel(objectDef) : objectName} + {objectDef ? servedObjectLabel(objectDef, language, objectLabel) : objectName} ) : null; const pageBar = embedded ? null : phonePage ? ( diff --git a/ui/packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx b/ui/packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx index 9a42245314c..779832bc2e2 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.listLinkPlural-11733.test.tsx @@ -22,7 +22,9 @@ * booted in the language under test (the `RecordFormPage.i18n` harness; the * form itself is a stand-in that fires `onSuccess`). The `zh-CN` plural differs * from the `zh-CN` label on purpose, so each assertion can tell which key was - * read; the no-plural object is the control. + * read; the no-plural object is the control. The definitions are served per + * language, as the server serves them: a `zh-CN` session holds the `zh-CN` + * answer (objectstack#30390). * * Direction, written before the run: with the link put back to the singular, * the two plural link cells RED (`Project` / `项目`); every title, toast and @@ -39,7 +41,10 @@ import { createI18n, I18nProvider } from '@object-ui/i18n'; import { en, zh } from '@object-ui/i18n/locales'; import { RecordFormPage } from './RecordFormPage'; -const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() })); +const { toastSuccess, served } = vi.hoisted(() => ({ + toastSuccess: vi.fn(), + served: { objects: [] as Array> }, +})); vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { @@ -72,13 +77,8 @@ vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ ), })); -const OBJECTS = [ - { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: { name: { type: 'text' } } }, - { name: 'showcase_note', label: 'Note', fields: { name: { type: 'text' } } }, -]; - vi.mock('../providers/MetadataProvider', () => ({ - useMetadata: () => ({ objects: OBJECTS, loading: false }), + useMetadata: () => ({ objects: served.objects, loading: false }), })); vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => null, @@ -86,6 +86,20 @@ vi.mock('../providers/AdapterProvider', () => ({ type Lang = 'en' | 'zh-CN'; +const FIELDS = { name: { type: 'text' } }; + +/** The object definitions as the server serves them in each language. */ +const OBJECTS: Record>> = { + en: [ + { name: 'showcase_project', label: 'Project', pluralLabel: 'Projects', fields: FIELDS }, + { name: 'showcase_note', label: 'Note', fields: FIELDS }, + ], + 'zh-CN': [ + { name: 'showcase_project', label: '项目', pluralLabel: '项目清单', fields: FIELDS }, + { name: 'showcase_note', label: 'Note', fields: FIELDS }, + ], +}; + /** The breadcrumb landmark's name in each language under test, read from the pack. */ const CRUMB_NAME: Record = { en: en.common.breadcrumb, 'zh-CN': zh.common.breadcrumb }; @@ -106,7 +120,13 @@ interface PageText { toast: string; } -async function pageText(lang: Lang, mode: 'create' | 'edit', objectName: string): Promise { +async function pageText( + lang: Lang, + mode: 'create' | 'edit', + objectName: string, + objects = OBJECTS[lang], +): Promise { + served.objects = objects; const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false }); // The instance really booted in the language under test. expect(i18n.language).toBe(lang); @@ -168,3 +188,28 @@ describe('RecordFormPage — the list link reads the plural, the record copy the }); }); }); + +/** + * objectstack#30390 — a served object's form page names it as served: the + * title, the toast and the list link all draw the served label, already + * translated by the server, never the packaged catalog string under a rename. + * Directions, written before the run: the renamed cell RED before the change + * (`新建项目` / `项目清单`), GREEN after; the control GREEN on both sides. + */ +describe('RecordFormPage — a served object is named as served (objectstack#30390)', () => { + const renamed = (extra: Record) => [{ name: 'showcase_project', fields: FIELDS, ...extra }]; + + it('zh-CN: a renamed object titles the form, the toast and the list link with the served labels', async () => { + expect( + await pageText('zh-CN', 'create', 'showcase_project', renamed({ label: '风险投资', pluralLabel: '风险投资清单' })), + ).toEqual({ link: '风险投资清单', title: '新建风险投资', toast: '风险投资创建成功' }); + }); + + it('zh-CN: control — an object served with no label is named by the bundle', async () => { + expect(await pageText('zh-CN', 'create', 'showcase_project', renamed({}))).toEqual({ + link: '项目清单', + title: '新建项目', + toast: '项目创建成功', + }); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordFormPage.tsx b/ui/packages/app-shell/src/views/RecordFormPage.tsx index 46ea9e6ccc2..6d93b66321a 100644 --- a/ui/packages/app-shell/src/views/RecordFormPage.tsx +++ b/ui/packages/app-shell/src/views/RecordFormPage.tsx @@ -49,6 +49,7 @@ import { buildExpressionUser } from '../providers/expressionUser.js'; import { SkeletonDetail } from '../skeletons/index.js'; import { ManagedByBadge } from '../components/ManagedByBadge.js'; import { resolveFormViewChrome, selectRecordFormView } from '../utils/recordFormNavigation.js'; +import { servedObjectLabel, servedObjectPluralLabel } from '../hooks/servedObjectLabel.js'; import { useAuth } from '@object-ui/auth'; export interface RecordFormPageProps { @@ -143,9 +144,10 @@ export function RecordFormPage({ mode }: RecordFormPageProps) { // Two names, not one (objectui#11733): the title and the toasts speak of ONE // record, so they keep the singular; the breadcrumb link opens the object's // list, so it reads the plural, as that list page's title and the header - // crumb above this page do. - const label = objectDef ? objectLabel(objectDef as any) : objectName ?? ''; - const listLabel = objectDef ? objectPluralLabel(objectDef) : objectName ?? ''; + // crumb above this page do. Both are the served labels as given; the bundle + // only when none is served (objectstack#30390). + const label = objectDef ? servedObjectLabel(objectDef, language, objectLabel) : objectName ?? ''; + const listLabel = objectDef ? servedObjectPluralLabel(objectDef, language, objectPluralLabel) : objectName ?? ''; // No inline `defaultValue` on these lookups: every key below is defined in // all ten locale packs and `all-locales-key-parity.test.ts` pins that // permanently, so i18next always resolves the pack value and a fallback diff --git a/ui/packages/app-shell/src/views/appReportIndex.ts b/ui/packages/app-shell/src/views/appReportIndex.ts index 46aaf248ddb..5ad02051333 100644 --- a/ui/packages/app-shell/src/views/appReportIndex.ts +++ b/ui/packages/app-shell/src/views/appReportIndex.ts @@ -102,7 +102,7 @@ import { usePermissions } from '@object-ui/permissions'; import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; import { useMetadata } from '../providers/MetadataProvider.js'; -import { metadataText } from '../hooks/useNavTargetLabel.js'; +import { metadataText, servedObjectLabel, servedObjectPluralLabel } from '../hooks/servedObjectLabel.js'; import { mayReadObject, type ObjectReadCheck } from '../utils/navObjectReadGate.js'; import { navEntriesShownToMember, type NavMemberGates } from '../hooks/useNavMemberGates.js'; import { defineWorkspacePreference, versionedPreferenceCodec } from '../utils/workspacePreference.js'; @@ -488,8 +488,9 @@ const nonBlank = (text: string | undefined): string | undefined => * the whole registry, the app's object scope (its package scope, the one the * navigation gates read, plus by name the objects it does not hold; for an * app with no package, the objects by name), else the registry as loaded. - * A label passes through the bundle lookup the console applies to object - * names (`useObjectLabel`). + * A served label is drawn as given, since the server translated it; the + * bundle lookup the console applies to object names (`useObjectLabel`) only + * when none is served (objectstack#30390). */ export function useReportObjectLabels(app: unknown, objects: readonly string[]): ReportObjectLabels { const metadata = useMetadata(); @@ -508,20 +509,10 @@ export function useReportObjectLabels(app: unknown, objects: readonly string[]): const name = (item as { name?: unknown }).name; if (nonEmptyString(name) && !byName.has(name)) byName.set(name, item as { label?: unknown; pluralLabel?: unknown }); } - const labelText = (object: string) => metadataText(byName.get(object)?.label, language) ?? ''; return { - plural: (object) => { - const pluralLabel = byName.get(object)?.pluralLabel; - return ( - nonBlank( - objectPluralLabel({ - name: object, - label: labelText(object), - ...(typeof pluralLabel === 'string' ? { pluralLabel } : {}), - }), - ) ?? object - ); - }, - singular: (object) => nonBlank(objectLabel({ name: object, label: labelText(object) })) ?? object, + plural: (object) => + nonBlank(servedObjectPluralLabel({ ...byName.get(object), name: object }, language, objectPluralLabel)) ?? object, + singular: (object) => + nonBlank(servedObjectLabel({ name: object, label: byName.get(object)?.label }, language, objectLabel)) ?? object, }; } diff --git a/ui/packages/app-shell/src/views/record-preview/RecordPreviewCard.test.tsx b/ui/packages/app-shell/src/views/record-preview/RecordPreviewCard.test.tsx index 38ccad0dc13..a18f876cf4a 100644 --- a/ui/packages/app-shell/src/views/record-preview/RecordPreviewCard.test.tsx +++ b/ui/packages/app-shell/src/views/record-preview/RecordPreviewCard.test.tsx @@ -30,6 +30,7 @@ import { useInlineEdit, } from '@object-ui/react'; import { PermissionProvider } from '@object-ui/permissions'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; import { MetadataProvider } from '../../providers/MetadataProvider'; import { RecordPreviewCard, type RecordPreviewSource } from './RecordPreviewCard'; @@ -533,3 +534,35 @@ describe('RecordPreviewCard — the readable state links to the record (objectui expect(h.recordHref).not.toHaveBeenCalled(); }); }); + +/** + * objectstack#30390 — the card heads a renamed object with its served label, + * already translated by the server, never the packaged catalog string. + * Directions, written before the run: the renamed cell RED before the change + * (`发票`), GREEN after; the control GREEN on both sides. + */ +describe('RecordPreviewCard — a served object is headed as served (objectstack#30390)', () => { + async function headed(def: Record) { + const i18n = createI18n({ defaultLanguage: 'zh-CN', detectBrowserLanguage: false }); + i18n.addResourceBundle('zh-CN', 'translation', { showcase: { objects: { [OBJECT]: { label: '发票' } } } }, true, true); + const ds = source(() => ({ ...RECORD })); + const { container } = mount( + + + , + { [OBJECT]: def }, + ); + await settle(container, 'readable'); + } + + it('zh-CN: a renamed object heads the card with the served label', async () => { + await headed({ ...DEF, label: '账单' }); + expect(screen.getByText('账单')).toBeInTheDocument(); + expect(screen.queryByText('发票')).toBeNull(); + }); + + it('zh-CN: control — an object served with no label is headed by the bundle', async () => { + await headed({ ...DEF, label: undefined }); + expect(screen.getByText('发票')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/record-preview/RecordPreviewCard.tsx b/ui/packages/app-shell/src/views/record-preview/RecordPreviewCard.tsx index 97981a8ed55..833666f60ba 100644 --- a/ui/packages/app-shell/src/views/record-preview/RecordPreviewCard.tsx +++ b/ui/packages/app-shell/src/views/record-preview/RecordPreviewCard.tsx @@ -119,6 +119,7 @@ import { } from '@object-ui/react'; import type { QueryParams } from '@object-ui/types'; import { Link2Off, Loader2 } from 'lucide-react'; +import { servedObjectLabel } from '../../hooks/servedObjectLabel.js'; /** * The one read the card makes. Structural, so the console's adapter satisfies @@ -221,7 +222,7 @@ export function RecordPreviewCard({ objectName, recordId, dataSource, className }; }, [targetObject, targetRecord, targetKey, defReady, expandKey, dataSource, invalidation]); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { objectLabel } = useObjectLabel(); // The host's record-URL builder (see "The link" in the module header). const recordLinks = useRelatedRecordActions(); @@ -252,7 +253,8 @@ export function RecordPreviewCard({ objectName, recordId, dataSource, className if (def && current?.status === 'readable') { const row = withoutDeniedFields(current.record, perms, targetObject); const title = getRecordDisplayName(def, row); - const typeLabel = objectLabel({ name: targetObject, label: def.label || targetObject }); + // The served label as given; the bundle only when none is served (objectstack#30390). + const typeLabel = servedObjectLabel({ name: targetObject, label: def.label }, language, objectLabel, targetObject); // Asked only here, where the read returned the record (objectui#12557). const href = targetRecord !== null && recordLinks?.recordHref ? recordLinks.recordHref(targetObject, targetRecord) : null; diff --git a/ui/packages/app-shell/src/views/recordDrawerStack.tsx b/ui/packages/app-shell/src/views/recordDrawerStack.tsx index ccf2f12291a..dd9fbceaba7 100644 --- a/ui/packages/app-shell/src/views/recordDrawerStack.tsx +++ b/ui/packages/app-shell/src/views/recordDrawerStack.tsx @@ -64,6 +64,7 @@ import { import { overlayWidthFor } from '@object-ui/plugin-view'; import type { FieldContainerLike } from '@object-ui/core'; import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { servedObjectLabel, servedObjectPluralLabel } from '../hooks/servedObjectLabel.js'; import { prefetchRelatedCounts, relatedCountTargetsIn } from '@object-ui/plugin-detail'; import { useFilterScope } from '@object-ui/react'; import { @@ -419,7 +420,7 @@ export function RecordStackDrawer({ renderRecord: (level: RecordDrawerLevel) => ReactNode; }) { const { objectLabel, objectPluralLabel } = useObjectLabel(); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const { path, depth, current, push, popTo, stepper, upcoming } = stack; const api = useMemo(() => ({ open: push }), [push]); const pane = useMemo( @@ -447,11 +448,13 @@ export function RecordStackDrawer({ objects.find((o) => (o as { name?: unknown } | null)?.name === objectName); const labelOf = (objectName: string) => { const def = defOf(objectName); - return def ? objectLabel(def as never) : objectName; + return def ? servedObjectLabel({ name: objectName, label: (def as { label?: unknown }).label }, language, objectLabel, objectName) : objectName; }; const pluralLabelOf = (objectName: string) => { const def = defOf(objectName); - return def ? objectPluralLabel(def as never) : objectName; + return def + ? servedObjectPluralLabel({ ...(def as { label?: unknown; pluralLabel?: unknown }), name: objectName }, language, objectPluralLabel, objectName) + : objectName; }; const shortId = (recordId: string) => `#${recordId.length > 12 ? `${recordId.slice(0, 8)}…` : recordId}`; const titleOf = (level: RecordDrawerLevel) => titles[levelKey(level)!] || level.title || shortId(level.recordId); diff --git a/ui/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx b/ui/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx index cbfd50e2fa1..0e50f6f493a 100644 --- a/ui/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx +++ b/ui/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx @@ -28,7 +28,8 @@ import { } from '@object-ui/fields'; import { EmptyValue, LazyIcon } from '@object-ui/components'; import { isEmptyValue } from '@object-ui/core'; -import { useObjectLabel } from '@object-ui/i18n'; +import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n'; +import { servedObjectLabel } from '../../hooks/servedObjectLabel.js'; import { useObjectRefLabel, useRecipientLabel } from './refLabelDirectory.js'; /** @@ -42,9 +43,10 @@ function Unresolved({ text }: { text: string }) { } /** - * `object-ref`: the object's icon and label, the label translated through the - * app bundle like every other object label, with the picker's own - * `Label (name)` spelling as its tooltip so the api name stays one hover away. + * `object-ref`: the object's icon and label, the label as the metadata store + * serves it (already translated), the app bundle only when none is served, + * with the picker's own `Label (name)` spelling as its tooltip so the api name + * stays one hover away. * The icon is drawn the way the console's navigation draws an object's — * `LazyIcon`, which falls back to the database glyph for an object that names * no icon (or one Lucide does not have). @@ -52,10 +54,11 @@ function Unresolved({ text }: { text: string }) { export function ObjectRefCellFace({ value, field }: CellRendererProps) { const ref = useObjectRefLabel(value); const { objectLabel } = useObjectLabel(); + const { language } = useObjectTranslation(); const safe = coerceToSafeValue(value); if (isEmptyValue(safe)) return ; if (ref.state === 'resolved' && typeof value === 'string') { - const label = objectLabel({ name: value, label: ref.label || value }); + const label = servedObjectLabel({ name: value, label: ref.label }, language, objectLabel, value); return (