Skip to content
Open
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/tryTask-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 tryTask form generation.
1 change: 1 addition & 0 deletions packages/open-workflow-diagram-editor/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { defineConfig } from "@playwright/test";

export default defineConfig({
testDir: "tests-e2e",
timeout: 60000,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this timeout? Even the slowest job (macos-latest) runs the full E2E suite in 60'', and these tests pass locally without it on my laptop.

@handreyrc handreyrc Oct 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fantonangeli,

Yes, we need it. I have text editor tests failing consistently due to timeouts with build:prod.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@handreyrc just curious, what I read under ubuntu-latest job is:

  packages/open-workflow-diagram-editor build:prod: > @openworkflowspec/diagram-editor@1.1.0 test-e2e /home/runner/work/editor/editor/packages/open-workflow-diagram-editor
  packages/open-workflow-diagram-editor build:prod: > playwright test
  packages/open-workflow-diagram-editor build:prod: Running 10 tests using 2 workers
  packages/open-workflow-diagram-editor build:prod: ··········
  packages/open-workflow-diagram-editor build:prod:   10 passed (32.6s)

https://github.com/open-workflow-specification/editor/actions/runs/37802840700/job/113399252881?pr=476#step:5:410
So it seems that executing 10 e2e tests takes 32 seconds in total.
But of course, I don't want to block this PR for a small setting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if cause my laptop is sort of old and it is taking more time.
image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From your image I see 1.8m on text-editor and 35s on diagram-editor.
But I don't see a particular text-editor test slowing down on your machine.
In my local env I have this execution time for the TE, using your branch:

> @openworkflowspec/text-editor@1.1.0 test-e2e /home/fantonan/NotBackedUp/repos/open-workflow-specification-editor-review/packages/text-editor
> playwright test


Running 12 tests using 1 worker

  ✓   1 tests-e2e/text-editor.spec.ts:35:3 › TextEditor JSON › Monaco editor is interactive (2.3s)
  ✓   2 tests-e2e/text-editor.spec.ts:44:5 › TextEditor JSON › Completions › JSON schema completion adds the `do` property (1.9s)
  ✓   3 tests-e2e/text-editor.spec.ts:64:5 › TextEditor JSON › Completions › Hello World Completion inserts the sample workflow (1.3s)
  ✓   4 tests-e2e/text-editor.spec.ts:76:5 › TextEditor JSON › Completions › completions are not available in read-only mode (1.1s)
  ✓   5 tests-e2e/text-editor.spec.ts:90:5 › TextEditor JSON › CodeLenses › Hello World CodeLens activates and inserts the sample workflow (2.0s)
  ✓   6 tests-e2e/text-editor.spec.ts:101:5 › TextEditor JSON › CodeLenses › CodeLens is hidden in read-only mode (1.1s)
  ✓   7 tests-e2e/text-editor.spec.ts:107:5 › TextEditor JSON › CodeLenses › JSON → YAML: CodeLens disappears after language switch (2.0s)
  ✓   8 tests-e2e/text-editor.spec.ts:120:5 › TextEditor JSON › CodeLenses › YAML → JSON: CodeLens reappears after language switch (2.0s)
  ✓   9 tests-e2e/text-editor.spec.ts:133:5 › TextEditor JSON › CodeLenses › YAML + isReadOnly true → false: CodeLens stays hidden (1.1s)
  ✓  10 tests-e2e/text-editor.spec.ts:151:5 › TextEditor JSON › Diagnostics › syntactically invalid JSON shows an error marker (1.3s)
  ✓  11 tests-e2e/text-editor.spec.ts:159:5 › TextEditor JSON › Diagnostics › OWS-schema-invalid JSON shows a warning marker (1.3s)
  ✓  12 tests-e2e/text-editor.spec.ts:168:5 › TextEditor JSON › Diagnostics › replacing OWS-invalid JSON with a valid workflow removes warning markers (1.5s)

  12 passed (22.3s)

Which seems good


expect: {
timeout: 30000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import type { DereferencedSchema } from "./schemaFilter";
import { isPlainObject } from "./utils";
import type { ContentFormat } from "./workflowSdk";
export type { ContentFormat } from "./workflowSdk";

/**
* A single form field descriptor produced by walking a task's JSON Schema.
Expand Down Expand Up @@ -88,6 +89,8 @@ export interface EnumField extends FieldBase {
options: string[];
/** Schema default = what the runtime assumes when the key is absent */
defaultValue?: string;
valueMap?: Record<string, unknown>;
innerObjectFormat?: ContentFormat;
}

/**
Expand Down Expand Up @@ -285,6 +288,53 @@ function isMapSchema(schema: Record<string, unknown>): boolean {
return false;
}

/**
* Returns the ordered variant keys if every oneOf candidate has exactly one
* property whose value schema is an empty-object marker.
*/
function isBackoffSchema(
schema: Record<string, unknown>,
defs: Record<string, unknown> | undefined,
): string[] | undefined {
if (!Array.isArray(schema.oneOf) || schema.properties) return undefined;
const candidates = schema.oneOf as unknown[];
if (candidates.length < 2) return undefined;

const keys: string[] = [];
for (const candidate of candidates) {
if (!isPlainObject(candidate)) return undefined;
const c = candidate as Record<string, unknown>;

const props = c.properties as Record<string, unknown> | undefined;
if (!props || !isPlainObject(props)) return undefined;
const propKeys = Object.keys(props);
if (propKeys.length !== 1) return undefined;
const key = propKeys[0]!;

let valueProp = props[key] as unknown;
if (
isPlainObject(valueProp) &&
typeof (valueProp as Record<string, unknown>).$ref === "string"
) {
const ref = resolveRef((valueProp as Record<string, unknown>).$ref as string, defs);
if (ref) valueProp = ref;
}
if (!isPlainObject(valueProp)) return undefined;
const valueObj = valueProp as Record<string, unknown>;
// Reject if the value schema has any structural constraints.
const hasStructure =
valueObj.properties !== undefined ||
valueObj.oneOf !== undefined ||
valueObj.anyOf !== undefined ||
valueObj.allOf !== undefined ||
(valueObj.additionalProperties !== undefined && valueObj.additionalProperties !== false);
if (hasStructure) return undefined;

keys.push(key);
}
return keys;
}

/**
* Returns the items schema for an array, following one $ref if present.
* Used by both task lists and ordered maps to get the array's item structure.
Expand Down Expand Up @@ -616,6 +666,28 @@ export function schemaToFormFields(
}
}

// ── Backoff type selector ───────────────────────────────────────────────
// Emits an EnumField with valueMap so EnumControl and sets innerObjectFormat
// so EnumControl renders a textarea for the inner object payload beneath the combobox.
const backoffOptions = isBackoffSchema(resolved, localDefs);
if (backoffOptions) {
const valueMap: Record<string, unknown> = {};
for (const opt of backoffOptions) {
valueMap[opt] = { [opt]: {} };
}
fields.push({
kind: "enum",
path: fieldPath,
label: deriveLabel(prop, key),
...withDesc(description),
required: isRequired,
options: backoffOptions,
valueMap,
innerObjectFormat: format,
});
continue;
}

// ── oneOf / anyOf at property level ────────────────────────────────────
const candidates = (resolved.oneOf ?? resolved.anyOf) as unknown[] | undefined;
if (Array.isArray(candidates)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const en = {
"sidebar.form.applied": "Applied",
"sidebar.form.selectOption": "Select an option…",
"sidebar.form.default": "(default)",
"sidebar.form.innerObject.parseError": "Must be a valid YAML/JSON object",
"aria.form.taskProperties": "Task properties",
"sidebar.map.addProperty": "+ Add property",
"sidebar.map.keyPlaceholder": "key",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ export function useWorkflowHistory(isReadOnly: boolean): UseWorkflowHistoryRetur
}

// No-op if model content is unchanged.
if (structuralEqual(present.model, newModel)) return;
if (structuralEqual(present.model, newModel)) {
return;
}

// Content changed externally (e.g. props.content updated by host or addon panel).
// Preserve the current viewport so undo restores to where the user was looking,
Expand Down Expand Up @@ -203,16 +205,12 @@ export function useWorkflowHistory(isReadOnly: boolean): UseWorkflowHistoryRetur
setPendingViewportRestore(null);
}, []);

const resetHistory = React.useCallback(() => {
reset();
}, [reset]);

return {
model: getPresent(state)?.model ?? null,
selectedNodeId: getPresent(state)?.selectedNodeId ?? null,
seedModel,
submitModel,
resetHistory,
resetHistory: reset,
undo,
redo,
canUndo,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@

import * as React from "react";
import type * as RF from "@xyflow/react";
import { GraphNodeType } from "@openworkflowspec/sdk";
import type { BaseNodeData } from "@/react-flow/nodes/Nodes";
import { useI18n } from "@openworkflowspec/i18n";
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, padRemovedPaths } from "@/side-panel/forms/TaskForm";
import { flattenTask, padRemovedPaths, setNestedPath } from "@/side-panel/forms/TaskForm";
import {
computeSentinelDefaults,
SENTINEL_KEY,
Expand All @@ -32,11 +33,17 @@ import {
} from "@/side-panel/forms/FormField";
import { getFormFieldsForNodeType } from "@/core";
import type { FormFieldDescriptor, OneOfField, OneOfVariant } from "@/core/schemaToFormFields";
import { getInnerObjectForPath } from "@/side-panel/forms/customFields/EnumControl";
import { useDiagramEditorContext } from "@/store/DiagramEditorContext";
import { useEditSession } from "./EditSession";
import { Check } from "lucide-react";
import type { Specification } from "@openworkflowspec/sdk";
import { collectFormListPaths, collectWholeValuePaths } from "./forms/taskFormContext";
import {
collectFormListPaths,
collectValueMapFields,
collectWholeValuePaths,
getNestedValue,
} from "./forms/taskFormContext";

/* How long the applied message stays in footer */
const APPLIED_MESSAGE_MS = 2400;
Expand Down Expand Up @@ -137,6 +144,21 @@ function DraftStatus({ changedCount, isDirty, showApplied }: DraftStatusProps) {
);
}

/**
* Returns the task id used to locate and update the task in the workflow model.
*/
function resolveTaskId(node: RF.Node<BaseNodeData>): string {
if (
(node.type === GraphNodeType.Try ||
node.type === GraphNodeType.Catch ||
node.type === "catch-container") &&
node.parentId !== undefined
) {
return node.parentId;
}
return node.id;
}

export function EditFormFooter({ node }: { node: RF.Node<BaseNodeData> }) {
const { t } = useI18n();
const { form } = useEditSession();
Expand All @@ -156,10 +178,11 @@ export function EditFormFooter({ node }: { node: RF.Node<BaseNodeData> }) {

const { dirtyFields, isDirty, defaultValues } = useFormState({ control: form.control });
const task = node.data.task;
const taskId = resolveTaskId(node);
const showApplied = appliedNodeId === node.id;

// Guard conditions that permanently prevent display
if (isReadOnly || task === undefined || node.data.taskReference === undefined || model === null) {
if (isReadOnly || task === undefined || model === null) {
return null;
}

Expand Down Expand Up @@ -203,6 +226,31 @@ export function EditFormFooter({ node }: { node: RF.Node<BaseNodeData> }) {
// that are exclusive to the non-selected variants so they can be removed.
const nodeType = node.type ?? "";
const allFields = nodeType ? getFormFieldsForNodeType(nodeType) : [];
const flatTask = flattenTask(task as Record<string, unknown>);
for (const enumField of collectValueMapFields(allFields)) {
// Read via getValues(path) — reads _formValues directly, bypassing the
// defaultValues fallback that getValues() (no args) applies for absent keys.
const live = form.getValues(enumField.path as never) as unknown;
const raw =
typeof live === "string" || live === undefined ? live : flatValues[enumField.path];
if (typeof raw === "string") {
const key = raw;
if (key && enumField.innerObjectFormat !== undefined) {
// Read the inner object content from the module-level store populated by EnumControl.
const innerObj = getInnerObjectForPath(enumField.path, enumField.innerObjectFormat);
// Keep the draft and inline parse error intact; do not partially commit the form.
if (innerObj === null) return;
flatValues[enumField.path] = { [key]: innerObj };
} else {
flatValues[enumField.path] = key ? enumField.valueMap![key] : undefined;
}
flatDirty.add(enumField.path);
} else if (raw === undefined && flatTask[enumField.path] !== undefined) {
flatValues[enumField.path] = undefined;
flatDirty.add(enumField.path);
}
}

const selectedVariants = collectSelectedVariants(allFields, flatValues);
const liveSentinelPaths = new Set([...sentinelPaths].filter((p) => selectedVariants.has(p)));
const sentinelConstWrites = new Map<string, Record<string, unknown>>();
Expand Down Expand Up @@ -240,7 +288,7 @@ export function EditFormFooter({ node }: { node: RF.Node<BaseNodeData> }) {
collectFormListPaths(allFields),
sentinelPresenceKeys,
) as Specification.Task;
const updatedModel = updateTask(model, node.id, updated);
const updatedModel = updateTask(model, taskId, updated);
commitWorkflow(updatedModel);
// Reset to committed state; pass current sentinel labels so variant
// selections are preserved even when the cleared field has no data match.
Expand All @@ -259,16 +307,21 @@ export function EditFormFooter({ node }: { node: RF.Node<BaseNodeData> }) {
updated as Record<string, unknown>,
currentSentinels,
);
const resetVals: Record<string, unknown> = {
const resetVals: Record<string, unknown> = structuredClone({
...(updated as Record<string, unknown>),
...(Object.keys(sentinelDefaults).length > 0 ? { [SENTINEL_KEY]: sentinelDefaults } : {}),
};
});
padRemovedPaths(
resetVals,
task as Record<string, unknown>,
updated as Record<string, unknown>,
collectWholeValuePaths(allFields, updated as Record<string, unknown>),
);
for (const enumField of collectValueMapFields(allFields)) {
if (getNestedValue(updated as Record<string, unknown>, enumField.path) === undefined) {
setNestedPath(resetVals, enumField.path, "");
}
}
form.reset(resetVals);
setAppliedNodeId(node.id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ import { getFormFieldsForNodeType, structuralEqual } from "@/core";
import { FormField, SENTINEL_KEY, SENTINEL_PREFIX, computeSentinelDefaults } from "./FormField";
import { useSiblingTaskNames } from "./useSiblingTaskNames";
import { useDiagramEditorContext } from "@/store/DiagramEditorContext";
import { TaskFormContext, collectWholeValuePaths, filterReadOnlyFields } from "./taskFormContext";
import {
TaskFormContext,
collectWholeValuePaths,
filterReadOnlyFields,
getNestedValue,
collectValueMapFields,
} from "./taskFormContext";
import { useWorkflowErrorsForForm } from "./validation";
import { useEditSession } from "@/side-panel/EditSession";

Expand Down Expand Up @@ -52,7 +58,15 @@ export function flattenTask(
if (Array.isArray(v) || wholeValuePaths.has(fullKey)) {
result[fullKey] = v;
} else if (typeof v === "object" && v !== null) {
result = { ...result, ...flattenTask(v, fullKey, wholeValuePaths) };
const nested = flattenTask(v, fullKey, wholeValuePaths);
// If recursing into the object produced no entries but the object itself
// is non-empty, keep it as a leaf so it is not silently discarded.
// Truly empty objects ({}) are dropped as before.
if (Object.keys(nested).length === 0 && Object.keys(v as object).length > 0) {
result[fullKey] = v;
} else {
result = { ...result, ...nested };
}
} else {
result[fullKey] = v;
}
Expand All @@ -62,7 +76,7 @@ export function flattenTask(
return prefix ? { [prefix]: value } : {};
}

function setNestedPath(obj: Record<string, unknown>, dotPath: string, value: unknown): void {
export function setNestedPath(obj: Record<string, unknown>, dotPath: string, value: unknown): void {
const parts = dotPath.split(".");
if (
parts.some((part) => part === "__proto__" || part === "prototype" || part === "constructor")
Expand Down Expand Up @@ -187,16 +201,21 @@ export function TaskForm({ nodeType, task, nodeId, taskReference }: TaskFormProp
liveSentinels,
);
const taskClone = structuredClone(task as Record<string, unknown>);
const resetVals: Record<string, unknown> = {
const resetVals: Record<string, unknown> = structuredClone({
...taskClone,
...(Object.keys(sentinelDefaults).length > 0 ? { [SENTINEL_KEY]: sentinelDefaults } : {}),
};
});
padRemovedPaths(
resetVals,
prevTask as Record<string, unknown>,
taskClone,
collectWholeValuePaths(allFields, taskClone),
);
for (const enumField of collectValueMapFields(allFields)) {
if (getNestedValue(task as Record<string, unknown>, enumField.path) === undefined) {
setNestedPath(resetVals, enumField.path, "");
}
}
form.reset(resetVals);
}, [task, nodeId, form, allFields]);

Expand Down
Loading
Loading