[Console Monaco migration] Implement history (#183181)

## Summary

Fixes https://github.com/elastic/kibana/issues/182948 

This PR migrates history component from ace to monaco and re-implements
the logic to insert the saved requests into the monaco editor.

To test: 
1. click the "History" tab and check that the component is using Monaco
to display requests
2. Check that "Clear history" button works
3. Check that a request from history can be inserted into the editor
- when the cursor is on the 1st line of the request, the history request
is inserted before the request in the editor
- if the cursor is not on the 1st line of the request, the history
request is inserted after the request in the editor



### Screen recording 


6031ee6a-8211-4ca7-96d7-3aafbaee0509

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yulia Čech 2024-05-22 16:43:13 +02:00 committed by GitHub
parent 7e21a1e130
commit 9194c8857c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 419 additions and 39 deletions

View file

@ -23,6 +23,7 @@ import {
import { useServicesContext } from '../../contexts';
import { HistoryViewer } from './history_viewer';
import { HistoryViewer as HistoryViewerMonaco } from './history_viewer_monaco';
import { useEditorReadContext } from '../../contexts/editor_context';
import { useRestoreRequestFromHistory } from '../../hooks';
@ -35,6 +36,7 @@ const CHILD_ELEMENT_PREFIX = 'historyReq';
export function ConsoleHistory({ close }: Props) {
const {
services: { history },
config: { isMonacoEnabled },
} = useServicesContext();
const { settings: readOnlySettings } = useEditorReadContext();
@ -91,7 +93,7 @@ export function ConsoleHistory({ close }: Props) {
initialize();
};
const restoreRequestFromHistory = useRestoreRequestFromHistory();
const restoreRequestFromHistory = useRestoreRequestFromHistory(isMonacoEnabled);
useEffect(() => {
initialize();
@ -181,7 +183,11 @@ export function ConsoleHistory({ close }: Props) {
<div className="conHistory__body__spacer" />
<HistoryViewer settings={readOnlySettings} req={viewingReq} />
{isMonacoEnabled ? (
<HistoryViewerMonaco settings={readOnlySettings} req={viewingReq} />
) : (
<HistoryViewer settings={readOnlySettings} req={viewingReq} />
)}
</div>
<EuiSpacer size="s" />

View file

@ -0,0 +1,71 @@
/*
* 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 React, { useCallback, useRef } from 'react';
import { css } from '@emotion/react';
import { CONSOLE_LANG_ID, CONSOLE_THEME_ID, monaco } from '@kbn/monaco';
import { CodeEditor } from '@kbn/code-editor';
import { i18n } from '@kbn/i18n';
import { formatRequestBodyDoc } from '../../../lib/utils';
import { DevToolsSettings } from '../../../services';
import { useResizeCheckerUtils } from '../editor/monaco/hooks';
export const HistoryViewer = ({
settings,
req,
}: {
settings: DevToolsSettings;
req: { method: string; endpoint: string; data: string; time: string } | null;
}) => {
const divRef = useRef<HTMLDivElement | null>(null);
const { setupResizeChecker, destroyResizeChecker } = useResizeCheckerUtils();
const editorDidMountCallback = useCallback(
(editor: monaco.editor.IStandaloneCodeEditor) => {
setupResizeChecker(divRef.current!, editor);
},
[setupResizeChecker]
);
const editorWillUnmountCallback = useCallback(() => {
destroyResizeChecker();
}, [destroyResizeChecker]);
let renderedHistoryRequest: string;
if (req) {
const indent = true;
const formattedData = req.data ? formatRequestBodyDoc([req.data], indent).data : '';
renderedHistoryRequest = req.method + ' ' + req.endpoint + '\n' + formattedData;
} else {
renderedHistoryRequest = i18n.translate('console.historyPage.noHistoryTextMessage', {
defaultMessage: 'No history available',
});
}
return (
<div
css={css`
width: 100%;
`}
ref={divRef}
>
<CodeEditor
languageId={CONSOLE_LANG_ID}
value={renderedHistoryRequest}
fullWidth={true}
editorDidMount={editorDidMountCallback}
editorWillUnmount={editorWillUnmountCallback}
options={{
readOnly: true,
fontSize: settings.fontSize,
wordWrap: settings.wrapMode ? 'on' : 'off',
theme: CONSOLE_THEME_ID,
automaticLayout: true,
}}
/>
</div>
);
};

View file

@ -30,6 +30,7 @@ interface SetInitialValueParams {
/**
* Util function for reading the load_from parameter from the current url.
*/
export const readLoadFromParam = () => {
const [, queryString] = (window.location.hash || window.location.search || '').split('?');

View file

@ -12,6 +12,7 @@ import { css } from '@emotion/react';
import { CodeEditor } from '@kbn/code-editor';
import { CONSOLE_LANG_ID, CONSOLE_THEME_ID, monaco } from '@kbn/monaco';
import { i18n } from '@kbn/i18n';
import { useSetInputEditor } from '../../../hooks';
import { ConsoleMenu } from '../../../components';
import {
useServicesContext,
@ -33,17 +34,11 @@ export interface EditorProps {
}
export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
const context = useServicesContext();
const {
services: {
notifications,
esHostService,
trackUiMetric,
http,
settings: settingsService,
autocompleteInfo,
},
services: { notifications, esHostService, settings: settingsService, autocompleteInfo },
docLinkVersion,
} = useServicesContext();
} = context;
const { toasts } = notifications;
const { settings } = useEditorReadContext();
@ -55,6 +50,7 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
const actionsProvider = useRef<MonacoEditorActionsProvider | null>(null);
const [editorActionsCss, setEditorActionsCss] = useState<CSSProperties>({});
const setInputEditor = useSetInputEditor();
const getCurlCallback = useCallback(async (): Promise<string> => {
const curl = await actionsProvider.current?.getCurl(esHostService.getHost());
return curl ?? '';
@ -69,12 +65,14 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
}, []);
const sendRequestsCallback = useCallback(async () => {
await actionsProvider.current?.sendRequests(toasts, dispatch, trackUiMetric, http);
}, [dispatch, http, toasts, trackUiMetric]);
await actionsProvider.current?.sendRequests(dispatch, context);
}, [dispatch, context]);
const editorDidMountCallback = useCallback(
(editor: monaco.editor.IStandaloneCodeEditor) => {
actionsProvider.current = new MonacoEditorActionsProvider(editor, setEditorActionsCss);
const provider = new MonacoEditorActionsProvider(editor, setEditorActionsCss);
setInputEditor(provider);
actionsProvider.current = provider;
setupResizeChecker(divRef.current!, editor);
registerKeyboardCommands({
editor,
@ -86,7 +84,13 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
moveToNextRequestEdge: async () => await actionsProvider.current?.moveToNextRequestEdge(),
});
},
[getDocumenationLink, registerKeyboardCommands, sendRequestsCallback, setupResizeChecker]
[
getDocumenationLink,
registerKeyboardCommands,
sendRequestsCallback,
setupResizeChecker,
setInputEditor,
]
);
const editorWillUnmountCallback = useCallback(() => {

View file

@ -69,6 +69,7 @@ describe('Editor actions provider', () => {
getPosition: jest.fn(),
getTopForLineNumber: jest.fn(),
getScrollTop: jest.fn(),
executeEdits: jest.fn(),
setPosition: jest.fn(),
} as unknown as jest.Mocked<monaco.editor.IStandaloneCodeEditor>;
@ -401,4 +402,160 @@ describe('Editor actions provider', () => {
});
});
});
describe('restoreRequestFromHistory', () => {
const testHistoryRequest = 'GET _alias';
beforeEach(() => {
/*
* The editor has the text
* "POST _search" on line 1
* { "test": "test" } on lines 2-4
* and "GET _analyze" on line 5
*/
mockGetParsedRequests.mockReturnValue([
{
startOffset: 0,
method: 'POST',
url: '_search',
endOffset: 35,
data: [
{
test: 'test',
},
],
},
{
startOffset: 36,
method: 'GET',
url: '_analyze',
endOffset: 48,
},
]);
editor.getModel.mockReturnValue({
getLineMaxColumn: (lineNumber: number) => {
// mock this function for line 4
return 2;
},
getPositionAt: (offset: number) => {
// mock this function for start offsets of the mocked requests
if (offset === 0) {
return { lineNumber: 1, column: 1 };
}
if (offset === 36) {
return { lineNumber: 5, column: 1 };
}
// mock this function for end offsets of the mocked requests
if (offset === 35) {
return { lineNumber: 4, column: 2 };
}
if (offset === 48) {
return { lineNumber: 5, column: 13 };
}
},
getLineContent: (lineNumber: number) => {
// mock this functions for line 1 and line 2
if (lineNumber === 1) {
return 'POST _search';
}
if (lineNumber === 2) {
return '{';
}
if (lineNumber === 3) {
return ' "test": "test"';
}
if (lineNumber === 4) {
return '}';
}
if (lineNumber === 5) {
return 'GET _analyze';
}
},
} as unknown as monaco.editor.ITextModel);
});
it('insert the request at the beginning of the selected request', async () => {
// the position of the cursor is in the middle of line 5
editor.getPosition.mockReturnValue({
lineNumber: 5,
column: 4,
} as monaco.Position);
editor.getSelection.mockReturnValue({
startLineNumber: 5,
endLineNumber: 5,
} as monaco.Selection);
await editorActionsProvider.restoreRequestFromHistory(testHistoryRequest);
const expectedRange = {
startLineNumber: 5,
startColumn: 1,
endLineNumber: 5,
endColumn: 1,
};
const expectedText = testHistoryRequest + '\n';
const expectedEdit = {
range: expectedRange,
text: expectedText,
forceMoveMarkers: true,
};
expect(editor.executeEdits).toHaveBeenCalledTimes(1);
expect(editor.executeEdits).toHaveBeenCalledWith('restoreFromHistory', [expectedEdit]);
});
it('insert the request at the end of the selected request', async () => {
// the position of the cursor is at the end of line 4
editor.getPosition.mockReturnValue({
lineNumber: 4,
column: 2,
} as monaco.Position);
editor.getSelection.mockReturnValue({
startLineNumber: 4,
endLineNumber: 4,
} as monaco.Selection);
await editorActionsProvider.restoreRequestFromHistory(testHistoryRequest);
const expectedRange = {
startLineNumber: 4,
startColumn: 2,
endLineNumber: 4,
endColumn: 2,
};
const expectedText = '\n' + testHistoryRequest;
const expectedEdit = {
range: expectedRange,
text: expectedText,
forceMoveMarkers: true,
};
expect(editor.executeEdits).toHaveBeenCalledTimes(1);
expect(editor.executeEdits).toHaveBeenCalledWith('restoreFromHistory', [expectedEdit]);
});
it('insert at the beginning of the line, if no selected request', async () => {
// mock no parsed requests
mockGetParsedRequests.mockReturnValue([]);
// the position of the cursor is at the end of line 4
editor.getPosition.mockReturnValue({
lineNumber: 4,
column: 2,
} as monaco.Position);
editor.getSelection.mockReturnValue({
startLineNumber: 4,
endLineNumber: 4,
} as monaco.Selection);
await editorActionsProvider.restoreRequestFromHistory(testHistoryRequest);
const expectedRange = {
startLineNumber: 4,
startColumn: 1,
endLineNumber: 4,
endColumn: 1,
};
const expectedText = testHistoryRequest + '\n';
const expectedEdit = {
range: expectedRange,
text: expectedText,
forceMoveMarkers: true,
};
expect(editor.executeEdits).toHaveBeenCalledTimes(1);
expect(editor.executeEdits).toHaveBeenCalledWith('restoreFromHistory', [expectedEdit]);
});
});
});

View file

@ -9,13 +9,12 @@
import { CSSProperties, Dispatch } from 'react';
import { debounce } from 'lodash';
import { ConsoleParsedRequestsProvider, getParsedRequestsProvider, monaco } from '@kbn/monaco';
import { IToasts } from '@kbn/core-notifications-browser';
import { i18n } from '@kbn/i18n';
import type { HttpSetup } from '@kbn/core-http-browser';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { isQuotaExceededError } from '../../../../services/history';
import { DEFAULT_VARIABLES } from '../../../../../common/constants';
import { getStorage, StorageKeys } from '../../../../services';
import { sendRequest } from '../../../hooks';
import { MetricsTracker } from '../../../../types';
import { Actions } from '../../../stores/request';
import {
@ -38,6 +37,8 @@ import {
} from './utils';
import type { AdjustedParsedRequest } from './types';
import { StorageQuotaError } from '../../../components/storage_quota_error';
import { ContextValue } from '../../../contexts';
const AUTO_INDENTATION_ACTION_LABEL = 'Apply indentations';
@ -180,12 +181,12 @@ export class MonacoEditorActionsProvider {
return curlRequests.join('\n');
}
public async sendRequests(
toasts: IToasts,
dispatch: Dispatch<Actions>,
trackUiMetric: MetricsTracker,
http: HttpSetup
): Promise<void> {
public async sendRequests(dispatch: Dispatch<Actions>, context: ContextValue): Promise<void> {
const {
services: { notifications, trackUiMetric, http, settings, history, autocompleteInfo },
startServices,
} = context;
const { toasts } = notifications;
try {
const requests = await this.getRequests();
if (!requests.length) {
@ -205,8 +206,63 @@ export class MonacoEditorActionsProvider {
const results = await sendRequest({ http, requests });
// TODO save to history
// TODO restart autocomplete polling
let saveToHistoryError: undefined | Error;
const isHistoryEnabled = settings.getIsHistoryEnabled();
if (isHistoryEnabled) {
results.forEach(({ request: { path, method, data } }) => {
try {
history.addToHistory(path, method, data);
} catch (e) {
// Grab only the first error
if (!saveToHistoryError) {
saveToHistoryError = e;
}
}
});
if (saveToHistoryError) {
const errorTitle = i18n.translate('console.notification.error.couldNotSaveRequestTitle', {
defaultMessage: 'Could not save request to Console history.',
});
if (isQuotaExceededError(saveToHistoryError)) {
const toast = notifications.toasts.addWarning({
title: i18n.translate('console.notification.error.historyQuotaReachedMessage', {
defaultMessage:
'Request history is full. Clear the console history or disable saving new requests.',
}),
text: toMountPoint(
StorageQuotaError({
onClearHistory: () => {
history.clearHistory();
notifications.toasts.remove(toast);
},
onDisableSavingToHistory: () => {
settings.setIsHistoryEnabled(false);
notifications.toasts.remove(toast);
},
}),
startServices
),
});
} else {
// Best effort, but still notify the user.
notifications.toasts.addError(saveToHistoryError, {
title: errorTitle,
});
}
}
}
const polling = settings.getPolling();
if (polling) {
// If the user has submitted a request against ES, something in the fields, indices, aliases,
// or templates may have changed, so we'll need to update this data. Assume that if
// the user disables polling they're trying to optimize performance or otherwise
// preserve resources, so they won't want this request sent either.
autocompleteInfo.retrieve(settings, settings.getAutocomplete());
}
dispatch({
type: 'requestSuccess',
payload: {
@ -347,6 +403,54 @@ export class MonacoEditorActionsProvider {
return this.getSuggestions(model, position, context);
}
/*
* This function inserts a request from the history into the editor
*/
public async restoreRequestFromHistory(request: string) {
const model = this.editor.getModel();
if (!model) {
return;
}
let position = this.editor.getPosition() as monaco.IPosition;
const requests = await this.getSelectedParsedRequests();
let prefix = '';
let suffix = '';
// if there are requests at the cursor/selection, insert either before or after
if (requests.length > 0) {
// if on the 1st line of the 1st request, insert at the beginning of that line
if (position && position.lineNumber === requests[0].startLineNumber) {
position = { column: 1, lineNumber: position.lineNumber };
suffix = '\n';
} else {
// otherwise insert at the end of the last line of the last request
const lastLineNumber = requests[requests.length - 1].endLineNumber;
position = { column: model.getLineMaxColumn(lastLineNumber), lineNumber: lastLineNumber };
prefix = '\n';
}
} else {
// if not inside a request, insert the request at the cursor line
if (position) {
// insert at the beginning of the cursor line
position = { lineNumber: position.lineNumber, column: 1 };
} else {
// otherwise insert on line 1
position = { lineNumber: 1, column: 1 };
}
suffix = '\n';
}
const edit: monaco.editor.IIdentifiedSingleEditOperation = {
range: {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column,
},
text: prefix + request + suffix,
forceMoveMarkers: true,
};
this.editor.executeEdits('restoreFromHistory', [edit]);
}
/*
This function returns the text in the provided range.
If no range is provided, it returns all text in the editor.

View file

@ -308,7 +308,6 @@ const getInsertText = (
} else {
templateLines = JSON.stringify(template, null, 2).split(newLineRegex);
}
// TODO add correct indentation
insertText += ': ' + templateLines.join('\n');
} else if (value === '{') {
insertText += '{}';

View file

@ -6,12 +6,13 @@
* Side Public License, v 1.
*/
import { MonacoEditorActionsProvider } from '../../containers/editor/monaco/monaco_editor_actions_provider';
import { SenseEditor } from '../../models/sense_editor';
export class EditorRegistry {
private inputEditor: SenseEditor | undefined;
private inputEditor: SenseEditor | MonacoEditorActionsProvider | undefined;
setInputEditor(inputEditor: SenseEditor) {
setInputEditor(inputEditor: SenseEditor | MonacoEditorActionsProvider) {
this.inputEditor = inputEditor;
}

View file

@ -30,7 +30,7 @@ export interface ContextValue {
services: ContextServices;
docLinkVersion: string;
docLinks: DocLinksStart['links'];
config?: {
config: {
isMonacoEnabled: boolean;
};
startServices: ConsoleStartServices;

View file

@ -0,0 +1,24 @@
/*
* 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 { formatRequestBodyDoc } from '../../../lib/utils';
import { MonacoEditorActionsProvider } from '../../containers/editor/monaco/monaco_editor_actions_provider';
import { ESRequest } from '../../../types';
export async function restoreRequestFromHistoryToMonaco(
provider: MonacoEditorActionsProvider,
req: ESRequest
) {
let s = req.method + ' ' + req.endpoint;
if (req.data) {
const indent = true;
const formattedData = formatRequestBodyDoc([req.data], indent);
s += '\n' + formattedData.data;
}
await provider.restoreRequestFromHistory(s);
}

View file

@ -7,13 +7,23 @@
*/
import { useCallback } from 'react';
import { SenseEditor } from '../../models';
import { instance as registry } from '../../contexts/editor_context/editor_registry';
import { ESRequest } from '../../../types';
import { restoreRequestFromHistory } from './restore_request_from_history';
import { restoreRequestFromHistoryToMonaco } from './restore_request_from_history_to_monaco';
import { MonacoEditorActionsProvider } from '../../containers/editor/monaco/monaco_editor_actions_provider';
export const useRestoreRequestFromHistory = () => {
return useCallback((req: ESRequest) => {
const editor = registry.getInputEditor();
restoreRequestFromHistory(editor, req);
}, []);
export const useRestoreRequestFromHistory = (isMonacoEnabled: boolean) => {
return useCallback(
async (req: ESRequest) => {
const editor = registry.getInputEditor();
if (isMonacoEnabled) {
await restoreRequestFromHistoryToMonaco(editor as MonacoEditorActionsProvider, req);
} else {
restoreRequestFromHistory(editor as SenseEditor, req);
}
},
[isMonacoEnabled]
);
};

View file

@ -19,6 +19,7 @@ import { track } from './track';
import { replaceVariables } from '../../../lib/utils';
import { StorageKeys } from '../../../services';
import { DEFAULT_VARIABLES } from '../../../../common/constants';
import { SenseEditor } from '../../models';
export const useSendCurrentRequest = () => {
const {
@ -30,7 +31,7 @@ export const useSendCurrentRequest = () => {
return useCallback(async () => {
try {
const editor = registry.getInputEditor();
const editor = registry.getInputEditor() as SenseEditor;
const variables = storage.get(StorageKeys.VARIABLES, DEFAULT_VARIABLES);
let requests = await editor.getRequestsInRange();
requests = replaceVariables(requests, variables);
@ -47,7 +48,7 @@ export const useSendCurrentRequest = () => {
dispatch({ type: 'sendRequest', payload: undefined });
// Fire and forget
setTimeout(() => track(requests, editor, trackUiMetric), 0);
setTimeout(() => track(requests, editor as SenseEditor, trackUiMetric), 0);
const results = await sendRequest({ http, requests });

View file

@ -10,12 +10,13 @@ import { useCallback } from 'react';
import { useEditorActionContext } from '../contexts/editor_context';
import { instance as registry } from '../contexts/editor_context/editor_registry';
import { SenseEditor } from '../models';
import { MonacoEditorActionsProvider } from '../containers/editor/monaco/monaco_editor_actions_provider';
export const useSetInputEditor = () => {
const dispatch = useEditorActionContext();
return useCallback(
(editor: SenseEditor) => {
(editor: SenseEditor | MonacoEditorActionsProvider) => {
dispatch({ type: 'setInputEditor', payload: editor });
registry.setInputEditor(editor);
},

View file

@ -12,6 +12,7 @@ import { identity } from 'fp-ts/lib/function';
import { DevToolsSettings, DEFAULT_SETTINGS } from '../../services';
import { TextObject } from '../../../common/text_object';
import { SenseEditor } from '../models';
import { MonacoEditorActionsProvider } from '../containers/editor/monaco/monaco_editor_actions_provider';
export interface Store {
ready: boolean;
@ -29,7 +30,7 @@ export const initialValue: Store = produce<Store>(
);
export type Action =
| { type: 'setInputEditor'; payload: SenseEditor }
| { type: 'setInputEditor'; payload: SenseEditor | MonacoEditorActionsProvider }
| { type: 'setCurrentTextObject'; payload: TextObject }
| { type: 'updateSettings'; payload: DevToolsSettings };