diff --git a/client/package.json b/client/package.json index f63ed194a64f..15d5b7f818c9 100644 --- a/client/package.json +++ b/client/package.json @@ -43,6 +43,7 @@ "@sentry/browser": "^7.74.1", "@types/jest": "^29.5.6", "@vueuse/core": "^10.5.0", + "@vueuse/math": "^10.9.0", "assert": "^2.1.0", "axios": "^1.6.2", "babel-runtime": "^6.26.0", diff --git a/client/src/components/Markdown/MarkdownEditor.vue b/client/src/components/Markdown/MarkdownEditor.vue index 7ffa96365c61..09ed3bd6e6bb 100644 --- a/client/src/components/Markdown/MarkdownEditor.vue +++ b/client/src/components/Markdown/MarkdownEditor.vue @@ -67,10 +67,6 @@ export default { type: String, default: null, }, - markdownConfig: { - type: Object, - default: null, - }, steps: { type: Object, default: null, diff --git a/client/src/components/Markdown/parse.ts b/client/src/components/Markdown/parse.ts index f7d5b17cd562..f6ff6bf2923b 100644 --- a/client/src/components/Markdown/parse.ts +++ b/client/src/components/Markdown/parse.ts @@ -62,8 +62,8 @@ export function splitMarkdown(markdown: string, preserveWhitespace: boolean = fa export function replaceLabel( markdown: string, labelType: WorkflowLabelKind, - fromLabel: string, - toLabel: string + fromLabel: string | null | undefined, + toLabel: string | null | undefined ): string { const { sections } = splitMarkdown(markdown, true); @@ -80,9 +80,9 @@ export function replaceLabel( } // we've got a section with a matching label and type... const newArgs = { ...args }; - newArgs[labelType] = toLabel; + newArgs[labelType] = toLabel ?? ""; const argRexExp = namedArgumentRegex(labelType); - let escapedToLabel = escapeRegExpReplacement(toLabel); + let escapedToLabel = escapeRegExpReplacement(toLabel ?? ""); const incomingContent = directiveSection.content; let content: string; const match = incomingContent.match(argRexExp); diff --git a/client/src/components/Workflow/Editor/Actions/actions.test.ts b/client/src/components/Workflow/Editor/Actions/actions.test.ts new file mode 100644 index 000000000000..917a4f68ccd1 --- /dev/null +++ b/client/src/components/Workflow/Editor/Actions/actions.test.ts @@ -0,0 +1,293 @@ +import { createPinia, setActivePinia } from "pinia"; + +import { LazyUndoRedoAction, UndoRedoAction, useUndoRedoStore } from "@/stores/undoRedoStore"; +import { useConnectionStore } from "@/stores/workflowConnectionStore"; +import { useWorkflowCommentStore } from "@/stores/workflowEditorCommentStore"; +import { useWorkflowStateStore } from "@/stores/workflowEditorStateStore"; +import { useWorkflowStepStore } from "@/stores/workflowStepStore"; + +import { fromSimple, type Workflow } from "../modules/model"; +import { + AddCommentAction, + ChangeColorAction, + DeleteCommentAction, + LazyChangeDataAction, + LazyChangePositionAction, + LazyChangeSizeAction, + LazyMoveMultipleAction, +} from "./commentActions"; +import { mockComment, mockToolStep, mockWorkflow } from "./mockData"; +import { + CopyStepAction, + InsertStepAction, + LazyMutateStepAction, + LazySetLabelAction, + LazySetOutputLabelAction, + RemoveStepAction, + UpdateStepAction, +} from "./stepActions"; +import { CopyIntoWorkflowAction, LazySetValueAction } from "./workflowActions"; + +const workflowId = "mock-workflow"; + +describe("Workflow Undo Redo Actions", () => { + jest.useFakeTimers(); + + const pinia = createPinia(); + setActivePinia(pinia); + + let workflow = mockWorkflow(); + let stores = resetStores(); + + beforeEach(async () => { + workflow = mockWorkflow(); + stores = resetStores(); + + await fromSimple(workflowId, workflow); + }); + + function testUndoRedo(action: UndoRedoAction | LazyUndoRedoAction, afterApplyCallback?: () => void) { + const beforeApplyAction = getWorkflowSnapshot(workflow); + + if (action instanceof LazyUndoRedoAction) { + undoRedoStore.applyLazyAction(action); + undoRedoStore.flushLazyAction(); + } else { + undoRedoStore.applyAction(action); + } + + afterApplyCallback?.(); + + const afterApplyActionSnapshot = getWorkflowSnapshot(workflow); + expect(afterApplyActionSnapshot).not.toEqual(beforeApplyAction); + + stores.undoRedoStore.undo(); + + const undoSnapshot = getWorkflowSnapshot(workflow); + expect(undoSnapshot).toEqual(beforeApplyAction); + + stores.undoRedoStore.redo(); + + const redoSnapshot = getWorkflowSnapshot(workflow); + expect(redoSnapshot).toEqual(afterApplyActionSnapshot); + } + + const { commentStore, undoRedoStore, stepStore, stateStore, connectionStore } = stores; + + describe("Comment Actions", () => { + function addComment() { + const comment = mockComment(commentStore.highestCommentId + 1); + commentStore.addComments([comment]); + return comment; + } + + it("AddCommentAction", () => { + expect(commentStore.comments.length).toBe(0); + + const comment = mockComment(0); + const insertAction = new AddCommentAction(commentStore, comment); + + testUndoRedo(insertAction, () => commentStore.addComments([comment])); + }); + + it("DeleteCommentAction", () => { + const comment = addComment(); + const action = new DeleteCommentAction(commentStore, comment); + testUndoRedo(action); + }); + + it("ChangeColorAction", () => { + const comment = addComment(); + const action = new ChangeColorAction(commentStore, comment, "pink"); + testUndoRedo(action); + }); + + it("LazyChangeDataAction", () => { + const comment = addComment(); + const action = new LazyChangeDataAction(commentStore, comment, { text: "abc", size: 1 }); + testUndoRedo(action); + }); + + it("LazyChangePositionAction", () => { + const comment = addComment(); + const action = new LazyChangePositionAction(commentStore, comment, [20, 80]); + testUndoRedo(action); + }); + + it("LazyChangeSizeAction", () => { + const comment = addComment(); + const action = new LazyChangeSizeAction(commentStore, comment, [1000, 1000]); + testUndoRedo(action); + }); + + it("LazyMoveMultipleAction", () => { + addComment(); + const action = new LazyMoveMultipleAction( + commentStore, + stores.stepStore, + commentStore.comments, + Object.values(stores.stepStore.steps) as any, + { x: 0, y: 0 }, + { x: 500, y: 500 } + ); + testUndoRedo(action); + }); + }); + + describe("Workflow Actions", () => { + it("LazySetValueAction", () => { + const setValueCallback = (tags: string[]) => { + workflow.tags = tags; + }; + + const showCanvasCallback = jest.fn(); + + const action = new LazySetValueAction([], ["hello", "world"], setValueCallback, showCanvasCallback); + testUndoRedo(action); + + expect(showCanvasCallback).toBeCalledTimes(2); + }); + + it("CopyIntoWorkflowAction", () => { + const other = mockWorkflow(); + const action = new CopyIntoWorkflowAction(workflowId, other, { left: 10, top: 20 }); + testUndoRedo(action); + }); + }); + + describe("Step Actions", () => { + function addStep() { + const step = mockToolStep(stepStore.getStepIndex + 1); + stepStore.addStep(step); + return step; + } + + it("LazyMutateStepAction", () => { + const step = addStep(); + const action = new LazyMutateStepAction(stepStore, step.id, "annotation", "", "hello world"); + testUndoRedo(action); + }); + + it("UpdateStepAction", () => { + const step = addStep(); + const action = new UpdateStepAction( + stepStore, + stateStore, + step.id, + { + outputs: step.outputs, + }, + { + outputs: [{ name: "output", extensions: ["input"], type: "data", optional: true }], + } + ); + testUndoRedo(action); + }); + + it("InsertStepAction", () => { + const step = mockToolStep(1); + const action = new InsertStepAction(stepStore, stateStore, { + contentId: "mock", + name: "step", + type: "tool", + position: { left: 0, top: 0 }, + }); + action.updateStepData = step; + testUndoRedo(action); + }); + + it("RemoveStepAction", () => { + const step = addStep(); + const showAttributesCallback = jest.fn(); + const action = new RemoveStepAction(stepStore, stateStore, connectionStore, showAttributesCallback, step); + testUndoRedo(action); + + expect(showAttributesCallback).toBeCalledTimes(2); + }); + + it("CopyStepAction", () => { + const step = addStep(); + const action = new CopyStepAction(stepStore, stateStore, step); + testUndoRedo(action); + }); + + it("LazySetLabelAction", () => { + const step = addStep(); + const action = new LazySetLabelAction(stepStore, stateStore, step.id, step.label, "custom_label"); + testUndoRedo(action); + }); + + it("LazySetOutputLabelAction", () => { + const step = addStep(); + const action = new LazySetOutputLabelAction(stepStore, stateStore, step.id, null, "abc", [ + { + label: "abc", + output_name: "out_file1", + }, + ]); + + testUndoRedo(action); + }); + }); +}); + +function resetStores(id = workflowId) { + const stepStore = useWorkflowStepStore(id); + const stateStore = useWorkflowStateStore(id); + const connectionStore = useConnectionStore(id); + const commentStore = useWorkflowCommentStore(id); + const undoRedoStore = useUndoRedoStore(id); + + stepStore.$reset(); + stateStore.$reset(); + connectionStore.$reset(); + commentStore.$reset(); + undoRedoStore.$reset(); + + return { + stepStore, + stateStore, + commentStore, + connectionStore, + undoRedoStore, + }; +} + +function extractKeys(object: O, keys: (keyof O)[]): Partial { + const extracted: Partial = {}; + + keys.forEach((key) => { + extracted[key] = object[key]; + }); + + return extracted; +} + +function getWorkflowSnapshot(workflow: Workflow, id = workflowId): object { + const stepStore = useWorkflowStepStore(id); + const stateStore = useWorkflowStateStore(id); + const connectionStore = useConnectionStore(id); + const commentStore = useWorkflowCommentStore(id); + + const state = structuredClone({ + stepStoreState: extractKeys(stepStore, ["steps", "stepExtraInputs", "stepInputMapOver", "stepMapOver"]), + stateStoreState: extractKeys(stateStore, [ + "inputTerminals", + "outputTerminals", + "stepPosition", + "stepLoadingState", + "report", + ]), + connectionStoreState: extractKeys(connectionStore, [ + "connections", + "invalidConnections", + "inputTerminalToOutputTerminals", + "terminalToConnection", + "stepToConnections", + ]), + commentStoreState: extractKeys(commentStore, ["commentsRecord"]), + workflowState: workflow, + }); + + return state; +} diff --git a/client/src/components/Workflow/Editor/Actions/commentActions.ts b/client/src/components/Workflow/Editor/Actions/commentActions.ts new file mode 100644 index 000000000000..7121652789a1 --- /dev/null +++ b/client/src/components/Workflow/Editor/Actions/commentActions.ts @@ -0,0 +1,229 @@ +import { LazyUndoRedoAction, UndoRedoAction } from "@/stores/undoRedoStore"; +import type { + BaseWorkflowComment, + WorkflowComment, + WorkflowCommentColor, + WorkflowCommentStore, +} from "@/stores/workflowEditorCommentStore"; +import type { Step, WorkflowStepStore } from "@/stores/workflowStepStore"; + +class CommentAction extends UndoRedoAction { + protected store: WorkflowCommentStore; + protected comment: WorkflowComment; + + constructor(store: WorkflowCommentStore, comment: BaseWorkflowComment) { + super(); + this.store = store; + this.comment = structuredClone(comment) as WorkflowComment; + } +} + +export class AddCommentAction extends CommentAction { + get name() { + return `add ${this.comment.type} comment`; + } + + undo() { + this.store.deleteComment(this.comment.id); + } + + redo() { + this.store.addComments([this.comment]); + } +} + +export class DeleteCommentAction extends CommentAction { + get name() { + return `delete ${this.comment.type} comment`; + } + + run() { + this.store.deleteComment(this.comment.id); + } + + undo() { + this.store.addComments([this.comment]); + } +} + +export class ChangeColorAction extends UndoRedoAction { + private commentId: number; + private toColor: WorkflowCommentColor; + private fromColor: WorkflowCommentColor; + private store: WorkflowCommentStore; + protected type; + + constructor(store: WorkflowCommentStore, comment: WorkflowComment, color: WorkflowCommentColor) { + super(); + this.store = store; + this.commentId = comment.id; + this.fromColor = comment.color; + this.toColor = color; + this.type = comment.type; + } + + get name() { + return `change ${this.type} comment color to ${this.toColor}`; + } + + run() { + this.store.changeColor(this.commentId, this.toColor); + } + + undo() { + this.store.changeColor(this.commentId, this.fromColor); + } +} + +class LazyMutateCommentAction extends LazyUndoRedoAction { + private commentId: number; + private startData: WorkflowComment[K]; + private endData: WorkflowComment[K]; + protected type; + protected applyDataCallback: (commentId: number, data: WorkflowComment[K]) => void; + + constructor( + comment: WorkflowComment, + key: K, + data: WorkflowComment[K], + applyDataCallback: (commentId: number, data: WorkflowComment[K]) => void + ) { + super(); + this.commentId = comment.id; + this.startData = structuredClone(comment[key]); + this.endData = structuredClone(data); + this.applyDataCallback = applyDataCallback; + this.type = comment.type; + } + + queued() { + this.applyDataCallback(this.commentId, this.endData); + } + + get name() { + return `change ${this.type} comment`; + } + + updateData(data: WorkflowComment[K]) { + this.endData = data; + this.applyDataCallback(this.commentId, this.endData); + } + + redo() { + this.applyDataCallback(this.commentId, this.endData); + } + + undo() { + this.applyDataCallback(this.commentId, this.startData); + } +} + +export class LazyChangeDataAction extends LazyMutateCommentAction<"data"> { + constructor(store: WorkflowCommentStore, comment: WorkflowComment, data: WorkflowComment["data"]) { + const callback = store.changeData; + super(comment, "data", data, callback); + } +} + +export class LazyChangePositionAction extends LazyMutateCommentAction<"position"> { + constructor(store: WorkflowCommentStore, comment: WorkflowComment, position: [number, number]) { + const callback = store.changePosition; + super(comment, "position", position, callback); + } + + get name() { + return `change ${this.type} comment position`; + } +} + +export class LazyChangeSizeAction extends LazyMutateCommentAction<"size"> { + constructor(store: WorkflowCommentStore, comment: WorkflowComment, size: [number, number]) { + const callback = store.changeSize; + super(comment, "size", size, callback); + } + + get name() { + return `resize ${this.type} comment`; + } +} + +type StepWithPosition = Step & { position: NonNullable }; + +export class LazyMoveMultipleAction extends LazyUndoRedoAction { + private commentStore; + private stepStore; + private comments; + private steps; + + private stepStartOffsets = new Map(); + private commentStartOffsets = new Map(); + + private positionFrom; + private positionTo; + + get name() { + return "move multiple nodes"; + } + + constructor( + commentStore: WorkflowCommentStore, + stepStore: WorkflowStepStore, + comments: WorkflowComment[], + steps: StepWithPosition[], + position: { x: number; y: number }, + positionTo?: { x: number; y: number } + ) { + super(); + this.commentStore = commentStore; + this.stepStore = stepStore; + this.comments = [...comments]; + this.steps = [...steps]; + + this.steps.forEach((step) => { + this.stepStartOffsets.set(step.id, [step.position.left - position.x, step.position.top - position.y]); + }); + + this.comments.forEach((comment) => { + this.commentStartOffsets.set(comment.id, [ + comment.position[0] - position.x, + comment.position[1] - position.y, + ]); + }); + + this.positionFrom = { ...position }; + this.positionTo = positionTo ? { ...positionTo } : { ...position }; + } + + changePosition(position: { x: number; y: number }) { + this.setPosition(position); + this.positionTo = { ...position }; + } + + private setPosition(position: { x: number; y: number }) { + this.steps.forEach((step) => { + const stepPosition = { left: 0, top: 0 }; + const offset = this.stepStartOffsets.get(step.id) ?? [0, 0]; + stepPosition.left = position.x + offset[0]; + stepPosition.top = position.y + offset[1]; + this.stepStore.updateStep({ ...step, position: stepPosition }); + }); + + this.comments.forEach((comment) => { + const offset = this.commentStartOffsets.get(comment.id) ?? [0, 0]; + const commentPosition = [position.x + offset[0], position.y + offset[1]] as [number, number]; + this.commentStore.changePosition(comment.id, commentPosition); + }); + } + + queued() { + this.setPosition(this.positionTo); + } + + undo() { + this.setPosition(this.positionFrom); + } + + redo() { + this.setPosition(this.positionTo); + } +} diff --git a/client/src/components/Workflow/Editor/Actions/mockData.ts b/client/src/components/Workflow/Editor/Actions/mockData.ts new file mode 100644 index 000000000000..2b5faea23161 --- /dev/null +++ b/client/src/components/Workflow/Editor/Actions/mockData.ts @@ -0,0 +1,229 @@ +import { WorkflowComment } from "@/stores/workflowEditorCommentStore"; +import type { Step } from "@/stores/workflowStepStore"; + +import type { Workflow } from "../modules/model"; + +export function mockToolStep(id: number): Step { + return { + id, + type: "tool", + label: null, + content_id: "cat1", + name: "Concatenate datasets", + tool_state: { + input1: '{"__class__": "ConnectedValue"}', + queries: "[]", + __page__: null, + __rerun_remap_job_id__: null, + }, + errors: null, + inputs: [ + { + name: "input1", + label: "Concatenate Dataset", + multiple: false, + extensions: ["data"], + optional: false, + input_type: "dataset", + }, + ], + outputs: [{ name: "out_file1", extensions: ["input"], type: "data", optional: false }], + config_form: { + model_class: "Tool", + id: "cat1", + name: "Concatenate datasets", + version: "1.0.0", + description: "tail-to-head", + labels: [], + edam_operations: ["operation_3436"], + edam_topics: [], + hidden: "", + is_workflow_compatible: true, + xrefs: [], + config_file: "/path/to/file", + panel_section_id: "textutil", + panel_section_name: "Text Manipulation", + form_style: "regular", + inputs: [ + { + model_class: "DataToolParameter", + name: "input1", + argument: null, + type: "data", + label: "Concatenate Dataset", + help: "", + refresh_on_change: true, + optional: false, + hidden: false, + is_dynamic: false, + value: { __class__: "ConnectedValue" }, + extensions: ["data"], + edam: { edam_formats: ["format_1915"], edam_data: ["data_0006"] }, + multiple: false, + options: { dce: [], ldda: [], hda: [], hdca: [] }, + tag: null, + default_value: { __class__: "RuntimeValue" }, + text_value: "Not available.", + }, + { + model_class: "Repeat", + name: "queries", + type: "repeat", + title: "Dataset", + help: null, + default: 0, + min: 0, + max: "__Infinity__", + inputs: [ + { + model_class: "DataToolParameter", + name: "input2", + argument: null, + type: "data", + label: "Select", + help: "", + refresh_on_change: true, + optional: false, + hidden: false, + is_dynamic: false, + value: { __class__: "RuntimeValue" }, + extensions: ["data"], + edam: { edam_formats: ["format_1915"], edam_data: ["data_0006"] }, + multiple: false, + options: { dce: [], ldda: [], hda: [], hdca: [] }, + tag: null, + }, + ], + cache: [], + }, + ], + help: "tool help", + citations: false, + sharable_url: null, + message: "", + warnings: null, + versions: ["1.0.0"], + requirements: [], + errors: {}, + tool_errors: null, + state_inputs: { input1: { __class__: "ConnectedValue" }, queries: [] }, + job_id: null, + job_remap: null, + history_id: null, + display: true, + action: "/tool_runner/index", + license: null, + creator: null, + method: "post", + enctype: "application/x-www-form-urlencoded", + }, + annotation: "", + post_job_actions: {}, + uuid: `fake-uuid-${id}`, + when: null, + workflow_outputs: [], + tooltip: "tip", + tool_version: "1.0.0", + input_connections: { input1: [{ output_name: "output", id: 0 }] }, + position: { left: 300, top: 100 }, + }; +} + +const inputStep = { + id: 0, + type: "data_input", + label: null, + content_id: null, + name: "Input dataset", + tool_state: { optional: "false", tag: null, __page__: null, __rerun_remap_job_id__: null }, + errors: null, + inputs: [], + outputs: [{ name: "output", extensions: ["input"], optional: false }], + config_form: { + title: "Input dataset", + inputs: [ + { + model_class: "BooleanToolParameter", + name: "optional", + argument: null, + type: "boolean", + label: "Optional", + help: null, + refresh_on_change: false, + optional: false, + hidden: false, + is_dynamic: false, + value: false, + truevalue: "true", + falsevalue: "false", + }, + { + model_class: "TextToolParameter", + name: "format", + argument: null, + type: "text", + label: "Format(s)", + help: "Leave empty to auto-generate filtered list at runtime based on connections.", + refresh_on_change: false, + optional: true, + hidden: false, + is_dynamic: false, + value: "", + area: false, + datalist: [], + }, + { + model_class: "TextToolParameter", + name: "tag", + argument: null, + type: "text", + label: "Tag filter", + help: "Tags to automatically filter inputs", + refresh_on_change: false, + optional: true, + hidden: false, + is_dynamic: false, + value: null, + area: false, + datalist: [], + }, + ], + }, + annotation: "", + post_job_actions: {}, + uuid: "8ecca4e7-a50e-4e96-a038-7b735e93b54f", + when: null, + workflow_outputs: [], + tooltip: null, + input_connections: {}, + position: { left: 0, top: 100 }, +} as Step; + +export function mockWorkflow(): Workflow { + return { + name: "Mock Workflow", + annotation: "this is not a real workflow", + comments: [], + creator: [], + license: "", + report: { + markdown: "", + }, + steps: { + "0": structuredClone(inputStep), + }, + tags: [], + version: 1, + }; +} + +export function mockComment(id: number): WorkflowComment { + return { + id, + position: [0, 0], + size: [170, 50], + type: "text", + color: "none", + data: { size: 2, text: "Enter Text" }, + }; +} diff --git a/client/src/components/Workflow/Editor/Actions/stepActions.ts b/client/src/components/Workflow/Editor/Actions/stepActions.ts new file mode 100644 index 000000000000..dfccbcbffb8d --- /dev/null +++ b/client/src/components/Workflow/Editor/Actions/stepActions.ts @@ -0,0 +1,510 @@ +import { replaceLabel } from "@/components/Markdown/parse"; +import { useToast } from "@/composables/toast"; +import { useRefreshFromStore } from "@/stores/refreshFromStore"; +import { LazyUndoRedoAction, UndoRedoAction, UndoRedoStore } from "@/stores/undoRedoStore"; +import { Connection, WorkflowConnectionStore } from "@/stores/workflowConnectionStore"; +import { WorkflowStateStore } from "@/stores/workflowEditorStateStore"; +import type { NewStep, Step, WorkflowStepStore } from "@/stores/workflowStepStore"; +import { assertDefined } from "@/utils/assertions"; + +export class LazyMutateStepAction extends LazyUndoRedoAction { + key: K; + fromValue: Step[K]; + toValue: Step[K]; + stepId; + stepStore; + onUndoRedo?: () => void; + + get name() { + return this.internalName ?? "modify step"; + } + + set name(name: string | undefined) { + this.internalName = name; + } + + constructor(stepStore: WorkflowStepStore, stepId: number, key: K, fromValue: Step[K], toValue: Step[K]) { + super(); + this.stepStore = stepStore; + this.stepId = stepId; + this.key = key; + this.fromValue = fromValue; + this.toValue = toValue; + } + + queued() { + this.stepStore.updateStepValue(this.stepId, this.key, this.toValue); + } + + changeValue(value: Step[K]) { + this.toValue = value; + this.stepStore.updateStepValue(this.stepId, this.key, this.toValue); + } + + undo() { + this.stepStore.updateStepValue(this.stepId, this.key, this.fromValue); + this.onUndoRedo?.(); + } + + redo() { + this.stepStore.updateStepValue(this.stepId, this.key, this.toValue); + this.onUndoRedo?.(); + } +} + +export class LazySetLabelAction extends LazyMutateStepAction<"label"> { + labelType: "input" | "step"; + labelTypeTitle: "Input" | "Step"; + stateStore: WorkflowStateStore; + success; + + constructor( + stepStore: WorkflowStepStore, + stateStore: WorkflowStateStore, + stepId: number, + fromValue: Step["label"], + toValue: Step["label"] + ) { + super(stepStore, stepId, "label", fromValue, toValue); + + const step = this.stepStore.getStep(this.stepId); + assertDefined(step); + + const stepType = step.type; + const isInput = ["data_input", "data_collection_input", "parameter_input"].indexOf(stepType) >= 0; + this.labelType = isInput ? "input" : "step"; + this.labelTypeTitle = isInput ? "Input" : "Step"; + this.stateStore = stateStore; + this.success = useToast().success; + } + + private toast(from: string, to: string) { + this.success(`${this.labelTypeTitle} label updated from "${from}" to "${to}" in workflow report.`); + } + + run() { + const markdown = this.stateStore.report.markdown ?? ""; + const newMarkdown = replaceLabel(markdown, this.labelType, this.fromValue as string, this.toValue as string); + this.stateStore.report.markdown = newMarkdown; + this.toast(this.fromValue ?? "", this.toValue ?? ""); + } + + undo() { + super.undo(); + + const markdown = this.stateStore.report.markdown ?? ""; + const newMarkdown = replaceLabel(markdown, this.labelType, this.toValue as string, this.fromValue as string); + this.stateStore.report.markdown = newMarkdown; + this.toast(this.toValue ?? "", this.fromValue ?? ""); + } + + redo() { + super.redo(); + this.run(); + } +} + +export class LazySetOutputLabelAction extends LazyMutateStepAction<"workflow_outputs"> { + success; + fromLabel; + toLabel; + stateStore; + + constructor( + stepStore: WorkflowStepStore, + stateStore: WorkflowStateStore, + stepId: number, + fromValue: string | null, + toValue: string | null, + toOutputs: Step["workflow_outputs"] + ) { + const step = stepStore.getStep(stepId); + assertDefined(step); + const fromOutputs = structuredClone(step.workflow_outputs); + + super(stepStore, stepId, "workflow_outputs", fromOutputs, structuredClone(toOutputs)); + + this.fromLabel = fromValue; + this.toLabel = toValue; + this.stateStore = stateStore; + this.success = useToast().success; + } + + private toast(from: string, to: string) { + this.success(`Output label updated from "${from}" to "${to}" in workflow report.`); + } + + run() { + const markdown = this.stateStore.report.markdown ?? ""; + const newMarkdown = replaceLabel(markdown, "output", this.fromLabel, this.toLabel); + this.stateStore.report.markdown = newMarkdown; + this.toast(this.fromLabel ?? "", this.toLabel ?? ""); + } + + undo() { + super.undo(); + + const markdown = this.stateStore.report.markdown ?? ""; + const newMarkdown = replaceLabel(markdown, "output", this.toLabel, this.fromLabel); + this.stateStore.report.markdown = newMarkdown; + + this.toast(this.toLabel ?? "", this.fromLabel ?? ""); + } + + redo() { + this.run(); + super.redo(); + } +} + +export class UpdateStepAction extends UndoRedoAction { + stepStore; + stateStore; + stepId; + fromPartial; + toPartial; + onUndoRedo?: () => void; + + get name() { + return this.internalName ?? "modify step"; + } + + set name(name: string | undefined) { + this.internalName = name; + } + + constructor( + stepStore: WorkflowStepStore, + stateStore: WorkflowStateStore, + stepId: number, + fromPartial: Partial, + toPartial: Partial + ) { + super(); + this.stepStore = stepStore; + this.stateStore = stateStore; + this.stepId = stepId; + this.fromPartial = fromPartial; + this.toPartial = toPartial; + } + + isEmpty() { + return Object.keys(this.fromPartial).length === 0; + } + + run() { + const step = this.stepStore.getStep(this.stepId); + assertDefined(step); + this.stateStore.activeNodeId = this.stepId; + this.stepStore.updateStep({ ...step, ...this.toPartial }); + } + + undo() { + const step = this.stepStore.getStep(this.stepId); + assertDefined(step); + this.stateStore.activeNodeId = this.stepId; + this.stepStore.updateStep({ ...step, ...this.fromPartial }); + this.onUndoRedo?.(); + } + + redo() { + this.run(); + this.onUndoRedo?.(); + } +} + +export class SetDataAction extends UpdateStepAction { + constructor(stepStore: WorkflowStepStore, stateStore: WorkflowStateStore, from: Step, to: Step) { + const fromPartial: Partial = {}; + const toPartial: Partial = {}; + + Object.entries(from).forEach(([key, value]) => { + const otherValue = to[key as keyof Step] as any; + + if (JSON.stringify(value) !== JSON.stringify(otherValue)) { + fromPartial[key as keyof Step] = structuredClone(value); + toPartial[key as keyof Step] = structuredClone(otherValue); + } + }); + + super(stepStore, stateStore, from.id, fromPartial, toPartial); + } +} + +export type InsertStepData = { + contentId: Parameters[0]; + name: Parameters[1]; + type: Parameters[2]; + position: Parameters[3]; +}; + +export class InsertStepAction extends UndoRedoAction { + stepStore; + stateStore; + stepData; + updateStepData?: Step; + stepId?: number; + newStepData?: Step; + + constructor(stepStore: WorkflowStepStore, stateStore: WorkflowStateStore, stepData: InsertStepData) { + super(); + this.stepStore = stepStore; + this.stateStore = stateStore; + this.stepData = stepData; + } + + get name() { + return `insert ${this.stepData.name}`; + } + + stepDataToTuple() { + return Object.values(this.stepData) as Parameters; + } + + getNewStepData() { + assertDefined(this.newStepData); + return this.newStepData; + } + + run() { + this.newStepData = this.stepStore.insertNewStep(...this.stepDataToTuple()); + this.stepId = this.newStepData.id; + + if (this.updateStepData) { + this.stepStore.updateStep(this.updateStepData); + this.stepId = this.updateStepData.id; + } + } + + undo() { + assertDefined(this.stepId); + this.stepStore.removeStep(this.stepId); + } + + redo() { + this.run(); + assertDefined(this.stepId); + this.stateStore.activeNodeId = this.stepId; + } +} + +export class RemoveStepAction extends UndoRedoAction { + stepStore; + stateStore; + connectionStore; + showAttributesCallback; + step: Step; + connections: Connection[]; + + constructor( + stepStore: WorkflowStepStore, + stateStore: WorkflowStateStore, + connectionStore: WorkflowConnectionStore, + showAttributesCallback: () => void, + step: Step + ) { + super(); + this.stepStore = stepStore; + this.stateStore = stateStore; + this.connectionStore = connectionStore; + this.showAttributesCallback = showAttributesCallback; + this.step = structuredClone(step); + this.connections = structuredClone(this.connectionStore.getConnectionsForStep(this.step.id)); + } + + get name() { + return `remove ${this.step.label ?? this.step.name}`; + } + + run() { + this.stepStore.removeStep(this.step.id); + this.showAttributesCallback(); + this.stateStore.hasChanges = true; + } + + undo() { + this.stepStore.addStep(structuredClone(this.step), false); + this.connections.forEach((connection) => this.connectionStore.addConnection(connection)); + this.stateStore.activeNodeId = this.step.id; + this.stateStore.hasChanges = true; + } +} + +export class CopyStepAction extends UndoRedoAction { + stepStore; + stateStore; + step: NewStep; + stepId?: number; + onUndoRedo?: () => void; + + constructor(stepStore: WorkflowStepStore, stateStore: WorkflowStateStore, step: Step) { + super(); + this.stepStore = stepStore; + this.stateStore = stateStore; + this.step = structuredClone(step); + delete this.step.id; + } + + get name() { + return `duplicate step ${this.step.label ?? this.step.name}`; + } + + run() { + const newStep = this.stepStore.addStep(structuredClone(this.step)); + this.stepId = newStep.id; + this.stateStore.activeNodeId = this.stepId; + this.stateStore.hasChanges = true; + } + + undo() { + assertDefined(this.stepId); + this.stepStore.removeStep(this.stepId); + } +} + +export function useStepActions( + stepStore: WorkflowStepStore, + undoRedoStore: UndoRedoStore, + stateStore: WorkflowStateStore, + connectionStore: WorkflowConnectionStore +) { + /** + * If the pending action is a `LazyMutateStepAction` and matches the step id and field key, returns it. + * Otherwise returns `null` + */ + function actionForIdAndKey(id: number, key: keyof Step) { + const pendingAction = undoRedoStore.pendingLazyAction; + + if (pendingAction instanceof LazyMutateStepAction && pendingAction.stepId === id && pendingAction.key === key) { + return pendingAction; + } else { + return null; + } + } + + /** + * Mutates a queued lazy action, if a matching one exists, + * otherwise creates a new lazy action ans queues it. + */ + function changeValueOrCreateAction( + step: Step, + key: K, + value: Step[K], + name?: string, + actionConstructor?: () => LazyMutateStepAction + ): InstanceType> { + const actionForKey = actionForIdAndKey(step.id, key); + + if (actionForKey) { + actionForKey.changeValue(value); + + return actionForKey; + } else { + actionConstructor = + actionConstructor ?? (() => new LazyMutateStepAction(stepStore, step.id, key, step[key], value)); + + const action = actionConstructor(); + + if (name) { + action.name = name; + } + + undoRedoStore.applyLazyAction(action); + + action.onUndoRedo = () => { + stateStore.activeNodeId = step.id; + stateStore.hasChanges = true; + }; + + return action; + } + } + + function setPosition(step: Step, position: NonNullable) { + changeValueOrCreateAction(step, "position", position, "change step position"); + } + + function setAnnotation(step: Step, annotation: Step["annotation"]) { + changeValueOrCreateAction(step, "annotation", annotation, "modify step annotation"); + } + + function setOutputLabel( + step: Step, + workflowOutputs: Step["workflow_outputs"], + fromLabel: string | null, + toLabel: string | null + ) { + const actionConstructor = () => + new LazySetOutputLabelAction(stepStore, stateStore, step.id, fromLabel, toLabel, workflowOutputs); + + changeValueOrCreateAction( + step, + "workflow_outputs", + workflowOutputs, + "modify step output label", + actionConstructor + ); + } + + function setLabel(step: Step, label: Step["label"]) { + const actionConstructor = () => new LazySetLabelAction(stepStore, stateStore, step.id, step.label, label); + changeValueOrCreateAction(step, "label", label, "modify step label", actionConstructor); + } + + const { refresh } = useRefreshFromStore(); + + function setData(from: Step, to: Step) { + const action = new SetDataAction(stepStore, stateStore, from, to); + + if (!action.isEmpty()) { + action.onUndoRedo = () => { + stateStore.activeNodeId = from.id; + stateStore.hasChanges = true; + refresh(); + }; + undoRedoStore.applyAction(action); + } + } + + function removeStep(step: Step, showAttributesCallback: () => void) { + const action = new RemoveStepAction(stepStore, stateStore, connectionStore, showAttributesCallback, step); + undoRedoStore.applyAction(action); + } + + function updateStep(id: number, toPartial: Partial) { + const fromStep = stepStore.getStep(id); + assertDefined(fromStep); + const fromPartial: Partial = {}; + + Object.keys(toPartial).forEach((key) => { + fromPartial[key as keyof Step] = structuredClone(fromStep[key as keyof Step]) as any; + }); + + const action = new UpdateStepAction(stepStore, stateStore, id, fromPartial, toPartial); + + if (!action.isEmpty()) { + action.onUndoRedo = () => { + stateStore.activeNodeId = id; + stateStore.hasChanges = true; + refresh(); + }; + undoRedoStore.applyAction(action); + } + } + + function copyStep(step: Step) { + const action = new CopyStepAction(stepStore, stateStore, step); + undoRedoStore.applyAction(action); + } + + return { + setPosition, + setAnnotation, + setLabel, + setData, + setOutputLabel, + removeStep, + updateStep, + copyStep, + }; +} diff --git a/client/src/components/Workflow/Editor/Actions/workflowActions.ts b/client/src/components/Workflow/Editor/Actions/workflowActions.ts new file mode 100644 index 000000000000..79b1a69a013c --- /dev/null +++ b/client/src/components/Workflow/Editor/Actions/workflowActions.ts @@ -0,0 +1,125 @@ +import { LazyUndoRedoAction, UndoRedoAction, UndoRedoStore } from "@/stores/undoRedoStore"; +import { useWorkflowCommentStore } from "@/stores/workflowEditorCommentStore"; +import { useWorkflowStepStore } from "@/stores/workflowStepStore"; + +import { defaultPosition } from "../composables/useDefaultStepPosition"; +import { fromSimple, Workflow } from "../modules/model"; + +export class LazySetValueAction extends LazyUndoRedoAction { + setValueHandler; + showAttributesCallback; + fromValue; + toValue; + + constructor(fromValue: T, toValue: T, setValueHandler: (value: T) => void, showCanvasCallback: () => void) { + super(); + this.fromValue = structuredClone(fromValue); + this.toValue = structuredClone(toValue); + this.setValueHandler = setValueHandler; + this.showAttributesCallback = showCanvasCallback; + } + + queued() { + this.setValueHandler(this.toValue); + } + + changeValue(value: T) { + this.toValue = structuredClone(value); + this.setValueHandler(this.toValue); + } + + undo() { + this.showAttributesCallback(); + this.setValueHandler(this.fromValue); + } + + redo() { + this.showAttributesCallback(); + this.setValueHandler(this.toValue); + } + + get name() { + return this.internalName ?? "modify workflow"; + } + + set name(name: string | undefined) { + this.internalName = name; + } +} + +export class SetValueActionHandler { + undoRedoStore; + setValueHandler; + showAttributesCallback; + lazyAction: LazySetValueAction | null = null; + name?: string; + + constructor( + undoRedoStore: UndoRedoStore, + setValueHandler: (value: T) => void, + showCanvasCallback: () => void, + name?: string + ) { + this.undoRedoStore = undoRedoStore; + this.setValueHandler = setValueHandler; + this.showAttributesCallback = showCanvasCallback; + this.name = name; + } + + set(from: T, to: T) { + if (this.lazyAction && this.undoRedoStore.isQueued(this.lazyAction)) { + this.lazyAction.changeValue(to); + } else { + this.lazyAction = new LazySetValueAction(from, to, this.setValueHandler, this.showAttributesCallback); + this.lazyAction.name = this.name; + this.undoRedoStore.applyLazyAction(this.lazyAction); + } + } +} + +export class CopyIntoWorkflowAction extends UndoRedoAction { + workflowId; + data; + newCommentIds: number[] = []; + newStepIds: number[] = []; + position; + stepStore; + commentStore; + + constructor(workflowId: string, data: Workflow, position: ReturnType) { + super(); + + this.workflowId = workflowId; + this.data = structuredClone(data); + this.position = structuredClone(position); + + this.stepStore = useWorkflowStepStore(this.workflowId); + this.commentStore = useWorkflowCommentStore(this.workflowId); + } + + get name() { + return `Copy ${this.data.name} into workflow`; + } + + run() { + const commentIdsBefore = new Set(this.commentStore.comments.map((comment) => comment.id)); + const stepIdsBefore = new Set(Object.values(this.stepStore.steps).map((step) => step.id)); + + fromSimple(this.workflowId, structuredClone(this.data), true, structuredClone(this.position)); + + const commentIdsAfter = this.commentStore.comments.map((comment) => comment.id); + const stepIdsAfter = Object.values(this.stepStore.steps).map((step) => step.id); + + this.newCommentIds = commentIdsAfter.filter((id) => !commentIdsBefore.has(id)); + this.newStepIds = stepIdsAfter.filter((id) => !stepIdsBefore.has(id)); + } + + redo() { + fromSimple(this.workflowId, structuredClone(this.data), true, structuredClone(this.position)); + } + + undo() { + this.newCommentIds.forEach((id) => this.commentStore.deleteComment(id)); + this.newStepIds.forEach((id) => this.stepStore.removeStep(id)); + } +} diff --git a/client/src/components/Workflow/Editor/Attributes.vue b/client/src/components/Workflow/Editor/Attributes.vue index 062635e65679..c0d7311e06ee 100644 --- a/client/src/components/Workflow/Editor/Attributes.vue +++ b/client/src/components/Workflow/Editor/Attributes.vue @@ -47,7 +47,7 @@
Tags - +
Apply tags to make it easy to search for and find items with the same tag.
@@ -117,7 +117,6 @@ export default { return { message: null, messageVariant: null, - tagsCurrent: this.tags, versionCurrent: this.version, annotationCurrent: this.annotation, nameCurrent: this.name, @@ -186,9 +185,8 @@ export default { }, methods: { onTags(tags) { - this.tagsCurrent = tags; this.onAttributes({ tags }); - this.$emit("onTags", this.tagsCurrent); + this.$emit("onTags", tags); }, onVersion() { this.$emit("onVersion", this.versionCurrent); diff --git a/client/src/components/Workflow/Editor/Comments/FrameComment.vue b/client/src/components/Workflow/Editor/Comments/FrameComment.vue index 8e316ddf8c96..9fce95958129 100644 --- a/client/src/components/Workflow/Editor/Comments/FrameComment.vue +++ b/client/src/components/Workflow/Editor/Comments/FrameComment.vue @@ -13,6 +13,7 @@ import { useWorkflowStores } from "@/composables/workflowStores"; import type { FrameWorkflowComment, WorkflowComment, WorkflowCommentColor } from "@/stores/workflowEditorCommentStore"; import type { Step } from "@/stores/workflowStepStore"; +import { LazyMoveMultipleAction } from "../Actions/commentActions"; import { brighterColors, darkenedColors } from "./colors"; import { useResizable } from "./useResizable"; import { selectAllText } from "./utilities"; @@ -66,7 +67,11 @@ function getInnerText() { } function saveText() { - emit("change", { ...props.comment.data, title: getInnerText() }); + const text = getInnerText(); + + if (text !== props.comment.data.title) { + emit("change", { ...props.comment.data, title: text }); + } } const showColorSelector = ref(false); @@ -91,7 +96,7 @@ function onSetColor(color: WorkflowCommentColor) { emit("set-color", color); } -const { stateStore, stepStore, commentStore } = useWorkflowStores(); +const { stateStore, stepStore, commentStore, undoRedoStore } = useWorkflowStores(); function getStepsInBounds(bounds: AxisAlignedBoundingBox) { const steps: StepWithPosition[] = []; @@ -139,8 +144,8 @@ type StepWithPosition = Step & { position: NonNullable }; let stepsInBounds: StepWithPosition[] = []; let commentsInBounds: WorkflowComment[] = []; -const stepStartOffsets = new Map(); -const commentStartOffsets = new Map(); + +let lazyAction: LazyMoveMultipleAction | null = null; function getAABB() { const aabb = new AxisAlignedBoundingBox(); @@ -157,39 +162,25 @@ function onDragStart() { stepsInBounds = getStepsInBounds(aabb); commentsInBounds = getCommentsInBounds(aabb); - stepsInBounds.forEach((step) => { - stepStartOffsets.set(step.id, [step.position.left - aabb.x, step.position.top - aabb.y]); - }); + commentsInBounds.push(props.comment); - commentsInBounds.forEach((comment) => { - commentStartOffsets.set(comment.id, [comment.position[0] - aabb.x, comment.position[1] - aabb.y]); - }); + lazyAction = new LazyMoveMultipleAction(commentStore, stepStore, commentsInBounds, stepsInBounds, aabb); + undoRedoStore.applyLazyAction(lazyAction); } function onDragEnd() { saveText(); stepsInBounds = []; commentsInBounds = []; - stepStartOffsets.clear(); - commentStartOffsets.clear(); + undoRedoStore.flushLazyAction(); } function onMove(position: { x: number; y: number }) { - stepsInBounds.forEach((step) => { - const stepPosition = { left: 0, top: 0 }; - const offset = stepStartOffsets.get(step.id) ?? [0, 0]; - stepPosition.left = position.x + offset[0]; - stepPosition.top = position.y + offset[1]; - stepStore.updateStep({ ...step, position: stepPosition }); - }); - - commentsInBounds.forEach((comment) => { - const offset = commentStartOffsets.get(comment.id) ?? [0, 0]; - const commentPosition = [position.x + offset[0], position.y + offset[1]] as [number, number]; - commentStore.changePosition(comment.id, commentPosition); - }); - - emit("move", [position.x, position.y]); + if (lazyAction && undoRedoStore.isQueued(lazyAction)) { + lazyAction.changePosition(position); + } else { + onDragStart(); + } } function onDoubleClick() { @@ -273,7 +264,6 @@ onMounted(() => { class="draggable-pan" @move="onMove" @mouseup="onDragEnd" - @start="onDragStart" @pan-by="(p) => emit('pan-by', p)" />
diff --git a/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue b/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue index b052dbdc508c..ac4841b3c293 100644 --- a/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue +++ b/client/src/components/Workflow/Editor/Comments/MarkdownComment.vue @@ -107,7 +107,7 @@ function onSetColor(color: WorkflowCommentColor) { function onTextChange() { const element = markdownTextarea.value; - if (element) { + if (element && element.value !== props.comment.data.text) { emit("change", { text: element.value }); } } diff --git a/client/src/components/Workflow/Editor/Comments/TextComment.vue b/client/src/components/Workflow/Editor/Comments/TextComment.vue index d3ede0bcfe3c..8139921c69c4 100644 --- a/client/src/components/Workflow/Editor/Comments/TextComment.vue +++ b/client/src/components/Workflow/Editor/Comments/TextComment.vue @@ -65,7 +65,11 @@ function getInnerText() { } function saveText() { - emit("change", { ...props.comment.data, text: getInnerText() }); + const text = getInnerText(); + + if (text !== props.comment.data.text) { + emit("change", { ...props.comment.data, text }); + } } function toggleBold() { diff --git a/client/src/components/Workflow/Editor/Comments/WorkflowComment.test.ts b/client/src/components/Workflow/Editor/Comments/WorkflowComment.test.ts index 20d9cf9d01f0..a472b331b802 100644 --- a/client/src/components/Workflow/Editor/Comments/WorkflowComment.test.ts +++ b/client/src/components/Workflow/Editor/Comments/WorkflowComment.test.ts @@ -1,6 +1,11 @@ +import { createTestingPinia } from "@pinia/testing"; import { mount, shallowMount } from "@vue/test-utils"; +import { setActivePinia } from "pinia"; import { nextTick } from "vue"; +import type { LazyUndoRedoAction, UndoRedoAction } from "@/stores/undoRedoStore"; +import type { TextWorkflowComment } from "@/stores/workflowEditorCommentStore"; + import MarkdownComment from "./MarkdownComment.vue"; import TextComment from "./TextComment.vue"; import WorkflowComment from "./WorkflowComment.vue"; @@ -11,6 +16,9 @@ const changePosition = jest.fn(); const changeColor = jest.fn(); const deleteComment = jest.fn(); +const pinia = createTestingPinia(); +setActivePinia(pinia); + jest.mock("@/composables/workflowStores", () => ({ useWorkflowStores: () => ({ commentStore: { @@ -21,6 +29,13 @@ jest.mock("@/composables/workflowStores", () => ({ deleteComment, isJustCreated: () => false, }, + undoRedoStore: { + applyAction: (action: UndoRedoAction) => action.run(), + applyLazyAction: (action: LazyUndoRedoAction) => { + action.queued(); + action.run(); + }, + }, }), })); @@ -106,9 +121,11 @@ describe("WorkflowComment", () => { }); it("forwards events to the comment store", () => { + const testComment = { ...comment, id: 123, data: { size: 1, text: "HelloWorld" } } as TextWorkflowComment; + const wrapper = mount(WorkflowComment as any, { propsData: { - comment: { ...comment, id: 123, data: { size: 1, text: "HelloWorld" } }, + comment: testComment, scale: 1, rootOffset: {}, }, diff --git a/client/src/components/Workflow/Editor/Comments/WorkflowComment.vue b/client/src/components/Workflow/Editor/Comments/WorkflowComment.vue index 1135fee4ce18..1427b3f085de 100644 --- a/client/src/components/Workflow/Editor/Comments/WorkflowComment.vue +++ b/client/src/components/Workflow/Editor/Comments/WorkflowComment.vue @@ -5,6 +5,14 @@ import { computed } from "vue"; import { useWorkflowStores } from "@/composables/workflowStores"; import type { WorkflowComment, WorkflowCommentColor } from "@/stores/workflowEditorCommentStore"; +import { + ChangeColorAction, + DeleteCommentAction, + LazyChangeDataAction, + LazyChangePositionAction, + LazyChangeSizeAction, +} from "../Actions/commentActions"; + import FrameComment from "./FrameComment.vue"; import FreehandComment from "./FreehandComment.vue"; import MarkdownComment from "./MarkdownComment.vue"; @@ -28,18 +36,34 @@ const cssVariables = computed(() => ({ "--height": `${props.comment.size[1]}px`, })); -const { commentStore } = useWorkflowStores(); +const { commentStore, undoRedoStore } = useWorkflowStores(); +let lazyAction: LazyChangeDataAction | LazyChangePositionAction | LazyChangeSizeAction | null = null; function onUpdateData(data: any) { - commentStore.changeData(props.comment.id, data); + if (lazyAction instanceof LazyChangeDataAction && undoRedoStore.isQueued(lazyAction)) { + lazyAction.updateData(data); + } else { + lazyAction = new LazyChangeDataAction(commentStore, props.comment, data); + undoRedoStore.applyLazyAction(lazyAction); + } } function onResize(size: [number, number]) { - commentStore.changeSize(props.comment.id, size); + if (lazyAction instanceof LazyChangeSizeAction && undoRedoStore.isQueued(lazyAction)) { + lazyAction.updateData(size); + } else { + lazyAction = new LazyChangeSizeAction(commentStore, props.comment, size); + undoRedoStore.applyLazyAction(lazyAction); + } } function onMove(position: [number, number]) { - commentStore.changePosition(props.comment.id, position); + if (lazyAction instanceof LazyChangePositionAction && undoRedoStore.isQueued(lazyAction)) { + lazyAction.updateData(position); + } else { + lazyAction = new LazyChangePositionAction(commentStore, props.comment, position); + undoRedoStore.applyLazyAction(lazyAction); + } } function onPan(position: { x: number; y: number }) { @@ -47,11 +71,11 @@ function onPan(position: { x: number; y: number }) { } function onRemove() { - commentStore.deleteComment(props.comment.id); + undoRedoStore.applyAction(new DeleteCommentAction(commentStore, props.comment)); } function onSetColor(color: WorkflowCommentColor) { - commentStore.changeColor(props.comment.id, color); + undoRedoStore.applyAction(new ChangeColorAction(commentStore, props.comment, color)); } diff --git a/client/src/components/Workflow/Editor/ConnectionMenu.vue b/client/src/components/Workflow/Editor/ConnectionMenu.vue index a8bf0b00e832..e97cc24a4ad3 100644 --- a/client/src/components/Workflow/Editor/ConnectionMenu.vue +++ b/client/src/components/Workflow/Editor/ConnectionMenu.vue @@ -62,7 +62,8 @@ watch(focused, (focused) => { } }); -const { connectionStore, stepStore } = useWorkflowStores(); +const stores = useWorkflowStores(); +const { stepStore } = stores; interface InputObject { stepId: number; @@ -97,7 +98,7 @@ function inputObjectToTerminal(inputObject: InputObject): InputTerminals { const step = stepStore.getStep(inputObject.stepId); assertDefined(step); const inputSource = step.inputs.find((input) => input.name == inputObject.inputName)!; - return terminalFactory(inputObject.stepId, inputSource, props.terminal.datatypesMapper, connectionStore, stepStore); + return terminalFactory(inputObject.stepId, inputSource, props.terminal.datatypesMapper, stores); } const validInputs: ComputedRef = computed(() => { diff --git a/client/src/components/Workflow/Editor/Forms/FormConditional.vue b/client/src/components/Workflow/Editor/Forms/FormConditional.vue index 319c4d919e42..fbdbc5525672 100644 --- a/client/src/components/Workflow/Editor/Forms/FormConditional.vue +++ b/client/src/components/Workflow/Editor/Forms/FormConditional.vue @@ -6,7 +6,7 @@ import type { Step } from "@/stores/workflowStepStore"; import FormElement from "@/components/Form/FormElement.vue"; const emit = defineEmits<{ - (e: "onUpdateStep", value: Step): void; + (e: "onUpdateStep", id: number, value: Partial): void; }>(); const props = defineProps<{ step: Step; @@ -18,15 +18,14 @@ const conditionalDefined = computed(() => { function onSkipBoolean(value: boolean) { if (props.step.when && value === false) { - emit("onUpdateStep", { ...props.step, when: undefined }); + emit("onUpdateStep", props.step.id, { when: undefined }); } else if (value === true && !props.step.when) { const when = "$(inputs.when)"; const newStep = { - ...props.step, when, input_connections: { ...props.step.input_connections, when: undefined }, }; - emit("onUpdateStep", newStep); + emit("onUpdateStep", props.step.id, newStep); } } diff --git a/client/src/components/Workflow/Editor/Forms/FormDefault.vue b/client/src/components/Workflow/Editor/Forms/FormDefault.vue index 72e786d3b6d3..8ac56fd941da 100644 --- a/client/src/components/Workflow/Editor/Forms/FormDefault.vue +++ b/client/src/components/Workflow/Editor/Forms/FormDefault.vue @@ -39,10 +39,14 @@ :area="true" help="Add an annotation or notes to this step. Annotations are available when a workflow is viewed." @input="onAnnotation" /> - +
@@ -51,19 +55,20 @@ :key="index" :name="output.name" :step="step" - :show-details="true" - @onOutputLabel="onOutputLabel" /> + :show-details="true" />
diff --git a/client/src/components/Workflow/Editor/Forms/FormOutput.vue b/client/src/components/Workflow/Editor/Forms/FormOutput.vue index 554b218df6ef..a0e41c37eb40 100644 --- a/client/src/components/Workflow/Editor/Forms/FormOutput.vue +++ b/client/src/components/Workflow/Editor/Forms/FormOutput.vue @@ -1,7 +1,7 @@ @@ -54,10 +55,12 @@ -