Repository: kestra-io/kestra
Issue: "Enable" Options for enabling disabled flow
Labels: enhancement

### Feature description

When we select multiple flow, there are "Disable" option but do not have "Enable" option. To enable the disabled flow, we need to enter each flow and remove the disabled line. 

Thank you

Issue comments:

Pull request review comments:
Rename canDisable var to canUpdate
```suggestion
        String regex = "^disabled\\s*:\\s*" + String.valueOf(!disabled) + "\\s*";
```
Idk if that's really an issue but maybe we should remove the disabled property entirely instead of creating or updating it when we enable back the flow ?
When we enable a flow, we filter by flows that are disabled so they already have the property (or the flow has been edited directly in the DB)

Patch that resolved the issue:
diff --git a/core/src/main/java/io/kestra/core/services/FlowService.java b/core/src/main/java/io/kestra/core/services/FlowService.java
index 56210c2c11..f0c080f9af 100644
--- a/core/src/main/java/io/kestra/core/services/FlowService.java
+++ b/core/src/main/java/io/kestra/core/services/FlowService.java
@@ -222,13 +222,15 @@ public static String cleanupSource(String source) {
         return source.replaceFirst("(?m)^revision: \\d+\n?","");
     }
 
-    public static String injectDisabledTrue(String source) {
-        Pattern p = Pattern.compile("^disabled\\s*:\\s*false\\s*", Pattern.MULTILINE);
+    public static String injectDisabled(String source, Boolean disabled) {
+        String regex = disabled ? "^disabled\\s*:\\s*false\\s*" : "^disabled\\s*:\\s*true\\s*";
+
+        Pattern p = Pattern.compile(regex, Pattern.MULTILINE);
         if (p.matcher(source).find()) {
-            return p.matcher(source).replaceAll("disabled: true\n");
+            return p.matcher(source).replaceAll(String.format("disabled: %s\n", disabled));
         }
 
-        return source + "\ndisabled: true";
+        return source + String.format("\ndisabled: %s", disabled);
     }
 
     public static String generateSource(Flow flow, @Nullable String source) {
diff --git a/ui/src/components/flows/Flows.vue b/ui/src/components/flows/Flows.vue
index 2d0f055d98..4a7f623ed3 100644
--- a/ui/src/components/flows/Flows.vue
+++ b/ui/src/components/flows/Flows.vue
@@ -113,7 +113,10 @@
                             <el-button v-if="canDelete" @click="deleteFlows" size="large" :icon="TrashCan">
                                 {{ $t('delete') }}
                             </el-button>
-                            <el-button v-if="canDisable" @click="disableFlows" size="large" :icon="FileDocumentRemoveOutline">
+                            <el-button v-if="canUpdate" @click="enableFlows" size="large" :icon="FileDocumentCheckOutline">
+                                {{ $t('enable') }}
+                            </el-button>
+                            <el-button v-if="canUpdate" @click="disableFlows" size="large" :icon="FileDocumentRemoveOutline">
                                 {{ $t('disable') }}
                             </el-button>
                         </bottom-line-counter>
@@ -162,6 +165,7 @@
     import Download from "vue-material-design-icons/Download.vue";
     import TrashCan from "vue-material-design-icons/TrashCan.vue";
     import FileDocumentRemoveOutline from "vue-material-design-icons/FileDocumentRemoveOutline.vue";
+    import FileDocumentCheckOutline from "vue-material-design-icons/FileDocumentCheckOutline.vue";
 </script>
 
 <script>
@@ -232,7 +236,7 @@
                     .toDate();
             },
             canCheck() {
-                return this.canRead || this.canDelete || this.canDisable;
+                return this.canRead || this.canDelete || this.canUpdate;
             },
             canRead() {
                 return this.user && this.user.isAllowed(permission.FLOW, action.READ, this.$route.query.namespace);
@@ -240,7 +244,7 @@
             canDelete() {
                 return this.user && this.user.isAllowed(permission.FLOW, action.DELETE, this.$route.query.namespace);
             },
-            canDisable() {
+            canUpdate() {
                 return this.user && this.user.isAllowed(permission.FLOW, action.UPDATE, this.$route.query.namespace);
             },
         },
@@ -311,6 +315,32 @@
                     () => {}
                 )
             },
+            enableFlows(){
+                this.$toast().confirm(
+                    this.$t("flow enable", {"flowCount": this.queryBulkAction ? this.total : this.flowsSelection.length}),
+                    () => {
+                        if (this.queryBulkAction) {
+                            return this.$store
+                                .dispatch("flow/enableFlowByQuery", this.loadQuery({
+                                    namespace: this.$route.query.namespace ? [this.$route.query.namespace] : undefined,
+                                    q: this.$route.query.q ? [this.$route.query.q] : undefined,
+                                }, false))
+                                .then(r => {
+                                    this.$toast().success(this.$t("flows enabled", {count: r.data.count}));
+                                    this.loadData(() => {})
+                                })
+                        } else {
+                            return this.$store
+                                .dispatch("flow/enableFlowByIds", {ids: this.flowsSelection})
+                                .then(r => {
+                                    this.$toast().success(this.$t("flows enabled", {count: r.data.count}));
+                                    this.loadData(() => {})
+                                })
+                        }
+                    },
+                    () => {}
+                )
+            },
             deleteFlows(){
                 this.$toast().confirm(
                     this.$t("flow delete", {"flowCount": this.queryBulkAction ? this.total : this.flowsSelection.length}),
diff --git a/ui/src/stores/flow.js b/ui/src/stores/flow.js
index 504db76399..16a73ba749 100644
--- a/ui/src/stores/flow.js
+++ b/ui/src/stores/flow.js
@@ -217,6 +217,12 @@ export default {
         disableFlowByQuery(_, options) {
             return this.$http.post("/api/v1/flows/disable/by-query", options, {params: options})
         },
+        enableFlowByIds(_, options) {
+            return this.$http.post("/api/v1/flows/enable/by-ids", options.ids)
+        },
+        enableFlowByQuery(_, options) {
+            return this.$http.post("/api/v1/flows/enable/by-query", options, {params: options})
+        },
         deleteFlowByIds(_, options) {
             return this.$http.delete("/api/v1/flows/delete/by-ids", {data: options.ids})
         },
diff --git a/ui/src/translations.json b/ui/src/translations.json
index a53412893e..b85fe3fc27 100644
--- a/ui/src/translations.json
+++ b/ui/src/translations.json
@@ -300,12 +300,15 @@
     "export all flows": "Export all flows",
     "import": "Import",
     "disable": "Disable",
+    "enable": "Enable",
     "template delete": "Are you sure you want to delete <code>{templateCount}</code> template(s)?",
     "flow delete": "Are you sure you want to delete <code>{flowCount}</code> flow(s)?",
     "templates deleted": "<code>{count}</code> Template(s) deleted",
     "flows deleted": "<code>{count}</code> Flow(s) deleted",
     "flow disable": "Are you sure you want to disable <code>{flowCount}</code> flow(s)?",
+    "flow enable": "Are you sure you want to enable <code>{flowCount}</code> flow(s)?",
     "flows disabled": "<code>{count}</code> Flow(s) disabled",
+    "flows enabled": "<code>{count}</code> Flow(s) enabled",
     "dependencies": "Dependencies",
     "see dependencies": "See dependencies",
     "dependencies missing acls": "No permissions on this flow",
@@ -748,12 +751,15 @@
     "export all flows": "Exporter tous les flows",
     "import": "Importer",
     "disable": "Désactiver",
+    "enable": "Activer",
     "template delete": "Êtes vous sûr de vouloir supprimer <code>{templateCount}</code> template(s)?",
     "flow delete": "Êtes vous sûr de vouloir supprimer <code>{flowCount}</code> flow(s)?",
     "templates deleted": "<code>{count}</code> Template(s) supprimé(s)",
     "flows deleted": "<code>{count}</code> Flow(s) supprimé(s)",
     "flow disable": "Êtes vous sûr de vouloir désactiver <code>{flowCount}</code> flow(s)?",
+    "flow enable": "Êtes vous sûr de vouloir activer <code>{flowCount}</code> flow(s)?",
     "flows disabled": "<code>{count}</code> Flow(s) désactivé(s)",
+    "flows enable": "<code>{count}</code> Flow(s) activé(s)",
     "dependencies": "Dépendances",
     "see dependencies": "Voir les dépendances",
     "dependencies missing acls": "Aucune permission sur ce flow",
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java b/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java
index 2118132719..c9221ed3d7 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/FlowController.java
@@ -593,26 +593,8 @@ public HttpResponse<BulkResponse> disableByQuery(
         @Parameter(description = "A namespace filter prefix") @Nullable @QueryValue String namespace,
         @Parameter(description = "A labels filter") @Nullable @QueryValue List<String> labels
     ) {
-        List<FlowWithSource> list = flowRepository
-            .findWithSource(query, namespace, RequestUtils.toMap(labels))
-            .stream()
-            .filter(flowWithSource -> !flowWithSource.isDisabled())
-            .peek(flow -> {
-                FlowWithSource flowUpdated = flow.toBuilder()
-                    .disabled(true)
-                    .source(FlowService.injectDisabledTrue(flow.getSource()))
-                    .build();
-
-                flowRepository.update(
-                    flowUpdated,
-                    flow,
-                    flowUpdated.getSource(),
-                    taskDefaultService.injectDefaults(flowUpdated)
-                );
-            })
-            .collect(Collectors.toList());
 
-        return HttpResponse.ok(BulkResponse.builder().count(list.size()).build());
+        return HttpResponse.ok(BulkResponse.builder().count(setFlowsDisableByQuery(query, namespace, labels, true).size()).build());
     }
 
     @ExecuteOn(TaskExecutors.IO)
@@ -624,28 +606,39 @@ public HttpResponse<BulkResponse> disableByQuery(
     public HttpResponse<BulkResponse> disableByIds(
         @Parameter(description = "A list of tuple flow ID and namespace as flow identifiers") @Body List<IdWithNamespace> ids
     ) {
-        List<FlowWithSource> list = ids
-            .stream()
-            .map(id -> flowRepository.findByIdWithSource(id.getNamespace(), id.getId()).orElseThrow())
-            .filter(flowWithSource -> !flowWithSource.isDisabled())
-            .peek(flow -> {
-                FlowWithSource flowUpdated = flow.toBuilder()
-                    .disabled(true)
-                    .source(FlowService.injectDisabledTrue(flow.getSource()))
-                    .build();
 
-                flowRepository.update(
-                    flowUpdated,
-                    flow,
-                    flowUpdated.getSource(),
-                    taskDefaultService.injectDefaults(flowUpdated)
-                );
-            })
-            .collect(Collectors.toList());
+        return HttpResponse.ok(BulkResponse.builder().count(setFlowsDisableByIds(ids, true).size()).build());
+    }
 
-        return HttpResponse.ok(BulkResponse.builder().count(list.size()).build());
+    @ExecuteOn(TaskExecutors.IO)
+    @Post(uri = "/enable/by-query")
+    @Operation(
+        tags = {"Flows"},
+        summary = "Enable flows returned by the query parameters."
+    )
+    public HttpResponse<BulkResponse> enableByQuery(
+        @Parameter(description = "A string filter") @Nullable @QueryValue(value = "q") String query,
+        @Parameter(description = "A namespace filter prefix") @Nullable @QueryValue String namespace,
+        @Parameter(description = "A labels filter") @Nullable @QueryValue List<String> labels
+    ) {
+
+        return HttpResponse.ok(BulkResponse.builder().count(setFlowsDisableByQuery(query, namespace, labels, false).size()).build());
+    }
+
+    @ExecuteOn(TaskExecutors.IO)
+    @Post(uri = "/enable/by-ids")
+    @Operation(
+        tags = {"Flows"},
+        summary = "Enable flows by their IDs."
+    )
+    public HttpResponse<BulkResponse> enableByIds(
+        @Parameter(description = "A list of tuple flow ID and namespace as flow identifiers") @Body List<IdWithNamespace> ids
+    ) {
+
+        return HttpResponse.ok(BulkResponse.builder().count(setFlowsDisableByIds(ids, false).size()).build());
     }
 
+
     @ExecuteOn(TaskExecutors.IO)
     @Post(uri = "/import", consumes = MediaType.MULTIPART_FORM_DATA)
     @Operation(
@@ -692,4 +685,46 @@ protected void importFlow(String source, Flow parsed) {
                 () -> flowRepository.create(parsed, source, taskDefaultService.injectDefaults(parsed))
             );
     }
+
+    protected List<FlowWithSource> setFlowsDisableByIds(List<IdWithNamespace> ids, boolean disable) {
+        return ids
+            .stream()
+            .map(id -> flowRepository.findByIdWithSource(id.getNamespace(), id.getId()).orElseThrow())
+            .filter(flowWithSource -> disable != flowWithSource.isDisabled())
+            .peek(flow -> {
+                FlowWithSource flowUpdated = flow.toBuilder()
+                    .disabled(disable)
+                    .source(FlowService.injectDisabled(flow.getSource(), disable))
+                    .build();
+
+                flowRepository.update(
+                    flowUpdated,
+                    flow,
+                    flowUpdated.getSource(),
+                    taskDefaultService.injectDefaults(flowUpdated)
+                );
+            })
+            .toList();
+    }
+
+    protected List<FlowWithSource> setFlowsDisableByQuery(String query, String namespace,List<String> labels, boolean disable) {
+        return flowRepository
+            .findWithSource(query, namespace, RequestUtils.toMap(labels))
+            .stream()
+            .filter(flowWithSource -> disable != flowWithSource.isDisabled())
+            .peek(flow -> {
+                FlowWithSource flowUpdated = flow.toBuilder()
+                    .disabled(disable)
+                    .source(FlowService.injectDisabled(flow.getSource(), disable))
+                    .build();
+
+                flowRepository.update(
+                    flowUpdated,
+                    flow,
+                    flowUpdated.getSource(),
+                    taskDefaultService.injectDefaults(flowUpdated)
+                );
+            })
+            .toList();
+    }
 }


Test patch:
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java
index b159b19c90..cf7246c3bb 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/FlowControllerTest.java
@@ -555,7 +555,7 @@ void importFlowsWithZip() throws IOException {
     }
 
     @Test
-    void disableFlowsByIds() {
+    void disableEnableFlowsByIds() {
         List<IdWithNamespace> ids = List.of(
             new IdWithNamespace("io.kestra.tests", "each-object"),
             new IdWithNamespace("io.kestra.tests", "webhook"),
@@ -575,10 +575,24 @@ void disableFlowsByIds() {
         assertThat(eachObject.isDisabled(), is(true));
         assertThat(webhook.isDisabled(), is(true));
         assertThat(taskFlow.isDisabled(), is(true));
+
+        response = client
+            .toBlocking()
+            .exchange(POST("/api/v1/flows/enable/by-ids", ids), BulkResponse.class);
+
+        assertThat(response.getBody().get().getCount(), is(3));
+
+        eachObject = parseFlow(client.toBlocking().retrieve(HttpRequest.GET("/api/v1/flows/io.kestra.tests/each-object"), String.class));
+        webhook = parseFlow(client.toBlocking().retrieve(HttpRequest.GET("/api/v1/flows/io.kestra.tests/webhook"), String.class));
+        taskFlow = parseFlow(client.toBlocking().retrieve(HttpRequest.GET("/api/v1/flows/io.kestra.tests/task-flow"), String.class));
+
+        assertThat(eachObject.isDisabled(), is(false));
+        assertThat(webhook.isDisabled(), is(false));
+        assertThat(taskFlow.isDisabled(), is(false));
     }
 
     @Test
-    void disableFlowsByQuery() throws InterruptedException {
+    void disableEnableFlowsByQuery() throws InterruptedException {
         Flow flow = generateFlow("toDisable","io.kestra.unittest.disabled", "a");
         client.toBlocking().retrieve(POST("/api/v1/flows", flow), String.class);
 
@@ -591,6 +605,16 @@ void disableFlowsByQuery() throws InterruptedException {
         Flow toDisable = parseFlow(client.toBlocking().retrieve(HttpRequest.GET("/api/v1/flows/io.kestra.unittest.disabled/toDisable"), String.class));
 
         assertThat(toDisable.isDisabled(), is(true));
+
+        response = client
+            .toBlocking()
+            .exchange(POST("/api/v1/flows/enable/by-query?namespace=io.kestra.unittest.disabled", Map.of()), BulkResponse.class);
+
+        assertThat(response.getBody().get().getCount(), is(1));
+
+        toDisable = parseFlow(client.toBlocking().retrieve(HttpRequest.GET("/api/v1/flows/io.kestra.unittest.disabled/toDisable"), String.class));
+
+        assertThat(toDisable.isDisabled(), is(false));
     }
 
     @Test