Repository: kestra-io/kestra (java)

Issue:
Add Inputs, Outputs, Trigger and Variables autocompletion 
### Feature description

Providing autocompletion after the following components would significantly improve the UX of writing flows and help onboard new users to the product:
- [ ] `{{ inputs.[list available inputs user can select from] }}`
- [ ] `{{ outputs.[list available tasks to select from] }}`
- [ ] `{{ outputs.mytask.[list available outputs of that task] }}`
- [ ] `{{ vars.[list available variables] }}`
- [ ] `{{ trigger.[list available trigger variables] }}`

Pull request:
feat/autocompletion-in-flow
closes #3331

Patch:
diff --git a/ui/src/components/executions/Gantt.vue b/ui/src/components/executions/Gantt.vue
index f1b33b711..7d883a60e 100644
--- a/ui/src/components/executions/Gantt.vue
+++ b/ui/src/components/executions/Gantt.vue
@@ -35,7 +35,7 @@
                             <div
                                 :style="{left: serie.start + '%', width: serie.width + '%'}"
                                 class="task-progress"
-                                @click="onTaskSelect(serie.task)"
+                                @click="onTaskSelect(serie.id)"
                             >
                                 <div class="progress">
                                     <div
@@ -58,7 +58,7 @@
                             @follow="forwardEvent('follow', $event)"
                             :target-execution="execution"
                             :target-flow="flow"
-                            :show-logs="taskTypeByTaskRunId[serie.task.id] !== 'io.kestra.core.tasks.flows.ForEachItem'"
+                            :show-logs="taskTypeByTaskRunId[serie.id] !== 'io.kestra.core.tasks.flows.ForEachItem'"
                         />
                     </td>
                 </tr>
@@ -294,13 +294,13 @@
                 }
                 this.dates = dates;
             },
-            onTaskSelect(taskRun) {
-                if(this.selectedTaskRuns.includes(taskRun.id)) {
-                    this.selectedTaskRuns = this.selectedTaskRuns.filter(id => id !== taskRun.id);
+            onTaskSelect(taskRunId) {
+                if(this.selectedTaskRuns.includes(taskRunId)) {
+                    this.selectedTaskRuns = this.selectedTaskRuns.filter(id => id !== taskRunId);
                     return
                 }
 
-                this.selectedTaskRuns.push(taskRun.id);
+                this.selectedTaskRuns.push(taskRunId);
             },
             stopRealTime() {
                 this.realTime = false
diff --git a/ui/src/components/flows/TriggerAvatar.vue b/ui/src/components/flows/TriggerAvatar.vue
index 890a9bed1..ef6f4cce6 100644
--- a/ui/src/components/flows/TriggerAvatar.vue
+++ b/ui/src/components/flows/TriggerAvatar.vue
@@ -16,7 +16,7 @@
                         </el-button>
                     </template>
                     <template #default>
-                        <trigger-vars :data="trigger" :execution="execution"/>
+                        <trigger-vars :data="trigger" :execution="execution" />
                     </template>
                 </el-popover>
             </template>
diff --git a/ui/src/components/inputs/EditorView.vue b/ui/src/components/inputs/EditorView.vue
index e14ebd0c4..4f90bb705 100644
--- a/ui/src/components/inputs/EditorView.vue
+++ b/ui/src/components/inputs/EditorView.vue
@@ -355,18 +355,11 @@
     const updatePluginDocumentation = (event) => {
         const taskType = yamlUtils.getTaskType(event.model.getValue(), event.position)
         const pluginSingleList = store.getters["plugin/getPluginSingleList"];
-        const pluginsDocumentation = store.getters["plugin/getPluginsDocumentation"];
         if (taskType && pluginSingleList && pluginSingleList.includes(taskType)) {
-            if (!pluginsDocumentation[taskType]) {
-                store
-                    .dispatch("plugin/load", {cls: taskType})
-                    .then(plugin => {
-                        store.commit("plugin/setPluginsDocumentation", {...pluginsDocumentation, [taskType]: plugin});
-                        store.commit("plugin/setEditorPlugin", plugin);
-                    });
-            } else if (pluginsDocumentation[taskType]) {
-                store.commit("plugin/setEditorPlugin", pluginsDocumentation[taskType]);
-            }
+            store.dispatch("plugin/load", {cls: taskType})
+                .then(plugin => {
+                    store.commit("plugin/setEditorPlugin", plugin);
+                });
         } else {
             store.commit("plugin/setEditorPlugin", undefined);
         }
@@ -809,7 +802,7 @@
             ref="editorDomElement"
             v-if="combinedEditor || viewType === editorViewTypes.SOURCE"
             :class="combinedEditor ? 'editor-combined' : ''"
-            :style="combinedEditor ? {'flex-basis': leftEditorWidth, 'flex-grow': 0} : {}"
+            :style="combinedEditor ? {'flex': '0 0 ' + leftEditorWidth} : {}"
             @save="save"
             @execute="execute"
             v-model="flowYaml"
diff --git a/ui/src/components/inputs/MonacoEditor.vue b/ui/src/components/inputs/MonacoEditor.vue
index d35f5aed4..308a63cf7 100644
--- a/ui/src/components/inputs/MonacoEditor.vue
+++ b/ui/src/components/inputs/MonacoEditor.vue
@@ -16,6 +16,8 @@
     import {configureMonacoYaml} from "monaco-yaml";
     import {yamlSchemas} from "override/utils/yamlSchemas"
     import Utils from "../../utils/utils";
+    import YamlUtils from "../../utils/yamlUtils";
+    import {uniqBy} from "lodash";
 
     window.MonacoEnvironment = {
         getWorker(moduleId, label) {
@@ -79,7 +81,7 @@
         watch: {
             options: {
                 deep: true,
-                handler: function(newValue, oldValue) {
+                handler: function (newValue, oldValue) {
                     if (this.editor && this.needReload(newValue, oldValue)) {
                         this.reload();
                     } else {
@@ -87,7 +89,7 @@
                     }
                 }
             },
-            value: function(newValue) {
+            value: function (newValue) {
                 if (this.editor) {
                     let editor = this.getModifiedEditor();
 
@@ -96,7 +98,7 @@
                     }
                 }
             },
-            original: function(newValue) {
+            original: function (newValue) {
                 if (this.editor && this.diffEditor) {
                     let editor = this.getOriginalEditor();
 
@@ -105,19 +107,19 @@
                     }
                 }
             },
-            language: function(newVal) {
+            language: function (newVal) {
                 if (this.editor) {
                     let editor = this.getModifiedEditor();
                     this.monaco.editor.setModelLanguage(editor.getModel(), newVal);
                 }
             },
-            theme: function(newVal) {
+            theme: function (newVal) {
                 if (this.editor) {
                     this.monaco.editor.setTheme(newVal);
                 }
             }
         },
-        mounted: function() {
+        mounted: function () {
             let _this = this;
 
             this.monaco = monaco;
@@ -133,12 +135,132 @@
                 format: true,
                 schemas: yamlSchemas(this.$store)
             });
+
+            this.autocompletionProvider = this.monaco.languages.registerCompletionItemProvider("yaml", {
+                triggerCharacters: ["."],
+                async provideCompletionItems(model, position) {
+                    const lineContent = model.getValueInRange({
+                        startLineNumber: position.lineNumber,
+                        startColumn: 1,
+                        endLineNumber: position.lineNumber,
+                        endColumn: model.getLineMaxColumn(position.lineNumber)
+                    });
+                    const tillCursorContent = lineContent.substring(0, position.column - 1);
+                    const match = tillCursorContent.match(/( *([^{ ]*)\.)([^.} ]*)$/);
+                    if (!match) {
+                        return {suggestions: []};
+                    }
+
+                    let nextDotIndex;
+                    // We're at the end of the line
+                    if (lineContent.length === tillCursorContent.length) {
+                        nextDotIndex = tillCursorContent.length;
+                    } else {
+                        const remainingLineText = lineContent.substring(tillCursorContent.length);
+                        const nextDotMatcher = remainingLineText.match(/[ .}]/);
+                        if (!nextDotMatcher) {
+                            nextDotIndex = lineContent.length - 1;
+                        } else {
+                            nextDotIndex = tillCursorContent.length + nextDotMatcher.index;
+                        }
+                    }
+
+                    const indexOfFieldToComplete = match.index + match[1].length;
+                    return {
+                        suggestions: await _this.autocompletion(
+                            model.getValue(),
+                            lineContent,
+                            match[2],
+                            match[3],
+                            position.lineNumber,
+                            [indexOfFieldToComplete, nextDotIndex]
+                        )
+                    };
+                }
+            })
         },
-        beforeUnmount: function() {
+        beforeUnmount: function () {
             this.destroy();
         },
         methods: {
-            initMonaco: function(monaco) {
+            async autocompletion(source, lineContent, field, rest, lineNumber, fieldToCompleteIndexes) {
+                const flowAsJs = YamlUtils.parse(source);
+                let autocompletions;
+                switch (field) {
+                case "inputs":
+                    autocompletions = flowAsJs?.inputs?.map(input => input.id);
+                    break;
+                case "outputs":
+                    autocompletions = flowAsJs?.tasks?.map(task => task.id);
+                    break;
+                case "labels":
+                    autocompletions =  Object.keys(flowAsJs?.labels ?? {});
+                    break;
+                case "flow":
+                    autocompletions = ["id", "namespace", "revision"]
+                    break;
+                case "execution":
+                    autocompletions = ["id", "startDate", "originalId"]
+                    break;
+                case "vars":
+                    autocompletions = Object.keys(flowAsJs?.variables ?? {});
+                    break;
+                case "trigger":
+                    autocompletions = await this.triggerVars(flowAsJs);
+                    break;
+                default: {
+                    let match = field.match(/^outputs\.([^.]+)$/);
+                    if (match) {
+                        autocompletions = await this.outputsFor(match[1], flowAsJs);
+                    }
+                }
+                }
+
+                return autocompletions?.filter(autocomplete => rest ? autocomplete.startsWith(rest) : true)
+                    ?.map(value => {
+                        let endColumn = fieldToCompleteIndexes[1] + 1;
+                        const endsWithDot = value.endsWith(".");
+                        if (endsWithDot && lineContent.at(endColumn - 1) === ".") {
+                            endColumn++;
+                        }
+                        return {
+                            kind: monaco.languages.CompletionItemKind.Field,
+                            label: endsWithDot ? value.substring(0, value.length - 1) : value,
+                            insertText: value,
+                            range: {
+                                startLineNumber: lineNumber,
+                                endLineNumber: lineNumber,
+                                startColumn: fieldToCompleteIndexes[0] + 1,
+                                endColumn: endColumn
+                            }
+                        }
+                    }) ?? [];
+            },
+            async outputsFor(taskId, flowAsJs) {
+                const task = flowAsJs?.tasks?.find(task => task.id === taskId);
+                if (!task?.type) {
+                    return [];
+                }
+
+                const pluginDoc = await this.$store.dispatch("plugin/load", {cls: task.type, commit: false});
+
+                return Object.entries(pluginDoc?.schema?.outputs?.properties ?? {})
+                    .map(([propName, propInfo]) => propName + (propInfo.type === "object" ? "." : ""));
+            },
+            async triggerVars(flowAsJs) {
+                const fetchTriggerVarsByType = await Promise.all(
+                    uniqBy(flowAsJs?.triggers?.map(trigger => trigger.type))
+                        .map(async triggerType => {
+                            const triggerDoc = await this.$store.dispatch("plugin/load", {
+                                cls: triggerType,
+                                commit: false
+                            });
+                            return Object.keys(triggerDoc?.schema?.outputs?.properties ?? {});
+                        })
+                );
+                return uniqBy(fetchTriggerVarsByType.flat());
+            },
+            initMonaco: function (monaco) {
                 let self = this;
 
                 this.$emit("editorWillMount", this.monaco);
@@ -170,7 +292,7 @@
                     }
 
                     monaco.editor.addKeybindingRule({
-                        keybinding:  monaco.KeyMod.CtrlCmd | monaco.KeyCode.Space,
+                        keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.Space,
                         command: "editor.action.triggerSuggest"
                     })
 
@@ -188,26 +310,27 @@
                 });
                 this.$emit("editorDidMount", this.editor);
             },
-            getEditor: function() {
+            getEditor: function () {
                 return this.editor;
             },
-            getModifiedEditor: function() {
+            getModifiedEditor: function () {
                 return this.diffEditor ? this.editor.getModifiedEditor() : this.editor;
             },
-            getOriginalEditor: function() {
+            getOriginalEditor: function () {
                 return this.diffEditor ? this.editor.getOriginalEditor() : this.editor;
             },
-            focus: function() {
+            focus: function () {
                 this.editor.focus();
             },
-            destroy: function() {
+            destroy: function () {
                 this.monacoYaml?.dispose();
+                this.autocompletionProvider?.dispose();
                 this.editor?.dispose();
             },
-            needReload: function(newValue, oldValue) {
+            needReload: function (newValue, oldValue) {
                 return oldValue.renderSideBySide !== newValue.renderSideBySide;
             },
-            reload: function() {
+            reload: function () {
                 this.destroy();
                 this.initMonaco(this.monaco);
             },
diff --git a/ui/src/mixins/flowTemplateEdit.js b/ui/src/mixins/flowTemplateEdit.js
index 92f67153e..82647ce4c 100644
--- a/ui/src/mixins/flowTemplateEdit.js
+++ b/ui/src/mixins/flowTemplateEdit.js
@@ -263,16 +263,10 @@ export default {
         updatePluginDocumentation(event) {
             const taskType = yamlUtils.getTaskType(event.model.getValue(), event.position)
             if (taskType && this.pluginSingleList.includes(taskType)) {
-                if (!this.pluginsDocumentation[taskType]) {
-                    this.$store
-                        .dispatch("plugin/load", {cls: taskType})
-                        .then(plugin => {
-                            this.$store.commit("plugin/setPluginsDocumentation", {...this.pluginsDocumentation, [taskType]: plugin});
-                            this.$store.commit("plugin/setEditorPlugin", plugin);
-                        });
-                } else if (this.pluginsDocumentation[taskType]) {
-                    this.$store.commit("plugin/setEditorPlugin", this.pluginsDocumentation[taskType]);
-                }
+                this.$store.dispatch("plugin/load", {cls: taskType})
+                    .then(plugin => {
+                        this.$store.commit("plugin/setEditorPlugin", plugin);
+                    });
             } else {
                 this.$store.commit("plugin/setEditorPlugin", undefined);
             }
diff --git a/ui/src/stores/plugins.js b/ui/src/stores/plugins.js
index 9fbee91e8..a99e44c42 100644
--- a/ui/src/stores/plugins.js
+++ b/ui/src/stores/plugins.js
@@ -21,17 +21,29 @@ export default {
                 return response.data;
             })
         },
-        load({commit}, options) {
+        load({commit, state}, options) {
             if (options.cls === undefined) {
                 throw new Error("missing required cls");
             }
 
+            const cachedPluginDoc = state.pluginsDocumentation[options.cls];
+            if (!options.all && cachedPluginDoc) {
+                return Promise.resolve(cachedPluginDoc);
+            }
+
             return this.$http.get(`${apiUrl(this)}/plugins/${options.cls}`, {params: options}).then(response => {
-                if (options.all === true) {
-                    commit("setPluginAllProps", response.data)
-                } else {
-                    commit("setPlugin", response.data)
+                if (options.commit !== false) {
+                    if (options.all === true) {
+                        commit("setPluginAllProps", response.data);
+                    } else {
+                        commit("setPlugin", response.data);
+                    }
                 }
+
+                if (!options.all) {
+                    commit("addPluginDocumentation", {[options.cls]: response.data});
+                }
+
                 return response.data;
             })
         },
@@ -100,6 +112,9 @@ export default {
         setPluginsDocumentation(state, pluginsDocumentation) {
             state.pluginsDocumentation = pluginsDocumentation
         },
+        addPluginDocumentation(state, pluginDocumentation) {
+            state.pluginsDocumentation = {...state.pluginsDocumentation, ...pluginDocumentation}
+        },
         setEditorPlugin(state, editorPlugin) {
             state.editorPlugin = editorPlugin
         },


Test patch:
diff --git a/core/src/test/java/io/kestra/core/models/tasks/runners/AbstractTaskRunnerTest.java b/core/src/test/java/io/kestra/core/models/tasks/runners/AbstractTaskRunnerTest.java
index 15c018a4b..d320bfdf3 100644
--- a/core/src/test/java/io/kestra/core/models/tasks/runners/AbstractTaskRunnerTest.java
+++ b/core/src/test/java/io/kestra/core/models/tasks/runners/AbstractTaskRunnerTest.java
@@ -51,10 +51,15 @@ public abstract class AbstractTaskRunnerTest {
         var commands = initScriptCommands(runContext);
         Mockito.when(commands.getEnableOutputDirectory()).thenReturn(false);
         Mockito.when(commands.outputDirectoryEnabled()).thenReturn(false);
+        Mockito.when(commands.getCommands()).thenReturn(ScriptService.scriptCommands(List.of("/bin/sh", "-c"), Collections.emptyList(), List.of("echo 'Hello World'")));
 
         var taskRunner = taskRunner();
         assertThat(taskRunner.additionalVars(runContext, commands).containsKey(ScriptService.VAR_OUTPUT_DIR), is(false));
         assertThat(taskRunner.env(runContext, commands).containsKey(ScriptService.ENV_OUTPUT_DIR), is(false));
+
+        var result = taskRunner.run(runContext, commands, Collections.emptyList(), Collections.emptyList());
+        assertThat(result, notNullValue());
+        assertThat(result.getExitCode(), is(0));
     }
 
     @Test