Repository: kestra-io/kestra
Issue: Save flow as draft
Labels: enhancement

### Feature description

As a Kestra beginner,

I imagined I could start a flow and leave it as a draft,

But I couldn’t save something with syntax errors

As a result, I spent more time than expected fixing my flow,

So I end up being late for other activities in my day.

*What would have helped?*

→ A way to save in draft. *Maybe in a deactivated mode?*

Issue comments:

Pull request review comments:
Check if readOnly before
Sure, forgot about this part

Patch that resolved the issue:
diff --git a/ui/src/components/graph/Topology.vue b/ui/src/components/graph/Topology.vue
index 043bc084a1..4696dd861f 100644
--- a/ui/src/components/graph/Topology.vue
+++ b/ui/src/components/graph/Topology.vue
@@ -1,5 +1,5 @@
 <script setup>
-import {ref, onMounted, nextTick, watch, getCurrentInstance, onBeforeUnmount, computed} from "vue";
+    import {ref, onMounted, nextTick, watch, getCurrentInstance, onBeforeUnmount, computed} from "vue";
     import {useStore} from "vuex"
     import {VueFlow, useVueFlow, Position, MarkerType} from "@vue-flow/core"
     import {Controls, ControlButton} from "@vue-flow/controls"
@@ -128,6 +128,10 @@ import {ref, onMounted, nextTick, watch, getCurrentInstance, onBeforeUnmount, co
     const taskError = ref(store.getters["flow/taskError"])
     const user = store.getters["auth/user"];
 
+    const localStorageKey = computed(() => {
+        return (props.isCreating ? "creation" : `${flow.namespace}.${flow.id}`) + "_draft";
+    })
+
     watch(() => store.getters["flow/taskError"], async () => {
         taskError.value = store.getters["flow/taskError"];
     });
@@ -177,7 +181,19 @@ import {ref, onMounted, nextTick, watch, getCurrentInstance, onBeforeUnmount, co
             id: props.flowId,
             namespace: props.namespace
         });
-        return flowYaml.value;
+        generateGraph();
+
+        if(!props.isReadOnly) {
+            const sourceFromLocalStorage = localStorage.getItem(localStorageKey.value);
+            if (sourceFromLocalStorage !== null) {
+                toast.confirm(props.isCreating ? t("save draft.retrieval.creation") : t("save draft.retrieval.existing", {flowFullName: `${flow.namespace}.${flow.id}`}), () => {
+                    flowYaml.value = sourceFromLocalStorage;
+                    onEdit(flowYaml.value);
+                })
+
+                localStorage.removeItem(localStorageKey.value);
+            }
+        }
     }
 
     const isTaskNode = (node) => {
@@ -209,13 +225,15 @@ import {ref, onMounted, nextTick, watch, getCurrentInstance, onBeforeUnmount, co
     };
 
     const regenerateGraph = () => {
-        removeEdges(getEdges.value)
-        removeNodes(getNodes.value)
-        removeSelectedElements(getElements.value)
-        elements.value = []
-        nextTick(() => {
-            generateGraph();
-        })
+        if(!props.flowError) {
+            removeEdges(getEdges.value)
+            removeNodes(getNodes.value)
+            removeSelectedElements(getElements.value)
+            elements.value = []
+            nextTick(() => {
+                generateGraph();
+            })
+        }
     }
 
     const toggleOrientation = () => {
@@ -417,7 +435,6 @@ import {ref, onMounted, nextTick, watch, getCurrentInstance, onBeforeUnmount, co
             store.commit("flow/setFlowGraph", undefined);
         }
         initYamlSource();
-        generateGraph();
         // Regenerate graph on window resize
         observeWidth();
         // Save on ctrl+s in topology
@@ -535,30 +552,56 @@ import {ref, onMounted, nextTick, watch, getCurrentInstance, onBeforeUnmount, co
         }
     };
 
+    const showDraftPopup = (draftReason, refreshAfterSelect = false) => {
+        toast.confirm(draftReason + " " + (props.isCreating ? t("save draft.prompt.creation") : t("save draft.prompt.existing", {
+            namespace: flow.namespace,
+            id: flow.id
+        })),
+            () => {
+                localStorage.setItem(localStorageKey.value, flowYaml.value);
+                store.dispatch("core/isUnsaved", false);
+                if(refreshAfterSelect){
+                    router.go();
+                }else {
+                    router.push({
+                        name: "flows/list"
+                    })
+                }
+            },
+            () => {
+                if(refreshAfterSelect) {
+                    store.dispatch("core/isUnsaved", false);
+                    router.go();
+                }
+            }
+        )
+    }
+
     const onEdit = (event) => {
-        store.dispatch("flow/validateFlow", {flow: event})
+        return store.dispatch("flow/validateFlow", {flow: event})
             .then(value => {
-                if (value[0].constraints && !flowHaveTasks(event)) {
-                    flowYaml.value = event;
-                    haveChange.value = true;
-                } else {
+                flowYaml.value = event;
+                haveChange.value = true;
+                store.dispatch("core/isUnsaved", true);
+
+                if (!value[0].constraints) {
                     // flowYaml need to be update before
                     // loadGraphFromSource to avoid
                     // generateGraph to be triggered with the old value
-                    flowYaml.value = event;
                     store.dispatch("flow/loadGraphFromSource", {
                         flow: event, config: {
                             validateStatus: (status) => {
                                 return status === 200 || status === 422;
                             }
                         }
-                    }).then(response => {
-                        haveChange.value = true;
-                        store.dispatch("core/isUnsaved", true);
                     })
                 }
-            })
 
+                return value;
+            }).catch(e => {
+                showDraftPopup(t("save draft.prompt.http_error"), true);
+                return Promise.reject(e);
+            })
     }
 
     const onDelete = (event) => {
@@ -740,7 +783,6 @@ import {ref, onMounted, nextTick, watch, getCurrentInstance, onBeforeUnmount, co
     const editorUpdate = (event) => {
         updatedFromEditor.value = true;
         flowYaml.value = event;
-        haveChange.value = true;
 
         clearTimeout(timer.value);
         timer.value = setTimeout(() => onEdit(event), 500);
@@ -780,9 +822,18 @@ import {ref, onMounted, nextTick, watch, getCurrentInstance, onBeforeUnmount, co
             tours["guidedTour"].nextStep();
             return;
         }
-        if (props.isCreating) {
-            const flowParsed = YamlUtils.parse(flowYaml.value);
-            if (flowParsed.id && flowParsed.namespace) {
+
+        onEdit(flowYaml.value).then(validation => {
+            let flowParsed;
+            try {
+                flowParsed = YamlUtils.parse(flowYaml.value);
+            } catch (_) {}
+            if (validation[0].constraints) {
+                showDraftPopup(t("save draft.prompt.base"));
+                return;
+            }
+
+            if (props.isCreating) {
                 store.dispatch("flow/createFlow", {flow: flowYaml.value})
                     .then((response) => {
                         toast.saved(response.id);
@@ -792,22 +843,17 @@ import {ref, onMounted, nextTick, watch, getCurrentInstance, onBeforeUnmount, co
                             params: {id: flowParsed.id, namespace: flowParsed.namespace, tab: "editor"}
                         });
                     })
-                return;
-            } else {
-                store.dispatch("core/showMessage", {
-                    variant: "error",
-                    title: t("can not save"),
-                    message: t("flow must have id and namespace")
-                });
-                return;
+            }else {
+                store
+                    .dispatch("flow/saveFlow", {flow: flowYaml.value})
+                    .then((response) => {
+                        toast.saved(response.id);
+                        store.dispatch("core/isUnsaved", false);
+                    })
             }
-        }
-        store
-            .dispatch("flow/saveFlow", {flow: flowYaml.value})
-            .then((response) => {
-                toast.saved(response.id);
-                store.dispatch("core/isUnsaved", false);
-            })
+
+            haveChange.value = false;
+        })
     };
 
     const canExecute = () => {
@@ -1129,8 +1175,8 @@ import {ref, onMounted, nextTick, watch, getCurrentInstance, onBeforeUnmount, co
                     size="large"
                     @click="save"
                     v-if="isAllowedEdit"
-                    :disabled="!haveChange || !flowHaveTasks()"
-                    type="primary"
+                    :type="flowError || !haveChange ? 'danger' : 'primary'"
+                    :disabled="!haveChange"
                     class="edit-flow-save-button"
                 >
                     {{ $t("save") }}
diff --git a/ui/src/components/templates/TemplateEdit.vue b/ui/src/components/templates/TemplateEdit.vue
index a030274b56..aa647d6a61 100644
--- a/ui/src/components/templates/TemplateEdit.vue
+++ b/ui/src/components/templates/TemplateEdit.vue
@@ -56,6 +56,9 @@
                         .dispatch("template/loadTemplate", this.$route.params)
                         .then(this.loadFile);
                 }
+            },
+            onChange() {
+                this.$store.dispatch("core/isUnsaved", this.previousContent !== this.content);
             }
         }
     };
diff --git a/ui/src/mixins/flowTemplateEdit.js b/ui/src/mixins/flowTemplateEdit.js
index c15c12f3dd..fdd1f4c34b 100644
--- a/ui/src/mixins/flowTemplateEdit.js
+++ b/ui/src/mixins/flowTemplateEdit.js
@@ -177,9 +177,6 @@ export default {
                     });
             }
         },
-        onChange() {
-            this.$store.dispatch("core/isUnsaved", this.previousContent !== this.content);
-        },
         save() {
             if (this.$tours["guidedTour"].isRunning.value && !this.guidedProperties.saveFlow) {
                 this.$store.dispatch("api/events", {
diff --git a/ui/src/stores/flow.js b/ui/src/stores/flow.js
index 9425e7017a..936b5a4c62 100644
--- a/ui/src/stores/flow.js
+++ b/ui/src/stores/flow.js
@@ -211,7 +211,7 @@ export default {
             return this.$http.delete("/api/v1/flows/delete/by-query", options, {params: options})
         },
         validateFlow({commit}, options) {
-            return axios.post(`${apiRoot}flows/validate`, options.flow, textYamlHeader)
+            return this.$http.post(`${apiRoot}flows/validate`, options.flow, textYamlHeader)
                 .then(response => {
                     commit("setFlowError", response.data[0] ? response.data[0].constraints : undefined)
                     return response.data
diff --git a/ui/src/translations.json b/ui/src/translations.json
index f2c2d283b6..46eea6692d 100644
--- a/ui/src/translations.json
+++ b/ui/src/translations.json
@@ -406,7 +406,19 @@
     "error in editor": "An error have been found in the editor",
     "delete task confirm": "Do you want to delete the task <code>{taskId}</code> ?",
     "can not save": "Can not save",
-    "flow must have id and namespace": "Flow must have an id and a namespace."
+    "flow must have id and namespace": "Flow must have an id and a namespace.",
+    "save draft": {
+      "prompt": {
+        "base": "The current Flow is not valid. Do you still want to save it as a draft ?",
+        "http_error": "Unable to validate the Flow due to an HTTP error. Do you want to save the current Flow as draft ?",
+        "creation": "You will retrieve it on your next Flow creation.",
+        "existing": "You will retrieve it on your next <code>{namespace}.{id}</code> Flow edition."
+      },
+      "retrieval": {
+        "creation": "A Flow creation draft was retrieved, do you want to resume its edition ?",
+        "existing": "A <code>{flowFullName}</code> Flow draft was retrieved, do you want to resume its edition ?"
+      }
+    }
   },
   "fr": {
     "id": "Identifiant",
@@ -816,6 +828,18 @@
     "error in editor": "Une erreur a été trouvé dans l'éditeur",
     "delete task confirm": "Êtes-vous sûr de vouloir supprimer la tâche <code>{taskId}</code> ?",
     "can not save": "Impossible de sauvegarder",
-    "flow must have id and namespace": "Le flow doit avoir un id et un namespace."
+    "flow must have id and namespace": "Le flow doit avoir un id et un namespace.",
+    "save draft": {
+      "prompt": {
+        "base": "Le Flow actuel est invalide. Voulez-vous le sauvegarder en tant que brouillon ?",
+        "http_error": "Impossible de valider le Flow en raison d'une erreur HTTP. Voulez-vous sauvegarder le Flow actuel en tant que brouillon ?",
+        "creation": "Vous le récupérerez à votre prochaine création de Flow.",
+        "existing": "Vous le récupérerez à votre prochaine édition du Flow <code>{namespace}.{id}</code>."
+      },
+      "retrieval": {
+        "creation": "Un brouillon de création de Flow existe, voulez-vous reprendre son édition ?",
+        "existing": "Un brouillon pour le Flow <code>{flowFullName}</code> existe, voulez-vous reprendre son édition?"
+      }
+    }
   }
 }
diff --git a/ui/src/utils/axios.js b/ui/src/utils/axios.js
index 714abd3d34..57dfe048af 100644
--- a/ui/src/utils/axios.js
+++ b/ui/src/utils/axios.js
@@ -96,10 +96,9 @@ export default (callback, store, router) => {
                 return Promise.reject(errorResponse);
             }
 
-            if (errorResponse.response.status === 401 && !store.getters["auth/isLogged"]) {
+            if (errorResponse.response.status === 401 && (!store.getters["auth/isLogged"] || store.getters["auth/expired"])) {
                 window.location = "/ui/login?from=" + window.location.pathname +
                     (window.location.search ? "?" + window.location.search : "")
-
             }
 
             if (errorResponse.response.status === 401 &&
@@ -111,6 +110,7 @@ export default (callback, store, router) => {
 
                 return Promise.reject(errorResponse);
             }
+
             if (errorResponse.response.status === 400){
                 return Promise.reject(errorResponse.response.data)
             }
@@ -122,6 +122,11 @@ export default (callback, store, router) => {
                     variant: "error"
                 })
 
+                if(errorResponse.response.status === 401 &&
+                    store.getters["auth/isLogged"]){
+                    store.commit("auth/setExpired", true);
+                }
+
                 return Promise.reject(errorResponse);
             }
 
diff --git a/ui/src/utils/toast.js b/ui/src/utils/toast.js
index cc7e307dd5..cd7de19296 100644
--- a/ui/src/utils/toast.js
+++ b/ui/src/utils/toast.js
@@ -27,7 +27,7 @@ export default {
                         return h("span", {innerHTML: message});
                     }
                 },
-                confirm: function(message, callback) {
+                confirm: function(message, callback, callbackIfCancel = () => {}) {
                     ElMessageBox.confirm(
                         this._wrap(message || self.$t("toast confirm")),
                         self.$t("confirmation"),
@@ -39,7 +39,7 @@ export default {
                             callback();
                         })
                         .catch(() => {
-
+                            callbackIfCancel()
                         })
                 },
                 saved: function(name, title, options) {


Test patch: