Repository: kestra-io/kestra (java)

Issue:
[UI bug] Administration trigger set to disabled in the source code should not be toggle-able (the toggle should be greyed out/locked)
### Issue description

I noticed that there seems to be no correlation between enabling or disabling triggers in the UI via Administration -> Triggers -> Enable/Disable toggle and the flow definition, where triggers can have a `disabled: true` flag which is also described in the [docs](https://kestra.io/docs/workflow-components/disabled#disabled-trigger).

Use case is to sync flows from Git, with triggers. Those triggers would be disabled by default (in the flow definition yaml), but perhaps can be turned on from the Administration Triggers UI.

Maybe it's still a new feature and being worked out. :)

Running on 0.15.0-SNAPSHOT

Pull request:
fix(ui): prevent enabled/disabled trigger already disabled from flow definition
closes #3113

Patch:
diff --git a/core/src/main/java/io/kestra/core/docs/AbstractClassDocumentation.java b/core/src/main/java/io/kestra/core/docs/AbstractClassDocumentation.java
index 541cc2a00..3b5260b48 100644
--- a/core/src/main/java/io/kestra/core/docs/AbstractClassDocumentation.java
+++ b/core/src/main/java/io/kestra/core/docs/AbstractClassDocumentation.java
@@ -112,6 +112,8 @@ public abstract class AbstractClassDocumentation<T> {
             Map<String, Object> finalValue = (Map<String, Object>) current.getValue();
             if (required.contains(current.getKey())) {
                 finalValue.put("$required", true);
+            } else {
+                finalValue.put("$required", false);
             }
 
             result.put(flattenKey(current.getKey(), parentName), finalValue);
diff --git a/ui/src/components/admin/Triggers.vue b/ui/src/components/admin/Triggers.vue
index 82ba13e6b..6a1c51375 100644
--- a/ui/src/components/admin/Triggers.vue
+++ b/ui/src/components/admin/Triggers.vue
@@ -24,7 +24,7 @@
                 </template>
                 <template #table>
                     <el-table
-                        :data="triggers"
+                        :data="triggersMerged"
                         ref="table"
                         :default-sort="{prop: 'flowId', order: 'ascending'}"
                         stripe
@@ -199,7 +199,9 @@
                 }).then(triggersData => {
                     this.triggers = triggersData.results;
                     this.total = triggersData.total;
-                    callback();
+                    if (callback) {
+                        callback();
+                    }
                 });
             },
             async unlock() {
@@ -225,6 +227,15 @@
                 this.triggerToUnlock = undefined;
             },
             setDisabled(trigger, value) {
+                if (trigger.codeDisabled) {
+                    this.$message({
+                        message: this.$t("triggerflow disabled"),
+                        type: "error",
+                        showClose: true,
+                        duration: 1500
+                    });
+                    return;
+                }
                 this.$store.dispatch("trigger/update", {...trigger, disabled: !value})
                     .then(_ => {
                         this.loadData();
@@ -237,6 +248,11 @@
                 return {
                     title: this.$t("triggers")
                 }
+            },
+            triggersMerged() {
+                return this.triggers.map(triggers => {
+                    return {...triggers.abstractTrigger, ...triggers.triggerContext, codeDisabled: triggers.abstractTrigger.disabled}
+                })
             }
         }
     };
diff --git a/ui/src/components/settings/BasicSettings.vue b/ui/src/components/settings/BasicSettings.vue
index b42d04cdf..10b6ade90 100644
--- a/ui/src/components/settings/BasicSettings.vue
+++ b/ui/src/components/settings/BasicSettings.vue
@@ -82,7 +82,7 @@
                 <el-checkbox :label="$t('Fold auto')" :model-value="autofoldTextEditor" @update:model-value="onAutofoldTextEditor" />
             </el-form-item>
 
-            <el-form-item :label="$t('Default namespace')">
+            <el-form-item :label="$t('Default namespace')" v-if="allowDefaultNamespace">
                 <namespace-select data-type="flow" :value="defaultNamespace" @update:model-value="onNamespaceSelect" />
             </el-form-item>
 
@@ -166,6 +166,12 @@
             LogLevelSelector,
             TopNavBar
         },
+        props: {
+            allowDefaultNamespace: {
+                type: Boolean,
+                default: true
+            }
+        },
         data() {
             return {
                 defaultNamespace: undefined,
diff --git a/ui/src/translations.json b/ui/src/translations.json
index 952edaec9..f2ea81353 100644
--- a/ui/src/translations.json
+++ b/ui/src/translations.json
@@ -269,6 +269,7 @@
     "trigger": "Trigger",
     "triggers": "Triggers",
     "trigger details": "Trigger details",
+    "triggerflow disabled": "Trigger is disabled from flow definition",
     "conditions": "conditions",
     "taskruns": "Task Runs",
     "Fold auto": "Editor: automatic fold of multilines",
@@ -649,8 +650,8 @@
     "new": "Nouveau",
     "edit": "Modifier",
     "edit flow": "Modifier le flow",
-    "executions": "Executions",
-    "execution": "Execution",
+    "executions": "Exécutions",
+    "execution": "Exécution",
     "details": "Détails",
     "overview": "Vue générale",
     "gantt": "Gantt",
@@ -660,8 +661,8 @@
     "queued duration": "Durée d'attente",
     "choose file": "Choisir un fichier ou le glisser le ici...",
     "launch execution": "Déclencher",
-    "warning flow with triggers": "Ce flow contient des déclencheurs, une execution manuelle peut ne pas fonctionner si celui-ci dépend de variables de déclencheurs !",
-    "triggered": "Execution déclenchée",
+    "warning flow with triggers": "Ce flow contient des déclencheurs, une exécution manuelle peut ne pas fonctionner si celui-ci dépend de variables de déclencheurs !",
+    "triggered": "Exécution déclenchée",
     "triggered done": "L'exécution <em>{name}</em> est déclenchée !",
     "topology": "Topologie",
     "date": "Date",
@@ -705,7 +706,7 @@
     "selected": "Sélectionné",
     "task": "Tâche",
     "task logs": "Journaux de la tâche",
-    "display flow {id} executions": "Voir les executions du flow {id}",
+    "display flow {id} executions": "Voir les exécutions du flow {id}",
     "actions": "Actions",
     "select datetime": "Sélectionner une date",
     "invalid field": "Champ invalide: {name}",
@@ -726,13 +727,13 @@
     "restarted": "L'exécution est relancée",
     "restart latest revision": "Relancer avec la dernière révision",
     "restart confirm": "Êtes-vous sur de vouloir relancer <code>{id}</code> ?",
-    "restart change revision": "Vous pouvez changer la révision qui sera utilisé pour la nouvelle execution relancée.",
+    "restart change revision": "Vous pouvez changer la révision qui sera utilisé pour la nouvelle exécution relancée.",
     "replay": "Rejouer",
     "replay from task tooltip": "Créer une nouvelle exécution similaire démarrant de la tâche <code>{taskId}</code>",
     "replay from beginning tooltip": "Créer une nouvelle exécution similaire depuis le début",
     "replay latest revision": "Rejouer avec la dernière révision",
-    "replayed": "Execution est rejouée",
-    "replay confirm": "Êtes-vous sur de vouloir relancer l'exécution <code>{id}</code> et créer une nouvelle execution ?",
+    "replayed": "Exécution rejouée",
+    "replay confirm": "Êtes-vous sur de vouloir relancer l'exécution <code>{id}</code> et créer une nouvelle exécution ?",
     "prefill inputs": "Pré-remplir",
     "current": "actuel",
     "change status": "Changer le statut",
@@ -783,9 +784,9 @@
     "key": "Clé",
     "value": "Valeur",
     "each value": "Valeur d'itération",
-    "current execution": "Execution en cours",
-    "parent execution": "Execution parente",
-    "original execution": "Execution d'origine",
+    "current execution": "Exécution en cours",
+    "parent execution": "Exécution parente",
+    "original execution": "Exécution d'origine",
     "automatic refresh": "rafraîchissement automatique",
     "toggle periodic refresh each 10 seconds": "Activer le rafraîchissement toutes les 10 secondes",
     "trigger refresh": "Déclencher le rafraîchissement",
@@ -848,7 +849,7 @@
       "please": "Veuillez choisir une tache sur la droite afin de voir sa documentation"
     },
     "last X days count": "{count} lors des {days} derniers jours",
-    "last execution date": "Dernière execution",
+    "last execution date": "Dernière exécution",
     "last execution status": "Dernier statut",
     "date range count": "{count} entre le {startDate} et le {endDate}",
     "date count": "{count} le {date}",
@@ -865,8 +866,9 @@
     "trigger": "Déclencheur",
     "triggers": "Déclencheurs",
     "trigger details": "Détails du déclencheur",
+    "triggerflow disabled": "Déclencheur désactivé depuis la définition du flow",
     "conditions": "conditions",
-    "taskruns": "Execution de tâches",
+    "taskruns": "Exécution de tâches",
     "Fold auto": "Éditeur : Replier automatiquement le contenu multi-lignes",
     "Fold content lines": "Replier les multilignes",
     "Unfold content lines": "Déplier les multilignes",
@@ -909,10 +911,10 @@
     },
     "delete execution running": "<div class=\"alert alert-warning mt-2 mb-0\">Cette exécution est en cours, l'effacer ne stoppera pas celle-ci.<br />Vous devez l'arrêter auparavant!</div>",
     "restore": "Restaurer",
-    "bulk delete": "Êtes-vous sur de vouloir supprimer <code>{executionCount}</code> execution(s) ?",
-    "bulk resume": "Êtes-vous sur de vouloir reprendre <code>{executionCount}</code> execution(s)?",
-    "bulk restart": "Êtes-vous sur de vouloir redémarrer <code>{executionCount}</code> execution(s) ?",
-    "bulk kill": "Êtes-vous sur de vouloir arrêter <code>{executionCount}</code> execution(s) ?",
+    "bulk delete": "Êtes-vous sur de vouloir supprimer <code>{executionCount}</code> exécution(s) ?",
+    "bulk resume": "Êtes-vous sur de vouloir reprendre <code>{executionCount}</code> exécution(s)?",
+    "bulk restart": "Êtes-vous sur de vouloir redémarrer <code>{executionCount}</code> exécution(s) ?",
+    "bulk kill": "Êtes-vous sur de vouloir arrêter <code>{executionCount}</code> exécution(s) ?",
     "selection": {
       "selected": "<strong>{count}</strong> sélectionnés",
       "all": "Tous sélectionnés ({count})"
@@ -938,10 +940,10 @@
     "invalid bulk restart": "Impossible de redémarrer les exécutions",
     "invalid bulk kill": "Impossible d'arrêter les exécutions",
     "invalid bulk delete": "Impossible de supprimer les exécutions",
-    "execution not found": "Execution <code>{executionId}</code> non trouvée",
-    "execution not in state PAUSED": "Execution <code>{executionId}</code> n'est pas dans l'état PAUSED",
-    "execution not in state FAILED": "Execution <code>{executionId}</code> n'est pas dans l'état FAILED",
-    "execution already finished": "Execution <code>{executionId}</code> déjà terminée",
+    "execution not found": "Exécution <code>{executionId}</code> non trouvée",
+    "execution not in state PAUSED": "Exécution <code>{executionId}</code> n'est pas dans l'état PAUSED",
+    "execution not in state FAILED": "Exécution <code>{executionId}</code> n'est pas dans l'état FAILED",
+    "execution already finished": "Exécution <code>{executionId}</code> déjà terminée",
     "restore revision": "Êtes-vous sur de vouloir restaurer la revision <code>{revision}</code> ?",
     "seeing old revision": "Vous visualisez une ancienne revision : {revision}",
     "export": "Exporter",
@@ -1012,7 +1014,7 @@
       },
       "step10": {
         "title": "Maintenant exécuter votre flow!",
-        "content": "Vous pouvez maintenant exécuter votre flow en cliquant sur le bouton Nouvelle Execution."
+        "content": "Vous pouvez maintenant exécuter votre flow en cliquant sur le bouton Nouvelle Exécution."
       },
       "step11": {
         "title": "Insérer les inputs",
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/TriggerController.java b/webserver/src/main/java/io/kestra/webserver/controllers/TriggerController.java
index d195c8fbb..730ee01c1 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/TriggerController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/TriggerController.java
@@ -7,6 +7,7 @@ import io.kestra.core.models.triggers.PollingTriggerInterface;
 import io.kestra.core.models.triggers.Trigger;
 import io.kestra.core.models.triggers.TriggerContext;
 import io.kestra.core.queues.QueueInterface;
+import io.kestra.core.repositories.ArrayListTotal;
 import io.kestra.core.repositories.FlowRepositoryInterface;
 import io.kestra.core.repositories.TriggerRepositoryInterface;
 import io.kestra.core.runners.RunContext;
@@ -25,12 +26,17 @@ import io.micronaut.scheduling.annotation.ExecuteOn;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Parameter;
 import jakarta.inject.Inject;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
 
 import java.time.ZonedDateTime;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 
 @Controller("/api/v1/triggers")
+@Slf4j
 public class TriggerController {
     @Inject
     private TriggerRepositoryInterface triggerRepository;
@@ -45,28 +51,57 @@ public class TriggerController {
     private TenantService tenantService;
 
     @Inject
-    private  RunContextFactory runContextFactory;
+    private RunContextFactory runContextFactory;
 
     @Inject
-    private  ConditionService conditionService;
+    private ConditionService conditionService;
 
     @ExecuteOn(TaskExecutors.IO)
     @Get(uri = "/search")
     @Operation(tags = {"Triggers"}, summary = "Search for triggers")
-    public PagedResults<Trigger> search(
+    public PagedResults<Triggers> search(
         @Parameter(description = "The current page") @QueryValue(defaultValue = "1") int page,
         @Parameter(description = "The current page size") @QueryValue(defaultValue = "10") int size,
         @Parameter(description = "The sort of current page") @Nullable @QueryValue List<String> sort,
         @Parameter(description = "A string filter") @Nullable @QueryValue(value = "q") String query,
         @Parameter(description = "A namespace filter prefix") @Nullable @QueryValue String namespace
     ) throws HttpStatusException {
-        return PagedResults.of(triggerRepository.find(
+
+        ArrayListTotal<Trigger> triggerContexts = triggerRepository.find(
             PageableUtils.from(page, size, sort, triggerRepository.sortMapping()),
             query,
             tenantService.resolveTenant(),
             namespace,
             null
-        ));
+        );
+
+        List<Triggers> triggers = new ArrayList<>();
+        triggerContexts.forEach(tc -> {
+            Optional<Flow> flow = flowRepository.findById(tc.getTenantId(), tc.getNamespace(), tc.getFlowId());
+            if (flow.isEmpty()) {
+                // Warn instead of throwing to avoid blocking the trigger UI
+                log.warn(String.format("Flow %s not found for trigger %s", tc.getFlowId(), tc.getTriggerId()));
+            }
+
+            AbstractTrigger abstractTrigger = flow.get().getTriggers().stream().filter(t -> t.getId().equals(tc.getTriggerId())).findFirst().orElse(null);
+            if (abstractTrigger == null) {
+                // Warn instead of throwing to avoid blocking the trigger UI
+                log.warn(String.format("Flow %s has no trigger %s", tc.getFlowId(), tc.getTriggerId()));
+            }
+
+            triggers.add(Triggers.builder()
+                .abstractTrigger(abstractTrigger)
+                .triggerContext(tc)
+                .build());
+        });
+        return PagedResults.of(new ArrayListTotal<>(triggers, triggerContexts.getTotal()));
+    }
+
+    @Builder
+    @Getter
+    public static class Triggers {
+        AbstractTrigger abstractTrigger;
+        TriggerContext triggerContext;
     }
 
     @ExecuteOn(TaskExecutors.IO)


Test patch:
diff --git a/cli/src/test/java/io/kestra/cli/commands/plugins/PluginDocCommandTest.java b/cli/src/test/java/io/kestra/cli/commands/plugins/PluginDocCommandTest.java
index 5c2af967b..d18fd242c 100644
--- a/cli/src/test/java/io/kestra/cli/commands/plugins/PluginDocCommandTest.java
+++ b/cli/src/test/java/io/kestra/cli/commands/plugins/PluginDocCommandTest.java
@@ -99,7 +99,7 @@ class PluginDocCommandTest {
                 ### `example`
                 * **Type:** ==string==
                 * **Dynamic:** ✔️
-                * **Required:** ❓
+                * **Required:** ❌
 
                 > Example interface
                 """));
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/TriggerControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/TriggerControllerTest.java
index ff8706645..fad22f7c4 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/TriggerControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/TriggerControllerTest.java
@@ -71,27 +71,30 @@ class TriggerControllerTest extends JdbcH2ControllerTest {
         String triggerFlowId = "schedule-trigger-search";
         String triggerNamespace = "io.kestra.tests.schedule";
 
+        Flow flow = generateFlow(triggerFlowId);
+        jdbcFlowRepository.create(flow, flow.generateSource(), flow);
+
         Trigger trigger = Trigger.builder()
             .flowId(triggerFlowId)
             .namespace(triggerNamespace)
-            .triggerId("schedule-every-min")
+            .triggerId("trigger-nextexec-schedule")
             .date(ZonedDateTime.now())
             .build();
 
         jdbcTriggerRepository.save(trigger);
-        jdbcTriggerRepository.save(trigger.toBuilder().triggerId("schedule-5-min").build());
+        jdbcTriggerRepository.save(trigger.toBuilder().triggerId("trigger-nextexec-polling").build());
 
-        PagedResults<Trigger> triggers = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/triggers/search?q=schedule-trigger-search&namespace=io.kestra.tests&sort=triggerId:asc"), Argument.of(PagedResults.class, Trigger.class));
+        PagedResults<TriggerController.Triggers> triggers = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/triggers/search?q=schedule-trigger-search&namespace=io.kestra.tests&sort=triggerId:asc"), Argument.of(PagedResults.class, TriggerController.Triggers.class));
         assertThat(triggers.getTotal(), greaterThanOrEqualTo(2L));
 
-        assertThat(triggers.getResults(), Matchers.hasItems(
+        assertThat(triggers.getResults().stream().map(TriggerController.Triggers::getTriggerContext).toList(), Matchers.hasItems(
                 allOf(
-                    hasProperty("triggerId", is("schedule-every-min")),
+                    hasProperty("triggerId", is("trigger-nextexec-schedule")),
                     hasProperty("namespace", is(triggerNamespace)),
                     hasProperty("flowId", is(triggerFlowId))
                 ),
                 allOf(
-                    hasProperty("triggerId", is("schedule-5-min")),
+                    hasProperty("triggerId", is("trigger-nextexec-polling")),
                     hasProperty("namespace", is(triggerNamespace)),
                     hasProperty("flowId", is(triggerFlowId))
                 )
@@ -183,15 +186,15 @@ class TriggerControllerTest extends JdbcH2ControllerTest {
             Duration.ofMillis(100),
             Duration.ofMinutes(2)
         );
-        PagedResults<Trigger> triggers = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/triggers/search?q=trigger-nextexec"), Argument.of(PagedResults.class, Trigger.class));
-        assertThat(triggers.getResults().get(0).getNextExecutionDate(), notNullValue());
-        assertThat(triggers.getResults().get(1).getNextExecutionDate(), notNullValue());
+        PagedResults<TriggerController.Triggers> triggers = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/triggers/search?q=trigger-nextexec"), Argument.of(PagedResults.class, TriggerController.Triggers.class));
+        assertThat(triggers.getResults().get(0).getTriggerContext().getNextExecutionDate(), notNullValue());
+        assertThat(triggers.getResults().get(1).getTriggerContext().getNextExecutionDate(), notNullValue());
     }
 
     private Flow generateFlow(String flowId) {
         return Flow.builder()
             .id(flowId)
-            .namespace("io.kestra.tests.scheduler")
+            .namespace("io.kestra.tests.schedule")
             .tasks(Collections.singletonList(Return.builder()
                 .id("task")
                 .type(Return.class.getName())
@@ -210,4 +213,6 @@ class TriggerControllerTest extends JdbcH2ControllerTest {
             ))
             .build();
     }
+
+
 }