Repository: kestra-io/kestra (java)

Issue:
Flow Execution Labels can't be deleted
### Describe the issue

Flow had no labels

After Flow is executed - if user will add a Label - they can't be deleted - fist label grayed out

https://github.com/user-attachments/assets/648fc588-eb8a-4b7d-a981-99ffa5aa3954



### Environment

- Kestra Version: develop
- Operating System (OS/Docker/Kubernetes):



Pull request description:
fix(*): allow deletion of label by setting empty key or empty value
close #5310 

Patch:
diff --git a/cli/src/main/java/io/kestra/cli/commands/flows/FlowCreateCommand.java b/cli/src/main/java/io/kestra/cli/commands/flows/FlowCreateCommand.java
new file mode 100644
index 000000000..2e85874ea
--- /dev/null
+++ b/cli/src/main/java/io/kestra/cli/commands/flows/FlowCreateCommand.java
@@ -0,0 +1,59 @@
+package io.kestra.cli.commands.flows;
+
+import io.kestra.cli.AbstractApiCommand;
+import io.kestra.cli.AbstractValidateCommand;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.MediaType;
+import io.micronaut.http.MutableHttpRequest;
+import io.micronaut.http.client.exceptions.HttpClientResponseException;
+import io.micronaut.http.client.netty.DefaultHttpClient;
+import lombok.extern.slf4j.Slf4j;
+import picocli.CommandLine;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+@CommandLine.Command(
+    name = "create",
+    description = "create a single flow",
+    mixinStandardHelpOptions = true
+)
+@Slf4j
+public class FlowCreateCommand extends AbstractApiCommand {
+    @CommandLine.Parameters(index = "0", description = "the file containing the flow")
+    public Path flowFile;
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public Integer call() throws Exception {
+        super.call();
+
+        checkFile();
+
+        String body = Files.readString(flowFile);
+
+        try(DefaultHttpClient client = client()) {
+            MutableHttpRequest<String> request = HttpRequest
+                .POST(apiUri("/flows"), body).contentType(MediaType.APPLICATION_YAML);
+
+            client.toBlocking().retrieve(
+                this.requestOptions(request),
+                String.class
+            );
+
+            stdOut("Flow successfully created !");
+        } catch (HttpClientResponseException e){
+            AbstractValidateCommand.handleHttpException(e, "flow");
+            return 1;
+        }
+
+
+        return 0;
+    }
+
+    protected void checkFile() {
+        if (!Files.isRegularFile(flowFile)) {
+            throw new IllegalArgumentException("The file '" + flowFile.toFile().getAbsolutePath() + "' is not a file");
+        }
+    }
+}
diff --git a/cli/src/main/java/io/kestra/cli/commands/flows/FlowDeleteCommand.java b/cli/src/main/java/io/kestra/cli/commands/flows/FlowDeleteCommand.java
new file mode 100644
index 000000000..b484da2ef
--- /dev/null
+++ b/cli/src/main/java/io/kestra/cli/commands/flows/FlowDeleteCommand.java
@@ -0,0 +1,47 @@
+package io.kestra.cli.commands.flows;
+
+import io.kestra.cli.AbstractApiCommand;
+import io.kestra.cli.AbstractValidateCommand;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.MutableHttpRequest;
+import io.micronaut.http.client.exceptions.HttpClientResponseException;
+import io.micronaut.http.client.netty.DefaultHttpClient;
+import lombok.extern.slf4j.Slf4j;
+import picocli.CommandLine;
+
+@CommandLine.Command(
+    name = "create",
+    description = "create a single flow",
+    mixinStandardHelpOptions = true
+)
+@Slf4j
+public class FlowDeleteCommand extends AbstractApiCommand {
+
+    @CommandLine.Parameters(index = "0", description = "the namespace of the flow")
+    public String namespace;
+
+    @CommandLine.Parameters(index = "1", description = "the id of the flow")
+    public String id;
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public Integer call() throws Exception {
+        super.call();
+
+        try(DefaultHttpClient client = client()) {
+            MutableHttpRequest<String> request = HttpRequest
+                .DELETE(apiUri("/flows/" + namespace + "/" + id ));
+
+            client.toBlocking().exchange(
+                this.requestOptions(request)
+            );
+
+            stdOut("Flow successfully deleted !");
+        } catch (HttpClientResponseException e){
+            AbstractValidateCommand.handleHttpException(e, "flow");
+            return 1;
+        }
+
+        return 0;
+    }
+}
diff --git a/cli/src/main/java/io/kestra/cli/commands/flows/FlowUpdateCommand.java b/cli/src/main/java/io/kestra/cli/commands/flows/FlowUpdateCommand.java
new file mode 100644
index 000000000..7cad07423
--- /dev/null
+++ b/cli/src/main/java/io/kestra/cli/commands/flows/FlowUpdateCommand.java
@@ -0,0 +1,64 @@
+package io.kestra.cli.commands.flows;
+
+import io.kestra.cli.AbstractApiCommand;
+import io.kestra.cli.AbstractValidateCommand;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.MediaType;
+import io.micronaut.http.MutableHttpRequest;
+import io.micronaut.http.client.exceptions.HttpClientResponseException;
+import io.micronaut.http.client.netty.DefaultHttpClient;
+import lombok.extern.slf4j.Slf4j;
+import picocli.CommandLine;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+@CommandLine.Command(
+    name = "update",
+    description = "update a single flow",
+    mixinStandardHelpOptions = true
+)
+@Slf4j
+public class FlowUpdateCommand extends AbstractApiCommand {
+    @CommandLine.Parameters(index = "0", description = "the file containing the flow")
+    public Path flowFile;
+
+    @CommandLine.Parameters(index = "1", description = "the namespace of the flow")
+    public String namespace;
+
+    @CommandLine.Parameters(index = "2", description = "the id of the flow")
+    public String id;
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public Integer call() throws Exception {
+        super.call();
+
+        checkFile();
+
+        String body = Files.readString(flowFile);
+
+        try(DefaultHttpClient client = client()) {
+            MutableHttpRequest<String> request = HttpRequest
+                .PUT(apiUri("/flows/" + namespace + "/" + id ), body).contentType(MediaType.APPLICATION_YAML);
+
+            client.toBlocking().retrieve(
+                this.requestOptions(request),
+                String.class
+            );
+
+            stdOut("Flow successfully updated !");
+        } catch (HttpClientResponseException e){
+            AbstractValidateCommand.handleHttpException(e, "flow");
+            return 1;
+        }
+
+        return 0;
+    }
+
+    protected void checkFile() {
+        if (!Files.isRegularFile(flowFile)) {
+            throw new IllegalArgumentException("The file '" + flowFile.toFile().getAbsolutePath() + "' is not a file");
+        }
+    }
+}
diff --git a/ui/src/components/executions/SetLabels.vue b/ui/src/components/executions/SetLabels.vue
index 412555746..612e697e5 100644
--- a/ui/src/components/executions/SetLabels.vue
+++ b/ui/src/components/executions/SetLabels.vue
@@ -76,7 +76,8 @@
         methods: {
             setLabels() {
                 const filtered = filterLabels(this.executionLabels)
-
+                console.log("executionLabels", this.executionLabels)
+                console.log("filtered", filtered)
                 if(filtered.error) {
                     this.$toast().error(this.$t("wrong labels"))
                     return;
diff --git a/ui/src/components/executions/utils.ts b/ui/src/components/executions/utils.ts
index 5d7d80e1a..fe28e47e8 100644
--- a/ui/src/components/executions/utils.ts
+++ b/ui/src/components/executions/utils.ts
@@ -9,6 +9,6 @@ interface FilterResult {
 }
 
 export const filterLabels = (labels: Label[]): FilterResult => {
-    const invalid = labels.some(label => label.key === null || label.value === null);
+    const invalid = labels.some(label => label.key === null || label.value === null || label.key === "" || label.value === "");
     return invalid ? {labels, error: true} : {labels};
 };
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 f9ed05269..a83a3d5eb 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
@@ -1594,7 +1594,7 @@ public class ExecutionController {
 
         Execution newExecution = execution
             .toBuilder()
-            .labels(newLabels.entrySet().stream().map(entry -> new Label(entry.getKey(), entry.getValue())).toList())
+            .labels(newLabels.entrySet().stream().map(entry -> new Label(entry.getKey(), entry.getValue())).filter(label -> !label.key().isEmpty() || !label.value().isEmpty()).toList())
             .build();
         eventPublisher.publishEvent(new CrudEvent<>(newExecution, execution, CrudEventType.UPDATE));
 


Test patch:
diff --git a/cli/src/test/java/io/kestra/cli/commands/flows/SingleFlowCommandsTest.java b/cli/src/test/java/io/kestra/cli/commands/flows/SingleFlowCommandsTest.java
new file mode 100644
index 000000000..a6cdc3884
--- /dev/null
+++ b/cli/src/test/java/io/kestra/cli/commands/flows/SingleFlowCommandsTest.java
@@ -0,0 +1,71 @@
+package io.kestra.cli.commands.flows;
+
+import io.micronaut.configuration.picocli.PicocliRunner;
+import io.micronaut.context.ApplicationContext;
+import io.micronaut.context.env.Environment;
+import io.micronaut.runtime.server.EmbeddedServer;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.net.URL;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.StringContains.containsString;
+
+public class SingleFlowCommandsTest {
+
+
+    @Test
+    void all() {
+        URL flow = SingleFlowCommandsTest.class.getClassLoader().getResource("flows/quattro.yml");
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        System.setOut(new PrintStream(out));
+
+        try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) {
+
+            EmbeddedServer embeddedServer = ctx.getBean(EmbeddedServer.class);
+            embeddedServer.start();
+
+            String[] deleteArgs = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                "io.kestra.outsider",
+                "quattro"
+            };
+            PicocliRunner.call(FlowDeleteCommand.class, ctx, deleteArgs);
+
+            assertThat(out.toString(), containsString("Flow successfully deleted !"));
+            out.reset();
+
+            String[] createArgs = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                flow.getPath(),
+            };
+            PicocliRunner.call(FlowCreateCommand.class, ctx, createArgs);
+
+            assertThat(out.toString(), containsString("Flow successfully created !"));
+
+
+            out.reset();String[] updateArgs = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                flow.getPath(),
+                "io.kestra.outsider",
+                "quattro"
+            };
+            PicocliRunner.call(FlowUpdateCommand.class, ctx, updateArgs);
+
+            assertThat(out.toString(), containsString("Flow successfully updated !"));
+            out.reset();
+        }
+    }
+
+}