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
22 changes: 22 additions & 0 deletions .changeset/26232-template-locale-reserved-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/formula': patch
'@objectstack/service-automation': patch
'@object-ui/core': patch
'@object-ui/react': patch
---

fix(formula,core,react): a template's formatter locale comes from a reserved context channel, so a root named `locale` is just a value, and the console passes the viewer's display locale

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

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped. What changes is which input sets the template dialect's formatter locale: the evaluate context's own `locale` field instead of a root named `locale`. A template that names a root `locale` keeps compiling and rendering; its hole still prints that value, only the number and date formatting no longer follows it. There is nothing for `objectstack migrate meta` to rewrite. The other categories are closed on facts: every package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and no runtime interface, type or export is removed or narrowed: `EvalContext` gains an optional member and `fillTemplateHoles` an optional trailing argument (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

The `template` dialect's formatters (`number`, `currency`, `percent`, `date`, `datetime`) used to take their locale from `extra.locale`, the same bag a caller passes the template's roots in. A template's roots are author-visible values, so a bulk action param or a flow variable named `locale` silently set the locale of every formatted value in the sentence, and no caller ever passed the viewer's locale.

- **`@objectstack/formula`**: `EvalContext` gains `locale?: string`, a reserved channel like `timezone`. It is not bound as a variable, and the template engine reads its formatter locale from it alone, defaulting to `en-US` when unset or empty. `extra.locale` is no longer read.
- **`@objectstack/service-automation`**: a flow text slot's formatters render in `en-US`, and a flow variable named `locale` is a value like any other. The renderer's doc said the variable was also the formatters' locale; that line is gone.
- **`@object-ui/core`**: `fillTemplateHoles(source, roots, locale?)` passes `locale` on the engine's reserved channel, apart from the roots. `ActionRunner.setDisplayLocale(read)` installs where the runner reads the viewer's display locale; `confirmText` and `successMessage` templates format in it, and a `callContext` dispatch keeps it.
- **`@object-ui/react`**: `<ActionProvider>` and `useActionRunner` install `useDisplayLocale()` on the runner they build, read when a template is filled, so a locale switch reaches a runner built before it.

**BREAKING** (a narrowing) for a caller that set the formatter locale through a root: a template evaluated with `extra: { locale }` (or a flow variable named `locale`) now formats in `en-US` and prints the value only where a hole names it. None was found in this repository: the one test that did so now passes `locale` on the context. What to do: pass the locale as the evaluate context's `locale` (`templateEngine.evaluate(expr, { extra: roots, locale })`), or as `fillTemplateHoles`'s third argument.
35 changes: 35 additions & 0 deletions packages/formula/src/template-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,38 @@ describe('templateEngine', () => {
expect(r).toEqual({ ok: true, value: 'hot' });
});
});

// The formatter locale rides the context's reserved `locale` channel, never a
// root (#26232). Expected strings come from `Intl` itself, so a newer ICU in CI
// cannot drift them.
describe('templateEngine — formatter locale channel', () => {
const source = '{{ amount | number:2 }} · {{ due | date:long }} · {{ locale }}';
const roots = { amount: 1234567.5, due: '2031-05-06T12:00:00Z' };
const due = new Date('2031-05-06T12:00:00Z');
const expected = (tag: string, printed = '') =>
`${new Intl.NumberFormat(tag, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1234567.5)} · ` +
`${new Intl.DateTimeFormat(tag, { dateStyle: 'long' }).format(due)} · ${printed}`;
const render = (ctx: Parameters<typeof templateEngine.evaluate>[1]) =>
templateEngine.evaluate<string>({ dialect: 'template', source }, ctx);

it('formats in the locale the context names', () => {
expect(render({ extra: roots, locale: 'de-DE' })).toEqual({ ok: true, value: expected('de-DE') });
expect(expected('de-DE')).not.toBe(expected('en-US'));
});

it('a root named `locale` is a value a hole prints, not the formatter locale', () => {
expect(render({ extra: { ...roots, locale: 'de-DE' } })).toEqual({
ok: true,
value: expected('en-US', 'de-DE'),
});
expect(render({ extra: { ...roots, locale: 'de-DE' }, locale: 'fr-FR' })).toEqual({
ok: true,
value: expected('fr-FR', 'de-DE'),
});
});

it('no locale, or an empty one, renders en-US as before', () => {
expect(render({ extra: roots })).toEqual({ ok: true, value: expected('en-US') });
expect(render({ extra: roots, locale: '' })).toEqual({ ok: true, value: expected('en-US') });
});
});
9 changes: 4 additions & 5 deletions packages/formula/src/template-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,9 @@ export const templateEngine: DialectEngine = {
if (!check.ok) return check as EvalResult<T>;

const scope = buildScope(ctx);
const locale =
(ctx.extra && typeof ctx.extra.locale === 'string' && ctx.extra.locale) ||
(typeof (ctx as { locale?: string }).locale === 'string' && (ctx as { locale?: string }).locale) ||
'en-US';
// Formatter locale: the context's reserved `locale` channel only, never a
// root — a root named `locale` is a value like any other (#26232).
const locale = typeof ctx.locale === 'string' && ctx.locale !== '' ? ctx.locale : 'en-US';
// Reference timezone for `datetime` rendering (ADR-0053 Phase 2). Unset →
// Intl uses the runtime zone, matching pre-Phase-2 behavior.
const timeZone = typeof ctx.timezone === 'string' ? ctx.timezone : undefined;
Expand All @@ -285,7 +284,7 @@ export const templateEngine: DialectEngine = {
if (!parsed) return _match; // compile already validated; defensive
const value = resolvePath(scope, parsed.path);
if (parsed.filter) {
return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale as string, timeZone);
return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale, timeZone);
}
return baseString(value);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/formula/src/template-formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-eng
function render(source: string, record: Record<string, unknown>, timezone?: string): string {
const r = templateEngine.evaluate<string>(
{ dialect: 'template', source },
{ record, extra: { locale: 'en-US' }, ...(timezone ? { timezone } : {}) },
{ record, locale: 'en-US', ...(timezone ? { timezone } : {}) },
);
if (!r.ok) throw new Error(r.error.message);
return r.value;
Expand Down
13 changes: 13 additions & 0 deletions packages/formula/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ export interface EvalContext {
* Defaults to `UTC` when unset. Calendar-day `date` rendering stays tz-naive.
*/
timezone?: string;
/**
* Locale (BCP-47 tag, e.g. `zh-CN`) the `template` dialect's formatters
* render numbers, money, percents and dates in. Defaults to `en-US` when
* unset or empty.
*
* A reserved channel, like {@link timezone}: it is NOT bound as a variable,
* and nothing in {@link extra} or any other root sets it. A template's roots
* are author-visible values (a bulk action's params, a flow's variables), so
* a root named `locale` is just a value a hole can print — it used to also
* set the formatter locale of the whole sentence (objectstack#26232). The
* caller that knows the viewer's display locale passes it here.
*/
locale?: string;
/**
* Current authenticated subject (hook / action / view contexts).
*
Expand Down
6 changes: 4 additions & 2 deletions packages/services/service-automation/src/builtin/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,10 @@ export function textTemplateScope(variables: VariableMap): Record<string, unknow
* Value→string is the engine's, defined per value (ADR-0032 §3): `null` /
* absent renders nothing, an object or array renders as JSON, a `Date` as its
* ISO text, and a formatter makes the rest explicit (`{{ amount | currency }}`,
* `{{ due | date:iso }}`). A flow variable named `locale` is also the
* formatters' locale — the engine reads its scope's `locale` for that.
* `{{ due | date:iso }}`). The formatters render in the engine's default
* `en-US`: a flow variable named `locale` is a value like any other, never the
* formatters' locale (#26232 — the engine reads that from its own reserved
* channel, not from a root).
*
* Absent in, absent out: a slot the author left unset — or one that renders
* no text at all — returns `undefined`, so a screen falls back to its node
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,13 @@ describe('#22110 — renderTextSlot, the one text renderer', () => {
it('leaves a single-brace token as literal text — the doors refuse it before a run, so the renderer never reads it', () => {
expect(renderTextSlot('Hello {record.name}', vars({ record: ACME }))).toBe('Hello {record.name}');
});

it('a flow variable named `locale` is a value a hole prints, not the formatters\' locale (#26232)', () => {
// Expected text from `Intl` itself, so a newer ICU in CI cannot drift it.
const amount = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1234567.5);
expect(renderTextSlot('{{ amount | number:2 }} ({{ locale }})', vars({ amount: 1234567.5, locale: 'de-DE' })))
.toBe(`${amount} (de-DE)`);
});
});

/** This package's `src` — the runtime whose `$` variables the spec judge lists. */
Expand Down
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/core.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/core","sources":111,"sha256":"05fe45802fffcc482f95bad59a66279b70572f8757decd9770dc126f119dab2e","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/core","sources":111,"sha256":"c2cf28d9a3a53535b8a328f099857f84b3ac90402fedc8b898a9266cb261f55c","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/react.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/react","sources":70,"sha256":"e3ea09e281e39a1d3f30d87b199d3756e88d7ffb48f91a11e777b2806a8bddff","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/react","sources":70,"sha256":"d89393cc7203be3bc88ea59d1a5e0ad6cbda588b8b8ad44baefed0a44da5d9d2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
35 changes: 30 additions & 5 deletions ui/packages/core/src/actions/ActionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1283,6 +1283,7 @@ export class ActionRunner {
private paramCollectionHandler: ParamCollectionHandler | null;
private resultDialogHandler: ResultDialogHandler | null;
private translate: ((key: string, options: { defaultValue: string }) => string) | null;
private displayLocale: (() => string | undefined) | null;

/**
* Built-in dispatch, one entry per runnable action type.
Expand Down Expand Up @@ -1322,6 +1323,7 @@ export class ActionRunner {
this.paramCollectionHandler = null;
this.resultDialogHandler = null;
this.translate = null;
this.displayLocale = null;
}

/**
Expand All @@ -1341,6 +1343,20 @@ export class ActionRunner {
this.translate = translate;
}

/**
* Set where the runner reads the viewer's display locale (a BCP-47 tag) —
* the host's own, injected so this package resolves none. It is read when a
* copy template is filled, so a language switch reaches a runner built
* before it. The `template` formatters in `confirmText` and
* `successMessage` render in it (`{{ amount | currency }}`,
* `{{ due | date:long }}`), through the engine's reserved locale channel, so
* a root named `locale` never sets it (objectstack#26232). With none
* installed they format in `en-US`.
*/
setDisplayLocale(displayLocale: () => string | undefined): void {
this.displayLocale = displayLocale;
}

/**
* Set a custom confirmation handler (e.g., Shadcn AlertDialog).
*/
Expand Down Expand Up @@ -1811,7 +1827,11 @@ export class ActionRunner {
const successCopy = actionCopySource(action.successMessage);
if (successCopy !== undefined && successCopy.trim() !== '') {
return hasTemplateHoles(successCopy)
? fillTemplateHoles(successCopy, { result: payload, record: this.dispatchRecord(action) })
? fillTemplateHoles(
successCopy,
{ result: payload, record: this.dispatchRecord(action) },
this.displayLocale?.(),
)
: successCopy;
}
return this.runnerText(RUNNER_TEXT.completedSuccessfully);
Expand Down Expand Up @@ -1846,7 +1866,11 @@ export class ActionRunner {
const staticParams = params && typeof params === 'object' && !Array.isArray(params)
? Object.fromEntries(Object.entries(params).filter(([key]) => key !== '_rowRecord'))
: undefined;
return fillTemplateHoles(text, { record: this.dispatchRecord(action), params: staticParams });
return fillTemplateHoles(
text,
{ record: this.dispatchRecord(action), params: staticParams },
this.displayLocale?.(),
);
}

/**
Expand Down Expand Up @@ -2917,9 +2941,9 @@ export class ActionRunner {
/**
* A one-shot runner for {@link ActionRunner.execute}'s `callContext`
* (objectui#13063): this runner's context with `callContext` merged over it,
* and this runner's handlers, translator and registries, shared rather than
* copied, so the dispatch confirms, collects params, toasts and navigates
* exactly as this runner does.
* and this runner's handlers, translator, display locale and registries,
* shared rather than copied, so the dispatch confirms, collects params,
* toasts and navigates exactly as this runner does.
*
* Two keys follow the merge rather than the base:
*
Expand Down Expand Up @@ -2961,6 +2985,7 @@ export class ActionRunner {
scoped.paramCollectionHandler = this.paramCollectionHandler;
scoped.resultDialogHandler = this.resultDialogHandler;
scoped.translate = this.translate;
scoped.displayLocale = this.displayLocale;
return scoped;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* 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.
*/

/**
* objectstack#26232 — a copy template's formatters render in the viewer's
* display locale, and a root named `locale` never sets it.
*
* Measured before this card: `fillTemplateHoles` passed its roots as the
* template engine's `extra`, and the engine read its formatter locale from
* `extra.locale`. So a param named `locale` set the locale of every formatted
* value in the sentence, and with no such param every viewer read `en-US`.
* The locale now rides the engine's reserved channel: the host installs its
* display locale on the runner (`setDisplayLocale`), and the filler passes it
* apart from the roots.
*
* Expected strings come from `Intl` itself, so a newer ICU cannot drift them.
*/
import { describe, it, expect, vi } from 'vitest';
import { ActionRunner, type ActionDef } from '../ActionRunner';
import { fillTemplateHoles } from '../actionCopyTemplate';

const AMOUNT = 1234567.5;
const money = (tag: string) => new Intl.NumberFormat(tag, { style: 'currency', currency: 'EUR' }).format(AMOUNT);

async function confirmTextFor(runner: ActionRunner, def: ActionDef, callContext?: object): Promise<unknown> {
const confirm = vi.fn().mockResolvedValue(false);
runner.setConfirmHandler(confirm);
await runner.execute(def, callContext);
expect(confirm).toHaveBeenCalledTimes(1);
return confirm.mock.calls[0][0];
}

describe('fillTemplateHoles keeps the formatter locale apart from the roots (objectstack#26232)', () => {
it('a root named `locale` is a value a hole prints, not the formatter locale', () => {
expect(money('de-DE')).not.toBe(money('en-US'));
expect(fillTemplateHoles('{{ amount | currency:EUR }} ({{ locale }})', { amount: AMOUNT, locale: 'de-DE' }))
.toBe(`${money('en-US')} (de-DE)`);
});

it('formats in the locale the caller passes', () => {
expect(fillTemplateHoles('{{ amount | currency:EUR }}', { amount: AMOUNT }, 'de-DE')).toBe(money('de-DE'));
});
});

describe('the runner fills its copy templates in the display locale it was given (objectstack#26232)', () => {
const CONFIRM: ActionDef = {
name: 'reprice',
onClick: () => {},
confirmText: 'Set {{ record.amount | currency:EUR }} ({{ params.locale }})?',
params: { locale: 'fr-FR' },
};
const runner = () => new ActionRunner({ objectName: 'crm_deal', record: { amount: AMOUNT } });

it('CONTROL: with no display locale installed, the formatters render en-US as before', async () => {
expect(await confirmTextFor(runner(), CONFIRM)).toBe(`Set ${money('en-US')} (fr-FR)?`);
});

it('confirmText: the installed display locale is the formatter locale', async () => {
const r = runner();
r.setDisplayLocale(() => 'de-DE');
expect(await confirmTextFor(r, CONFIRM)).toBe(`Set ${money('de-DE')} (fr-FR)?`);
});

it('the display locale is read per fill, so a switch reaches a runner built before it', async () => {
const r = runner();
let current = 'de-DE';
r.setDisplayLocale(() => current);
expect(await confirmTextFor(r, CONFIRM)).toBe(`Set ${money('de-DE')} (fr-FR)?`);
current = 'ja-JP';
expect(await confirmTextFor(r, CONFIRM)).toBe(`Set ${money('ja-JP')} (fr-FR)?`);
});

it('a dispatch with a callContext keeps the display locale', async () => {
const r = new ActionRunner({ objectName: 'crm_deal' });
r.setDisplayLocale(() => 'de-DE');
expect(await confirmTextFor(r, CONFIRM, { record: { amount: AMOUNT } })).toBe(`Set ${money('de-DE')} (fr-FR)?`);
});

it('successMessage: formats `result` in the display locale', async () => {
const r = runner();
const toast = vi.fn();
r.setToastHandler(toast);
r.setDisplayLocale(() => 'de-DE');
r.registerHandler('script', async () => ({ success: true, data: { total: AMOUNT, locale: 'fr-FR' } }));
await r.execute({
type: 'script',
name: 'reprice',
successMessage: { dialect: 'template', source: 'Total {{ result.total | currency:EUR }} ({{ result.locale }})' },
});
expect(toast.mock.calls[0][0]).toBe(`Total ${money('de-DE')} (fr-FR)`);
});
});
15 changes: 13 additions & 2 deletions ui/packages/core/src/actions/actionCopyTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,19 @@ export function hasTemplateHoles(text: string): boolean {
* unbalanced delimiter, logic inside a hole) is returned as written: the
* publish gate refuses it on the server side, and the user still reads the
* author's sentence rather than nothing.
*
* `locale` is the viewer's display locale (a BCP-47 tag) the formatters render
* in, passed by the caller that already has it — ⛔ never resolved here. It
* rides the engine's reserved `locale` channel, apart from `roots`, so a root
* named `locale` (a param of that name) is a value a hole prints, not the
* locale of the whole sentence (objectstack#26232). Omitted, the engine
* formats in `en-US`.
*/
export function fillTemplateHoles(source: string, roots: Record<string, unknown>): string {
const filled = templateEngine.evaluate<string>({ dialect: 'template', source }, { extra: roots });
export function fillTemplateHoles(
source: string,
roots: Record<string, unknown>,
locale?: string,
): string {
const filled = templateEngine.evaluate<string>({ dialect: 'template', source }, { extra: roots, locale });
return filled.ok && typeof filled.value === 'string' ? filled.value : source;
}
Loading
Loading