From 871a0ef3c4ee0cf47adf4dc52f315b3ddfac5617 Mon Sep 17 00:00:00 2001 From: "brian.mulier" Date: Thu, 18 Apr 2024 14:50:22 +0200 Subject: [PATCH] feat(ui): add autocompletion & dropdown for subflows part of #2473 --- .../repositories/FlowRepositoryInterface.java | 12 +++ ui/src/App.vue | 3 +- ui/src/components/flows/FlowRun.vue | 2 +- ui/src/components/flows/TriggerVars.vue | 1 - ui/src/components/flows/tasks/Task.js | 10 +- ui/src/components/flows/tasks/TaskFlowId.vue | 43 ++++++++ .../components/flows/tasks/TaskNamespace.vue | 26 +++++ ui/src/components/flows/tasks/TaskObject.vue | 5 +- ui/src/components/inputs/MonacoEditor.vue | 99 +++++++++++++++++-- .../components/namespace/NamespaceSelect.vue | 6 +- ui/src/stores/flow.js | 5 + ui/src/stores/namespaces.js | 10 +- ui/src/utils/init.js | 4 + ui/src/utils/yamlUtils.js | 40 +++++--- .../controllers/api/FlowController.java | 6 +- 15 files changed, 235 insertions(+), 37 deletions(-) create mode 100644 ui/src/components/flows/tasks/TaskFlowId.vue create mode 100644 ui/src/components/flows/tasks/TaskNamespace.vue diff --git a/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java b/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java index e70b6c5fa8..b5fd29a903 100644 --- a/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java +++ b/core/src/main/java/io/kestra/core/repositories/FlowRepositoryInterface.java @@ -107,6 +107,18 @@ List findWithSource( List findDistinctNamespace(String tenantId); + default List findDistinctNamespace(String tenantId, String prefix) { + List distinctNamespaces = this.findDistinctNamespace(tenantId); + + if (prefix == null) { + return distinctNamespaces; + } + + return distinctNamespaces.stream() + .filter(n -> n.startsWith(prefix)) + .toList(); + } + FlowWithSource create(Flow flow, String flowSource, Flow flowWithDefaults); FlowWithSource update(Flow flow, Flow previous, String flowSource, Flow flowWithDefaults) throws ConstraintViolationException; diff --git a/ui/src/App.vue b/ui/src/App.vue index 683e28584e..88d77bb14f 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -175,8 +175,7 @@ surveyVisible = false; }) - window.addEventListener("KestraRouterAfterEach", (event) => { - console.log(event) + window.addEventListener("KestraRouterAfterEach", () => { if (surveyVisible) { window.dispatchEvent(new Event("PHSurveyClosed")) surveyVisible = false; diff --git a/ui/src/components/flows/FlowRun.vue b/ui/src/components/flows/FlowRun.vue index edd77a2e52..4c073cae7a 100644 --- a/ui/src/components/flows/FlowRun.vue +++ b/ui/src/components/flows/FlowRun.vue @@ -28,7 +28,7 @@ v-model="inputs[input.id]" > + + + + + diff --git a/ui/src/components/flows/tasks/TaskNamespace.vue b/ui/src/components/flows/tasks/TaskNamespace.vue new file mode 100644 index 0000000000..dffeaa9260 --- /dev/null +++ b/ui/src/components/flows/tasks/TaskNamespace.vue @@ -0,0 +1,26 @@ + + diff --git a/ui/src/components/flows/tasks/TaskObject.vue b/ui/src/components/flows/tasks/TaskObject.vue index e8eb239a83..3cb1ead835 100644 --- a/ui/src/components/flows/tasks/TaskObject.vue +++ b/ui/src/components/flows/tasks/TaskObject.vue @@ -18,14 +18,15 @@ - {{ getType(schema, key) }} + {{ getType(schema) }} 0) { + filteredNamespaces = filteredNamespaces.filter(n => n.startsWith(match[3])); + } + return { + suggestions: filteredNamespaces.map(namespace => ({ + kind: monaco.languages.CompletionItemKind.Value, + label: namespace, + insertText: (match[2].length > 0 ? "" : " ") + namespace, + range: { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: indexOfFieldToComplete + 1, + endColumn: YamlUtils.nextDelimiterIndex(lineContent, position.column - 1) + } + })) + }; + } + + match = tillCursorContent.match(/^( *flowId:( *))(.*)$/); + if (!match) { + return {suggestions: []}; + } + + indexOfFieldToComplete = match.index + match[1].length; + + const source = model.getValue(); + const namespacesWithRange = YamlUtils.extractFieldFromMaps(source, "namespace").reverse(); + const namespace = namespacesWithRange.find(namespaceWithRange => { + const range = namespaceWithRange.range; + return range[0] < position.offset < range[2]; + })?.namespace; + if (namespace === undefined) { + return {suggestions: []}; + } + + const flowAsJs = YamlUtils.parse(source); + let flowIds = (await _this.$store.dispatch("flow/flowsByNamespace", namespace)) + .map(flow => flow.id) + if (match[3].length > 0) { + flowIds = flowIds.filter(flowId => flowId.startsWith(match[3])); + } + if (flowAsJs?.id) { + flowIds = flowIds.filter(flowId => flowId !== flowAsJs?.id); + } + + return { + suggestions: flowIds.map(flowId => ({ + kind: monaco.languages.CompletionItemKind.Value, + label: flowId, + insertText: (match[2].length > 0 ? "" : " ") + flowId, + range: { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: indexOfFieldToComplete + 1, + endColumn: YamlUtils.nextDelimiterIndex(lineContent, position.column - 1) + } + })) + }; + } + }) + + this.nestedFieldAutocompletionProvider = 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 lineContent = _this.lineContent(model, position); const tillCursorContent = lineContent.substring(0, position.column - 1); const match = tillCursorContent.match(/( *([^{ ]*)\.)([^.} ]*)$/); if (!match) { @@ -183,6 +257,14 @@ this.destroy(); }, methods: { + lineContent(model, position) { + return model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: model.getLineMaxColumn(position.lineNumber) + }); + }, async autocompletion(source, lineContent, field, rest, lineNumber, fieldToCompleteIndexes) { const flowAsJs = YamlUtils.parse(source); let autocompletions; @@ -324,7 +406,8 @@ }, destroy: function () { this.monacoYaml?.dispose(); - this.autocompletionProvider?.dispose(); + this.subflowAutocompletionProvider?.dispose(); + this.nestedFieldAutocompletionProvider?.dispose(); this.editor?.dispose(); }, needReload: function (newValue, oldValue) { diff --git a/ui/src/components/namespace/NamespaceSelect.vue b/ui/src/components/namespace/NamespaceSelect.vue index 844f383fef..f0dc4910a8 100644 --- a/ui/src/components/namespace/NamespaceSelect.vue +++ b/ui/src/components/namespace/NamespaceSelect.vue @@ -44,13 +44,13 @@ emits: ["update:modelValue"], created() { this.$store - .dispatch("namespace/loadNamespaces", {dataType: this.dataType}) + .dispatch("namespace/loadNamespacesForDatatype", {dataType: this.dataType}) .then(() => { - this.groupedNamespaces = this.groupNamespaces(this.namespaces); + this.groupedNamespaces = this.groupNamespaces(this.datatypeNamespaces); }); }, computed: { - ...mapState("namespace", ["namespaces"]) + ...mapState("namespace", ["datatypeNamespaces"]) }, data() { return { diff --git a/ui/src/stores/flow.js b/ui/src/stores/flow.js index 57482ab2c7..ced6172a43 100644 --- a/ui/src/stores/flow.js +++ b/ui/src/stores/flow.js @@ -54,6 +54,11 @@ export default { return response.data; }) }, + flowsByNamespace(_, namespace) { + return this.$http.get(`${apiUrl(this)}/flows/${namespace}`).then(response => { + return response.data; + }) + }, loadFlow({commit}, options) { return this.$http.get(`${apiUrl(this)}/flows/${options.namespace}/${options.id}?source=true`, { diff --git a/ui/src/stores/namespaces.js b/ui/src/stores/namespaces.js index a68be06143..5d8894ddd8 100644 --- a/ui/src/stores/namespaces.js +++ b/ui/src/stores/namespaces.js @@ -4,13 +4,13 @@ import Utils from "../utils/utils"; export default { namespaced: true, state: { - namespaces: undefined, + datatypeNamespaces: undefined, }, actions: { - loadNamespaces({commit}, options) { + loadNamespacesForDatatype({commit}, options) { return this.$http.get(`${apiUrl(this)}/${options.dataType}s/distinct-namespaces`).then(response => { - commit("setNamespaces", response.data) + commit("setDatatypeNamespaces", response.data) }) }, importFile({_commit}, options) { @@ -38,8 +38,8 @@ export default { } }, mutations: { - setNamespaces(state, namespaces) { - state.namespaces = namespaces + setDatatypeNamespaces(state, datatypeNamespaces) { + state.datatypeNamespaces = datatypeNamespaces } }, getters: {} diff --git a/ui/src/utils/init.js b/ui/src/utils/init.js index 823e33de15..5764f4f056 100644 --- a/ui/src/utils/init.js +++ b/ui/src/utils/init.js @@ -44,6 +44,8 @@ import TaskObject from "../components/flows/tasks/TaskObject.vue"; import TaskString from "../components/flows/tasks/TaskString.vue"; import TaskTask from "../components/flows/tasks/TaskTask.vue"; import TaskAnyOf from "../components/flows/tasks/TaskAnyOf.vue"; +import TaskNamespace from "../components/flows/tasks/TaskNamespace.vue"; +import TaskFlowId from "../components/flows/tasks/TaskFlowId.vue"; export default (app, routes, stores, translations) => { // charts @@ -142,6 +144,8 @@ export default (app, routes, stores, translations) => { app.component("TaskString", TaskString) app.component("TaskTask", TaskTask) app.component("TaskAnyOf", TaskAnyOf) + app.component("TaskNamespace", TaskNamespace) + app.component("TaskFlowId", TaskFlowId) return {store, router}; } diff --git a/ui/src/utils/yamlUtils.js b/ui/src/utils/yamlUtils.js index b779278268..ffe7fdc84f 100644 --- a/ui/src/utils/yamlUtils.js +++ b/ui/src/utils/yamlUtils.js @@ -161,38 +161,54 @@ export default class YamlUtils { return index === -1 ? Number.MAX_SAFE_INTEGER : index; } - static extractAllTypes(source) { + static nextDelimiterIndex(content, currentIndex) { + if (currentIndex === content.length - 1) { + return currentIndex; + } + + const remainingContent = content.substring(currentIndex + 1); + + const nextDelimiterMatcher = remainingContent.match(/[ .}]/); + if (!nextDelimiterMatcher) { + return content.length - 1; + } else { + return currentIndex + nextDelimiterMatcher.index; + } + } + + static extractFieldFromMaps(source, fieldName, yamlDocPredicate = (_) => true) { const yamlDoc = yaml.parseDocument(source); - const types = []; - if (yamlDoc.contents && yamlDoc.contents.items && yamlDoc.contents.items.find(e => ["tasks", "triggers", "errors"].includes(e.key.value))) { + const maps = []; + if (yamlDocPredicate(yamlDoc)) { yaml.visit(yamlDoc, { Map(_, map) { if (map.items) { for (const item of map.items) { - if (item.key.value === "type") { - const type = item.value?.value; - types.push({type, range: map.range}); + if (item.key.value === fieldName) { + const fieldValue = item.value?.value; + maps.push({[fieldName]: fieldValue, range: map.range}); } } } } }) } - return types; + return maps; + } + + static extractAllTypes(source) { + return this.extractFieldFromMaps(source, "type", (yamlDoc) => yamlDoc.contents && yamlDoc.contents.items && yamlDoc.contents.items.find(e => ["tasks", "triggers", "errors"].includes(e.key.value))) } static getTaskType(source, position) { - const types = this.extractAllTypes(source) + const types = this.extractAllTypes(source); const lineCounter = new LineCounter(); yaml.parseDocument(source, {lineCounter}); const cursorIndex = lineCounter.lineStarts[position.lineNumber - 1] + position.column; for(const type of types.reverse()) { - if (cursorIndex > type.range[1]) { - return type.type; - } - if (cursorIndex >= type.range[0] && cursorIndex <= type.range[1]) { + if (cursorIndex >= type.range[0]) { return type.type; } } diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java index c0abba98db..1288fa322f 100644 --- a/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java +++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java @@ -464,8 +464,10 @@ public HttpResponse delete( @ExecuteOn(TaskExecutors.IO) @Get(uri = "distinct-namespaces") @Operation(tags = {"Flows"}, summary = "List all distinct namespaces") - public List listDistinctNamespace() { - return flowRepository.findDistinctNamespace(tenantService.resolveTenant()); + public List listDistinctNamespace( + @Parameter(description = "A string filter") @Nullable @QueryValue(value = "q") String query + ) { + return flowRepository.findDistinctNamespace(tenantService.resolveTenant(), query); }