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/emitTask-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 emitTask form generation.
231 changes: 159 additions & 72 deletions packages/open-workflow-diagram-editor/src/core/schemaToFormFields.ts

Large diffs are not rendered by default.

16 changes: 10 additions & 6 deletions packages/open-workflow-diagram-editor/src/core/schemaWalker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,16 @@ const _fieldCache = new Map<string, FormFieldDescriptor[]>();
* 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
* Results are cached by `nodeType+format` so the schema walk only happens once
* per combination. 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);
export function getFormFieldsForNodeType(
nodeType: string,
format: "json" | "yaml" = "yaml",
): FormFieldDescriptor[] {
const cacheKey = `${nodeType}:${format}`;
const cached = _fieldCache.get(cacheKey);
if (cached !== undefined) return cached;

const defName = NODE_TYPE_TO_DEF[nodeType];
Expand All @@ -69,8 +73,8 @@ export function getFormFieldsForNodeType(nodeType: string): FormFieldDescriptor[
const s = getSchemaForDefinition(defName);
const defs = s.$defs as Record<string, unknown> | undefined;
const requiredSet = new Set<string>(Array.isArray(s.required) ? (s.required as string[]) : []);
const fields = schemaToFormFields(s, defs, requiredSet, "");
_fieldCache.set(nodeType, fields);
const fields = schemaToFormFields(s, defs, requiredSet, "", format);
_fieldCache.set(cacheKey, fields);
return fields;
} catch {
return [];
Expand Down
35 changes: 19 additions & 16 deletions packages/open-workflow-diagram-editor/src/core/taskDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,6 @@
* 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.
*/
Expand Down Expand Up @@ -53,16 +47,6 @@ export function unflattenValues(flat: Record<string, unknown>): Record<string, u
* 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()`.
Expand All @@ -75,6 +59,9 @@ export function applyDirtyValues(
original: Record<string, unknown>,
allValues: Record<string, unknown>,
dirtyPaths: Set<string>,

// Paths that are dirty solely because the variant selector (sentinel) changed.
sentinelPaths: Set<string> = new Set(),
): Record<string, unknown> {
// Deep clone the original so we never mutate the store value.
const result = deepClone(original);
Expand All @@ -91,6 +78,19 @@ export function applyDirtyValues(
}
}

// 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 (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("."));
}
}

return result;
}

Expand All @@ -115,7 +115,10 @@ function deepClone<T>(value: T): T {
function isDirtyPath(dotPath: string, dirtyPaths: Set<string>): boolean {
if (dirtyPaths.has(dotPath)) return true;
for (const dirty of dirtyPaths) {
// Case 1: dotPath is a leaf under a dirty parent
if (dotPath.startsWith(dirty + ".")) return true;
// Case 2: a dirty leaf is nested under dotPath
if (dirty.startsWith(dotPath + ".")) return true;
}
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ 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 { getFormFieldsForNodeType } from "@/core";
import { useDiagramEditorContext } from "@/store/DiagramEditorContext";
import { useEditSession } from "./EditSession";
import { Check } from "lucide-react";
Expand All @@ -32,6 +34,9 @@ import type { Specification } from "@openworkflowspec/sdk";
/* How long the applied message stays in footer */
const APPLIED_MESSAGE_MS = 2400;

Comment thread
lornakelly marked this conversation as resolved.
const SENTINEL_KEY = "__oneof__";
const SENTINEL_PREFIX = `${SENTINEL_KEY}.`;

type DraftStatusProps = {
changedCount: number;
isDirty: boolean;
Expand Down Expand Up @@ -85,10 +90,19 @@ export function EditFormFooter({ node }: { node: RF.Node<BaseNodeData> }) {
return null;
}

const changedCount = Object.keys(flattenTask(dirtyFields)).length;
// Use the subscribed dirtyFields (public API) for the UI count
const changedCount = Object.keys(flattenTask(dirtyFields as Record<string, unknown>)).filter(
(p) => !p.startsWith(SENTINEL_PREFIX),
).length;

const handleCancel = () => {
form.reset(task as unknown as Record<string, unknown>);
const nodeType = node.type ?? "";
const allFields = nodeType ? getFormFieldsForNodeType(nodeType) : [];
const sentinelDefaults = computeSentinelDefaults(allFields, task as Record<string, unknown>);
form.reset({
...(task as Record<string, unknown>),
...(Object.keys(sentinelDefaults).length > 0 ? { [SENTINEL_KEY]: sentinelDefaults } : {}),
});
setAppliedNodeId(null);
};

Expand All @@ -98,20 +112,36 @@ export function EditFormFooter({ node }: { node: RF.Node<BaseNodeData> }) {
// 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 rawFlatDirty = Object.keys(flattenTask(dirtyFields));
const flatDirty = new Set<string>();
// Sentinel paths: dirty solely because the variant selector changed.
// Kept separate so applyDirtyValues can handle them correctly — they always
// delete the model property unless the field is also independently dirty.
const sentinelPaths = new Set<string>();
for (const path of rawFlatDirty) {
if (path.startsWith(SENTINEL_PREFIX)) {
sentinelPaths.add(path.slice(SENTINEL_PREFIX.length));
} else {
flatDirty.add(path);
}
}
const updated = applyDirtyValues(
task as unknown as Record<string, unknown>,
flatValues,
flatDirty,
sentinelPaths,
) 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());
// Reset to the committed task state (not form.getValues()) so that
// defaultValues reflect what was actually saved.
const nodeType = node.type ?? "";
const allFields = nodeType ? getFormFieldsForNodeType(nodeType) : [];
const sentinelDefaults = computeSentinelDefaults(allFields, updated as Record<string, unknown>);
form.reset({
...(updated as Record<string, unknown>),
...(Object.keys(sentinelDefaults).length > 0 ? { [SENTINEL_KEY]: sentinelDefaults } : {}),
});
setAppliedNodeId(node.id);

if (dismissTimer.current !== null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ type EditSessionValue = {
form: UseFormReturn<Record<string, unknown>>;
/* true while navigation is being blocked by dirty draft (while discard dialog is on screen) */
isNavigationBlocked: boolean;
/*
/*
* Entry point for anything that would abandon a draft - selecting another node, deselecting, closing panel etc.
* Runs proceed and returns true on clean draft. Holds proceed and returns false on dirty draft
*/
*/
requestNavigation: (proceed: () => void) => boolean;
/* Discards draft and proceeds with navigation */
confirmDiscard: () => void;
/* Abandons navigation and keeps the draft */
cancelNavigation:() => void;
/* Abandons navigation and keeps the draft */
cancelNavigation: () => void;
};

const EditSessionContext = React.createContext<EditSessionValue | undefined>(undefined);
Expand All @@ -38,44 +38,51 @@ export function EditSessionProvider({ children }: { children: React.ReactNode })
const form = useForm<Record<string, unknown>>({ defaultValues: {} });

const isDirtyRef = React.useRef(false);
React.useEffect(()=>
form.subscribe({
formState: { isDirty:true },
callback: ({isDirty}) =>{
isDirtyRef.current = isDirty === true;
},
}),
[form]
)
React.useEffect(
() =>
form.subscribe({
formState: { isDirty: true },
callback: ({ isDirty }) => {
isDirtyRef.current = isDirty === true;
},
}),
[form],
);

const [blockedNavigation, setBlockedNavigation] = React.useState<(()=>void) | null>(null);
const [blockedNavigation, setBlockedNavigation] = React.useState<(() => void) | null>(null);

const requestNavigation = React.useCallback((proceed: () => void)=>{
if(!isDirtyRef.current){
proceed()
const requestNavigation = React.useCallback((proceed: () => void) => {
if (!isDirtyRef.current) {
proceed();
return true;
}

setBlockedNavigation(()=> proceed)
setBlockedNavigation(() => proceed);
return false;
}, [])
}, []);

const confirmDiscard = React.useCallback(()=>{
if(blockedNavigation === null){
const confirmDiscard = React.useCallback(() => {
if (blockedNavigation === null) {
return;
}

form.reset();
setBlockedNavigation(null)
form.reset(form.formState.defaultValues);
setBlockedNavigation(null);
blockedNavigation();
}, [blockedNavigation, form])
}, [blockedNavigation, form]);

const cancelNavigation = React.useCallback(()=> setBlockedNavigation(null),[]);
const cancelNavigation = React.useCallback(() => setBlockedNavigation(null), []);

const value = React.useMemo(()=>({
form, isNavigationBlocked: blockedNavigation !== null,
requestNavigation, confirmDiscard, cancelNavigation
}), [form, blockedNavigation,requestNavigation,confirmDiscard,cancelNavigation])
const value = React.useMemo(
() => ({
form,
isNavigationBlocked: blockedNavigation !== null,
requestNavigation,
confirmDiscard,
cancelNavigation,
}),
[form, blockedNavigation, requestNavigation, confirmDiscard, cancelNavigation],
);

return (
<EditSessionContext.Provider value={value}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
DurationField,
ThenField,
ChildTaskListField,
StructuredValueField,
} from "./customFields";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -55,5 +56,7 @@ export function FieldControl({ field, id }: FieldControlProps) {
return <ThenField field={field} {...idProp} />;
case "child-task-list":
return <ChildTaskListField field={field} />;
case "json":
return <StructuredValueField field={field} {...idProp} />;
}
}
Loading
Loading