Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/27520-bulk-result-names-required-hint.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions examples/app-crm/src/objects/opportunity.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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, string>) => 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<string, string>) =>
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<string, string>) =>
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]');
});
});
54 changes: 46 additions & 8 deletions ui/packages/app-shell/src/utils/consoleServerAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,29 @@ import {
resolveServerActionRecordId,
type ActionContext,
type ActionDef,
type ActionResult,
type ServerActionFetch,
type ServerActionHandler,
type ServerActionRecordIdResolver,
} from '@object-ui/core';
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, string>,
) => 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). */
Expand Down Expand Up @@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion ui/packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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' },
]);
});
});
32 changes: 32 additions & 0 deletions ui/packages/core/src/actions/actionResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> | 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;
}
6 changes: 5 additions & 1 deletion ui/packages/core/src/actions/serverActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
Expand Down
Loading
Loading