Skip to content

Commit f0ad1f7

Browse files
fix(app-shell): word the flow closure refusal on the Problems list in the Console's language (#30721)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuMV2VKbhgYbNaU6UB5gnKrv)_ Fixes #30038 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:流程绑定了本软件包不能用的对象时,Studio「问题」列表不再原样显示服务端的英文打包术语,而按服务端给的规则 id 与路径显示中文(或英文)说明:点名对象、说明软件包能用哪些对象、给出两种改法。 - 为什么改:#30038 第 3 项,执行裁决 A(只改 ui,线上形状不动);第 1、2、4 项已随 PR #30148 落地。 - 风险与回滚:只影响这两个规则 id 在三个位置的错误条目;其余条目原样显示。回滚即还原本 PR,无数据或协议影响。 - 先看:`previews/flow-closure-refusal.ts`(新渲染模块),`previews/flow-problems.ts`(接入点一行),`draftVerdict.ts`(保留 `code`)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/previews/flow-problems.closureRefusal-30038.test.ts`,预期 7/7 通过。 - 需要你做的:无,席位落地。 ## What changes - **New module** `ui/packages/app-shell/src/views/metadata-admin/previews/flow-closure-refusal.ts`: `closureRefusalMessage(entry, flow, locale)` words the publish refusal from the served entry's `code` and item-relative `path`, and only at the ruling's three positions: `flow-trigger-unknown-object` at `nodes.N.config.objectName` and `nodes.N.config.timeRelative.object`, `flow-input-unknown-object` at `variables.N.objectName`. The object name is read from the flow on screen at that path. Any other code, any other position, or no name at the path answers `undefined`. - **`code` threaded through the three ui-internal positions** (ruling item 3): - `previews/flow-problems.ts`: `ServerDiagnostic` gains `code?: string`; `buildFlowProblems` uses the new module for an error-level server entry and falls back to the served `message` (ruling item 4). - `draftVerdict.ts`: `verdictPreviewEntries` (and `verdictEntriesStillShown`, which wraps it) keeps an error's `code`. Studio's Automations pillar reaches the Problems list through this function, and its existing `{ ...entry }` spread carries the code on, so `StudioDesignSurface.tsx` is unchanged. - `ResourceEditPage.tsx`: `previewDiagnostics` keeps a served error's `code` on the entries it hands the canvas (it used to drop it). - **Copy** (ruling item 5): three keys `engine.flowProblems.closureRefusal.{triggerObject,scanObject,inputObject}` in `ui/packages/i18n/src/namespaces/studio/en.ts` and `zh.ts`. Those two are every catalogue the `studio` namespace has. The wording reuses the Console's existing package vocabulary (`engine.inspector.objectPicker.outsideReason`). The eager language packs `ui/packages/i18n/src/locales/*.ts` are not touched, so the first-screen English pack carries no new bytes. The studio `en` catalogue is its own chunk (`i18n-ns-studio-en`), and `ui/scripts/check-eager-closure-budget.mjs` does not budget it. - One changeset: `@object-ui/app-shell` + `@object-ui/i18n` patch. The wire shape does not move: no `packages/**` file changes (ruling A). ## Tests - New `previews/flow-problems.closureRefusal-30038.test.ts` (7 cases). zh-CN renders Chinese naming the object, at the trigger position. en renders the studio sentence, not the packaging one. The scan and input positions render too. An unknown code, or no code, passes through unchanged. A known code at a position it does not word, at a node with no name, or under the other rule id passes through. A warning under the same id passes through. `verdictPreviewEntries` keeps `code` end to end. - `ResourceEditPage.draftPublishVerdict-26707.test.tsx` gains a top-level describe that pins the canvas entry carrying the draft verdict's `code`. The file's harness already serves a coded refusal. ## Acceptance notes - **Ruling item 2, package id. Deviation, declared.** The sentence says "this package" (本软件包) and does not print the package id. The Problems list is built in `buildFlowProblems`, a pure function whose host (`FlowPreview`, through `MetadataPreviewProps`) receives no package. Threading one in would widen past the claim's file surface (`preview-registry.ts`, `FlowPreview.tsx`, `StudioDesignSurface.tsx`), and the package is the one the Studio page is already showing. If the seat wants the id printed, that is a follow-up through `MetadataPreviewProps`. - **"Did you mean" is not recomputed** (ruling item 2's fallback). The Problems list has no object catalogue, so the sentence says to pick an object from the list or to declare a dependency on the package that defines it. - **The object name is read from the flow on screen** at the served path (ruling item 2), not from the judged body. If the author edits the value before the next verdict read, the sentence names the new value. That is the staleness every served entry already has until the next read. - **Advisory warnings are not reworded.** Both rule ids also raise a no-closure warning with other words. The draft's `_diagnostics` carries only the publish's refusals in `errors[]` (`withPublishVerdict`), so the module rewords error entries only. - **Not reworded on purpose (outside the ruling's "Problems panel" reach):** `ResourceEditPage`'s diagnostics banner and the SchemaForm inline issues still show the served English for these entries. - `MetadataPreviewProps.diagnostics` (`preview-registry.ts`) does not declare `code` in its element type. The value passes structurally, and both new pins hold the runtime behaviour. The type line is outside the file surface. ## Verification All readings are on head `1f905121` unless noted. <details><summary>Tests (lock VERDICT command-exit 0)</summary> `pnpm --dir ui exec vitest run --maxWorkers=2` over `previews/flow-problems.closureRefusal-30038.test.ts`, `ResourceEditPage.draftPublishVerdict-26707.test.tsx`, `draftVerdict.test.ts`, `previews/flow-problems.test.ts` and `packages/i18n/src/__tests__/studio-namespace.test.ts`: `Test Files 5 passed (5)`, `Tests 52 passed (52)`. </details> <details><summary>Ablation, three legs (scripts/ablation-replace.mjs, each restored: blob == HEAD, git diff HEAD empty)</summary> The tests import `src` directly, so no `dist/` is involved. - Leg A: in `flow-problems.ts`, `closureRefusalMessage(diag, { nodes, variables }, locale)` → `(undefined as string | undefined)`. Result: `Tests 4 failed | 3 passed (7)`. All four rendering cases went red. The three pass-through cases stayed green. - Leg B: in `draftVerdict.ts`, deleted the `code` spread. Result: `Tests 1 failed | 6 passed (7)`, the end-to-end `verdictPreviewEntries` case. - Leg C: in `ResourceEditPage.tsx`, deleted the `code` spread. Result: `Tests 1 failed | 3 passed (4)`, the new canvas-`code` case. - The first tries at legs A, B and C were refused before running, because the replacement text sat inside the anchor. They were re-run with a distinct replacement (A) and with `--delete` (B, C). </details> <details><summary>Type check</summary> `pnpm exec turbo run type-check --concurrency=2 --filter=./ui/packages/app-shell --filter=./ui/packages/i18n`: `Tasks: 68 successful, 68 total`. Both packages ran `tsc --noEmit && tsc -p tsconfig.test.json`. Lock VERDICT command-exit 0. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` (merge base `4bf9a2d7e`, 9 paths, head `1f905121`): 35 families, each run with its exit code captured before any pipe, all 35 exit 0. They include `pnpm --dir ui exec vitest run scripts/` (lock VERDICT command-exit 0), `check:type-check-debt` (0 raw tsc errors over the ledger), `check:type-check-coverage`, `check:nul-bytes`, `check:published-files`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check-adr-0087-registration`, `check-empty-changeset` and `check-sdui-manifest`. `--ran` reconciliation: "35 derived famil(ies) accounted for, 35 run, 0 NOT-MEASURED (a DERIVED zero, all 35 recorded an exit code and none of them is 3)". The tests, ablation and type check ran on `ac42ace6`. `1f905121` only adds the changeset, and every source blob is unchanged. CI check-runs were not awaited. </details> <details><summary>Lint (per file, CI owns the repo sweep)</summary> `npx eslint --no-inline-config` over the 8 changed source files: 3 errors, all in `ResourceEditPage.tsx` at L3444/3577/3600 (`react-hooks/static-components`). The same file on `origin/main`, through `--stdin`, gives the same 3. None is in this diff's hunks. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Per-file reading above; the repo sweep is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (Harness-pinned card-thread branch.) - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (Except the 维护者速读 block, which is in Chinese by repo rule.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgU6Ui5Bf4TtimeCqH2 --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgU6Ui5Bf4TtimeCqH2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a91ad42 commit f0ad1f7

9 files changed

Lines changed: 298 additions & 8 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): a flow bound to an object its package may not use is explained on the Problems list in the Console's language
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
When a package's flow triggers on, scans, or declares a start input of an object outside what the package may use (its own objects, the objects of the packages it declares as dependencies, and system objects), the publish refuses it, and the flow's Problems list showed the server's English sentence in packaging terms to a reader in any language. The Problems list now words that refusal from the served entry's rule id (`flow-trigger-unknown-object`, `flow-input-unknown-object`) and its path: it names the object the flow holds there, says which objects a package can use, and points at the two fixes, picking an object from the list or declaring a dependency on the package that defines it. The copy lives in the Studio string namespace in English and Chinese. Any other server entry, and any of these at a position the Console does not word, still shows the server's sentence as sent. The served shape does not change: the editor pages now keep each served error's `code` on the entries they hand the canvas, where it used to be dropped.

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.draftPublishVerdict-26707.test.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,19 @@ describe('MetadataResourceEditPage — a pending draft is judged by its own serv
193193
expect(canvasDiagnostics().map((d) => d.message)).not.toContain(REFUSAL.message);
194194
});
195195
});
196+
197+
describe('MetadataResourceEditPage — a served refusal reaches the canvas with its rule id (objectstack#30038)', () => {
198+
it('the canvas entry carries the draft verdict\'s `code`, the key the flow Problems list words a refusal by', async () => {
199+
server.active.set('page/home', PAGE);
200+
server.drafts.set('page/home', { ...PAGE, label: 'broken' });
201+
renderEditor();
202+
203+
await waitFor(
204+
() =>
205+
expect(canvasDiagnostics()).toContainEqual(
206+
expect.objectContaining({ path: REFUSAL.path, code: REFUSAL.code, severity: 'error' }),
207+
),
208+
{ timeout: 8000 },
209+
);
210+
});
211+
});

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -905,21 +905,32 @@ function MetadataResourceEditPageImpl({
905905
// warnings stay server-sourced. Mirrors the read-only banner's source
906906
// selection, flattened to a path-keyed, severity-tagged list.
907907
const previewDiagnostics = React.useMemo<
908-
Array<{ path?: string; message: string; severity: 'error' | 'warning' }>
908+
Array<{ path?: string; message: string; severity: 'error' | 'warning'; code?: string }>
909909
>(() => {
910910
// objectstack#26707 — the verdict on the body on screen: the pending
911911
// draft's (which carries what its publish would refuse) while one is held.
912912
const diag = serverDiagnostics;
913+
// objectstack#30038 — a served error keeps its rule id (`code`), so the
914+
// flow Problems list can word a refusal it knows in the Console's language.
913915
const errs = (hasClientValidator(type, draftMode)
914916
? withServerOnlyErrors(displayIssues, diag?.errors)
915917
: (diag?.errors ?? [])
916-
).map((i) => ({ path: i.path, message: translateSchemaIssue(i, locale) }));
918+
).map((i) => ({
919+
path: i.path,
920+
message: translateSchemaIssue(i, locale),
921+
code: 'code' in i && typeof i.code === 'string' && i.code ? i.code : undefined,
922+
}));
917923
const warns = (diag?.warnings ?? []).map((i) => ({
918924
path: i.path,
919925
message: translateValidationMessage(i.message, locale),
920926
}));
921927
return [
922-
...errs.map((e) => ({ path: e.path || undefined, message: e.message, severity: 'error' as const })),
928+
...errs.map((e) => ({
929+
path: e.path || undefined,
930+
message: e.message,
931+
severity: 'error' as const,
932+
...(e.code ? { code: e.code } : {}),
933+
})),
923934
...warns.map((w) => ({ path: w.path || undefined, message: w.message, severity: 'warning' as const })),
924935
];
925936
}, [serverDiagnostics, displayIssues, type, locale, draftMode]);

‎ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,16 @@ export function withServerOnlyErrors<T extends { path: string; message: string }
8787
*/
8888
export function verdictPreviewEntries(
8989
diagnostics: ServerDiagnostics | undefined,
90-
): Array<{ path?: string; message: string; severity: 'error' | 'warning' }> {
90+
): Array<{ path?: string; message: string; severity: 'error' | 'warning'; code?: string }> {
9191
return [
92-
...(diagnostics?.errors ?? []).map((e) => ({ path: e.path || undefined, message: e.message, severity: 'error' as const })),
92+
// objectstack#30038 — an error keeps its rule id, so the Problems list can
93+
// word a refusal it knows in the Console's language (`flow-closure-refusal.ts`).
94+
...(diagnostics?.errors ?? []).map((e) => ({
95+
path: e.path || undefined,
96+
message: e.message,
97+
severity: 'error' as const,
98+
...(e.code ? { code: e.code } : {}),
99+
})),
93100
...(diagnostics?.warnings ?? []).map((w) => ({ path: w.path || undefined, message: w.message, severity: 'warning' as const })),
94101
];
95102
}
@@ -188,7 +195,7 @@ export function verdictEntriesStillShown(
188195
diagnostics: ServerDiagnostics | undefined,
189196
judged: unknown,
190197
shown: unknown,
191-
): Array<{ path?: string; message: string; severity: 'error' | 'warning' }> {
198+
): Array<{ path?: string; message: string; severity: 'error' | 'warning'; code?: string }> {
192199
return verdictPreviewEntries(diagnostics).filter(
193200
(entry) =>
194201
!entry.path ||
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* flow-closure-refusal — the Studio sentence for one publish refusal: a flow
5+
* that binds an object its package may not use (objectstack#30038, ruling A).
6+
*
7+
* Under a package closure the publish gate refuses a flow whose trigger object,
8+
* time-relative scan object or start-input object is outside that closure, and
9+
* the draft's `_diagnostics.errors[]` carry the refusal as the gate wrote it: an
10+
* English sentence in packaging terms, with the rule id as the entry's `code`
11+
* and the item-relative `path` of the offending key. The Problems panel used to
12+
* show that English text as sent, to a reader in any language.
13+
*
14+
* This module renders the sentence in the Console's language from what the
15+
* wire already carries, and nothing else: the entry's `code` (the rule id the
16+
* spec calls "the key a renderer groups or suppresses by") picks the copy, the
17+
* entry's `path` picks the position, and the object name is read from the flow
18+
* on screen at that path. The wire shape does not move.
19+
*
20+
* - Two rule ids at three positions, and only this refusal:
21+
* `flow-trigger-unknown-object` at `nodes.N.config.objectName` (the start
22+
* node's object) and at `nodes.N.config.timeRelative.object` (a time-relative
23+
* scan's object); `flow-input-unknown-object` at `variables.N.objectName` (a
24+
* start input's declaration). Both ids also raise a no-closure advisory with
25+
* other words; that one is a warning and never reaches `errors[]`, so an
26+
* error entry with one of these ids is this refusal.
27+
* - The sentence names the object and says "this package": the package is the
28+
* one Studio is editing, which the page already shows. "Did you mean" is not
29+
* recomputed here; the sentence points at the object list and at the
30+
* dependency declaration instead.
31+
* - Anything else — another code, another position, a value no longer a name
32+
* at that path — answers `undefined`, and the caller shows the server's own
33+
* sentence as before.
34+
*
35+
* ⛔ No verdict is computed here: the refusal is the server's, and this only
36+
* words it.
37+
*/
38+
39+
import { tFormat } from '../i18n.js';
40+
41+
/** The rule id of the trigger / time-relative scan refusal (`@objectstack/lint`). */
42+
export const FLOW_TRIGGER_UNKNOWN_OBJECT = 'flow-trigger-unknown-object';
43+
/** The rule id of the start-input declaration refusal (`@objectstack/lint`). */
44+
export const FLOW_INPUT_UNKNOWN_OBJECT = 'flow-input-unknown-object';
45+
46+
/** The flow on screen, as far as this sentence reads it. */
47+
export interface ClosureRefusalFlow {
48+
nodes?: readonly unknown[];
49+
variables?: readonly unknown[];
50+
}
51+
52+
/** The served entry, as far as this sentence reads it. */
53+
export interface ClosureRefusalEntry {
54+
code?: string;
55+
path?: string | ReadonlyArray<string | number>;
56+
}
57+
58+
const TRIGGER_OBJECT = /^nodes\.(\d+)\.config\.objectName$/;
59+
const SCAN_OBJECT = /^nodes\.(\d+)\.config\.timeRelative\.object$/;
60+
const INPUT_OBJECT = /^variables\.(\d+)\.objectName$/;
61+
62+
function dottedPath(path: ClosureRefusalEntry['path']): string {
63+
if (Array.isArray(path)) return path.join('.');
64+
return typeof path === 'string' ? path : '';
65+
}
66+
67+
function record(value: unknown): Record<string, unknown> | undefined {
68+
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
69+
}
70+
71+
function name(value: unknown): string | undefined {
72+
return typeof value === 'string' && value.trim() ? value : undefined;
73+
}
74+
75+
/**
76+
* The localized sentence for a served closure refusal, or `undefined` when the
77+
* entry is not one of the three positions this module words (the caller then
78+
* shows the server's message unchanged).
79+
*/
80+
export function closureRefusalMessage(
81+
entry: ClosureRefusalEntry,
82+
flow: ClosureRefusalFlow,
83+
locale?: string,
84+
): string | undefined {
85+
const path = dottedPath(entry.path);
86+
if (entry.code === FLOW_TRIGGER_UNKNOWN_OBJECT) {
87+
const trigger = TRIGGER_OBJECT.exec(path);
88+
if (trigger) {
89+
const object = name(record(record(flow.nodes?.[Number(trigger[1])])?.config)?.objectName);
90+
return object ? tFormat('engine.flowProblems.closureRefusal.triggerObject', locale, { object }) : undefined;
91+
}
92+
const scan = SCAN_OBJECT.exec(path);
93+
if (scan) {
94+
const config = record(record(flow.nodes?.[Number(scan[1])])?.config);
95+
const object = name(record(config?.timeRelative)?.object);
96+
return object ? tFormat('engine.flowProblems.closureRefusal.scanObject', locale, { object }) : undefined;
97+
}
98+
return undefined;
99+
}
100+
if (entry.code === FLOW_INPUT_UNKNOWN_OBJECT) {
101+
const input = INPUT_OBJECT.exec(path);
102+
if (!input) return undefined;
103+
const variable = record(flow.variables?.[Number(input[1])]);
104+
const object = name(variable?.objectName);
105+
if (!object) return undefined;
106+
return tFormat('engine.flowProblems.closureRefusal.inputObject', locale, {
107+
object,
108+
input: name(variable?.name) ?? `#${input[1]}`,
109+
});
110+
}
111+
return undefined;
112+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#30038 (ruling A) — the publish refusal of a flow bound to an
5+
* object outside its package's closure reads, on the Problems list, in the
6+
* Console's language. The sentence is chosen by the served entry's rule id
7+
* (`code`) and its item-relative `path`, and names the object the flow on
8+
* screen holds at that path; the wire shape does not move.
9+
*
10+
* Pinned: the three positions of the two rule ids render the studio copy
11+
* (zh-CN in Chinese, en in English); an unmatched code, an unmatched position,
12+
* a missing value and a warning keep the server's sentence as sent; and the
13+
* served verdict's `code` survives `verdictPreviewEntries`, the step that
14+
* flattens a draft's `_diagnostics` for the canvas.
15+
*/
16+
17+
import { describe, it, expect, beforeAll } from 'vitest';
18+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
19+
import { buildFlowProblems, type ServerDiagnostic } from './flow-problems';
20+
import { verdictPreviewEntries } from '../draftVerdict';
21+
import type { FlowDesignerNode } from './flow-canvas-layout';
22+
23+
beforeAll(async () => {
24+
await loadStudioCatalogue('zh');
25+
});
26+
27+
/** The sentence the publish gate writes (`@objectstack/lint` validate-flow-trigger-readiness). */
28+
const SERVED =
29+
"triggers on object 'crm_custom_eval_1104', which is outside the closure of package 'com.example.crm.custom' " +
30+
"(its own objects, the objects of the packages its manifest declares in `dependencies`, and platform objects), " +
31+
'so the package may not bind a flow to it.';
32+
33+
const nodes = [
34+
{
35+
id: 'start',
36+
type: 'start',
37+
label: 'Start',
38+
config: {
39+
triggerType: 'record-after-update',
40+
objectName: 'crm_custom_eval_1104',
41+
timeRelative: { object: 'crm_scan_target' },
42+
},
43+
},
44+
{ id: 'end', type: 'end', label: 'End' },
45+
] as unknown as FlowDesignerNode[];
46+
const edges = [{ id: 'e1', source: 'start', target: 'end' }];
47+
const variables = [{ name: 'deal_id', type: 'text', isInput: true, objectName: 'crm_deal_x' }];
48+
49+
function serverMessages(serverDiagnostics: ServerDiagnostic[], locale: string): string[] {
50+
return buildFlowProblems({ nodes, edges, serverDiagnostics, variables, locale })
51+
.filter((p) => p.source === 'server')
52+
.map((p) => p.message);
53+
}
54+
55+
const trigger: ServerDiagnostic = {
56+
path: 'nodes.0.config.objectName',
57+
message: SERVED,
58+
code: 'flow-trigger-unknown-object',
59+
};
60+
61+
describe('flow Problems — the closure refusal in the Console\'s language (objectstack#30038)', () => {
62+
it('zh-CN: the trigger object refusal renders in Chinese and names the object on screen', () => {
63+
const [message] = serverMessages([trigger], 'zh-CN');
64+
expect(message).toMatch(/[一-鿿]/);
65+
expect(message).toContain('“crm_custom_eval_1104”');
66+
expect(message).not.toContain('closure');
67+
});
68+
69+
it('en: the same entry renders the studio English sentence, not the packaging one', () => {
70+
const [message] = serverMessages([trigger], 'en');
71+
expect(message).toContain('"crm_custom_eval_1104"');
72+
expect(message).not.toBe(SERVED);
73+
expect(message).not.toContain('closure');
74+
});
75+
76+
it('the time-relative scan object and the start input are the rule ids\' other two positions', () => {
77+
const [scan, input] = serverMessages(
78+
[
79+
{ path: 'nodes.0.config.timeRelative.object', message: SERVED, code: 'flow-trigger-unknown-object' },
80+
{ path: 'variables.0.objectName', message: SERVED, code: 'flow-input-unknown-object' },
81+
],
82+
'zh-CN',
83+
);
84+
expect(scan).toContain('“crm_scan_target”');
85+
expect(scan).toMatch(/[一-鿿]/);
86+
expect(input).toContain('“crm_deal_x”');
87+
expect(input).toContain('“deal_id”');
88+
expect(input).toMatch(/[一-鿿]/);
89+
});
90+
91+
it('an unknown code passes the server\'s sentence through', () => {
92+
expect(serverMessages([{ ...trigger, code: 'some-other-rule' }], 'zh-CN')).toEqual([SERVED]);
93+
expect(serverMessages([{ path: trigger.path, message: SERVED }], 'zh-CN')).toEqual([SERVED]);
94+
});
95+
96+
it('a known code at a position it does not word, or with no name at the path, passes through', () => {
97+
expect(serverMessages([{ ...trigger, path: 'nodes.0.config.condition' }], 'zh-CN')).toEqual([SERVED]);
98+
expect(serverMessages([{ ...trigger, path: 'nodes.1.config.objectName' }], 'zh-CN')).toEqual([SERVED]);
99+
expect(serverMessages([{ ...trigger, code: 'flow-input-unknown-object' }], 'zh-CN')).toEqual([SERVED]);
100+
});
101+
102+
it('a warning under the same rule id (the no-closure advisory) is not reworded', () => {
103+
expect(serverMessages([{ ...trigger, severity: 'warning' }], 'zh-CN')).toEqual([SERVED]);
104+
});
105+
106+
it('the draft verdict\'s `code` survives the flattening the canvas reads', () => {
107+
const entries = verdictPreviewEntries({ valid: false, errors: [{ ...trigger, path: 'nodes.0.config.objectName' }] });
108+
expect(entries).toEqual([
109+
{ path: 'nodes.0.config.objectName', message: SERVED, severity: 'error', code: 'flow-trigger-unknown-object' },
110+
]);
111+
const [message] = serverMessages(entries, 'zh-CN');
112+
expect(message).toContain('“crm_custom_eval_1104”');
113+
expect(message).toMatch(/[一-鿿]/);
114+
});
115+
});

‎ui/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { flowNodeDisplayName } from './flow-canvas-parts.js';
3333
import { t, tFormat } from '../i18n.js';
3434
import { uniqueId } from '../inspectors/unique-id.js';
3535
import { isBlankExpressionRefusal } from '../inspectors/flow-value-envelope.js';
36+
import { closureRefusalMessage } from './flow-closure-refusal.js';
3637

3738
/** What a problem points at on the canvas — drives badge placement + reveal. */
3839
export type FlowProblemTarget =
@@ -73,6 +74,13 @@ export interface ServerDiagnostic {
7374
message: string;
7475
/** Defaults to `'error'`. */
7576
severity?: DiagnosticLevel;
77+
/**
78+
* The rule id the server raised the entry under (`_diagnostics.errors[].code`),
79+
* when it sent one. objectstack#30038 — the one key a renderer may word a
80+
* served refusal by (`flow-closure-refusal.ts`); without it the entry's
81+
* `message` is shown as sent.
82+
*/
83+
code?: string;
7684
}
7785

7886
/** Stable `source->target` key matching an edge problem to a rendered edge. */
@@ -282,10 +290,11 @@ export function buildFlowProblems({ nodes, edges, serverDiagnostics, variables,
282290
problems.push({
283291
id: `server:${i}:${targetKey(target)}`,
284292
level,
285-
// The words the node's value cell uses for the same refusal.
293+
// The words the node's value cell uses for the same refusal; the closure
294+
// refusal in the Console's language (objectstack#30038); else as sent.
286295
message: isBlankExpressionRefusal(diag.message)
287296
? t('engine.inspector.flowNode.kv.expressionNeeded', locale)
288-
: diag.message,
297+
: (level === 'error' ? closureRefusalMessage(diag, { nodes, variables }, locale) : undefined) ?? diag.message,
289298
target,
290299
source: 'server',
291300
...(location ? { location } : {}),

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4271,6 +4271,11 @@ const STUDIO_EN: Record<string, string> = {
42714271
'engine.flowProblems.connectRefused.missingTarget': 'Cannot connect: this flow has no node "{target}".',
42724272
'engine.flowProblems.connectRefused.self': 'Cannot connect "{source}" to itself.',
42734273
'engine.flowProblems.connectRefused.repeat': '"{source}" already connects to "{target}". Select that connection to change it.',
4274+
// objectstack#30038 — the publish refusal of a flow bound to an object outside its package's closure,
4275+
// worded from the served entry's rule id and path (`previews/flow-closure-refusal.ts`).
4276+
'engine.flowProblems.closureRefusal.triggerObject': 'This flow starts on records of "{object}", an object this package cannot use. A package can use only its own objects, the objects of the packages it declares as dependencies, and system objects. Pick an object from the list, or declare a dependency on the package that defines "{object}".',
4277+
'engine.flowProblems.closureRefusal.scanObject': 'This flow\u2019s scheduled scan reads records of "{object}", an object this package cannot use. A package can use only its own objects, the objects of the packages it declares as dependencies, and system objects. Pick an object from the list, or declare a dependency on the package that defines "{object}".',
4278+
'engine.flowProblems.closureRefusal.inputObject': 'Input "{input}" takes a record of "{object}", an object this package cannot use. A package can use only its own objects, the objects of the packages it declares as dependencies, and system objects. Pick an object from the list for this input, or declare a dependency on the package that defines "{object}".',
42744279
'engine.repeater.empty': 'No items. Click + to add.',
42754280
'engine.cancel': 'Cancel',
42764281
'engine.close': 'Close',

0 commit comments

Comments
 (0)