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
19 changes: 19 additions & 0 deletions .changeset/popup-copy-english-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@object-ui/app-shell": patch
"@object-ui/i18n": patch
---

The console server-action wrapper's `opensInNewTab` choreography no longer
ships hard-coded bilingual Chinese/English copy (objectui#3321, AGENTS.md
Commandment #-1): the pre-opened SSO spinner tab (title + body) and the
popup-blocked toast (title, description, action label) are now localized
through new `console.serverAction.*` keys in `@object-ui/i18n`, added at full
parity across all eleven locale packs.

`createConsoleServerActionHandler` gains an optional i18next-style `t` option
(`t(key, englishDefault)`) — the wrapper is a plain function, so the translate
function is injected from the two hook-context call sites
(`useConsoleActionRuntime`, `RecordDetailView`) via `useObjectTranslation`.
When omitted (tests / standalone), every string falls back to its English
default; no non-English copy remains in code. Locale strings are HTML-escaped
before being written into the spinner document.
8 changes: 6 additions & 2 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -507,14 +507,18 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
// memoized once) while the config thunks read the latest object scope and
// refresh callback — the factory's in-flight guard only spans invocations of
// the same instance.
const serverActionEnvRef = useRef({ objApiName, refresh });
serverActionEnvRef.current = { objApiName, refresh };
const serverActionEnvRef = useRef({ objApiName, refresh, t });
serverActionEnvRef.current = { objApiName, refresh, t };
const serverActionHandler = useMemo(
() => createConsoleServerActionHandler({
fetch: authFetch,
baseUrl: () => import.meta.env.VITE_SERVER_URL || '',
resolveObject: () => serverActionEnvRef.current.objApiName,
onRefresh: () => serverActionEnvRef.current.refresh(),
// Read through the env ref so the spinner-tab / popup-blocked copy
// follows a language switch without invalidating the handler instance
// (objectui#3321).
t: (key, englishDefault) => String(serverActionEnvRef.current.t(key, englishDefault)),
}),
[authFetch],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ describe('redirectUrl convention', () => {

expect(openSpy).toHaveBeenCalledWith('https://example.test/sso', '_blank');
expect(toast).toHaveBeenCalledTimes(1); // popup blocked → one-click fallback
// Default (no injected `t`) copy is the English fallback (objectui#3321).
expect(toast).toHaveBeenCalledWith('Popup blocked', expect.objectContaining({
description: 'Your browser blocked the new tab from opening.',
action: expect.objectContaining({ label: 'Open in new tab' }),
}));
});

it('closes the optimistically pre-opened tab when the handler returns no redirectUrl', async () => {
Expand All @@ -174,6 +179,75 @@ describe('redirectUrl convention', () => {
});
});

describe('user-facing copy (objectui#3321)', () => {
it('the spinner tab defaults to English-only copy — no CJK in code (Commandment #-1)', async () => {
const tab = makeTab();
vi.spyOn(window, 'open').mockReturnValue(tab as any);
const { handler } = makeHandler();

await handler({
type: 'script', name: 'sso_as_owner', opensInNewTab: true,
newTabUrl: '/sso-open/{recordId}', params: { recordId: 'e1' },
} as any);

expect(tab.document.write).toHaveBeenCalledTimes(1);
const html = (tab.document.write as any).mock.calls[0][0] as string;
expect(html).toContain('<title>Opening…</title>');
expect(html).toContain('Opening… this may take a moment.');
// The commandment pin: the copy shipped from CODE carries no CJK. Chinese
// lives in `@object-ui/i18n`'s zh locale pack and arrives via `t`.
expect(html).not.toMatch(/[\u3000-\u30ff\u4e00-\u9fff]/);
});

it('an injected `t` localizes the spinner tab via console.serverAction.* keys', async () => {
const tab = makeTab();
vi.spyOn(window, 'open').mockReturnValue(tab as any);
const { handler } = makeHandler({ t: (key: string) => `x:${key}` });

await handler({
type: 'script', name: 'sso_as_owner', opensInNewTab: true,
newTabUrl: '/sso-open/{recordId}', params: { recordId: 'e1' },
} as any);

const html = (tab.document.write as any).mock.calls[0][0] as string;
expect(html).toContain('<title>x:console.serverAction.openingTitle</title>');
expect(html).toContain('x:console.serverAction.openingBody');
});

it('an injected `t` localizes the popup-blocked toast via console.serverAction.* keys', async () => {
vi.spyOn(window, 'open').mockReturnValue(null);
const { handler } = makeHandler({
t: (key: string) => `x:${key}`,
fetch: okFetch({
success: true,
data: { success: true, data: { redirectUrl: 'https://example.test/sso' } },
}) as any,
});

await handler({ type: 'script', name: 'open_env' } as any);

expect(toast).toHaveBeenCalledWith('x:console.serverAction.popupBlockedTitle', expect.objectContaining({
description: 'x:console.serverAction.popupBlockedDescription',
action: expect.objectContaining({ label: 'x:console.serverAction.popupBlockedAction' }),
}));
});

it('locale strings are HTML-escaped before entering the spinner document', async () => {
const tab = makeTab();
vi.spyOn(window, 'open').mockReturnValue(tab as any);
const { handler } = makeHandler({ t: (_key: string, englishDefault: string) => `<b>&${englishDefault}` });

await handler({
type: 'script', name: 'sso_as_owner', opensInNewTab: true,
newTabUrl: '/sso-open/{recordId}', params: { recordId: 'e1' },
} as any);

const html = (tab.document.write as any).mock.calls[0][0] as string;
expect(html).toContain('&lt;b&gt;&amp;Opening…');
expect(html).not.toContain('<b>&Opening…');
});
});

describe('failure paths close the pre-opened tab', () => {
it('on a failed dispatch', async () => {
const tab = makeTab();
Expand Down
47 changes: 37 additions & 10 deletions packages/app-shell/src/utils/consoleServerAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ import {
type ServerActionRecordIdResolver,
} from '@object-ui/core';

/**
* i18next-style translate function: `t(key, englishDefault)` returns the
* translation for `key` in the active locale, or `englishDefault` when the
* key is missing. Injected from a hook context (`useObjectTranslation().t`)
* because this wrapper is a plain function and cannot call hooks itself.
*/
export type ConsoleServerActionTranslate = (key: string, englishDefault: string) => string;

/** No-`t` fallback (tests / standalone): every string is its English default. */
const defaultTranslate: ConsoleServerActionTranslate = (_key, englishDefault) => englishDefault;

export interface ConsoleServerActionOptions {
/** Authenticated fetch wrapper (Bearer + tenant + cookies). */
fetch: ServerActionFetch;
Expand All @@ -63,6 +74,19 @@ export interface ConsoleServerActionOptions {
resolveRecordId?: ServerActionRecordIdResolver;
/** Data invalidation, invoked per the action's `refreshAfter` semantics. */
onRefresh: () => void;
/**
* Localizes the user-facing copy this wrapper owns (the SSO spinner tab,
* the popup-blocked toast) via the `console.serverAction.*` keys in
* `@object-ui/i18n`. Optional: when omitted the English defaults apply —
* per AGENTS.md Commandment #-1, no non-English copy lives in code
* (objectui#3321).
*/
t?: ConsoleServerActionTranslate;
}

/** Minimal HTML escape for locale strings interpolated into the spinner document. */
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

/**
Expand All @@ -75,11 +99,13 @@ export interface ConsoleServerActionOptions {
* fallback would fire, and the CURRENT tab would navigate to the now-consumed
* SSO URL (the double-navigation bug).
*/
function preOpenSpinnerTab(): Window | null {
function preOpenSpinnerTab(t: ConsoleServerActionTranslate): Window | null {
try {
const tab = window.open('about:blank', '_blank');
if (tab) {
tab.document.write('<!doctype html><meta charset="utf-8"><title>正在打开… Opening…</title><body style="margin:0;font-family:system-ui,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;gap:16px;color:#4b5563"><div style="width:28px;height:28px;border:3px solid #e5e7eb;border-top-color:#6366f1;border-radius:50%;animation:s .8s linear infinite"></div><div>正在为你打开环境…</div><style>@keyframes s{to{transform:rotate(360deg)}}</style></body>');
const title = escapeHtml(t('console.serverAction.openingTitle', 'Opening…'));
const body = escapeHtml(t('console.serverAction.openingBody', 'Opening… this may take a moment.'));
tab.document.write(`<!doctype html><meta charset="utf-8"><title>${title}</title><body style="margin:0;font-family:system-ui,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;gap:16px;color:#4b5563"><div style="width:28px;height:28px;border:3px solid #e5e7eb;border-top-color:#6366f1;border-radius:50%;animation:s .8s linear infinite"></div><div>${body}</div><style>@keyframes s{to{transform:rotate(360deg)}}</style></body>`);
tab.document.close();
}
return tab;
Expand All @@ -99,7 +125,7 @@ function closeTab(tab: Window | null): void {
* and, when the popup blocker eats it, offer a one-click toast instead of
* silently hijacking the current tab.
*/
function openInTab(preOpenedTab: Window | null, url: string): void {
function openInTab(preOpenedTab: Window | null, url: string, t: ConsoleServerActionTranslate): void {
if (preOpenedTab) {
try {
preOpenedTab.location.href = url;
Expand All @@ -114,9 +140,9 @@ function openInTab(preOpenedTab: Window | null, url: string): void {
// null return would always trip the toast fallback.
try { popup = window.open(url, '_blank'); } catch { popup = null; }
if (!popup) {
toast('浏览器拦截了弹窗 / Popup blocked', {
description: '点击在新标签页打开环境',
action: { label: '打开环境', onClick: () => { try { window.open(url, '_blank'); } catch { window.location.href = url; } } },
toast(t('console.serverAction.popupBlockedTitle', 'Popup blocked'), {
description: t('console.serverAction.popupBlockedDescription', 'Your browser blocked the new tab from opening.'),
action: { label: t('console.serverAction.popupBlockedAction', 'Open in new tab'), onClick: () => { try { window.open(url, '_blank'); } catch { window.location.href = url; } } },
duration: 10000,
});
}
Expand All @@ -131,6 +157,7 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption
onRefresh: opts.onRefresh,
});
const resolveRecordId = opts.resolveRecordId ?? resolveServerActionRecordId;
const t = opts.t ?? defaultTranslate;

return async (action: ActionDef, context?: ActionContext) => {
// ── Zero-roundtrip fast path ────────────────────────────────────────
Expand All @@ -151,18 +178,18 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption
if (recordId == null) {
return { success: false, error: 'This action runs on a single record — no record id available.' };
}
const preOpenedTab = preOpenSpinnerTab();
const preOpenedTab = preOpenSpinnerTab(t);
// Absolute URL required: the pre-opened tab is an about:blank document,
// so a bare-relative href has no reliable resolution base.
const directUrl = `${opts.baseUrl() || window.location.origin}${newTabUrl.replace('{recordId}', encodeURIComponent(String(recordId)))}`;
openInTab(preOpenedTab, directUrl);
openInTab(preOpenedTab, directUrl, t);
if (action.refreshAfter === true) opts.onRefresh();
return { success: true };
}

// Popup-blocker workaround: pre-open about:blank synchronously before the
// await so the user-gesture context is preserved.
const preOpenedTab = action.opensInNewTab ? preOpenSpinnerTab() : null;
const preOpenedTab = action.opensInNewTab ? preOpenSpinnerTab(t) : null;
try {
const result = await dispatch(action, context);
if (!result.success) {
Expand All @@ -182,7 +209,7 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption
? (payload as { redirectUrl: string }).redirectUrl
: null;
if (redirectUrl) {
openInTab(preOpenedTab, redirectUrl);
openInTab(preOpenedTab, redirectUrl, t);
} else {
// Handler didn't return a redirectUrl — close the empty tab we
// optimistically pre-opened so the user isn't left with about:blank.
Expand Down
8 changes: 6 additions & 2 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -814,8 +814,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
// `action.recordId`; header/more actions carry none and use this page's id.
// The env ref keeps the handler instance stable across renders (authFetch is
// memoized once) while the thunks read the live record/object.
const serverActionEnvRef = useRef({ objectName, pureRecordId, notifyRecordChanged });
serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged };
const serverActionEnvRef = useRef({ objectName, pureRecordId, notifyRecordChanged, t });
serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged, t };
const serverActionHandler = useMemo(
() => createConsoleServerActionHandler({
fetch: authFetch,
Expand All @@ -825,6 +825,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
recordId: (action as { recordId?: unknown }).recordId ?? serverActionEnvRef.current.pureRecordId ?? undefined,
}),
onRefresh: () => serverActionEnvRef.current.notifyRecordChanged(),
// Read through the env ref so the spinner-tab / popup-blocked copy
// follows a language switch without invalidating the handler instance
// (objectui#3321).
t: (key, englishDefault) => String(serverActionEnvRef.current.t(key, englishDefault)),
}),
[authFetch],
);
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,13 @@ const ar = {
retry: "إعادة المحاولة",
retrying: "جاري إعادة المحاولة…",
},
serverAction: {
openingTitle: "جارٍ الفتح…",
openingBody: "جارٍ الفتح… قد يستغرق ذلك لحظة.",
popupBlockedTitle: "تم حظر النافذة المنبثقة",
popupBlockedDescription: "حظر المتصفح فتح علامة التبويب الجديدة.",
popupBlockedAction: "فتح في علامة تبويب جديدة",
},
shortcuts: {
title: "اختصارات لوحة المفاتيح",
description: "مرجع سريع لجميع اختصارات لوحة المفاتيح المتاحة.",
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,13 @@ const de = {
retry: "Erneut versuchen",
retrying: "Wird wiederholt…",
},
serverAction: {
openingTitle: "Wird geöffnet…",
openingBody: "Wird geöffnet… das kann einen Moment dauern.",
popupBlockedTitle: "Pop-up blockiert",
popupBlockedDescription: "Der Browser hat das Öffnen des neuen Tabs blockiert.",
popupBlockedAction: "In neuem Tab öffnen",
},
shortcuts: {
title: "Tastenkürzel",
description: "Kurzreferenz für alle verfügbaren Tastenkürzel.",
Expand Down
9 changes: 9 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1414,6 +1414,15 @@ const en = {
retry: 'Retry',
retrying: 'Retrying…',
},
// Copy owned by the console server-action wrapper (`consoleServerAction.ts`,
// objectui#3321): the pre-opened SSO spinner tab and the popup-blocked toast.
serverAction: {
openingTitle: 'Opening…',
openingBody: 'Opening… this may take a moment.',
popupBlockedTitle: 'Popup blocked',
popupBlockedDescription: 'Your browser blocked the new tab from opening.',
popupBlockedAction: 'Open in new tab',
},
shortcuts: {
title: 'Keyboard Shortcuts',
description: 'Quick reference for all available keyboard shortcuts.',
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,13 @@ const es = {
retry: "Reintentar",
retrying: "Reintentando…",
},
serverAction: {
openingTitle: "Abriendo…",
openingBody: "Abriendo… esto puede tardar un momento.",
popupBlockedTitle: "Ventana emergente bloqueada",
popupBlockedDescription: "El navegador bloqueó la apertura de la nueva pestaña.",
popupBlockedAction: "Abrir en una pestaña nueva",
},
shortcuts: {
title: "Atajos de teclado",
description: "Referencia rápida de todos los atajos de teclado disponibles.",
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,13 @@ const fr = {
retry: "Réessayer",
retrying: "Nouvelle tentative…",
},
serverAction: {
openingTitle: "Ouverture…",
openingBody: "Ouverture… cela peut prendre un moment.",
popupBlockedTitle: "Fenêtre contextuelle bloquée",
popupBlockedDescription: "Le navigateur a bloqué l'ouverture du nouvel onglet.",
popupBlockedAction: "Ouvrir dans un nouvel onglet",
},
shortcuts: {
title: "Raccourcis clavier",
description: "Référence rapide de tous les raccourcis clavier disponibles.",
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,13 @@ const ja = {
retry: "再試行",
retrying: "再試行中…",
},
serverAction: {
openingTitle: "開いています…",
openingBody: "開いています…しばらくお待ちください。",
popupBlockedTitle: "ポップアップがブロックされました",
popupBlockedDescription: "ブラウザが新しいタブを開くのをブロックしました。",
popupBlockedAction: "新しいタブで開く",
},
shortcuts: {
title: "キーボードショートカット",
description: "利用可能なすべてのキーボードショートカットのクイックリファレンス。",
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,13 @@ const ko = {
retry: "재시도",
retrying: "재시도 중…",
},
serverAction: {
openingTitle: "여는 중…",
openingBody: "여는 중… 잠시 기다려 주세요.",
popupBlockedTitle: "팝업이 차단되었습니다",
popupBlockedDescription: "브라우저가 새 탭 열기를 차단했습니다.",
popupBlockedAction: "새 탭에서 열기",
},
shortcuts: {
title: "키보드 단축키",
description: "사용 가능한 모든 키보드 단축키 빠른 참조.",
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,13 @@ const pt = {
retry: "Tentar novamente",
retrying: "Tentando novamente…",
},
serverAction: {
openingTitle: "Abrindo…",
openingBody: "Abrindo… isso pode levar um momento.",
popupBlockedTitle: "Pop-up bloqueado",
popupBlockedDescription: "O navegador bloqueou a abertura da nova aba.",
popupBlockedAction: "Abrir em nova aba",
},
shortcuts: {
title: "Atalhos de teclado",
description: "Referência rápida de todos os atalhos de teclado disponíveis.",
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,13 @@ const ru = {
retry: "Повторить",
retrying: "Повтор…",
},
serverAction: {
openingTitle: "Открытие…",
openingBody: "Открытие… это может занять некоторое время.",
popupBlockedTitle: "Всплывающее окно заблокировано",
popupBlockedDescription: "Браузер заблокировал открытие новой вкладки.",
popupBlockedAction: "Открыть в новой вкладке",
},
shortcuts: {
title: "Горячие клавиши",
description: "Краткий справочник по всем доступным горячим клавишам.",
Expand Down
Loading
Loading