From e88cd2e045db7a356c0306dbd00d3b9f8c6ba852 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 10:49:25 +0000 Subject: [PATCH 1/4] fix(app-shell,fields,plugin-form,plugin-gantt,plugin-grid,components,console): record surfaces draw a served object label as served A served object label is already translated by the server (its label order for an overridden item), so running the client bundle over it again let the base package's string win a rename back. The record page, form, drawer, preview card, hover card, pointer cell and the rest of the family now draw the served label as given and ask the bundle only when none is served. WIP: tests and changeset follow. Claude-Session: https://claude.ai/code/session_01Utp6CZgb7dMvMK3Biysr7P Co-authored-by: Claude --- ui/apps/console/src/components/FormPage.tsx | 12 +++---- .../app-shell/src/console/AppContent.tsx | 13 ++++---- .../app-shell/src/hooks/useNavTargetLabel.ts | 33 ++++++++++++++++--- .../src/layout/ActivityObjectLabel.tsx | 11 ++++--- .../app-shell/src/layout/ConsoleLayout.tsx | 11 ++++--- .../app-shell/src/views/ObjectDataPage.tsx | 4 +-- .../app-shell/src/views/ObjectView.tsx | 16 +++++---- .../app-shell/src/views/RecordDetailView.tsx | 27 +++++++-------- .../app-shell/src/views/RecordFormPage.tsx | 8 +++-- .../app-shell/src/views/appReportIndex.ts | 25 +++++--------- .../record-preview/RecordPreviewCard.tsx | 6 ++-- .../app-shell/src/views/recordDrawerStack.tsx | 9 +++-- .../widget-ref-cells/WidgetRefCellFaces.tsx | 13 +++++--- .../src/renderers/layout/containers.tsx | 11 ++++--- ui/packages/fields/src/RecordHoverCard.tsx | 9 ++--- .../plugin-form/src/ObjectFormContainer.tsx | 11 +++++-- .../src/closedAffordanceNotice.tsx | 13 ++++---- ui/packages/plugin-gantt/src/ObjectGantt.tsx | 13 ++++---- .../plugin-grid/src/RecordPointerCell.tsx | 4 ++- 19 files changed, 146 insertions(+), 103 deletions(-) diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index dcaff702016..b953905762f 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -3490,14 +3490,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..d759f3e79cb 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/useNavTargetLabel.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/hooks/useNavTargetLabel.ts b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts index b3c47beafbc..0b427c2f06f 100644 --- a/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts +++ b/ui/packages/app-shell/src/hooks/useNavTargetLabel.ts @@ -98,24 +98,49 @@ function nonEmpty(text: string | undefined): string | 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; `''` 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. + * 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: '' }); + 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 }); } /** The two members this hook reads off a cached metadata record. */ 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..a1aef31670a 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/useNavTargetLabel.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/views/ObjectDataPage.tsx b/ui/packages/app-shell/src/views/ObjectDataPage.tsx index c2bd5762169..693ae26cca0 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/useNavTargetLabel.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.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 2a51c59bd9c..dd054b519ef 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/useNavTargetLabel.js'; import { resolveCascadeDeleteLabels } from '../hooks/useObjectActions.js'; import { useRecentItems } from '../hooks/useRecentItems.js'; import { useObjectPresence } from '../hooks/useObjectPresence.js'; @@ -2259,9 +2260,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. @@ -4055,7 +4056,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, @@ -4128,7 +4129,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#27055, Target 3 — the record's field changes, as feed items of // the timeline stream. `null` while there are none to give (no // `trackHistory`, an authored page, the read not made or not landed). @@ -4187,7 +4188,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) @@ -4352,7 +4353,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', })} @@ -4613,14 +4614,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 @@ -4800,14 +4801,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} ) : ( @@ -4817,7 +4818,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 ( @@ -4866,7 +4867,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)} ); @@ -4884,7 +4885,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.tsx b/ui/packages/app-shell/src/views/RecordFormPage.tsx index 46ea9e6ccc2..9fa9d78d3da 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/useNavTargetLabel.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..1f51b843446 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/useNavTargetLabel.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.tsx b/ui/packages/app-shell/src/views/record-preview/RecordPreviewCard.tsx index 97981a8ed55..b3aba781e3f 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/useNavTargetLabel.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..deef1763af3 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/useNavTargetLabel.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..4055a16a2d1 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/useNavTargetLabel.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 (