[Console] Monaco migration: send request and copy as curl buttons (#179808)

## Summary

Closes https://github.com/elastic/kibana/issues/178990

This PR adds the actions buttons to the monaco editor and the
functionality for the buttons to send a request and copy the request as
a curl command. When the cursor or user selection doesn't overlap or
contain any requests on the selected line, the actions buttons are
hidden. When the cursor or selection changes, the buttons are displayed
on the 1st line of the 1st selected request. Several requests can be
sent at once. Only the 1st request is copied as a curl command.

There are also some minor UI and copy changes (see screenshots below) as
suggested by @MichaelMarcialis in the review.

### Screenshot 

#### Before

<img width="882" alt="Screenshot 2024-04-15 at 15 16 59"
src="8fa95a5b-51c6-4220-8837-38adc4696602">
<img width="296" alt="Screenshot 2024-04-15 at 15 17 07"
src="6c99a4db-f7a6-4872-a24a-cddb56e0ec3d">

#### After
<img width="916" alt="Screenshot 2024-04-15 at 15 18 08"
src="1ae0b161-c731-42e1-90af-69468e9f0905">
<img width="367" alt="Screenshot 2024-04-15 at 15 18 14"
src="bb8818bd-38d4-4ec3-8bf4-30e24afd3664">


### How to test
- Check that the actions buttons are not displayed when there are no
requests in the input
- Check that the actions buttons are not displayed when no requests are
selected by the cursor or the selection
- Check that the selected requests are highlighted
- Check that the buttons are displayed on the 1st line of the 1st
selected request
- Check that the position of the buttons is updated when the editor is
scrolled up or down

### Follow up issues 
- The functionality for the button to open a documentation for the 1st
selected request
- The functionality for the button to auto-indent input
- The input highlighting is temporarily reset when sending a request
- Sent requests need to be saved to history
- After sending a request, the autocomplete polling needs to be
restarted
- Add more unit tests
This commit is contained in:
Yulia Čech 2024-04-16 15:16:30 +02:00 committed by GitHub
parent 721d354a13
commit 0b4e60e7d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 995 additions and 43 deletions

View file

@ -37,4 +37,8 @@ export {
CONSOLE_OUTPUT_LANG_ID,
CONSOLE_THEME_ID,
CONSOLE_OUTPUT_THEME_ID,
getParsedRequestsProvider,
ConsoleParsedRequestsProvider,
} from './src/console';
export type { ParsedRequest } from './src/console';

View file

@ -0,0 +1,63 @@
/*
* 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 { ConsoleWorkerProxyService } from './console_worker_proxy';
import { CONSOLE_LANG_ID } from './constants';
import { monaco } from '../monaco_imports';
/*
* This setup function runs when the Console language is registered into the Monaco editor.
* It adds a listener that is attached to the editor input when the Monaco editor is used
* with the Console language.
* The Console parser that runs in a web worker analyzes the editor input when it changes and
* if any errors are found, they are added as "error markers" to the Monaco editor.
*/
export const setupConsoleErrorsProvider = (workerProxyService: ConsoleWorkerProxyService) => {
const updateErrorMarkers = async (model: monaco.editor.IModel): Promise<void> => {
if (model.isDisposed()) {
return;
}
const parserResult = await workerProxyService.getParserResult(model.uri);
if (!parserResult) {
return;
}
const { errors } = parserResult;
monaco.editor.setModelMarkers(
model,
CONSOLE_LANG_ID,
errors.map(({ offset, text }) => {
const { column, lineNumber } = model.getPositionAt(offset);
return {
startLineNumber: lineNumber,
startColumn: column,
endLineNumber: lineNumber,
endColumn: column,
message: text,
severity: monaco.MarkerSeverity.Error,
};
})
);
};
const onModelAdd = (model: monaco.editor.IModel) => {
if (model.getLanguageId() !== CONSOLE_LANG_ID) {
return;
}
const { dispose } = model.onDidChangeContent(async () => {
await updateErrorMarkers(model);
});
model.onWillDispose(() => {
dispose();
});
updateErrorMarkers(model);
};
monaco.editor.onDidCreateModel(onModelAdd);
};

View file

@ -0,0 +1,31 @@
/*
* 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 { ConsoleWorkerProxyService } from './console_worker_proxy';
import { ParsedRequest } from './types';
import { monaco } from '../monaco_imports';
/*
* This class is a helper interface that is used in the Console plugin.
* The provider access the Console parser that runs in a web worker and analyzes the editor input
* when it changes.
* The parsed result contains the requests and errors which are used in the Console plugin.
*/
export class ConsoleParsedRequestsProvider {
constructor(
private workerProxyService: ConsoleWorkerProxyService,
private model: monaco.editor.ITextModel | null
) {}
public async getRequests(): Promise<ParsedRequest[]> {
if (!this.model) {
return [];
}
const parserResult = await this.workerProxyService.getParserResult(this.model.uri);
return parserResult?.requests ?? [];
}
}

View file

@ -0,0 +1,37 @@
/*
* 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 '../monaco_imports';
import { CONSOLE_LANG_ID } from './constants';
import { ConsoleParserResult, ConsoleWorkerDefinition } from './types';
/*
* This class contains logic to create a web worker where the code for the Console parser can
* execute without blocking the main thread. The parser only runs when the Monaco editor
* is used with the Console language. The parser can only be accessed via this proxy service class.
*/
export class ConsoleWorkerProxyService {
private worker: monaco.editor.MonacoWebWorker<ConsoleWorkerDefinition> | undefined;
public async getParserResult(modelUri: monaco.Uri): Promise<ConsoleParserResult | undefined> {
if (!this.worker) {
throw new Error('Worker Proxy Service has not been setup!');
}
await this.worker.withSyncedResources([modelUri]);
const parser = await this.worker.getProxy();
return parser.getParserResult(modelUri.toString());
}
public setup() {
this.worker = monaco.editor.createWebWorker({ label: CONSOLE_LANG_ID, moduleId: '' });
}
public stop() {
if (this.worker) this.worker.dispose();
}
}

View file

@ -40,3 +40,7 @@ export const ConsoleOutputLang: LangModuleType = {
lexerRules: consoleOutputLexerRules,
languageConfiguration: consoleOutputLanguageConfiguration,
};
export type { ParsedRequest } from './types';
export { getParsedRequestsProvider } from './language';
export { ConsoleParsedRequestsProvider } from './console_parsed_requests_provider';

View file

@ -6,14 +6,19 @@
* Side Public License, v 1.
*/
import { ConsoleWorker } from './worker';
import { WorkerProxyService } from '../ace_migration/worker_proxy';
import { setupConsoleErrorsProvider } from './console_errors_provider';
import { ConsoleWorkerProxyService } from './console_worker_proxy';
import { monaco } from '../monaco_imports';
import { CONSOLE_LANG_ID } from './constants';
import { setupWorker } from '../ace_migration/setup_worker';
import { ConsoleParsedRequestsProvider } from './console_parsed_requests_provider';
const workerProxyService = new ConsoleWorkerProxyService();
export const getParsedRequestsProvider = (model: monaco.editor.ITextModel | null) => {
return new ConsoleParsedRequestsProvider(workerProxyService, model);
};
const OWNER = 'CONSOLE_GRAMMAR_CHECKER';
const wps = new WorkerProxyService<ConsoleWorker>();
monaco.languages.onLanguage(CONSOLE_LANG_ID, async () => {
setupWorker(CONSOLE_LANG_ID, OWNER, wps);
workerProxyService.setup();
setupConsoleErrorsProvider(workerProxyService);
});

View file

@ -12,7 +12,6 @@ export const createParser = () => {
let at, // The index of the current character
ch, // The current character
annos, // annotations
escapee = {
'"': '"',
'\\': '\\',
@ -24,8 +23,44 @@ export const createParser = () => {
t: '\t',
},
text,
annotate = function (type, text) {
annos.push({ type: type, text: text, at: at });
errors,
addError = function (text) {
errors.push({ text: text, offset: at });
},
requests,
requestStartOffset,
requestEndOffset,
getLastRequest = function() {
return requests.length > 0 ? requests.pop() : {};
},
addRequestStart = function() {
requestStartOffset = at - 1;
requests.push({ startOffset: requestStartOffset });
},
addRequestMethod = function(method) {
const lastRequest = getLastRequest();
lastRequest.method = method;
requests.push(lastRequest);
requestEndOffset = at - 1;
},
addRequestUrl = function(url) {
const lastRequest = getLastRequest();
lastRequest.url = url;
requests.push(lastRequest);
requestEndOffset = at - 1;
},
addRequestData = function(data) {
const lastRequest = getLastRequest();
const dataArray = lastRequest.data || [];
dataArray.push(data);
lastRequest.data = dataArray;
requests.push(lastRequest);
requestEndOffset = at - 1;
},
addRequestEnd = function() {
const lastRequest = getLastRequest();
lastRequest.endOffset = requestEndOffset;
requests.push(lastRequest);
},
error = function (m) {
throw {
@ -373,14 +408,18 @@ export const createParser = () => {
},
request = function () {
white();
method();
addRequestStart();
const parsedMethod = method();
addRequestMethod(parsedMethod);
strictWhite();
url();
const parsedUrl = url();
addRequestUrl(parsedUrl );
strictWhite(); // advance to one new line
newLine();
strictWhite();
if (ch == '{') {
object();
const parsedObject = object();
addRequestData(parsedObject);
}
// multi doc request
strictWhite(); // advance to one new line
@ -388,11 +427,13 @@ export const createParser = () => {
strictWhite();
while (ch == '{') {
// another object
object();
const parsedObject = object();
addRequestData(parsedObject);
strictWhite();
newLine();
strictWhite();
}
addRequestEnd();
},
comment = function () {
while (ch == '#') {
@ -417,7 +458,7 @@ export const createParser = () => {
request();
white();
} catch (e) {
annotate('error', e.message);
addError(e.message);
// snap
const substring = text.substr(at);
const nextMatch = substring.search(/^POST|HEAD|GET|PUT|DELETE|PATCH/m);
@ -432,15 +473,16 @@ export const createParser = () => {
text = source;
at = 0;
annos = [];
errors = [];
requests = [];
next();
multi_request();
white();
if (ch) {
annotate('error', 'Syntax error');
addError('Syntax error');
}
result = { annotations: annos };
result = { errors, requests };
return typeof reviver === 'function'
? (function walk(holder, key) {

View file

@ -0,0 +1,59 @@
/*
* 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 { createParser } from './parser';
import { ConsoleParserResult } from './types';
const parser = createParser();
describe('console parser', () => {
it('returns errors if input is not correct', () => {
const input = 'Incorrect input';
const parserResult = parser(input) as ConsoleParserResult;
// the parser logs 2 errors: for the unexpected method and a general syntax error
expect(parserResult.errors.length).toBe(2);
// the parser logs a beginning of the request that it's trying to parse
expect(parserResult.requests.length).toBe(1);
});
it('returns parsedRequests if the input is correct', () => {
const input = 'GET _search';
const { requests, errors } = parser(input) as ConsoleParserResult;
expect(requests.length).toBe(1);
expect(errors.length).toBe(0);
const { method, url, startOffset, endOffset } = requests[0];
expect(method).toBe('GET');
expect(url).toBe('_search');
// the start offset of the request is the beginning of the string
expect(startOffset).toBe(0);
// the end offset of the request is the end of the string
expect(endOffset).toBe(11);
});
it('parses several requests', () => {
const input = 'GET _search\nPOST _test_index';
const { requests } = parser(input) as ConsoleParserResult;
expect(requests.length).toBe(2);
});
it('parses a request with a request body', () => {
const input =
'GET _search\n' + '{\n' + ' "query": {\n' + ' "match_all": {}\n' + ' }\n' + '}';
const { requests } = parser(input) as ConsoleParserResult;
expect(requests.length).toBe(1);
const { method, url, data } = requests[0];
expect(method).toBe('GET');
expect(url).toBe('_search');
expect(data).toEqual([
{
query: {
match_all: {},
},
},
]);
});
});

View file

@ -0,0 +1,29 @@
/*
* 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.
*/
export interface ErrorAnnotation {
offset: number;
text: string;
}
export interface ParsedRequest {
startOffset: number;
endOffset: number;
method: string;
url: string;
data?: Array<Record<string, unknown>>;
}
export interface ConsoleParserResult {
errors: ErrorAnnotation[];
requests: ParsedRequest[];
}
export interface ConsoleWorkerDefinition {
getParserResult: (modelUri: string) => ConsoleParserResult | undefined;
}
export type ConsoleParser = (source: string) => ConsoleParserResult | undefined;

View file

@ -8,20 +8,23 @@
/* eslint-disable-next-line @kbn/eslint/module_migration */
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { Parser, ParseResult } from '../../ace_migration/types';
import { ConsoleParserResult, ConsoleWorkerDefinition, ConsoleParser } from '../types';
import { createParser } from '../parser';
export class ConsoleWorker {
constructor(private ctx: monaco.worker.IWorkerContext) {}
private parser: Parser | undefined;
export class ConsoleWorker implements ConsoleWorkerDefinition {
private parser: ConsoleParser | undefined;
private parserResult: ConsoleParserResult | undefined;
async parse(modelUri: string): Promise<ParseResult | undefined> {
constructor(private ctx: monaco.worker.IWorkerContext) {}
getParserResult(modelUri: string): ConsoleParserResult | undefined {
if (!this.parser) {
this.parser = createParser();
}
const model = this.ctx.getMirrorModels().find((m) => m.uri.toString() === modelUri);
if (model) {
return this.parser(model.getValue());
this.parserResult = this.parser(model.getValue());
}
return this.parserResult;
}
}

View file

@ -121,7 +121,7 @@ export class ConsoleMenu extends Component<Props, State> {
defaultMessage: 'Request options',
})}
>
<EuiIcon type="wrench" />
<EuiIcon type="boxesVertical" />
</EuiLink>
);
@ -135,10 +135,22 @@ export class ConsoleMenu extends Component<Props, State> {
this.closePopover();
this.copyAsCurl();
}}
icon="copyClipboard"
>
<FormattedMessage
id="console.requestOptions.copyAsUrlButtonLabel"
defaultMessage="Copy as cURL"
defaultMessage="Copy cURL command"
/>
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="consoleMenuAutoIndent"
key="Auto indent"
onClick={this.autoIndent}
icon="arrowEnd"
>
<FormattedMessage
id="console.requestOptions.autoIndentButtonLabel"
defaultMessage="Apply indentations"
/>
</EuiContextMenuItem>,
<EuiContextMenuItem
@ -147,20 +159,11 @@ export class ConsoleMenu extends Component<Props, State> {
onClick={() => {
this.openDocs();
}}
icon="documentation"
>
<FormattedMessage
id="console.requestOptions.openDocumentationButtonLabel"
defaultMessage="Open documentation"
/>
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="consoleMenuAutoIndent"
key="Auto indent"
onClick={this.autoIndent}
>
<FormattedMessage
id="console.requestOptions.autoIndentButtonLabel"
defaultMessage="Auto indent"
defaultMessage="View documentation"
/>
</EuiContextMenuItem>,
];

View file

@ -288,14 +288,14 @@ function EditorUI({ initialTextValue, setEditorInstance }: EditorProps) {
})}
>
<EuiLink
color="success"
color="primary"
onClick={sendCurrentRequest}
data-test-subj="sendRequestButton"
aria-label={i18n.translate('console.sendRequestButtonTooltip', {
defaultMessage: 'Click to send request',
})}
>
<EuiIcon type="playFilled" />
<EuiIcon type="play" />
</EuiLink>
</EuiToolTip>
</EuiFlexItem>

View file

@ -6,14 +6,22 @@
* Side Public License, v 1.
*/
import React, { useEffect, useState } from 'react';
import { CodeEditor } from '@kbn/code-editor';
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui';
import { css } from '@emotion/react';
import { CONSOLE_LANG_ID, CONSOLE_THEME_ID } from '@kbn/monaco';
import { CodeEditor } from '@kbn/code-editor';
import { CONSOLE_LANG_ID, CONSOLE_THEME_ID, monaco } from '@kbn/monaco';
import { i18n } from '@kbn/i18n';
import { ConsoleMenu } from '../../../components';
import {
useServicesContext,
useEditorReadContext,
useRequestActionContext,
} from '../../../contexts';
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 { useServicesContext, useEditorReadContext } from '../../../contexts';
export interface EditorProps {
initialTextValue: string;
@ -22,12 +30,32 @@ export interface EditorProps {
export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
const {
services: {
notifications: { toasts },
notifications,
esHostService,
trackUiMetric,
http,
settings: settingsService,
autocompleteInfo,
},
} = useServicesContext();
const { toasts } = notifications;
const { settings } = useEditorReadContext();
const dispatch = useRequestActionContext();
const actionsProvider = useRef<MonacoEditorActionsProvider | null>(null);
const [editorActionsCss, setEditorActionsCss] = useState<CSSProperties>({});
const editorDidMountCallback = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => {
actionsProvider.current = new MonacoEditorActionsProvider(editor, setEditorActionsCss);
}, []);
const getCurlCallback = useCallback(async (): Promise<string> => {
const curl = await actionsProvider.current?.getCurl(esHostService.getHost());
return curl ?? '';
}, [esHostService]);
const sendRequestsCallback = useCallback(async () => {
await actionsProvider.current?.sendRequests(toasts, dispatch, trackUiMetric, http);
}, [dispatch, http, toasts, trackUiMetric]);
const [value, setValue] = useState(initialTextValue);
@ -47,6 +75,42 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
width: 100%;
`}
>
<EuiFlexGroup
className="conApp__editorActions"
id="ConAppEditorActions"
gutterSize="none"
responsive={false}
style={editorActionsCss}
>
<EuiFlexItem>
<EuiToolTip
content={i18n.translate('console.sendRequestButtonTooltip', {
defaultMessage: 'Click to send request',
})}
>
<EuiLink
color="primary"
onClick={sendRequestsCallback}
data-test-subj="sendRequestButton"
aria-label={i18n.translate('console.sendRequestButtonTooltip', {
defaultMessage: 'Click to send request',
})}
>
<EuiIcon type="play" />
</EuiLink>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<ConsoleMenu
getCurl={getCurlCallback}
getDocumentation={() => {
return Promise.resolve(null);
}}
autoIndent={() => {}}
notifications={notifications}
/>
</EuiFlexItem>
</EuiFlexGroup>
<CodeEditor
languageId={CONSOLE_LANG_ID}
value={value}
@ -58,6 +122,7 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
wordWrap: settings.wrapMode === true ? 'on' : 'off',
theme: CONSOLE_THEME_ID,
}}
editorDidMount={editorDidMountCallback}
/>
</div>
);

View file

@ -0,0 +1,104 @@
/*
* 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.
*/
/*
* Mock kbn/monaco to provide the console parser code directly without a web worker
*/
const mockGetParsedRequests = jest.fn();
jest.mock('@kbn/monaco', () => {
const original = jest.requireActual('@kbn/monaco');
return {
...original,
getParsedRequestsProvider: () => {
return {
getRequests: mockGetParsedRequests,
};
},
};
});
jest.mock('../../../../services', () => {
return {
getStorage: () => ({
get: () => [],
}),
StorageKeys: {
VARIABLES: 'test',
},
};
});
import { MonacoEditorActionsProvider } from './monaco_editor_actions_provider';
import { monaco } from '@kbn/monaco';
describe('Editor actions provider', () => {
let editorActionsProvider: MonacoEditorActionsProvider;
let editor: jest.Mocked<monaco.editor.IStandaloneCodeEditor>;
beforeEach(() => {
editor = {
getModel: jest.fn(),
createDecorationsCollection: () => ({
clear: jest.fn(),
set: jest.fn(),
}),
focus: jest.fn(),
onDidChangeCursorPosition: jest.fn(),
onDidScrollChange: jest.fn(),
onDidChangeCursorSelection: jest.fn(),
onDidContentSizeChange: jest.fn(),
getSelection: jest.fn(),
getTopForLineNumber: jest.fn(),
getScrollTop: jest.fn(),
} as unknown as jest.Mocked<monaco.editor.IStandaloneCodeEditor>;
editor.getModel.mockReturnValue({
getLineMaxColumn: () => 10,
getPositionAt: () => ({ lineNumber: 1 }),
getLineContent: () => 'GET _search',
} as unknown as monaco.editor.ITextModel);
editor.getSelection.mockReturnValue({
startLineNumber: 1,
endLineNumber: 1,
} as unknown as monaco.Selection);
mockGetParsedRequests.mockResolvedValue([
{
startOffset: 0,
endOffset: 11,
method: 'GET',
url: '_search',
},
]);
const setEditorActionsCssMock = jest.fn();
editorActionsProvider = new MonacoEditorActionsProvider(editor, setEditorActionsCssMock);
});
describe('getCurl', () => {
it('returns an empty string if no requests', async () => {
mockGetParsedRequests.mockResolvedValue([]);
const curl = await editorActionsProvider.getCurl('http://localhost');
expect(curl).toBe('');
});
it('returns an empty string if there is a request but not in the selection range', async () => {
editor.getSelection.mockReturnValue({
// the request is on line 1, the user selected line 2
startLineNumber: 2,
endLineNumber: 2,
} as unknown as monaco.Selection);
const curl = await editorActionsProvider.getCurl('http://localhost');
expect(curl).toBe('');
});
it('returns the correct string if there is a request in the selection range', async () => {
const curl = await editorActionsProvider.getCurl('http://localhost');
expect(curl).toBe('curl -XGET "http://localhost/_search" -H "kbn-xsrf: reporting"');
});
});
});

View file

@ -0,0 +1,238 @@
/*
* 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 { CSSProperties, Dispatch } from 'react';
import { debounce } from 'lodash';
import {
ConsoleParsedRequestsProvider,
getParsedRequestsProvider,
monaco,
ParsedRequest,
} from '@kbn/monaco';
import { IToasts } from '@kbn/core-notifications-browser';
import { i18n } from '@kbn/i18n';
import type { HttpSetup } from '@kbn/core-http-browser';
import { DEFAULT_VARIABLES } from '../../../../../common/constants';
import { getStorage, StorageKeys } from '../../../../services';
import { sendRequest } from '../../../hooks/use_send_current_request/send_request';
import { MetricsTracker } from '../../../../types';
import { Actions } from '../../../stores/request';
import {
stringifyRequest,
replaceRequestVariables,
getCurlRequest,
trackSentRequests,
} from './utils';
const selectedRequestsClass = 'console__monaco_editor__selectedRequests';
export interface EditorRequest {
method: string;
url: string;
data: string[];
}
export class MonacoEditorActionsProvider {
private parsedRequestsProvider: ConsoleParsedRequestsProvider;
private highlightedLines: monaco.editor.IEditorDecorationsCollection;
constructor(
private editor: monaco.editor.IStandaloneCodeEditor,
private setEditorActionsCss: (css: CSSProperties) => void
) {
this.parsedRequestsProvider = getParsedRequestsProvider(this.editor.getModel());
this.highlightedLines = this.editor.createDecorationsCollection();
this.editor.focus();
const debouncedHighlightRequests = debounce(() => this.highlightRequests(), 200, {
leading: true,
});
debouncedHighlightRequests();
// init all listeners
editor.onDidChangeCursorPosition(async (event) => {
await debouncedHighlightRequests();
});
editor.onDidScrollChange(async (event) => {
await debouncedHighlightRequests();
});
editor.onDidChangeCursorSelection(async (event) => {
await debouncedHighlightRequests();
});
editor.onDidContentSizeChange(async (event) => {
await debouncedHighlightRequests();
});
}
private updateEditorActions(lineNumber?: number) {
// if no request is currently selected, hide the actions buttons
if (!lineNumber) {
this.setEditorActionsCss({
visibility: 'hidden',
});
} else {
// if a request is selected, the actions buttons are placed at lineNumberOffset - scrollOffset
const offset = this.editor.getTopForLineNumber(lineNumber) - this.editor.getScrollTop();
this.setEditorActionsCss({
visibility: 'visible',
top: offset,
});
}
}
private async highlightRequests(): Promise<void> {
// get the requests in the selected range
const { range: selectedRange, parsedRequests } = await this.getSelectedParsedRequestsAndRange();
// 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);
this.highlightedLines.set([
{
range: selectedRange,
options: {
isWholeLine: true,
className: selectedRequestsClass,
},
},
]);
} else {
// if no requests are selected, hide actions buttons and remove highlighted lines
this.updateEditorActions();
this.highlightedLines.clear();
}
}
private async getSelectedParsedRequestsAndRange(): Promise<{
parsedRequests: ParsedRequest[];
range: monaco.IRange;
}> {
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),
});
}
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);
// sometimes the parser includes a trailing empty line into the request
if (requestEndLineContent.trim().length < 1) {
requestEndLine = requestEndLine - 1;
}
if (requestStartLine > endLineNumber) {
// request is past the selection, no need to check further requests
break;
}
if (requestEndLine < 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;
}
}
}
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)
),
};
}
private async getRequests() {
const { parsedRequests } = await this.getSelectedParsedRequestsAndRange();
const stringifiedRequests = parsedRequests.map((parsedRequest) =>
stringifyRequest(parsedRequest)
);
// get variables values
const variables = getStorage().get(StorageKeys.VARIABLES, DEFAULT_VARIABLES);
return stringifiedRequests.map((request) => replaceRequestVariables(request, variables));
}
public async getCurl(elasticsearchBaseUrl: string): Promise<string> {
const requests = await this.getRequests();
const curlRequests = requests.map((request) => getCurlRequest(request, elasticsearchBaseUrl));
return curlRequests.join('\n');
}
public async sendRequests(
toasts: IToasts,
dispatch: Dispatch<Actions>,
trackUiMetric: MetricsTracker,
http: HttpSetup
): Promise<void> {
try {
const requests = await this.getRequests();
if (!requests.length) {
toasts.add(
i18n.translate('console.notification.error.noRequestSelectedTitle', {
defaultMessage:
'No request selected. Select a request by placing the cursor inside it.',
})
);
return;
}
dispatch({ type: 'sendRequest', payload: undefined });
// track the requests
setTimeout(() => trackSentRequests(requests, trackUiMetric), 0);
const results = await sendRequest({ http, requests });
// TODO save to history
// TODO restart autocomplete polling
dispatch({
type: 'requestSuccess',
payload: {
data: results,
},
});
} catch (e) {
if (e?.response) {
dispatch({
type: 'requestFail',
payload: e,
});
} else {
dispatch({
type: 'requestFail',
payload: undefined,
});
toasts.addError(e, {
title: i18n.translate('console.notification.error.unknownErrorTitle', {
defaultMessage: 'Unknown Request Error',
}),
});
}
}
}
}

View file

@ -0,0 +1,182 @@
/*
* 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 {
getCurlRequest,
removeTrailingWhitespaces,
replaceRequestVariables,
stringifyRequest,
trackSentRequests,
} from './utils';
import { MetricsTracker } from '../../../../types';
describe('monaco editor utils', () => {
const dataObjects = [
{
query: {
match_all: {},
},
},
{
test: 'test',
},
];
describe('removeTrailingWhitespaces', () => {
it(`works with an empty string`, () => {
const url = '';
const result = removeTrailingWhitespaces(url);
expect(result).toBe(url);
});
it(`doesn't change the string if no trailing whitespaces`, () => {
const url = '_search';
const result = removeTrailingWhitespaces(url);
expect(result).toBe(url);
});
it(`removes any text after the first whitespace`, () => {
const url = '_search some_text';
const result = removeTrailingWhitespaces(url);
expect(result).toBe('_search');
});
});
describe('stringifyRequest', () => {
const request = {
startOffset: 0,
endOffset: 11,
method: 'get',
url: '_search some_text',
};
it('calls the "removeTrailingWhitespaces" on the url', () => {
const stringifiedRequest = stringifyRequest(request);
expect(stringifiedRequest.url).toBe('_search');
});
it('normalizes the method to upper case', () => {
const stringifiedRequest = stringifyRequest(request);
expect(stringifiedRequest.method).toBe('GET');
});
it('stringifies the request body', () => {
const result = stringifyRequest({ ...request, data: [dataObjects[0]] });
expect(result.data.length).toBe(1);
expect(result.data[0]).toBe(JSON.stringify(dataObjects[0], null, 2));
});
it('works for several request bodies', () => {
const result = stringifyRequest({ ...request, data: dataObjects });
expect(result.data.length).toBe(2);
expect(result.data[0]).toBe(JSON.stringify(dataObjects[0], null, 2));
expect(result.data[1]).toBe(JSON.stringify(dataObjects[1], null, 2));
});
});
describe('replaceRequestVariables', () => {
const variables = [
{ id: '1', name: 'variable1', value: 'test1' },
{
id: '2',
name: 'variable2',
value: 'test2',
},
];
describe('replaces variables in the url', () => {
const request = {
method: 'GET',
url: '${variable1}',
data: [],
};
it('when there is no other text', () => {
const result = replaceRequestVariables(request, variables);
expect(result.url).toBe('test1');
});
it('inside a string', () => {
const result = replaceRequestVariables(
{ ...request, url: 'test_${variable1}_test' },
variables
);
expect(result.url).toBe('test_test1_test');
});
it('works with several variables', () => {
const result = replaceRequestVariables(
{ ...request, url: '${variable1}_${variable2}' },
variables
);
expect(result.url).toBe('test1_test2');
});
});
describe('replaces variables in the request body', () => {
const request = {
method: 'GET',
url: '${variable1}',
data: [JSON.stringify({ '${variable1}': '${variable2}' }, null, 2)],
};
it('works with several variables', () => {
const result = replaceRequestVariables(request, variables);
expect(result.data[0]).toBe(JSON.stringify({ test1: 'test2' }, null, 2));
});
});
});
describe('getCurlRequest', () => {
it('works without a request body', () => {
const request = { method: 'GET', url: '_search', data: [] };
const result = getCurlRequest(request, 'http://test.com');
expect(result).toBe('curl -XGET "http://test.com/_search" -H "kbn-xsrf: reporting"');
});
it('works with a request body', () => {
const request = {
method: 'GET',
url: '_search',
data: [JSON.stringify(dataObjects[0], null, 2)],
};
const result = getCurlRequest(request, 'http://test.com');
expect(result).toBe(
'curl -XGET "http://test.com/_search" -H "kbn-xsrf: reporting" -H "Content-Type: application/json" -d\'\n' +
'{\n' +
' "query": {\n' +
' "match_all": {}\n' +
' }\n' +
"}'"
);
});
it('works with several request bodies', () => {
const request = {
method: 'GET',
url: '_search',
data: [JSON.stringify(dataObjects[0], null, 2), JSON.stringify(dataObjects[1], null, 2)],
};
const result = getCurlRequest(request, 'http://test.com');
expect(result).toBe(
'curl -XGET "http://test.com/_search" -H "kbn-xsrf: reporting" -H "Content-Type: application/json" -d\'\n' +
'{\n' +
' "query": {\n' +
' "match_all": {}\n' +
' }\n' +
'}\n' +
'{\n' +
' "test": "test"\n' +
"}'"
);
});
});
describe('trackSentRequests', () => {
it('tracks each request correctly', () => {
const requests = [
{ method: 'GET', url: '_search', data: [] },
{ method: 'POST', url: '_test', data: [] },
];
const mockMetricsTracker: jest.Mocked<MetricsTracker> = { count: jest.fn(), load: jest.fn() };
trackSentRequests(requests, mockMetricsTracker);
expect(mockMetricsTracker.count).toHaveBeenCalledTimes(2);
expect(mockMetricsTracker.count).toHaveBeenNthCalledWith(1, 'GET__search');
expect(mockMetricsTracker.count).toHaveBeenNthCalledWith(2, 'POST__test');
});
});
});

View file

@ -0,0 +1,76 @@
/*
* 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 { ParsedRequest } from '@kbn/monaco';
import { constructUrl } from '../../../../lib/es';
import type { DevToolsVariable } from '../../../components';
import { EditorRequest } from './monaco_editor_actions_provider';
import { MetricsTracker } from '../../../../types';
const whitespacesRegex = /\s/;
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];
};
export const stringifyRequest = (parsedRequest: ParsedRequest): EditorRequest => {
const url = removeTrailingWhitespaces(parsedRequest.url);
const method = parsedRequest.method.toUpperCase();
const data = parsedRequest.data?.map((parsedData) => JSON.stringify(parsedData, null, 2));
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) => {
const variable = variables.find(({ name }) => name === key);
return variable?.value ?? match;
});
}
return text;
};
export const replaceRequestVariables = (
{ method, url, data }: EditorRequest,
variables: DevToolsVariable[]
): EditorRequest => {
return {
method,
url: replaceVariables(url, variables),
data: data.map((dataObject) => replaceVariables(dataObject, variables)),
};
};
export const getCurlRequest = (
{ method, url, data }: EditorRequest,
elasticsearchBaseUrl: string
): string => {
const curlUrl = constructUrl(elasticsearchBaseUrl, url);
let curlRequest = `curl -X${method} "${curlUrl}" -H "kbn-xsrf: reporting"`;
if (data.length > 0) {
curlRequest += ` -H "Content-Type: application/json" -d'\n`;
curlRequest += data.join('\n');
curlRequest += "'";
}
return curlRequest;
};
export const trackSentRequests = (
requests: EditorRequest[],
trackUiMetric: MetricsTracker
): void => {
requests.map(({ method, url }) => {
const eventName = `${method}_${url}`;
trackUiMetric.count(eventName);
});
};

View file

@ -125,3 +125,10 @@
.conApp__tabsExtension {
border-bottom: $euiBorderThin;
}
/*
* The highlighting for the selected requests in the monaco editor
*/
.console__monaco_editor__selectedRequests {
background: transparentize($euiColorLightShade, .3);
}