Skip to content
Draft
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/32657-console-localized-user-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/app-shell': patch
'@object-ui/console': patch
'@object-ui/data-objectstack': patch
---

fix(console): a metadata save's advisories and refusals show the server's sentence in the user's language

Clause-②: yes (widening)
Metadata-protocol: none

**Advisory toast.** After a save or publish, each advisory finding that carries a `userMessage` (the sentence the server composes for the person who saved, in the request's language, else the workspace default, else English) shows that sentence after its rule id and location, in place of the developer-facing English `message` and `hint`. A finding the server wrote no sentence for keeps the `message` and `hint`.

**Refused save.** The issues of a `422` refusal from the runtime authoring gate carry the same `userMessage`. `formatMetadataIssue` (and so `formatMetadataError` and the Studio publish-failure list) shows it after the issue's path in place of the English `message`, and Studio's refusal sentence carries it verbatim instead of reading the English `message` through the console's own validation-message table. A spec-validation issue carries no `userMessage` and is shown as before.

**Type.** `MetadataValidationIssue` on `@object-ui/data-objectstack` gains the optional `userMessage`, typed from `RuntimeAuthoringIssue['userMessage']` in `@objectstack/spec/api`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* 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.
*/

/**
* The post-save advisory toast shows the producer's localized sentence
* (objectstack#32657, layer 3 of #32654: ruling C on #32440).
*
* The save door composes each advisory's `userMessage`
* (`RuntimeAuthoringIssueSchema.userMessage`) in the caller's language. The
* toast renders that sentence in place of the developer-facing English
* `message` and `hint`; a finding the producer wrote no sentence for keeps
* the `message` + `hint` face.
*
* Rendered end to end: the real `AdapterProvider` builds the real adapter and
* subscribes, the real SDK client parses a save response from a `fetch`
* double, and the REAL sonner `Toaster` draws the toast — what is asserted is
* the text on screen, not what a sink was called with. The two sentences below
* are the `security-private-no-readscope` catalog entries
* (`validate-security-posture.user-text.ts`) for these params, as the save
* door sends them for an en and a zh-CN request.
*/

import { useEffect } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, render, waitFor } from '@testing-library/react';
import { Toaster, toast } from 'sonner';
import { AdapterProvider, useAdapter } from './AdapterProvider';

const FINDING = {
severity: 'warning' as const,
rule: 'security-private-no-readscope',
where: 'permission set "sales_rep" · object "crm_deal"',
path: 'objects.crm_deal',
message:
'permission set "sales_rep" grants read on private object "crm_deal" without a readScope, so holders see only their own rows',
hint: 'set readScope on the grant, or widen the object sharing model',
};

const USER_TEXT_EN =
'Holders of permission set "sales_rep" see only the "crm_deal" records they own or that are shared with them: ' +
'"crm_deal" is private, and the set grants read access without a read scope. If they should see more, give the ' +
"set a read scope or widen the object's sharing.";

const USER_TEXT_ZH =
'权限集“sales_rep”的持有者只能看到自己拥有或被共享给他们的“crm_deal”记录:“crm_deal”是私有对象,' +
'而该权限集授予了读取权限却没有设置读取范围。如果他们应当看到更多记录,请为该权限集设置读取范围,或放宽该对象的共享设置。';

const CLEAN_BODY = { success: true, version: 'v2', seq: 4, state: 'active' as const };

function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
}

/** A server that answers discovery and every metadata write with `saveBody`. */
function serverAnswers(saveBody: unknown) {
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = String(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
if (url.includes('/discovery')) return jsonResponse({ success: true, data: {} });
return jsonResponse(saveBody);
}),
);
}

let captured: { getClient(): { meta: { saveItem(t: string, n: string, i: unknown): Promise<unknown> } } } | null = null;

function CaptureAdapter() {
const adapter = useAdapter();
useEffect(() => {
captured = adapter as unknown as typeof captured;
}, [adapter]);
return null;
}

async function saveThroughProvider(): Promise<void> {
render(
<>
<Toaster duration={60_000} />
<AdapterProvider>
<CaptureAdapter />
</AdapterProvider>
</>,
);
await waitFor(() => expect(captured).not.toBeNull());
await act(async () => {
await captured!.getClient().meta.saveItem('permission', 'sales_rep', { name: 'sales_rep' });
});
}

/** The text of the one advisory toast on screen. */
async function toastText(): Promise<string> {
let text = '';
await waitFor(() => {
const shown = Array.from(document.querySelectorAll<HTMLElement>('[data-sonner-toast]'));
expect(shown).toHaveLength(1);
text = shown[0].textContent ?? '';
expect(text).toMatch(/Saved/);
});
return text;
}

beforeEach(() => {
captured = null;
});

afterEach(() => {
act(() => {
toast.dismiss();
});
cleanup();
vi.unstubAllGlobals();
});

describe('the post-save advisory toast shows the producer’s localized sentence (objectstack#32657)', () => {
it('a zh-CN userMessage is the finding’s text, in place of the English message and hint', async () => {
serverAnswers({ ...CLEAN_BODY, advisories: [{ ...FINDING, userMessage: USER_TEXT_ZH }] });
await saveThroughProvider();

const text = await toastText();
expect(text).toContain(USER_TEXT_ZH);
expect(text).toContain(FINDING.rule);
expect(text).not.toContain(FINDING.message);
expect(text).not.toContain(FINDING.hint);
});

it('an en userMessage is shown the same way', async () => {
serverAnswers({ ...CLEAN_BODY, advisories: [{ ...FINDING, userMessage: USER_TEXT_EN }] });
await saveThroughProvider();

const text = await toastText();
expect(text).toContain(USER_TEXT_EN);
expect(text).not.toContain(FINDING.message);
});

it('CONTROL: a finding without a userMessage keeps the message + hint face', async () => {
serverAnswers({ ...CLEAN_BODY, advisories: [FINDING] });
await saveThroughProvider();

const text = await toastText();
expect(text).toContain(FINDING.message);
expect(text).toContain(FINDING.hint);
expect(text).toContain(FINDING.rule);
});
});
22 changes: 22 additions & 0 deletions ui/packages/app-shell/src/providers/saveAdvisoryToast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,28 @@ describe('emitSaveAdvisories (#4133)', () => {
expect(description).toContain(FINDING.hint);
});

it('a finding with a userMessage (objectstack#32657): that sentence replaces message and hint, rule and where stay', () => {
const sink = makeSink();
const userMessage = '流程“nightly_purge”中的删除步骤没有筛选条件,会删除所有记录。请添加筛选条件。';

emitSaveAdvisories(event({ advisories: [{ ...FINDING, userMessage }] }), t, sink);

const description = sink.warning.mock.calls[0]![1]!.description!;
expect(description).toBe(`[${FINDING.rule}] ${FINDING.where} — ${userMessage}`);
expect(description).not.toContain(FINDING.message);
expect(description).not.toContain(FINDING.hint);
});

it('CONTROL: a blank userMessage is no sentence, and the message + hint face stays', () => {
const sink = makeSink();

emitSaveAdvisories(event({ advisories: [{ ...FINDING, userMessage: ' ' }] }), t, sink);

const description = sink.warning.mock.calls[0]![1]!.description!;
expect(description).toContain(FINDING.message);
expect(description).toContain(FINDING.hint);
});

it('renders `where` as secondary context and omits the machine `path`', () => {
const sink = makeSink();

Expand Down
26 changes: 20 additions & 6 deletions ui/packages/app-shell/src/providers/saveAdvisoryToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,21 @@
* finding is a 422 and never reaches a 2xx body), which is why nothing here
* branches on it.
*
* ## Why the finding text is not translated
* ## Why the finding text is not translated here
*
* `message` and `hint` are SERVER data — composed by the gate's rules, which
* name the offending node, object and field. They are rendered verbatim. Only
* the frame around them (the title, and the "rule — message" scaffolding) is
* i18n copy. Putting server prose through `t()` would need a key per rule and
* would go stale the moment a rule's wording changed upstream.
* The finding's prose is SERVER data — composed by the gate's rules, which
* name the offending node, object and field — and is rendered verbatim. Only
* the frame around it (the title, and the "rule — text" scaffolding) is i18n
* copy. Putting server prose through `t()` would need a key per rule and would
* go stale the moment a rule's wording changed upstream.
*
* Which prose: the finding's `userMessage` when the producer sent one
* (`RuntimeAuthoringIssueSchema.userMessage` — the rule's sentence for the
* person who saved, composed in the request's language, the workspace default,
* else `en`), in place of the developer-facing English `message` and `hint`.
* A finding the producer wrote no sentence for carries no `userMessage` and
* keeps the `message` + `hint` face. The rule id and `where` are locators, not
* prose, and frame both faces alike.
*
* @module providers/saveAdvisoryToast
*/
Expand Down Expand Up @@ -91,9 +99,15 @@ const ADVISORY_TOAST_MS = 10_000;
* needs to go fix it. `path` is deliberately NOT rendered: it is a document
* pointer (`flows[0].nodes[2].config…`) meant for tooling, and next to a
* human-readable `where` it reads as noise.
*
* The text after the dash is the producer's localized `userMessage` when the
* finding carries one, else its `message` and `hint` (module header).
*/
function formatFinding(f: MetadataSaveAdvisoryEvent['advisories'][number]): string {
const head = f.where ? `[${f.rule}] ${f.where}` : `[${f.rule}]`;
if (typeof f.userMessage === 'string' && f.userMessage.trim() !== '') {
return `${head} — ${f.userMessage}`;
}
const hint = f.hint ? ` ${f.hint}` : '';
return `${head} — ${f.message}${hint}`;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#32657 — a refused metadata save shows the producer's localized
* sentence for each issue that carries one.
*
* The runtime authoring gate's 422 `issues[]` carry `userMessage`
* (`RuntimeAuthoringIssueSchema.userMessage`) in the caller's language. The one
* issue reader (`formatMetadataIssue`, behind `formatMetadataError` and the
* publish formatter) and Studio's refusal sentence (`issueRefusal`) render it
* in place of the developer-facing English `message`; an issue without one —
* every spec-validation issue — keeps today's face, and only that one is read
* through the console's validation-message table.
*/

import { describe, expect, it } from 'vitest';
import { formatMetadataError, formatMetadataIssue } from '@object-ui/data-objectstack';
import { flowSaveRefusal, formatPublishFailures } from './metadataError';
import { t, tFormat } from '../metadata-admin/i18n';

import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
await loadStudioCatalogue('zh');

const MESSAGE = 'flow "approval" node "tell" config.title is required by the notify step';
const USER_TEXT_ZH = '通知步骤“Notify approver”缺少标题,请填写后再保存。';
const MISSING = 'Invalid input: expected string, received undefined';

function serverRefusal(issues: Array<{ path: string; message: string; userMessage?: string }>): Error {
return Object.assign(new Error(`${issues.length} issue(s)`), { status: 422, code: 'INVALID_METADATA', issues });
}

const FLOW = {
name: 'approval',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'route', type: 'decision', label: 'Route' },
{ id: 'tell', type: 'notify', label: 'Notify approver' },
],
edges: [{ id: 'e1', source: 'start', target: 'route' }],
};

describe('the issue line reads the producer’s userMessage (objectstack#32657)', () => {
it('an issue with a userMessage: the line is the path and that sentence, not the English message', () => {
const line = formatMetadataIssue({ path: 'nodes.2.config.title', message: MESSAGE, userMessage: USER_TEXT_ZH });
expect(line).toBe(`• nodes.2.config.title — ${USER_TEXT_ZH}`);
});

it('CONTROL: an issue without one keeps the message face', () => {
expect(formatMetadataIssue({ path: 'fields.amount.type', message: 'Required' })).toBe('• fields.amount.type — Required');
});

it('the banner reader and the publish formatter carry the sentence through', () => {
const e = serverRefusal([{ path: 'nodes.2.config.title', message: MESSAGE, userMessage: USER_TEXT_ZH }]);
expect(formatMetadataError(e)).toContain(USER_TEXT_ZH);
expect(formatMetadataError(e)).not.toContain(MESSAGE);

const published = formatPublishFailures([
{
type: 'flow',
name: 'approval',
error: 'failed the authoring check',
issues: [{ path: 'nodes.2.config.title', message: MESSAGE, userMessage: USER_TEXT_ZH }],
},
]);
expect(published).toContain(USER_TEXT_ZH);
expect(published).not.toContain(MESSAGE);
});
});

describe('Studio’s refusal sentence reads the producer’s userMessage (objectstack#32657)', () => {
const where = (locale: string) =>
tFormat('engine.studio.refusal.stepInput', locale, { input: locale === 'zh' ? '标题' : 'Title', step: 'Notify approver' });

it('an issue with a userMessage: the sentence carries it verbatim, never a console translation', () => {
const e = serverRefusal([{ path: 'nodes.2.config.title', message: MISSING, userMessage: USER_TEXT_ZH }]);
const refusal = flowSaveRefusal(e, FLOW, 'zh');

expect(refusal.message).toContain(USER_TEXT_ZH);
expect(refusal.message).not.toContain(t('engine.validation.expectedStringUndefined', 'zh'));
expect(refusal.target).toEqual({ kind: 'node', id: 'tell' });
});

it('CONTROL: an issue without one keeps the console’s reading of its message', () => {
const e = serverRefusal([{ path: 'nodes.2.config.title', message: MISSING }]);
const refusal = flowSaveRefusal(e, FLOW, 'en');

expect(refusal.message).toBe(
tFormat('engine.studio.refusal.issue', 'en', {
where: where('en'),
problem: t('engine.validation.expectedStringUndefined', 'en'),
}),
);
});
});
11 changes: 7 additions & 4 deletions ui/packages/app-shell/src/views/studio-design/metadataError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,13 @@ export function issueRefusal(e: unknown, locale: string, locate?: IssueLocator):
const path = String(issue.path ?? '').split('.').filter((seg) => seg !== '');
const at = locate?.(path);
if (!at) continue;
const sentence = tFormat('engine.studio.refusal.issue', locale, {
where: at.where,
problem: translateValidationMessage(issue.message, locale),
});
// The producer's own sentence, already in the request's language, when it
// sent one (`RuntimeAuthoringIssueSchema.userMessage`); only an issue
// without one has its English `message` read through the console's table.
const problem = typeof issue.userMessage === 'string' && issue.userMessage.trim() !== ''
? issue.userMessage
: translateValidationMessage(issue.message, locale);
const sentence = tFormat('engine.studio.refusal.issue', locale, { where: at.where, problem });
return located(sentence, issues.length, at, detail, locale);
}
return { message: t('engine.studio.refusal.unlocated', locale), detail };
Expand Down
8 changes: 8 additions & 0 deletions ui/packages/data-objectstack/src/metadata-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,14 @@ export interface MetadataValidationIssue {
path: string;
message: string;
code?: string;
/**
* The producer's sentence for the person who saved, in the request's
* language — `RuntimeAuthoringIssueSchema.userMessage`, which the runtime
* authoring gate's 422 `issues[]` carry. Typed from the spec row, so a
* rename there stops this compiling. Absent on spec-validation issues and on
* any finding the producer wrote no sentence for.
*/
userMessage?: RuntimeAuthoringIssue['userMessage'];
}

export interface MetadataError extends Error {
Expand Down
8 changes: 7 additions & 1 deletion ui/packages/data-objectstack/src/metadata-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,18 @@ export function extractIssues(e: unknown): MetadataValidationIssue[] {
/**
* One issue → a single field-anchored line: `• fields.amount.type — Required`.
*
* The text after the dash is the issue's `userMessage` when the producer sent
* one — its sentence for the person who saved, already in their language, so it
* replaces the developer-facing English `message` — else the `message`. The
* path is a locator, not prose, and leads both.
*
* Exported so a caller that formats a LIST of failures — `app-shell`'s publish
* formatter, whose `failed[]` entries carry their own `issues` — writes each
* issue in this same grammar instead of keeping a second copy of it.
*/
export function formatMetadataIssue(i: MetadataValidationIssue): string {
return `• ${i.path && i.path.length > 0 ? i.path : '(root)'} — ${i.message}`;
const text = typeof i.userMessage === 'string' && i.userMessage.trim() !== '' ? i.userMessage : i.message;
return `• ${i.path && i.path.length > 0 ? i.path : '(root)'} — ${text}`;
}

/**
Expand Down
Loading
Loading