From ad56c4cf569e941de64c60017df50b34f12d6928 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:32:26 +0000 Subject: [PATCH 1/5] feat(plugin-grid): a bulk run's result names the records that succeeded The result step said only 'Succeeded N / M'. It now lists the records the run wrote, named as the confirm step names them, whenever every failure is attributed to a record of the run; a count-only shortfall keeps the count. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzF2YJvme2ABRytQ9o --- ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 1 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + .../bulkResultSucceededNames-27520.test.tsx | 93 +++++++++++++++++++ .../src/components/BulkActionDialog.tsx | 34 +++++++ 12 files changed, 137 insertions(+) create mode 100644 ui/packages/plugin-grid/src/__tests__/bulkResultSucceededNames-27520.test.tsx diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index c63b4aa4a2f..cd9d0517576 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -529,6 +529,7 @@ const ar = { undonePrefix: "تم التراجع — ", succeeded: "نجح {{count}} / {{total}}", resultFailed: " · فشل {{count}}", + succeededRecords: "السجلات الناجحة", retry: "إعادة المحاولة", downloadErrorCsv: "تنزيل ملف CSV للأخطاء", cancel: "إلغاء", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 70494c266ef..ccac5e10612 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -483,6 +483,7 @@ const de = { undonePrefix: "Rückgängig gemacht — ", succeeded: "Erfolgreich {{count}} / {{total}}", resultFailed: " · Fehlgeschlagen {{count}}", + succeededRecords: "Erfolgreiche Datensätze", retry: "Erneut versuchen", downloadErrorCsv: "Fehler-CSV herunterladen", cancel: "Abbrechen", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 9e2c94d84ec..ff710d03608 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -956,6 +956,7 @@ const en = { undonePrefix: 'Undone \u2014 ', succeeded: 'Succeeded {{count}} / {{total}}', resultFailed: ' \u00b7 Failed {{count}}', + succeededRecords: 'Records that succeeded', retry: 'Retry', downloadErrorCsv: 'Download error CSV', cancel: 'Cancel', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index f0c5a12557e..ae66c33b300 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -498,6 +498,7 @@ const es = { undonePrefix: "Deshecho: ", succeeded: "Correctos {{count}} / {{total}}", resultFailed: " · Fallidos {{count}}", + succeededRecords: "Registros correctos", retry: "Reintentar", downloadErrorCsv: "Descargar CSV de errores", cancel: "Cancelar", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 2dae04eac80..832ef69dacb 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -493,6 +493,7 @@ const fr = { undonePrefix: "Annulé — ", succeeded: "Réussis {{count}} / {{total}}", resultFailed: " · Échecs {{count}}", + succeededRecords: "Enregistrements réussis", retry: "Réessayer", downloadErrorCsv: "Télécharger le CSV d'erreurs", cancel: "Annuler", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 4f6988cbd2b..255ebf31770 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -483,6 +483,7 @@ const ja = { undonePrefix: "取り消し済み — ", succeeded: "成功 {{count}} / {{total}}", resultFailed: " · 失敗 {{count}}", + succeededRecords: "成功したレコード", retry: "再試行", downloadErrorCsv: "エラー CSV をダウンロード", cancel: "キャンセル", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 7a4b65e9776..3bb96e6491c 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -483,6 +483,7 @@ const ko = { undonePrefix: "되돌림 — ", succeeded: "성공 {{count}} / {{total}}", resultFailed: " · 실패 {{count}}", + succeededRecords: "성공한 레코드", retry: "다시 시도", downloadErrorCsv: "오류 CSV 다운로드", cancel: "취소", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index e2104805641..0266c1dbf65 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -493,6 +493,7 @@ const pt = { undonePrefix: "Desfeito — ", succeeded: "Concluídos {{count}} / {{total}}", resultFailed: " · Falhas {{count}}", + succeededRecords: "Registros concluídos", retry: "Tentar novamente", downloadErrorCsv: "Baixar CSV de erros", cancel: "Cancelar", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 6d4eec20ec5..10a22c32661 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -506,6 +506,7 @@ const ru = { undonePrefix: "Отменено — ", succeeded: "Успешно {{count}} / {{total}}", resultFailed: " · Ошибок {{count}}", + succeededRecords: "Успешные записи", retry: "Повторить", downloadErrorCsv: "Скачать CSV с ошибками", cancel: "Отмена", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 2a4729d3f75..92287285339 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -720,6 +720,7 @@ const zh = { undonePrefix: '已撤销 — ', succeeded: '成功 {{count}} / {{total}}', resultFailed: ' · 失败 {{count}}', + succeededRecords: '成功的记录', retry: '重试', downloadErrorCsv: '下载错误 CSV', cancel: '取消', diff --git a/ui/packages/plugin-grid/src/__tests__/bulkResultSucceededNames-27520.test.tsx b/ui/packages/plugin-grid/src/__tests__/bulkResultSucceededNames-27520.test.tsx new file mode 100644 index 00000000000..0cc0ccdeb3f --- /dev/null +++ b/ui/packages/plugin-grid/src/__tests__/bulkResultSucceededNames-27520.test.tsx @@ -0,0 +1,93 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack-ai/objectstack#27520 row 8 — a bulk run's result names the + * records it wrote, not only how many. + * + * Measured on the showcase's Tasks → Bulk Actions view: Mark Done over two + * rows answered `Succeeded 2 / 2` and nothing else, so the user could not see + * WHICH tasks were marked. The result now lists them, named the way the + * confirm step names the affected records (the object's `nameField`). + * + * The list is only drawn when the successes are known: every failure the run + * reports sits under a record of the run, so the rows left over are exactly + * the ones that succeeded. A bulk call that answers a bare count names no + * failed record, and the result keeps to its count. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import type { BulkActionDef } from '@object-ui/types'; +import { BulkActionDialog } from '../components/BulkActionDialog'; + +afterEach(() => cleanup()); + +const ROWS = [ + { id: 't1', title: 'Content backlog' }, + { id: 't2', title: 'Evidence collection' }, + { id: 't3', title: 'Design system' }, +]; +const OBJECT_DEF = { name: 'task', nameField: 'title', fields: { title: { type: 'text', label: 'Title' } } }; +const MARK_DONE: BulkActionDef = { name: 'mark_done', label: 'Mark Done', operation: 'update', patch: { done: true } } as BulkActionDef; + +async function runOver(rows: typeof ROWS, dataSource: Record) { + render( + {}} + />, + ); + fireEvent.click(await screen.findByRole('button', { name: 'Run' })); +} + +describe('bulk result names the records it wrote (objectstack#27520 row 8)', () => { + it('lists every record of a clean run by its title', async () => { + const update = vi.fn(async () => ({})); + await runOver(ROWS.slice(0, 2), { update }); + + expect(await screen.findByText(/Succeeded 2 \/ 2/)).toBeInTheDocument(); + const list = screen.getByTestId('bulk-succeeded-records'); + expect(list).toHaveAccessibleName('Records that succeeded'); + const items = within(list).getAllByRole('listitem').map(li => li.textContent); + expect(items).toEqual(['• Content backlog', '• Evidence collection']); + }); + + it('lists only the records that landed when the run refused one', async () => { + const update = vi.fn(async (_resource: string, id: string) => { + if (id === 't2') throw new Error('Evidence collection is locked'); + return {}; + }); + await runOver(ROWS, { update }); + + expect(await screen.findByText(/Succeeded 2 \/ 3/)).toBeInTheDocument(); + const list = screen.getByTestId('bulk-succeeded-records'); + expect(within(list).getByText(/Content backlog/)).toBeInTheDocument(); + expect(within(list).getByText(/Design system/)).toBeInTheDocument(); + expect(within(list).queryByText(/Evidence collection/)).toBeNull(); + // The refused record stays listed under its refusal. + expect(screen.getByTestId('bulk-error-record-t2')).toHaveTextContent('Evidence collection'); + }); + + it('names no record when a bulk call answered a count without naming its failures', async () => { + const update = vi.fn(async () => ({})); + // Two of three landed, and the call does not say which. + const bulkUpdate = vi.fn(async () => 2); + await runOver(ROWS, { update, bulkUpdate }); + + expect(await screen.findByText(/Succeeded 2 \/ 3/)).toBeInTheDocument(); + expect(bulkUpdate).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('bulk-succeeded-records')).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx b/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx index 1bce6d9da1d..d5b4cf32da7 100644 --- a/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx +++ b/ui/packages/plugin-grid/src/components/BulkActionDialog.tsx @@ -400,6 +400,20 @@ export const BulkActionDialog: React.FC = ({ const previewRows = rows.slice(0, 5); const restCount = Math.max(0, rows.length - previewRows.length); + // The result names the records the run wrote, as the confirm step named + // them. Only when every failure is attributed to a record of the run: then + // the rows left over are exactly the ones that succeeded. A failure the + // server answered with a count only leaves the successes unknown, and the + // result keeps to its count rather than name a record that may have failed. + const succeededRows = (() => { + if (!result || result.succeeded === 0) return []; + const failedIds = new Set(result.errors.map(e => e.id)); + const landed = rows.filter(r => r.id != null && !failedIds.has(String(r.id))); + return landed.length === result.succeeded ? landed : []; + })(); + const succeededPreview = succeededRows.slice(0, 5); + const succeededRest = succeededRows.length - succeededPreview.length; + return ( { if (!o) onClose(result); }}> = ({ {result.failed > 0 && t('grid.bulk.resultFailed', { count: result.failed, defaultValue: ` · Failed ${result.failed}` })} + {succeededPreview.length > 0 && ( + +
    + {succeededPreview.map(r => ( +
  • + • {affectedRecordName(objectDef, r, String(r.id))} +
  • + ))} + {succeededRest > 0 && ( +
  • + {t('grid.bulk.andMore', { count: succeededRest, defaultValue: `… and ${succeededRest} more` })} +
  • + )} +
+
+ )} {result.errors.length > 0 && ( <> From 51b558dae3dc01b35da1da3ad4748f2c478baaa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:35:42 +0000 Subject: [PATCH 2/5] feat(console): a refused action that names required fields says what to fill in The action route's refusal carries details.fields, and the dispatch kept only its text. The failed ActionResult now carries those entries (fieldErrors), and the console's server-action wrapper follows the server's sentence with 'Fill in , then run again.' The CRM Expected Revenue formula declares its currency return type, so it reads as money beside Deal Value. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzF2YJvme2ABRytQ9o --- .../app-crm/src/objects/opportunity.object.ts | 4 + .../src/hooks/useConsoleActionRuntime.tsx | 8 +- ...verAction.requiredFieldsHint-27520.test.ts | 101 ++++++++++++++++++ .../src/utils/consoleServerAction.ts | 65 +++++++++-- .../app-shell/src/views/RecordDetailView.tsx | 8 +- .../actionRefusalFields-27520.test.ts | 68 ++++++++++++ .../core/src/actions/actionResponse.ts | 32 ++++++ .../core/src/actions/serverActionHandler.ts | 6 +- ui/packages/i18n/src/locales/ar.ts | 2 + ui/packages/i18n/src/locales/de.ts | 2 + ui/packages/i18n/src/locales/en.ts | 4 + ui/packages/i18n/src/locales/es.ts | 2 + ui/packages/i18n/src/locales/fr.ts | 2 + ui/packages/i18n/src/locales/ja.ts | 2 + ui/packages/i18n/src/locales/ko.ts | 2 + ui/packages/i18n/src/locales/pt.ts | 2 + ui/packages/i18n/src/locales/ru.ts | 2 + ui/packages/i18n/src/locales/zh.ts | 2 + ui/packages/types/src/index.ts | 1 + ui/packages/types/src/ui-action.ts | 24 +++++ 20 files changed, 324 insertions(+), 15 deletions(-) create mode 100644 ui/packages/app-shell/src/utils/__tests__/consoleServerAction.requiredFieldsHint-27520.test.ts create mode 100644 ui/packages/core/src/actions/__tests__/actionRefusalFields-27520.test.ts diff --git a/examples/app-crm/src/objects/opportunity.object.ts b/examples/app-crm/src/objects/opportunity.object.ts index 8d3f698b9c1..b8f26d3370f 100644 --- a/examples/app-crm/src/objects/opportunity.object.ts +++ b/examples/app-crm/src/objects/opportunity.object.ts @@ -47,8 +47,12 @@ export const Opportunity = ObjectSchema.create({ min: 0, max: 100, }), + // An amount of money, so it declares the `currency` return type: the + // console reads `returnType` and never infers money from the expression, + // and an undeclared formula draws the plain number face beside `amount`. expected_revenue: Field.formula({ label: 'Expected Revenue', + returnType: 'currency', expression: cel`(record.amount == null ? 0 : record.amount) * (record.probability == null ? 0 : record.probability) / 100`, }), close_date: Field.date({ diff --git a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index a17e2680bbf..ea2498efe60 100644 --- a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -804,8 +804,8 @@ 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, t, navigate }); - serverActionEnvRef.current = { objApiName, refresh, t, navigate }; + const serverActionEnvRef = useRef({ objApiName, refresh, t, navigate, language }); + serverActionEnvRef.current = { objApiName, refresh, t, navigate, language }; const serverActionHandler = useMemo( () => createConsoleServerActionHandler({ fetch: authFetch, @@ -819,7 +819,9 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // 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)), + t: (key, englishDefault, values) => + String(serverActionEnvRef.current.t(key, { defaultValue: englishDefault, ...values })), + locale: () => serverActionEnvRef.current.language, }), [authFetch], ); diff --git a/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.requiredFieldsHint-27520.test.ts b/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.requiredFieldsHint-27520.test.ts new file mode 100644 index 00000000000..97737cdcfd5 --- /dev/null +++ b/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.requiredFieldsHint-27520.test.ts @@ -0,0 +1,101 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack-ai/objectstack#27520 row 11 — a refused action that names + * required fields says what to do next. + * + * Measured on the showcase: Submit for Sign-off on a draft invoice answered + * `Issued On is required` and nothing else, so the user had no hint that the + * invoice needed editing first. The console's action dispatch now follows the + * server's sentence with "Fill in Issued On, then run “Submit for Sign-off” + * again." The server's text stays first and whole, whatever it says. + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { ActionDef, ServerActionFetch } from '@object-ui/core'; +import { createConsoleServerActionHandler } from '../consoleServerAction'; + +const SUBMIT = { type: 'script', name: 'showcase_submit_signoff', label: 'Submit for Sign-off', objectName: 'showcase_invoice', recordId: 'inv_1' } as ActionDef; + +function refusing(message: string, fields: unknown[]) { + return vi.fn(async () => ({ + ok: false, + status: 400, + json: async () => ({ success: false, error: { code: 'VALIDATION_FAILED', message, details: { fields } } }), + })) as unknown as ServerActionFetch; +} + +function handlerOver(fetch: ServerActionFetch, locale = 'en') { + return createConsoleServerActionHandler({ + fetch, + baseUrl: () => '', + resolveObject: () => 'showcase_invoice', + onRefresh: () => {}, + locale: () => locale, + }); +} + +describe('a required-field refusal names what to fill in (objectstack#27520 row 11)', () => { + it('follows the server’s sentence with the field and the action to run again', async () => { + const res = await handlerOver(refusing('Issued On is required', [ + { field: 'issued_on', code: 'required', message: 'Issued On is required', label: 'Issued On' }, + ]))(SUBMIT); + expect(res.success).toBe(false); + expect(res.error).toBe('Issued On is required. Fill in Issued On, then run “Submit for Sign-off” again.'); + }); + + it('joins several required fields in the session’s language, each once', async () => { + const fields = [ + { field: 'issued_on', code: 'required', label: 'Issued On' }, + { field: 'due_on', code: 'required', label: 'Due On' }, + { field: 'issued_on', code: 'required', label: 'Issued On' }, + ]; + const en = await handlerOver(refusing('Two fields are required', fields))(SUBMIT); + expect(en.error).toBe('Two fields are required. Fill in Issued On and Due On, then run “Submit for Sign-off” again.'); + const de = await handlerOver(refusing('Zwei Felder fehlen', fields), 'de')(SUBMIT); + expect(de.error).toContain('Issued On und Due On'); + }); + + it('keeps the server’s own stop, and ends a CJK sentence with its own', async () => { + const fields = [{ field: 'issued_on', code: 'required', label: '开具日期' }]; + const stopped = await handlerOver(refusing('Issued On is required.', fields))(SUBMIT); + expect(stopped.error?.startsWith('Issued On is required. Fill in')).toBe(true); + const zh = await handlerOver(refusing('开具日期为必填项', fields), 'zh')(SUBMIT); + expect(zh.error?.startsWith('开具日期为必填项。Fill in 开具日期')).toBe(true); + }); + + it('names the field by its key when the server sent no label, and asks plainly when the action has none', async () => { + const res = await handlerOver(refusing('due_on is required', [{ field: 'due_on', code: 'required' }]))( + { ...SUBMIT, label: undefined } as ActionDef, + ); + expect(res.error).toBe('due_on is required. Fill in due_on first.'); + }); + + it('leaves every other refusal as the server said it', async () => { + const res = await handlerOver(refusing('Amount must be at least 0', [ + { field: 'amount', code: 'min', message: 'Amount must be at least 0' }, + { field: '_record', code: 'required', message: 'A contact is required' }, + ]))(SUBMIT); + expect(res.error).toBe('Amount must be at least 0'); + }); + + it('translates the hint through the host’s t, holes filled from its values', async () => { + const t = vi.fn((key: string, englishDefault: string, values?: Record) => + key === 'console.serverAction.fillRequiredThenRetry' ? `[${values?.fields}|${values?.action}]` : englishDefault); + const handler = createConsoleServerActionHandler({ + fetch: refusing('Issued On is required', [{ field: 'issued_on', code: 'required', label: 'Issued On' }]), + baseUrl: () => '', + resolveObject: () => 'showcase_invoice', + onRefresh: () => {}, + t, + }); + const res = await handler(SUBMIT); + expect(res.error).toBe('Issued On is required. [Issued On|Submit for Sign-off]'); + }); +}); diff --git a/ui/packages/app-shell/src/utils/consoleServerAction.ts b/ui/packages/app-shell/src/utils/consoleServerAction.ts index 95a9cf072e1..eda64a84532 100644 --- a/ui/packages/app-shell/src/utils/consoleServerAction.ts +++ b/ui/packages/app-shell/src/utils/consoleServerAction.ts @@ -49,6 +49,7 @@ import { resolveServerActionRecordId, type ActionContext, type ActionDef, + type ActionResult, type ServerActionFetch, type ServerActionHandler, type ServerActionRecordIdResolver, @@ -56,15 +57,21 @@ import { import { takesNewTabUrlFastPath } from './newTabUrlFastPath.js'; /** - * 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. + * i18next-style translate function: `t(key, englishDefault, values)` returns + * the translation for `key` in the active locale, or `englishDefault` when the + * key is missing, with its `{{name}}` holes filled from `values`. 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; +export type ConsoleServerActionTranslate = ( + key: string, + englishDefault: string, + values?: Record, +) => string; -/** No-`t` fallback (tests / standalone): every string is its English default. */ -const defaultTranslate: ConsoleServerActionTranslate = (_key, englishDefault) => englishDefault; +/** No-`t` fallback (tests / standalone): every string is its English default, holes filled. */ +const defaultTranslate: ConsoleServerActionTranslate = (_key, englishDefault, values) => + values ? englishDefault.replace(/\{\{(\w+)\}\}/g, (hole, name: string) => values[name] ?? hole) : englishDefault; export interface ConsoleServerActionOptions { /** Authenticated fetch wrapper (Bearer + tenant + cookies). */ @@ -90,6 +97,12 @@ export interface ConsoleServerActionOptions { * (objectui#3321). */ t?: ConsoleServerActionTranslate; + /** + * The session's language (a BCP-47 tag), read per refusal: the language the + * field names of a required-fields hint are joined in. Optional: without it + * the runtime's default locale joins them. + */ + locale?: () => string | undefined; /** * SPA route hop, for a handler that returned `openIn: 'self'` alongside its * `redirectUrl`. The console passes react-router's `navigate` — the router @@ -102,6 +115,42 @@ export interface ConsoleServerActionOptions { navigate?: (url: string) => void; } +/** + * A refusal that names required fields says what to do next after the + * server's own sentence: fill those fields in, then run the action again. + * The action route has no form to mark them on, so the hint names them. + * Only `required` entries located on a field count; any other refusal, and a + * record-level rule, keep the server's text alone. + */ +function withRequiredFieldsHint( + result: ActionResult, + action: ActionDef, + t: ConsoleServerActionTranslate, + locale: string | undefined, +): ActionResult { + const names = [...new Set( + (result.fieldErrors ?? []) + .filter((entry) => entry.code === 'required' && entry.field !== '_record') + .map((entry) => entry.label || entry.field), + )]; + if (names.length === 0 || !result.error) return result; + let fields: string; + try { + fields = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }).format(names); + } catch { + fields = names.join(', '); + } + const hint = action.label + ? t('console.serverAction.fillRequiredThenRetry', 'Fill in {{fields}}, then run “{{action}}” again.', { fields, action: action.label }) + : t('console.serverAction.fillRequiredFirst', 'Fill in {{fields}} first.', { fields }); + // The server's sentence ends before the hint starts: its own stop, else one + // in its script, and a CJK stop takes no space after it. + const error = result.error.trimEnd(); + const stop = /[.!?。!?]$/u.test(error) ? '' : /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]$/u.test(error) ? '。' : '.'; + const sentence = `${error}${stop}`; + return { ...result, error: `${sentence}${/[。!?]$/u.test(sentence) ? '' : ' '}${hint}` }; +} + /** Minimal HTML escape for locale strings interpolated into the spinner document. */ function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); @@ -221,7 +270,7 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption closeTab(preOpenedTab); // Don't toast here — the ActionRunner's post-execution hook surfaces // `error` as a toast; toasting here too double-fires it. - return result; + return withRequiredFieldsHint(result, action, t, opts.locale?.()); } // ── redirectUrl convention ──────────────────────────────────────── // Read off the HANDLER's return value, not the action envelope wrapping diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index a9bc8d66263..81363abd45a 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -1817,8 +1817,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, t, navigate }); - serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged, t, navigate }; + const serverActionEnvRef = useRef({ objectName, pureRecordId, notifyRecordChanged, t, navigate, language }); + serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged, t, navigate, language }; const serverActionHandler = useMemo( () => createConsoleServerActionHandler({ fetch: authFetch, @@ -1834,7 +1834,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // 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)), + t: (key, englishDefault, values) => + String(serverActionEnvRef.current.t(key, { defaultValue: englishDefault, ...values })), + locale: () => serverActionEnvRef.current.language, }), [authFetch], ); diff --git a/ui/packages/core/src/actions/__tests__/actionRefusalFields-27520.test.ts b/ui/packages/core/src/actions/__tests__/actionRefusalFields-27520.test.ts new file mode 100644 index 00000000000..c101842d62c --- /dev/null +++ b/ui/packages/core/src/actions/__tests__/actionRefusalFields-27520.test.ts @@ -0,0 +1,68 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectstack-ai/objectstack#27520 row 11 — a refused action keeps the + * refusal's per-field entries, not only its text. + * + * The answer below is the one the action route gave on the showcase's draft + * invoice → Submit for Sign-off (measured on a live stack): a 400 + * `VALIDATION_FAILED` whose `details.fields` names the field the + * `requiredWhen` rule refused. The text alone reached the toast; the entries + * were dropped, so the host could not say which field to fill in. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { interpretActionResponse } from '../actionResponse'; +import { createServerActionHandler, type ServerActionFetch } from '../serverActionHandler'; +import type { ActionDef } from '../ActionRunner'; + +const SUBMIT_REFUSAL = { + success: false, + error: { + code: 'VALIDATION_FAILED', + message: 'Issued On is required', + httpStatus: 400, + details: { fields: [{ field: 'issued_on', code: 'required', message: 'Issued On is required', label: 'Issued On' }] }, + }, +}; + +describe('a refused action keeps its per-field entries (objectstack#27520 row 11)', () => { + it('reads the entries off the answer beside its text', () => { + const out = interpretActionResponse({ ok: false, status: 400 }, SUBMIT_REFUSAL, 'Action "submit"'); + expect(out.ok).toBe(false); + expect(out.error).toBe('Issued On is required'); + expect(out.fieldErrors).toEqual([ + { field: 'issued_on', code: 'required', message: 'Issued On is required', label: 'Issued On' }, + ]); + }); + + it('drops an entry with no field, and sets nothing when none is left', () => { + const out = interpretActionResponse({ ok: false, status: 400 }, { + success: false, + error: { message: 'Refused', details: { fields: [{ code: 'required' }, { field: '' }] } }, + }, 'Action "x"'); + expect(out.error).toBe('Refused'); + expect(out).not.toHaveProperty('fieldErrors'); + }); + + it('reads the legacy inner envelope’s own list', () => { + const out = interpretActionResponse({ ok: true, status: 200 }, { + success: true, + data: { success: false, error: 'Issued On is required', fields: [{ field: 'issued_on', code: 'required' }] }, + }, 'Action "x"'); + expect(out.ok).toBe(false); + expect(out.fieldErrors).toEqual([{ field: 'issued_on', code: 'required' }]); + }); + + it('hands the entries on through the dispatch’s failed result', async () => { + const fetch = vi.fn(async () => ({ ok: false, status: 400, json: async () => SUBMIT_REFUSAL })) as unknown as ServerActionFetch; + const handler = createServerActionHandler({ fetch }); + const res = await handler({ type: 'script', name: 'submit', objectName: 'invoice', recordId: 'inv_1' } as ActionDef); + expect(res.success).toBe(false); + expect(res.error).toBe('Issued On is required'); + expect(res.fieldErrors).toEqual([ + { field: 'issued_on', code: 'required', message: 'Issued On is required', label: 'Issued On' }, + ]); + }); +}); diff --git a/ui/packages/core/src/actions/actionResponse.ts b/ui/packages/core/src/actions/actionResponse.ts index 5b491fc9cb4..192dc3bc374 100644 --- a/ui/packages/core/src/actions/actionResponse.ts +++ b/ui/packages/core/src/actions/actionResponse.ts @@ -49,12 +49,18 @@ * failure directly. */ +import type { ActionFieldError } from '@object-ui/types'; import { actionErrorDetail } from './actionErrorDetail.js'; export interface ActionResponseOutcome { ok: boolean; /** Human-readable failure text. Always a string when `ok` is false. */ error?: string; + /** + * The refusal's per-field entries (`error.details.fields`), when it carried + * any with a usable `field`. Only on a failure. + */ + fieldErrors?: ActionFieldError[]; /** * The ACTION ENVELOPE (`body.data`) — `{ success, data }`. Kept as-is * because `ActionResult.data` has always carried this level and @@ -119,13 +125,39 @@ export function interpretActionResponse( // `{message, code}` object, which `actionErrorDetail` resolves to a // string — handing that object to `toast.error()` as a React child // crashes the page, React #31). + const fieldErrors = refusalFieldErrors(innerFailed ? envelope : json); return { ok: false, error: innerFailed ? actionErrorDetail(envelope, `${label} failed`) : actionErrorDetail(json, `${label} failed (HTTP ${res.status})`), + ...(fieldErrors ? { fieldErrors } : {}), }; } return { ok: true, envelope, payload: readActionPayload(envelope) }; } + +/** + * The `fields` list of a refusal body: `error.details.fields` on the current + * envelope, `fields` on the legacy inner one. Entries with no string `field` + * are dropped rather than guessed at; the rest keep the server's own `code`, + * `message` and `label`. + */ +function refusalFieldErrors(body: unknown): ActionFieldError[] | undefined { + const b = body as { error?: { details?: { fields?: unknown } }; fields?: unknown } | null | undefined; + const raw = b?.error?.details?.fields ?? b?.fields; + if (!Array.isArray(raw)) return undefined; + const out: ActionFieldError[] = []; + for (const entry of raw) { + const e = entry as Record | null; + if (!e || typeof e !== 'object' || typeof e.field !== 'string' || e.field === '') continue; + out.push({ + field: e.field, + ...(typeof e.code === 'string' ? { code: e.code } : {}), + ...(typeof e.message === 'string' ? { message: e.message } : {}), + ...(typeof e.label === 'string' ? { label: e.label } : {}), + }); + } + return out.length > 0 ? out : undefined; +} diff --git a/ui/packages/core/src/actions/serverActionHandler.ts b/ui/packages/core/src/actions/serverActionHandler.ts index e29b7db2430..f76cf534e17 100644 --- a/ui/packages/core/src/actions/serverActionHandler.ts +++ b/ui/packages/core/src/actions/serverActionHandler.ts @@ -301,7 +301,11 @@ export function createServerActionHandler(config: ServerActionHandlerConfig): Se if (!outcome.ok) { // Don't toast here — the ActionRunner's post-execution hook surfaces // `error` as a toast; a handler-side toast double-fires it. - return { success: false, error: outcome.error }; + return { + success: false, + error: outcome.error, + ...(outcome.fieldErrors ? { fieldErrors: outcome.fieldErrors } : {}), + }; } const shouldRefresh = action.refreshAfter !== false; if (shouldRefresh) onRefresh?.(); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index cd9d0517576..1340e73f1c2 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2375,6 +2375,8 @@ const ar = { popupBlockedDescription: "حظر المتصفح فتح علامة التبويب الجديدة.", popupBlockedAction: "فتح في علامة تبويب جديدة", noRecordId: "يُنفَّذ هذا الإجراء على سجل واحد — لا يتوفر معرّف سجل.", + fillRequiredThenRetry: "املأ {{fields}}، ثم شغّل \"{{action}}\" مرة أخرى.", + fillRequiredFirst: "املأ {{fields}} أولاً.", }, shortcuts: { title: "اختصارات لوحة المفاتيح", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index ccac5e10612..59241979dfb 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2152,6 +2152,8 @@ const de = { 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.", + fillRequiredThenRetry: "Füllen Sie {{fields}} aus und führen Sie dann „{{action}}“ erneut aus.", + fillRequiredFirst: "Füllen Sie zuerst {{fields}} aus.", }, shortcuts: { title: "Tastenkürzel", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index ff710d03608..c6699ab04b0 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2323,6 +2323,10 @@ const en = { // 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.', + // A refused action whose answer named required fields: what to do next, + // after the server's own sentence (`createConsoleServerActionHandler`). + fillRequiredThenRetry: 'Fill in {{fields}}, then run “{{action}}” again.', + fillRequiredFirst: 'Fill in {{fields}} first.', }, shortcuts: { title: 'Keyboard Shortcuts', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index ae66c33b300..031713312c3 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2210,6 +2210,8 @@ const es = { 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.", + fillRequiredThenRetry: "Completa {{fields}} y vuelve a ejecutar «{{action}}».", + fillRequiredFirst: "Completa primero {{fields}}.", }, shortcuts: { title: "Atajos de teclado", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 832ef69dacb..1b08ae77a5c 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2206,6 +2206,8 @@ const fr = { 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.", + fillRequiredThenRetry: "Renseignez {{fields}}, puis relancez « {{action}} ».", + fillRequiredFirst: "Renseignez d’abord {{fields}}.", }, shortcuts: { title: "Raccourcis clavier", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 255ebf31770..ae0729ed956 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2154,6 +2154,8 @@ const ja = { popupBlockedDescription: "ブラウザが新しいタブを開くのをブロックしました。", popupBlockedAction: "新しいタブで開く", noRecordId: "この操作は 1 件のレコードに対して実行されますが、利用できるレコード ID がありません。", + fillRequiredThenRetry: "{{fields}} を入力してから、もう一度「{{action}}」を実行してください。", + fillRequiredFirst: "先に {{fields}} を入力してください。", }, shortcuts: { title: "キーボードショートカット", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 3bb96e6491c..2262cbf8199 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2152,6 +2152,8 @@ const ko = { popupBlockedDescription: "브라우저가 새 탭 열기를 차단했습니다.", popupBlockedAction: "새 탭에서 열기", noRecordId: "이 작업은 단일 레코드에서 실행되지만 사용할 수 있는 레코드 ID가 없습니다.", + fillRequiredThenRetry: "{{fields}}을(를) 입력한 다음 \"{{action}}\"을(를) 다시 실행하세요.", + fillRequiredFirst: "먼저 {{fields}}을(를) 입력하세요.", }, shortcuts: { title: "키보드 단축키", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 0266c1dbf65..50dc963ebf2 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2205,6 +2205,8 @@ const pt = { 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.", + fillRequiredThenRetry: "Preencha {{fields}} e execute “{{action}}” novamente.", + fillRequiredFirst: "Preencha {{fields}} primeiro.", }, shortcuts: { title: "Atalhos de teclado", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 10a22c32661..d3a8291d8b1 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2270,6 +2270,8 @@ const ru = { popupBlockedDescription: "Браузер заблокировал открытие новой вкладки.", popupBlockedAction: "Открыть в новой вкладке", noRecordId: "Это действие выполняется для одной записи — идентификатор записи недоступен.", + fillRequiredThenRetry: "Заполните {{fields}}, затем снова запустите «{{action}}».", + fillRequiredFirst: "Сначала заполните {{fields}}.", }, shortcuts: { title: "Горячие клавиши", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 92287285339..08357f71a31 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1940,6 +1940,8 @@ const zh = { popupBlockedDescription: '浏览器阻止了新标签页的打开。', popupBlockedAction: '在新标签页打开', noRecordId: '此操作针对单条记录,但没有可用的记录 ID。', + fillRequiredThenRetry: '请先填写{{fields}},然后重新执行“{{action}}”。', + fillRequiredFirst: '请先填写{{fields}}。', }, shortcuts: { title: '键盘快捷键', diff --git a/ui/packages/types/src/index.ts b/ui/packages/types/src/index.ts index 358342012f0..81252c667fc 100644 --- a/ui/packages/types/src/index.ts +++ b/ui/packages/types/src/index.ts @@ -1241,6 +1241,7 @@ export type { DeclaredActionsResolution, ActionContext, ActionResult, + ActionFieldError, // The `undo` payload of `ActionResult`, moved down from `@object-ui/core`'s // `UndoManager` with it (objectui#6349, batch 4); core re-exports it. UndoableOperation, diff --git a/ui/packages/types/src/ui-action.ts b/ui/packages/types/src/ui-action.ts index cc1eb44e092..1c9dcaa8d89 100644 --- a/ui/packages/types/src/ui-action.ts +++ b/ui/packages/types/src/ui-action.ts @@ -1099,6 +1099,22 @@ export interface UndoableOperation { redoData: Record; } +/** + * One entry of a refused write's `fields` list, as the action route answers + * it: the field the failed rule names, the rule's machine `code` (`required`, + * …), the server's sentence and the field's display label. + */ +export interface ActionFieldError { + /** The field the failed rule names (`_record` for a record-level rule). */ + field: string; + /** The rule's machine code, e.g. `required`. */ + code?: string; + /** The server's sentence for this entry, shown verbatim. */ + message?: string; + /** The field's display label, when the server sent one. */ + label?: string; +} + /** * Action execution result — the ONE declaration of this name (objectui#6349, * batch 4). `@object-ui/core`'s `ActionRunner` module used to declare a second @@ -1122,6 +1138,14 @@ export interface ActionResult { /** Error message if failed */ error?: string; + /** + * The per-field entries of a refused write, as the server answered them + * (`error.details.fields`): one per failed rule, located on the field it + * names. Set only on a failed result whose refusal carried such a list, so + * a host can say what to fix beside `error`, which stays the whole text. + */ + fieldErrors?: ActionFieldError[]; + /** Whether the caller should reload its data after the action */ reload?: boolean; From 714e3766cb7c31547ed77790b88c6668c7d0e256 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:40:32 +0000 Subject: [PATCH 3/5] fix(console): join a required-fields hint with the pack's list punctuation Intl.ListFormat is outside the app-shell lib target, and the form's field lists already join with validation.formInvalidJoiner. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzF2YJvme2ABRytQ9o --- .../27520-bulk-result-names-required-hint.md | 16 ++++++++++++++++ .../src/hooks/useConsoleActionRuntime.tsx | 5 ++--- ...erverAction.requiredFieldsHint-27520.test.ts | 16 +++++++++------- .../app-shell/src/utils/consoleServerAction.ts | 17 +++-------------- .../app-shell/src/views/RecordDetailView.tsx | 5 ++--- 5 files changed, 32 insertions(+), 27 deletions(-) create mode 100644 .changeset/27520-bulk-result-names-required-hint.md diff --git a/.changeset/27520-bulk-result-names-required-hint.md b/.changeset/27520-bulk-result-names-required-hint.md new file mode 100644 index 00000000000..9ac0ff8137b --- /dev/null +++ b/.changeset/27520-bulk-result-names-required-hint.md @@ -0,0 +1,16 @@ +--- +'@object-ui/types': patch +'@object-ui/core': patch +'@object-ui/app-shell': patch +'@object-ui/plugin-grid': patch +'@object-ui/i18n': patch +--- + +feat(console): a bulk result names the records it wrote, and a required-field refusal says what to fill in + +Clause-②: yes (widening: `ActionResult` gains the optional `fieldErrors` member, `ActionFieldError` is a new export of `@object-ui/types`, and `ActionResponseOutcome` gains `fieldErrors`; no accept set moves) +Metadata-protocol: none + +- **Bulk action result.** After a bulk action such as Mark Done, the result read only "Succeeded 2 / 2". It now lists the records that succeeded, named the way the confirm step names them (the object's name field). The list appears when every failure is reported against a record of the run; when the server answered a bare count, the result keeps to the count rather than name a record that may have failed. +- **Required-field refusal from an action.** Running an action whose write the server refuses for a missing required field, such as Submit for Sign-off on a draft invoice with no Issued On, used to say only "Issued On is required". It now continues "Fill in Issued On, then run “Submit for Sign-off” again." Several fields are joined in the session's language. Any other refusal is shown as the server wrote it. +- For code that dispatches actions through `createServerActionHandler`: a failed `ActionResult` now carries the refusal's per-field entries as `fieldErrors` (field, code, message, label), read from the answer's `error.details.fields`. `error` is unchanged. diff --git a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index ea2498efe60..6a989142cb3 100644 --- a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -804,8 +804,8 @@ 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, t, navigate, language }); - serverActionEnvRef.current = { objApiName, refresh, t, navigate, language }; + const serverActionEnvRef = useRef({ objApiName, refresh, t, navigate }); + serverActionEnvRef.current = { objApiName, refresh, t, navigate }; const serverActionHandler = useMemo( () => createConsoleServerActionHandler({ fetch: authFetch, @@ -821,7 +821,6 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // (objectui#3321). t: (key, englishDefault, values) => String(serverActionEnvRef.current.t(key, { defaultValue: englishDefault, ...values })), - locale: () => serverActionEnvRef.current.language, }), [authFetch], ); diff --git a/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.requiredFieldsHint-27520.test.ts b/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.requiredFieldsHint-27520.test.ts index 97737cdcfd5..9f5ec04c6da 100644 --- a/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.requiredFieldsHint-27520.test.ts +++ b/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.requiredFieldsHint-27520.test.ts @@ -31,13 +31,13 @@ function refusing(message: string, fields: unknown[]) { })) as unknown as ServerActionFetch; } -function handlerOver(fetch: ServerActionFetch, locale = 'en') { +function handlerOver(fetch: ServerActionFetch, t?: (key: string, englishDefault: string, values?: Record) => string) { return createConsoleServerActionHandler({ fetch, baseUrl: () => '', resolveObject: () => 'showcase_invoice', onRefresh: () => {}, - locale: () => locale, + t, }); } @@ -50,23 +50,25 @@ describe('a required-field refusal names what to fill in (objectstack#27520 row expect(res.error).toBe('Issued On is required. Fill in Issued On, then run “Submit for Sign-off” again.'); }); - it('joins several required fields in the session’s language, each once', async () => { + it('joins several required fields with the pack’s list punctuation, each once', async () => { const fields = [ { field: 'issued_on', code: 'required', label: 'Issued On' }, { field: 'due_on', code: 'required', label: 'Due On' }, { field: 'issued_on', code: 'required', label: 'Issued On' }, ]; const en = await handlerOver(refusing('Two fields are required', fields))(SUBMIT); - expect(en.error).toBe('Two fields are required. Fill in Issued On and Due On, then run “Submit for Sign-off” again.'); - const de = await handlerOver(refusing('Zwei Felder fehlen', fields), 'de')(SUBMIT); - expect(de.error).toContain('Issued On und Due On'); + expect(en.error).toBe('Two fields are required. Fill in Issued On, Due On, then run “Submit for Sign-off” again.'); + const zhJoiner = (key: string, englishDefault: string, values?: Record) => + key === 'validation.formInvalidJoiner' ? '、' : englishDefault.replace(/\{\{(\w+)\}\}/g, (_h, n: string) => values?.[n] ?? ''); + const zh = await handlerOver(refusing('两个字段为必填项', fields), zhJoiner)(SUBMIT); + expect(zh.error).toContain('Fill in Issued On、Due On,'); }); it('keeps the server’s own stop, and ends a CJK sentence with its own', async () => { const fields = [{ field: 'issued_on', code: 'required', label: '开具日期' }]; const stopped = await handlerOver(refusing('Issued On is required.', fields))(SUBMIT); expect(stopped.error?.startsWith('Issued On is required. Fill in')).toBe(true); - const zh = await handlerOver(refusing('开具日期为必填项', fields), 'zh')(SUBMIT); + const zh = await handlerOver(refusing('开具日期为必填项', fields))(SUBMIT); expect(zh.error?.startsWith('开具日期为必填项。Fill in 开具日期')).toBe(true); }); diff --git a/ui/packages/app-shell/src/utils/consoleServerAction.ts b/ui/packages/app-shell/src/utils/consoleServerAction.ts index eda64a84532..70e6bf57313 100644 --- a/ui/packages/app-shell/src/utils/consoleServerAction.ts +++ b/ui/packages/app-shell/src/utils/consoleServerAction.ts @@ -97,12 +97,6 @@ export interface ConsoleServerActionOptions { * (objectui#3321). */ t?: ConsoleServerActionTranslate; - /** - * The session's language (a BCP-47 tag), read per refusal: the language the - * field names of a required-fields hint are joined in. Optional: without it - * the runtime's default locale joins them. - */ - locale?: () => string | undefined; /** * SPA route hop, for a handler that returned `openIn: 'self'` alongside its * `redirectUrl`. The console passes react-router's `navigate` — the router @@ -126,7 +120,6 @@ function withRequiredFieldsHint( result: ActionResult, action: ActionDef, t: ConsoleServerActionTranslate, - locale: string | undefined, ): ActionResult { const names = [...new Set( (result.fieldErrors ?? []) @@ -134,12 +127,8 @@ function withRequiredFieldsHint( .map((entry) => entry.label || entry.field), )]; if (names.length === 0 || !result.error) return result; - let fields: string; - try { - fields = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }).format(names); - } catch { - fields = names.join(', '); - } + // The pack's list punctuation, the key the form's field lists join with. + const fields = names.join(t('validation.formInvalidJoiner', ', ')); const hint = action.label ? t('console.serverAction.fillRequiredThenRetry', 'Fill in {{fields}}, then run “{{action}}” again.', { fields, action: action.label }) : t('console.serverAction.fillRequiredFirst', 'Fill in {{fields}} first.', { fields }); @@ -270,7 +259,7 @@ export function createConsoleServerActionHandler(opts: ConsoleServerActionOption closeTab(preOpenedTab); // Don't toast here — the ActionRunner's post-execution hook surfaces // `error` as a toast; toasting here too double-fires it. - return withRequiredFieldsHint(result, action, t, opts.locale?.()); + return withRequiredFieldsHint(result, action, t); } // ── redirectUrl convention ──────────────────────────────────────── // Read off the HANDLER's return value, not the action envelope wrapping diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 81363abd45a..f270c05b404 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -1817,8 +1817,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, t, navigate, language }); - serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged, t, navigate, language }; + const serverActionEnvRef = useRef({ objectName, pureRecordId, notifyRecordChanged, t, navigate }); + serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged, t, navigate }; const serverActionHandler = useMemo( () => createConsoleServerActionHandler({ fetch: authFetch, @@ -1836,7 +1836,6 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // (objectui#3321). t: (key, englishDefault, values) => String(serverActionEnvRef.current.t(key, { defaultValue: englishDefault, ...values })), - locale: () => serverActionEnvRef.current.language, }), [authFetch], ); From 9f34e7d30fe11348dc28b28f81dab592295c60ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 06:09:18 +0000 Subject: [PATCH 4/5] test(i18n): count the de pack's new quoted action span Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzF2YJvme2ABRytQ9o --- .../i18n/src/__tests__/de-quote-pairing-3876.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 30912cb8c93..4535bcf74e8 100644 --- a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -366,7 +366,11 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 101 once objectstack-ai/objectstack#27823 added // `objectActions.deletedNamed`, the delete toast that names the record // („{{name}}“) — one interpolated span, runtime data. - expect(okSpans, 'correctly paired spans').toBe(101); + // 102 once objectstack-ai/objectstack#27520 added + // `console.serverAction.fillRequiredThenRetry`, the refused action's hint + // that names the action to run again („{{action}}“) — one interpolated + // span, runtime data. + expect(okSpans, 'correctly paired spans').toBe(102); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -437,8 +441,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 100 / 100 / 0 after objectstack-ai/objectstack#27480 added // `chatbot.plan.iterateOrNew.question` and `.extend`, one matched pair each. // 101 / 101 / 0 after objectstack-ai/objectstack#27823 added - // `objectActions.deletedNamed`, one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 101, close: 101, rdq: 0 }); + // `objectActions.deletedNamed`, one more matched pair. 102 / 102 / 0 after + // objectstack-ai/objectstack#27520 added + // `console.serverAction.fillRequiredThenRetry`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 102, close: 102, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); From e578f9353b32cdcd8cdb7e2d3a9a85dd04d33771 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 07:32:14 +0000 Subject: [PATCH 5/5] test(core): a refusal carrying userMessage keeps its field entries Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibzF2YJvme2ABRytQ9o --- .../actions/__tests__/actionRefusalFields-27520.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ui/packages/core/src/actions/__tests__/actionRefusalFields-27520.test.ts b/ui/packages/core/src/actions/__tests__/actionRefusalFields-27520.test.ts index c101842d62c..8b44ce6184e 100644 --- a/ui/packages/core/src/actions/__tests__/actionRefusalFields-27520.test.ts +++ b/ui/packages/core/src/actions/__tests__/actionRefusalFields-27520.test.ts @@ -37,6 +37,15 @@ describe('a refused action keeps its per-field entries (objectstack#27520 row 11 ]); }); + it('keeps the entries when the refusal also carries a producer-marked userMessage', () => { + const out = interpretActionResponse({ ok: false, status: 400 }, { + ...SUBMIT_REFUSAL, + error: { ...SUBMIT_REFUSAL.error, userMessage: 'Add the issue date, then submit again.' }, + }, 'Action "submit"'); + expect(out.ok).toBe(false); + expect(out.fieldErrors).toEqual(SUBMIT_REFUSAL.error.details.fields); + }); + it('drops an entry with no field, and sets nothing when none is left', () => { const out = interpretActionResponse({ ok: false, status: 400 }, { success: false,