diff --git a/.changeset/13397-flow-row-guard.md b/.changeset/13397-flow-row-guard.md new file mode 100644 index 0000000000..cf41b9df31 --- /dev/null +++ b/.changeset/13397-flow-row-guard.md @@ -0,0 +1,10 @@ +--- +'@object-ui/i18n': patch +'@object-ui/app-shell': patch +--- + +A record-scoped flow action launched from a list toolbar with 0 or several rows selected is refused before its param dialog opens, and its refusal is translated (objectui#13397). + +The console's `flowHandler` now carries `refuseBeforeInput`, the early refusal objectui#13384 gave the `script` handler, answered by the same resolver its dispatch runs. The runner asks it before the confirmation and the param dialog, so "select a row first" and "select exactly one row" show in the session's language before the user fills anything in, and nothing is sent. With exactly one row the flow behaves as before. A direct call that is refused at dispatch time is shown through the refusal's pack key too. + +The console server-action wrapper's `newTabUrl` refusal ("no record id available") now has a pack key, `console.serverAction.noRecordId`, defined in every shipped pack, and the wrapper's fast path shows the resolver's own refusal through its pack key. diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx new file mode 100644 index 0000000000..5e5c095d80 --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.flowRowGuard-13397.test.tsx @@ -0,0 +1,204 @@ +/** + * 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#13397 — a record-scoped `type: 'flow'` action launched from a list + * toolbar with a row count other than 1 is refused BEFORE its param dialog, + * with a translated toast and no request. objectui#13384 gave the `script` + * handler that early refusal (`refuseBeforeInput`); the console `flowHandler` + * ran the same resolver only at dispatch time, after the whole dialog, and + * showed its English `error`. + * + * The session here reads zh-CN: the translator answers from the shipped zh + * pack, so a refusal that reaches the toast untranslated reads English and + * fails the text pin. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor, renderHook, act } from '@testing-library/react'; +import React from 'react'; + +const navigateSpy = vi.fn(); +vi.mock('react-router-dom', () => ({ useNavigate: () => navigateSpy })); + +const authFetchSpy = vi.fn(); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => authFetchSpy, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => { + const { builtInLocales } = await import('@object-ui/i18n/locales'); + // The zh-CN pack, read by dotted key; a key it lacks answers its default. + const zhT = (key: string, options?: { defaultValue?: string }) => { + const hit = key.split('.').reduce( + (node, part) => (node && typeof node === 'object' ? (node as Record)[part] : undefined), + builtInLocales.zh, + ); + return typeof hit === 'string' ? hit : String(options?.defaultValue ?? key); + }; + return { + ...(await importOriginal>()), + useObjectLabel: () => ({ + fieldLabel: (_o: unknown, _n: unknown, l: string) => l, + fieldOptionLabel: (_o: unknown, _f: unknown, _v: unknown, l: string) => l, + actionParamText: (_o: unknown, _a: unknown, _p: unknown, _attr: unknown, fallback: string) => fallback, + actionParamOptionLabel: (_o: unknown, _a: unknown, _p: unknown, _v: unknown, fallback: string) => fallback, + actionDescription: (_o: unknown, _a: unknown, fallback: string) => fallback, + }), + useObjectTranslation: () => ({ t: zhT, language: 'zh' }), + }; +}); + +vi.mock('../useActionModal', () => ({ + useActionModal: () => ({ + modalHandler: vi.fn(async () => ({ success: true })), + modalElement: null, + closeModal: () => {}, + resolveModalTarget: vi.fn(async () => null), + }), +})); + +// The param dialog renders a marker while it is open — the "did a dialog open" +// probe. The other dialogs stay inert. +vi.mock('../../views/ActionParamDialog', () => ({ + ActionParamDialog: ({ state }: { state: { open?: boolean } }) => + state?.open ?
: null, +})); +vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null })); + +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }) })); + +import { toast } from 'sonner'; +import { useConsoleActionRuntime, ConsoleActionRuntimeProvider } from '../useConsoleActionRuntime'; +import { useAction } from '@object-ui/react'; +import { resolveServerActionRecordId, type ActionContext, type ActionDef } from '@object-ui/core'; + +const ZH_SELECT_ROW_FIRST = '此操作针对单条记录,请先选择一行。'; +const ZH_SELECT_EXACTLY_ONE_ROW = '此操作针对单条记录,请只选择一行。'; + +/** A record-scoped flow with a param dialog, mounted on rows AND the toolbar. */ +const REASSIGN = { + type: 'flow', + name: 'reassign', + target: 'reassign_wizard', + locations: ['list_item', 'list_toolbar'], + params: [{ name: 'owner', label: 'Owner', type: 'text', required: true }], +}; +const launch = (extra: Record = {}): ActionDef => ({ ...JSON.parse(JSON.stringify(REASSIGN)), ...extra }); +const rowsContext = (rows: Record[]): ActionContext => ({ selectedRecords: rows }); +const toastError = vi.mocked(toast.error); + +beforeEach(() => { + authFetchSpy.mockReset(); + toastError.mockClear(); + vi.mocked(toast.success).mockClear(); +}); + +describe('flowHandler.refuseBeforeInput — the resolver its dispatch runs (objectui#13397)', () => { + const runtime = () => renderHook(() => useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'inv' })).result.current; + + it.each([ + ['0 rows', [] as Record[], 'actions.selectRowFirst'], + ['2 rows', [{ id: 'a' }, { id: 'b' }], 'actions.selectExactlyOneRow'], + ])('%s: answers the resolver\'s refusal with its pack key', (_label, rows, errorKey) => { + const context = rowsContext(rows); + const rule = resolveServerActionRecordId(launch(), context); + + expect(runtime().flowHandler.refuseBeforeInput?.(launch(), context)).toEqual({ error: rule.error, errorKey }); + }); + + it('1 row: no refusal', () => { + expect(runtime().flowHandler.refuseBeforeInput?.(launch(), rowsContext([{ id: 'a' }]))) + .toBeUndefined(); + }); + + it('control: an object-level toolbar flow with 0 rows is not refused', () => { + expect(runtime().flowHandler.refuseBeforeInput?.( + launch({ locations: ['list_toolbar'] }), + rowsContext([]), + )).toBeUndefined(); + }); + + it('control: a dialog that itself collects `recordId` is not judged before it runs', () => { + const collectsRecordId = launch({ params: [{ name: 'recordId', label: 'Record', type: 'text' }] }); + + expect(runtime().flowHandler.refuseBeforeInput?.(collectsRecordId, rowsContext([]))) + .toBeUndefined(); + }); +}); + +describe('a toolbar flow launch through the console runtime (objectui#13397)', () => { + function Probe({ rows, action }: { rows: Record[]; action: ActionDef }) { + const { execute, updateContext } = useAction(); + return ( + + ); + } + const mount = (rows: Record[], action: ActionDef = launch()) => { + render( + + + , + ); + fireEvent.click(screen.getByText('run-flow')); + }; + + it.each([ + ['0 rows', [] as Record[], ZH_SELECT_ROW_FIRST], + ['2 rows', [{ id: 'a' }, { id: 'b' }], ZH_SELECT_EXACTLY_ONE_ROW], + ])('%s: refused before the param dialog, with a zh-CN toast and no request', async (_label, rows, zh) => { + mount(rows); + + await waitFor(() => expect(toastError).toHaveBeenCalledWith(zh)); + expect(screen.queryByTestId('param-dialog')).toBeNull(); + expect(authFetchSpy).not.toHaveBeenCalled(); + }); + + it('1 row: the param dialog opens, as today, and nothing is refused', async () => { + mount([{ id: 'rec_1' }]); + + await screen.findByTestId('param-dialog'); + expect(toastError).not.toHaveBeenCalled(); + expect(authFetchSpy).not.toHaveBeenCalled(); + }); + + it('control: an object-level toolbar flow with 0 rows still opens its dialog', async () => { + mount([], launch({ locations: ['list_toolbar'] })); + + await screen.findByTestId('param-dialog'); + expect(toastError).not.toHaveBeenCalled(); + }); +}); + +describe('flowHandler called directly: the dispatch-time refusal is translated (objectui#13397)', () => { + it.each([ + ['0 rows', [] as Record[], ZH_SELECT_ROW_FIRST], + ['2 rows', [{ id: 'a' }, { id: 'b' }], ZH_SELECT_EXACTLY_ONE_ROW], + ])('%s: refuses in zh-CN through the pack key, with no request', async (_label, rows, zh) => { + const { result } = renderHook(() => useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'inv' })); + + let res: unknown; + await act(async () => { + res = await result.current.flowHandler(launch({ params: {} }), rowsContext(rows)); + }); + + expect(res).toEqual({ success: false, error: zh }); + expect(authFetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 6fbfec4366..31c577f99f 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -37,6 +37,7 @@ import type { NavigationHandler, ParamCollectionHandler, ResultDialogHandler, + ServerActionHandler, ToastHandler, } from '@object-ui/core'; import { actionErrorDetail, captureUpdateUndoData, resolveRecordIdParamSeed, resolveServerActionRecordId } from '@object-ui/core'; @@ -92,7 +93,14 @@ export interface ConsoleActionRuntime { // narrower restatement that nothing could catch while the tests calling it // with two arguments were unchecked (objectui#4040). apiHandler: (action: ActionDef, context?: ActionContext) => Promise; - flowHandler: (action: ActionDef, context?: ActionContext) => Promise; + /** + * Carries `refuseBeforeInput` (objectui#13397), so the runner refuses a + * single-record flow launched with 0 or several rows selected before its + * param dialog opens — the member `serverActionHandler` carries since + * objectui#13384, answered by the same resolver. + */ + flowHandler: ((action: ActionDef, context?: ActionContext) => Promise) + & Pick; serverActionHandler: (action: ActionDef, context?: ActionContext) => Promise; /** `type: 'modal'` — opens `target` as a page/object form, else runs the action server-side. */ modalActionHandler: (action: ActionDef, context?: ActionContext) => Promise; @@ -636,8 +644,11 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons * * `context` is the shared ActionRunner context (registered handlers are * invoked as `handler(action, runnerContext)`). + * + * This is the dispatch only; the registered `flowHandler` below wraps it + * with the early row-count refusal (objectui#13397). */ - const flowHandler = useCallback(async (action: ActionDef, context?: ActionContext): Promise => { + const flowDispatch = useCallback(async (action: ActionDef, context?: ActionContext): Promise => { const flowName = action.target || action.name; if (!flowName) { return { success: false, error: 'No flow target provided for flow action' }; @@ -659,8 +670,20 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // ID"); a pure object-level toolbar flow keeps triggering with no record. // This handler used to hold a second copy of that rule, worded for flows. // ⛔ Never re-add one here: a rule written twice drifts. + // + // A launch through the runner is refused before its dialog by + // `flowHandler.refuseBeforeInput` below; this check stays for a caller + // that invokes the handler directly. Its refusal is shown through its + // pack key, as the runner shows the early one (objectui#13397), with + // the English source as the default and the fallback for an empty + // answer. const resolution = resolveServerActionRecordId(action, context); - if (resolution.error) return { success: false, error: resolution.error }; + if (resolution.error) { + const error = resolution.errorKey + ? String(t(resolution.errorKey, { defaultValue: resolution.error })) || resolution.error + : resolution.error; + return { success: false, error }; + } const recordId = resolution.recordId; if (recordId != null && params.recordId == null) params.recordId = recordId; // Ask the store for the object type BEFORE the lookup, as the param @@ -710,7 +733,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons } catch (error) { return { success: false, error: (error as Error).message }; } - }, [authFetch, objApiName, objects, metadata, refresh, i18n, language]); + }, [authFetch, objApiName, objects, metadata, refresh, i18n, language, t]); // Server-side action handler — POST /api/v1/actions/{object}/{action}, built // from @object-ui/core's `createServerActionHandler` via the shared console @@ -746,6 +769,30 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons [authFetch], ); + /** + * The registered flow handler: {@link flowDispatch}, plus the early + * row-count refusal the runner asks before its confirmation and param + * dialog (`ActionRunnerHandler`'s `refuseBeforeInput`, objectui#13397). + * Without it a single-record flow launched from a list toolbar with 0 or + * several rows ran its whole param dialog and was refused only after + * Confirm, in English. + * + * The member is `serverActionHandler`'s own — core's + * `createServerActionHandler` builds it (objectui#13384) and the console + * wrapper forwards it. That handler is built here with no `resolveRecordId`, + * so its member runs `resolveServerActionRecordId`, the resolver + * `flowDispatch` runs, and skips an action whose dialog itself collects + * `recordId`. ⛔ Never write a flow copy of that rule: a rule written twice + * drifts. + */ + const flowHandler = useMemo( + () => Object.assign( + (action: ActionDef, context?: ActionContext) => flowDispatch(action, context), + { refuseBeforeInput: serverActionHandler.refuseBeforeInput }, + ), + [flowDispatch, serverActionHandler], + ); + // Client-side modal transport, shared with RecordDetailView so a // `type: 'modal'` action behaves the SAME on a list page, an SDUI page, a // declared-actions bar and a record page. Before this, only RecordDetailView diff --git a/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx b/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx index eec8547ee0..4ebd1343cb 100644 --- a/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx +++ b/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx @@ -21,7 +21,8 @@ vi.mock('sonner', () => { }); import { toast } from 'sonner'; -import { ActionRunner } from '@object-ui/core'; +import { ActionRunner, type ActionDef } from '@object-ui/core'; +import { builtInLocales } from '@object-ui/i18n/locales'; import { createConsoleServerActionHandler } from '../consoleServerAction'; /** A pre-openable tab stub with the surface the wrapper drives. */ @@ -492,3 +493,55 @@ describe('the early record-id refusal is forwarded to the runner (objectui#13384 }); }); }); + +describe('the newTabUrl fast path refuses in the session language (objectui#13397)', () => { + // The zh-CN pack as the wrapper's `t`, read by dotted key; a key it lacks + // answers the English default, as `useObjectTranslation().t` does. + const zhT = (key: string, englishDefault: string) => { + const hit = key.split('.').reduce( + (node, part) => (node && typeof node === 'object' ? (node as Record)[part] : undefined), + builtInLocales.zh, + ); + return typeof hit === 'string' ? hit : englishDefault; + }; + const SSO: ActionDef = { type: 'script', name: 'sso_as_owner', opensInNewTab: true, newTabUrl: '/sso-open/{recordId}' }; + + it('no resolvable record id: the refusal reads zh-CN, with no tab', async () => { + const openSpy = vi.spyOn(window, 'open'); + const { handler, fetch } = makeHandler({ t: zhT }); + + const res = await handler({ ...SSO }); + + expect(res).toEqual({ success: false, error: '此操作针对单条记录,但没有可用的记录 ID。' }); + expect(openSpy).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('control: with no `t` the same refusal stays English', async () => { + const { handler } = makeHandler(); + + expect(await handler({ ...SSO })).toEqual({ + success: false, + error: 'This action runs on a single record — no record id available.', + }); + }); + + it("the resolver's own refusal reads zh-CN through its pack key, with no tab", async () => { + const openSpy = vi.spyOn(window, 'open'); + const { handler } = makeHandler({ t: zhT }); + + const res = await handler({ ...SSO }, { selectedRecords: [{ id: 'a' }, { id: 'b' }] }); + + expect(res).toEqual({ success: false, error: '此操作针对单条记录,请只选择一行。' }); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('control: with one row the fast path still opens the tab', async () => { + const tab = makeTab(); + vi.spyOn(window, 'open').mockReturnValue(tab); + const { handler } = makeHandler({ t: zhT }); + + expect(await handler({ ...SSO }, { selectedRecords: [{ id: 'e1' }] })).toEqual({ success: true }); + expect(tab.location.href).toBe('https://api.test/sso-open/e1'); + }); +}); diff --git a/packages/app-shell/src/utils/consoleServerAction.ts b/packages/app-shell/src/utils/consoleServerAction.ts index 9e6656bc43..783ac77bdc 100644 --- a/packages/app-shell/src/utils/consoleServerAction.ts +++ b/packages/app-shell/src/utils/consoleServerAction.ts @@ -82,7 +82,8 @@ export interface ConsoleServerActionOptions { onRefresh: () => void; /** * Localizes the user-facing copy this wrapper owns (the SSO spinner tab, - * the popup-blocked toast) via the `console.serverAction.*` keys in + * the popup-blocked toast, the `newTabUrl` fast path's "no record id" + * refusal) 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). @@ -186,13 +187,20 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption // (multi-select, empty selection) never flashes a tab. const newTabUrl = typeof action.newTabUrl === 'string' ? action.newTabUrl : ''; if (action.opensInNewTab && newTabUrl) { + // Both refusals below are shown in the session's language + // (objectui#13397): the resolver's through the pack key it names + // (objectui#13384), this wrapper's own through `console.serverAction.*`. const resolution = resolveRecordId(action, context); if (resolution.error) { - return { success: false, error: resolution.error }; + const error = resolution.errorKey ? t(resolution.errorKey, resolution.error) : resolution.error; + return { success: false, error: error || resolution.error }; } const recordId = resolution.recordId; if (recordId == null) { - return { success: false, error: 'This action runs on a single record — no record id available.' }; + return { + success: false, + error: t('console.serverAction.noRecordId', 'This action runs on a single record — no record id available.'), + }; } const preOpenedTab = preOpenSpinnerTab(t); // Absolute URL required: the pre-opened tab is an about:blank document, diff --git a/packages/i18n/src/__tests__/consoleNoRecordId-13397.test.ts b/packages/i18n/src/__tests__/consoleNoRecordId-13397.test.ts new file mode 100644 index 0000000000..863e73997d --- /dev/null +++ b/packages/i18n/src/__tests__/consoleNoRecordId-13397.test.ts @@ -0,0 +1,40 @@ +/** + * 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#13397 — the console server-action wrapper's `newTabUrl` refusal + * ("no record id available") has a pack key, `console.serverAction.noRecordId`, + * in every shipped pack. `createConsoleServerActionHandler` used to return it + * as a hard-coded English string. The zh-CN words are pinned here; every other + * pack is held to defining the key with its own text. + */ +import { describe, it, expect } from 'vitest'; +import { builtInLocales } from '../locales/index'; + +const noRecordIdOf = (pack: unknown) => + (pack as { console: { serverAction: Record } }).console.serverAction.noRecordId; + +describe('objectui#13397 — the newTabUrl "no record id" refusal is translated', () => { + it('zh-CN reads it in Chinese', () => { + expect(noRecordIdOf(builtInLocales.zh)).toBe('此操作针对单条记录,但没有可用的记录 ID。'); + }); + + it('en holds the English source the wrapper falls back to', () => { + expect(noRecordIdOf(builtInLocales.en)).toBe('This action runs on a single record — no record id available.'); + }); + + for (const [code, pack] of Object.entries(builtInLocales)) { + if (code === 'en') continue; + it(`${code} defines it with its own text`, () => { + const value = noRecordIdOf(pack); + expect(typeof value, `${code} console.serverAction.noRecordId`).toBe('string'); + expect((value as string).trim(), `${code} console.serverAction.noRecordId`).not.toBe(''); + expect(value, `${code} console.serverAction.noRecordId`).not.toBe(noRecordIdOf(builtInLocales.en)); + }); + } +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 5472a1112c..af1908f46e 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2244,6 +2244,7 @@ const ar = { popupBlockedTitle: "تم حظر النافذة المنبثقة", popupBlockedDescription: "حظر المتصفح فتح علامة التبويب الجديدة.", popupBlockedAction: "فتح في علامة تبويب جديدة", + noRecordId: "يُنفَّذ هذا الإجراء على سجل واحد — لا يتوفر معرّف سجل.", }, shortcuts: { title: "اختصارات لوحة المفاتيح", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 0672ae2dde..131049f8c2 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2057,6 +2057,7 @@ const de = { popupBlockedTitle: "Pop-up blockiert", popupBlockedDescription: "Der Browser hat das Öffnen des neuen Tabs blockiert.", popupBlockedAction: "In neuem Tab öffnen", + noRecordId: "Diese Aktion gilt für einen einzelnen Datensatz – keine Datensatz-ID verfügbar.", }, shortcuts: { title: "Tastenkürzel", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7079bedc0c..e22c9f9282 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2210,6 +2210,9 @@ const en = { popupBlockedTitle: 'Popup blocked', popupBlockedDescription: 'Your browser blocked the new tab from opening.', popupBlockedAction: 'Open in new tab', + // The `newTabUrl` fast path's refusal when no record id resolves + // (`createConsoleServerActionHandler`, objectui#13397). + noRecordId: 'This action runs on a single record — no record id available.', }, shortcuts: { title: 'Keyboard Shortcuts', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 341b08f6c8..d3f6bf3371 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2106,6 +2106,7 @@ const es = { popupBlockedTitle: "Ventana emergente bloqueada", popupBlockedDescription: "El navegador bloqueó la apertura de la nueva pestaña.", popupBlockedAction: "Abrir en una pestaña nueva", + noRecordId: "Esta acción se ejecuta sobre un solo registro: no hay ningún ID de registro disponible.", }, shortcuts: { title: "Atajos de teclado", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 107fd54fff..ac00ae3f5f 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2102,6 +2102,7 @@ const fr = { popupBlockedTitle: "Fenêtre contextuelle bloquée", popupBlockedDescription: "Le navigateur a bloqué l'ouverture du nouvel onglet.", popupBlockedAction: "Ouvrir dans un nouvel onglet", + noRecordId: "Cette action porte sur un seul enregistrement — aucun identifiant d’enregistrement n’est disponible.", }, shortcuts: { title: "Raccourcis clavier", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index d793f28aa7..fbd605f74e 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2059,6 +2059,7 @@ const ja = { popupBlockedTitle: "ポップアップがブロックされました", popupBlockedDescription: "ブラウザが新しいタブを開くのをブロックしました。", popupBlockedAction: "新しいタブで開く", + noRecordId: "この操作は 1 件のレコードに対して実行されますが、利用できるレコード ID がありません。", }, shortcuts: { title: "キーボードショートカット", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index ccba9c18b7..afe64da60e 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2057,6 +2057,7 @@ const ko = { popupBlockedTitle: "팝업이 차단되었습니다", popupBlockedDescription: "브라우저가 새 탭 열기를 차단했습니다.", popupBlockedAction: "새 탭에서 열기", + noRecordId: "이 작업은 단일 레코드에서 실행되지만 사용할 수 있는 레코드 ID가 없습니다.", }, shortcuts: { title: "키보드 단축키", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 18d15c6fab..1175bb8989 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2101,6 +2101,7 @@ const pt = { popupBlockedTitle: "Pop-up bloqueado", popupBlockedDescription: "O navegador bloqueou a abertura da nova aba.", popupBlockedAction: "Abrir em nova aba", + noRecordId: "Esta ação é executada em um único registro — nenhum ID de registro disponível.", }, shortcuts: { title: "Atalhos de teclado", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 1bb32a93ac..5ee2de8e3a 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2157,6 +2157,7 @@ const ru = { popupBlockedTitle: "Всплывающее окно заблокировано", popupBlockedDescription: "Браузер заблокировал открытие новой вкладки.", popupBlockedAction: "Открыть в новой вкладке", + noRecordId: "Это действие выполняется для одной записи — идентификатор записи недоступен.", }, shortcuts: { title: "Горячие клавиши", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 9bca6046c1..896bf20883 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1845,6 +1845,7 @@ const zh = { popupBlockedTitle: '浏览器拦截了弹窗', popupBlockedDescription: '浏览器阻止了新标签页的打开。', popupBlockedAction: '在新标签页打开', + noRecordId: '此操作针对单条记录,但没有可用的记录 ID。', }, shortcuts: { title: '键盘快捷键',