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/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..6a989142cb3 100644 --- a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -819,7 +819,8 @@ 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 })), }), [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..9f5ec04c6da --- /dev/null +++ b/ui/packages/app-shell/src/utils/__tests__/consoleServerAction.requiredFieldsHint-27520.test.ts @@ -0,0 +1,103 @@ +/** + * 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, t?: (key: string, englishDefault: string, values?: Record) => string) { + return createConsoleServerActionHandler({ + fetch, + baseUrl: () => '', + resolveObject: () => 'showcase_invoice', + onRefresh: () => {}, + t, + }); +} + +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 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, 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))(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..70e6bf57313 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). */ @@ -102,6 +109,37 @@ 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, +): 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; + // 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 }); + // 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 +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 result; + 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 2ccf25dd2fd..6728c687b91 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -1829,7 +1829,8 @@ 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 })), }), [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..8b44ce6184e --- /dev/null +++ b/ui/packages/core/src/actions/__tests__/actionRefusalFields-27520.test.ts @@ -0,0 +1,77 @@ +/** + * 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('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, + 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/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index ad2273f6c38..8160ea5cf48 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 @@ -369,7 +369,11 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 102 once objectstack-ai/objectstack#28241 added `setupAssignments.notFound`, // the Setup assignment pages' line that names an unknown item („{{name}}“) — // one interpolated span, runtime data. - expect(okSpans, 'correctly paired spans').toBe(102); + // 103 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(103); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -442,8 +446,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // 101 / 101 / 0 after objectstack-ai/objectstack#27823 added // `objectActions.deletedNamed`, one more matched pair. 102 / 102 / 0 after // objectstack-ai/objectstack#28241 added `setupAssignments.notFound`, one - // more matched pair. - expect({ open, close, rdq }).toEqual({ open: 102, close: 102, rdq: 0 }); + // more matched pair. 103 / 103 / 0 after objectstack-ai/objectstack#27520 + // added `console.serverAction.fillRequiredThenRetry`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 103, close: 103, 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); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index a0e4bed19ba..48393ba1330 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: "إلغاء", @@ -2377,6 +2378,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 246d6a95e43..fcaa4abdc42 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", @@ -2154,6 +2155,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 0ced9c95101..61e68983a9d 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', @@ -2325,6 +2326,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 2807a984a8b..132f3eedc5f 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", @@ -2212,6 +2213,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 8bec754d9e7..0ac1560aaee 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", @@ -2208,6 +2209,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 72a3cf3b765..19d4f09bd4d 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: "キャンセル", @@ -2156,6 +2157,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 07470827138..182319ec46a 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: "취소", @@ -2154,6 +2155,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 dde4cbf8d7d..ca1525db69f 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", @@ -2207,6 +2208,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 75355b27376..cb718ff0a40 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: "Отмена", @@ -2272,6 +2273,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 a110de05872..68223408afd 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: '取消', @@ -1942,6 +1943,8 @@ const zh = { popupBlockedDescription: '浏览器阻止了新标签页的打开。', popupBlockedAction: '在新标签页打开', noRecordId: '此操作针对单条记录,但没有可用的记录 ID。', + fillRequiredThenRetry: '请先填写{{fields}},然后重新执行“{{action}}”。', + fillRequiredFirst: '请先填写{{fields}}。', }, shortcuts: { title: '键盘快捷键', 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 && ( <> 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;