mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Console Monaco Migration] Implement autocomplete support (#181347)
## Summary
Partially addresses https://github.com/elastic/kibana/issues/180208
This PR adds autocomplete support for method, url and url parameters in
the Monaco editor. The url suggestions include the indices name.
#### Screen recording
b31f87b2
-df11-48ad-88a7-cc3f9fbb5ffc
### 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)
This commit is contained in:
parent
de75523495
commit
9fb859ea67
10 changed files with 595 additions and 124 deletions
|
@ -413,7 +413,7 @@ export const createParser = () => {
|
|||
addRequestMethod(parsedMethod);
|
||||
strictWhite();
|
||||
const parsedUrl = url();
|
||||
addRequestUrl(parsedUrl );
|
||||
addRequestUrl(parsedUrl);
|
||||
strictWhite(); // advance to one new line
|
||||
newLine();
|
||||
strictWhite();
|
||||
|
|
|
@ -13,7 +13,7 @@ export interface ErrorAnnotation {
|
|||
|
||||
export interface ParsedRequest {
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
endOffset?: number;
|
||||
method: string;
|
||||
url: string;
|
||||
data?: Array<Record<string, unknown>>;
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
||||
import React, { CSSProperties, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { CodeEditor } from '@kbn/code-editor';
|
||||
|
@ -22,6 +22,7 @@ import { useSetInitialValue } from './use_set_initial_value';
|
|||
import { MonacoEditorActionsProvider } from './monaco_editor_actions_provider';
|
||||
import { useSetupAutocompletePolling } from './use_setup_autocomplete_polling';
|
||||
import { useSetupAutosave } from './use_setup_autosave';
|
||||
import { getSuggestionProvider } from './monaco_editor_suggestion_provider';
|
||||
import { useResizeCheckerUtils } from './use_resize_checker_utils';
|
||||
|
||||
export interface EditorProps {
|
||||
|
@ -75,6 +76,9 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
|
|||
await actionsProvider.current?.sendRequests(toasts, dispatch, trackUiMetric, http);
|
||||
}, [dispatch, http, toasts, trackUiMetric]);
|
||||
|
||||
const suggestionProvider = useMemo(() => {
|
||||
return getSuggestionProvider(actionsProvider);
|
||||
}, []);
|
||||
const [value, setValue] = useState(initialTextValue);
|
||||
|
||||
useSetInitialValue({ initialTextValue, setValue, toasts });
|
||||
|
@ -137,6 +141,7 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
|
|||
wordWrap: settings.wrapMode === true ? 'on' : 'off',
|
||||
theme: CONSOLE_THEME_ID,
|
||||
}}
|
||||
suggestionProvider={suggestionProvider}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -147,4 +147,67 @@ describe('Editor actions provider', () => {
|
|||
expect(link).toBe(docsLink);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provideCompletionItems', () => {
|
||||
const mockModel = {
|
||||
getWordUntilPosition: () => {
|
||||
return {
|
||||
startColumn: 1,
|
||||
};
|
||||
},
|
||||
getPositionAt: () => {
|
||||
return {
|
||||
lineNumber: 1,
|
||||
};
|
||||
},
|
||||
getLineCount: () => 1,
|
||||
getLineContent: () => 'GET ',
|
||||
getValueInRange: () => 'GET ',
|
||||
} as unknown as jest.Mocked<monaco.editor.ITextModel>;
|
||||
const mockPosition = { lineNumber: 1, column: 1 } as jest.Mocked<monaco.Position>;
|
||||
const mockContext = {} as jest.Mocked<monaco.languages.CompletionContext>;
|
||||
const token = {} as jest.Mocked<monaco.CancellationToken>;
|
||||
it('returns completion items for method if no requests', async () => {
|
||||
mockGetParsedRequests.mockResolvedValue([]);
|
||||
const completionItems = await editorActionsProvider.provideCompletionItems(
|
||||
mockModel,
|
||||
mockPosition,
|
||||
mockContext,
|
||||
token
|
||||
);
|
||||
expect(completionItems?.suggestions.length).toBe(6);
|
||||
const methods = completionItems?.suggestions.map((suggestion) => suggestion.label);
|
||||
expect((methods as string[]).sort()).toEqual([
|
||||
'DELETE',
|
||||
'GET',
|
||||
'HEAD',
|
||||
'PATCH',
|
||||
'POST',
|
||||
'PUT',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns completion items for url path if method already typed in', async () => {
|
||||
// mock a parsed request that only has a method
|
||||
mockGetParsedRequests.mockResolvedValue([
|
||||
{
|
||||
startOffset: 0,
|
||||
method: 'GET',
|
||||
},
|
||||
]);
|
||||
mockPopulateContext.mockImplementation((...args) => {
|
||||
const context = args[0][1];
|
||||
context.autoCompleteSet = [{ name: '_search' }, { name: '_cat' }];
|
||||
});
|
||||
const completionItems = await editorActionsProvider.provideCompletionItems(
|
||||
mockModel,
|
||||
mockPosition,
|
||||
mockContext,
|
||||
token
|
||||
);
|
||||
expect(completionItems?.suggestions.length).toBe(2);
|
||||
const endpoints = completionItems?.suggestions.map((suggestion) => suggestion.label);
|
||||
expect((endpoints as string[]).sort()).toEqual(['_cat', '_search']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,21 +17,24 @@ import {
|
|||
import { IToasts } from '@kbn/core-notifications-browser';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { HttpSetup } from '@kbn/core-http-browser';
|
||||
import { AutoCompleteContext } from '../../../../lib/autocomplete/types';
|
||||
import { populateContext } from '../../../../lib/autocomplete/engine';
|
||||
import { DEFAULT_VARIABLES } from '../../../../../common/constants';
|
||||
import { getStorage, StorageKeys } from '../../../../services';
|
||||
import { getTopLevelUrlCompleteComponents } from '../../../../lib/kb';
|
||||
import { sendRequest } from '../../../hooks/use_send_current_request/send_request';
|
||||
import { MetricsTracker } from '../../../../types';
|
||||
import { Actions } from '../../../stores/request';
|
||||
import {
|
||||
stringifyRequest,
|
||||
replaceRequestVariables,
|
||||
containsUrlParams,
|
||||
getCurlRequest,
|
||||
getDocumentationLink,
|
||||
getLineTokens,
|
||||
getMethodCompletionItems,
|
||||
getRequestEndLineNumber,
|
||||
getRequestStartLineNumber,
|
||||
getUrlParamsCompletionItems,
|
||||
getUrlPathCompletionItems,
|
||||
replaceRequestVariables,
|
||||
stringifyRequest,
|
||||
trackSentRequests,
|
||||
tokenizeRequestUrl,
|
||||
getDocumentationLinkFromAutocompleteContext,
|
||||
} from './utils';
|
||||
|
||||
const selectedRequestsClass = 'console__monaco_editor__selectedRequests';
|
||||
|
@ -42,6 +45,17 @@ export interface EditorRequest {
|
|||
data: string[];
|
||||
}
|
||||
|
||||
interface AdjustedParsedRequest extends ParsedRequest {
|
||||
startLineNumber: number;
|
||||
endLineNumber: number;
|
||||
}
|
||||
enum AutocompleteType {
|
||||
PATH = 'path',
|
||||
URL_PARAMS = 'url_params',
|
||||
METHOD = 'method',
|
||||
BODY = 'body',
|
||||
}
|
||||
|
||||
export class MonacoEditorActionsProvider {
|
||||
private parsedRequestsProvider: ConsoleParsedRequestsProvider;
|
||||
private highlightedLines: monaco.editor.IEditorDecorationsCollection;
|
||||
|
@ -91,12 +105,21 @@ export class MonacoEditorActionsProvider {
|
|||
|
||||
private async highlightRequests(): Promise<void> {
|
||||
// get the requests in the selected range
|
||||
const { range: selectedRange, parsedRequests } = await this.getSelectedParsedRequestsAndRange();
|
||||
const parsedRequests = await this.getSelectedParsedRequests();
|
||||
// if any requests are selected, highlight the lines and update the position of actions buttons
|
||||
if (parsedRequests.length > 0) {
|
||||
const selectedRequestStartLine = selectedRange.startLineNumber;
|
||||
// display the actions buttons on the 1st line of the 1st selected request
|
||||
this.updateEditorActions(selectedRequestStartLine);
|
||||
const selectionStartLineNumber = parsedRequests[0].startLineNumber;
|
||||
this.updateEditorActions(selectionStartLineNumber);
|
||||
// highlight the lines from the 1st line of the first selected request
|
||||
// to the last line of the last selected request
|
||||
const selectionEndLineNumber = parsedRequests[parsedRequests.length - 1].endLineNumber;
|
||||
const selectedRange = new monaco.Range(
|
||||
selectionStartLineNumber,
|
||||
1,
|
||||
selectionEndLineNumber,
|
||||
this.editor.getModel()?.getLineMaxColumn(selectionEndLineNumber) ?? 1
|
||||
);
|
||||
this.highlightedLines.set([
|
||||
{
|
||||
range: selectedRange,
|
||||
|
@ -113,67 +136,51 @@ export class MonacoEditorActionsProvider {
|
|||
}
|
||||
}
|
||||
|
||||
private async getSelectedParsedRequestsAndRange(): Promise<{
|
||||
parsedRequests: ParsedRequest[];
|
||||
range: monaco.IRange;
|
||||
}> {
|
||||
private async getSelectedParsedRequests(): Promise<AdjustedParsedRequest[]> {
|
||||
const model = this.editor.getModel();
|
||||
const selection = this.editor.getSelection();
|
||||
if (!model || !selection) {
|
||||
return Promise.resolve({
|
||||
parsedRequests: [],
|
||||
range: selection ?? new monaco.Range(1, 1, 1, 1),
|
||||
});
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const { startLineNumber, endLineNumber } = selection;
|
||||
const parsedRequests = await this.parsedRequestsProvider.getRequests();
|
||||
const selectedRequests = [];
|
||||
let selectionStartLine = startLineNumber;
|
||||
let selectionEndLine = endLineNumber;
|
||||
for (const parsedRequest of parsedRequests) {
|
||||
const { startOffset: requestStart, endOffset: requestEnd } = parsedRequest;
|
||||
const { lineNumber: requestStartLine } = model.getPositionAt(requestStart);
|
||||
let { lineNumber: requestEndLine } = model.getPositionAt(requestEnd);
|
||||
const requestEndLineContent = model.getLineContent(requestEndLine);
|
||||
return this.getRequestsBetweenLines(model, startLineNumber, endLineNumber);
|
||||
}
|
||||
|
||||
// sometimes the parser includes a trailing empty line into the request
|
||||
if (requestEndLineContent.trim().length < 1) {
|
||||
requestEndLine = requestEndLine - 1;
|
||||
}
|
||||
if (requestStartLine > endLineNumber) {
|
||||
private async getRequestsBetweenLines(
|
||||
model: monaco.editor.ITextModel,
|
||||
startLineNumber: number,
|
||||
endLineNumber: number
|
||||
): Promise<AdjustedParsedRequest[]> {
|
||||
const parsedRequests = await this.parsedRequestsProvider.getRequests();
|
||||
const selectedRequests: AdjustedParsedRequest[] = [];
|
||||
for (const [index, parsedRequest] of parsedRequests.entries()) {
|
||||
const requestStartLineNumber = getRequestStartLineNumber(parsedRequest, model);
|
||||
const requestEndLineNumber = getRequestEndLineNumber(
|
||||
parsedRequest,
|
||||
model,
|
||||
index,
|
||||
parsedRequests
|
||||
);
|
||||
if (requestStartLineNumber > endLineNumber) {
|
||||
// request is past the selection, no need to check further requests
|
||||
break;
|
||||
}
|
||||
if (requestEndLine < startLineNumber) {
|
||||
if (requestEndLineNumber < startLineNumber) {
|
||||
// request is before the selection, do nothing
|
||||
} else {
|
||||
// request is selected
|
||||
selectedRequests.push(parsedRequest);
|
||||
// expand the start of the selection to the request start
|
||||
if (selectionStartLine > requestStartLine) {
|
||||
selectionStartLine = requestStartLine;
|
||||
}
|
||||
// expand the end of the selection to the request end
|
||||
if (selectionEndLine < requestEndLine) {
|
||||
selectionEndLine = requestEndLine;
|
||||
}
|
||||
selectedRequests.push({
|
||||
...parsedRequest,
|
||||
startLineNumber: requestStartLineNumber,
|
||||
endLineNumber: requestEndLineNumber,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
parsedRequests: selectedRequests,
|
||||
// the expanded selected range goes from the 1st char of the start line of the 1st request
|
||||
// to the last char of the last line of the last request
|
||||
range: new monaco.Range(
|
||||
selectionStartLine,
|
||||
1,
|
||||
selectionEndLine,
|
||||
model.getLineMaxColumn(selectionEndLine)
|
||||
),
|
||||
};
|
||||
return selectedRequests;
|
||||
}
|
||||
|
||||
private async getRequests() {
|
||||
const { parsedRequests } = await this.getSelectedParsedRequestsAndRange();
|
||||
const parsedRequests = await this.getSelectedParsedRequests();
|
||||
const stringifiedRequests = parsedRequests.map((parsedRequest) =>
|
||||
stringifyRequest(parsedRequest)
|
||||
);
|
||||
|
@ -248,21 +255,89 @@ export class MonacoEditorActionsProvider {
|
|||
}
|
||||
const request = requests[0];
|
||||
|
||||
// get autocomplete components for the request method
|
||||
const components = getTopLevelUrlCompleteComponents(request.method);
|
||||
// get the url parts from the request url
|
||||
const urlTokens = tokenizeRequestUrl(request.url);
|
||||
return getDocumentationLink(request, docLinkVersion);
|
||||
}
|
||||
|
||||
// this object will contain the information later, it needs to be initialized with some data
|
||||
// similar to the old ace editor context
|
||||
const context: AutoCompleteContext = {
|
||||
method: request.method,
|
||||
urlTokenPath: urlTokens,
|
||||
private async getAutocompleteType(
|
||||
model: monaco.editor.ITextModel,
|
||||
{ lineNumber, column }: monaco.Position
|
||||
): Promise<AutocompleteType | null> {
|
||||
// get the current request on this line
|
||||
const currentRequests = await this.getRequestsBetweenLines(model, lineNumber, lineNumber);
|
||||
const currentRequest = currentRequests.at(0);
|
||||
// if there is no request, suggest method
|
||||
if (!currentRequest) {
|
||||
return AutocompleteType.METHOD;
|
||||
}
|
||||
|
||||
// if on the 1st line of the request, suggest method, url or url_params depending on the content
|
||||
const { startLineNumber: requestStartLineNumber } = currentRequest;
|
||||
if (lineNumber === requestStartLineNumber) {
|
||||
// get the content on the line up until the position
|
||||
const lineContent = model.getValueInRange({
|
||||
startLineNumber: lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: lineNumber,
|
||||
endColumn: column,
|
||||
});
|
||||
const lineTokens = getLineTokens(lineContent);
|
||||
// if there is 1 or fewer tokens, suggest method
|
||||
if (lineTokens.length <= 1) {
|
||||
return AutocompleteType.METHOD;
|
||||
}
|
||||
// if there are 2 tokens, look at the 2nd one and suggest path or url_params
|
||||
if (lineTokens.length === 2) {
|
||||
const token = lineTokens[1];
|
||||
if (containsUrlParams(token)) {
|
||||
return AutocompleteType.URL_PARAMS;
|
||||
}
|
||||
return AutocompleteType.PATH;
|
||||
}
|
||||
// if more than 2 tokens, no suggestions
|
||||
return null;
|
||||
}
|
||||
|
||||
// if not on the 1st line of the request, suggest request body
|
||||
|
||||
return AutocompleteType.BODY;
|
||||
}
|
||||
|
||||
private async getSuggestions(model: monaco.editor.ITextModel, position: monaco.Position) {
|
||||
// determine autocomplete type
|
||||
const autocompleteType = await this.getAutocompleteType(model, position);
|
||||
if (!autocompleteType) {
|
||||
return {
|
||||
suggestions: [],
|
||||
};
|
||||
}
|
||||
if (autocompleteType === AutocompleteType.METHOD) {
|
||||
return {
|
||||
// suggest all methods, the editor will filter according to the input automatically
|
||||
suggestions: getMethodCompletionItems(model, position),
|
||||
};
|
||||
}
|
||||
if (autocompleteType === AutocompleteType.PATH) {
|
||||
return {
|
||||
suggestions: getUrlPathCompletionItems(model, position),
|
||||
};
|
||||
}
|
||||
|
||||
if (autocompleteType === AutocompleteType.URL_PARAMS) {
|
||||
return {
|
||||
suggestions: getUrlParamsCompletionItems(model, position),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
// this function uses the autocomplete info and the url tokens to find the correct endpoint
|
||||
populateContext(urlTokens, context, undefined, true, components);
|
||||
|
||||
return getDocumentationLinkFromAutocompleteContext(context, docLinkVersion);
|
||||
}
|
||||
public provideCompletionItems(
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position,
|
||||
context: monaco.languages.CompletionContext,
|
||||
token: monaco.CancellationToken
|
||||
): monaco.languages.ProviderResult<monaco.languages.CompletionList> {
|
||||
return this.getSuggestions(model, position);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { monaco } from '@kbn/monaco';
|
||||
import { MutableRefObject } from 'react';
|
||||
import { MonacoEditorActionsProvider } from './monaco_editor_actions_provider';
|
||||
|
||||
export const getSuggestionProvider = (
|
||||
actionsProvider: MutableRefObject<MonacoEditorActionsProvider | null>
|
||||
): monaco.languages.CompletionItemProvider => {
|
||||
return {
|
||||
// force suggestions when these characters are used
|
||||
triggerCharacters: ['/', '.', '_', ',', '?', '=', '&'],
|
||||
provideCompletionItems: (...args) => {
|
||||
if (actionsProvider.current) {
|
||||
return actionsProvider.current?.provideCompletionItems(...args);
|
||||
}
|
||||
return {
|
||||
suggestions: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
|
@ -8,16 +8,28 @@
|
|||
|
||||
import {
|
||||
getCurlRequest,
|
||||
getDocumentationLinkFromAutocompleteContext,
|
||||
getDocumentationLink,
|
||||
removeTrailingWhitespaces,
|
||||
replaceRequestVariables,
|
||||
stringifyRequest,
|
||||
tokenizeRequestUrl,
|
||||
trackSentRequests,
|
||||
} from './utils';
|
||||
import { MetricsTracker } from '../../../../types';
|
||||
import { AutoCompleteContext } from '../../../../lib/autocomplete/types';
|
||||
|
||||
/*
|
||||
* Mock the function "populateContext" that accesses the autocomplete definitions
|
||||
*/
|
||||
const mockPopulateContext = jest.fn();
|
||||
|
||||
jest.mock('../../../../lib/autocomplete/engine', () => {
|
||||
return {
|
||||
populateContext: (...args: any) => {
|
||||
mockPopulateContext(args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('monaco editor utils', () => {
|
||||
const dataObjects = [
|
||||
{
|
||||
|
@ -183,28 +195,21 @@ describe('monaco editor utils', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('tokenizeRequestUrl', () => {
|
||||
it('returns the url if it has only 1 part', () => {
|
||||
const url = '_search';
|
||||
const urlTokens = tokenizeRequestUrl(url);
|
||||
expect(urlTokens).toEqual(['_search', '__url_path_end__']);
|
||||
});
|
||||
|
||||
it('returns correct url tokens', () => {
|
||||
const url = '_search/test';
|
||||
const urlTokens = tokenizeRequestUrl(url);
|
||||
expect(urlTokens).toEqual(['_search', 'test', '__url_path_end__']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDocumentationLinkFromAutocompleteContext', () => {
|
||||
describe('getDocumentationLink', () => {
|
||||
const mockRequest = { method: 'GET', url: '_search', data: [] };
|
||||
const version = '8.13';
|
||||
const expectedLink = 'http://elastic.co/8.13/_search';
|
||||
|
||||
it('correctly replaces {branch} with the version', () => {
|
||||
const endpoint = {
|
||||
documentation: 'http://elastic.co/{branch}/_search',
|
||||
} as AutoCompleteContext['endpoint'];
|
||||
const link = getDocumentationLinkFromAutocompleteContext({ endpoint }, version);
|
||||
// mock the populateContext function that finds the correct autocomplete endpoint object and puts it into the context object
|
||||
mockPopulateContext.mockImplementation((...args) => {
|
||||
const context = args[0][1];
|
||||
context.endpoint = endpoint;
|
||||
});
|
||||
const link = getDocumentationLink(mockRequest, version);
|
||||
expect(link).toBe(expectedLink);
|
||||
});
|
||||
|
||||
|
@ -212,7 +217,12 @@ describe('monaco editor utils', () => {
|
|||
const endpoint = {
|
||||
documentation: 'http://elastic.co/master/_search',
|
||||
} as AutoCompleteContext['endpoint'];
|
||||
const link = getDocumentationLinkFromAutocompleteContext({ endpoint }, version);
|
||||
// mock the populateContext function that finds the correct autocomplete endpoint object and puts it into the context object
|
||||
mockPopulateContext.mockImplementation((...args) => {
|
||||
const context = args[0][1];
|
||||
context.endpoint = endpoint;
|
||||
});
|
||||
const link = getDocumentationLink(mockRequest, version);
|
||||
expect(link).toBe(expectedLink);
|
||||
});
|
||||
|
||||
|
@ -220,7 +230,12 @@ describe('monaco editor utils', () => {
|
|||
const endpoint = {
|
||||
documentation: 'http://elastic.co/current/_search',
|
||||
} as AutoCompleteContext['endpoint'];
|
||||
const link = getDocumentationLinkFromAutocompleteContext({ endpoint }, version);
|
||||
// mock the populateContext function that finds the correct autocomplete endpoint object and puts it into the context object
|
||||
mockPopulateContext.mockImplementation((...args) => {
|
||||
const context = args[0][1];
|
||||
context.endpoint = endpoint;
|
||||
});
|
||||
const link = getDocumentationLink(mockRequest, version);
|
||||
expect(link).toBe(expectedLink);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,20 +6,54 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { ParsedRequest } from '@kbn/monaco';
|
||||
import { monaco, ParsedRequest } from '@kbn/monaco';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { getTopLevelUrlCompleteComponents } from '../../../../lib/kb';
|
||||
import { AutoCompleteContext } from '../../../../lib/autocomplete/types';
|
||||
import { constructUrl } from '../../../../lib/es';
|
||||
import type { DevToolsVariable } from '../../../components';
|
||||
import { EditorRequest } from './monaco_editor_actions_provider';
|
||||
import { MetricsTracker } from '../../../../types';
|
||||
import { populateContext } from '../../../../lib/autocomplete/engine';
|
||||
|
||||
const whitespacesRegex = /\s/;
|
||||
/*
|
||||
* Helper constants
|
||||
*/
|
||||
const whitespacesRegex = /\s+/;
|
||||
const slashRegex = /\//;
|
||||
const ampersandRegex = /&/;
|
||||
const equalsSignRegex = /=/;
|
||||
const variableTemplateRegex = /\${(\w+)}/g;
|
||||
const endOfUrlToken = '__url_path_end__';
|
||||
|
||||
/*
|
||||
* Helper interfaces
|
||||
*/
|
||||
export interface ParsedLineTokens {
|
||||
method: string;
|
||||
urlPathTokens: string[];
|
||||
urlParamsTokens: string[][];
|
||||
}
|
||||
|
||||
/*
|
||||
* i18n for autocomplete labels
|
||||
*/
|
||||
const methodDetailLabel = i18n.translate('console.autocompleteSuggestions.methodLabel', {
|
||||
defaultMessage: 'method',
|
||||
});
|
||||
const endpointDetailLabel = i18n.translate('console.autocompleteSuggestions.endpointLabel', {
|
||||
defaultMessage: 'endpoint',
|
||||
});
|
||||
const paramDetailLabel = i18n.translate('console.autocompleteSuggestions.paramLabel', {
|
||||
defaultMessage: 'param',
|
||||
});
|
||||
|
||||
/*
|
||||
* This functions removes any trailing inline comments, for example
|
||||
* "_search // comment" -> "_search"
|
||||
* Ideally the parser would do that, but currently they are included in url.
|
||||
*/
|
||||
export const removeTrailingWhitespaces = (url: string): string => {
|
||||
/*
|
||||
* This helper removes any trailing inline comments, for example
|
||||
* "_search // comment" -> "_search"
|
||||
* Ideally the parser removes those comments initially
|
||||
*/
|
||||
return url.trim().split(whitespacesRegex)[0];
|
||||
};
|
||||
|
||||
|
@ -30,7 +64,6 @@ export const stringifyRequest = (parsedRequest: ParsedRequest): EditorRequest =>
|
|||
return { url, method, data: data ?? [] };
|
||||
};
|
||||
|
||||
const variableTemplateRegex = /\${(\w+)}/g;
|
||||
const replaceVariables = (text: string, variables: DevToolsVariable[]): string => {
|
||||
if (variableTemplateRegex.test(text)) {
|
||||
text = text.replaceAll(variableTemplateRegex, (match, key) => {
|
||||
|
@ -41,6 +74,7 @@ const replaceVariables = (text: string, variables: DevToolsVariable[]): string =
|
|||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
export const replaceRequestVariables = (
|
||||
{ method, url, data }: EditorRequest,
|
||||
variables: DevToolsVariable[]
|
||||
|
@ -77,26 +111,20 @@ export const trackSentRequests = (
|
|||
};
|
||||
|
||||
/*
|
||||
* This function takes a request url as a string and returns it parts,
|
||||
* for example '_search/test' => ['_search', 'test']
|
||||
* This function initializes the autocomplete context for the request
|
||||
* and returns a documentation link from the endpoint object
|
||||
* with the branch in the url replaced by the current version "docLinkVersion"
|
||||
*/
|
||||
const urlPartsSeparatorRegex = /\//;
|
||||
const endOfUrlToken = '__url_path_end__';
|
||||
export const tokenizeRequestUrl = (url: string): string[] => {
|
||||
const parts = url.split(urlPartsSeparatorRegex);
|
||||
// this special token is used to mark the end of the url
|
||||
parts.push(endOfUrlToken);
|
||||
return parts;
|
||||
};
|
||||
|
||||
/*
|
||||
* This function returns a documentation link from the autocomplete endpoint object
|
||||
* and replaces the branch in the url with the current version "docLinkVersion"
|
||||
*/
|
||||
export const getDocumentationLinkFromAutocompleteContext = (
|
||||
{ endpoint }: AutoCompleteContext,
|
||||
docLinkVersion: string
|
||||
): string | null => {
|
||||
export const getDocumentationLink = (request: EditorRequest, docLinkVersion: string) => {
|
||||
// get the url parts from the request url
|
||||
const { urlPathTokens } = parseUrlTokens(request.url);
|
||||
// remove the last token, if it's empty
|
||||
if (!urlPathTokens[urlPathTokens.length - 1]) {
|
||||
urlPathTokens.pop();
|
||||
}
|
||||
// add the end of url token
|
||||
urlPathTokens.push(endOfUrlToken);
|
||||
const { endpoint } = populateContextForMethodAndUrl(request.method, urlPathTokens);
|
||||
if (endpoint && endpoint.documentation && endpoint.documentation.indexOf('http') !== -1) {
|
||||
return endpoint.documentation
|
||||
.replace('/master/', `/${docLinkVersion}/`)
|
||||
|
@ -105,3 +133,263 @@ export const getDocumentationLinkFromAutocompleteContext = (
|
|||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/*
|
||||
* This function converts the start offset value of the parsed request to a line number in the model
|
||||
*/
|
||||
export const getRequestStartLineNumber = (
|
||||
parsedRequest: ParsedRequest,
|
||||
model: monaco.editor.ITextModel
|
||||
): number => {
|
||||
return model.getPositionAt(parsedRequest.startOffset).lineNumber;
|
||||
};
|
||||
|
||||
/*
|
||||
* This function converts the end offset value of the parsed request to a line number in the model.
|
||||
* If there is no end offset (the parser was not able to parse this request completely),
|
||||
* then the last non-empty line is returned or the line before the next request.
|
||||
*/
|
||||
export const getRequestEndLineNumber = (
|
||||
parsedRequest: ParsedRequest,
|
||||
model: monaco.editor.ITextModel,
|
||||
index: number,
|
||||
parsedRequests: ParsedRequest[]
|
||||
): number => {
|
||||
let endLineNumber: number;
|
||||
if (parsedRequest.endOffset) {
|
||||
// if the parser set an end offset for this request, then find the line number for it
|
||||
endLineNumber = model.getPositionAt(parsedRequest.endOffset).lineNumber;
|
||||
} else {
|
||||
// if no end offset, try to find the line before the next request starts
|
||||
const nextRequest = parsedRequests.at(index + 1);
|
||||
if (nextRequest) {
|
||||
const nextRequestStartLine = model.getPositionAt(nextRequest.startOffset).lineNumber;
|
||||
endLineNumber = nextRequestStartLine - 1;
|
||||
} else {
|
||||
// if there is no next request, take the last line of the model
|
||||
endLineNumber = model.getLineCount();
|
||||
}
|
||||
}
|
||||
// if the end line is empty, go up to find the first non-empty line
|
||||
let lineContent = model.getLineContent(endLineNumber).trim();
|
||||
while (!lineContent) {
|
||||
endLineNumber = endLineNumber - 1;
|
||||
lineContent = model.getLineContent(endLineNumber).trim();
|
||||
}
|
||||
return endLineNumber;
|
||||
};
|
||||
|
||||
/*
|
||||
* This function returns an array of completion items for the request method
|
||||
*/
|
||||
const autocompleteMethods = ['GET', 'PUT', 'POST', 'DELETE', 'HEAD', 'PATCH'];
|
||||
export const getMethodCompletionItems = (
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position
|
||||
): monaco.languages.CompletionItem[] => {
|
||||
// get the word before suggestions to replace when selecting a suggestion from the list
|
||||
const wordUntilPosition = model.getWordUntilPosition(position);
|
||||
return autocompleteMethods.map((method) => ({
|
||||
label: method,
|
||||
insertText: method,
|
||||
detail: methodDetailLabel,
|
||||
// only used to configure the icon
|
||||
kind: monaco.languages.CompletionItemKind.Constant,
|
||||
range: {
|
||||
// replace the whole word with the suggestion
|
||||
startColumn: wordUntilPosition.startColumn,
|
||||
startLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
endLineNumber: position.lineNumber,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
/*
|
||||
* This function splits a string on whitespaces and returns its parts as an array
|
||||
*/
|
||||
export const getLineTokens = (lineContent: string): string[] => {
|
||||
return lineContent.split(whitespacesRegex);
|
||||
};
|
||||
|
||||
/*
|
||||
* This function checks if the url contains url params
|
||||
*/
|
||||
const questionMarkRegex = /\?/;
|
||||
export const containsUrlParams = (lineContent: string): boolean => {
|
||||
return questionMarkRegex.test(lineContent);
|
||||
};
|
||||
|
||||
/*
|
||||
* This function initializes the autocomplete context for the provided method and url token path.
|
||||
*/
|
||||
const populateContextForMethodAndUrl = (method: string, urlTokenPath: string[]) => {
|
||||
// get autocomplete components for the request method
|
||||
const components = getTopLevelUrlCompleteComponents(method);
|
||||
// this object will contain the information later, it needs to be initialized with some data
|
||||
// similar to the old ace editor context
|
||||
const context: AutoCompleteContext = {
|
||||
method,
|
||||
urlTokenPath,
|
||||
};
|
||||
|
||||
// mutate the context object and put the autocomplete information there
|
||||
populateContext(urlTokenPath, context, undefined, true, components);
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
/*
|
||||
* This function returns an array of completion items for the request method and the url path
|
||||
*/
|
||||
export const getUrlPathCompletionItems = (
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position
|
||||
): monaco.languages.CompletionItem[] => {
|
||||
const { lineNumber, column } = position;
|
||||
// get the content of the line up until the current position
|
||||
const lineContent = model.getValueInRange({
|
||||
startLineNumber: lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: lineNumber,
|
||||
endColumn: column,
|
||||
});
|
||||
|
||||
// get the method and previous url parts for context
|
||||
const { method, urlPathTokens } = parseLineContent(lineContent);
|
||||
// remove the last token that is either empty if the url has like "_search/" as the last char
|
||||
// or it's a word that need to be replaced with autocomplete suggestions like "_search/s"
|
||||
urlPathTokens.pop();
|
||||
const { autoCompleteSet } = populateContextForMethodAndUrl(method, urlPathTokens);
|
||||
|
||||
const wordUntilPosition = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
// replace the whole word with the suggestion
|
||||
startColumn: lineContent.endsWith('.')
|
||||
? // if there is a dot at the end of the content, it's ignored in the wordUntilPosition
|
||||
wordUntilPosition.startColumn - 1
|
||||
: wordUntilPosition.startColumn,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
};
|
||||
if (autoCompleteSet && autoCompleteSet.length > 0) {
|
||||
return (
|
||||
autoCompleteSet
|
||||
// filter autocomplete items without a name
|
||||
.filter(({ name }) => Boolean(name))
|
||||
// map autocomplete items to completion items
|
||||
.map((item) => {
|
||||
return {
|
||||
label: item.name!,
|
||||
insertText: item.name!,
|
||||
detail: item.meta ?? endpointDetailLabel,
|
||||
// the kind is only used to configure the icon
|
||||
kind: monaco.languages.CompletionItemKind.Constant,
|
||||
range,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
/*
|
||||
* This function returns an array of completion items for the url params
|
||||
*/
|
||||
export const getUrlParamsCompletionItems = (
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position
|
||||
): monaco.languages.CompletionItem[] => {
|
||||
const { lineNumber, column } = position;
|
||||
// get the content of the line up until the current position
|
||||
const lineContent = model.getValueInRange({
|
||||
startLineNumber: lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: lineNumber,
|
||||
endColumn: column,
|
||||
});
|
||||
|
||||
// get the method and previous url parts for context
|
||||
const { method, urlPathTokens, urlParamsTokens } = parseLineContent(lineContent);
|
||||
urlPathTokens.push(endOfUrlToken);
|
||||
const context = populateContextForMethodAndUrl(method, urlPathTokens);
|
||||
|
||||
const urlParamsComponents = context.endpoint?.paramsAutocomplete.getTopLevelComponents(method);
|
||||
|
||||
const currentUrlParamToken = urlParamsTokens.pop();
|
||||
// check if we are at the param name or the param value
|
||||
const urlParamTokenPath = [];
|
||||
// if there are 2 tokens in the current url param, then we have the name and the value of the param
|
||||
if (currentUrlParamToken && currentUrlParamToken.length > 1) {
|
||||
urlParamTokenPath.push(currentUrlParamToken![0]);
|
||||
}
|
||||
|
||||
populateContext(urlParamTokenPath, context, undefined, true, urlParamsComponents);
|
||||
|
||||
if (context.autoCompleteSet && context.autoCompleteSet.length > 0) {
|
||||
const wordUntilPosition = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
// replace the whole word with the suggestion
|
||||
startColumn: wordUntilPosition.startColumn,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
};
|
||||
return (
|
||||
context.autoCompleteSet
|
||||
// filter autocomplete items without a name
|
||||
.filter(({ name }) => Boolean(name))
|
||||
// map autocomplete items to completion items
|
||||
.map((item) => {
|
||||
return {
|
||||
label: item.name!,
|
||||
insertText: item.name!,
|
||||
detail: item.meta ?? paramDetailLabel,
|
||||
// the kind is only used to configure the icon
|
||||
kind: monaco.languages.CompletionItemKind.Constant,
|
||||
range,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const parseLineContent = (lineContent: string): ParsedLineTokens => {
|
||||
// try to parse into method and url (split on whitespace)
|
||||
const parts = lineContent.split(whitespacesRegex);
|
||||
// 1st part is the method
|
||||
const method = parts[0];
|
||||
// 2nd part is the url
|
||||
const url = parts[1];
|
||||
// try to parse into url path and url params (split on question mark)
|
||||
const { urlPathTokens, urlParamsTokens } = parseUrlTokens(url);
|
||||
return { method, urlPathTokens, urlParamsTokens };
|
||||
};
|
||||
|
||||
const parseUrlTokens = (
|
||||
url: string
|
||||
): {
|
||||
urlPathTokens: ParsedLineTokens['urlPathTokens'];
|
||||
urlParamsTokens: ParsedLineTokens['urlParamsTokens'];
|
||||
} => {
|
||||
let urlPathTokens: ParsedLineTokens['urlPathTokens'] = [];
|
||||
let urlParamsTokens: ParsedLineTokens['urlParamsTokens'] = [];
|
||||
const urlParts = url.split(questionMarkRegex);
|
||||
// 1st part is the url path
|
||||
const urlPath = urlParts[0];
|
||||
// try to parse into url path tokens (split on slash)
|
||||
if (urlPath) {
|
||||
urlPathTokens = urlPath.split(slashRegex);
|
||||
}
|
||||
// 2nd part is the url params
|
||||
const urlParams = urlParts[1];
|
||||
// try to parse into url param tokens
|
||||
if (urlParams) {
|
||||
urlParamsTokens = urlParams.split(ampersandRegex).map((urlParamsPart) => {
|
||||
return urlParamsPart.split(equalsSignRegex);
|
||||
});
|
||||
}
|
||||
return { urlPathTokens, urlParamsTokens };
|
||||
};
|
||||
|
|
|
@ -337,11 +337,7 @@ export default function ({
|
|||
}
|
||||
}
|
||||
|
||||
function addMetaToTermsList(
|
||||
list: unknown[],
|
||||
meta: unknown,
|
||||
template?: string
|
||||
): Array<{ meta: unknown; template: unknown; name?: string }> {
|
||||
function addMetaToTermsList(list: ResultTerm[], meta: string, template?: string): ResultTerm[] {
|
||||
return _.map(list, function (t) {
|
||||
if (typeof t !== 'object') {
|
||||
t = { name: t };
|
||||
|
@ -984,7 +980,7 @@ export default function ({
|
|||
|
||||
const components = getTopLevelUrlCompleteComponents(context.method);
|
||||
let urlTokenPath = context.urlTokenPath;
|
||||
let predicate: (term: ReturnType<typeof addMetaToTermsList>[0]) => boolean = () => true;
|
||||
let predicate: (term: ResultTerm) => boolean = () => true;
|
||||
|
||||
const tokenIter = createTokenIterator({ editor, position: pos });
|
||||
const currentTokenType = tokenIter.getCurrentToken()?.type;
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import { CoreEditor, Range, Token } from '../../types';
|
||||
|
||||
export interface ResultTerm {
|
||||
meta?: string;
|
||||
context?: AutoCompleteContext;
|
||||
insertValue?: string;
|
||||
name?: string;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue