Skip to content

Commit

Permalink
[Console Monaco migration] Autocomplete fixes (elastic#184032)
Browse files Browse the repository at this point in the history
## Summary

Fixes elastic#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
<img width="260" alt="Screenshot 2024-05-24 at 17 28 17"
src="https://github.com/elastic/kibana/assets/6585477/63ab9951-62b7-4ace-acda-b2cf21737ecf">
<img width="242" alt="Screenshot 2024-05-24 at 17 28 33"
src="https://github.com/elastic/kibana/assets/6585477/14c1b65a-8194-4f67-a0f7-e762b19b1566">

- 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&mdash;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&mdash;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)
  • Loading branch information
yuliacech committed Jun 3, 2024
1 parent f10ffdc commit c32f283
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 67 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -347,7 +347,7 @@ export class MonacoEditorActionsProvider {
model: monaco.editor.ITextModel,
position: monaco.Position,
context: monaco.languages.CompletionContext
) {
): Promise<monaco.languages.CompletionList> {
// determine autocomplete type
const autocompleteType = await this.getAutocompleteType(model, position);
if (!autocompleteType) {
Expand Down Expand Up @@ -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,
};
Expand All @@ -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<monaco.languages.CompletionList> {
): Promise<monaco.languages.CompletionList> {
return this.getSuggestions(model, position, context);
}

Expand Down Expand Up @@ -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 };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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<monaco.languages.CompletionItem[]> => {
const { lineNumber, column } = position;

// get the content on the method+url line
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 5 additions & 4 deletions src/plugins/console/public/lib/autocomplete/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions src/plugins/console/public/lib/autocomplete/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit c32f283

Please sign in to comment.