Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/core': patch
'@object-ui/plugin-detail': patch
'@object-ui/app-shell': patch
---

fix(plugin-detail,app-shell): the related list, the reference rail and the packaged-actions table name a renamed object by the name the server serves

Clause-②: yes (widening)
Metadata-protocol: none

- **The surfaces.** A `record:related_list` block and a `record:reference_rail` card with no authored `title` are headed with the related object's served plural label, else its served label, as the server translated it for the session (a `.custom` extension's rename included). They used to ask only the client translation bundle, which still held the base package's name. The bundle is asked only when the server serves neither.
- **Packaged actions.** Setup → Packaged Actions reads the object list in the session's language (`Accept-Language`), not the browser's, and its Object column draws that served label as given.
- **The widening.** The served-object-label rule now lives in `@object-ui/core`, which `@object-ui/plugin-detail` can import: new exports `servedObjectLabel`, `servedObjectPluralLabel`, `metadataText` and the types `ObjectLabelLookup` and `ObjectPluralLabelLookup`. app-shell's internal copy is now a re-export of these, so both packages read one rule.
84 changes: 14 additions & 70 deletions ui/packages/app-shell/src/hooks/servedObjectLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,77 +7,21 @@
*/

/**
* 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.
* The served-object-label rule (objectstack#30311, objectstack#30390), for
* app-shell's importers. The rule itself lives in `@object-ui/core`
* (`utils/served-object-label.ts`) since objectstack#30486, so the related
* list and reference rail in `@object-ui/plugin-detail`, which cannot import
* this package, read the very same functions. ⛔ Keep this a re-export: a
* second body here is the drift that move closed. 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 });
}
export {
metadataText,
servedObjectLabel,
servedObjectPluralLabel,
type ObjectLabelLookup,
type ObjectPluralLabelLookup,
} from '@object-ui/core';
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-li
import React from 'react';

import { toast } from 'sonner';
import { createI18n, I18nProvider } from '@object-ui/i18n';
import { PackagedActionsSection } from './PackagedActionsSection';

vi.mock('sonner', () => ({ toast: { success: vi.fn() } }));
Expand Down Expand Up @@ -537,3 +538,76 @@ describe('the switch confirms off, says what happened, and names the object', ()
expect(within(row).getByText('account')).toBeInTheDocument();
});
});

/* -------------------------------------------------------------------------- */
/* 7) The object list is read in the session's language, and its served */
/* object label is drawn as given (objectstack#30486) */
/* -------------------------------------------------------------------------- */

/**
* The server translates each served object `label` for the request's
* `Accept-Language`, a `.custom` rename included, so the object read carries
* the SESSION's language and the column draws that label as given (the
* served-object-label rule, `servedObjectLabel`), never through the bundle's
* translation of the base package's name. An object served without a label
* shows its machine name alone, as before.
*
* Direction, written before the run: with the read's header dropped and the
* column put back to the bundle-first lookup, the header cell and the rename
* cell RED (no header; `客户`); the control GREEN on both sides.
*/
describe('the object column draws the served label, read in the session language (objectstack#30486)', () => {
function renderIn(lang: 'en' | 'zh-CN') {
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
expect(i18n.language).toBe(lang);
// The base package's translation, which a rename must win over.
i18n.addResourceBundle(lang, 'translation', {
crm: { objects: { account: { label: lang === 'zh-CN' ? '客户' : 'Account' } } },
}, true, true);
render(
<I18nProvider instance={i18n}>
<PackagedActionsSection />
</I18nProvider>,
);
return screen.findByTestId('packaged-action-account-send_invoice');
}

const objectReadHeaders = () =>
vi
.mocked(fetch)
.mock.calls.filter(([input]) => String(input).includes('/meta/object'))
.map(([, init]) => new Headers((init as RequestInit | undefined)?.headers));

it('asks for the object list in the session language', async () => {
await renderIn('zh-CN');
const headers = objectReadHeaders();
expect(headers.length).toBeGreaterThan(0);
for (const h of headers) expect(h.get('Accept-Language')).toBe('zh-CN');
});

it('zh-CN: a served rename is drawn as given, not the bundle translation', async () => {
fixture.objects = [
{ ...packagedObject('account', [{ name: 'send_invoice', label: 'Send invoice' }]), label: '往来单位' },
];
const row = await renderIn('zh-CN');
expect(within(row).getByTestId('packaged-action-object-label')).toHaveTextContent('往来单位');
expect(within(row).queryByText('客户')).toBeNull();
});

it('en: a served rename is drawn as given', async () => {
fixture.objects = [
{ ...packagedObject('account', [{ name: 'send_invoice', label: 'Send invoice' }]), label: 'Counterparty' },
];
const row = await renderIn('en');
expect(within(row).getByTestId('packaged-action-object-label')).toHaveTextContent('Counterparty');
});

it('CONTROL: an object served without a label shows only its machine name', async () => {
fixture.objects = [
{ name: 'account', actions: [{ name: 'send_invoice', label: 'Send invoice' }], _packageId: 'com.objectstack.crm', _provenance: 'package' },
];
const row = await renderIn('zh-CN');
expect(within(row).queryByTestId('packaged-action-object-label')).toBeNull();
expect(within(row).getByText('account')).toBeInTheDocument();
});
});
34 changes: 24 additions & 10 deletions ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import { actionErrorDetail } from '@object-ui/core';
// The LEAF module, for the reason its own header gives: this section joins the
// page's graph, and the page is imported eagerly by `services/builtinComponents`.
import { apiBase } from '../../utils/apiBase.js';
import { servedObjectLabel } from '../../hooks/servedObjectLabel.js';
import { envelopeData, envelopeRefused } from './packagedFlows.js';
import {
collectPackagedActions,
Expand Down Expand Up @@ -125,18 +126,25 @@ async function readJson(res: Response): Promise<unknown> {
return res.json().catch(() => null);
}

async function getJson(path: string, signal: AbortSignal): Promise<Response> {
async function getJson(path: string, signal: AbortSignal, language?: string): Promise<Response> {
return fetch(`${apiBase()}${path}`, {
credentials: 'include',
headers: JSON_HEADERS,
headers: language ? { ...JSON_HEADERS, 'Accept-Language': language } : JSON_HEADERS,
cache: 'no-store',
signal,
});
}

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

export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedActionsSectionProps) {
const { t } = useObjectTranslation();
const { t, language } = useObjectTranslation();
const { objectLabel } = useObjectLabel();

const [rows, setRows] = React.useState<PackagedActionRow[] | null>(null);
Expand All @@ -231,7 +239,7 @@ export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedAct
(async () => {
try {
const [objects, standalone, activation] = await Promise.all([
fetchObjectMetadata(controller.signal),
fetchObjectMetadata(controller.signal, language),
fetchStandaloneActionMetadata(controller.signal),
fetchActivationLedger(controller.signal),
]);
Expand All @@ -253,7 +261,7 @@ export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedAct
cancelled = true;
controller.abort();
};
}, [nonce, t]);
}, [nonce, t, language]);

const clearRefusal = React.useCallback((key: string) => {
setRefusals((prev) => {
Expand Down Expand Up @@ -408,10 +416,16 @@ export function PackagedActionsSection({ nonce = 0, headed = true }: PackagedAct
{/* The object's label in the reader's language, over the
machine name, which is also the `:object` segment the
flip addresses — `global` for an object-less action,
the platform's own spelling, drawn alone. */}
the platform's own spelling, drawn alone. The label is
the one the server served for this session's language,
drawn as given (the served-object-label rule,
objectstack#30486), never re-translated by the client
bundle, whose base-package name would win a rename
back. An object served without a label shows its
machine name alone. */}
{row.objectLabel && (
<div data-testid="packaged-action-object-label">
{objectLabel({ name: row.objectName, label: row.objectLabel })}
{servedObjectLabel({ name: row.objectName, label: row.objectLabel }, language, objectLabel)}
</div>
)}
<code className="font-mono text-xs text-muted-foreground break-all">{row.objectName}</code>
Expand Down
6 changes: 4 additions & 2 deletions ui/packages/app-shell/src/views/setup/packagedActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,10 @@ export interface PackagedActionRow {
label: string;
/**
* The owning object's label as `GET /meta/object` serves it, when the
* object is in that list and declares one; the page resolves it in the
* reader's language and keeps the machine name beside it.
* object is in that list and declares one. The section reads that list in
* the session's language, so this is already the reader's name for the
* object, a `.custom` rename included; the page draws it as given
* (objectstack#30486) and keeps the machine name beside it.
*/
objectLabel?: string;
/** Activation as the ledger reports it — `true` when no row says otherwise. */
Expand Down
11 changes: 11 additions & 0 deletions ui/packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,17 @@ export * from './utils/dataset-format.js';
// (objectstack#5473 / objectstack#5665).
export * from './utils/dataset-pivot.js';
export * from './utils/record-title.js';
// The served-object-label rule (objectstack#30311, objectstack#30390): a label
// the server served is drawn as given, and the client bundle is asked only
// when none is served. Homed here by objectstack#30486 so `plugin-detail`'s
// related list and reference rail read the same rule as app-shell's surfaces.
export {
metadataText,
servedObjectLabel,
servedObjectPluralLabel,
type ObjectLabelLookup,
type ObjectPluralLabelLookup,
} from './utils/served-object-label.js';
// The one `colorField` ladder every record view shares (objectui#7243) — the
// gantt, calendar and timeline all resolve an authored option colour here
// instead of each guessing at the raw stored value.
Expand Down
90 changes: 90 additions & 0 deletions ui/packages/core/src/utils/served-object-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* 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.
*
* Homed here (objectstack#30486) rather than in `@object-ui/app-shell`, where
* it was written, because `@object-ui/plugin-detail`'s related list and
* reference rail name an object too and cannot import `app-shell` (which
* depends on them). This package is the one both already import, it holds no
* provider, and it already reads `@objectstack/spec/ui` at runtime. app-shell's
* `hooks/servedObjectLabel.ts` re-exports these names for its own importers.
* ⛔ Do not copy the rule into another package: one rule, one home.
*
* @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 app-shell's `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, a reference rail card.
*/
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, and
* the packaged-actions table's object column.
*/
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