Repository: kestra-io/kestra (java)

Issue:
Cannot disable flow from low-code editor
### Describe the issue

If I hit _Actions_ -> _Edit Metadata_ and I try to disable the flow, the change doesn't get persisted :(

![disabled](https://github.com/user-attachments/assets/225c2f16-b9e5-40b9-a9bd-9790501fa9f5)



### Environment

- Kestra Version: 0.19.0-SNAPSHOT (d329c68)
- Operating System (OS/Docker/Kubernetes): Docker


Pull request:
Fix cannot disable flow from low-code editor issue
- This PR solves the issue #5051

- Added the disabled property to order List to handle the disable property in the Editor and also added appropriate checks in the cleanMetadata function 


https://github.com/user-attachments/assets/653b0eb0-395f-41c8-93e1-933bd994660c



closes #5051



Patch:
diff --git a/Docker.md b/Docker.md
new file mode 100644
index 000000000..45ab6732e
--- /dev/null
+++ b/Docker.md
@@ -0,0 +1,53 @@
+# Kestra Docker Image Documentation
+
+Welcome to the official Docker image documentation for [Kestra](https://kestra.io). This document explains how to use Kestra's Docker images, along with the available tags and links to the installation and deployment guides.
+
+## Getting Started
+
+To pull the official Kestra Docker image, use the following command:
+
+```bash
+docker pull kestra/kestra:latest
+```
+Installation and Deployment Guide
+For detailed instructions on installing and deploying Kestra, please visit our Installation Guide.
+[Kestra Documentation](https://kestra.io/docs)
+This guide includes:
+
+- **Step-by-step instructions for setting up Kestra in a Docker environment.**
+- **Configuration options for both local and cloud deployments.**
+- **Details on using Kestra with Kubernetes, Helm, and Docker Compose.**
+
+## Docker Image Tags
+
+Kestra provides various Docker image tags to meet different needs. Below is an explanation of the key tags:
+
+- **`latest`**: The `latest` tag points to the most recent stable release of Kestra. This is recommended for most users who want to keep up-to-date with stable features and security fixes.
+
+    ```bash
+    docker pull kestra/kestra:latest
+    ```
+
+- **`beta`**: The `beta` tag contains new features that are still in the testing phase. It's ideal for users who want to test upcoming features but may encounter bugs.
+
+    ```bash
+    docker pull kestra/kestra:beta
+    ```
+
+- **`vX.Y.Z`**: These tags correspond to specific versions of Kestra. Use them if you require a particular version for compatibility or stability reasons.
+
+  Example: To pull version `0.3.0`, use:
+
+    ```bash
+    docker pull kestra/kestra:v0.3.0
+    ```
+
+You can find all available version tags on [Docker Hub](https://hub.docker.com/r/kestra/kestra/tags).
+
+## Running Kestra
+
+Once you have pulled the image, you can run Kestra using the following basic command:
+
+```bash
+docker run -d --name kestra -p 8080:8080 kestra/kestra:latest
+
diff --git a/core/src/main/java/io/kestra/core/models/flows/AbstractFlow.java b/core/src/main/java/io/kestra/core/models/flows/AbstractFlow.java
index f92aeae2e..9705b6f17 100644
--- a/core/src/main/java/io/kestra/core/models/flows/AbstractFlow.java
+++ b/core/src/main/java/io/kestra/core/models/flows/AbstractFlow.java
@@ -4,10 +4,7 @@ import io.kestra.core.models.DeletedInterface;
 import io.kestra.core.models.TenantInterface;
 import io.swagger.v3.oas.annotations.Hidden;
 import jakarta.validation.Valid;
-import jakarta.validation.constraints.Min;
-import jakarta.validation.constraints.NotBlank;
-import jakarta.validation.constraints.NotNull;
-import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.*;
 import lombok.Builder;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
@@ -22,10 +19,12 @@ public abstract class AbstractFlow implements DeletedInterface, TenantInterface
     @NotNull
     @NotBlank
     @Pattern(regexp = "^[a-zA-Z0-9][a-zA-Z0-9._-]*")
+    @Size(min = 1, max = 100)
     String id;
 
     @NotNull
     @Pattern(regexp = "^[a-z0-9][a-z0-9._-]*")
+    @Size(min = 1, max = 150)
     String namespace;
 
     @Min(value = 1)
diff --git a/kestra b/kestra
new file mode 160000
index 000000000..5a9816100
--- /dev/null
+++ b/kestra
@@ -0,0 +1,1 @@
+Subproject commit 5a9816100e09d48a4f290a173761dc6e6c7a7d3d
diff --git a/platform/build.gradle b/platform/build.gradle
index 47f46c38f..1e4c346d7 100644
--- a/platform/build.gradle
+++ b/platform/build.gradle
@@ -116,7 +116,7 @@ dependencies {
         api ("org.jooq:jooq:3.19.13")
 
         // Tests
-        api "org.junit-pioneer:junit-pioneer:2.2.0"
+        api "org.junit-pioneer:junit-pioneer:2.3.0"
         api 'org.hamcrest:hamcrest:3.0'
         api 'org.hamcrest:hamcrest-library:3.0'
         api group: 'org.exparity', name: 'hamcrest-date', version: '2.0.8'
diff --git a/storage-local/src/main/java/io/kestra/storage/local/LocalStorage.java b/storage-local/src/main/java/io/kestra/storage/local/LocalStorage.java
index ece281a34..9669338c4 100644
--- a/storage-local/src/main/java/io/kestra/storage/local/LocalStorage.java
+++ b/storage-local/src/main/java/io/kestra/storage/local/LocalStorage.java
@@ -237,7 +237,7 @@ public class LocalStorage implements StorageInterface {
         Path prefix = (tenantId == null) ?
             basePath.toAbsolutePath() :
             Path.of(basePath.toAbsolutePath().toString(), tenantId);
-        return URI.create("kestra:///" + prefix.relativize(path));
+        return URI.create("kestra:///" + prefix.relativize(path).toString().replace("\\", "/"));
     }
 
     private void parentTraversalGuard(URI uri) {
diff --git a/ui/src/components/flows/Flows.vue b/ui/src/components/flows/Flows.vue
index 421bc03a2..2af7aea6d 100644
--- a/ui/src/components/flows/Flows.vue
+++ b/ui/src/components/flows/Flows.vue
@@ -106,10 +106,10 @@
                                 <el-button v-if="canDelete" @click="deleteFlows" :icon="TrashCan">
                                     {{ $t('delete') }}
                                 </el-button>
-                                <el-button v-if="canUpdate" @click="enableFlows" :icon="FileDocumentCheckOutline">
+                                <el-button v-if="canUpdate && anyFlowDisabled()" @click="enableFlows" :icon="FileDocumentCheckOutline">
                                     {{ $t('enable') }}
                                 </el-button>
-                                <el-button v-if="canUpdate" @click="disableFlows" :icon="FileDocumentRemoveOutline">
+                                <el-button v-if="canUpdate && anyFlowEnabled()" @click="disableFlows" :icon="FileDocumentRemoveOutline">
                                     {{ $t('disable') }}
                                 </el-button>
                             </bulk-select>
@@ -331,7 +331,8 @@
             selectionMapper(element) {
                 return {
                     id: element.id,
-                    namespace: element.namespace
+                    namespace: element.namespace,
+                    enabled: !element.disabled
                 }
             },
             exportFlows() {
@@ -388,6 +389,12 @@
                     }
                 )
             },
+            anyFlowDisabled() {
+                return this.selection.some(flow => !flow.enabled);
+            },
+            anyFlowEnabled() {
+                return this.selection.some(flow => flow.enabled);
+            },
             enableFlows() {
                 this.$toast().confirm(
                     this.$t("flow enable", {"flowCount": this.queryBulkAction ? this.total : this.selection.length}),
@@ -542,7 +549,7 @@
             rowClasses(row) {
                 return row && row.row && row.row.disabled ? "disabled" : "";
             }
-        }        
+        }
     };
 </script>
 
diff --git a/ui/src/components/flows/MetadataEditor.vue b/ui/src/components/flows/MetadataEditor.vue
index f8c52c1a5..21e572b9b 100644
--- a/ui/src/components/flows/MetadataEditor.vue
+++ b/ui/src/components/flows/MetadataEditor.vue
@@ -139,7 +139,7 @@
                 <code>disabled</code>
             </template>
             <div>
-                <el-switch active-color="green" v-model="newMetadata.disabled" @change="update" />
+                <el-switch active-color="green" v-model="newMetadata.disabled" @update:model-value="(value) => newMetadata.disabled = value" />
             </div>
         </el-form-item>
     </el-form>
diff --git a/ui/src/components/inputs/EditorView.vue b/ui/src/components/inputs/EditorView.vue
index 7fdde8f92..96cf9f107 100644
--- a/ui/src/components/inputs/EditorView.vue
+++ b/ui/src/components/inputs/EditorView.vue
@@ -705,7 +705,7 @@
     };
 
     const save = async (e) => {
-        if (!currentTab?.value?.dirty && !props.isCreating) {
+        if (!haveChange.value && !props.isCreating) {
             return;
         }
         if (e) {
diff --git a/ui/src/components/namespace/Executions.vue b/ui/src/components/namespace/Executions.vue
new file mode 100644
index 000000000..0c47bc522
--- /dev/null
+++ b/ui/src/components/namespace/Executions.vue
@@ -0,0 +1,15 @@
+<template>
+    <Executions :namespace="$route.params.id || $route.query.id" :topbar="false" :hidden="['selection','inputs','flowRevision','taskRunList.taskId']" />
+</template>
+
+<script>
+    import Executions from "../executions/Executions.vue"
+    import {mapState} from "vuex";
+
+    export default {
+        components: {Executions},
+        computed: {
+            ...mapState("namespace", ["namespace"]),
+        },
+    };
+</script>
diff --git a/ui/src/components/namespace/Namespace.vue b/ui/src/components/namespace/Namespace.vue
index 5453edfe5..8d4f08e7f 100644
--- a/ui/src/components/namespace/Namespace.vue
+++ b/ui/src/components/namespace/Namespace.vue
@@ -48,6 +48,7 @@
     import permission from "../../models/permission";
     import action from "../../models/action";
     import Overview from "./Overview.vue";
+    import Executions from "./Executions.vue";
     import NamespaceKV from "./NamespaceKV.vue";
     import NamespaceFlows from "./NamespaceFlows.vue";
     import EditorView from "../inputs/EditorView.vue";
@@ -144,6 +145,14 @@
                             id: this.$route.query.id
                         }
                     },   
+                    {
+                        name: "executions",
+                        component: Executions,
+                        title: this.$t("executions"),
+                        query: {
+                            id: this.$route.query.id
+                        }
+                    },
                     {
                         name: "dependencies",
                         component: NamespaceDependenciesWrapper,
diff --git a/ui/src/utils/yamlUtils.js b/ui/src/utils/yamlUtils.js
index 8c7f410c8..6b59977fc 100644
--- a/ui/src/utils/yamlUtils.js
+++ b/ui/src/utils/yamlUtils.js
@@ -589,11 +589,11 @@ export default class YamlUtils {
             return source;
         }
 
-        const order = ["id", "namespace", "description", "retry", "labels", "inputs", "variables", "tasks", "triggers", "errors", "pluginDefaults", "taskDefaults", "concurrency", "outputs"];
+        const order = ["id", "namespace", "description", "retry", "labels", "inputs", "variables", "tasks", "triggers", "errors", "pluginDefaults", "taskDefaults", "concurrency", "outputs", "disabled"];
         const updatedItems = [];
         for (const prop of order) {
             const item = yamlDoc.contents.items.find(e => e.key.value === prop);
-            if (item && (((isSeq(item.value) || isMap(item.value)) && item.value.items.length > 0) || item.value.value)) {
+            if (item && (((isSeq(item.value) || isMap(item.value)) && item.value.items.length > 0) || (item.value.value !== undefined && item.value.value !== null))) {
                 updatedItems.push(item);
             }
         }
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java
index d414f5e3e..314f45e0c 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java
@@ -694,9 +694,10 @@ public class ExecutionController {
             throw new NoSuchElementException("Unable to find execution id '" + executionId + "'");
         }
 
-        Optional<Flow> flow = flowRepository.findById(execution.get().getTenantId(), execution.get().getNamespace(), execution.get().getFlowId());
+        String flowId = execution.get().getFlowId();
+        Optional<Flow> flow = flowRepository.findById(execution.get().getTenantId(), execution.get().getNamespace(), flowId);
         if (flow.isEmpty()) {
-            throw new NoSuchElementException("Unable to find flow id '" + executionId + "'");
+            throw new NoSuchElementException("Unable to find flow id '" + flowId + "'");
         }
 
         String prefix = StorageContext


Test patch:
diff --git a/core/src/test/java/io/kestra/core/serializers/YamlFlowParserTest.java b/core/src/test/java/io/kestra/core/serializers/YamlFlowParserTest.java
index c290a5516..c422ee11b 100644
--- a/core/src/test/java/io/kestra/core/serializers/YamlFlowParserTest.java
+++ b/core/src/test/java/io/kestra/core/serializers/YamlFlowParserTest.java
@@ -223,7 +223,7 @@ class YamlFlowParserTest {
         Optional<ConstraintViolationException> valid = modelValidator.isValid(parse);
 
         assertThat(valid.isPresent(), is(true));
-        assertThat(valid.get().getConstraintViolations().size(), is(8));
+        assertThat(valid.get().getConstraintViolations().size(), is(10));
         assertThat(new ArrayList<>(valid.get().getConstraintViolations()).stream().filter(r -> r.getMessage().contains("must not be empty")).count(), is(3L));
     }