Repository: kestra-io/kestra (java)

Issue:
Flow metadata is not persisted upon `Save` from low-code editor
### Describe the issue

If I edit flow metadata from the UI low-code editor, even changes if changes are reflected in-source, they are not 'captured' as an actual source change, and the flow's _Save_ button is therefore inoperative. Upon page reload, or any navigation to another page, I then lose these changes.

![no_save](https://github.com/user-attachments/assets/605a92fa-cbeb-472f-abe9-079a8885eaf6)


### Environment

- Kestra Version: Kestra Version: 0.19.0-SNAPSHOT (d329c68)
- Operating System (OS/Docker/Kubernetes): Docker


Pull request description:
Fix flow metadata is not persisted upon Save from low-code editor
- This PR solves the issue #5071 

- Added the appropriate condition for detecting changes.


https://github.com/user-attachments/assets/f35592de-5b71-43ab-8ac9-ae8257cf0f1b



closes #5071

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/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/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,


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));
     }