diff --git a/client/src/components/Panels/WorkflowBox.vue b/client/src/components/Panels/WorkflowBox.vue index e36a3b018227..bef5d682603d 100644 --- a/client/src/components/Panels/WorkflowBox.vue +++ b/client/src/components/Panels/WorkflowBox.vue @@ -47,7 +47,7 @@ function userTitle(title: string) { variant="link" :title="userTitle('Create new workflow')" :disabled="isAnonymous" - @click="$router.push('/workflows/create')"> + @click="$router.push('/workflows/edit')"> { name: TEST_NAME, tags: ["workflow_tag_0", "workflow_tag_1"], parameters: untypedParameters, + version: 0, versions: TEST_VERSIONS, annotation: TEST_ANNOTATION, }, diff --git a/client/src/components/Workflow/Editor/Attributes.vue b/client/src/components/Workflow/Editor/Attributes.vue index 56e4fb3c9ab3..2247b4dcad41 100644 --- a/client/src/components/Workflow/Editor/Attributes.vue +++ b/client/src/components/Workflow/Editor/Attributes.vue @@ -6,9 +6,13 @@
Name - +
-
+
Version @@ -184,7 +188,7 @@ export default { onTags(tags) { this.tagsCurrent = tags; this.onAttributes({ tags }); - this.$emit("input", this.tagsCurrent); + this.$emit("onTags", this.tagsCurrent); }, onVersion() { this.$emit("onVersion", this.versionCurrent); @@ -200,9 +204,11 @@ export default { this.messageVariant = "danger"; }, onAttributes(data) { - this.services.updateWorkflow(this.id, data).catch((error) => { - this.onError(error); - }); + if (!this.id.includes("workflow-editor")) { + this.services.updateWorkflow(this.id, data).catch((error) => { + this.onError(error); + }); + } }, }, }; diff --git a/client/src/components/Workflow/Editor/Index.test.ts b/client/src/components/Workflow/Editor/Index.test.ts index 135ddf7b914a..62e9673f3673 100644 --- a/client/src/components/Workflow/Editor/Index.test.ts +++ b/client/src/components/Workflow/Editor/Index.test.ts @@ -47,9 +47,9 @@ describe("Index", () => { }); wrapper = shallowMount(Index, { propsData: { - id: "workflow_id", + workflowId: "workflow_id", initialVersion: 1, - tags: ["moo", "cow"], + workflowTags: ["moo", "cow"], moduleSections: [], dataManagers: [], workflows: [], diff --git a/client/src/components/Workflow/Editor/Index.vue b/client/src/components/Workflow/Editor/Index.vue index e2c6460a79ef..871521c935f8 100644 --- a/client/src/components/Workflow/Editor/Index.vue +++ b/client/src/components/Workflow/Editor/Index.vue @@ -18,7 +18,7 @@ title="Save As a New Workflow" ok-title="Save" cancel-title="Cancel" - @ok="doSaveAs"> + @ok="doSaveAs(false)"> @@ -41,7 +41,8 @@
Workflow Editor - {{ name }} + {{ name }} + Create New Workflow
-
+
import axios from "axios"; import { storeToRefs } from "pinia"; -import Vue, { computed, onUnmounted, ref } from "vue"; +import Vue, { computed, onUnmounted, ref, unref } from "vue"; import { getUntypedWorkflowParameters } from "@/components/Workflow/Editor/modules/parameters"; import { ConfirmDialog } from "@/composables/confirmDialog"; import { useDatatypesMapper } from "@/composables/datatypesMapper"; +import { useUid } from "@/composables/utils/uid"; import { provideScopedWorkflowStores } from "@/composables/workflowStores"; import { hide_modal } from "@/layout/modal"; import { getAppRoot } from "@/onload/loadConfig"; +import { useScopePointerStore } from "@/stores/scopePointerStore"; import { LastQueue } from "@/utils/promise-queue"; import { defaultPosition } from "./composables/useDefaultStepPosition"; @@ -207,17 +213,17 @@ export default { WorkflowGraph, }, props: { - id: { + workflowId: { type: String, - required: true, + default: undefined, }, initialVersion: { type: Number, - required: true, + default: undefined, }, - tags: { + workflowTags: { type: Array, - required: true, + default: () => [], }, moduleSections: { type: Array, @@ -235,7 +241,10 @@ export default { setup(props, { emit }) { const { datatypes, datatypesMapper, datatypesMapperLoading } = useDatatypesMapper(); - const { connectionStore, stepStore, stateStore, commentStore } = provideScopedWorkflowStores(props.id); + const uid = unref(useUid("workflow-editor-")); + const id = ref(props.workflowId || uid); + + const { connectionStore, stepStore, stateStore, commentStore } = provideScopedWorkflowStores(id); const { comments } = storeToRefs(commentStore); const { getStepIndex, steps } = storeToRefs(stepStore); @@ -248,14 +257,19 @@ export default { }); const hasChanges = ref(false); + const initialLoading = ref(true); const hasInvalidConnections = computed(() => Object.keys(connectionStore.invalidConnections).length > 0); stepStore.$subscribe((_mutation, _state) => { - hasChanges.value = true; + if (!initialLoading.value) { + hasChanges.value = true; + } }); commentStore.$subscribe((_mutation, _state) => { - hasChanges.value = true; + if (!initialLoading.value) { + hasChanges.value = true; + } }); function resetStores() { @@ -271,6 +285,7 @@ export default { }); return { + id, connectionStore, hasChanges, hasInvalidConnections, @@ -285,6 +300,7 @@ export default { datatypesMapperLoading, stateStore, resetStores, + initialLoading, }; }, data() { @@ -300,6 +316,7 @@ export default { creator: null, annotation: null, name: null, + tags: this.workflowTags, stateMessages: [], insertedStateMessages: [], refactorActions: [], @@ -333,6 +350,9 @@ export default { hasActiveNodeTool() { return this.activeStep?.type == "tool"; }, + isNewTempWorkflow() { + return !this.workflowId; + }, }, watch: { id(newId, oldId) { @@ -341,23 +361,29 @@ export default { } }, annotation(newAnnotation, oldAnnotation) { - if (newAnnotation != oldAnnotation) { + if (newAnnotation != oldAnnotation && !this.isNewTempWorkflow) { this.hasChanges = true; } }, name(newName, oldName) { - if (newName != oldName) { + if (newName != oldName && !this.isNewTempWorkflow) { this.hasChanges = true; } }, hasChanges() { this.$emit("update:confirmation", this.hasChanges); }, + initialVersion(newVal, oldVal) { + if (newVal != oldVal && oldVal === undefined) { + this.version = this.initialVersion; + } + }, }, - created() { + async created() { this.lastQueue = new LastQueue(); - this._loadCurrent(this.id, this.version); + await this._loadCurrent(this.id, this.version); hide_modal(); + this.initialLoading = false; }, methods: { onUpdateStep(step) { @@ -505,9 +531,9 @@ export default { onDownload() { window.location = `${getAppRoot()}api/workflows/${this.id}/download?format=json-download`; }, - doSaveAs() { - const rename_name = this.saveAsName ?? `SavedAs_${this.name}`; - const rename_annotation = this.saveAsAnnotation ?? ""; + async doSaveAs(create = false) { + const rename_name = create ? this.name : this.saveAsName ?? `SavedAs_${this.name}`; + const rename_annotation = create ? this.annotation || "" : this.saveAsAnnotation ?? ""; // This is an old web controller endpoint that wants form data posted... const formData = new FormData(); @@ -516,16 +542,20 @@ export default { formData.append("from_tool_form", true); formData.append("workflow_data", JSON.stringify(toSimple(this.id, this))); - axios - .post(`${getAppRoot()}workflow/save_workflow_as`, formData) - .then((response) => { - this.onWorkflowMessage("Workflow saved as", "success"); - this.hideModal(); - this.onNavigate(`${getAppRoot()}workflows/edit?id=${response.data}`, true); - }) - .catch((response) => { - this.onWorkflowError("Saving workflow failed, please contact an administrator."); - }); + try { + const response = await axios.post(`${getAppRoot()}workflow/save_workflow_as`, formData); + const newId = response.data; + + if (!create) { + this.name = rename_name; + this.annotation = rename_annotation; + } + + this.hasChanges = false; + await this.routeToWorkflow(newId); + } catch (e) { + this.onWorkflowError("Saving workflow failed, please contact an administrator."); + } }, onSaveAs() { this.showSaveAsModal = true; @@ -550,6 +580,53 @@ export default { const step = { ...this.steps[nodeId], annotation: newAnnotation }; this.onUpdateStep(step); }, + async routeToWorkflow(id) { + // map scoped stores to existing stores, before updating the id + const { addScopePointer } = useScopePointerStore(); + addScopePointer(id, this.id); + + this.id = id; + await this.onSave(); + this.hasChanges = false; + this.$router.replace({ query: { id } }); + }, + async onCreate() { + if (!this.name) { + const response = "Please provide a name for your workflow."; + this.onWorkflowError("Creating workflow failed", response, { + Ok: () => { + this.hideModal(); + }, + }); + this.onAttributes(); + return; + } + try { + // if nothing other than payload vars changed, just use `create` endpoint + if (!this.hasChanges) { + const payload = { + workflow_name: this.name, + workflow_annotation: this.annotation || "", + workflow_tags: this.tags, + }; + const { data } = await axios.put(`${getAppRoot()}workflow/create`, payload); + const { id } = data; + + await this.routeToWorkflow(id); + } else { + // otherwise, use `save_as` endpoint to include steps, etc. + await this.doSaveAs(true); + } + } catch (e) { + this.onWorkflowError("Creating workflow failed"), + e || "Please contact an administrator.", + { + Ok: () => { + this.hideModal(); + }, + }; + } + }, onSetData(stepId, newData) { this.lastQueue .enqueue(() => getModule(newData, stepId, this.stateStore.setLoadingState)) @@ -695,18 +772,24 @@ export default { await Vue.nextTick(); this.hasChanges = has_changes; }, - _loadCurrent(id, version) { - this.resetStores(); - this.onWorkflowMessage("Loading workflow...", "progress"); - this.lastQueue - .enqueue(loadWorkflow, { id, version }) - .then((data) => { - fromSimple(id, data); - this._loadEditorData(data); - }) - .catch((response) => { - this.onWorkflowError("Loading workflow failed...", response); - }); + async _loadCurrent(id, version) { + if (!this.isNewTempWorkflow) { + this.resetStores(); + this.onWorkflowMessage("Loading workflow...", "progress"); + + try { + const data = await this.lastQueue.enqueue(loadWorkflow, { id, version }); + await fromSimple(id, data); + await this._loadEditorData(data); + } catch (e) { + this.onWorkflowError("Loading workflow failed...", e); + } + } + }, + onTags(tags) { + if (this.tags != tags) { + this.tags = tags; + } }, onLicense(license) { if (this.license != license) { diff --git a/client/src/components/Workflow/Editor/Options.vue b/client/src/components/Workflow/Editor/Options.vue index 654292b65056..1c3f3110e44f 100644 --- a/client/src/components/Workflow/Editor/Options.vue +++ b/client/src/components/Workflow/Editor/Options.vue @@ -7,6 +7,7 @@ import { useConfirmDialog } from "@/composables/confirmDialog"; const emit = defineEmits<{ (e: "onAttributes"): void; (e: "onSave"): void; + (e: "onCreate"): void; (e: "onReport"): void; (e: "onSaveAs"): void; (e: "onLayout"): void; @@ -17,6 +18,7 @@ const emit = defineEmits<{ }>(); const props = defineProps<{ + isNewTempWorkflow?: boolean; hasChanges?: boolean; hasInvalidConnections?: boolean; requiredReindex?: boolean; @@ -25,7 +27,9 @@ const props = defineProps<{ const { confirm } = useConfirmDialog(); const saveHover = computed(() => { - if (!props.hasChanges) { + if (props.isNewTempWorkflow) { + return "Create a new workflow"; + } else if (!props.hasChanges) { return "Workflow has no changes"; } else if (props.hasInvalidConnections) { return "Workflow has invalid connections, review and remove invalid connections"; @@ -34,6 +38,14 @@ const saveHover = computed(() => { } }); +function emitSaveOrCreate() { + if (props.isNewTempWorkflow) { + emit("onCreate"); + } else { + emit("onSave"); + } +} + async function onSave() { if (props.hasInvalidConnections) { console.log("getting confirmation"); @@ -45,10 +57,10 @@ async function onSave() { } ); if (confirmed) { - emit("onSave"); + emitSaveOrCreate(); } } else { - emit("onSave"); + emitSaveOrCreate(); } } @@ -56,7 +68,7 @@ async function onSave() {
+ class="editor-button-options" + :disabled="isNewTempWorkflow"> @@ -113,12 +127,13 @@ async function onSave() { diff --git a/client/src/components/Workflow/Editor/modules/model.ts b/client/src/components/Workflow/Editor/modules/model.ts index 4f43288fe0b8..e8437f439032 100644 --- a/client/src/components/Workflow/Editor/modules/model.ts +++ b/client/src/components/Workflow/Editor/modules/model.ts @@ -10,6 +10,7 @@ interface Workflow { report: any; steps: Steps; comments: WorkflowComment[]; + tags: string[]; } /** @@ -80,6 +81,7 @@ export function toSimple(id: string, workflow: Workflow): Omit !(comment.type === "text" && comment.data.text === "")); - return { steps, report, license, creator, annotation, name, comments }; + return { steps, report, license, creator, annotation, name, comments, tags }; } diff --git a/client/src/components/Workflow/Editor/modules/services.js b/client/src/components/Workflow/Editor/modules/services.js index 65905c5d7849..8534cc3b18a7 100644 --- a/client/src/components/Workflow/Editor/modules/services.js +++ b/client/src/components/Workflow/Editor/modules/services.js @@ -60,7 +60,9 @@ export async function saveWorkflow(workflow) { workflow.hasChanges = false; workflow.stored = true; workflow.version = data.version; - workflow.annotation = data.annotation; + if (workflow.annotation || data.annotation) { + workflow.annotation = data.annotation; + } return data; } catch (e) { rethrowSimple(e); diff --git a/client/src/components/Workflow/InvocationsList.test.js b/client/src/components/Workflow/InvocationsList.test.js index 13885868164f..d2f230303a08 100644 --- a/client/src/components/Workflow/InvocationsList.test.js +++ b/client/src/components/Workflow/InvocationsList.test.js @@ -6,16 +6,20 @@ import axios from "axios"; import MockAdapter from "axios-mock-adapter"; import { formatDistanceToNow, parseISO } from "date-fns"; import { getLocalVue } from "tests/jest/helpers"; +import VueRouter from "vue-router"; import InvocationsList from "./InvocationsList"; import mockInvocationData from "./test/json/invocation.json"; const localVue = getLocalVue(); +localVue.use(VueRouter); +const router = new VueRouter(); const pinia = createTestingPinia(); describe("InvocationsList.vue", () => { let axiosMock; let wrapper; + let $router; beforeEach(async () => { axiosMock = new MockAdapter(axios); @@ -158,7 +162,9 @@ describe("InvocationsList.vue", () => { }, localVue, pinia, + router, }); + $router = wrapper.vm.$router; }); it("renders one row", async () => { @@ -194,8 +200,10 @@ describe("InvocationsList.vue", () => { }); it("calls executeWorkflow", async () => { + const mockMethod = jest.fn(); + $router.push = mockMethod; await wrapper.find(".workflow-run").trigger("click"); - expect(window.location).toBeAt("workflows/run?id=workflowId"); + expect(mockMethod).toHaveBeenCalledWith("/workflows/run?id=workflowId"); }); it("should not render pager", async () => { diff --git a/client/src/components/Workflow/WorkflowIndexActions.test.js b/client/src/components/Workflow/WorkflowIndexActions.test.js index 9a9999b9885e..9704a168c3db 100644 --- a/client/src/components/Workflow/WorkflowIndexActions.test.js +++ b/client/src/components/Workflow/WorkflowIndexActions.test.js @@ -35,7 +35,7 @@ describe("WorkflowIndexActions", () => { describe("naviation", () => { it("should create a workflow when create is clicked", async () => { await wrapper.find(ROOT_COMPONENT.workflows.new_button.selector).trigger("click"); - expect(getCurrentPath($router)).toBe("/workflows/create"); + expect(getCurrentPath($router)).toBe("/workflows/edit"); }); it("should import a workflow when create is clicked", async () => { diff --git a/client/src/components/Workflow/WorkflowIndexActions.vue b/client/src/components/Workflow/WorkflowIndexActions.vue index 7e549cdec6a2..c067b483dcf7 100644 --- a/client/src/components/Workflow/WorkflowIndexActions.vue +++ b/client/src/components/Workflow/WorkflowIndexActions.vue @@ -54,7 +54,7 @@ export default { }, methods: { navigateToCreate: function () { - this.$router.push("/workflows/create"); + this.$router.push("/workflows/edit"); }, navigateToImport: function () { this.$router.push("/workflows/import"); diff --git a/client/src/components/Workflow/WorkflowRunButton.vue b/client/src/components/Workflow/WorkflowRunButton.vue index 08d0c81ad492..26e40e8fe949 100644 --- a/client/src/components/Workflow/WorkflowRunButton.vue +++ b/client/src/components/Workflow/WorkflowRunButton.vue @@ -34,7 +34,7 @@ export default { }, methods: { executeWorkflow() { - window.location.assign(`${this.root}workflows/run?id=${this.id}`); + this.$router.push(`/workflows/run?id=${this.id}`); }, }, }; diff --git a/client/src/composables/workflowStores.ts b/client/src/composables/workflowStores.ts index 25d8e1c3f5c6..996d37a2add2 100644 --- a/client/src/composables/workflowStores.ts +++ b/client/src/composables/workflowStores.ts @@ -1,4 +1,4 @@ -import { inject, onScopeDispose, provide } from "vue"; +import { inject, onScopeDispose, provide, type Ref, ref, unref } from "vue"; import { useConnectionStore } from "@/stores/workflowConnectionStore"; import { useWorkflowCommentStore } from "@/stores/workflowEditorCommentStore"; @@ -15,14 +15,18 @@ import { useWorkflowStepStore } from "@/stores/workflowStepStore"; * @param workflowId the workflow to scope to * @returns workflow Stores */ -export function provideScopedWorkflowStores(workflowId: string) { +export function provideScopedWorkflowStores(workflowId: Ref | string) { + if (typeof workflowId === "string") { + workflowId = ref(workflowId); + } + provide("workflowId", workflowId); - const connectionStore = useConnectionStore(workflowId); - const stateStore = useWorkflowStateStore(workflowId); - const stepStore = useWorkflowStepStore(workflowId); - const commentStore = useWorkflowCommentStore(workflowId); - const toolbarStore = useWorkflowEditorToolbarStore(workflowId); + const connectionStore = useConnectionStore(workflowId.value); + const stateStore = useWorkflowStateStore(workflowId.value); + const stepStore = useWorkflowStepStore(workflowId.value); + const commentStore = useWorkflowCommentStore(workflowId.value); + const toolbarStore = useWorkflowEditorToolbarStore(workflowId.value); onScopeDispose(() => { connectionStore.$dispose(); @@ -51,19 +55,20 @@ export function provideScopedWorkflowStores(workflowId: string) { * @returns workflow stores */ export function useWorkflowStores() { - const workflowId = inject("workflowId"); + const workflowId = inject("workflowId") as Ref | string; + const id = unref(workflowId); - if (typeof workflowId !== "string") { + if (typeof id !== "string") { throw new Error( "Workflow ID not provided by parent component. Use `provideScopedWorkflowStores` on a parent component." ); } - const connectionStore = useConnectionStore(workflowId); - const stateStore = useWorkflowStateStore(workflowId); - const stepStore = useWorkflowStepStore(workflowId); - const commentStore = useWorkflowCommentStore(workflowId); - const toolbarStore = useWorkflowEditorToolbarStore(workflowId); + const connectionStore = useConnectionStore(id); + const stateStore = useWorkflowStateStore(id); + const stepStore = useWorkflowStepStore(id); + const commentStore = useWorkflowCommentStore(id); + const toolbarStore = useWorkflowEditorToolbarStore(id); return { connectionStore, diff --git a/client/src/entry/analysis/modules/WorkflowEditor.vue b/client/src/entry/analysis/modules/WorkflowEditor.vue index 15b2d47fb770..3ef6913db658 100644 --- a/client/src/entry/analysis/modules/WorkflowEditor.vue +++ b/client/src/entry/analysis/modules/WorkflowEditor.vue @@ -1,11 +1,11 @@ diff --git a/client/src/stores/scopePointerStore.ts b/client/src/stores/scopePointerStore.ts new file mode 100644 index 000000000000..4d7c2df6232d --- /dev/null +++ b/client/src/stores/scopePointerStore.ts @@ -0,0 +1,28 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; + +/** + * This store allows for pointing scoped stores to other scoped stores + */ +export const useScopePointerStore = defineStore("scopePointerStore", () => { + type FromScopeId = string; + type ToScopeId = string; + + const scopePointers = ref>({}); + + function addScopePointer(from: FromScopeId, to: ToScopeId) { + scopePointers.value[from] = scope.value(to); + } + + const scope = computed(() => (scopeId: string) => scopePointers.value[scopeId] ?? scopeId); + + function removeScopePointer(id: FromScopeId) { + scopePointers.value[id] = undefined; + } + + return { + addScopePointer, + removeScopePointer, + scope, + }; +}); diff --git a/client/src/stores/workflowConnectionStore.ts b/client/src/stores/workflowConnectionStore.ts index 686cc9b4a287..ba201dab52fe 100644 --- a/client/src/stores/workflowConnectionStore.ts +++ b/client/src/stores/workflowConnectionStore.ts @@ -4,6 +4,8 @@ import Vue from "vue"; import { useWorkflowStepStore } from "@/stores/workflowStepStore"; import { pushOrSet } from "@/utils/pushOrSet"; +import { useScopePointerStore } from "./scopePointerStore"; + interface InvalidConnections { [index: ConnectionId]: string | undefined; } @@ -43,11 +45,9 @@ interface TerminalToOutputTerminals { } export const useConnectionStore = (workflowId: string) => { - if (!workflowId) { - throw new Error("WorkflowId is undefined"); - } + const { scope } = useScopePointerStore(); - return defineStore(`workflowConnectionStore${workflowId}`, { + return defineStore(`workflowConnectionStore${scope(workflowId)}`, { state: (): State => ({ connections: [] as Array>, invalidConnections: {} as InvalidConnections, diff --git a/client/src/stores/workflowEditorCommentStore.ts b/client/src/stores/workflowEditorCommentStore.ts index 261887e66e76..f193e64d4157 100644 --- a/client/src/stores/workflowEditorCommentStore.ts +++ b/client/src/stores/workflowEditorCommentStore.ts @@ -15,6 +15,7 @@ import { import { assertDefined } from "@/utils/assertions"; import { hasKeys, match } from "@/utils/utils"; +import { useScopePointerStore } from "./scopePointerStore"; import { useWorkflowStateStore } from "./workflowEditorStateStore"; import { Step, useWorkflowStepStore } from "./workflowStepStore"; @@ -92,7 +93,9 @@ function assertCommentDataValid( export type WorkflowCommentStore = ReturnType; export const useWorkflowCommentStore = (workflowId: string) => { - return defineStore(`workflowCommentStore${workflowId}`, () => { + const { scope } = useScopePointerStore(); + + return defineStore(`workflowCommentStore${scope(workflowId)}`, () => { const commentsRecord = ref>({}); const localCommentsMetadata = ref>({}); diff --git a/client/src/stores/workflowEditorStateStore.ts b/client/src/stores/workflowEditorStateStore.ts index b7e5f1bdfbfb..6cba836b721d 100644 --- a/client/src/stores/workflowEditorStateStore.ts +++ b/client/src/stores/workflowEditorStateStore.ts @@ -5,6 +5,8 @@ import Vue, { reactive } from "vue"; import type { OutputTerminals } from "@/components/Workflow/Editor/modules/terminals"; +import { useScopePointerStore } from "./scopePointerStore"; + export interface InputTerminalPosition { endX: number; endY: number; @@ -34,7 +36,9 @@ interface State { } export const useWorkflowStateStore = (workflowId: string) => { - return defineStore(`workflowStateStore${workflowId}`, { + const { scope } = useScopePointerStore(); + + return defineStore(`workflowStateStore${scope(workflowId)}`, { state: (): State => ({ inputTerminals: {}, outputTerminals: {}, diff --git a/client/src/stores/workflowEditorToolbarStore.ts b/client/src/stores/workflowEditorToolbarStore.ts index 4743fc304683..30b3680bfd68 100644 --- a/client/src/stores/workflowEditorToolbarStore.ts +++ b/client/src/stores/workflowEditorToolbarStore.ts @@ -4,6 +4,7 @@ import { computed, onScopeDispose, reactive, ref, watch } from "vue"; import { useUserLocalStorage } from "@/composables/userLocalStorage"; +import { useScopePointerStore } from "./scopePointerStore"; import { WorkflowCommentColor } from "./workflowEditorCommentStore"; export type CommentTool = "textComment" | "markdownComment" | "frameComment" | "freehandComment" | "freehandEraser"; @@ -28,7 +29,9 @@ export interface InputCatcherEvent { export type WorkflowEditorToolbarStore = ReturnType; export const useWorkflowEditorToolbarStore = (workflowId: string) => { - return defineStore(`workflowEditorToolbarStore${workflowId}`, () => { + const { scope } = useScopePointerStore(); + + return defineStore(`workflowEditorToolbarStore${scope(workflowId)}`, () => { const snapActive = useUserLocalStorage("workflow-editor-toolbar-snap-active", false); const currentTool = ref("pointer"); const inputCatcherActive = ref(false); diff --git a/client/src/stores/workflowStepStore.ts b/client/src/stores/workflowStepStore.ts index b53a2f078623..1358e7b028da 100644 --- a/client/src/stores/workflowStepStore.ts +++ b/client/src/stores/workflowStepStore.ts @@ -5,6 +5,8 @@ import type { CollectionTypeDescriptor } from "@/components/Workflow/Editor/modu import { type Connection, getConnectionId, useConnectionStore } from "@/stores/workflowConnectionStore"; import { assertDefined } from "@/utils/assertions"; +import { useScopePointerStore } from "./scopePointerStore"; + interface State { steps: { [index: string]: Step }; stepIndex: number; @@ -145,7 +147,9 @@ interface StepInputMapOver { } export const useWorkflowStepStore = (workflowId: string) => { - return defineStore(`workflowStepStore${workflowId}`, { + const { scope } = useScopePointerStore(); + + return defineStore(`workflowStepStore${scope(workflowId)}`, { state: (): State => ({ steps: {} as Steps, stepMapOver: {} as { [index: number]: CollectionTypeDescriptor }, diff --git a/lib/galaxy/managers/workflows.py b/lib/galaxy/managers/workflows.py index 5629410946bd..3e6232671784 100644 --- a/lib/galaxy/managers/workflows.py +++ b/lib/galaxy/managers/workflows.py @@ -723,13 +723,23 @@ def update_workflow_from_raw_description( stored_workflow.latest_workflow = workflow workflow.stored_workflow = stored_workflow + data = raw_workflow_description.as_dict + if isinstance(data, str): + data = json.loads(data) + if "tags" in data: + trans.tag_handler.set_tags_from_list( + trans.user, + stored_workflow, + data.get("tags", []), + ) + if workflow_update_options.update_stored_workflow_attributes: update_dict = raw_workflow_description.as_dict if "name" in update_dict: sanitized_name = sanitize_html(update_dict["name"]) workflow.name = sanitized_name stored_workflow.name = sanitized_name - if "annotation" in update_dict: + if update_dict.get("annotation") is not None: newAnnotation = sanitize_html(update_dict["annotation"]) sa_session = None if dry_run else trans.sa_session self.add_item_annotation(sa_session, stored_workflow.user, stored_workflow, newAnnotation) diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index 5f45c2b93550..f156f348703d 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -1516,13 +1516,17 @@ def workflow_create_new(self, annotation=None, clear_placeholder=False): self.click_button_new_workflow() self.sleep_for(self.wait_types.UX_RENDER) name = self._get_random_name() - name_component = self.components.workflows.create.name + name_component = self.components.workflow_editor.edit_name if clear_placeholder: name_component.wait_for_visible().clear() name_component.wait_for_and_send_keys(name) annotation = annotation or self._get_random_name() - self.components.workflows.create.annotation.wait_for_and_send_keys(annotation) - self.components.workflows.create.submit.wait_for_and_click() + self.components.workflow_editor.edit_annotation.wait_for_and_send_keys(annotation) + save_button = self.components.workflow_editor.save_button + save_button.wait_for_visible() + assert not save_button.has_class("disabled") + save_button.wait_for_and_click() + self.sleep_for(self.wait_types.UX_RENDER) return name def invocation_index_table_elements(self): diff --git a/lib/galaxy/webapps/galaxy/controllers/workflow.py b/lib/galaxy/webapps/galaxy/controllers/workflow.py index b2dd07de100f..b401e500c541 100644 --- a/lib/galaxy/webapps/galaxy/controllers/workflow.py +++ b/lib/galaxy/webapps/galaxy/controllers/workflow.py @@ -355,6 +355,7 @@ def create(self, trans, payload=None, **kwd): user = trans.get_user() workflow_name = payload.get("workflow_name") workflow_annotation = payload.get("workflow_annotation") + workflow_tags = payload.get("workflow_tags", []) if not workflow_name: return self.message_exception(trans, "Please provide a workflow name.") # Create the new stored workflow @@ -370,6 +371,12 @@ def create(self, trans, payload=None, **kwd): # Add annotation. workflow_annotation = sanitize_html(workflow_annotation) self.add_item_annotation(trans.sa_session, trans.get_user(), stored_workflow, workflow_annotation) + # Add tags + trans.tag_handler.set_tags_from_list( + trans.user, + stored_workflow, + workflow_tags, + ) # Persist session = trans.sa_session session.add(stored_workflow) @@ -442,12 +449,16 @@ def editor(self, trans, id=None, workflow_id=None, version=None, **kwargs): an iframe (necessary for scrolling to work properly), which is rendered by `editor_canvas`. """ + + new_workflow = False if not id: if workflow_id: stored_workflow = self.app.workflow_manager.get_stored_workflow(trans, workflow_id, by_stored_id=False) self.security_check(trans, stored_workflow, True, False) id = trans.security.encode_id(stored_workflow.id) - stored = self.get_stored_workflow(trans, id) + else: + new_workflow = True + # The following query loads all user-owned workflows, # So that they can be copied or inserted in the workflow editor. workflows = ( @@ -457,10 +468,6 @@ def editor(self, trans, id=None, workflow_id=None, version=None, **kwargs): .options(joinedload(model.StoredWorkflow.latest_workflow).joinedload(model.Workflow.steps)) .all() ) - if version is None: - version = len(stored.workflows) - 1 - else: - version = int(version) # create workflow module models module_sections = [] @@ -492,6 +499,18 @@ def editor(self, trans, id=None, workflow_id=None, version=None, **kwargs): } ) + stored = None + if new_workflow is False: + stored = self.get_stored_workflow(trans, id) + + if version is None: + version = len(stored.workflows) - 1 + else: + version = int(version) + + # identify item tags + item_tags = stored.make_tag_string_list() + # create workflow models workflows = [ { @@ -501,24 +520,28 @@ def editor(self, trans, id=None, workflow_id=None, version=None, **kwargs): "name": workflow.name, } for workflow in workflows - if workflow.id != stored.id + if new_workflow or workflow.id != stored.id ] - # identify item tags - item_tags = stored.make_tag_string_list() - # build workflow editor model editor_config = { - "id": trans.security.encode_id(stored.id), - "name": stored.name, - "tags": item_tags, - "initialVersion": version, - "annotation": self.get_item_annotation_str(trans.sa_session, trans.user, stored), "moduleSections": module_sections, "dataManagers": data_managers, "workflows": workflows, } + # for existing workflow add its data to the model + if new_workflow is False: + editor_config.update( + { + "id": trans.security.encode_id(stored.id), + "name": stored.name, + "tags": item_tags, + "initialVersion": version, + "annotation": self.get_item_annotation_str(trans.sa_session, trans.user, stored), + } + ) + # parse to mako return editor_config diff --git a/test/integration/test_workflow_refactoring.py b/test/integration/test_workflow_refactoring.py index 730106e58cdc..4d9066d988ad 100644 --- a/test/integration/test_workflow_refactoring.py +++ b/test/integration/test_workflow_refactoring.py @@ -898,6 +898,7 @@ def __init__(self, app, user): self.user = user self.history = None self.workflow_building_mode = workflow_building_modes.ENABLED + self.tag_handler = app.tag_handler @property def galaxy_session(self):