Skip to content
17 changes: 17 additions & 0 deletions .changeset/30390-served-object-label-record-surfaces.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 6 additions & 6 deletions ui/apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))}
</h1>
{/*
* objectui#8408 — the subtitle slot renders the form's `description`.
Expand Down
13 changes: 7 additions & 6 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1102,15 +1103,15 @@ 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,
});
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
Expand Down Expand Up @@ -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(); },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>()),
useMetadata: () => METADATA,
useMetadata: () => METADATA[session.lang],
}));

const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
Expand Down Expand Up @@ -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<string> {
session.lang = lang;
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
// The instance really booted in the language under test.
expect(i18n.language).toBe(lang);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
},
Expand All @@ -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<Record<string, unknown>>()),
useMetadata: () => METADATA,
useMetadata: () => METADATA[session.lang],
}));

const ACTION_RUNNER = { execute, runner: { registerHandler: () => {}, getContext: () => ({}) } };
Expand Down Expand Up @@ -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<string, unknown>, lang: Lang = 'en'): Promise<void> {
session.lang = lang;
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
expect(i18n.language).toBe(lang);
i18n.addResourceBundle(lang, 'translation', BUNDLE[lang], true, true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -90,7 +91,7 @@ function renderCard(lang: Lang, value: ActivityFeedReading['value'] = rows()) {
expect(i18n.language).toBe(lang);
render(
<I18nProvider instance={i18n} persistLanguage={false}>
<MetadataCtx.Provider value={metadata() as never}>
<MetadataCtx.Provider value={metadata(lang) as never}>
<HomeActivity activity={{ value, status: 'ready', readable: true }} onViewAll={vi.fn()} t={t} />
</MetadataCtx.Provider>
</I18nProvider>,
Expand Down Expand Up @@ -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('线索');
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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('项目清单');
});
});
83 changes: 83 additions & 0 deletions ui/packages/app-shell/src/hooks/servedObjectLabel.ts
Original file line number Diff line number Diff line change
@@ -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<typeof resolveInlineI18nLabel>[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 });
}
Loading
Loading