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
18 changes: 18 additions & 0 deletions .changeset/29249-inline-action-description-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/react': patch
---

fix(react): an inline action's `description` locale map shows in the user's language in the param dialog

Clause-②: no
Metadata-protocol: none

An inline action on a page (`element:button` with `properties.action`) can declare a `description`, the line its param dialog shows under the title. It may be written as a per-locale map such as `{ en: '…', 'zh-CN': '…' }`. Before this change the action text localizer left that map alone, so the dialog received an object instead of a sentence.

What changes:

- `useActionTextLocalizer` now collapses a declared `description` to the active language, the same way it already treats `confirmText`, `successMessage` and `errorMessage`. A plain string comes back unchanged, and a `{ dialect: 'template', source }` envelope comes back as itself.
- It does not read a translation bundle entry for `description`. The param-dialog host still resolves `_actions.<name>.description` over the string it is handed.
- An action that declares no `description` gains no key, so its dialog keeps the generic "Please provide the required information to continue." line.

What to do: nothing. Nothing a page, view or action declares changes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* 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#29249 — an inline `element:button` action's `description`
* reaches the param dialog in the user's language.
*
* `InlineActionSchema` picks `description` (an `I18nLabel`: a string or a
* per-locale map). The button hands its action to `useActionTextLocalizer`
* before the runner, so a map collapses to the active language there; the
* runner and the param-collection host have no language of their own.
*
* The path is the real one up to the param-collection seam: the registered
* `element:button`, the real localizer, the real `ActionRunner` behind
* `ActionProvider`. The dialog is this file's host, drawn with this package's
* `Dialog` the way the console's `ActionParamDialog` (`@object-ui/app-shell`,
* which this package cannot import) draws its line: the action's description,
* else the generic `actionDialog.description`.
*
* Each row first asserts the dialog opened with the action's own title (the
* positive control), so a missing line is never "no dialog opened".
*/

import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { ActionContext, ActionDef, ActionResult, ParamCollectionHandler } from '@object-ui/core';
import { ActionProvider } from '@object-ui/react';
import { I18nProvider, useObjectTranslation } from '@object-ui/i18n';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '../ui/dialog';
import '../renderers/basic/elements';

let api: Mock<(action: ActionDef, ctx: ActionContext) => Promise<ActionResult>>;
let handed: Array<ActionDef | undefined>;

beforeEach(() => {
api = vi.fn(async () => ({ success: true }));
handed = [];
});

/** The producer's shape: a nameless inline `api` action that collects params. */
const INVITE = {
type: 'api',
target: '/api/v1/cloud/invitations',
label: { en: 'Invite member', 'zh-CN': '邀请成员' },
params: [{ name: 'email', label: 'Email', type: 'email', required: true }],
};

const DESCRIPTION = {
en: 'We e-mail them an invitation link.',
'zh-CN': '我们会向对方发送一封邀请邮件。',
};

/**
* The registered renderer, read once at module scope (a component created
* during render remounts on every render). An unregistered `element:button`
* fails the whole file here, before any row runs.
*/
function registeredElementButton() {
const C = ComponentRegistry.get('element:button');
if (!C) throw new Error('element:button not registered');
return C;
}
const ElementButton = registeredElementButton();

/**
* The param-collection host: opens a dialog from what the runner hands it and
* leaves the promise pending, as a dialog awaiting its Confirm does.
*/
function ParamHost({ action }: { action: Record<string, unknown> }) {
const { t } = useObjectTranslation();
const [dialog, setDialog] = React.useState<{ title: string; description: string } | null>(null);
const onParamCollection = React.useCallback<ParamCollectionHandler>((_params, dispatched) => {
handed.push(dispatched);
// A non-string reaching the host is drawn as nothing, so the assertions
// below see the gap instead of React refusing an object child.
const title = dispatched?.label;
const description = dispatched?.description;
setDialog({
title: typeof title === 'string' ? title : '',
description: typeof description === 'string' ? description : '',
});
return new Promise(() => {});
}, []);
return (
<ActionProvider handlers={{ api }} onParamCollection={onParamCollection}>
<ElementButton
schema={{
type: 'element:button',
properties: { label: { en: 'Invite member', 'zh-CN': '邀请成员' }, action },
}}
/>
{dialog && (
<Dialog open>
<DialogContent>
<DialogTitle>{dialog.title}</DialogTitle>
<DialogDescription>{dialog.description || t('actionDialog.description')}</DialogDescription>
</DialogContent>
</Dialog>
)}
</ActionProvider>
);
}

function renderIn(language: string, action: Record<string, unknown>) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
<ParamHost action={action} />
</I18nProvider>,
);
}

describe('element:button — the param dialog shows the inline description in the active language (objectstack#29249)', () => {
it('a zh-CN/en map shows the zh-CN string under a zh-CN locale', async () => {
renderIn('zh-CN', { ...INVITE, description: DESCRIPTION });

fireEvent.click(screen.getByRole('button', { name: '邀请成员' }));

const dialog = await screen.findByRole('dialog');
expect(screen.getByRole('heading', { name: '邀请成员' })).toBeTruthy();
expect(dialog.textContent).toContain('我们会向对方发送一封邀请邮件。');
// The runner was handed one string, never the map.
expect(handed).toHaveLength(1);
expect(handed[0]?.description).toBe('我们会向对方发送一封邀请邮件。');
// Nothing runs until the dialog's own Confirm.
expect(api).not.toHaveBeenCalled();
});

it('the same map shows the English string under an en locale', async () => {
renderIn('en', { ...INVITE, description: DESCRIPTION });

fireEvent.click(screen.getByRole('button', { name: 'Invite member' }));

const dialog = await screen.findByRole('dialog');
expect(screen.getByRole('heading', { name: 'Invite member' })).toBeTruthy();
expect(dialog.textContent).toContain('We e-mail them an invitation link.');
expect(handed[0]?.description).toBe('We e-mail them an invitation link.');
});

it('no description: the dialog keeps its generic line', async () => {
renderIn('zh-CN', INVITE);

fireEvent.click(screen.getByRole('button', { name: '邀请成员' }));

const dialog = await screen.findByRole('dialog');
expect(screen.getByRole('heading', { name: '邀请成员' })).toBeTruthy();
expect(dialog.textContent).toContain('请填写以下信息以继续操作。');
expect(handed[0]?.description).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* 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#29249 — an action's `description`, the line the param dialog
* draws under its title, is declared `I18nLabel` copy like `confirmText`: an
* inline per-locale map collapses to the active language before the runner
* and the dialog, which have no language, receive it.
*
* Declared-only and collapse-only, like `errorMessage`: an action that
* declares no `description` gains no key (the dialog keeps its generic line),
* and the bundle's `_actions.<name>.description` is not read here — the
* param-collection host resolves that entry over the string handed on.
*
* Measured under a real `I18nProvider`, reading what the localizer returns.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, renderHook } from '@testing-library/react';
import React from 'react';
import { I18nProvider, useObjectTranslation } from '@object-ui/i18n';
import { useActionTextLocalizer } from '../useActionTextLocalizer';

afterEach(() => cleanup());

const DESCRIPTION = {
en: 'We e-mail them an invitation link.',
'zh-CN': '我们会向对方发送一封邀请邮件。',
};

/** The producer's shape: a nameless inline `api` action that collects params. */
const INVITE = {
type: 'api',
target: '/api/v1/cloud/invitations',
label: { en: 'Invite member', 'zh-CN': '邀请成员' },
params: [{ name: 'email', label: 'Email', type: 'email', required: true }],
};

function harness(language: string, bundle?: Record<string, unknown>) {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
{children}
</I18nProvider>
);
const { result } = renderHook(
() => ({ localize: useActionTextLocalizer(), translation: useObjectTranslation() }),
{ wrapper },
);
if (bundle) result.current.translation.i18n.addResourceBundle(language, 'translation', bundle, true, true);
return result;
}

describe('useActionTextLocalizer — description (objectstack#29249)', () => {
it('a nameless inline action: the locale map collapses to the zh-CN string', () => {
const result = harness('zh-CN');
const localized = result.current.localize(undefined, { ...INVITE, description: DESCRIPTION });
expect(localized.description).toBe('我们会向对方发送一封邀请邮件。');
});

it('the same map reads the English entry under an en locale', () => {
const result = harness('en');
const localized = result.current.localize(undefined, { ...INVITE, description: DESCRIPTION });
expect(localized.description).toBe('We e-mail them an invitation link.');
});

it('a named action collapses the map too, and the bundle entry is not read here', () => {
const result = harness('zh-CN', {
sys: { globalActions: { invite_member: { label: '邀请同事', description: '来自翻译包的说明' } } },
});
const localized = result.current.localize(undefined, {
...INVITE,
name: 'invite_member',
description: DESCRIPTION,
});
// Positive control: the same bundle node IS read for `label`, so the
// harness reached a named action's bundle path.
expect(localized.label).toBe('邀请同事');
// Collapse-only: the host's `actionDescription` owns the bundle read.
expect(localized.description).toBe('我们会向对方发送一封邀请邮件。');
});

it('a `template` envelope comes back as itself, never collapsed as a locale map', () => {
const result = harness('zh-CN');
const envelope = { dialect: 'template', source: 'Invite {{ record.name }} to the team.' };
const action = { ...INVITE, label: 'Invite member', description: envelope };
const localized = result.current.localize(undefined, action);
expect(localized.description).toBe(envelope);
expect(localized.description).toEqual({ dialect: 'template', source: 'Invite {{ record.name }} to the team.' });
});

it('a plain string comes back unchanged', () => {
const result = harness('zh-CN');
const localized = result.current.localize(undefined, {
...INVITE,
description: 'We e-mail them an invitation link.',
});
expect(localized.description).toBe('We e-mail them an invitation link.');
});

it('an action with no description gains no key, nameless or named', () => {
const result = harness('zh-CN', {
sys: { globalActions: { invite_member: { description: '来自翻译包的说明' } } },
});
const nameless = result.current.localize(undefined, INVITE);
const named = result.current.localize(undefined, { ...INVITE, name: 'invite_member' });
expect('description' in nameless).toBe(false);
expect('description' in named).toBe(false);
});
});
26 changes: 18 additions & 8 deletions ui/packages/react/src/hooks/useActionTextLocalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,22 @@
* declares resolves `_actions.<name>.outcomeMessages.<outcome>`; a bundle
* entry for an outcome the action does not declare is never read, so a
* bundle cannot add copy for an answer the author never wrote one for.
* - **`label`, `confirmText`, `successMessage`, `errorMessage` and each
* `outcomeMessages` entry are reduced through `pickLocalized` first**,
* because each is an `I18nLabel` — a string or an inline per-locale map. The map is collapsed to the active language
* - **`label`, `confirmText`, `successMessage`, `errorMessage`, `description`
* and each `outcomeMessages` entry are reduced through `pickLocalized`
* first**, because each is an `I18nLabel` — a string or an inline per-locale map. The map is collapsed to the active language
* BEFORE it is offered to the bundle as the fallback, so a per-locale
* literal and a bundle entry cannot disagree about what "the authored text"
* is, and the action runner, which has no language, only ever receives a
* string. `errorMessage` has no bundle key (the spec's action translation
* declares none), so the collapse is all it gets (objectui#12757).
* - **`description` is collapse-only too** (objectstack#29249): the line the
* param dialog draws under its title is an `I18nLabel` on an inline action
* (`InlineActionSchema` picks it), so a locale map collapses to the active
* language here, before the runner and the dialog, which have no language,
* receive it. Its bundle entry (`_actions.<name>.description`) is NOT read
* here: the param-collection host resolves it (`actionDescription` in
* `useConsoleActionRuntime`), over the string this hook hands on. An action
* that declares none gains no key, so the dialog keeps its generic line.
* - **`disabledReason` follows the same declared-only rule**
* (objectstack#26619): the reason a greyed action gives resolves
* `_actions.<name>.disabledReason` only when the action declares one, after
Expand All @@ -90,7 +98,8 @@
* scope `onSuccess.navigate` reads — one interpolation, in one place.
* - **The `template` envelope keeps its shape** (objectui#13367, the renderer
* half of objectstack#24814). `confirmText`, `successMessage`,
* `errorMessage` and each `outcomeMessages` entry (objectstack#27948) may
* `errorMessage`, `description` and each `outcomeMessages` entry
* (objectstack#27948) may
* arrive as `{ dialect: 'template', source }` instead of a string. The
* envelope is not an `I18nLabel` map — read as one it would
* collapse to its first string value, the word `template` — so its `source`
Expand All @@ -113,10 +122,11 @@ export interface ActionTextLocalizerOptions {

/**
* Returns a copy of `action` with `label` — plus `confirmText`,
* `successMessage`, `errorMessage`, `outcomeMessages`, `disabledReason` and
* `cancelText` when the action declares them —
* `successMessage`, `errorMessage`, `description`, `outcomeMessages`,
* `disabledReason` and `cancelText` when the action declares them —
* resolved from the active locale's bundle, falling back to the authored
* literals. `objectName` is the host object the action is drawn on; the
* literals (`errorMessage` and `description` are collapsed to the active
* language only; neither reads the bundle here). `objectName` is the host object the action is drawn on; the
* action's own declared `objectName` wins over it when present.
*/
export type ActionTextLocalizer = <T extends Record<string, any>>(
Expand All @@ -126,7 +136,7 @@ export type ActionTextLocalizer = <T extends Record<string, any>>(
) => T;

/** The declared `I18nLabel` copy, besides `label`, that reaches the runner. */
const DECLARED_COPY_KEYS = ['confirmText', 'successMessage', 'errorMessage'] as const;
const DECLARED_COPY_KEYS = ['confirmText', 'successMessage', 'errorMessage', 'description'] as const;

/**
* Collapse one declared copy value to the active language: an `I18nLabel`
Expand Down
Loading