Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/raiseTask-form.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openworkflowspec/diagram-editor": minor
---

Add full field support to raiseTask form generation.
1 change: 1 addition & 0 deletions packages/open-workflow-diagram-editor/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from "./validationErrors";
export * from "./graph";
export * from "./taskDraft";
export * from "./taskSubType";
export * from "./taskTypes";
export * from "./elkjs";
export * from "./mermaidExport";
export * from "./schemaFilter";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,12 +249,15 @@ function isFlowDirectiveSchema(
/** Derive a human-readable label from a schema node and the property key. */
function deriveLabel(schema: Record<string, unknown>, 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;
const lastWord = words[words.length - 1];
// Use the last word only if it matches the property key (case-insensitive).
if (lastWord !== undefined && lastWord.toLowerCase() === key.toLowerCase()) {
return lastWord;
}
}
return key;
}
Expand Down Expand Up @@ -284,6 +287,15 @@ function formatVariantLabel(title: string): string {
.trim();
}

const API_ENDPOINT_PLACEHOLDER = "https://example.com/api/{id}";
const ADDRESS_PATH_SUFFIXES = ["endpoint", "uri", "source"] as const;

/* Whether a path holds the address of a service in workflow calls - and should get the API-endpoint example */
function isApiEndpointPath(path: string): boolean {
const lower = path.toLowerCase();
return ADDRESS_PATH_SUFFIXES.some((suffix) => lower.endsWith(suffix));
}

/** Only include the `description` key when it has a value (exactOptionalPropertyTypes). */
function withDesc(description: string | undefined): { description?: string } {
return description !== undefined ? { description } : {};
Expand Down Expand Up @@ -639,6 +651,7 @@ function buildOneOfVariants(
format: "json" | "yaml" = "yaml",
): OneOfVariant[] {
const leafPath = parentPath || "__leaf__";
const isApiEndpoint = isApiEndpointPath(parentPath);

// First pass: resolve candidate refs and build raw variant list
const resolvedList = candidates.flatMap((candidate, idx): ResolvedVariant[] => {
Expand Down Expand Up @@ -752,17 +765,8 @@ function buildOneOfVariants(
];
} 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 = isRuntimeExpressionSchema(c, resolved);
const placeholder = isUriOrTemplate
? "https://example.com/api/{id}"
: isRe
? "${...}"
: undefined;
const placeholder = isRe ? "${...}" : isApiEndpoint ? API_ENDPOINT_PLACEHOLDER : undefined;

leafField = {
kind: "string",
Expand Down Expand Up @@ -839,8 +843,7 @@ function buildOneOfVariants(
for (const item of resolvedList) {
if (item.kind === "string") {
const isUriContext =
parentPath.toLowerCase().endsWith("endpoint") ||
parentPath.toLowerCase().endsWith("uri") ||
isApiEndpoint ||
(typeof item.c.$ref === "string" && item.c.$ref.includes("uriTemplate")) ||
item.resolved.title === "UriTemplate";

Expand All @@ -863,8 +866,8 @@ function buildOneOfVariants(
required: false,
multiline: false,
isRuntimeExpression: isRe,
...(isUriContext
? { placeholder: "https://example.com/api/{id}" }
...(isApiEndpoint
? { placeholder: API_ENDPOINT_PLACEHOLDER }
: inheritedPlaceholder !== undefined
? { placeholder: inheritedPlaceholder }
: {}),
Expand All @@ -879,7 +882,9 @@ function buildOneOfVariants(
const merged = mergedStringVariant.fields[0] as StringField;
if (isUriContext) {
mergedStringVariant.label = "URI";
merged.placeholder = "https://example.com/api/{id}";
}
if (isApiEndpoint) {
merged.placeholder = API_ENDPOINT_PLACEHOLDER;
} else if (isRe && merged.placeholder === undefined && inheritedPlaceholder !== undefined) {
merged.placeholder = inheritedPlaceholder;
}
Expand Down
38 changes: 25 additions & 13 deletions packages/open-workflow-diagram-editor/src/core/taskDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* limitations under the License.
*/

import { getTaskTypeKey } from "./taskTypes";

/**
* Reconstructs a nested task object from the flat dot-notation form values
* produced by `flattenTask` in TaskForm. Arrays (child-task-list values) are
Expand Down Expand Up @@ -65,29 +67,32 @@ export function applyDirtyValues(
): Record<string, unknown> {
// Deep clone the original so we never mutate the store value.
const result = deepClone(original);
const taskTypeKey = getTaskTypeKey(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("."));
deletePath(result, dotPath.split("."), {
prune: !sentinelPaths.has(dotPath),
protectedKey: taskTypeKey,
});
} else {
setPath(result, dotPath.split("."), value);
}
}

// For sentinel-derived paths: delete from the model unless the same path (or
// a leaf under it) is independently dirty in dirtyPaths — which means the
// user actually edited the field after switching back to it.
// For sentinel-derived paths: delete from the model unless a dirty field supplied a value - the path itself, a leaf under it or an ancestor
// (Ancestor because RHF reports that when a whole shape has changed like raise.error does when an error name becomes an inline definition)
for (const sentinelPath of sentinelPaths) {
const prefix = sentinelPath + ".";
const independentlyDirty =
dirtyPaths.has(sentinelPath) ||
[...dirtyPaths].some((p) => p === sentinelPath || p.startsWith(prefix));
if (!independentlyDirty) {
deletePath(result, sentinelPath.split("."));
const suppliedByEdit = [...dirtyPaths].some(
(p) => p === sentinelPath || p.startsWith(prefix) || sentinelPath.startsWith(p + "."),
);
if (!suppliedByEdit) {
deletePath(result, sentinelPath.split("."), { prune: false });
}
}

Expand Down Expand Up @@ -151,8 +156,15 @@ function setPath(obj: Record<string, unknown>, parts: string[], value: unknown):
current[parts[parts.length - 1]!] = value;
}

/** Removes a key at a dot-notation path within `obj`. Cleans up empty parent objects. */
function deletePath(obj: Record<string, unknown>, parts: string[]): void {
/** Removes a key at a dot-notation path within `obj`. Cleans up empty parent objects.
* @param options.prune - Whether parents emptied by the deletion are removed too.
* @param options.protectedKey - A top-level key that is never pruned
*/
function deletePath(
obj: Record<string, unknown>,
parts: string[],
options: { prune: boolean; protectedKey?: string | undefined } = { prune: true },
): void {
if (parts.length === 0) return;
if (parts.some((p) => !isSafeKey(p))) {
throw new Error(`Unsafe path segment in: ${parts.join(".")}`);
Expand All @@ -164,9 +176,9 @@ function deletePath(obj: Record<string, unknown>, parts: string[]): void {
const head = parts[0]!;
const child = obj[head];
if (child !== null && typeof child === "object" && !Array.isArray(child)) {
deletePath(child as Record<string, unknown>, parts.slice(1));
deletePath(child as Record<string, unknown>, parts.slice(1), { prune: options.prune });
// Remove the parent if it became empty after deletion.
if (Object.keys(child).length === 0) {
if (options.prune && head !== options.protectedKey && Object.keys(child).length === 0) {
delete obj[head];
}
}
Expand Down
59 changes: 59 additions & 0 deletions packages/open-workflow-diagram-editor/src/core/taskTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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";

/* The key that gives a task its type — the twelve task keywords the DSL defines.
*
* `GraphNodeType.Catch` is deliberately NOT here. `catch` is a property of a
* try task, not a task type
*/
export type TaskTypeKey =
| typeof GraphNodeType.Call
| typeof GraphNodeType.Do
| typeof GraphNodeType.Emit
| typeof GraphNodeType.For
| typeof GraphNodeType.Fork
| typeof GraphNodeType.Listen
| typeof GraphNodeType.Raise
| typeof GraphNodeType.Run
| typeof GraphNodeType.Set
| typeof GraphNodeType.Switch
| typeof GraphNodeType.Try
| typeof GraphNodeType.Wait;

export const TASK_TYPE_KEYS: ReadonlySet<string> = new Set<TaskTypeKey>([
GraphNodeType.Call,
GraphNodeType.Do,
GraphNodeType.Emit,
GraphNodeType.For,
GraphNodeType.Fork,
GraphNodeType.Listen,
GraphNodeType.Raise,
GraphNodeType.Run,
GraphNodeType.Set,
GraphNodeType.Switch,
GraphNodeType.Try,
GraphNodeType.Wait,
]);

/* Returns the key that gives the task its type or undefined when it has none i.e object not a task */
export function getTaskTypeKey(task: Record<string, unknown>): string | undefined {
return Object.keys(task).find((key) => TASK_TYPE_KEYS.has(key));
}



24 changes: 2 additions & 22 deletions packages/open-workflow-diagram-editor/src/core/validationErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,11 @@
* limitations under the License.
*/

import { TASK_TYPE_KEYS } from "./taskTypes";
import { SdkError, ValidationError } from "./workflowSdk";

/* workflowSdk produces a flat array of errors, but the UI needs them split into two categories: errors that attach to a specific node, and workflow-level errors that don't. This file provides helper functions to filter, sort, and slice that error list.
*/

/* The SDK reports an invalid task as "missing" every other task type, which is
* noise. These are the missing-type errors to filter out.
*
* `catch` is intentionally excluded: a missing-property error on a `catch` is a genuine problem worth surfacing.
*/
const MISSING_PROP_TASK_TYPES = new Set([
"call",
"do",
"emit",
"for",
"fork",
"listen",
"raise",
"run",
"set",
"switch",
"try",
"wait",
]);

type NodeError = ValidationError & { path: string };

export function isValidationError(error: SdkError): error is ValidationError {
Expand All @@ -58,7 +38,7 @@ function isNoiseError(error: ValidationError): boolean {
}

const missingProperty = error.object?.["missingProperty"];
if (typeof missingProperty === "string" && MISSING_PROP_TASK_TYPES.has(missingProperty)) {
if (typeof missingProperty === "string" && TASK_TYPE_KEYS.has(missingProperty)) {
return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ import { useFormState } from "react-hook-form";
import { updateTask } from "@/core/workflowEditing";
import { applyDirtyValues } from "@/core/taskDraft";
import { flattenTask } from "@/side-panel/forms/TaskForm";
import { computeSentinelDefaults } from "@/side-panel/forms/FormField";
import {
computeSentinelDefaults,
SENTINEL_KEY,
SENTINEL_PREFIX,
SENTINEL_SUFFIX,
} from "@/side-panel/forms/FormField";
import { getFormFieldsForNodeType } from "@/core";
import { useDiagramEditorContext } from "@/store/DiagramEditorContext";
import { useEditSession } from "./EditSession";
Expand All @@ -34,9 +39,6 @@ import type { Specification } from "@openworkflowspec/sdk";
/* How long the applied message stays in footer */
const APPLIED_MESSAGE_MS = 2400;

const SENTINEL_KEY = "__oneof__";
const SENTINEL_PREFIX = `${SENTINEL_KEY}.`;

type DraftStatusProps = {
changedCount: number;
isDirty: boolean;
Expand Down Expand Up @@ -120,7 +122,7 @@ export function EditFormFooter({ node }: { node: RF.Node<BaseNodeData> }) {
const sentinelPaths = new Set<string>();
for (const path of rawFlatDirty) {
if (path.startsWith(SENTINEL_PREFIX)) {
sentinelPaths.add(path.slice(SENTINEL_PREFIX.length));
sentinelPaths.add(path.slice(SENTINEL_PREFIX.length, -SENTINEL_SUFFIX.length));
} else {
flatDirty.add(path);
}
Expand Down
Loading
Loading