diff --git a/.changeset/10900-zh-cn-leftovers.md b/.changeset/10900-zh-cn-leftovers.md new file mode 100644 index 0000000000..5fd1799b07 --- /dev/null +++ b/.changeset/10900-zh-cn-leftovers.md @@ -0,0 +1,47 @@ +--- +'@object-ui/core': minor +'@object-ui/auth': minor +'@object-ui/react': patch +'@object-ui/i18n': patch +'@object-ui/app-shell': patch +'@object-ui/console': patch +--- + +Four Console surfaces that read English under a zh-CN session now read the session's +language (objectui#10900). English stays the default. + +- **The generic action success toast.** When an action declares no `successMessage` and + the server returns no message, the runner falls back to "Action completed successfully". + That fallback now goes through a translator: `ActionRunner.setTranslator(translate)` is + new in `@object-ui/core`, and `` and `useActionRunner` in + `@object-ui/react` install the session's `t` on the runner they build. The pack key is + `actions.completedSuccessfully`. An author's `successMessage` and a server-returned + message still reach the toast verbatim, and a runner with no translator installed still + toasts the English sentence. +- **The social sign-in buttons on the login and sign-up pages.** `SocialSignInButtons` + takes a new `buttonText` prop, a template whose `{provider}` is replaced with the + provider's display name; unset, the buttons keep "Continue with {provider}" and + "Sign up with {provider}". `LoginForm` and `RegisterForm` take a new `socialButton` + label and pass it, with their existing `orText` label, to the buttons. `orText` was + documented as the divider label but rendered nowhere; it now sets the divider under the + buttons, and its documented default is corrected from "or" to "or continue with email", + the text that divider has always shown. The console's login and sign-up pages and + `@object-ui/app-shell`'s `DefaultLoginPage` / `DefaultRegisterPage` pass the new + `auth.login.*` and `auth.register.*` `socialButton` / `orText` keys. The provider's display + name is inserted as-is: the component's own label for the branded providers it knows, + otherwise the name the server reports. +- **Build Doctor.** The build conversation's Build Doctor button (its accessible name and + both tooltips) and the title of the drawer it opens read `console.ai.buildDoctor`, + `console.ai.buildDoctorTitle` and `console.ai.buildDoctorDisabledTitle`. +- **Setup → marketplace.** On `system/marketplace` and the pages under it, the breadcrumb + segment after System reads `console.breadcrumb.marketplace` instead of the humanized URL + slug; other `system/*` segments are unchanged. The zh marketplace search placeholder + reads 「按名称或标识搜索应用…」 instead of 「按名称或 manifest ID 搜索应用…」. The search + itself is unchanged: it matches the display name, the identifier and the description. + +All ten locale packs carry the nine new keys; no existing `en` value changes. + +**Clause-②: yes** — besides the nine pack keys, the public surface widens by four optional +members: `ActionRunner.setTranslator`, `SocialSignInButtonsProps.buttonText`, +`LoginFormLabels.socialButton` and `RegisterFormLabels.socialButton`. Nothing is removed, +renamed or narrowed, no accept set changes, and `@object-ui/react` exports nothing new. diff --git a/apps/console/src/pages/auth/LoginPage.tsx b/apps/console/src/pages/auth/LoginPage.tsx index 929eacb75e..6016887d43 100644 --- a/apps/console/src/pages/auth/LoginPage.tsx +++ b/apps/console/src/pages/auth/LoginPage.tsx @@ -391,6 +391,10 @@ function LoginFormCard({ usePasswordSignInText: t('auth.login.usePasswordSignInText', { defaultValue: 'Sign in with password instead', }), + // `{provider}` is `SocialSignInButtons`' own single-brace hole — kept + // out of i18next's `{{…}}` interpolation on purpose. + socialButton: t('auth.login.socialButton', { defaultValue: 'Continue with {provider}' }), + orText: t('auth.login.orText', { defaultValue: 'or continue with email' }), }} /> ); diff --git a/apps/console/src/pages/auth/RegisterPage.tsx b/apps/console/src/pages/auth/RegisterPage.tsx index c9917f2202..616839c0fe 100644 --- a/apps/console/src/pages/auth/RegisterPage.tsx +++ b/apps/console/src/pages/auth/RegisterPage.tsx @@ -200,6 +200,10 @@ export function RegisterPage() { submittingButton: t('auth.register.submittingButton', { defaultValue: 'Creating account…' }), hasAccountText: t('auth.register.hasAccountText', { defaultValue: 'Already have an account?' }), signInText: t('auth.register.signInText', { defaultValue: 'Sign in' }), + // `{provider}` is `SocialSignInButtons`' own single-brace hole — kept + // out of i18next's `{{…}}` interpolation on purpose. + socialButton: t('auth.register.socialButton', { defaultValue: 'Sign up with {provider}' }), + orText: t('auth.register.orText', { defaultValue: 'or continue with email' }), }} /> diff --git a/apps/console/src/pages/auth/__tests__/socialButtonsLocale-10900.test.tsx b/apps/console/src/pages/auth/__tests__/socialButtonsLocale-10900.test.tsx new file mode 100644 index 0000000000..eac5779625 --- /dev/null +++ b/apps/console/src/pages/auth/__tests__/socialButtonsLocale-10900.test.tsx @@ -0,0 +1,98 @@ +/** + * 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. + */ + +/** + * objectui#10900 — the environment login and sign-up pages render their social + * provider button and the divider under it in the session's language. + * + * The 2026-09-28 cloud E2E, browser in zh-CN, read `Continue with ObjectStack` + * and `OR CONTINUE WITH EMAIL` (the capitals are the divider's `uppercase` + * class) on the environment login page. Both were `SocialSignInButtons` + * defaults that no label reached. The pages now pass `auth.login.*` / + * `auth.register.*` `socialButton` and `orText` through the forms' `labels`. + * + * Rendered as shipped: the console page, `@object-ui/auth`'s real form and + * buttons, a real `AuthProvider` and a real `I18nProvider`; only the auth + * client is a stub, answering `/auth/config` with one social provider. The + * provider's own name stays as the server reports it. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { AuthProvider } from '@object-ui/auth'; +import type { AuthClient } from '@object-ui/auth'; +import { LoginPage } from '../LoginPage'; +import { RegisterPage } from '../RegisterPage'; + +afterEach(cleanup); +beforeEach(() => { + window.localStorage.clear(); +}); + +const CONFIG = { + socialProviders: [{ id: 'objectstack-cloud', name: 'ObjectStack', enabled: true, type: 'oidc' }], + emailPassword: { enabled: true, disableSignUp: false }, +}; + +function createMockClient(): AuthClient { + return { + getSession: vi.fn().mockResolvedValue(null), + getConfig: vi.fn().mockResolvedValue(CONFIG), + } as unknown as AuthClient; +} + +const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; +const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; + +function renderAt(path: '/login' | '/register', config: typeof ZH | typeof EN) { + window.history.replaceState({}, '', path); + return render( + + + + {path === '/login' ? : } + + + , + ); +} + +// Button names are matched at their END: an unknown provider id renders a +// one-letter icon badge inside the button, ahead of the label. +describe('console LoginPage — social sign-in under zh-CN (objectui#10900)', () => { + it('renders the button and the divider in Chinese under zh', async () => { + renderAt('/login', ZH); + expect(await screen.findByRole('button', { name: /使用 ObjectStack 继续$/ })).toBeInTheDocument(); + expect(screen.getByText('或使用邮箱继续')).toBeInTheDocument(); + expect(screen.queryByText(/Continue with/)).toBeNull(); + expect(screen.queryByText('or continue with email')).toBeNull(); + }); + + it('stays English under en', async () => { + renderAt('/login', EN); + expect(await screen.findByRole('button', { name: /Continue with ObjectStack$/ })).toBeInTheDocument(); + expect(screen.getByText('or continue with email')).toBeInTheDocument(); + }); +}); + +describe('console RegisterPage — social sign-up under zh-CN (objectui#10900)', () => { + it('renders the button and the divider in Chinese under zh', async () => { + renderAt('/register', ZH); + expect(await screen.findByRole('button', { name: /使用 ObjectStack 注册$/ })).toBeInTheDocument(); + expect(screen.getByText('或使用邮箱继续')).toBeInTheDocument(); + expect(screen.queryByText(/Sign up with/)).toBeNull(); + }); + + it('stays English under en', async () => { + renderAt('/register', EN); + expect(await screen.findByRole('button', { name: /Sign up with ObjectStack$/ })).toBeInTheDocument(); + expect(screen.getByText('or continue with email')).toBeInTheDocument(); + }); +}); diff --git a/content/docs/core/enhanced-actions.mdx b/content/docs/core/enhanced-actions.mdx index 0d221b1b14..e7cd76d3b9 100644 --- a/content/docs/core/enhanced-actions.mdx +++ b/content/docs/core/enhanced-actions.mdx @@ -312,7 +312,12 @@ authored callback fails at the authoring site with the migration in the message. What to write instead: - **A notice** — `successMessage` / `errorMessage`, plain strings (the runner - surfaces `successMessage` as a toast after a successful action). + surfaces `successMessage` as a toast after a successful action). The runner + shows `successMessage` as written. Without one, and without a message from + the server, it falls back to a generic "Action completed successfully", + which is the one toast text it translates: `` and + `useActionRunner` install the session's translator on their runner, and a + runner you build yourself takes one through `runner.setTranslator(t)`. - **Post-success navigation** — the spec's `onSuccess` block, `{ navigate, openIn }`, declared on `UIActionSchema` and forwarded to the runner (objectui#5934). It is a spec key, not a member of this legacy type, so it is not shown in a fence here. diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 7d03dae6be..652c9ef596 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -2257,9 +2257,9 @@ export function ChatPane({ className="h-7 w-7 text-muted-foreground hover:text-foreground" onClick={onDebug} disabled={!conversationId} - aria-label="Build Doctor" + aria-label={t('console.ai.buildDoctor')} data-testid="ai-chat-debug-button" - title={conversationId ? 'Build Doctor — what actually landed?' : 'Send a message first'} + title={conversationId ? t('console.ai.buildDoctorTitle') : t('console.ai.buildDoctorDisabledTitle')} > diff --git a/packages/app-shell/src/console/ai/BuildDebugDrawer.tsx b/packages/app-shell/src/console/ai/BuildDebugDrawer.tsx index 00f9479389..f0c93e7780 100644 --- a/packages/app-shell/src/console/ai/BuildDebugDrawer.tsx +++ b/packages/app-shell/src/console/ai/BuildDebugDrawer.tsx @@ -13,7 +13,7 @@ */ import React, { useEffect, useState } from 'react'; -import { useDisplayLocale } from '@object-ui/i18n'; +import { useDisplayLocale, useObjectTranslation } from '@object-ui/i18n'; import { Sheet, SheetContent, @@ -35,6 +35,7 @@ export function BuildDebugDrawer({ apiBase, conversationId, open, onOpenChange } // Dates and numbers on this surface read the display locale; a bare // `toLocale*()` call used the MACHINE's locale (objectui#9909). const displayLocale = useDisplayLocale(); + const { t } = useObjectTranslation(); const [report, setReport] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -70,7 +71,7 @@ export function BuildDebugDrawer({ apiBase, conversationId, open, onOpenChange } - Build Doctor + {t('console.ai.buildDoctor')} What the agent claimed vs what is actually live. Read-only diagnostic. diff --git a/packages/app-shell/src/console/ai/__tests__/buildDoctor.locale-10900.test.tsx b/packages/app-shell/src/console/ai/__tests__/buildDoctor.locale-10900.test.tsx new file mode 100644 index 0000000000..1d4f6a1631 --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/buildDoctor.locale-10900.test.tsx @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10900 — the build conversation's Build Doctor button and the drawer + * it opens speak the session's language. + * + * The 2026-09-28 cloud E2E, browser in zh-CN, read `Build Doctor` in the + * build conversation header: the button's accessible name and both of its + * tooltips were English literals beside a Share button that already read + * `console.ai.*` keys, and the drawer's title was a literal too. Rendered here + * as shipped under a real `I18nProvider` — `ChatPane` with `useObjectChat` + * stubbed and `ChatbotEnhanced` reduced to the `headerSlot` ChatPane hands it + * (the way the sibling ChatPane suites stub them), and the drawer with its one + * request answered. + */ +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import React from 'react'; + +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + // The header the pane builds reaches the chat surface as `headerSlot`; + // render just that, so the button is in the DOM as ChatPane made it. + ChatbotEnhanced: (props: { headerSlot?: React.ReactNode }) =>
{props.headerSlot}
, + useObjectChat: () => ({ + messages: [], + isLoading: false, + error: undefined, + sendMessage: vi.fn(), + stop: vi.fn(), + reload: vi.fn(), + clear: vi.fn(), + setMessages: vi.fn(), + }), + useAiModels: () => ({ models: [], defaultModelId: undefined }), + }; +}); + +vi.mock('../../../providers/MetadataProvider', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useMetadata: () => ({ apps: [] }) }; +}); +vi.mock('../../../providers/AdapterProvider', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => null }; +}); + +import { I18nProvider } from '@object-ui/i18n'; +import type { AgentDescriptor } from '@object-ui/plugin-chatbot'; +import { ChatPane } from '../AiChatPage'; +import { BuildDebugDrawer } from '../BuildDebugDrawer'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; +const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; + +function renderPane(config: typeof ZH | typeof EN, conversationId: string | undefined) { + return render( + + + + + , + ); +} + +function renderDrawer(config: typeof ZH | typeof EN) { + return render( + + {}} /> + , + ); +} + +beforeEach(() => { + window.localStorage.clear(); + // The drawer's one request; an unreadable answer is enough — the title is + // drawn before and regardless of the report. + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) }))); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('Build Doctor button (objectui#10900)', () => { + it('zh: accessible name and tooltip are Chinese', () => { + renderPane(ZH, 'conv-1'); + const button = screen.getByTestId('ai-chat-debug-button'); + expect(button).toHaveAttribute('aria-label', '构建诊断'); + expect(button).toHaveAttribute('title', '构建诊断 — 实际生效了哪些变更?'); + }); + + it('zh: the tooltip before the first message is Chinese', () => { + renderPane(ZH, undefined); + expect(screen.getByTestId('ai-chat-debug-button')).toHaveAttribute('title', '请先发送一条消息'); + }); + + it('en: unchanged English', () => { + renderPane(EN, 'conv-1'); + const button = screen.getByTestId('ai-chat-debug-button'); + expect(button).toHaveAttribute('aria-label', 'Build Doctor'); + expect(button).toHaveAttribute('title', 'Build Doctor — what actually landed?'); + }); + + it('en: the tooltip before the first message is unchanged English', () => { + renderPane(EN, undefined); + expect(screen.getByTestId('ai-chat-debug-button')).toHaveAttribute('title', 'Send a message first'); + }); +}); + +describe('Build Doctor drawer title (objectui#10900)', () => { + it('zh: the title is Chinese', () => { + renderDrawer(ZH); + expect(screen.getByRole('dialog', { name: '构建诊断' })).toBeInTheDocument(); + }); + + it('en: the title is unchanged English', () => { + renderDrawer(EN); + expect(screen.getByRole('dialog', { name: 'Build Doctor' })).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/console/auth/LoginPage.tsx b/packages/app-shell/src/console/auth/LoginPage.tsx index b7c8a9fc22..6d08dae7a0 100644 --- a/packages/app-shell/src/console/auth/LoginPage.tsx +++ b/packages/app-shell/src/console/auth/LoginPage.tsx @@ -50,6 +50,8 @@ export function LoginPage() { submittingButton: t('auth.login.submittingButton'), noAccountText: t('auth.login.noAccountText'), signUpText: t('auth.login.signUpText'), + socialButton: t('auth.login.socialButton'), + orText: t('auth.login.orText'), }} /> diff --git a/packages/app-shell/src/console/auth/RegisterPage.tsx b/packages/app-shell/src/console/auth/RegisterPage.tsx index d9717b42e1..8c4873484b 100644 --- a/packages/app-shell/src/console/auth/RegisterPage.tsx +++ b/packages/app-shell/src/console/auth/RegisterPage.tsx @@ -132,6 +132,8 @@ export function RegisterPage() { submittingButton: t('auth.register.submittingButton'), hasAccountText: t('auth.register.hasAccountText'), signInText: t('auth.register.signInText'), + socialButton: t('auth.register.socialButton'), + orText: t('auth.register.orText'), }} /> diff --git a/packages/app-shell/src/console/auth/__tests__/authPages.socialLabels-10900.test.tsx b/packages/app-shell/src/console/auth/__tests__/authPages.socialLabels-10900.test.tsx new file mode 100644 index 0000000000..0cd52e5d48 --- /dev/null +++ b/packages/app-shell/src/console/auth/__tests__/authPages.socialLabels-10900.test.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10900 — `DefaultLoginPage` / `DefaultRegisterPage`, the auth pages + * this package publishes for hosts, localize the social provider button and + * the divider under it the same way the console's own pages do. + * + * The console app's pages carry the E2E finding's pin + * (`apps/console/src/pages/auth/__tests__/socialButtonsLocale-10900.test.tsx`); + * these two are the other callers of `LoginForm` / `RegisterForm` in the tree, + * and were left passing no social labels at all. Rendered with the real forms, + * a real `AuthProvider` over a stub client and a real `I18nProvider`. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { AuthProvider, type AuthClient } from '@object-ui/auth'; +import { LoginPage } from '../LoginPage'; +import { RegisterPage } from '../RegisterPage'; + +afterEach(cleanup); + +const CONFIG = { + socialProviders: [{ id: 'objectstack-cloud', name: 'ObjectStack', enabled: true, type: 'oidc' }], + emailPassword: { enabled: true, disableSignUp: false }, +}; + +function createMockClient(): AuthClient { + return { + getSession: vi.fn().mockResolvedValue(null), + getConfig: vi.fn().mockResolvedValue(CONFIG), + } as unknown as AuthClient; +} + +const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; +const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; + +function renderPage(page: 'login' | 'register', config: typeof ZH | typeof EN) { + return render( + + + {page === 'login' ? : } + + , + ); +} + +// Button names are matched at their END: an unknown provider id renders a +// one-letter icon badge inside the button, ahead of the label. +describe('DefaultLoginPage / DefaultRegisterPage — social labels (objectui#10900)', () => { + it('login: Chinese under zh', async () => { + renderPage('login', ZH); + expect(await screen.findByRole('button', { name: /使用 ObjectStack 继续$/ })).toBeInTheDocument(); + expect(screen.getByText('或使用邮箱继续')).toBeInTheDocument(); + }); + + it('login: English under en', async () => { + renderPage('login', EN); + expect(await screen.findByRole('button', { name: /Continue with ObjectStack$/ })).toBeInTheDocument(); + expect(screen.getByText('or continue with email')).toBeInTheDocument(); + }); + + it('register: Chinese under zh', async () => { + renderPage('register', ZH); + expect(await screen.findByRole('button', { name: /使用 ObjectStack 注册$/ })).toBeInTheDocument(); + expect(screen.getByText('或使用邮箱继续')).toBeInTheDocument(); + }); + + it('register: English under en', async () => { + renderPage('register', EN); + expect(await screen.findByRole('button', { name: /Sign up with ObjectStack$/ })).toBeInTheDocument(); + expect(screen.getByText('or continue with email')).toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.searchPlaceholder-10900.test.tsx b/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.searchPlaceholder-10900.test.tsx new file mode 100644 index 0000000000..5f70eddd77 --- /dev/null +++ b/packages/app-shell/src/console/marketplace/__tests__/MarketplacePage.searchPlaceholder-10900.test.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10900 — the marketplace search box's zh placeholder says what it + * searches in plain Chinese, and says nothing the search does not do. + * + * The 2026-09-28 cloud E2E, browser in zh-CN, read 「按名称或 manifest ID 搜索应用…」: + * `manifest ID` is jargon to a customer, and English inside the Chinese line. + * The zh pack now reads 「按名称或标识搜索应用…」 — 标识 being the identifier each + * card prints under its name (the `manifest_id` code), which is the vocabulary + * the zh pack already uses for identifiers. `en` is unchanged. + * + * The placeholder is only honest if the search matches what it names, so this + * suite measures that too, through the page's own filter: the display name and + * the identifier each find the package, and text that is neither does not. + * + * Harness: `MarketplacePage.guardOrder.test.tsx`'s — the real runtime-config + * boot, an admin viewer, the API module stubbed — with the REAL + * `useObjectTranslation` under a real `I18nProvider`, since the pack value is + * the subject here. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; + +vi.mock('react-router-dom', () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({}), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); + +const PKG = { + id: 'pkg_1', + manifest_id: 'com.acme.helpdesk', + display_name: 'Service Desk', + description: 'Tickets and SLAs', + category: null, + latest_version: null, +}; + +vi.mock('../marketplaceApi', () => ({ + listMarketplacePackages: async () => ({ items: [PKG] }), + listLocalInstalls: async () => [], + listOrgPackages: async () => ({ items: [] }), + listInstalledPackages: async () => ({ items: [] }), + installPackage: async () => ({}), + installLocal: async () => ({}), +})); + +vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => {} })); +vi.mock('../../../providers/MetadataProvider', () => ({ useMetadata: () => ({ refresh: () => {} }) })); + +import { I18nProvider } from '@object-ui/i18n'; +import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config'; +import { MarketplacePage } from '../MarketplacePage'; + +const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; +const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; + +beforeEach(async () => { + resetRuntimeConfigForTesting(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + cloudUrl: 'https://cloud.objectos.ai', + singleEnvironment: true, + features: { installLocal: true, marketplace: true, aiStudio: true, autoPublishAiBuilds: true }, + branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' }, + }), + })), + ); + await initRuntimeConfig(); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + resetRuntimeConfigForTesting(); +}); + +function renderIn(config: typeof ZH | typeof EN) { + return render( + + + , + ); +} + +describe('MarketplacePage search placeholder (objectui#10900)', () => { + it('zh: plain Chinese, no manifest jargon', async () => { + renderIn(ZH); + const search = await screen.findByLabelText('搜索市场应用'); + expect(search).toHaveAttribute('placeholder', '按名称或标识搜索应用…'); + expect(search.getAttribute('placeholder')).not.toMatch(/manifest/i); + }); + + it('en: unchanged English', async () => { + renderIn(EN); + const search = await screen.findByLabelText('Search marketplace apps'); + expect(search).toHaveAttribute('placeholder', 'Search apps by name or manifest ID…'); + }); + + it('the search matches the name and the identifier the placeholder names', async () => { + renderIn(ZH); + await screen.findByTestId(`marketplace-card-${PKG.manifest_id}`); + const search = screen.getByLabelText('搜索市场应用'); + + // The name only — the identifier does not contain it. + fireEvent.change(search, { target: { value: 'service desk' } }); + expect(screen.getByTestId(`marketplace-card-${PKG.manifest_id}`)).toBeInTheDocument(); + + // The identifier only — the name and description do not contain it. + fireEvent.change(search, { target: { value: 'com.acme' } }); + expect(screen.getByTestId(`marketplace-card-${PKG.manifest_id}`)).toBeInTheDocument(); + + fireEvent.change(search, { target: { value: 'no-such-package' } }); + expect(screen.queryByTestId(`marketplace-card-${PKG.manifest_id}`)).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index 9d4c6ca78e..48c4ba66f5 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -371,7 +371,11 @@ export function AppHeader({ } } else if (routeType === 'system') { extraSegments.push({ label: t('console.breadcrumb.system') }); - if (pathParts[3]) extraSegments.push({ label: humanizeSlug(pathParts[3]) }); + // `marketplace` is the one system page this shell mounts itself + // (`AppContent`'s `system/marketplace` routes), so its segment reads from + // the pack. A host-mounted page's segment is still its humanized slug. + if (pathParts[3] === 'marketplace') extraSegments.push({ label: t('console.breadcrumb.marketplace') }); + else if (pathParts[3]) extraSegments.push({ label: humanizeSlug(pathParts[3]) }); } else if (routeType) { const currentObject = safeObjects.find((o: any) => o.name === routeType); if (currentObject) { diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.marketplaceBreadcrumb-10900.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.marketplaceBreadcrumb-10900.test.tsx new file mode 100644 index 0000000000..2b2760def0 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/AppHeader.marketplaceBreadcrumb-10900.test.tsx @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10900 — the environment Setup → marketplace breadcrumb reads + * `Marketplace` from the packs. + * + * The 2026-09-28 cloud E2E, browser in zh-CN, read `系统 / Marketplace` on + * `/apps/setup/system/marketplace`. The header builds a `system/*` breadcrumb + * from `console.breadcrumb.system` plus the next URL segment put through + * `humanizeSlug` — so the segment was the slug, capitalized, in every + * language. `marketplace` is the one `system/*` page `AppContent` mounts + * itself, so its segment now reads `console.breadcrumb.marketplace`. + * + * Deliberately NOT pinned here: a host-mounted `system/*` page (the console + * app's `system/audit-log`, `system/settings`, …) still shows its humanized + * slug. That is outside this card, and a pin on it would enshrine English + * under zh as correct. + * + * Harness: the sibling AppHeader suites' mocks, except `useObjectTranslation`, + * which is real here under a real `I18nProvider` — the pack lookup is the + * subject. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +const route = vi.hoisted(() => ({ pathname: '/apps/setup/system/marketplace' })); + +vi.mock('react-router-dom', () => ({ + useLocation: () => ({ pathname: route.pathname, search: '', hash: '', state: null, key: 't' }), + useParams: () => ({ appName: 'setup' }), + useNavigate: () => vi.fn(), + useSearchParams: () => [new URLSearchParams(), vi.fn()] as const, + Link: ({ children, to, ...p }: any) => {children}, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectLabel: () => ({ + objectLabel: (n: string) => n, + dashboardLabel: (n: string) => n, + pageLabel: (n: string) => n, + reportLabel: (n: string) => n, + viewLabel: (n: string) => n, + appLabel: (n: string) => n, + }), +})); + +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal>(); + const stripProps = (p: any) => { + const { asChild, variant, size, align, sideOffset, onOpenChange, ...rest } = p ?? {}; + return rest; + }; + const Pass = ({ children, ...p }: any) =>
{children}
; + return { + ...actual, + Button: ({ children, asChild, variant, size, ...p }: any) => ( + + ), + DropdownMenu: Pass, + DropdownMenuTrigger: Pass, + DropdownMenuContent: () => null, + DropdownMenuItem: () => null, + DropdownMenuLabel: () => null, + DropdownMenuSeparator: () => null, + DropdownMenuGroup: Pass, + Avatar: Pass, + AvatarImage: () => null, + AvatarFallback: Pass, + Popover: Pass, + PopoverTrigger: Pass, + PopoverContent: () => null, + Tabs: Pass, + TabsList: Pass, + TabsTrigger: ({ children }: any) => , + TabsContent: Pass, + cn: (...c: any[]) => c.filter(Boolean).join(' '), + }; +}); + +vi.mock('lucide-react', () => { + const Icon = () => ; + return new Proxy({ __esModule: true } as Record, { + get: (target, prop) => { + if (prop === 'then' || prop === '__esModule' || typeof prop === 'symbol') return target[prop]; + return Icon; + }, + has: (_target, prop) => prop !== 'then', + }); +}); + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useOffline: () => ({ isOnline: true }), +})); +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + PresenceAvatars: () => null, + useTenantPresence: () => [], +})); +vi.mock('../ModeToggle', () => ({ ModeToggle: () => null })); +vi.mock('../LocaleSwitcher', () => ({ LocaleSwitcher: () => null })); +vi.mock('../ConnectionStatus', () => ({ ConnectionStatus: () => null })); +vi.mock('../AppSwitcher', () => ({ AppSwitcher: () => null })); +vi.mock('../LocalizedSidebarTrigger', () => ({ LocalizedSidebarTrigger: () => null })); +vi.mock('../PreviewBadge', () => ({ PreviewBadge: () => null })); +vi.mock('../WorkspaceSwitcher', () => ({ WorkspaceSwitcher: () => null })); + +// ONE module-scope fetch double, never torn down: the header's pollers fire and +// forget, and may read after a test body returns (see the sibling suites). +vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [], items: [], agents: [], requests: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), +); + +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ apps: [], dashboards: [], pages: [], reports: [] }), +})); +vi.mock('../../context/NavigationContext.js', () => ({ + useNavigationContext: () => ({ currentAppName: 'setup', recordTitle: undefined }), +})); +vi.mock('@object-ui/auth', async (importOriginal) => { + // ONE stable identity — a fresh closure per render re-arms every + // `[getAuthConfig]` effect forever (see the sibling suites). + const getAuthConfig = () => Promise.resolve({ features: { multiOrgEnabled: false } }); + return { + ...(await importOriginal>()), + useAuth: () => ({ + user: { id: 'u1', name: 'Zhang San', email: 'zs@example.com' }, + signOut: vi.fn(), + isAuthEnabled: true, + organizations: [], + activeOrganization: null, + isOrganizationsLoading: false, + switchOrganization: vi.fn(), + getAuthConfig, + }), + getUserInitials: () => 'ZS', + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), + }; +}); +const fakeAdapter = { find: () => Promise.resolve({ data: [] }), getClient: () => undefined }; +vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => fakeAdapter })); + +import { I18nProvider } from '@object-ui/i18n'; +import { AppHeader } from '../AppHeader'; + +afterEach(cleanup); + +const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; +const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; + +function renderAt(pathname: string, config: typeof ZH | typeof EN) { + route.pathname = pathname; + return render( + + + , + ); +} + +describe('AppHeader — the Setup → marketplace breadcrumb (objectui#10900)', () => { + it('zh: the segment after 系统 is Chinese', () => { + renderAt('/apps/setup/system/marketplace', ZH); + // The last segment is drawn twice — the desktop trail and the compact + // mobile title — so both copies are counted. + expect(screen.getByText('系统')).toBeInTheDocument(); + expect(screen.getAllByText('应用市场').length).toBeGreaterThan(0); + expect(screen.queryAllByText('Marketplace')).toHaveLength(0); + }); + + it('en: the segment is unchanged English', () => { + renderAt('/apps/setup/system/marketplace', EN); + expect(screen.getByText('System')).toBeInTheDocument(); + expect(screen.getAllByText('Marketplace').length).toBeGreaterThan(0); + }); + + it('zh: a package page under the marketplace keeps the marketplace segment', () => { + renderAt('/apps/setup/system/marketplace/com.acme.crm', ZH); + expect(screen.getAllByText('应用市场').length).toBeGreaterThan(0); + expect(screen.queryAllByText('Marketplace')).toHaveLength(0); + }); +}); diff --git a/packages/auth/README.md b/packages/auth/README.md index 340f6de3ab..47950e252e 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -137,6 +137,13 @@ declare const navigate: (to: string) => void; navigate('/check-email')} />; ``` +`labels` is how the forms take translated text, and it reaches the social +provider buttons the server's `/auth/config` turns on: `socialButton` +is a template whose `{provider}` is replaced with the provider's display name +(defaults "Continue with {provider}" on `LoginForm`, "Sign up with {provider}" +on `RegisterForm`), and `orText` is the divider under the buttons (default +"or continue with email"). + When the server requires email verification, a registration ends in the user's inbox, and the verification link is the only way back. `verificationCallbackURL` says where that link lands, e.g. the invitation the user signed up from. It is diff --git a/packages/auth/src/LoginForm.tsx b/packages/auth/src/LoginForm.tsx index 1b8aa306be..e80d504162 100644 --- a/packages/auth/src/LoginForm.tsx +++ b/packages/auth/src/LoginForm.tsx @@ -36,8 +36,17 @@ export interface LoginFormLabels { submittingButton?: string; noAccountText?: string; signUpText?: string; - /** Divider label between social sign-in and email/password (defaults to "or") */ + /** + * Divider label between the social provider buttons and the email/password + * form (defaults to "or continue with email"). Rendered only when the server + * reports at least one social provider. + */ orText?: string; + /** + * Social provider button label (defaults to "Continue with {provider}"); + * `{provider}` is replaced with the provider's display name. + */ + socialButton?: string; /** Label for the SSO sign-in button (defaults to "Sign in with SSO") */ ssoButton?: string; /** @@ -237,7 +246,6 @@ export function LoginForm({ submittingButton: labels.submittingButton ?? 'Signing in…', noAccountText: labels.noAccountText ?? "Don't have an account?", signUpText: labels.signUpText ?? 'Sign up', - orText: labels.orText ?? 'or', ssoButton: labels.ssoButton ?? 'Sign in with SSO', usePasswordText: labels.usePasswordText ?? 'Use a password instead', backToSsoText: labels.backToSsoText ?? 'Back to single sign-on', @@ -370,7 +378,14 @@ export function LoginForm({ ) : ( <> - setHasSocialProviders(hasProviders)} /> + {/* Unset labels stay `undefined` here, so the component's own English + defaults apply — one copy of each default, not two. */} + setHasSocialProviders(hasProviders)} + /> {passwordFormVisible ? ( mode === 'phone-otp' ? ( diff --git a/packages/auth/src/RegisterForm.tsx b/packages/auth/src/RegisterForm.tsx index 8d11143de5..066851ca15 100644 --- a/packages/auth/src/RegisterForm.tsx +++ b/packages/auth/src/RegisterForm.tsx @@ -36,8 +36,17 @@ export interface RegisterFormLabels { submittingButton?: string; hasAccountText?: string; signInText?: string; - /** Divider label between social sign-up and email/password (defaults to "or") */ + /** + * Divider label between the social provider buttons and the email/password + * form (defaults to "or continue with email"). Rendered only when the server + * reports at least one social provider. + */ orText?: string; + /** + * Social provider button label (defaults to "Sign up with {provider}"); + * `{provider}` is replaced with the provider's display name. + */ + socialButton?: string; } export interface RegisterFormProps { @@ -153,7 +162,6 @@ export function RegisterForm({ submittingButton: labels.submittingButton ?? 'Creating account…', hasAccountText: labels.hasAccountText ?? 'Already have an account?', signInText: labels.signInText ?? 'Sign in', - orText: labels.orText ?? 'or', }; const handleSubmit = async (e: React.FormEvent) => { @@ -197,7 +205,13 @@ export function RegisterForm({ />
- + {/* Unset labels stay `undefined` here, so the component's own English + defaults apply — one copy of each default, not two. */} +
{/* No divider here: SocialSignInButtons already renders its own diff --git a/packages/auth/src/SocialSignInButtons.tsx b/packages/auth/src/SocialSignInButtons.tsx index e462e9c7fc..96a577bce2 100644 --- a/packages/auth/src/SocialSignInButtons.tsx +++ b/packages/auth/src/SocialSignInButtons.tsx @@ -95,8 +95,15 @@ export interface SocialSignInButtonsProps { callbackURL?: string; /** Where the provider should redirect on error. Defaults to current page. */ errorCallbackURL?: string; - /** Divider text shown between social buttons and the email form */ + /** Divider text shown between social buttons and the email form (defaults to "or continue with email") */ dividerText?: string; + /** + * Provider button label, for i18n. `{provider}` is replaced with the + * provider's display name, so a translation places the name where its + * grammar needs it. Defaults to "Continue with {provider}" in `sign-in` + * mode and "Sign up with {provider}" in `sign-up` mode. + */ + buttonText?: string; /** Called once after config resolves with whether any social providers are available. */ onProvidersResolved?: (hasProviders: boolean) => void; } @@ -113,6 +120,7 @@ export function SocialSignInButtons({ callbackURL, errorCallbackURL, dividerText = 'or continue with email', + buttonText, onProvidersResolved, }: SocialSignInButtonsProps) { const { getAuthConfig, signInWithProvider } = useAuth(); @@ -151,7 +159,8 @@ export function SocialSignInButtons({ if (loading || providers.length === 0) return null; - const label = mode === 'sign-in' ? 'Continue with' : 'Sign up with'; + const labelTemplate = + buttonText ?? (mode === 'sign-in' ? 'Continue with {provider}' : 'Sign up with {provider}'); const defaultCallback = typeof window !== 'undefined' ? window.location.href : undefined; @@ -190,7 +199,8 @@ export function SocialSignInButtons({ className="inline-flex h-10 w-full items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" > {pendingId === p.id ? : } - {label} {PROVIDER_LABEL[p.id] ?? p.name} + {/* A replacer FUNCTION, so a `$` in a provider name is never read as a replacement pattern. */} + {labelTemplate.replace('{provider}', () => PROVIDER_LABEL[p.id] ?? p.name)} ))}
diff --git a/packages/auth/src/__tests__/socialButtonLabels-10900.test.tsx b/packages/auth/src/__tests__/socialButtonLabels-10900.test.tsx new file mode 100644 index 0000000000..9e8c4a9674 --- /dev/null +++ b/packages/auth/src/__tests__/socialButtonLabels-10900.test.tsx @@ -0,0 +1,96 @@ +/** + * 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. + */ + +/** + * objectui#10900 — the social provider buttons and the divider under them + * reach `LoginForm` / `RegisterForm`'s `labels`, the one channel this package + * localizes through. + * + * The 2026-09-28 cloud E2E, browser in zh-CN, read `Continue with ObjectStack` + * and `OR CONTINUE WITH EMAIL` on the environment login page. Both strings were + * `SocialSignInButtons` defaults that no label reached: the forms rendered the + * buttons with no text props, and their `orText` label — documented as this + * very divider — was defaulted and then never rendered anywhere. `orText` now + * feeds the divider and `socialButton` the button, a `{provider}` template so a + * translation places the name where its grammar needs it. + * + * Unset labels are the control: the English an embedder without labels has + * always seen must not move. + */ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { AuthProvider } from '../AuthProvider'; +import { LoginForm } from '../LoginForm'; +import { RegisterForm } from '../RegisterForm'; +import type { AuthClient, AuthPublicConfig } from '../types'; + +afterEach(() => cleanup()); + +const CONFIG: AuthPublicConfig = { + socialProviders: [{ id: 'objectstack-cloud', name: 'ObjectStack', enabled: true, type: 'oidc' }], +}; + +function client(config: AuthPublicConfig): AuthClient { + return { + getSession: vi.fn().mockResolvedValue(null), + getConfig: vi.fn().mockResolvedValue(config), + signInWithProvider: vi.fn().mockResolvedValue(undefined), + } as unknown as AuthClient; +} + +function renderIn(node: React.ReactNode, config: AuthPublicConfig = CONFIG) { + return render( + + {node} + , + ); +} + +// Names are matched at their END: an unknown provider id renders a one-letter +// icon badge (`ProviderIcon`) inside the button, ahead of the label. +describe('LoginForm — social button labels (objectui#10900)', () => { + it('unset labels keep the English defaults', async () => { + renderIn(); + expect(await screen.findByRole('button', { name: /Continue with ObjectStack$/ })).toBeTruthy(); + expect(screen.getByText('or continue with email')).toBeTruthy(); + }); + + it('socialButton fills {provider} with the provider name, and orText is the divider', async () => { + renderIn(); + expect(await screen.findByRole('button', { name: /使用 ObjectStack 继续$/ })).toBeTruthy(); + expect(screen.getByText('或使用邮箱继续')).toBeTruthy(); + expect(screen.queryByText(/Continue with/)).toBeNull(); + expect(screen.queryByText('or continue with email')).toBeNull(); + }); + + it('a provider name is inserted as text, never read as a replacement pattern', async () => { + // As a plain replacement STRING, `$&` would echo the matched `{provider}` + // back: "Continue with Acme {provider} Co". + renderIn(, { + socialProviders: [{ id: 'acme-sso', name: 'Acme $& Co', enabled: true, type: 'oidc' }], + }); + const button = await screen.findByRole('button', { name: /Continue with Acme \$& Co$/ }); + expect(button.textContent).not.toContain('{provider}'); + }); +}); + +describe('RegisterForm — social button labels (objectui#10900)', () => { + it('unset labels keep the English defaults', async () => { + renderIn(); + expect(await screen.findByRole('button', { name: /Sign up with ObjectStack$/ })).toBeTruthy(); + expect(screen.getByText('or continue with email')).toBeTruthy(); + }); + + it('socialButton and orText reach the sign-up buttons and divider', async () => { + renderIn(); + expect(await screen.findByRole('button', { name: /使用 ObjectStack 注册$/ })).toBeTruthy(); + expect(screen.getByText('或使用邮箱继续')).toBeTruthy(); + expect(screen.queryByText(/Sign up with/)).toBeNull(); + }); +}); diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index e418b95063..d8035970d9 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -539,6 +539,19 @@ export type ToastHandler = (message: string, options?: { undo?: { label?: string }; }) => void; +/** + * The success toast the runner falls back to when neither the server (a + * `data.message` on the result) nor the author (`successMessage`) supplied one. + * It is the only toast text the runner writes itself, so it is the only one + * {@link ActionRunner.setTranslator}'s translator is asked for: `key` names the + * locale-pack entry (`@object-ui/i18n`'s packs define it), `defaultValue` is the + * English source and what shows when no translator is installed. + */ +const DEFAULT_SUCCESS_TOAST = { + key: 'actions.completedSuccessfully', + defaultValue: 'Action completed successfully', +} as const; + /** * Modal handler — consumers provide to render modal dialogs. */ @@ -993,6 +1006,7 @@ export class ActionRunner { private navigationHandler: NavigationHandler | null; private paramCollectionHandler: ParamCollectionHandler | null; private resultDialogHandler: ResultDialogHandler | null; + private translate: ((key: string, options: { defaultValue: string }) => string) | null; /** * Built-in dispatch, one entry per runnable action type. @@ -1031,6 +1045,19 @@ export class ActionRunner { this.navigationHandler = null; this.paramCollectionHandler = null; this.resultDialogHandler = null; + this.translate = null; + } + + /** + * Set the translator for the text the runner supplies itself — the host's + * `t`, injected so this package takes no i18n dependency. Today that is one + * string: the generic success toast shown when an action declares no + * `successMessage` and the server returned no message. An author's + * `successMessage` and a server message reach the toast verbatim, translator + * or not. With no translator the toast stays English. + */ + setTranslator(translate: (key: string, options: { defaultValue: string }) => string): void { + this.translate = translate; } /** @@ -1316,6 +1343,16 @@ export class ActionRunner { return lastResult; } + /** + * The generic success toast, in the installed translator's language. An + * empty answer from the translator falls back to the English source rather + * than raising an empty toast. + */ + private defaultSuccessToast(): string { + const { key, defaultValue } = DEFAULT_SUCCESS_TOAST; + return this.translate?.(key, { defaultValue }) || defaultValue; + } + /** * Post-execution: emit toast notifications, handle chaining, callbacks. */ @@ -1338,12 +1375,14 @@ export class ActionRunner { // check_app_updates / publish / install compute a real outcome // ("2 app updates available: CRM 1.0.0→1.0.1", "Published v1.2.0") // that the static label can't express; without this the user only ever - // sees a generic "Done". Falls back to the static label, then a default. + // sees a generic "Done". Falls back to the static label, then a default + // — the one string here the runner writes itself, so the only one its + // translator is asked for (see `setTranslator`). const dyn = (result.data && typeof result.data === 'object' && typeof (result.data as { message?: unknown }).message === 'string') ? String((result.data as { message?: unknown }).message).trim() : ''; - const message = dyn || action.successMessage || 'Action completed successfully'; + const message = dyn || action.successMessage || this.defaultSuccessToast(); // Undoable action: register the captured operation on the global // UndoManager and surface an "Undo" affordance on the toast (the // consumer's toast handler wires the button to UndoManager). diff --git a/packages/core/src/actions/__tests__/ActionRunner.defaultSuccessToast-10900.test.ts b/packages/core/src/actions/__tests__/ActionRunner.defaultSuccessToast-10900.test.ts new file mode 100644 index 0000000000..e7c60a7f9a --- /dev/null +++ b/packages/core/src/actions/__tests__/ActionRunner.defaultSuccessToast-10900.test.ts @@ -0,0 +1,80 @@ +/** + * 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. + */ + +/** + * objectui#10900 — the runner's own success toast goes through the host's + * translator; an author's `successMessage` and a server message never do. + * + * A zh-CN session saw "Action completed successfully" after a welcome CTA: the + * runner's fallback was an English literal with no way to reach a translator, + * because this package takes no i18n dependency. `setTranslator` is the + * injected seam (the same shape `recordDelete` takes its `t` in); the React + * owners of a runner install the session's `t` there. Those owners are pinned + * in `@object-ui/react`'s `ActionProvider.defaultSuccessToast-10900` suite; this + * one pins the runner half on its own. + */ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { ActionRunner, type ToastHandler } from '../ActionRunner'; + +const SUCCEEDS = async () => ({ success: true }); + +describe('ActionRunner — the default success toast is translatable (objectui#10900)', () => { + let runner: ActionRunner; + let toast: Mock; + + beforeEach(() => { + runner = new ActionRunner({}); + toast = vi.fn(); + runner.setToastHandler(toast); + runner.registerHandler('script', SUCCEEDS); + }); + + it('with no translator installed, the toast is the English source', async () => { + await runner.execute({ type: 'script', name: 'welcome_cta' }); + expect(toast).toHaveBeenCalledWith('Action completed successfully', expect.objectContaining({ type: 'success' })); + }); + + it('with a translator, the toast is its answer for the pack key, asked with the English default', async () => { + const translate = vi.fn((_key: string, _options: { defaultValue: string }) => '操作已成功完成'); + runner.setTranslator(translate); + + await runner.execute({ type: 'script', name: 'welcome_cta' }); + + expect(translate).toHaveBeenCalledWith('actions.completedSuccessfully', { + defaultValue: 'Action completed successfully', + }); + expect(toast).toHaveBeenCalledWith('操作已成功完成', expect.objectContaining({ type: 'success' })); + }); + + it("an author's successMessage reaches the toast verbatim and the translator is never asked", async () => { + const translate = vi.fn(() => '操作已成功完成'); + runner.setTranslator(translate); + + await runner.execute({ type: 'script', name: 'welcome_cta', successMessage: 'Welcome aboard' }); + + expect(toast).toHaveBeenCalledWith('Welcome aboard', expect.objectContaining({ type: 'success' })); + expect(translate).not.toHaveBeenCalled(); + }); + + it('a server-returned message reaches the toast verbatim and the translator is never asked', async () => { + const translate = vi.fn(() => '操作已成功完成'); + runner.setTranslator(translate); + runner.registerHandler('script', async () => ({ success: true, data: { message: 'Published v1.2.0' } })); + + await runner.execute({ type: 'script', name: 'publish' }); + + expect(toast).toHaveBeenCalledWith('Published v1.2.0', expect.objectContaining({ type: 'success' })); + expect(translate).not.toHaveBeenCalled(); + }); + + it('an empty answer from the translator falls back to the English source, not an empty toast', async () => { + runner.setTranslator(() => ''); + await runner.execute({ type: 'script', name: 'welcome_cta' }); + expect(toast).toHaveBeenCalledWith('Action completed successfully', expect.objectContaining({ type: 'success' })); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index ee05aa8c6f..d91a43d17d 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -159,6 +159,7 @@ const ar = { copyAll: 'نسخ الكل', }, notAvailableHere: '"{{action}}" غير متاح في الصفحة الحالية.', + completedSuccessfully: 'اكتمل الإجراء بنجاح', }, validation: { required: "{{field}} مطلوب", @@ -1595,6 +1596,9 @@ const ar = { share: "مشاركة", shareTitle: "مشاركة هذه المحادثة", shareDisabledTitle: "ابدأ المحادثة لتفعيل المشاركة", + buildDoctor: "تشخيص البناء", + buildDoctorTitle: "تشخيص البناء — ما الذي طُبِّق فعلًا؟", + buildDoctorDisabledTitle: "أرسل رسالة أولًا", newChat: "جديدة", searchChats: "البحث في المحادثات…", noChatsYet: "لا توجد محادثات بعد", @@ -1802,6 +1806,7 @@ const ar = { pages: "الصفحات", reports: "التقارير", system: "النظام", + marketplace: "السوق", }, // objectui#4024 — the single-namespace settings screen. Its sibling // `settingsHub` above already resolved through this pack; the view was the @@ -2090,6 +2095,8 @@ const ar = { resendOtpCountdownText: "إعادة الإرسال بعد {seconds} ثانية", usePhoneOtpText: "تسجيل الدخول برمز التحقق", usePasswordSignInText: "تسجيل الدخول بكلمة المرور بدلاً من ذلك", + socialButton: "المتابعة باستخدام {provider}", + orText: "أو تابع باستخدام البريد الإلكتروني", }, register: { title: "إنشاء حساب", @@ -2108,6 +2115,8 @@ const ar = { submittingButton: "جارٍ إنشاء الحساب…", hasAccountText: "لديك حساب بالفعل؟", signInText: "تسجيل الدخول", + socialButton: "التسجيل باستخدام {provider}", + orText: "أو تابع باستخدام البريد الإلكتروني", errors: { userExists: "يوجد حساب بهذا البريد الإلكتروني بالفعل. حاول تسجيل الدخول.", }, diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index fd131b20be..5703e328c3 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -148,6 +148,7 @@ const de = { copyAll: 'Alle kopieren', }, notAvailableHere: '„{{action}}“ ist auf der aktuellen Seite nicht verfügbar.', + completedSuccessfully: 'Aktion erfolgreich abgeschlossen', }, validation: { required: "{{field}} ist erforderlich", @@ -1575,6 +1576,9 @@ const de = { share: "Teilen", shareTitle: "Diese Unterhaltung teilen", shareDisabledTitle: "Beginnen Sie zu chatten, um das Teilen zu aktivieren", + buildDoctor: "Build-Diagnose", + buildDoctorTitle: "Build-Diagnose — was ist tatsächlich angekommen?", + buildDoctorDisabledTitle: "Senden Sie zuerst eine Nachricht", newChat: "Neu", searchChats: "Chats durchsuchen…", noChatsYet: "Noch keine Chats", @@ -1782,6 +1786,7 @@ const de = { pages: "Seiten", reports: "Berichte", system: "System", + marketplace: "Marktplatz", }, // objectui#4024 — the single-namespace settings screen. Its sibling // `settingsHub` above already resolved through this pack; the view was the @@ -2067,6 +2072,8 @@ const de = { resendOtpCountdownText: "Erneut senden in {seconds} s", usePhoneOtpText: "Mit Bestätigungscode anmelden", usePasswordSignInText: "Stattdessen mit Passwort anmelden", + socialButton: "Weiter mit {provider}", + orText: "oder mit E-Mail fortfahren", }, register: { title: "Konto erstellen", @@ -2085,6 +2092,8 @@ const de = { submittingButton: "Konto wird erstellt…", hasAccountText: "Bereits ein Konto?", signInText: "Anmelden", + socialButton: "Mit {provider} registrieren", + orText: "oder mit E-Mail fortfahren", errors: { userExists: "Ein Konto mit dieser E-Mail existiert bereits. Versuchen Sie sich anzumelden.", }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 98f8677436..801225657d 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -175,6 +175,10 @@ const en = { // `visible` gate outranks (objectui#4191) — the deep link or host asked // for it, but the author hid it on this surface. notAvailableHere: '"{{action}}" is not available on the current page.', + // The success toast the action runner shows when an action declares no + // `successMessage` and the server returned no message — the one toast + // text the runner writes itself (objectui#10900). + completedSuccessfully: 'Action completed successfully', }, validation: { required: '{{field}} is required', @@ -1766,6 +1770,8 @@ const en = { pages: 'Pages', reports: 'Reports', system: 'System', + // The Setup system page segment after `System` (objectui#10900). + marketplace: 'Marketplace', }, nav: { pinItem: 'Pin {{name}}', @@ -1958,6 +1964,12 @@ const en = { share: 'Share', shareTitle: 'Share this conversation', shareDisabledTitle: 'Start chatting to enable sharing', + // The build conversation's Build Doctor button (its accessible name, + // and its tooltip enabled / before the first message) and the drawer + // title it opens (objectui#10900). + buildDoctor: 'Build Doctor', + buildDoctorTitle: 'Build Doctor — what actually landed?', + buildDoctorDisabledTitle: 'Send a message first', newChat: 'New', searchChats: 'Search chats…', noChatsYet: 'No chats yet', @@ -2385,7 +2397,7 @@ const en = { signingIn: 'Signing you in…', ssoHandoff: 'Continue to {{target}}', // Phone/OTP sign-in labels. `LoginForm` interpolates `{seconds}` with a - // literal `.replace()` of its own (packages/auth/src/LoginForm.tsx:429), + // literal `.replace('{seconds}', …)` of its own (in `LoginForm`), // so those SINGLE braces must survive translation — i18next never sees // them. emailOrPhoneLabel: 'Email or phone number', @@ -2398,6 +2410,13 @@ const en = { resendOtpCountdownText: 'Resend in {seconds}s', usePhoneOtpText: 'Sign in with verification code', usePasswordSignInText: 'Sign in with password instead', + // The social provider buttons and the divider under them + // (`SocialSignInButtons`, fed through `LoginForm`'s labels). + // `{provider}` is the component's own single-brace hole, filled by a + // literal `.replace()` with the provider's name as the server reports + // it — the same convention as `{seconds}` above (objectui#10900). + socialButton: 'Continue with {provider}', + orText: 'or continue with email', devAdminHint: { title: 'Development instance', body: 'Sign in with the seeded dev admin:', @@ -2426,6 +2445,9 @@ const en = { submittingButton: 'Creating account…', hasAccountText: 'Already have an account?', signInText: 'Sign in', + // Same pair as `auth.login.socialButton` / `orText`, for sign-up. + socialButton: 'Sign up with {provider}', + orText: 'or continue with email', errors: { userExists: 'An account with this email already exists. Try signing in instead.', }, diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index a71655402a..2099d03620 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -147,6 +147,7 @@ const es = { copyAll: 'Copiar todo', }, notAvailableHere: '«{{action}}» no está disponible en la página actual.', + completedSuccessfully: 'La acción se completó correctamente', }, validation: { // "{{field}} es obligatorio" only agreed with masculine field labels — @@ -1579,6 +1580,9 @@ const es = { share: "Compartir", shareTitle: "Compartir esta conversación", shareDisabledTitle: "Empiece a chatear para habilitar el uso compartido", + buildDoctor: "Diagnóstico de compilación", + buildDoctorTitle: "Diagnóstico de compilación — ¿qué se aplicó realmente?", + buildDoctorDisabledTitle: "Envíe primero un mensaje", newChat: "Nuevo", searchChats: "Buscar chats…", noChatsYet: "Todavía no hay chats", @@ -1786,6 +1790,7 @@ const es = { pages: "Páginas", reports: "Informes", system: "Sistema", + marketplace: "Marketplace", }, // objectui#4024 — the single-namespace settings screen. Its sibling // `settingsHub` above already resolved through this pack; the view was the @@ -2071,6 +2076,8 @@ const es = { resendOtpCountdownText: "Reenviar en {seconds} s", usePhoneOtpText: "Iniciar sesión con código de verificación", usePasswordSignInText: "Iniciar sesión con contraseña en su lugar", + socialButton: "Continuar con {provider}", + orText: "o continúe con correo electrónico", }, register: { title: "Crear una cuenta", @@ -2089,6 +2096,8 @@ const es = { submittingButton: "Creando cuenta…", hasAccountText: "¿Ya tienes una cuenta?", signInText: "Iniciar sesión", + socialButton: "Registrarse con {provider}", + orText: "o continúe con correo electrónico", errors: { userExists: "Ya existe una cuenta con ese correo. Intente iniciar sesión.", }, diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 1bbc1681f7..755037abfc 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -148,6 +148,7 @@ const fr = { copyAll: 'Tout copier', }, notAvailableHere: '« {{action}} » n\'est pas disponible sur la page actuelle.', + completedSuccessfully: 'Action effectuée avec succès', }, validation: { required: "{{field}} est obligatoire", @@ -1577,6 +1578,9 @@ const fr = { share: "Partager", shareTitle: "Partager cette conversation", shareDisabledTitle: "Commencez à discuter pour activer le partage", + buildDoctor: "Diagnostic de build", + buildDoctorTitle: "Diagnostic de build — qu'est-ce qui a réellement été appliqué ?", + buildDoctorDisabledTitle: "Envoyez d'abord un message", newChat: "Nouveau", searchChats: "Rechercher des discussions…", noChatsYet: "Aucune discussion pour l'instant", @@ -1784,6 +1788,7 @@ const fr = { pages: "Pages", reports: "Rapports", system: "Système", + marketplace: "Marketplace", }, // objectui#4024 — the single-namespace settings screen. Its sibling // `settingsHub` above already resolved through this pack; the view was the @@ -2069,6 +2074,8 @@ const fr = { resendOtpCountdownText: "Renvoyer dans {seconds} s", usePhoneOtpText: "Se connecter avec un code de vérification", usePasswordSignInText: "Se connecter avec un mot de passe à la place", + socialButton: "Continuer avec {provider}", + orText: "ou continuer avec l'e-mail", }, register: { title: "Créer un compte", @@ -2087,6 +2094,8 @@ const fr = { submittingButton: "Création du compte…", hasAccountText: "Vous avez déjà un compte ?", signInText: "Se connecter", + socialButton: "S'inscrire avec {provider}", + orText: "ou continuer avec l'e-mail", errors: { userExists: "Un compte avec cet e-mail existe déjà. Essayez de vous connecter.", }, diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index def06821ce..12a82967ee 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -148,6 +148,7 @@ const ja = { copyAll: 'すべてコピー', }, notAvailableHere: '「{{action}}」は現在のページでは利用できません。', + completedSuccessfully: '操作が正常に完了しました', }, validation: { required: "{{field}}は必須です", @@ -1577,6 +1578,9 @@ const ja = { share: "共有", shareTitle: "この会話を共有", shareDisabledTitle: "共有するにはチャットを開始してください", + buildDoctor: "ビルド診断", + buildDoctorTitle: "ビルド診断 — 実際に反映された内容は?", + buildDoctorDisabledTitle: "先にメッセージを送信してください", newChat: "新規", searchChats: "チャットを検索…", noChatsYet: "チャットはまだありません", @@ -1784,6 +1788,7 @@ const ja = { pages: "ページ", reports: "レポート", system: "システム", + marketplace: "マーケットプレイス", }, // objectui#4024 — the single-namespace settings screen. Its sibling // `settingsHub` above already resolved through this pack; the view was the @@ -2069,6 +2074,8 @@ const ja = { resendOtpCountdownText: "{seconds} 秒後に再送信", usePhoneOtpText: "確認コードでサインイン", usePasswordSignInText: "パスワードでサインインする", + socialButton: "{provider} で続行", + orText: "またはメールアドレスで続行", }, register: { title: "アカウントを作成", @@ -2087,6 +2094,8 @@ const ja = { submittingButton: "アカウント作成中…", hasAccountText: "すでにアカウントをお持ちですか?", signInText: "サインイン", + socialButton: "{provider} で新規登録", + orText: "またはメールアドレスで続行", errors: { userExists: "このメールアドレスのアカウントはすでに存在します。代わりにサインインしてください。", }, diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index e390060e0e..e95aef406e 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -148,6 +148,7 @@ const ko = { copyAll: '모두 복사', }, notAvailableHere: '"{{action}}"은(는) 현재 페이지에서 사용할 수 없습니다.', + completedSuccessfully: '작업이 완료되었습니다', }, validation: { required: "{{field}}은(는) 필수입니다", @@ -1575,6 +1576,9 @@ const ko = { share: "공유", shareTitle: "이 대화 공유", shareDisabledTitle: "공유하려면 먼저 대화를 시작하세요", + buildDoctor: "빌드 진단", + buildDoctorTitle: "빌드 진단 — 실제로 반영된 내용은?", + buildDoctorDisabledTitle: "먼저 메시지를 보내세요", newChat: "새로 만들기", searchChats: "채팅 검색…", noChatsYet: "아직 채팅이 없습니다", @@ -1782,6 +1786,7 @@ const ko = { pages: "페이지", reports: "보고서", system: "시스템", + marketplace: "마켓플레이스", }, // objectui#4024 — the single-namespace settings screen. Its sibling // `settingsHub` above already resolved through this pack; the view was the @@ -2067,6 +2072,8 @@ const ko = { resendOtpCountdownText: "{seconds}초 후 재전송", usePhoneOtpText: "인증 코드로 로그인", usePasswordSignInText: "비밀번호로 로그인하기", + socialButton: "{provider} 계정으로 계속", + orText: "또는 이메일로 계속", }, register: { title: "계정 만들기", @@ -2085,6 +2092,8 @@ const ko = { submittingButton: "계정 생성 중…", hasAccountText: "이미 계정이 있으신가요?", signInText: "로그인", + socialButton: "{provider} 계정으로 가입", + orText: "또는 이메일로 계속", errors: { userExists: "해당 이메일로 이미 계정이 존재합니다. 로그인해 보세요.", }, diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 10fbb82ddc..833b9084d0 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -147,6 +147,7 @@ const pt = { copyAll: 'Copiar tudo', }, notAvailableHere: '"{{action}}" não está disponível na página atual.', + completedSuccessfully: 'A ação foi concluída com sucesso', }, validation: { required: "{{field}} é obrigatório", @@ -1574,6 +1575,9 @@ const pt = { share: "Compartilhar", shareTitle: "Compartilhar esta conversa", shareDisabledTitle: "Comece a conversar para habilitar o compartilhamento", + buildDoctor: "Diagnóstico de build", + buildDoctorTitle: "Diagnóstico de build — o que foi realmente aplicado?", + buildDoctorDisabledTitle: "Envie uma mensagem primeiro", newChat: "Nova", searchChats: "Pesquisar conversas…", noChatsYet: "Ainda não há conversas", @@ -1781,6 +1785,7 @@ const pt = { pages: "Páginas", reports: "Relatórios", system: "Sistema", + marketplace: "Marketplace", }, // objectui#4024 — the single-namespace settings screen. Its sibling // `settingsHub` above already resolved through this pack; the view was the @@ -2066,6 +2071,8 @@ const pt = { resendOtpCountdownText: "Reenviar em {seconds} s", usePhoneOtpText: "Entrar com código de verificação", usePasswordSignInText: "Entrar com senha em vez disso", + socialButton: "Continuar com {provider}", + orText: "ou continue com e-mail", }, register: { title: "Criar uma conta", @@ -2084,6 +2091,8 @@ const pt = { submittingButton: "Criando conta…", hasAccountText: "Já tem uma conta?", signInText: "Entrar", + socialButton: "Cadastre-se com {provider}", + orText: "ou continue com e-mail", errors: { userExists: "Já existe uma conta com esse e-mail. Tente fazer login.", }, diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 9ee3cbf2ad..a0f2a4eccd 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -158,6 +158,7 @@ const ru = { copyAll: 'Копировать всё', }, notAvailableHere: '«{{action}}» недоступно на текущей странице.', + completedSuccessfully: 'Действие успешно выполнено', }, validation: { required: "Поле {{field}} обязательно для заполнения", @@ -1597,6 +1598,9 @@ const ru = { share: "Поделиться", shareTitle: "Поделиться этим диалогом", shareDisabledTitle: "Начните диалог, чтобы включить публикацию", + buildDoctor: "Диагностика сборки", + buildDoctorTitle: "Диагностика сборки — что на самом деле применено?", + buildDoctorDisabledTitle: "Сначала отправьте сообщение", newChat: "Создать", searchChats: "Поиск по чатам…", noChatsYet: "Чатов пока нет", @@ -1804,6 +1808,7 @@ const ru = { pages: "Страницы", reports: "Отчёты", system: "Система", + marketplace: "Маркетплейс", }, // objectui#4024 — the single-namespace settings screen. Its sibling // `settingsHub` above already resolved through this pack; the view was the @@ -2092,6 +2097,8 @@ const ru = { resendOtpCountdownText: "Отправить снова через {seconds} с", usePhoneOtpText: "Войти по коду подтверждения", usePasswordSignInText: "Войти с паролем", + socialButton: "Продолжить через {provider}", + orText: "или продолжите с электронной почтой", }, register: { title: "Создать аккаунт", @@ -2110,6 +2117,8 @@ const ru = { submittingButton: "Создание аккаунта…", hasAccountText: "Уже есть аккаунт?", signInText: "Войти", + socialButton: "Зарегистрироваться через {provider}", + orText: "или продолжите с электронной почтой", errors: { userExists: "Аккаунт с таким email уже существует. Попробуйте войти.", }, diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index a1069b23f6..ca79498e18 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -155,6 +155,7 @@ const zh = { copyAll: '全部复制', }, notAvailableHere: '「{{action}}」在当前页面不可用。', + completedSuccessfully: '操作已成功完成', }, validation: { required: '{{field}}不能为空', @@ -1581,6 +1582,7 @@ const zh = { // restore 报告 without a new ruling. reports: '报表', system: '系统', + marketplace: '应用市场', }, nav: { pinItem: '固定 {{name}}', @@ -1735,6 +1737,9 @@ const zh = { share: '分享', shareTitle: '分享此对话', shareDisabledTitle: '开始对话后即可分享', + buildDoctor: '构建诊断', + buildDoctorTitle: '构建诊断 — 实际生效了哪些变更?', + buildDoctorDisabledTitle: '请先发送一条消息', newChat: '新对话', searchChats: '搜索对话…', noChatsYet: '暂无对话', @@ -2143,6 +2148,8 @@ const zh = { resendOtpCountdownText: '{seconds} 秒后可重新发送', usePhoneOtpText: '使用验证码登录', usePasswordSignInText: '改用密码登录', + socialButton: '使用 {provider} 继续', + orText: '或使用邮箱继续', }, register: { title: '创建账户', @@ -2161,6 +2168,8 @@ const zh = { submittingButton: '创建中…', hasAccountText: '已有账户?', signInText: '登录', + socialButton: '使用 {provider} 注册', + orText: '或使用邮箱继续', errors: { userExists: '该邮箱已被注册,请直接登录或更换邮箱。', }, @@ -3305,7 +3314,7 @@ const zh = { marketplace: { title: '应用市场', subtitle: '浏览已通过审核、发布到 ObjectStack 目录中的应用。点击应用查看详情并安装到你的某个环境中。', - searchPlaceholder: '按名称或 manifest ID 搜索应用…', + searchPlaceholder: '按名称或标识搜索应用…', searchAria: '搜索市场应用', installed: '已安装', installedCount: '已安装({{count}})', diff --git a/packages/react/src/context/ActionContext.tsx b/packages/react/src/context/ActionContext.tsx index a81f571a63..4a0cfdc3b7 100644 --- a/packages/react/src/context/ActionContext.tsx +++ b/packages/react/src/context/ActionContext.tsx @@ -26,6 +26,7 @@ import { type ParamCollectionHandler, type ResultDialogHandler, } from '@object-ui/core'; +import { useActionRunnerTranslator } from './actionRunnerTranslator.js'; export interface ActionProviderProps { children: React.ReactNode; @@ -95,6 +96,8 @@ export const ActionProvider: React.FC = ({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); + // The runner's own copy (the generic success toast) in the session's language. + const translate = useActionRunnerTranslator(); const runner = useMemo(() => { // Normalize the evaluator scope so predicates can use *either* flat @@ -111,6 +114,7 @@ export const ActionProvider: React.FC = ({ : { ...context }, } as ActionCtx; const r = new ActionRunner(normalizedContext); + r.setTranslator(translate); if (onConfirm) r.setConfirmHandler(onConfirm); if (onToast) r.setToastHandler(onToast); if (onModal) r.setModalHandler(onModal); diff --git a/packages/react/src/context/__tests__/ActionProvider.defaultSuccessToast-10900.test.tsx b/packages/react/src/context/__tests__/ActionProvider.defaultSuccessToast-10900.test.tsx new file mode 100644 index 0000000000..105446f211 --- /dev/null +++ b/packages/react/src/context/__tests__/ActionProvider.defaultSuccessToast-10900.test.tsx @@ -0,0 +1,148 @@ +/** + * 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. + */ + +/** + * objectui#10900 — the generic action success toast speaks the session's + * language through both React owners of an `ActionRunner`. + * + * The 2026-09-28 cloud E2E, browser in zh-CN, read "Action completed + * successfully" after a welcome CTA. That string is the runner's fallback for + * an action with no `successMessage` and no server message, and nothing + * translated it. `` and `useActionRunner` now install the + * session's `t` on the runner they build (`useActionRunnerTranslator`), so every + * host whose toasts come through either one gets the pack value. Measured by + * running a real action through a real provider under a real `I18nProvider`, + * and reading what the host's `onToast` receives. + * + * The author's `successMessage` is the control: it is the author's text in the + * author's language and must reach the toast verbatim under zh. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, useObjectTranslation } from '@object-ui/i18n'; +import type { ActionResult, ToastHandler } from '@object-ui/core'; +import { ActionProvider, useAction } from '../ActionContext'; +import { useActionRunner } from '../../hooks/useActionRunner'; + +afterEach(() => cleanup()); + +const EN = 'Action completed successfully'; +/** `actions.completedSuccessfully` in the zh pack. */ +const ZH = '操作已成功完成'; + +const ZH_CONFIG = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const; +const EN_CONFIG = { defaultLanguage: 'en', detectBrowserLanguage: false } as const; + +const SUCCEEDS = async (): Promise => ({ success: true }); + +function providerHarness(config: typeof ZH_CONFIG | typeof EN_CONFIG, onToast: ToastHandler) { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ); + return renderHook(() => ({ action: useAction(), i18n: useObjectTranslation() }), { wrapper }); +} + +describe(' — the default success toast (objectui#10900)', () => { + it('reads the zh pack value under a zh session', async () => { + const onToast = vi.fn(); + const { result } = providerHarness(ZH_CONFIG, onToast); + + await act(async () => { + await result.current.action.execute({ type: 'script', name: 'welcome_cta' }); + }); + + expect(onToast).toHaveBeenCalledWith(ZH, expect.objectContaining({ type: 'success' })); + expect(onToast).not.toHaveBeenCalledWith(EN, expect.anything()); + }); + + it('stays English under an en session', async () => { + const onToast = vi.fn(); + const { result } = providerHarness(EN_CONFIG, onToast); + + await act(async () => { + await result.current.action.execute({ type: 'script', name: 'welcome_cta' }); + }); + + expect(onToast).toHaveBeenCalledWith(EN, expect.objectContaining({ type: 'success' })); + }); + + it("the author's successMessage reaches the toast verbatim under zh", async () => { + const onToast = vi.fn(); + const { result } = providerHarness(ZH_CONFIG, onToast); + + await act(async () => { + await result.current.action.execute({ + type: 'script', + name: 'welcome_cta', + successMessage: 'Welcome aboard', + }); + }); + + expect(onToast).toHaveBeenCalledWith('Welcome aboard', expect.objectContaining({ type: 'success' })); + expect(onToast).not.toHaveBeenCalledWith(ZH, expect.anything()); + }); + + it('follows a language switch without rebuilding the runner', async () => { + const onToast = vi.fn(); + const { result } = providerHarness(EN_CONFIG, onToast); + const runnerBefore = result.current.action.runner; + + await act(async () => { + await result.current.i18n.changeLanguage('zh'); + }); + // The runner is keyed on `context` only; the switch must not have replaced + // it, so what is measured below is the translator reading the latest `t`. + expect(result.current.action.runner).toBe(runnerBefore); + + await act(async () => { + await result.current.action.execute({ type: 'script', name: 'welcome_cta' }); + }); + + expect(onToast).toHaveBeenLastCalledWith(ZH, expect.objectContaining({ type: 'success' })); + }); +}); + +describe('useActionRunner — the default success toast (objectui#10900)', () => { + function hookHarness(config: typeof ZH_CONFIG | typeof EN_CONFIG, onToast: ToastHandler) { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + return renderHook(() => useActionRunner({ context: {}, onToast }), { wrapper }); + } + + it('reads the zh pack value under a zh session', async () => { + const onToast = vi.fn(); + const { result } = hookHarness(ZH_CONFIG, onToast); + result.current.runner.registerHandler('script', SUCCEEDS); + + await act(async () => { + await result.current.execute({ type: 'script', name: 'welcome_cta' }); + }); + + expect(onToast).toHaveBeenCalledWith(ZH, expect.objectContaining({ type: 'success' })); + }); + + it('stays English under an en session', async () => { + const onToast = vi.fn(); + const { result } = hookHarness(EN_CONFIG, onToast); + result.current.runner.registerHandler('script', SUCCEEDS); + + await act(async () => { + await result.current.execute({ type: 'script', name: 'welcome_cta' }); + }); + + expect(onToast).toHaveBeenCalledWith(EN, expect.objectContaining({ type: 'success' })); + }); +}); diff --git a/packages/react/src/context/actionRunnerTranslator.ts b/packages/react/src/context/actionRunnerTranslator.ts new file mode 100644 index 0000000000..98d4abce77 --- /dev/null +++ b/packages/react/src/context/actionRunnerTranslator.ts @@ -0,0 +1,42 @@ +/** + * 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. + */ + +import { useEffect, useRef, useState } from 'react'; +import { useObjectTranslation } from '@object-ui/i18n'; + +/** What `ActionRunner.setTranslator` takes. */ +type ActionRunnerTranslate = (key: string, options: { defaultValue: string }) => string; + +/** + * The translator both React owners of an `ActionRunner` — `` + * and `useActionRunner` — install on the runner they build, so the text the + * runner writes itself (the generic success toast an action with no + * `successMessage` ends in) renders in the session's language instead of + * English. Internal to this package; not re-exported from its entry. + * + * The function is created once per mount and reads the latest `t` when it is + * CALLED, so a language switch reaches a runner that was built before it. Both + * owners rebuild their runner only when their `context` changes, and a + * translator captured at build time would keep answering in the language that + * was active then. The identity comes from `useState`'s initializer, not from a + * memo, so nothing depends on a `useMemo` result staying put (AGENTS.md #10). + */ +export function useActionRunnerTranslator(): ActionRunnerTranslate { + const { t } = useObjectTranslation(); + const latest = useRef(t); + // No dependency list: after every render, the ref holds that render's `t`. + // Actions run from user events, which arrive after the commit. + useEffect(() => { + latest.current = t; + }); + const [translate] = useState(() => { + const read: ActionRunnerTranslate = (key, options) => String(latest.current(key, options)); + return read; + }); + return translate; +} diff --git a/packages/react/src/hooks/useActionRunner.ts b/packages/react/src/hooks/useActionRunner.ts index 3878c1790c..c51d53a1a0 100644 --- a/packages/react/src/hooks/useActionRunner.ts +++ b/packages/react/src/hooks/useActionRunner.ts @@ -19,6 +19,7 @@ import { type ParamCollectionHandler, type ResultDialogHandler, } from '@object-ui/core'; +import { useActionRunnerTranslator } from '../context/actionRunnerTranslator.js'; export interface UseActionRunnerOptions { context?: ActionContext; @@ -68,9 +69,12 @@ export function useActionRunner( const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); + // The runner's own copy (the generic success toast) in the session's language. + const translate = useActionRunnerTranslator(); const runner = useMemo(() => { const r = new ActionRunner(context); + r.setTranslator(translate); if (onConfirm) r.setConfirmHandler(onConfirm); if (onToast) r.setToastHandler(onToast); if (onModal) r.setModalHandler(onModal);