From 27d3742ae8c519061cb62488727c9b408e9e5fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Mon, 3 Jun 2024 18:43:01 +0200 Subject: [PATCH] [Console Monaco migration] Autocomplete fixes (#184032) ## Summary Fixes https://github.com/elastic/kibana/issues/183421 This PR fixes following issues in the autocomplete suggestions for request body: - Conditional template - Display suggestions for boolean values - Display async loaded suggestions - Move the cursor inside an empty array/object after inserting it as a template ### How to test - Set the config for Monaco migration `console.dev.enableMonaco: true` in `/config/kibana.dev.yml` - Start ES and Kibana with `yarn es snapshot` and `yarn start` - Conditional template - Try creating different types of repos and check that the "settings" property changes its template for each type Screenshot 2024-05-24 at 17 28 17 Screenshot 2024-05-24 at 17 28 33 - Check autocomplete suggestions of any boolean property, for example for the request `GET _search` the property `explain` - Check asynchronously loaded suggestions, for example `fields` property for the request `GET _search` - Check templates with empty objects/arrays, for example `query` in the `GET _search` request ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../monaco/monaco_editor_actions_provider.ts | 35 +++- .../editor/monaco/utils/autocomplete_utils.ts | 182 ++++++++++++------ .../editor/monaco/utils/tokens_utils.test.ts | 4 + .../editor/monaco/utils/tokens_utils.ts | 2 +- .../public/lib/autocomplete/autocomplete.ts | 9 +- .../console/public/lib/autocomplete/types.ts | 5 +- 6 files changed, 170 insertions(+), 67 deletions(-) diff --git a/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts b/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts index 8001966907272e..f0bc4b34d899b8 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts @@ -7,7 +7,7 @@ */ import { CSSProperties, Dispatch } from 'react'; -import { debounce } from 'lodash'; +import { debounce, range } from 'lodash'; import { ConsoleParsedRequestsProvider, getParsedRequestsProvider, monaco } from '@kbn/monaco'; import { i18n } from '@kbn/i18n'; import { toMountPoint } from '@kbn/react-kibana-mount'; @@ -347,7 +347,7 @@ export class MonacoEditorActionsProvider { model: monaco.editor.ITextModel, position: monaco.Position, context: monaco.languages.CompletionContext - ) { + ): Promise { // determine autocomplete type const autocompleteType = await this.getAutocompleteType(model, position); if (!autocompleteType) { @@ -384,7 +384,12 @@ export class MonacoEditorActionsProvider { position.lineNumber ); const requestStartLineNumber = requests[0].startLineNumber; - const suggestions = getBodyCompletionItems(model, position, requestStartLineNumber); + const suggestions = await getBodyCompletionItems( + model, + position, + requestStartLineNumber, + this + ); return { suggestions, }; @@ -394,12 +399,12 @@ export class MonacoEditorActionsProvider { suggestions: [], }; } - public provideCompletionItems( + public async provideCompletionItems( model: monaco.editor.ITextModel, position: monaco.Position, context: monaco.languages.CompletionContext, token: monaco.CancellationToken - ): monaco.languages.ProviderResult { + ): Promise { return this.getSuggestions(model, position, context); } @@ -565,4 +570,24 @@ export class MonacoEditorActionsProvider { this.editor.setPosition({ lineNumber: firstRequestAfter.endLineNumber, column: 1 }); } } + + /* + * This function is to get an array of line contents + * from startLine to endLine including both line numbers + */ + public getLines(startLine: number, endLine: number): string[] { + const model = this.editor.getModel(); + if (!model) { + return []; + } + // range returns an array not including the end of the range, so we need to add 1 + return range(startLine, endLine + 1).map((lineNumber) => model.getLineContent(lineNumber)); + } + + /* + * This function returns the current position of the cursor + */ + public getCurrentPosition(): monaco.IPosition { + return this.editor.getPosition() ?? { lineNumber: 1, column: 1 }; + } } diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/autocomplete_utils.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/autocomplete_utils.ts index 5302bf90d82c0e..9f8a6e5efd99c6 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/autocomplete_utils.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/autocomplete_utils.ts @@ -7,13 +7,18 @@ */ import { monaco } from '@kbn/monaco'; +import { MonacoEditorActionsProvider } from '../monaco_editor_actions_provider'; import { getEndpointBodyCompleteComponents, getGlobalAutocompleteComponents, getTopLevelUrlCompleteComponents, getUnmatchedEndpointComponents, } from '../../../../../lib/kb'; -import { AutoCompleteContext, ResultTerm } from '../../../../../lib/autocomplete/types'; +import { + AutoCompleteContext, + type DataAutoCompleteRulesOneOf, + ResultTerm, +} from '../../../../../lib/autocomplete/types'; import { populateContext } from '../../../../../lib/autocomplete/engine'; import type { EditorRequest } from '../types'; import { parseBody, parseLine, parseUrl } from './tokens_utils'; @@ -133,8 +138,8 @@ export const getUrlPathCompletionItems = ( // map autocomplete items to completion items .map((item) => { return { - label: item.name!, - insertText: item.name!, + label: item.name + '', + insertText: item.name + '', detail: item.meta ?? i18nTexts.endpoint, // the kind is only used to configure the icon kind: monaco.languages.CompletionItemKind.Constant, @@ -195,8 +200,8 @@ export const getUrlParamsCompletionItems = ( // map autocomplete items to completion items .map((item) => { return { - label: item.name!, - insertText: item.name!, + label: item.name + '', + insertText: item.name + '', detail: item.meta ?? i18nTexts.param, // the kind is only used to configure the icon kind: monaco.languages.CompletionItemKind.Constant, @@ -211,11 +216,12 @@ export const getUrlParamsCompletionItems = ( /* * This function returns an array of completion items for the request body params */ -export const getBodyCompletionItems = ( +export const getBodyCompletionItems = async ( model: monaco.editor.ITextModel, position: monaco.Position, - requestStartLineNumber: number -): monaco.languages.CompletionItem[] => { + requestStartLineNumber: number, + editor: MonacoEditorActionsProvider +): Promise => { const { lineNumber, column } = position; // get the content on the method+url line @@ -244,62 +250,91 @@ export const getBodyCompletionItems = ( } else { components = getUnmatchedEndpointComponents(); } - populateContext(bodyTokens, context, undefined, true, components); - - if (context.autoCompleteSet && context.autoCompleteSet.length > 0) { - const wordUntilPosition = model.getWordUntilPosition(position); - // if there is " after the cursor, replace it - let endColumn = position.column; - const charAfterPosition = model.getValueInRange({ - startLineNumber: position.lineNumber, - startColumn: position.column, - endLineNumber: position.lineNumber, - endColumn: position.column + 1, - }); - if (charAfterPosition === '"') { - endColumn = endColumn + 1; - } - const range = { - startLineNumber: position.lineNumber, - // replace the whole word with the suggestion - startColumn: wordUntilPosition.startColumn, - endLineNumber: position.lineNumber, - endColumn, - }; - return ( - context.autoCompleteSet - // filter autocomplete items without a name - .filter(({ name }) => Boolean(name)) - // map autocomplete items to completion items - .map((item) => { - const suggestion = { - // convert name to a string - label: item.name + '', - insertText: getInsertText(item, bodyContent), - detail: i18nTexts.api, - // the kind is only used to configure the icon - kind: monaco.languages.CompletionItemKind.Constant, - range, - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - }; - return suggestion; - }) - ); + context.editor = editor; + context.requestStartRow = requestStartLineNumber; + populateContext(bodyTokens, context, editor, true, components); + if (!context) { + return []; } - return []; + if (context.asyncResultsState?.isLoading && context.asyncResultsState) { + const results = await context.asyncResultsState.results; + return getSuggestions(model, position, results, context, bodyContent); + } + + return getSuggestions(model, position, context.autoCompleteSet ?? [], context, bodyContent); }; +const getSuggestions = ( + model: monaco.editor.ITextModel, + position: monaco.Position, + autocompleteSet: ResultTerm[], + context: AutoCompleteContext, + bodyContent: string +) => { + const wordUntilPosition = model.getWordUntilPosition(position); + // if there is " after the cursor, replace it + let endColumn = position.column; + const charAfterPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: position.lineNumber, + endColumn: position.column + 1, + }); + if (charAfterPosition === '"') { + endColumn = endColumn + 1; + } + const range = { + startLineNumber: position.lineNumber, + // replace the whole word with the suggestion + startColumn: wordUntilPosition.startColumn, + endLineNumber: position.lineNumber, + endColumn, + }; + return ( + autocompleteSet + // filter out items that don't have name + .filter(({ name }) => name !== undefined) + // map autocomplete items to completion items + .map((item) => { + const suggestion = { + // convert name to a string + label: item.name + '', + insertText: getInsertText(item, bodyContent, context), + detail: i18nTexts.api, + // the kind is only used to configure the icon + kind: monaco.languages.CompletionItemKind.Constant, + range, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + }; + return suggestion; + }) + ); +}; const getInsertText = ( { name, insertValue, template, value }: ResultTerm, - bodyContent: string + bodyContent: string, + context: AutoCompleteContext ): string => { - let insertText = bodyContent.endsWith('"') ? '' : '"'; - if (insertValue && insertValue !== '{' && insertValue !== '[') { - insertText += `${insertValue}"`; + if (name === undefined) { + return ''; + } + let insertText = ''; + if (typeof name === 'string') { + insertText = bodyContent.endsWith('"') ? '' : '"'; + if (insertValue && insertValue !== '{' && insertValue !== '[') { + insertText += `${insertValue}"`; + } else { + insertText += `${name}"`; + } } else { - insertText += `${name}"`; + insertText = name + ''; } + // check if there is template to add + const conditionalTemplate = getConditionalTemplate(name, bodyContent, context.endpoint); + if (conditionalTemplate) { + template = conditionalTemplate; + } if (template !== undefined) { let templateLines; const { __raw, value: templateValue } = template; @@ -314,5 +349,42 @@ const getInsertText = ( } else if (value === '[') { insertText += '[]'; } + // the string $0 is used to move the cursor between empty curly/square brackets + if (insertText.endsWith('{}')) { + insertText = insertText.substring(0, insertText.length - 2) + '{$0}'; + } + if (insertText.endsWith('[]')) { + insertText = insertText.substring(0, insertText.length - 2) + '[$0]'; + } return insertText; }; + +const getConditionalTemplate = ( + name: string | boolean, + bodyContent: string, + endpoint: AutoCompleteContext['endpoint'] +) => { + if (typeof name !== 'string' || !endpoint || !endpoint.data_autocomplete_rules) { + return; + } + // get the autocomplete rules for the request body + const { data_autocomplete_rules: autocompleteRules } = endpoint; + // get the rules for this property name + const rules = autocompleteRules[name]; + // check if the rules have "__one_of" property + if (!rules || typeof rules !== 'object' || !('__one_of' in rules)) { + return; + } + const oneOfRules = rules.__one_of as DataAutoCompleteRulesOneOf[]; + // try to match one of the rules to the body content + const matchedRule = oneOfRules.find((rule) => { + if (rule.__condition && rule.__condition.lines_regex) { + return new RegExp(rule.__condition.lines_regex, 'm').test(bodyContent); + } + return false; + }); + // use the template from the matched rule + if (matchedRule && matchedRule.__template) { + return matchedRule.__template; + } +}; diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.test.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.test.ts index 56d9dea22b743e..600b5f4a98e4a1 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.test.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.test.ts @@ -118,6 +118,10 @@ describe('tokens_utils', () => { value: '{"property1":{"nested1":"value","nested2":{}},"', tokens: ['{'], }, + { + value: '{\n "explain": false,\n "', + tokens: ['{'], + }, ]; for (const testCase of testCases) { const { value, tokens } = testCase; diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.ts index 76e6e9672252fc..f52a0bb6a9079e 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.ts @@ -228,7 +228,7 @@ export const parseBody = (value: string): string[] => { break; } case 'f': { - if (peek(1) === 'a' && peek(2) === 'l' && peek(3) === 's' && peek(3) === 'e') { + if (peek(1) === 'a' && peek(2) === 'l' && peek(3) === 's' && peek(4) === 'e') { next(); next(); next(); diff --git a/src/plugins/console/public/lib/autocomplete/autocomplete.ts b/src/plugins/console/public/lib/autocomplete/autocomplete.ts index bdb2a16c879abe..d6d2a8e711f13e 100644 --- a/src/plugins/console/public/lib/autocomplete/autocomplete.ts +++ b/src/plugins/console/public/lib/autocomplete/autocomplete.ts @@ -799,7 +799,8 @@ export default function ({ // if not on the first line if (context.rangeToReplace && context.rangeToReplace.start?.lineNumber > 1) { const prevTokenLineNumber = position.lineNumber; - const line = context.editor?.getLineValue(prevTokenLineNumber) ?? ''; + const editorFromContext = context.editor as CoreEditor | undefined; + const line = editorFromContext?.getLineValue(prevTokenLineNumber) ?? ''; const prevLineLength = line.length; const linesToEnter = context.rangeToReplace.end.lineNumber - prevTokenLineNumber; @@ -1188,7 +1189,7 @@ export default function ({ context: AutoCompleteContext; completer?: { insertMatch: (v: unknown) => void }; } = { - value: term.name, + value: term.name + '', meta: 'API', score: 0, context, @@ -1206,8 +1207,8 @@ export default function ({ ); terms.sort(function ( - t1: { score: number; name?: string }, - t2: { score: number; name?: string } + t1: { score: number; name?: string | boolean }, + t2: { score: number; name?: string | boolean } ) { /* score sorts from high to low */ if (t1.score > t2.score) { diff --git a/src/plugins/console/public/lib/autocomplete/types.ts b/src/plugins/console/public/lib/autocomplete/types.ts index 7d1fb383f52a57..58af8914886955 100644 --- a/src/plugins/console/public/lib/autocomplete/types.ts +++ b/src/plugins/console/public/lib/autocomplete/types.ts @@ -6,13 +6,14 @@ * Side Public License, v 1. */ +import { MonacoEditorActionsProvider } from '../../application/containers/editor/monaco/monaco_editor_actions_provider'; import { CoreEditor, Range, Token } from '../../types'; export interface ResultTerm { meta?: string; context?: AutoCompleteContext; insertValue?: string; - name?: string; + name?: string | boolean; value?: string; score?: number; template?: { __raw?: boolean; value?: string; [key: string]: unknown }; @@ -53,7 +54,7 @@ export interface AutoCompleteContext { replacingToken?: boolean; rangeToReplace?: Range; autoCompleteType?: null | string; - editor?: CoreEditor; + editor?: CoreEditor | MonacoEditorActionsProvider; /** * The tokenized user input that prompted the current autocomplete at the cursor. This can be out of sync with