|
112 | 112 | * beats N dialects). If it is absent the message degrades to the envelope's own |
113 | 113 | * `error.message`, never to silence. |
114 | 114 | * |
| 115 | + * ## A refusal met inside the run reads like the same refusal on a save (objectstack#24914) |
| 116 | + * |
| 117 | + * `message` names node ids and object API names, in English: a duplicate met |
| 118 | + * by a `create_record` node read `Node 'create_account' failed: |
| 119 | + * create_record(crm_account) failed: …`. Ruling B′ gave the run the record |
| 120 | + * save's two carriers — the producer-marked `userMessage` (the envelope's top |
| 121 | + * level on a 400, `AutomationResult.userMessage` on a 200) and `innerRefusal` |
| 122 | + * (`error.details` on a 400, the run's own member on a 200: the node's refusal |
| 123 | + * in its wire `code`, `message` already without the prefixes). Both failure |
| 124 | + * arms read them through {@link flowFailureMessage}, between the authored |
| 125 | + * `errorMessage` and `message`, and render the inner refusal by the record |
| 126 | + * form's rule. `message` itself is untouched on the wire. |
| 127 | + * |
115 | 128 | * ## A LAUNCH is judged here too, not just interpreted (objectui#9973) |
116 | 129 | * |
117 | 130 | * Classifying the response was never the whole rule. The two launch handlers |
|
188 | 201 |
|
189 | 202 | import { actionCopySource, actionErrorDetail } from '@object-ui/core'; |
190 | 203 | import type { ActionResult } from '@object-ui/core'; |
| 204 | +import { getLoadedBuiltInLocales } from '@object-ui/i18n'; |
| 205 | +import { declaredUserMessage, isPermissionError } from '@object-ui/react'; |
191 | 206 | import { errorCodeIs } from '@object-ui/types'; |
192 | 207 | import type { AutomationResult } from '@objectstack/spec/contracts'; |
193 | 208 | import { translateFlow, type TranslationBundle } from '@objectstack/spec/system'; |
@@ -289,14 +304,72 @@ export type FlowResponseOutcome<S = unknown> = |
289 | 304 | | ({ kind: 'done'; data: FlowRunResult | undefined; successMessage?: string } & Pick<AutomationResult, 'flowLabel'>); |
290 | 305 |
|
291 | 306 | /** |
292 | | - * A flow declares a friendly `errorMessage`; prefer it — translated, when the |
293 | | - * bundle carries `flows.FLOW.errorMessage` (objectui#12075) — over the raw |
294 | | - * `error`, then fall back to the label. `actionErrorDetail` guarantees a string. |
| 307 | + * What a failed run's message is read from. On a 200 every member is the run's |
| 308 | + * own (`AutomationResult`); on a 400 `FLOW_FAILED` `errorMessage` and |
| 309 | + * `innerRefusal` are `error.details`' (`FlowFailureDetailsSchema`) and |
| 310 | + * `userMessage` is the envelope's top level (`ApiErrorSchema.userMessage`). |
| 311 | + */ |
| 312 | +interface FlowFailureCarriers { |
| 313 | + errorMessage?: unknown; |
| 314 | + /** Already read by `declaredUserMessage`, so non-empty or `null`. */ |
| 315 | + userMessage: string | null; |
| 316 | + innerRefusal?: unknown; |
| 317 | +} |
| 318 | + |
| 319 | +/** The one subtree of a resident catalogue {@link permissionDeniedLine} reads. */ |
| 320 | +type PermissionLinePack = { form?: { noPermissionToSave?: unknown } } | undefined; |
| 321 | + |
| 322 | +/** |
| 323 | + * The record form's permission line (`form.noPermissionToSave`) in `locale`, |
| 324 | + * read from the RESIDENT built-in catalogue — exact tag first, then its base |
| 325 | + * language, the registry's own order (the same read `LoadingScreen` makes). |
| 326 | + * The active language's catalogue is resident whenever the console runs in |
| 327 | + * it; anything else answers the resident `en` catalogue, which always is. |
| 328 | + * The literal is the last resort only for a registry that answers neither. |
| 329 | + * |
| 330 | + * Each read is one chain off `getLoadedBuiltInLocales()`, never a local |
| 331 | + * binding of its result, so `scripts/check-i18n-dead-keys.mjs` derives the |
| 332 | + * `en` read as a read of `form.noPermissionToSave` and the active-language |
| 333 | + * reads as runtime-indexed; its header bullet for this file says which leg |
| 334 | + * sees what. |
| 335 | + */ |
| 336 | +function permissionDeniedLine(locale: string | undefined): string { |
| 337 | + const usable = (line: unknown): string | undefined => |
| 338 | + typeof line === 'string' && line.trim() ? line : undefined; |
| 339 | + const base = locale ? locale.toLowerCase().split('-')[0] : undefined; |
| 340 | + return ( |
| 341 | + (locale ? usable((getLoadedBuiltInLocales()[locale] as PermissionLinePack)?.form?.noPermissionToSave) : undefined) |
| 342 | + ?? (base ? usable((getLoadedBuiltInLocales()[base] as PermissionLinePack)?.form?.noPermissionToSave) : undefined) |
| 343 | + ?? usable((getLoadedBuiltInLocales().en as PermissionLinePack)?.form?.noPermissionToSave) |
| 344 | + ?? "You don't have permission to save this record." |
| 345 | + ); |
| 346 | +} |
| 347 | + |
| 348 | +/** |
| 349 | + * A failed run's message, by the record-save rule (objectstack#24914 ruling |
| 350 | + * B′): the flow's authored `errorMessage` — translated when the bundle carries |
| 351 | + * `flows.FLOW.errorMessage` (objectui#12075) — then the producer-marked |
| 352 | + * `userMessage`, then the refusal the run's write node met (`innerRefusal`, |
| 353 | + * whose `message` the spec serves without the node and operation prefixes), |
| 354 | + * then the run's own `message`. |
| 355 | + * |
| 356 | + * The inner refusal is rendered exactly as the record form renders the same |
| 357 | + * refusal on a save: a permission denial (`isPermissionError`) becomes the |
| 358 | + * form's localized permission line instead of server prose (objectstack#3821), |
| 359 | + * anything else reads its own sentence. `message` stays the last rung, so a |
| 360 | + * run that met no refusal still says something, never nothing. |
295 | 361 | */ |
296 | | -function flowFailureMessage(data: FlowRunResult, fallback: string, copy: FlowCopyLocale | undefined): string { |
297 | | - const friendly = data.errorMessage; |
| 362 | +function flowFailureMessage(failure: FlowFailureCarriers, message: string, copy: FlowCopyLocale | undefined): string { |
| 363 | + const friendly = failure.errorMessage; |
298 | 364 | if (typeof friendly === 'string' && friendly.length > 0) return translatedFlowMessage('errorMessage', friendly, copy); |
299 | | - return actionErrorDetail(data, fallback); |
| 365 | + if (failure.userMessage) return failure.userMessage; |
| 366 | + const inner = failure.innerRefusal as { code?: unknown; message?: unknown } | null | undefined; |
| 367 | + if (inner && typeof inner === 'object' && typeof inner.code === 'string') { |
| 368 | + const refusal = { code: inner.code, message: typeof inner.message === 'string' ? inner.message : '' }; |
| 369 | + if (isPermissionError(refusal)) return permissionDeniedLine(copy?.locale); |
| 370 | + if (refusal.message.trim()) return refusal.message; |
| 371 | + } |
| 372 | + return message; |
300 | 373 | } |
301 | 374 |
|
302 | 375 | /** |
@@ -343,10 +416,15 @@ export function interpretFlowResponse<S = unknown>( |
343 | 416 | // authorable `errorMessage` stays preferred for it; the envelope's own |
344 | 417 | // `message` is the fallback. See the header note. |
345 | 418 | if (res.status === 400 && errorCodeIs(json?.error, 'FLOW_FAILED')) { |
| 419 | + const details = errorEnvelopeDetails(json); |
346 | 420 | return { |
347 | 421 | kind: 'failed', |
348 | 422 | error: flowFailureMessage( |
349 | | - errorEnvelopeDetails(json), |
| 423 | + { |
| 424 | + errorMessage: details.errorMessage, |
| 425 | + userMessage: declaredUserMessage(json?.error), |
| 426 | + innerRefusal: details.innerRefusal, |
| 427 | + }, |
350 | 428 | actionErrorDetail(json, `${label} failed`), |
351 | 429 | copy, |
352 | 430 | ), |
@@ -390,7 +468,15 @@ export function interpretFlowResponse<S = unknown>( |
390 | 468 | if (data.success === false || data.status === 'failed') { |
391 | 469 | return { |
392 | 470 | kind: 'failed', |
393 | | - error: flowFailureMessage(data, `${label} failed`, copy), |
| 471 | + error: flowFailureMessage( |
| 472 | + { |
| 473 | + errorMessage: data.errorMessage, |
| 474 | + userMessage: declaredUserMessage(data), |
| 475 | + innerRefusal: data.innerRefusal, |
| 476 | + }, |
| 477 | + actionErrorDetail(data, `${label} failed`), |
| 478 | + copy, |
| 479 | + ), |
394 | 480 | retryable: false, |
395 | 481 | }; |
396 | 482 | } |
|
0 commit comments