[Console] Implement documentation action button (#181057)

## Summary
Closes https://github.com/elastic/kibana/issues/180209 

This PR implements the "view documentation" button in the new Monaco
editor in Console. The code re-use the existing autocomplete
functionality and gets the documentation link for the current request
from autocomplete definitions. The current request is the 1st request of
the user selection in the editor. The link is opened in the new tab and
if no link is available or the request is unknown, then nothing happens
(existing functionality, we might want to hide the button in that case
in a [follow up work](https://github.com/elastic/kibana/issues/180911))

### Screen recording 



56ea016c-02b6-4134-97b7-914204557d61

### How to test
1. Add `console.dev.enableMonaco: true` to the `config/kibana.dev.yml`
file
2. Start Kibana and ES locally
3. Navigate to the Dev tools Console and try using the "view
documentation" button for various requests

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yulia Čech 2024-04-22 14:05:08 +02:00 committed by GitHub
parent 753e8c7917
commit e18d19fafc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 158 additions and 3 deletions

View file

@ -37,6 +37,7 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
settings: settingsService,
autocompleteInfo,
},
docLinkVersion,
} = useServicesContext();
const { toasts } = notifications;
const { settings } = useEditorReadContext();
@ -53,6 +54,10 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
return curl ?? '';
}, [esHostService]);
const getDocumenationLink = useCallback(async () => {
return actionsProvider.current!.getDocumentationLink(docLinkVersion);
}, [docLinkVersion]);
const sendRequestsCallback = useCallback(async () => {
await actionsProvider.current?.sendRequests(toasts, dispatch, trackUiMetric, http);
}, [dispatch, http, toasts, trackUiMetric]);
@ -103,9 +108,7 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
<EuiFlexItem>
<ConsoleMenu
getCurl={getCurlCallback}
getDocumentation={() => {
return Promise.resolve(null);
}}
getDocumentation={getDocumenationLink}
autoIndent={() => {}}
notifications={notifications}
/>

View file

@ -10,6 +10,12 @@
* Mock kbn/monaco to provide the console parser code directly without a web worker
*/
const mockGetParsedRequests = jest.fn();
/*
* Mock the function "populateContext" that accesses the autocomplete definitions
*/
const mockPopulateContext = jest.fn();
jest.mock('@kbn/monaco', () => {
const original = jest.requireActual('@kbn/monaco');
return {
@ -33,6 +39,14 @@ jest.mock('../../../../services', () => {
};
});
jest.mock('../../../../lib/autocomplete/engine', () => {
return {
populateContext: (...args: any) => {
mockPopulateContext(args);
},
};
});
import { MonacoEditorActionsProvider } from './monaco_editor_actions_provider';
import { monaco } from '@kbn/monaco';
@ -101,4 +115,36 @@ describe('Editor actions provider', () => {
expect(curl).toBe('curl -XGET "http://localhost/_search" -H "kbn-xsrf: reporting"');
});
});
describe('getDocumentationLink', () => {
const docLinkVersion = '8.13';
const docsLink = 'http://elastic.co/_search';
// 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 = {
documentation: docsLink,
};
});
it('returns null if no requests', async () => {
mockGetParsedRequests.mockResolvedValue([]);
const link = await editorActionsProvider.getDocumentationLink(docLinkVersion);
expect(link).toBe(null);
});
it('returns null 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 link = await editorActionsProvider.getDocumentationLink(docLinkVersion);
expect(link).toBe(null);
});
it('returns the correct link if there is a request in the selection range', async () => {
const link = await editorActionsProvider.getDocumentationLink(docLinkVersion);
expect(link).toBe(docsLink);
});
});
});

View file

@ -17,8 +17,11 @@ 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';
@ -27,6 +30,8 @@ import {
replaceRequestVariables,
getCurlRequest,
trackSentRequests,
tokenizeRequestUrl,
getDocumentationLinkFromAutocompleteContext,
} from './utils';
const selectedRequestsClass = 'console__monaco_editor__selectedRequests';
@ -235,4 +240,29 @@ export class MonacoEditorActionsProvider {
}
}
}
public async getDocumentationLink(docLinkVersion: string): Promise<string | null> {
const requests = await this.getRequests();
if (requests.length < 1) {
return null;
}
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);
// 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,
};
// 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);
}
}

View file

@ -8,12 +8,15 @@
import {
getCurlRequest,
getDocumentationLinkFromAutocompleteContext,
removeTrailingWhitespaces,
replaceRequestVariables,
stringifyRequest,
tokenizeRequestUrl,
trackSentRequests,
} from './utils';
import { MetricsTracker } from '../../../../types';
import { AutoCompleteContext } from '../../../../lib/autocomplete/types';
describe('monaco editor utils', () => {
const dataObjects = [
@ -179,4 +182,46 @@ describe('monaco editor utils', () => {
expect(mockMetricsTracker.count).toHaveBeenNthCalledWith(2, 'POST__test');
});
});
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', () => {
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);
expect(link).toBe(expectedLink);
});
it('correctly replaces /master/ with the version', () => {
const endpoint = {
documentation: 'http://elastic.co/master/_search',
} as AutoCompleteContext['endpoint'];
const link = getDocumentationLinkFromAutocompleteContext({ endpoint }, version);
expect(link).toBe(expectedLink);
});
it('correctly replaces /current/ with the version', () => {
const endpoint = {
documentation: 'http://elastic.co/current/_search',
} as AutoCompleteContext['endpoint'];
const link = getDocumentationLinkFromAutocompleteContext({ endpoint }, version);
expect(link).toBe(expectedLink);
});
});
});

View file

@ -7,6 +7,7 @@
*/
import { ParsedRequest } from '@kbn/monaco';
import { AutoCompleteContext } from '../../../../lib/autocomplete/types';
import { constructUrl } from '../../../../lib/es';
import type { DevToolsVariable } from '../../../components';
import { EditorRequest } from './monaco_editor_actions_provider';
@ -74,3 +75,33 @@ export const trackSentRequests = (
trackUiMetric.count(eventName);
});
};
/*
* This function takes a request url as a string and returns it parts,
* for example '_search/test' => ['_search', 'test']
*/
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 => {
if (endpoint && endpoint.documentation && endpoint.documentation.indexOf('http') !== -1) {
return endpoint.documentation
.replace('/master/', `/${docLinkVersion}/`)
.replace('/current/', `/${docLinkVersion}/`)
.replace('/{branch}/', `/${docLinkVersion}/`);
}
return null;
};