diff --git a/.changeset/form-generation.md b/.changeset/form-generation.md new file mode 100644 index 00000000..65d29697 --- /dev/null +++ b/.changeset/form-generation.md @@ -0,0 +1,5 @@ +--- +"@openworkflowspec/diagram-editor": minor +--- + +Add dynamic task form generation driven by JSON schema. diff --git a/packages/open-workflow-diagram-editor/src/core/index.ts b/packages/open-workflow-diagram-editor/src/core/index.ts index 407547e2..015e1470 100644 --- a/packages/open-workflow-diagram-editor/src/core/index.ts +++ b/packages/open-workflow-diagram-editor/src/core/index.ts @@ -18,11 +18,13 @@ export * from "./workflowSdk"; export * from "./workflowEditing"; export * from "./validationErrors"; export * from "./graph"; -export * from "./taskDetails"; export * from "./taskDraft"; export * from "./taskSubType"; export * from "./elkjs"; export * from "./mermaidExport"; export * from "./schemaFilter"; +export * from "./schemaWalker"; +export * from "./structuralEqual"; +export * from "./schemaToFormFields"; /* TEMPORARY — remove with the workaround; see the revert checklist in workflowSdk.ts. */ export * from "./specWorkarounds"; diff --git a/packages/open-workflow-diagram-editor/src/core/schemaToFormFields.ts b/packages/open-workflow-diagram-editor/src/core/schemaToFormFields.ts new file mode 100644 index 00000000..f3b23a85 --- /dev/null +++ b/packages/open-workflow-diagram-editor/src/core/schemaToFormFields.ts @@ -0,0 +1,835 @@ +/* + * Copyright 2021-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DereferencedSchema } from "./schemaFilter"; + +/** + * A single form field descriptor produced by walking a task's JSON Schema. + * Each descriptor drives one row in the form UI. + * + * The walker is fully generic — it understands JSON Schema structure but + * carries no knowledge of any specific schema definition names or task types. + * Domain-specific concerns (e.g. mapping graph node types to definition names) + * live in `src/core/schemaWalker.ts`. + */ +export type FormFieldDescriptor = + | StringField + | NumberField + | BooleanField + | EnumField + | DurationField + | ThenField + | ChildTaskListField + | ObjectField + | MapField + | OneOfField; + +interface FieldBase { + /** Dot-notation path from the task root, e.g. "for.each" */ + path: string; + /** Human-readable label (title from schema, or the last path segment) */ + label: string; + /** Schema description shown as a tooltip when present */ + description?: string | undefined; + /** Whether the field must have a value (required in schema) */ + required: boolean; +} + +export interface StringField extends FieldBase { + kind: "string"; + /** When true the field uses a Textarea rather than an Input */ + multiline: boolean; + /** The runtime-expression pattern — field value must match `${...}` syntax */ + isRuntimeExpression: boolean; + /** Optional placeholder hint, e.g. "https://example.com/api/{id}" */ + placeholder?: string | undefined; +} + +export interface NumberField extends FieldBase { + kind: "number"; +} + +export interface BooleanField extends FieldBase { + kind: "boolean"; +} + +export interface EnumField extends FieldBase { + kind: "enum"; + options: string[]; +} + +/** + * An ISO-8601 duration string field. + * Identified structurally: a string property whose `pattern` starts with `^P`. + */ +export interface DurationField extends FieldBase { + kind: "duration"; +} + +/** + * The `then` transition field — a combobox driven by sibling task names + * in the workflow. Identified structurally: any property named `then`, or + * any property whose schema is an `anyOf` containing an enum variant and a + * plain-string variant (the flowDirective pattern). + */ +export interface ThenField extends FieldBase { + kind: "then"; +} + +/** + * A property that resolves to an array of tagged task entries. + * Rendered as a read-only list of child-task names. + * + * Identified structurally: an array whose `items.additionalProperties.$ref` + * points to the task union definition. + */ +export interface ChildTaskListField extends FieldBase { + kind: "child-task-list"; +} + +/** + * A plain object with known sub-properties. + * Rendered as a collapsible group that recurses into its children. + */ +export interface ObjectField extends FieldBase { + kind: "object"; + children: FormFieldDescriptor[]; +} + +/** + * An open-ended key-value map (an object schema with `additionalProperties` + * set and no fixed `properties` block). + * + * Identified structurally so it works with any conforming schema definition — + * not just `setTask`. Examples: `set`, `with` (custom function call), + * `headers`, `query`, `environment` in runTask scripts, etc. + * + * Rendered as a dynamic list of key/value rows with add and delete controls. + */ +export interface MapField extends FieldBase { + kind: "map"; +} + +/** + * A field that can hold one of several variant types (oneOf / anyOf in the + * schema). Each variant is a sub-schema with its own label and child fields. + */ +export interface OneOfField extends FieldBase { + kind: "one-of"; + variants: OneOfVariant[]; +} + +export interface OneOfVariant { + /** Label for the variant (from schema `title`, or a generated fallback) */ + label: string; + /** The fields that belong to this variant */ + fields: FormFieldDescriptor[]; + /** + * Discriminator predicate: given the actual task value at this field's path, + * returns true when this variant is the one that matches the current data. + * Used in read-only mode to auto-select the correct variant. + */ + matchesData: (data: unknown) => boolean; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const RUNTIME_EXPRESSION_PATTERN = /^\s*\$\{.+\}\s*$/; + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** Resolve a `$ref` string like `"#/$defs/taskList"` against the local `$defs` block. */ +function resolveRef( + ref: string, + defs: Record | undefined, +): Record | null { + if (!ref.startsWith("#/$defs/") || !defs) return null; + const name = ref.slice("#/$defs/".length); + const def = defs[name]; + return isPlainObject(def) ? def : null; +} + +/** + * Returns true if the schema represents an open-ended key-value map: a plain + * object with `additionalProperties` set (to `true` or to a sub-schema) and + * no fixed `properties` block. + * + * Detection is purely structural — no schema definition name is referenced — + * so it captures every place the workflow schema uses this pattern: `set`, + * `with` (custom function call), `headers`, `query`, `environment`, etc. + */ +function isMapSchema(schema: Record): boolean { + if (schema.type !== "object" || !!schema.properties) return false; + // Explicit additionalProperties — covers schemas like set's object variant + // ({ type: "object", additionalProperties: true }). + if (schema.additionalProperties !== undefined && schema.additionalProperties !== false) + return true; + // Bare { type: "object" } with no structural constraints — treat as an open + // key-value map. This covers input.from / output.as / export.as which omit + // additionalProperties but are semantically identical open maps. + if (schema.additionalProperties === undefined && !schema.oneOf && !schema.anyOf) return true; + return false; +} + +/** + * Returns true if the schema node (or any `$ref` it resolves to) represents + * a task-list — an array whose `items.additionalProperties.$ref` points to + * the task union. Detection is purely structural; no definition name is + * hardcoded beyond the conventional task-union ref pattern. + */ +function isTaskListSchema( + schema: Record, + defs: Record | undefined, +): boolean { + let node: Record = schema; + + // Follow one level of $ref + if (typeof node.$ref === "string") { + const resolved = resolveRef(node.$ref, defs); + if (!resolved) return false; + node = resolved; + } + + if (node.type !== "array") return false; + const items = node.items; + if (!isPlainObject(items)) return false; + const ap = (items as Record).additionalProperties; + if (!isPlainObject(ap)) return false; + const apRef = ap.$ref; + // Matches any ref whose last path segment is "task" (e.g. "#/$defs/task") + return typeof apRef === "string" && (apRef === "#/$defs/task" || apRef.endsWith("/task")); +} + +/** + * Returns true if the schema represents a flow-directive field — a combobox + * that combines a set of named flow directives with a free-text task reference. + * + * Detection is structural: an `anyOf` that contains at least one variant with + * an `enum` array and at least one plain-string variant (no enum). This + * matches the `flowDirective` definition without referring to its name. + */ +function isFlowDirectiveSchema( + schema: Record, + defs: Record | undefined, +): boolean { + // Recurse through a single level of $ref first + if (typeof schema.$ref === "string") { + const resolved = resolveRef(schema.$ref, defs); + if (resolved) return isFlowDirectiveSchema(resolved, defs); + } + + if (!Array.isArray(schema.anyOf)) return false; + const anyOf = schema.anyOf as unknown[]; + + const hasEnum = anyOf.some( + (v) => isPlainObject(v) && Array.isArray((v as Record).enum), + ); + const hasPlainString = anyOf.some( + (v) => + isPlainObject(v) && + (v as Record).type === "string" && + !Array.isArray((v as Record).enum), + ); + return hasEnum && hasPlainString; +} + +/** Derive a human-readable label from a schema node and the property key. */ +function deriveLabel(schema: Record, key: string): string { + if (typeof schema.title === "string") { + // Strip any CamelCase prefix from composite titles like "ForTaskDo" → "Do" + const words = schema.title + .replace(/([A-Z])/g, " $1") + .trim() + .split(" "); + return words[words.length - 1] ?? key; + } + return key; +} + +/** Formats a schema title or camelCase identifier into a user-friendly label. */ +function formatVariantLabel(title: string): string { + if ( + title === "UriTemplate" || + title === "LiteralEndpointURI" || + title === "LiteralUriTemplate" || + title === "LiteralUri" + ) { + return "URI"; + } + if (title === "RuntimeExpression" || title === "ExpressionEndpointURI") { + return "Expression"; + } + // Split camelCase into words (e.g. "EndpointConfiguration" -> "Endpoint Configuration") + return title + .replace(/([A-Z][a-z]+)/g, " $1") + .replace(/([A-Z]+)(?=[A-Z][a-z])/g, " $1") + .trim(); +} + +/** Only include the `description` key when it has a value (exactOptionalPropertyTypes). */ +function withDesc(description: string | undefined): { description?: string } { + return description !== undefined ? { description } : {}; +} + +// --------------------------------------------------------------------------- +// Core walker +// --------------------------------------------------------------------------- + +/** + * Walks a resolved JSON Schema and produces an ordered list of + * `FormFieldDescriptor`s that drive the task form UI. + * + * The walker understands JSON Schema structure (properties, oneOf, anyOf, + * $ref, type) and maps schema shapes to form field kinds. It is intentionally + * schema-agnostic: no specific definition names are referenced, so it works + * with any conforming JSON Schema regardless of which workflow DSL version + * produced it. + * + * @param schema - The merged schema node (a `properties` block owner). + * @param defs - The `$defs` bundle accompanying the top-level schema. + * @param requiredSet - Set of required property names at this level. + * @param path - Dot-notation prefix (empty string at root). + */ +export function schemaToFormFields( + schema: DereferencedSchema, + defs?: Record, + requiredSet?: Set, + path = "", +): FormFieldDescriptor[] { + const fields: FormFieldDescriptor[] = []; + + // Tasks like callTask have a top-level `oneOf` with no own `properties`. + if (Array.isArray(schema.oneOf) && !schema.properties) { + const variants = buildOneOfVariants(schema.oneOf as unknown[], defs, path); + if (variants.length > 1) { + fields.push({ + kind: "one-of", + path: path || "__root__", + label: typeof schema.title === "string" ? schema.title : "Type", + ...withDesc(typeof schema.description === "string" ? schema.description : undefined), + required: false, + variants, + }); + } else if (variants.length === 1 && variants[0]) { + return variants[0].fields; + } + return fields; + } + + const properties = schema.properties as Record | undefined; + if (!properties) return fields; + + const localDefs = (schema.$defs as Record | undefined) ?? defs; + const req = + requiredSet ?? + new Set(Array.isArray(schema.required) ? (schema.required as string[]) : []); + + for (const [key, rawProp] of Object.entries(properties)) { + if (!isPlainObject(rawProp)) continue; + + const prop = rawProp as Record; + const fieldPath = path ? `${path}.${key}` : key; + const isRequired = req.has(key); + const description = typeof prop.description === "string" ? prop.description : undefined; + + // ── Special case: `then` key or flow-directive schema ───────────────── + // The `then` property is the canonical transition field and is always + // rendered as a sibling-task selector, regardless of its schema shape. + // Any other property whose schema structurally matches the flow-directive + // pattern (anyOf enum + plain string) is also treated as a `then` field. + if (key === "then" || isFlowDirectiveSchema(prop, localDefs)) { + fields.push({ + kind: "then", + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + }); + continue; + } + + // ── Resolve $ref ─────────────────────────────────────────────────────── + let resolved: Record = prop; + if (typeof prop.$ref === "string") { + const ref = resolveRef(prop.$ref, localDefs); + if (ref) { + resolved = { ...ref, ...prop, $ref: undefined }; + } + } + + // ── Child task list ──────────────────────────────────────────────────── + if (isTaskListSchema(resolved, localDefs)) { + fields.push({ + kind: "child-task-list", + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + }); + continue; + } + + // ── oneOf / anyOf at property level ──────────────────────────────────── + const candidates = (resolved.oneOf ?? resolved.anyOf) as unknown[] | undefined; + if (Array.isArray(candidates)) { + const variants = buildOneOfVariants(candidates, localDefs, fieldPath); + if (variants.length > 1) { + fields.push({ + kind: "one-of", + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + variants, + }); + continue; + } else if (variants.length === 1 && variants[0]) { + // When only 1 variant exists (e.g. collapsed string/expression scalar), + // unwrap its inner fields directly instead of rendering a 1-option dropdown. + const singleVariant = variants[0]; + for (const childField of singleVariant.fields) { + if (childField.path === fieldPath || childField.path === `${fieldPath}.__leaf__`) { + fields.push({ + ...childField, + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + }); + } else { + fields.push(childField); + } + } + continue; + } + } + + // ── Open-ended key-value map (additionalProperties, no fixed properties) ─ + if (isMapSchema(resolved)) { + fields.push({ + kind: "map", + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + }); + continue; + } + + // ── Object with known sub-properties ─────────────────────────────────── + if (resolved.type === "object" && resolved.properties) { + const childRequired = new Set( + Array.isArray(resolved.required) ? (resolved.required as string[]) : [], + ); + const children = schemaToFormFields( + resolved as DereferencedSchema, + localDefs, + childRequired, + fieldPath, + ); + fields.push({ + kind: "object", + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + children, + }); + continue; + } + + // ── Boolean ──────────────────────────────────────────────────────────── + if (resolved.type === "boolean") { + fields.push({ + kind: "boolean", + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + }); + continue; + } + + // ── Enum (string with enum array) ────────────────────────────────────── + if (resolved.type === "string" && Array.isArray(resolved.enum)) { + fields.push({ + kind: "enum", + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + options: resolved.enum as string[], + }); + continue; + } + + // ── Duration (string whose pattern describes an ISO 8601 duration) ───── + if ( + resolved.type === "string" && + typeof resolved.pattern === "string" && + resolved.pattern.startsWith("^P") + ) { + fields.push({ + kind: "duration", + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + }); + continue; + } + + // ── Number / integer ─────────────────────────────────────────────────── + if (resolved.type === "number" || resolved.type === "integer") { + fields.push({ + kind: "number", + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + }); + continue; + } + + // ── String ───────────────────────────────────────────────────────────── + if (resolved.type === "string") { + const isRe = RUNTIME_EXPRESSION_PATTERN.test(String(resolved.pattern ?? "")); + // Multi-line heuristic: keys that conventionally hold large text blocks + const multiline = key === "command" || key === "code" || key === "script"; + fields.push({ + kind: "string", + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + multiline, + isRuntimeExpression: isRe, + }); + continue; + } + + // ── Fallback: treat as free-form string ──────────────────────────────── + // Multi-line heuristic also applies here for untyped properties (e.g. + // SchemaInline.document has no explicit type in the schema). + const fallbackMultiline = key === "document"; + fields.push({ + kind: "string", + path: fieldPath, + label: deriveLabel(prop, key), + ...withDesc(description), + required: isRequired, + multiline: fallbackMultiline, + isRuntimeExpression: false, + }); + } + + return fields; +} + +// --------------------------------------------------------------------------- +// Discriminator helpers +// --------------------------------------------------------------------------- + +/** + * Builds a `matchesData` predicate for a resolved schema variant. + * + * Strategy (in order): + * 1. Property with `const` value → data must have that property equal to the + * const (e.g. `call: { const: "http" }`). + * 2. Single unique required property → data must have that key present. + * 3. Scalar type → check `typeof data`. + * 4. Object type (no const discriminator) → data must be a non-array object. + * 5. Fallback → always returns false (last variant wins at the call site). + */ +function buildDiscriminator( + resolved: Record, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _defs: Record | undefined, +): (data: unknown) => boolean { + const properties = resolved.properties as Record | undefined; + + // Strategy 1: property with `const` + if (properties) { + for (const [key, propSchema] of Object.entries(properties)) { + if (isPlainObject(propSchema)) { + const constVal = (propSchema as Record).const; + if (constVal !== undefined) { + return (data: unknown) => + isPlainObject(data) && (data as Record)[key] === constVal; + } + } + } + } + + // Strategy 2: single unique required property key + if (properties) { + const ownKeys = Object.keys(properties); + const required = Array.isArray(resolved.required) ? (resolved.required as string[]) : []; + if (ownKeys.length === 1 && required.includes(ownKeys[0]!)) { + const uniqueKey = ownKeys[0]!; + return (data: unknown) => + isPlainObject(data) && (data as Record)[uniqueKey] !== undefined; + } + } + + // Strategy 3: scalar type + if (resolved.type === "string" || Array.isArray(resolved.anyOf)) { + return (data: unknown) => typeof data === "string"; + } + if (resolved.type === "number" || resolved.type === "integer") { + return (data: unknown) => typeof data === "number"; + } + if (resolved.type === "boolean") { + return (data: unknown) => typeof data === "boolean"; + } + + // Strategy 4: object type + if (resolved.type === "object" || properties) { + return (data: unknown) => isPlainObject(data) && !Array.isArray(data); + } + + // Fallback + return () => false; +} + +// --------------------------------------------------------------------------- + +/** Intermediate representation for a resolved oneOf/anyOf candidate before collapsing. */ +type ResolvedVariant = { + kind: "string" | "number" | "boolean" | "enum" | "map" | "object"; + label: string; + matchesData: (data: unknown) => boolean; + fields: FormFieldDescriptor[]; + resolved: Record; + c: Record; +}; + +function buildOneOfVariants( + candidates: unknown[], + defs: Record | undefined, + parentPath: string, +): OneOfVariant[] { + // First pass: resolve candidate refs and build raw variant list + const resolvedList = candidates.flatMap((candidate, idx): ResolvedVariant[] => { + if (!isPlainObject(candidate)) return []; + const c = candidate as Record; + + let resolved: Record = c; + if (typeof c.$ref === "string") { + const ref = resolveRef(c.$ref, defs); + if (ref) resolved = { ...ref, ...c, $ref: undefined }; + } + + const titleCandidate = + typeof c.title === "string" + ? c.title + : typeof resolved.title === "string" + ? resolved.title + : typeof resolved.type === "string" + ? resolved.type + : undefined; + + const rawLabel = titleCandidate ? formatVariantLabel(titleCandidate) : `Option ${idx + 1}`; + const matchesData = buildDiscriminator(resolved, defs); + + // Variants with no fixed properties and no nested oneOf are either maps or scalars. + if (!resolved.properties && !Array.isArray(resolved.oneOf)) { + // ── Key-value map variant ──────────────────────────────────────────── + if (isMapSchema(resolved)) { + const GENERIC_TYPE_LABELS = new Set([ + "string", + "object", + "number", + "integer", + "boolean", + "array", + ]); + const isGenericTypeLabel = GENERIC_TYPE_LABELS.has(titleCandidate ?? ""); + const label = + titleCandidate && !isGenericTypeLabel ? formatVariantLabel(titleCandidate) : "key-value"; + const mapField: MapField = { + kind: "map", + path: parentPath || "__leaf__", + label, + required: false, + }; + return [{ kind: "map" as const, label, matchesData, fields: [mapField], resolved, c }]; + } + + // ── Pure scalar variants ───────────────────────────────────────────── + const leafPath = parentPath || "__leaf__"; + let leafField: FormFieldDescriptor; + + if (resolved.type === "string" && Array.isArray(resolved.enum)) { + leafField = { + kind: "enum", + path: leafPath, + label: rawLabel, + required: false, + options: resolved.enum as string[], + }; + return [ + { kind: "enum" as const, label: rawLabel, matchesData, fields: [leafField], resolved, c }, + ]; + } else if (resolved.type === "number" || resolved.type === "integer") { + leafField = { kind: "number", path: leafPath, label: rawLabel, required: false }; + return [ + { + kind: "number" as const, + label: rawLabel, + matchesData, + fields: [leafField], + resolved, + c, + }, + ]; + } else if (resolved.type === "boolean") { + leafField = { kind: "boolean", path: leafPath, label: rawLabel, required: false }; + return [ + { + kind: "boolean" as const, + label: rawLabel, + matchesData, + fields: [leafField], + resolved, + c, + }, + ]; + } else { + // plain string (or uriTemplate anyOf or runtimeExpression) + const isUriOrTemplate = + (typeof c.$ref === "string" && c.$ref.includes("uriTemplate")) || + resolved.title === "UriTemplate" || + parentPath.toLowerCase().endsWith("endpoint") || + parentPath.toLowerCase().endsWith("uri"); + const isRe = RUNTIME_EXPRESSION_PATTERN.test(String(resolved.pattern ?? "")); + const placeholder = isUriOrTemplate + ? "https://example.com/api/{id}" + : isRe + ? "${...}" + : undefined; + + leafField = { + kind: "string", + path: leafPath, + label: rawLabel, + required: false, + multiline: false, + isRuntimeExpression: isRe, + ...(placeholder ? { placeholder } : {}), + }; + return [ + { + kind: "string" as const, + label: rawLabel, + matchesData, + fields: [leafField], + resolved, + c, + }, + ]; + } + } + + const req = new Set( + Array.isArray(resolved.required) ? (resolved.required as string[]) : [], + ); + const children = schemaToFormFields(resolved as DereferencedSchema, defs, req, parentPath); + + return [ + { kind: "object" as const, label: rawLabel, matchesData, fields: children, resolved, c }, + ]; + }); + + // Second pass: collapse consecutive plain string variants (e.g. RuntimeExpression + UriTemplate) + // into a single "URI" or "string" variant with URI template placeholder support. + const collapsed: OneOfVariant[] = []; + let mergedStringVariant: { + label: string; + fields: FormFieldDescriptor[]; + matchPredicates: ((data: unknown) => boolean)[]; + } | null = null; + + for (const item of resolvedList) { + if (item.kind === "string") { + const isUriContext = + parentPath.toLowerCase().endsWith("endpoint") || + parentPath.toLowerCase().endsWith("uri") || + (typeof item.c.$ref === "string" && item.c.$ref.includes("uriTemplate")) || + item.resolved.title === "UriTemplate"; + + const preferredLabel = isUriContext + ? "URI" + : item.label === "Option 1" || item.label === "Option 2" + ? "string" + : item.label; + + if (!mergedStringVariant) { + const stringField: StringField = { + kind: "string", + path: parentPath || "__leaf__", + label: preferredLabel, + required: false, + multiline: false, + isRuntimeExpression: false, + ...(isUriContext ? { placeholder: "https://example.com/api/{id}" } : {}), + }; + mergedStringVariant = { + label: preferredLabel, + fields: [stringField], + matchPredicates: [item.matchesData], + }; + } else { + mergedStringVariant.matchPredicates.push(item.matchesData); + if (isUriContext) { + mergedStringVariant.label = "URI"; + (mergedStringVariant.fields[0] as StringField).placeholder = + "https://example.com/api/{id}"; + } + } + } else { + if (mergedStringVariant) { + const preds = [...mergedStringVariant.matchPredicates]; + collapsed.push({ + label: mergedStringVariant.label, + fields: mergedStringVariant.fields, + matchesData: (data: unknown) => typeof data === "string" || preds.some((p) => p(data)), + }); + mergedStringVariant = null; + } + collapsed.push({ + label: item.label, + fields: item.fields, + matchesData: item.matchesData, + }); + } + } + + if (mergedStringVariant) { + const preds = [...mergedStringVariant.matchPredicates]; + collapsed.push({ + label: mergedStringVariant.label, + fields: mergedStringVariant.fields, + matchesData: (data: unknown) => typeof data === "string" || preds.some((p) => p(data)), + }); + } + + return collapsed; +} diff --git a/packages/open-workflow-diagram-editor/src/core/schemaWalker.ts b/packages/open-workflow-diagram-editor/src/core/schemaWalker.ts new file mode 100644 index 00000000..245acda9 --- /dev/null +++ b/packages/open-workflow-diagram-editor/src/core/schemaWalker.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2021-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GraphNodeType } from "@openworkflowspec/sdk"; +import { getSchemaForDefinition } from "./schemaFilter"; +import { schemaToFormFields, FormFieldDescriptor } from "./schemaToFormFields"; + +// --------------------------------------------------------------------------- +// Node-type → schema definition-name mapping +// +// Maps every graph node type to the `$defs` key that describes its schema. +// Catch nodes share the tryTask schema because `try.catch` is inlined there. +// --------------------------------------------------------------------------- + +export const CATCH_CONTAINER_NODE_TYPE = "catch-container"; + +const NODE_TYPE_TO_DEF: Readonly> = { + [GraphNodeType.Call]: "callTask", + [GraphNodeType.Do]: "doTask", + [GraphNodeType.Emit]: "emitTask", + [GraphNodeType.For]: "forTask", + [GraphNodeType.Fork]: "forkTask", + [GraphNodeType.Listen]: "listenTask", + [GraphNodeType.Raise]: "raiseTask", + [GraphNodeType.Run]: "runTask", + [GraphNodeType.Set]: "setTask", + [GraphNodeType.Switch]: "switchTask", + [GraphNodeType.Try]: "tryTask", + [GraphNodeType.Wait]: "waitTask", + [GraphNodeType.Catch]: "tryTask", + [CATCH_CONTAINER_NODE_TYPE]: "tryTask", +} as const; + +// --------------------------------------------------------------------------- +// Field cache — avoids re-walking the same schema on every render +// --------------------------------------------------------------------------- + +const _fieldCache = new Map(); + +/** + * Returns the ordered list of `FormFieldDescriptor`s for a given graph node + * type, or an empty array when no schema definition is registered for it. + * + * Results are cached by node type so the schema walk only happens once per + * definition. The cache is module-scoped and lives for the lifetime of the + * application — schemas do not change at runtime. + */ +export function getFormFieldsForNodeType(nodeType: string): FormFieldDescriptor[] { + const cached = _fieldCache.get(nodeType); + if (cached !== undefined) return cached; + + const defName = NODE_TYPE_TO_DEF[nodeType]; + if (!defName) return []; + + try { + const s = getSchemaForDefinition(defName); + const defs = s.$defs as Record | undefined; + const requiredSet = new Set(Array.isArray(s.required) ? (s.required as string[]) : []); + const fields = schemaToFormFields(s, defs, requiredSet, ""); + _fieldCache.set(nodeType, fields); + return fields; + } catch { + return []; + } +} + +export type { FormFieldDescriptor }; diff --git a/packages/open-workflow-diagram-editor/src/core/taskDetails.ts b/packages/open-workflow-diagram-editor/src/core/taskDetails.ts deleted file mode 100644 index 2eab6828..00000000 --- a/packages/open-workflow-diagram-editor/src/core/taskDetails.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright 2021-Present The Open Workflow Specification Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Specification } from "@openworkflowspec/sdk"; - -/* TaskBase: Common fields every task inherits (none required) (metadata is dropped for now) */ -const TASK_BASE_KEYS = new Set(["if", "input", "output", "export", "timeout", "then", "metadata"]); - -/* Number of object levels to expand into dot-notation rows */ -const MAX_DEPTH = 4; - -/* Flattened task row" - - kind: how the view should render it - - label: the dot-joined display label(`with.method`) - - segments: the real key path and source of truth -*/ -type DetailFieldBase = { label: string; segments: string[] }; - -export type DetailField = - | (DetailFieldBase & { kind: "scalar"; value: string | number | boolean }) - /* TODO: Temporary until rendering arrays and objects */ - | (DetailFieldBase & { kind: "array"; count: number }) - | (DetailFieldBase & { kind: "object" }); - -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function flattenFields( - value: unknown, - segments: string[], - depth: number = 0, - outputFields: DetailField[] = [], -): void { - if (value === undefined || value === null) { - return; - } - - const label = segments.join("."); - - if (Array.isArray(value)) { - outputFields.push({ label, segments, kind: "array", count: value.length }); - return; - } - - if (isPlainObject(value)) { - if (depth >= MAX_DEPTH) { - /* Too deep - bare path, full value available in Source */ - outputFields.push({ label, segments, kind: "object" }); - return; - } - - for (const [key, val] of Object.entries(value)) { - flattenFields(val, [...segments, key], depth + 1, outputFields); - } - - return; - } - - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - outputFields.push({ - label, - segments, - kind: "scalar", - value, - }); - } -} - -/* Builds the flattened detail rows for a task: task-specific fields first, inherited base fields last */ -export function getTaskDetails(task: Specification.Task): DetailField[] { - const record = task as Record; - const nested = (key: string): Record | undefined => { - const value = record[key]; - return isPlainObject(value) ? value : undefined; - }; - - // Handle timeout as it can be a string or an object (after) - const timeoutSource = - typeof record.timeout === "string" - ? { segments: ["timeout"], value: record.timeout } - : { - segments: ["timeout", "after"], - value: nested("timeout")?.after, - }; - - /* Base fields, each labelled with its dsl path */ - const baseSources: Array<{ segments: string[]; value: unknown }> = [ - { segments: ["if"], value: record.if }, - { segments: ["input", "from"], value: nested("input")?.from }, - { segments: ["output", "as"], value: nested("output")?.as }, - { segments: ["export", "as"], value: nested("export")?.as }, - timeoutSource, - { segments: ["then"], value: record.then }, - ]; - - const base: DetailField[] = []; - for (const { segments, value } of baseSources) { - flattenFields(value, segments, 0, base); - } - - /* Top level keys (base keys and metadata excluded) */ - const specific: DetailField[] = []; - for (const [key, value] of Object.entries(record)) { - if (!TASK_BASE_KEYS.has(key)) { - flattenFields(value, [key], 0, specific); - } - } - - return [...specific, ...base]; -} diff --git a/packages/open-workflow-diagram-editor/src/core/taskDraft.ts b/packages/open-workflow-diagram-editor/src/core/taskDraft.ts index b272d7cf..f56dbf0f 100644 --- a/packages/open-workflow-diagram-editor/src/core/taskDraft.ts +++ b/packages/open-workflow-diagram-editor/src/core/taskDraft.ts @@ -14,83 +14,157 @@ * limitations under the License. */ -import type { Specification } from "@openworkflowspec/sdk"; - -/* - * This module bridges react-hook-form (which uses string field names) with nested task objects - * (which have deep property paths). It solves a critical problem: react-hook-form interprets - * dots (.), brackets ([]), and slashes (/) as path separators, but workflow task properties - * can contain these characters as literal parts of their keys. +/** + * Reconstructs a nested task object from the flat dot-notation form values + * produced by `flattenTask` in TaskForm. Arrays (child-task-list values) are + * kept as-is. + * + * For example: + * `{ "for.each": "${items}", "for.in": "${data}" }` + * becomes: + * `{ for: { each: "${items}", in: "${data}" } }` + * + * Empty strings, null, and undefined values are omitted so the resulting + * object only carries properties that were actually set. */ - -export type FieldChange = { segments: string[], value: unknown }; - -const UNSAFE_SEGMENT_CHARS = /[%./[\]]/g; -const ESCAPE_SEQUENCE = /%([0-9A-F]{2})/g; - -// Escapes special characters in a single path segment to prevent react-hook-form from misinterpreting them -const encodeSegment = (segment: string): string => - segment.replace(UNSAFE_SEGMENT_CHARS,(char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}` -); - -// Reverses the encoding to get back the original key name -const decodeSegment = (segment: string): string => - segment.replace(ESCAPE_SEQUENCE,(_match, hex: string) => - String.fromCharCode(Number.parseInt(hex, 16)) -); - -// Converts a property path array into a single encoded string for react-hook-form -export function fieldName(segments: string[]): string { - return segments.map(encodeSegment).join("/"); -} - -// Converts an encoded field name back into the original property path array -export function parseFieldName(name: string): string[] { - return name.split("/").map(decodeSegment); +export function unflattenValues(flat: Record): Record { + const result: Record = {}; + for (const [dotPath, value] of Object.entries(flat)) { + if (value === undefined || value === null || value === "") continue; + const parts = dotPath.split("."); + let current = result; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]!; + if ( + current[part] === undefined || + typeof current[part] !== "object" || + Array.isArray(current[part]) + ) { + current[part] = {}; + } + current = current[part] as Record; + } + current[parts[parts.length - 1]!] = value; + } + return result; } -// Writes a value to a deeply nested property path, creating intermediate objects as needed. -function writeAtSegments(target: Record, change: FieldChange): void { - const {segments, value} = change; - const leafKey = segments[segments.length - 1]; - - if(leafKey === undefined) { - return +/** + * Produces an updated task by applying only the dirty form fields onto a deep + * clone of the original task. + * + * The form may render optional sections (e.g. `input`, `output`, `export`) + * whose fields all have empty / falsy default values. Reconstructing the task + * purely from `getValues()` would inject empty intermediate objects such as + * `{ input: { schema: {} } }` that cause the SDK to report missing-required- + * property errors for fields the user never intended to fill in. + * + * By starting from the original task and writing only the paths that the user + * actually changed, untouched optional sections are left exactly as they were + * — either with their original values or simply absent. + * + * @param original - The current task snapshot held in the store, used as + * the base for the deep clone. + * @param allValues - All flat dot-notation form values from `form.getValues()`. + * @param dirtyPaths - Set of dot-notation paths that are dirty according to + * react-hook-form's `dirtyFields` (top-level keys only is + * sufficient because `KeyValueMapField` registers individual + * leaf paths under the map prefix). + */ +export function applyDirtyValues( + original: Record, + allValues: Record, + dirtyPaths: Set, +): Record { + // Deep clone the original so we never mutate the store value. + const result = deepClone(original); + + for (const [dotPath, value] of Object.entries(allValues)) { + if (!isDirtyPath(dotPath, dirtyPaths)) continue; + + // A dirty path with an empty / null value means the user cleared the + // field — delete it from the clone rather than writing an empty string. + if (value === undefined || value === null || value === "") { + deletePath(result, dotPath.split(".")); + } else { + setPath(result, dotPath.split("."), value); } + } - let node = target + return result; +} - for (const segment of segments.slice(0, -1)) { - const next = node[segment] +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- - if(Array.isArray(next)) { - // TODO not handled yet - throw new Error("array editing not implemented yet") - } +function deepClone(value: T): T { + // JSON round-trip is sufficient: task data is always plain JSON-serialisable. + return JSON.parse(JSON.stringify(value)) as T; +} - if(typeof next !== "object" || next === null){ - node[segment] = {} - } +/** + * Returns true when `dotPath` should be written. + * + * react-hook-form's `dirtyFields` uses top-level keys for simple scalar + * fields and individual leaf paths for `KeyValueMapField` entries (which + * register their keys as `mapPath.entryKey`). A path is considered dirty + * when it either matches a key in `dirtyPaths` exactly, or when it starts + * with a dirty prefix (map-field case). + */ +function isDirtyPath(dotPath: string, dirtyPaths: Set): boolean { + if (dirtyPaths.has(dotPath)) return true; + for (const dirty of dirtyPaths) { + if (dotPath.startsWith(dirty + ".")) return true; + } + return false; +} - node = node[segment] as Record - } +/** + * Returns false for path segments that could reach inherited object keys and + * cause prototype pollution (__proto__, prototype, constructor). + */ +function isSafeKey(key: string): boolean { + return key !== "__proto__" && key !== "prototype" && key !== "constructor"; +} - if(value === undefined){ - delete node[leafKey] - return +/** Sets a value at a dot-notation path within `obj`, creating intermediates as needed. */ +function setPath(obj: Record, parts: string[], value: unknown): void { + if (parts.some((p) => !isSafeKey(p))) { + throw new Error(`Unsafe path segment in: ${parts.join(".")}`); + } + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]!; + if ( + !Object.prototype.hasOwnProperty.call(current, part) || + typeof current[part] !== "object" || + Array.isArray(current[part]) + ) { + current[part] = Object.create(null) as Record; } - - node[leafKey] = value + current = current[part] as Record; + } + current[parts[parts.length - 1]!] = value; } - -// Applies multiple field changes to a task, returning a new modified task -export function applyFieldValues(task: Specification.Task, changes: FieldChange[]): Specification.Task { - const draft = structuredClone(task) as Record - - for(const change of changes) { - writeAtSegments(draft, change) +/** Removes a key at a dot-notation path within `obj`. Cleans up empty parent objects. */ +function deletePath(obj: Record, parts: string[]): void { + if (parts.length === 0) return; + if (parts.some((p) => !isSafeKey(p))) { + throw new Error(`Unsafe path segment in: ${parts.join(".")}`); + } + if (parts.length === 1) { + delete obj[parts[0]!]; + return; + } + const head = parts[0]!; + const child = obj[head]; + if (child !== null && typeof child === "object" && !Array.isArray(child)) { + deletePath(child as Record, parts.slice(1)); + // Remove the parent if it became empty after deletion. + if (Object.keys(child).length === 0) { + delete obj[head]; } - - return draft as Specification.Task -} \ No newline at end of file + } +} diff --git a/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts b/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts index 1ecf2672..ffabb9a5 100644 --- a/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts +++ b/packages/open-workflow-diagram-editor/src/i18n/locales/en.ts @@ -47,6 +47,7 @@ export const en = { "aria.minimap.hide": "Hide minimap", "aria.minimap.show": "Show minimap", "aria.badge": "Badge:", + "aria.help": "Help", "aria.panel.nodeDetails": "Node details panel", "aria.panel.workflowInfo": "Workflow information panel", "aria.panel.content": "Panel content", @@ -57,6 +58,9 @@ export const en = { "toast.download.success": "Download started", "toast.download.error": "Download failed", "sidebar.duration.title": "Enter an ISO 8601 duration, for example PT30S or PT5M", + "sidebar.duration.placeholder": "PT30S", + "sidebar.then.flowDirectiveGroup": "Flow directive", + "sidebar.then.taskGroup": "Task", "sidebar.field.item": "item", "sidebar.field.items": "items", "sidebar.form.apply": "Apply", @@ -64,6 +68,14 @@ export const en = { "sidebar.form.changed": "changed", "sidebar.form.noChanges": "No changes", "sidebar.form.applied": "Applied", + "sidebar.form.selectOption": "Select an option…", + "aria.form.taskProperties": "Task properties", + "sidebar.map.addProperty": "+ Add property", + "sidebar.map.keyPlaceholder": "key", + "sidebar.map.valuePlaceholder": "value", + "sidebar.map.keyLabel": "Entry key", + "sidebar.map.valueLabel": "Entry value", + "sidebar.map.deleteEntry": "Delete entry", } as const; export type TranslationKeys = keyof typeof en; diff --git a/packages/open-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts b/packages/open-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts index 74e736bb..f5923ecb 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts +++ b/packages/open-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts @@ -17,6 +17,7 @@ import * as RF from "@xyflow/react"; import { buildFlatGraph, + CATCH_CONTAINER_NODE_TYPE, getErrorTaskReferences, getTaskReferences, type SdkError, @@ -25,7 +26,7 @@ import { BaseNodeData, ReactFlowNodeTypes } from "../nodes/Nodes"; import { BaseEdgeData, EdgeTypes } from "../edges/Edges"; import * as sdk from "@openworkflowspec/sdk"; import { getNodeSize } from "./autoLayout"; -import { CATCH_CONTAINER_NODE_TYPE, isTerminalNodeType } from "../nodes/taskNodeConfig"; +import { isTerminalNodeType } from "../nodes/taskNodeConfig"; export type ReactFlowGraph = { nodes: RF.Node[]; diff --git a/packages/open-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx b/packages/open-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx index c3470a6e..8255c18c 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx +++ b/packages/open-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx @@ -19,7 +19,6 @@ import { GraphNodeType, type Specification } from "@openworkflowspec/sdk"; import * as RF from "@xyflow/react"; import { useI18n } from "@openworkflowspec/i18n"; import { - CATCH_CONTAINER_NODE_TYPE, type ContainerNodeType, type LeafNodeType, type TerminalNodeType, @@ -27,7 +26,12 @@ import { containerNodeConfigMap, terminalNodeConfigMap, } from "./taskNodeConfig"; -import { getCallSubType, getListenSubType, getRunSubType } from "../../core"; +import { + CATCH_CONTAINER_NODE_TYPE, + getCallSubType, + getListenSubType, + getRunSubType, +} from "../../core"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { CircleAlert } from "lucide-react"; diff --git a/packages/open-workflow-diagram-editor/src/react-flow/nodes/taskNodeConfig.ts b/packages/open-workflow-diagram-editor/src/react-flow/nodes/taskNodeConfig.ts index 192a2817..a05f5ff3 100644 --- a/packages/open-workflow-diagram-editor/src/react-flow/nodes/taskNodeConfig.ts +++ b/packages/open-workflow-diagram-editor/src/react-flow/nodes/taskNodeConfig.ts @@ -34,6 +34,9 @@ import { } from "lucide-react"; import type { ComponentType } from "react"; import type { TranslationKeys } from "../../i18n/locales/en"; +import { CATCH_CONTAINER_NODE_TYPE } from "@/core"; + +export { CATCH_CONTAINER_NODE_TYPE }; export interface TaskNodeConfig { color: string; @@ -46,9 +49,6 @@ export interface TerminalNodeConfig { labelKey: TranslationKeys; } -/* Custom react-flow only node type for catch nodes that contain child nodes (i.e are containers) (the sdk uses GraphNodeType.Catch for both leaf and container catch nodes) */ -export const CATCH_CONTAINER_NODE_TYPE = "catch-container"; - export type TerminalNodeType = typeof GraphNodeType.Entry | typeof GraphNodeType.Exit; export type ContainerNodeType = diff --git a/packages/open-workflow-diagram-editor/src/side-panel/EditFormFooter.tsx b/packages/open-workflow-diagram-editor/src/side-panel/EditFormFooter.tsx index 80a3cfe3..c4609f07 100644 --- a/packages/open-workflow-diagram-editor/src/side-panel/EditFormFooter.tsx +++ b/packages/open-workflow-diagram-editor/src/side-panel/EditFormFooter.tsx @@ -22,97 +22,118 @@ import { SidebarFooter } from "@/components/ui/sidebar"; import { Button } from "@/components/ui/button"; import { useFormState } from "react-hook-form"; import { updateTask } from "@/core/workflowEditing"; +import { applyDirtyValues } from "@/core/taskDraft"; +import { flattenTask } from "@/side-panel/forms/TaskForm"; import { useDiagramEditorContext } from "@/store/DiagramEditorContext"; import { useEditSession } from "./EditSession"; -import { applyFieldValues, parseFieldName } from "@/core/taskDraft"; import { Check } from "lucide-react"; +import type { Specification } from "@openworkflowspec/sdk"; /* How long the applied message stays in footer */ -const APPLIED_MESSAGE_MS = 2400; +const APPLIED_MESSAGE_MS = 2400; -type DraftStatusProps ={ +type DraftStatusProps = { changedCount: number; isDirty: boolean; showApplied: boolean; -} +}; function DraftStatus({ changedCount, isDirty, showApplied }: DraftStatusProps) { - const {t} = useI18n(); + const { t } = useI18n(); - const variant = isDirty? "changed": showApplied? "applied": "nochanges"; - const label = variant === "changed" ? `${changedCount} ${t("sidebar.form.changed")}` : variant === "applied" ? t("sidebar.form.applied") : t("sidebar.form.noChanges"); + const variant = isDirty ? "changed" : showApplied ? "applied" : "nochanges"; + const label = + variant === "changed" + ? `${changedCount} ${t("sidebar.form.changed")}` + : variant === "applied" + ? t("sidebar.form.applied") + : t("sidebar.form.noChanges"); return ( - {variant === "applied" ?( ); } - - export function EditFormFooter({ node }: { node: RF.Node }) { const { t } = useI18n(); - const {form, isEditing, setIsEditing} = useEditSession() - const {commitWorkflow, isReadOnly, model} = useDiagramEditorContext() + const { form } = useEditSession(); + const { commitWorkflow, isReadOnly, model } = useDiagramEditorContext(); - const [appliedNodeId, setAppliedNodeId] = React.useState(null); + const [appliedNodeId, setAppliedNodeId] = React.useState(null); const dismissTimer = React.useRef | null>(null); - React.useEffect(() => () =>{ - if(dismissTimer.current !== null) { + React.useEffect( + () => () => { + if (dismissTimer.current !== null) { + clearTimeout(dismissTimer.current); + } + }, + [], + ); + + const { dirtyFields, isDirty } = useFormState({ control: form.control }); + const task = node.data.task; + const showApplied = appliedNodeId === node.id; + + // Guard conditions that permanently prevent display + if (isReadOnly || task === undefined || node.data.taskReference === undefined || model === null) { + return null; + } + + const changedCount = Object.keys(flattenTask(dirtyFields)).length; + + const handleCancel = () => { + form.reset(task as unknown as Record); + setAppliedNodeId(null); + }; + + const handleApply = () => { + // TODO: Should add error handling if apply fails but first need to decide how to display that to the user before implementing + // RHF stores form values as a nested object (dot-notation names are resolved + // as nested paths internally). Flatten back to dot-notation so applyDirtyValues + // can match keys against its dirtyPaths set correctly. + const flatValues = flattenTask(form.getValues()); + // dirtyFields is also nested: { timeout: { after: { hours: true } } }. + // Flatten it the same way to get leaf dot-notation paths. + const flatDirty = new Set(Object.keys(flattenTask(dirtyFields))); + const updated = applyDirtyValues( + task as unknown as Record, + flatValues, + flatDirty, + ) as Specification.Task; + const updatedModel = updateTask(model, node.id, updated); + commitWorkflow(updatedModel); + // Reset to the current nested form values (not the flat version) so that + // RHF's defaultValues stay consistent with the nested Controller paths and + // no sibling fields are spuriously marked dirty after apply. + form.reset(form.getValues()); + setAppliedNodeId(node.id); + + if (dismissTimer.current !== null) { clearTimeout(dismissTimer.current); } - }, []); -const {dirtyFields, isDirty} = useFormState({ control: form.control }); -const task = node.data.task; - -if(isReadOnly || (!isEditing && !isDirty) || task === undefined || node.data.taskReference === undefined || model === null) { - return null; -} - -const changedNames = Object.keys(dirtyFields); - -const handleCancel = () => { - form.reset(); - setIsEditing(false); -}; - -const handleApply = () => { - // TODO: Should add error handling if apply fails but first need to decide how to display that to the user before implementing - const values = form.getValues() - const changes = changedNames.map((name) => ({ - segments: parseFieldName(name), - value: values[name], - })); - - const updated = updateTask(model, node.id, applyFieldValues(task, changes)); - commitWorkflow(updated) - form.reset(values); - setAppliedNodeId(node.id); - - if(dismissTimer.current !== null) { - clearTimeout(dismissTimer.current); - } - - dismissTimer.current = setTimeout(() => setAppliedNodeId(null), APPLIED_MESSAGE_MS); -}; + dismissTimer.current = setTimeout(() => setAppliedNodeId(null), APPLIED_MESSAGE_MS); + }; return ( - -
- -
- - -
+ +
+ +
+ +
- +
+
); } diff --git a/packages/open-workflow-diagram-editor/src/side-panel/EditSession.tsx b/packages/open-workflow-diagram-editor/src/side-panel/EditSession.tsx index f4c276c6..dcc8f3e7 100644 --- a/packages/open-workflow-diagram-editor/src/side-panel/EditSession.tsx +++ b/packages/open-workflow-diagram-editor/src/side-panel/EditSession.tsx @@ -17,30 +17,28 @@ import * as React from "react"; import { FormProvider, useForm, type UseFormReturn } from "react-hook-form"; -export type DraftValues = Record; - type EditSessionValue = { - form: UseFormReturn; - isEditing: boolean; - setIsEditing: React.Dispatch>; -} + form: UseFormReturn>; +}; const EditSessionContext = React.createContext(undefined); export function EditSessionProvider({ children }: { children: React.ReactNode }) { - const form = useForm({ defaultValues: {}}); - const [isEditing, setIsEditing] = React.useState(false); - - const value = React.useMemo(() => ({ form, isEditing, setIsEditing }), [form, isEditing]); + const form = useForm>({ defaultValues: {} }); - return ({{children}}) + const value = React.useMemo(() => ({ form }), [form]); + return ( + + {children} + + ); } export function useEditSession() { - const context = React.useContext(EditSessionContext); - if (!context) { - throw new Error("useEditSession must be used within an EditSessionProvider"); - } - return context; + const context = React.useContext(EditSessionContext); + if (!context) { + throw new Error("useEditSession must be used within an EditSessionProvider"); + } + return context; } diff --git a/packages/open-workflow-diagram-editor/src/side-panel/EditableProperties.tsx b/packages/open-workflow-diagram-editor/src/side-panel/EditableProperties.tsx deleted file mode 100644 index f1d2474b..00000000 --- a/packages/open-workflow-diagram-editor/src/side-panel/EditableProperties.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2021-Present The Open Workflow Specification Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as React from "react"; -import type { DetailField } from "@/core/taskDetails"; -import { Field, FieldGroup } from "@/components/ui/field"; -import { FieldControl } from "./FieldControls"; -import { PropertyValue, StaticPropertyRow } from "./Fields"; -import { useEditSession, type DraftValues } from "./EditSession"; -import { fieldName } from "@/core/taskDraft"; - -/** - * Editable presentation of a task's properties. Display when isReadOnly={false} - * - * By default this renders the same static rows as read-only mode; edit mode is entered deliberately, by clicking a row, which - * also focuses it. - * - */ - -type EditablePropertiesProps = { - fields: DetailField[]; - nodeId: string; -}; - -/* Creates a field name for each and maps each fieldname to its current value. Allows RHF to track changes (only scaler for now) */ -function toDraftValues(fields: DetailField[]): DraftValues { - const values: DraftValues = {}; - - for (const field of fields) { - if (field.kind === "scalar") { - values[fieldName(field.segments)] = field.value; - } - } - - return values; -} - -export function EditableProperties({ fields, nodeId }: EditablePropertiesProps) { - const baseId = React.useId(); - const { form, isEditing, setIsEditing } = useEditSession(); - - const fieldToFocus = React.useRef(null); - - /* Reset on node change rather than remounting behind a `key`: `useForm` lives above the - rows, so remounting them alone would leave the previous node's values in the draft. */ - const renderedNodeId = React.useRef(null); - React.useEffect(() => { - if (renderedNodeId.current === nodeId) { - return; - } - - renderedNodeId.current = nodeId; - fieldToFocus.current = null; - form.reset(toDraftValues(fields)); - setIsEditing(false); - }, [nodeId, fields, form, setIsEditing]); - - /* Deferred to an effect because the control does not exist until edit mode has rendered. */ - React.useEffect(() => { - if (!isEditing || fieldToFocus.current === null) { - return; - } - - form.setFocus(fieldToFocus.current); - fieldToFocus.current = null; - }, [isEditing, fieldToFocus, form]); - - const activateField = (name: string) => { - if (!isEditing) { - form.reset(toDraftValues(fields)); - setIsEditing(true); - } - - fieldToFocus.current = name; - }; - - return ( - <> - {isEditing ? ( - - {fields.map((field, index) => { - if (field.kind !== "scalar") { - return ; - } - - const controlId = `${baseId}-${index}`; - - return ( - - - - - ); - })} - - ) : ( -
- {fields.map((field) => - field.kind === "scalar" ? ( - - ) : ( - - ), - )} -
- )} - - ); -} diff --git a/packages/open-workflow-diagram-editor/src/side-panel/FieldControls.tsx b/packages/open-workflow-diagram-editor/src/side-panel/FieldControls.tsx deleted file mode 100644 index 9ace8441..00000000 --- a/packages/open-workflow-diagram-editor/src/side-panel/FieldControls.tsx +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2021-Present The Open Workflow Specification Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { DetailField } from "@/core/taskDetails"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Switch } from "@/components/ui/switch"; -import { Controller, useFormContext } from "react-hook-form"; - -export type EditableDetailField = Extract; - -type ControlProps = { - id: string; - name: string; -}; - -function TextControl({ id, name }: ControlProps) { - const { register } = useFormContext(); - return ; -} - -/* A shell command or script body needs room to edit.*/ -function MultilineControl({ id, name }: ControlProps) { - const { register } = useFormContext(); - return