diff --git a/.changeset/studio-flow-problems-read-publish-verdict.md b/.changeset/studio-flow-problems-read-publish-verdict.md new file mode 100644 index 00000000000..470f178d970 --- /dev/null +++ b/.changeset/studio-flow-problems-read-publish-verdict.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(studio): the flow Problems list says what the publish will refuse, and a blank expression reads "This value needs an expression" + +Clause-②: no +Metadata-protocol: none + +On the Studio's Automations page, a flow's Problems list judged only the canvas structure, so an Update Records value written as an expression and left blank showed a refusal under the value while Problems said "No problems — this flow is structurally valid", and Publish then refused the flow. + +The Problems list now reads the verdict the pending draft's read already carries, the one the step inspectors read and the one Publish enforces, so a value the publish refuses is listed, located by the server's own words naming the step and the field. The value cell and the Problems row say the same thing, in business words: "This value needs an expression. Write one, or switch the value back to text." Any other refusal is shown as before. diff --git a/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts b/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts index fa23083ca41..618bf1efefa 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/draftVerdict.ts @@ -78,6 +78,22 @@ export function withServerOnlyErrors { + return [ + ...(diagnostics?.errors ?? []).map((e) => ({ path: e.path || undefined, message: e.message, severity: 'error' as const })), + ...(diagnostics?.warnings ?? []).map((w) => ({ path: w.path || undefined, message: w.message, severity: 'warning' as const })), + ]; +} + /** * objectstack#26858 — the served verdict on the body on screen, for a field * that edits one path of it. The page holding the draft read provides it diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.tsx index 9f71951b764..c772aa8c3f2 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.tsx @@ -49,6 +49,7 @@ import type { ScopeGroup } from './useFlowScope.js'; import { FlowExprIssue } from './FlowExprIssue.js'; import { ASSIGNMENT_ARRAY_FORM_PRESCRIPTION, + isBlankExpressionRefusal, isEditableValueEnvelope, valueEnvelopeRefusal, writeValueEnvelope, @@ -227,6 +228,12 @@ export interface FlowKeyValueFieldProps { toggleLabel: string; /** Placeholder of an expression row's source input. */ expressionPlaceholder: string; + /** + * What the cell says where the spec refuses an expression left blank + * (`isBlankExpressionRefusal`), in place of the envelope's wire shape. + * The verdict stays the spec's; only its words change. + */ + blankExpressionMessage?: string; }; /** The spec requires this map (objectui#10948): the label carries {@link RequiredMarker}. */ required?: boolean; @@ -371,6 +378,10 @@ export function FlowKeyValueField({ const hole = valueMode === 'hole' && !expression; const pickerGroups = expression || hole ? (scopeGroups ?? []) : []; const refusal = envelopeSlot && writesMap ? valueRefusal(rowValue(row)) : null; + const blankText = valueEnvelope?.blankExpressionMessage; + const shownRefusal = refusal + ? [...new Set(refusal.map((m) => (blankText && isBlankExpressionRefusal(m) ? blankText : m)))] + : null; return (
{/* @@ -436,9 +447,9 @@ export function FlowKeyValueField({ )}
- {refusal ? ( + {shownRefusal ? (
- {refusal.map((message, i) => ( + {shownRefusal.map((message, i) => (

{message}

diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.valueEnvelope.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.valueEnvelope.test.tsx index c05019bad3b..38c4b3d323d 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.valueEnvelope.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.valueEnvelope.test.tsx @@ -16,6 +16,7 @@ import { } from '@objectstack/spec/automation'; import { FlowNodeConfigField } from './FlowNodeConfigField'; import type { FlowConfigField } from './flow-node-config'; +import { t } from '../i18n'; afterEach(cleanup); @@ -119,7 +120,11 @@ describe('assignment value cell — text or CEL expression (objectui#7588)', () expect(screen.queryByRole('alert')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: TOGGLE })); expect(onCommit).toHaveBeenLastCalledWith({ empty: { dialect: 'cel', source: '' } }); - expect(screen.getByRole('alert').textContent).toContain(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL); + // objectstack#27799 — the spec's refusal of a blank expression, said in + // business words rather than the envelope's wire shape. + const alert = screen.getByRole('alert').textContent ?? ''; + expect(alert).toBe(t('engine.inspector.flowNode.kv.expressionNeeded')); + expect(alert).not.toContain(ASSIGNMENT_VALUE_ENVELOPE_REFUSAL); }); it('names the legacy array form with the spec prescription, and an expression writes the map', () => { diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx index f0420f0e674..3553623f08c 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx @@ -621,6 +621,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, ? { toggleLabel: t('engine.inspector.flowNode.kv.asExpression', locale), expressionPlaceholder: t('engine.inspector.flowNode.kv.expressionPlaceholder', locale), + blankExpressionMessage: t('engine.inspector.flowNode.kv.expressionNeeded', locale), } : undefined, // objectui#12101 — a map whose every value is a flow text slot (http diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.ts index b3dc18e5b02..f9ccb52ba8a 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-value-envelope.ts @@ -118,3 +118,23 @@ export function valueEnvelopeRefusal(value: unknown): string[] | null { const result = AssignmentValueSchema.safeParse(value); return result.success ? null : result.error.issues.map((issue) => issue.message); } + +/** + * The spec's refusal of an expression left blank, read once from the spec's + * own judge rather than restated: the messages {@link valueEnvelopeRefusal} + * gives an envelope in the slot's dialect whose `source` is empty. + */ +const BLANK_EXPRESSION_REFUSALS: ReadonlySet = new Set( + valueEnvelopeRefusal({ dialect: VALUE_DIALECT, source: '' }) ?? [], +); + +/** + * Whether `message` is the spec's refusal of a blank expression in a value + * slot, so an editor can say it in business words. The node's value cell and + * the flow's Problems list both ask this of the one verdict they show (the + * cell's live spec parse, the publish verdict the draft read serves), so the + * two say the same thing. + */ +export function isBlankExpressionRefusal(message: string): boolean { + return BLANK_EXPRESSION_REFUSALS.has(message); +} diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/ProblemsPanel.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/ProblemsPanel.tsx index 0b56b3020a6..90ed7ad4800 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/ProblemsPanel.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/ProblemsPanel.tsx @@ -62,7 +62,7 @@ function targetLabel( title: `${p.target.source} → ${p.target.target}`, }; } - return { text: 'flow' }; + return p.location ? { text: p.location, title: p.location } : { text: 'flow' }; } export function ProblemsPanel({ problems, selectedKey, onSelectProblem, locale, runStatus, nodes = [] }: ProblemsPanelProps) { diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts index a8b2772b4db..dfc896e60b8 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/flow-problems.ts @@ -32,6 +32,7 @@ import { describeExprSite, flowNodeIds, nodeIdPositions, type ExprSite } from '. import { flowNodeDisplayName } from './flow-canvas-parts.js'; import { t, tFormat } from '../i18n.js'; import { uniqueId } from '../inspectors/unique-id.js'; +import { isBlankExpressionRefusal } from '../inspectors/flow-value-envelope.js'; /** What a problem points at on the canvas — drives badge placement + reveal. */ export type FlowProblemTarget = @@ -55,6 +56,14 @@ export interface FlowProblem { * e.g. every hop of a cycle. The badge + click-reveal still use `target`. */ highlight?: { nodeIds: string[]; edges: Array<{ source: string; target: string }> }; + /** + * Where the server located a problem it could not place on an element: its + * own locator, shown as the row's target in place of the bare flow. A + * publish finding on a node's config value is located in words (`flow 'x' · + * node 'y' (update_record) … at config.fields.id`), and that is the only + * place the row names the node and the field. + */ + location?: string; } /** A server diagnostic entry (subset of the layered record's `_diagnostics`). */ @@ -269,12 +278,17 @@ export function buildFlowProblems({ nodes, edges, serverDiagnostics, variables, (serverDiagnostics ?? []).forEach((diag, i) => { const level: DiagnosticLevel = diag.severity === 'warning' ? 'warning' : 'error'; const target = serverTarget(diag.path, nodes, edges); + const location = target.kind === 'flow' ? pathSegments(diag.path).join('.') : ''; problems.push({ id: `server:${i}:${targetKey(target)}`, level, - message: diag.message, + // The words the node's value cell uses for the same refusal. + message: isBlankExpressionRefusal(diag.message) + ? t('engine.inspector.flowNode.kv.expressionNeeded', locale) + : diag.message, target, source: 'server', + ...(location ? { location } : {}), }); }); diff --git a/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.problemsAgree-27799.test.tsx b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.problemsAgree-27799.test.tsx new file mode 100644 index 00000000000..a192005b036 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/AutomationsPillar.problemsAgree-27799.test.tsx @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27799 — on the Studio Automations page, a flow's Problems list + * and a node's value cell agree about a value written as a blank expression. + * + * The spec's value-slot rule is the one judge: the cell runs it live, and the + * publish gate runs it on the stored draft, whose verdict the draft read + * serves (`_diagnostics`). The page already handed that verdict to the + * inspectors (objectstack#26858) but not to the canvas, so its Problems list + * read "No problems" for a flow the publish refuses. It now reads the same + * verdict, and both say the refusal in the same business words. + * + * The client is a server double that judges the way the server does: the save + * door parses `FlowSchema` (the flow parse does not run the value-slot rule), + * and the draft read folds in the publish gate's finding, which is the real + * `validateStackExpressions` from `@objectstack/lint`, located by its `where` + * as the gate locates a node's config value. The canvas is the real + * `FlowPreview` beside a button that selects the Update Records node. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { FlowSchema } from '@objectstack/spec/automation'; +import { validateStackExpressions } from '@objectstack/lint'; + +const PKG = 'com.acme.app'; + +const FLOW = { + name: 'touch_account', + label: 'Touch account', + type: 'record_change', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'account', triggerType: 'on_update' } }, + { id: 'write', type: 'update_record', label: 'Write', config: { objectName: 'account' } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'write' }, + { id: 'e2', source: 'write', target: 'end' }, + ], +}; + +type Entry = { path: string; message: string; code?: string }; + +const server = vi.hoisted(() => ({ draft: null as Record | null, draftReads: 0 })); + +/** The save door's gate: the flow parse. */ +function schemaErrors(body: unknown): Entry[] { + const parsed = FlowSchema.safeParse(body); + return parsed.success ? [] : parsed.error.issues.map((i) => ({ path: i.path.map(String).join('.'), message: i.message })); +} + +/** The draft read's `_diagnostics`: the parse, plus what the publish gate refuses. */ +function draftVerdict(body: Record): { valid: boolean; errors?: Entry[] } { + const publish = validateStackExpressions({ flows: [body] }) + .filter((i) => (i.severity ?? 'error') === 'error') + .map((i) => ({ path: i.path ?? i.where, message: i.message, code: 'expression-invalid' })); + const errors = [...schemaErrors(body), ...publish]; + return errors.length ? { valid: false, errors } : { valid: true }; +} + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async () => [{ name: 'touch_account', label: 'Touch account' }]), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async () => ({ code: null, overlay: null, overlayScope: null, effective: null, editable: true })), + getDraft: vi.fn(async (type: string, name: string) => { + server.draftReads += 1; + if (!server.draft) { + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + } + return { type, name, item: { ...server.draft, _draft: true, _diagnostics: draftVerdict(server.draft) } }; + }), + save: vi.fn(async (type: string, name: string, item: unknown) => { + const issues = schemaErrors(item); + if (issues.length) { + throw Object.assign(new Error(`${type}/${name} failed spec validation`), { code: 'INVALID_METADATA', status: 422, issues }); + } + server.draft = JSON.parse(JSON.stringify(item)) as Record; + return { type, name, item }; + }), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +// The engine palette / config-schema reads at their offline answer, and an +// empty field catalog (the shape `flowNodeLabels-13200.test.tsx` uses). +vi.mock('../metadata-admin/previews/useFlowNodePalette', async () => { + const { NODE_PALETTE } = await import('../metadata-admin/previews/flow-canvas-parts'); + return { useActionConfigSchemas: () => ({}), useFlowNodePalette: () => NODE_PALETTE }; +}); +vi.mock('../metadata-admin/previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { AutomationsPillar } from './StudioDesignSurface'; +import { createEmptyDataSource } from './__tests__/emptyDataSource'; +import { getMetadataPreview, registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; +import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector'; +import { FlowPreview } from '../metadata-admin/previews/FlowPreview'; +import { valueEnvelopeRefusal } from '../metadata-admin/inspectors/flow-value-envelope'; +import { t } from '../metadata-admin/i18n'; + +const dataSource = createEmptyDataSource(); + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +/** The real flow canvas, plus a button that selects the Update Records node. */ +function PreviewWithSelect(props: React.ComponentProps) { + return ( + <> + + + + ); +} + +const NEEDED = () => t('engine.inspector.flowNode.kv.expressionNeeded'); +const EMPTY = () => t('engine.flowProblems.empty'); + +const realFlow = getMetadataPreview('flow'); +registerMetadataInspector('flow', FlowInspector); + +beforeEach(() => { + server.draft = JSON.parse(JSON.stringify(FLOW)); + server.draftReads = 0; + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + registerMetadataPreview('flow', PreviewWithSelect as never); +}); + +afterEach(() => { + cleanup(); + if (realFlow) registerMetadataPreview('flow', realFlow); +}); + +/** Open the flow, select Update Records, add the value `id` and switch it to a blank expression. */ +async function writeBlankExpression() { + render( + + + , + ); + fireEvent.click(await screen.findByRole('button', { name: 'select write' }, { timeout: 8000 })); + // The node has two maps (Filter, Field values); the value goes in Field values. + const adds = await screen.findAllByRole('button', { name: 'Add entry' }, { timeout: 8000 }); + const fieldValues = adds.find((b) => b.parentElement?.querySelector('label')?.textContent === 'Field values'); + expect(fieldValues).toBeTruthy(); + const readsBefore = server.draftReads; + fireEvent.click(fieldValues!); + const map = fieldValues!.parentElement!; + const key = within(map).getByPlaceholderText('Key'); + fireEvent.change(key, { target: { value: 'id' } }); + fireEvent.blur(key); + fireEvent.click(within(map).getByRole('button', { name: 'Write as a CEL expression' })); + return { map, readsBefore }; +} + +describe('Automations page — Problems and the node agree on a blank expression (objectstack#27799)', () => { + it('the publish verdict on the blank value is the node cell\'s own refusal: one judge', () => { + const blank = { dialect: 'cel', source: '' }; + const write = { ...FLOW.nodes[1], config: { objectName: 'account', fields: { id: blank } } }; + const body = { ...FLOW, nodes: [FLOW.nodes[0], write, FLOW.nodes[2]] }; + // The flow parse lets the value through, so the draft is stored … + expect(schemaErrors(body)).toEqual([]); + // … and the publish verdict refuses it with exactly what the cell's live spec parse says. + expect((draftVerdict(body).errors ?? []).map((e) => e.message)).toEqual(valueEnvelopeRefusal(blank)); + }); + + it('the node cell and the Problems list both name the blank expression, in the same words', async () => { + const { map, readsBefore } = await writeBlankExpression(); + + // The node: business words, not the envelope's wire shape. + expect(within(map).getByRole('alert').textContent).toBe(NEEDED()); + + // The draft is stored, and its verdict read again … + await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 6000 }); + await waitFor(() => expect(server.draftReads).toBeGreaterThan(readsBefore), { timeout: 6000 }); + + // … and the Problems list says what the publish will refuse, and where. + fireEvent.click(screen.getByTestId('auto-tab-problems')); + const target = await screen.findByTestId('flow-problem-target', {}, { timeout: 6000 }); + expect(screen.queryByText(EMPTY())).toBeNull(); + const row = target.closest('li')!; + expect(within(row).getByText(NEEDED())).toBeInTheDocument(); + expect(target.textContent).toContain("node 'write'"); + expect(target.textContent).toContain('config.fields.id'); + expect(screen.getByTestId('auto-problems-count')).toHaveTextContent('1'); + }); + + it('control: once the expression is written, the cell and the Problems list clear together', async () => { + const { map } = await writeBlankExpression(); + await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 6000 }); + fireEvent.click(screen.getByTestId('auto-tab-problems')); + await screen.findByTestId('flow-problem-target', {}, { timeout: 6000 }); + + const saves = mockClient.save.mock.calls.length; + const source = within(map).getByPlaceholderText('CEL expression, e.g. joinNonEmpty(…)'); + fireEvent.change(source, { target: { value: 'record.id' } }); + fireEvent.blur(source); + + expect(within(map).queryByRole('alert')).toBeNull(); + await waitFor(() => expect(mockClient.save.mock.calls.length).toBeGreaterThan(saves), { timeout: 6000 }); + expect(await screen.findByText(EMPTY(), {}, { timeout: 6000 })).toBeInTheDocument(); + expect(screen.queryByTestId('flow-problem-target')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 9befa881905..627857d034f 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -236,6 +236,7 @@ import { DraftVerdictContext, draftEnvelopeDiagnostics, editedItemDiagnostics, + verdictPreviewEntries, type ServerDiagnostics, } from '../metadata-admin/draftVerdict.js'; import { @@ -282,7 +283,7 @@ import { useSurfaceDeepLink, resolveSurfaceDeepLink, type SurfaceTarget } from ' import { SurfaceDeepLinkProvider, useRequestedSurface, useSurfaceNavigator } from './surfaceDeepLinkChannel.js'; import { buildObjectSkeleton, buildFlowSkeleton, buildAppSkeleton, buildPermissionSkeleton, type AppNavSeed } from './skeletons.js'; import { OWD_CREATE_MODELS, OWD_DEFAULT, type OwdCreateModel } from './owd-sharing.js'; -import { t, tFormat, translateMetadataType, useMetadataLocale } from '../metadata-admin/i18n.js'; +import { t, tFormat, translateMetadataType, translateValidationMessage, useMetadataLocale } from '../metadata-admin/i18n.js'; import { useRegisterUnsavedEdits } from '../../chrome/unsavedEdits.js'; import { fieldsForNodeType, localizeFlowFields, type FlowConfigField } from '../metadata-admin/inspectors/flow-node-config.js'; import { ObjectPicker } from '../metadata-admin/inspectors/ObjectPicker.js'; @@ -8537,6 +8538,16 @@ export function AutomationsPillar({ // draft's (what its publish will refuse), else the layered read's. Taken off // each read, never computed here; the inspectors read it at their paths. const [flowVerdict, setFlowVerdict] = React.useState(undefined); + // objectstack#27799 — and the flow's Problems list reads that same verdict, + // so it never calls a flow valid that its publish refuses. + const flowPreviewDiagnostics = React.useMemo( + () => + verdictPreviewEntries(flowVerdict).map((entry) => ({ + ...entry, + message: translateValidationMessage(entry.message, locale), + })), + [flowVerdict, locale], + ); const flowVerdictSeq = React.useRef(0); const rereadFlowVerdict = React.useCallback( (flowName: string) => { @@ -9506,6 +9517,8 @@ export function AutomationsPillar({ // edge selection still open the inspector read-only below. onPatch: readOnly ? undefined : onPatch, locale, + // objectstack#27799 — the Problems list reads the verdict the inspectors read. + diagnostics: flowPreviewDiagnostics, })} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index a5a65380516..1bb4083a2a4 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -651,6 +651,7 @@ const STUDIO_EN: Record = { // map (the assignment node's `assignments`); see `flow-value-envelope.ts`. 'engine.inspector.flowNode.kv.asExpression': 'Write as a CEL expression', 'engine.inspector.flowNode.kv.expressionPlaceholder': 'CEL expression, e.g. joinNonEmpty(…)', + 'engine.inspector.flowNode.kv.expressionNeeded': 'This value needs an expression. Write one, or switch the value back to text.', 'engine.inspector.flowNode.list.add': 'Add item', 'engine.inspector.flowNode.list.item': 'Value', 'engine.inspector.flowNode.list.remove': 'Remove item', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index adf8be2a13a..577447a5356 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -557,6 +557,7 @@ const STUDIO_ZH: Record = { 'engine.inspector.flowNode.kv.empty': '暂无条目。', 'engine.inspector.flowNode.kv.asExpression': '写成 CEL 表达式', 'engine.inspector.flowNode.kv.expressionPlaceholder': 'CEL 表达式,例如 joinNonEmpty(…)', + 'engine.inspector.flowNode.kv.expressionNeeded': '这个值需要一个表达式。请填写表达式,或把这个值切回文本。', 'engine.inspector.flowNode.list.add': '添加项', 'engine.inspector.flowNode.list.item': '值', 'engine.inspector.flowNode.list.remove': '删除项',