Skip to content

Commit

Permalink
feat(ui): add autocompletion for inputs
Browse files Browse the repository at this point in the history
part of #2473
  • Loading branch information
brian-mulier-p committed Apr 25, 2024
1 parent 347fce3 commit 86d5daf
Show file tree
Hide file tree
Showing 5 changed files with 233 additions and 66 deletions.
2 changes: 1 addition & 1 deletion ui/src/components/inputs/EditorView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@
const onEdit = (event) => {
flowYaml.value = event;
if (!props.isCreating && (routeParams.id !== flowParsed.value.id || routeParams.namespace !== flowParsed.value.namespace)) {
if (flowParsed.value && !props.isCreating && (routeParams.id !== flowParsed.value.id || routeParams.namespace !== flowParsed.value.namespace)) {
store.dispatch("core/showMessage", {
variant: "error",
title: t("readonly property"),
Expand Down
231 changes: 167 additions & 64 deletions ui/src/components/inputs/MonacoEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
});
export default defineComponent({
data() {
return {
flowsInputsCache: {}
}
},
computed: {
...mapState("namespace", ["datatypeNamespaces"])
},
Expand Down Expand Up @@ -143,83 +148,30 @@
this.subflowAutocompletionProvider = this.monaco.languages.registerCompletionItemProvider("yaml", {
triggerCharacters: [":"],
async provideCompletionItems(model, position) {
const lineContent = _this.lineContent(model, position);
const tillCursorContent = lineContent.substring(0, position.column - 1);
let match = tillCursorContent.match(/^( *namespace:( *))(.*)$/);
let indexOfFieldToComplete;
if (match) {
indexOfFieldToComplete = match.index + match[1].length;
if (!_this.datatypeNamespaces) {
await _this.$store.dispatch("namespace/loadNamespacesForDatatype", {dataType: "flow"})
}
let filteredNamespaces = _this.datatypeNamespaces;
if (match[3].length > 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)
}
}))
};
const namespaceAutocompletion = await _this.namespaceAutocompletion(model, position);
if (namespaceAutocompletion) {
return namespaceAutocompletion;
}
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 flowIdAutocompletion = await _this.flowIdAutocompletion(model, position);
if (flowIdAutocompletion) {
return flowIdAutocompletion;
}
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);
const subflowInputsAutocompletion = await _this.subflowInputsAutocompletion(model, position);
if (subflowInputsAutocompletion) {
return subflowInputsAutocompletion;
}
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)
}
}))
};
return {suggestions: []};
}
})
this.nestedFieldAutocompletionProvider = this.monaco.languages.registerCompletionItemProvider("yaml", {
triggerCharacters: ["."],
async provideCompletionItems(model, position) {
const lineContent = _this.lineContent(model, position);
const tillCursorContent = lineContent.substring(0, position.column - 1);
const tillCursorContent = _this.tillCursorContent(lineContent, position);
const match = tillCursorContent.match(/( *([^{ ]*)\.)([^.} ]*)$/);
if (!match) {
return {suggestions: []};
Expand Down Expand Up @@ -257,6 +209,154 @@
this.destroy();
},
methods: {
async namespaceAutocompletion(model, position) {
const lineContent = this.lineContent(model, position);
const match = this.tillCursorContent(lineContent, position).match(/^( *namespace:( *))(.*)$/);
if (!match) {
return undefined;
}
const indexOfFieldToComplete = match.index + match[1].length;
if (!this.datatypeNamespaces) {
await this.$store.dispatch("namespace/loadNamespacesForDatatype", {dataType: "flow"})
}
let filteredNamespaces = this.datatypeNamespaces;
if (match[3].length > 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)
}
}))
};
},
async flowIdAutocompletion(model, position) {
const lineContent = this.lineContent(model, position);
const match = this.tillCursorContent(lineContent, position).match(/^( *flowId:( *))(.*)$/);
if (!match) {
return undefined;
}
const 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 undefined;
}
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: this.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)
}
}))
};
},
async subflowInputsAutocompletion(model, position) {
const subflowsWithRange = YamlUtils.extractMaps(model.getValue(), {
namespace: {populated: true},
flowId: {populated: true},
inputs: {present: true}
});
const previousWordCharWithInputsCapture = model.findPreviousMatch("(inputs)?([\\w:])", position, true, false, null, true);
let prefixAtPosition = model.getWordUntilPosition(position);
if (prefixAtPosition?.word === "") {
prefixAtPosition = null;
}
const wordAtPosition = model.getWordAtPosition(position);
const subflowTaskWithRange = subflowsWithRange.reverse().find(subflowWithRange => {
const range = subflowWithRange.range;
return range[0] < previousWordCharWithInputsCapture.range.endOffset < range[2];
});
const subflowTask = subflowTaskWithRange?.map;
if (!subflowTask) {
return undefined;
}
const subflowUid = subflowTask.namespace + "." + subflowTask.flowId;
if (!this.flowsInputsCache[subflowUid]) {
try {
this.flowsInputsCache[subflowUid] = (await this.$store.dispatch(
"flow/loadFlow",
{
namespace: subflowTask.namespace,
id: subflowTask.flowId,
revision: subflowTask.revision,
source: false,
store: false
}
)).inputs?.map(input => input.id) ?? [];
} catch (e) {
return undefined;
}
}
let flowInputs = this.flowsInputsCache[subflowUid].filter(input => subflowTask.inputs?.[input] === undefined);
if (prefixAtPosition?.word) {
flowInputs = flowInputs.filter(input => input.startsWith(prefixAtPosition.word));
}
let preInsertText = "";
// We don't have any word under cursor but we're on the same line as the previous word => We must add a newline
if (!wordAtPosition && previousWordCharWithInputsCapture?.range?.endLineNumber === position.lineNumber) {
preInsertText = "\n";
// By default, the new line will respect the parent indent. The only border case is when being on the same line as the expected parent (inputs), we must add manually the child indent
if (previousWordCharWithInputsCapture.matches[1]) {
preInsertText += " ";
} else if (previousWordCharWithInputsCapture.matches[2] === ":") {
// User is filling an input value
return undefined;
}
}
return {
suggestions: flowInputs.map(input => {
const insertText = input + ": ";
return {
kind: this.monaco.languages.CompletionItemKind.Value,
label: input,
insertText: preInsertText + insertText,
range: {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: wordAtPosition?.startColumn ?? position.column,
endColumn: wordAtPosition?.endColumn ?? position.column
}
};
})
}
},
lineContent(model, position) {
return model.getValueInRange({
startLineNumber: position.lineNumber,
Expand All @@ -265,6 +365,9 @@
endColumn: model.getLineMaxColumn(position.lineNumber)
});
},
tillCursorContent(lineContent, position) {
return lineContent.substring(0, position.column - 1);
},
async autocompletion(source, lineContent, field, rest, lineNumber, fieldToCompleteIndexes) {
const flowAsJs = YamlUtils.parse(source);
let autocompletions;
Expand Down
2 changes: 1 addition & 1 deletion ui/src/stores/flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export default {
})
},
loadFlow({commit}, options) {
return this.$http.get(`${apiUrl(this)}/flows/${options.namespace}/${options.id}?source=true`,
return this.$http.get(`${apiUrl(this)}/flows/${options.namespace}/${options.id}${options.source === undefined ? "?source=true" : ""}`,
{
params: options,
validateStatus: (status) => {
Expand Down
32 changes: 32 additions & 0 deletions ui/src/utils/yamlUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,38 @@ export default class YamlUtils {
return maps;
}

static extractMaps(source, fieldConditions) {
const yamlDoc = yaml.parseDocument(source);
const maps = [];
yaml.visit(yamlDoc, {
Map(_, yamlMap) {
if (yamlMap.items) {
const map = yamlMap.toJS(yamlDoc);
for (let [fieldName, condition] of Object.entries(fieldConditions)) {
if (condition.present) {
if (map[fieldName] === undefined) {
return;
}

if (map[fieldName] === null) {
map[fieldName] = undefined;
}
}
if (condition.populated) {
if (map[fieldName] === undefined || map[fieldName] === null || map[fieldName] === "") {
return;
}
}
}

maps.push({map, range: yamlMap.range});
}
}
});

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)))
}
Expand Down
32 changes: 32 additions & 0 deletions ui/tests/unit/utils/yamlUtils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,39 @@ type: io.kestra.core.tasks.log.Log
message: "replaced"
`

const extractMapsSample = `
firstMap:
populatedField:
presentField:
extraField: "firstMap"
secondMap:
populatedField: "populated"
presentField:
extraField: "secondMap"
thirdMap:
populatedField: "populated"
extraField: "thirdMap"
`

describe("YamlUtils", () => {
it("extractMaps with field conditions", () => {
const extractMaps = YamlUtils.extractMaps(extractMapsSample, {
populatedField: {
populated: true
},
presentField: {
present: true
}
});

expect(extractMaps.length).toBe(1);
const map = extractMaps[0].map;
expect(map.populatedField).toBe("populated");
expect(map.presentField).toBe(undefined);
expect(map.extraField).toBe("secondMap");
expect(extractMaps[0].range).toStrictEqual([83,153,153]);
})

it("extractTask from a flat flow", () => {
let doc = YamlUtils.extractTask(flat, "1-1", "tasks");

Expand Down

0 comments on commit 86d5daf

Please sign in to comment.