From 964780002aa645ef28ee980cbf4d2ea52a355021 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 14:54:39 +0000 Subject: [PATCH 1/3] feat(app-shell): the flow node inspector marks the config keys the installed spec refuses the node without (objectui#10948) Spec 17.5.0 refuses at authoring a flow node whose executor could not run it: a required config key left out, a decision branch with no label, a screen field with no name, a connector_action naming no connector/action. The designer's live FlowSchema pass already locates each at the node's config path; this marks the same keys in the inspector before the author reaches that error, with SchemaForm's required marker idiom. The source is the installed spec, asked of the node as it stands (flow-required-keys.ts): the key is removed from a copy of the node and handed to the judges the flow parse runs (flowNodeConfigRefusals, the predicate-slot walk, FlowNodeSchema). No required-key list lives here. Pins: every seeded kind's located save error and a clean control flow (the measurement), a blank branch label / screen field name authored through the real row editor, and the markers per seeded kind as an equality over the drawn labels. Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude --- .../10948-flow-inspector-required-keys.md | 22 ++ .../inspectors/FlowKeyValueField.tsx | 10 +- .../inspectors/FlowNodeConfigField.tsx | 37 ++- ...deInspector.requiredMarkers-10948.test.tsx | 251 ++++++++++++++++++ .../inspectors/FlowNodeInspector.tsx | 19 ++ .../inspectors/FlowObjectListField.tsx | 26 +- .../inspectors/FlowReferenceField.tsx | 11 +- .../inspectors/FlowStringListField.tsx | 10 +- .../inspectors/VariableTextInput.tsx | 7 + .../metadata-admin/inspectors/_shared.tsx | 37 ++- .../inspectors/flow-required-keys.ts | 145 ++++++++++ ...low-canvas-seeds.saveErrors-10948.test.tsx | 231 ++++++++++++++++ 12 files changed, 791 insertions(+), 15 deletions(-) create mode 100644 .changeset/10948-flow-inspector-required-keys.md create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.requiredMarkers-10948.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/flow-required-keys.ts create mode 100644 packages/app-shell/src/views/metadata-admin/previews/flow-canvas-seeds.saveErrors-10948.test.tsx diff --git a/.changeset/10948-flow-inspector-required-keys.md b/.changeset/10948-flow-inspector-required-keys.md new file mode 100644 index 0000000000..5073e52a0e --- /dev/null +++ b/.changeset/10948-flow-inspector-required-keys.md @@ -0,0 +1,22 @@ +--- +'@object-ui/app-shell': minor +--- + +feat(app-shell): the flow node inspector marks the config keys the installed spec refuses the node +without (objectui#10948). + +`@objectstack/spec` 17.5.0 refuses, at authoring, a flow node whose executor could not run it: a key +its config contract requires left out (a CRUD node's `objectName`, an `http` node's `url`, a +`notify` node's `title` while it has no `template`, a `script` node's `function`, a `subflow` or +`map` node's `flowName`, …), a decision branch with no `label` or `expression`, a screen field with +no `name`, and a `connector_action` whose `connectorConfig` names no connector or action. The +designer's live flow check already reports each one at the node's config path; the inspector now +marks the same keys before the author reaches that error, with the metadata form's own required +marker — the `*` in the field label, the row label of a branch or screen-field row, and +`aria-required` on the control where the inspector owns it. + +The inspector keeps no list of required keys. Each marker is the installed spec's own verdict, asked +of the node as it stands: the key is removed from a copy of the node and handed to the judges the +flow parse runs (`flowNodeConfigRefusals`, the predicate-slot walk, and `FlowNodeSchema`), so a +requirement that depends on the configuration — `notify`'s `title` without a `template`, a `loop`'s +`collection` once it has a body, a refused `end`'s `message` — is marked only while it applies. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.tsx index 8c612b27f3..9d385a701f 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowKeyValueField.tsx @@ -26,7 +26,7 @@ import * as React from 'react'; import { Code2, Plus, X } from 'lucide-react'; import { Button, Input, Label, cn } from '@object-ui/components'; -import { uniqueId } from './_shared.js'; +import { RequiredMarker, uniqueId } from './_shared.js'; import { VariableTextInput } from './VariableTextInput.js'; import type { ScopeGroup } from './useFlowScope.js'; import { FlowExprIssue } from './FlowExprIssue.js'; @@ -211,6 +211,8 @@ export interface FlowKeyValueFieldProps { /** Placeholder of an expression row's source input. */ expressionPlaceholder: string; }; + /** The spec requires this map (objectui#10948): the label carries {@link RequiredMarker}. */ + required?: boolean; } /** @@ -242,6 +244,7 @@ export function FlowKeyValueField({ emptyLabel, scopeGroups, valueEnvelope, + required, }: FlowKeyValueFieldProps) { // Preserve whichever shape the value was authored in (object map vs the // assignment-node array form) across edits. @@ -301,7 +304,10 @@ export function FlowKeyValueField({ return (
- + {envelopeSlot && arrayShape && (

{ASSIGNMENT_ARRAY_FORM_PRESCRIPTION} diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx index ac18721214..c170895b1d 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx @@ -15,6 +15,7 @@ import { InspectorNumberField, InspectorSelectField, InspectorCheckboxField, + RequiredMarker, flagUnknownValue, } from './_shared.js'; import { Button, Label } from '@object-ui/components'; @@ -150,9 +151,22 @@ export interface FlowNodeConfigFieldProps { * DELIBERATELY. Omit to render the notice without the button. */ onClearInactive?: () => void; + /** + * objectui#10948 — the installed spec refuses this node without a value at + * `field.path`. Supplied by the host inspector, which owns the node, from + * `specRequiresField` (`flow-required-keys.ts`) — the spec's own verdict, + * never a list kept here. The label carries {@link RequiredMarker}; a control + * this component owns carries `aria-required`. + */ + required?: boolean; + /** + * objectui#10948 — for an `objectList` field, the column keys the spec + * requires on every row (`specRequiredColumns`), handed to the list editor. + */ + requiredColumns?: ReadonlySet; } -export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, context, scopeGroups, approvalScopeGroups, triggerScope, inactiveRetained, onClearInactive }: FlowNodeConfigFieldProps) { +export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, context, scopeGroups, approvalScopeGroups, triggerScope, inactiveRetained, onClearInactive, required, requiredColumns }: FlowNodeConfigFieldProps) { const refMode: 'expression' | 'template' = field.refMode ?? (field.kind === 'expression' ? 'expression' : 'template'); // objectui#6226 — the row-based condition builder, on the fields that opted in @@ -190,6 +204,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, onCommit={(v) => onCommit(v)} disabled={disabled} context={context} + required={required} /> ); case 'keyValue': @@ -205,6 +220,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, removeLabel={t('engine.inspector.flowNode.kv.remove', locale)} emptyLabel={t('engine.inspector.flowNode.kv.empty', locale)} scopeGroups={scopeGroups} + required={required} // objectui#7588 — the per-value text / expression toggle, offered // only on a map the spec's expression ledger declares `value`-role // for this node type (today the assignment node's `assignments`). @@ -225,6 +241,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, value={value} onCommit={(v) => onCommit(v)} disabled={disabled} + required={required} addLabel={t('engine.inspector.flowNode.list.add', locale)} itemLabel={t('engine.inspector.flowNode.list.item', locale)} removeLabel={t('engine.inspector.flowNode.list.remove', locale)} @@ -246,6 +263,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, onCommit(nums.length ? nums : undefined); }} disabled={disabled} + required={required} addLabel={t('engine.inspector.flowNode.list.add', locale)} itemLabel={t('engine.inspector.flowNode.list.item', locale)} removeLabel={t('engine.inspector.flowNode.list.remove', locale)} @@ -270,6 +288,8 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, // objectui#10772 — with `context.node`, names a screen's `fields` // list, whose `visibleWhen` column binds the screen's own fields. fieldId={field.id} + required={required} + requiredColumns={requiredColumns} /> ); case 'number': @@ -280,6 +300,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, placeholder={field.placeholder} onCommit={(v) => onCommit(v)} disabled={disabled} + required={required} /> ); case 'boolean': { @@ -391,6 +412,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, } onCommit={(v) => onCommit(v)} disabled={disabled} + required={required} /> ); })(); @@ -402,6 +424,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,

@@ -410,7 +433,10 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, case 'textarea': return (
- +
); @@ -428,7 +455,10 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, default: return (
- +
); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.requiredMarkers-10948.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.requiredMarkers-10948.test.tsx new file mode 100644 index 0000000000..76111a3524 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.requiredMarkers-10948.test.tsx @@ -0,0 +1,251 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10948 — the flow node inspector marks the config keys the installed + * spec refuses the node without, so the author meets the requirement before the + * save-time error does. + * + * The marker is the metadata form's own (`RequiredMarker`, `SchemaForm`'s + * `data-required-marker` `*`), and its source is the spec, asked at render time + * (`flow-required-keys.ts`): no required-key list lives in the product. The + * expectations below are the MEASURED answer for each seeded node — what the + * installed spec requires of it — and each is asserted as an EQUALITY over the + * labels the form draws, so one row pins both halves: a marker where the spec + * requires the key, and none where it does not. + * + * Every row is lit by a control: the labels it expects UNMARKED are asserted + * present, so an empty marked set can never be a form that drew nothing. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +vi.mock('../previews/useFlowNodePalette', () => ({ + useActionConfigSchemas: () => ({}), + useFlowNodePalette: () => [], +})); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { FlowNodeInspector } from './FlowNodeInspector'; +import { fieldsForNodeType } from './flow-node-config'; +import { specRequiredColumns, specRequiresField } from './flow-required-keys'; +import type { MetadataSelection } from '../preview-registry'; +import { defaultNodeExtras, defaultNodeLabel } from '../previews/flow-canvas-parts'; + +const META_PREFIX = '/api/v1/meta/'; +let calls: string[] = []; +const routeOf = (url: string) => url.split('?')[0]; + +beforeEach(() => { + calls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String( + input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input, + ); + calls.push(url); + const route = routeOf(url); + if (!route.startsWith(META_PREFIX)) { + return { ok: false, status: 404, headers: new Headers(), json: async () => ({}) }; + } + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({ type: route.slice(META_PREFIX.length), items: [] }), + }; + }), + ); +}); + +afterEach(() => { + expect(calls.filter((url) => !routeOf(url).startsWith(META_PREFIX))).toEqual([]); + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderNode(node: Record) { + const { container } = render( + , + ); + return container; +} + +/** A label's text as the author reads it, without the marker. */ +const ownText = (el: Element) => { + const copy = el.cloneNode(true) as Element; + copy.querySelectorAll('[data-required-marker]').forEach((m) => m.remove()); + return (copy.textContent ?? '').trim(); +}; + +/** Every label the form drew that carries the required marker, by its own text. */ +const markedLabels = (container: HTMLElement) => + Array.from(container.querySelectorAll('[data-required-marker="true"]')).map((m) => { + expect(m.getAttribute('aria-hidden'), 'the marker is visual-only, as in SchemaForm').toBe('true'); + return ownText(m.parentElement!); + }); + +/** Every label (`