[Console] Support creating variables (#134215)

* Support creating variables

* Fix checks

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* Fix type checks

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* Parse input values

* Add case insensitive flag for parsing input

* Parse request in range

* Minor refactor
* Add functional tests
* Reduce bundle size

* Remove unused vars

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* Memoize callbacks

* Minor refactor.

* Add unit tests
* Refactor

* Remove unused imports

* Fix functional tests

* Change modal to flyout

* Address comments

* Improve accessibility and usability

* Fix default request test case

* Fix checks

* Address comments

Co-authored-by: Muhammad Ibragimov <muhammad.ibragimov@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Muhammad Ibragimov 2022-07-18 14:45:17 +05:00 committed by GitHub
parent 3514f42b46
commit 20edc9b02a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 698 additions and 19 deletions

View file

@ -8,3 +8,4 @@
export { MAJOR_VERSION } from './plugin';
export { API_BASE_PATH, KIBANA_API_PREFIX } from './api';
export { DEFAULT_VARIABLES } from './variables';

View file

@ -0,0 +1,14 @@
/*
* 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 uuid from 'uuid';
export const DEFAULT_VARIABLES = [
{ id: uuid.v4(), name: 'exampleVariable1', value: '_search' },
{ id: uuid.v4(), name: 'exampleVariable2', value: 'match_all' },
];

View file

@ -6,6 +6,9 @@
* Side Public License, v 1.
*/
import React from 'react';
import { withSuspense } from '@kbn/shared-ux-utility';
export { NetworkRequestStatusBar } from './network_request_status_bar';
export { SomethingWentWrongCallout } from './something_went_wrong_callout';
export type { TopNavMenuItem } from './top_nav_menu';
@ -13,6 +16,40 @@ export { TopNavMenu } from './top_nav_menu';
export { ConsoleMenu } from './console_menu';
export { WelcomePanel } from './welcome_panel';
export type { AutocompleteOptions } from './settings_modal';
export { DevToolsSettingsModal } from './settings_modal';
export { HelpPanel } from './help_panel';
export { EditorContentSpinner } from './editor_content_spinner';
export type { DevToolsVariable } from './variables';
/**
* The Lazily-loaded `DevToolsSettingsModal` component. Consumers should use `React.Suspense` or
* the withSuspense` HOC to load this component.
*/
export const DevToolsSettingsModalLazy = React.lazy(() =>
import('./settings_modal').then(({ DevToolsSettingsModal }) => ({
default: DevToolsSettingsModal,
}))
);
/**
* A `DevToolsSettingsModal` component that is wrapped by the `withSuspense` HOC. This component can
* be used directly by consumers and will load the `DevToolsSettingsModalLazy` component lazily with
* a predefined fallback and error boundary.
*/
export const DevToolsSettingsModal = withSuspense(DevToolsSettingsModalLazy);
/**
* The Lazily-loaded `DevToolsVariablesFlyout` component. Consumers should use `React.Suspense` or
* the withSuspense` HOC to load this component.
*/
export const DevToolsVariablesFlyoutLazy = React.lazy(() =>
import('./variables').then(({ DevToolsVariablesFlyout }) => ({
default: DevToolsVariablesFlyout,
}))
);
/**
* A `DevToolsVariablesFlyout` component that is wrapped by the `withSuspense` HOC. This component can
* be used directly by consumers and will load the `DevToolsVariablesFlyoutLazy` component lazily with
* a predefined fallback and error boundary.
*/
export const DevToolsVariablesFlyout = withSuspense(DevToolsVariablesFlyoutLazy);

View file

@ -59,7 +59,7 @@ const intervalOptions = PRESETS_IN_MINUTES.map((value) => ({
: everyNMinutesTimeInterval(value),
}));
interface Props {
export interface DevToolsSettingsModalProps {
onSaveSettings: (newSettings: DevToolsSettings) => void;
onClose: () => void;
refreshAutocompleteSettings: (selectedSettings: DevToolsSettings['autocomplete']) => void;
@ -67,7 +67,7 @@ interface Props {
editorInstance: SenseEditor | null;
}
export function DevToolsSettingsModal(props: Props) {
export const DevToolsSettingsModal = (props: DevToolsSettingsModalProps) => {
const [fontSize, setFontSize] = useState(props.settings.fontSize);
const [wrapMode, setWrapMode] = useState(props.settings.wrapMode);
const [fields, setFields] = useState(props.settings.autocomplete.fields);
@ -355,4 +355,4 @@ export function DevToolsSettingsModal(props: Props) {
</EuiModalFooter>
</EuiModal>
);
}
};

View file

@ -0,0 +1,10 @@
/*
* 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 * from './variables_flyout';
export * from './utils';

View file

@ -0,0 +1,39 @@
/*
* 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 uuid from 'uuid';
import type { DevToolsVariable } from './variables_flyout';
export const editVariable = (
name: string,
value: string,
id: string,
variables: DevToolsVariable[]
) => {
const index = variables.findIndex((v) => v.id === id);
if (index === -1) {
return variables;
}
return [
...variables.slice(0, index),
{ ...variables[index], [name]: value },
...variables.slice(index + 1),
];
};
export const deleteVariable = (variables: DevToolsVariable[], id: string) => {
return variables.filter((v) => v.id !== id);
};
export const generateEmptyVariableField = (): DevToolsVariable => ({
id: uuid.v4(),
name: '',
value: '',
});

View file

@ -0,0 +1,215 @@
/*
* 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, { useState, useCallback, ChangeEvent, FormEvent } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlyout,
EuiFlyoutHeader,
EuiTitle,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiButtonEmpty,
EuiBasicTable,
EuiFieldText,
useGeneratedHtmlId,
EuiForm,
EuiFormRow,
EuiButtonIcon,
EuiSpacer,
EuiText,
type EuiBasicTableColumn,
} from '@elastic/eui';
import * as utils from './utils';
export interface DevToolsVariablesFlyoutProps {
onClose: () => void;
onSaveVariables: (newVariables: DevToolsVariable[]) => void;
variables: [];
}
export interface DevToolsVariable {
id: string;
name: string;
value: string;
}
export const DevToolsVariablesFlyout = (props: DevToolsVariablesFlyoutProps) => {
const [variables, setVariables] = useState<DevToolsVariable[]>(props.variables);
const formId = useGeneratedHtmlId({ prefix: '__console' });
const addNewVariable = useCallback(() => {
setVariables((v) => [...v, utils.generateEmptyVariableField()]);
}, []);
const deleteVariable = useCallback(
(id: string) => {
const updatedVariables = utils.deleteVariable(variables, id);
setVariables(updatedVariables);
},
[variables]
);
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
props.onSaveVariables(variables.filter(({ name, value }) => name.trim() && value));
},
[props, variables]
);
const onChange = useCallback(
(event: ChangeEvent<HTMLInputElement>, id: string) => {
const { name, value } = event.target;
const editedVariables = utils.editVariable(name, value, id, variables);
setVariables(editedVariables);
},
[variables]
);
const columns: Array<EuiBasicTableColumn<DevToolsVariable>> = [
{
field: 'name',
name: i18n.translate('console.variablesPage.variablesTable.columns.variableHeader', {
defaultMessage: 'Variable name',
}),
render: (name, { id }) => {
// Avoid characters that get URL-encoded, because they'll result in unusable variable names.
const isInvalid = name && !name.match(/^[a-zA-Z0-9]+$/g);
return (
<EuiFormRow
isInvalid={isInvalid}
error={[
<FormattedMessage
id="console.variablesPage.variablesTable.variableInputError.validCharactersText"
defaultMessage="Only letters and numbers are allowed"
/>,
]}
fullWidth={true}
css={{ flexGrow: 1 }}
>
<EuiFieldText
data-test-subj="variablesNameInput"
name="name"
value={name}
onChange={(e) => onChange(e, id)}
isInvalid={isInvalid}
fullWidth={true}
aria-label={i18n.translate(
'console.variablesPage.variablesTable.variableInput.ariaLabel',
{
defaultMessage: 'Variable name',
}
)}
/>
</EuiFormRow>
);
},
},
{
field: 'value',
name: i18n.translate('console.variablesPage.variablesTable.columns.valueHeader', {
defaultMessage: 'Value',
}),
render: (value, { id }) => (
<EuiFieldText
data-test-subj="variablesValueInput"
name="value"
onChange={(e) => onChange(e, id)}
value={value}
aria-label={i18n.translate('console.variablesPage.variablesTable.valueInput.ariaLabel', {
defaultMessage: 'Variable value',
})}
/>
),
},
{
field: 'id',
name: '',
width: '5%',
render: (id: string) => (
<EuiButtonIcon
iconType="trash"
aria-label="Delete"
color="danger"
onClick={() => deleteVariable(id)}
data-test-subj="variablesRemoveButton"
/>
),
},
];
return (
<EuiFlyout onClose={props.onClose}>
<EuiFlyoutHeader hasBorder>
<EuiTitle>
<h2>
<FormattedMessage id="console.variablesPage.pageTitle" defaultMessage="Variables" />
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText color="subdued">
<p>
<FormattedMessage
id="console.variablesPage.descriptionText"
defaultMessage="Define variables and use them in your requests in the form of {variable}."
values={{
variable: (
<code>
<FormattedMessage
id="console.variablesPage.descriptionText.variableNameText"
defaultMessage="{variableName}"
values={{
variableName: '${variableName}',
}}
/>
</code>
),
}}
/>
</p>
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiForm id={formId} component="form" onSubmit={onSubmit}>
<EuiBasicTable items={variables} columns={columns} />
<EuiButtonEmpty
data-test-subj="variablesAddButton"
iconType="plus"
onClick={addNewVariable}
>
<FormattedMessage id="console.variablesPage.addButtonLabel" defaultMessage="Add" />
</EuiButtonEmpty>
</EuiForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty data-test-subj="variablesCancelButton" onClick={props.onClose}>
<FormattedMessage
id="console.variablesPage.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill data-test-subj="variablesSaveButton" type="submit" form={formId}>
<FormattedMessage id="console.variablesPage.saveButtonLabel" defaultMessage="Save" />
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
jest.mock('../../../../../lib/utils', () => ({ replaceVariables: jest.fn() }));
import './editor.test.mock';
import React from 'react';
@ -30,6 +32,7 @@ import { getEndpointFromPosition } from '../../../../../lib/autocomplete/get_end
import type { DevToolsSettings } from '../../../../../services';
import * as consoleMenuActions from '../console_menu_actions';
import { Editor } from './editor';
import * as utils from '../../../../../lib/utils';
describe('Legacy (Ace) Console Editor Component Smoke Test', () => {
let mockedAppContextValue: ContextValue;
@ -51,6 +54,7 @@ describe('Legacy (Ace) Console Editor Component Smoke Test', () => {
beforeEach(() => {
document.queryCommandSupported = sinon.fake(() => true);
mockedAppContextValue = serviceContextMock.create();
(utils.replaceVariables as jest.Mock).mockReturnValue(['test']);
});
afterEach(() => {

View file

@ -54,10 +54,11 @@ const abs: CSSProperties = {
right: '0',
};
const DEFAULT_INPUT_VALUE = `GET _search
const DEFAULT_INPUT_VALUE = `# Click the Variables button, above, to create your own variables.
GET \${exampleVariable1} // _search
{
"query": {
"match_all": {}
"\${exampleVariable2}": {} // match_all
}
}`;

View file

@ -12,9 +12,15 @@ interface Props {
onClickHistory: () => void;
onClickSettings: () => void;
onClickHelp: () => void;
onClickVariables: () => void;
}
export function getTopNavConfig({ onClickHistory, onClickSettings, onClickHelp }: Props) {
export function getTopNavConfig({
onClickHistory,
onClickSettings,
onClickHelp,
onClickVariables,
}: Props) {
return [
{
id: 'history',
@ -42,6 +48,19 @@ export function getTopNavConfig({ onClickHistory, onClickSettings, onClickHelp }
},
testId: 'consoleSettingsButton',
},
{
id: 'variables',
label: i18n.translate('console.topNav.variablesTabLabel', {
defaultMessage: 'Variables',
}),
description: i18n.translate('console.topNav.variablesTabDescription', {
defaultMessage: 'Variables',
}),
onClick: () => {
onClickVariables();
},
testId: 'consoleVariablesButton',
},
{
id: 'help',
label: i18n.translate('console.topNav.helpTabLabel', {

View file

@ -12,6 +12,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiPageContent } from '@elastic/eu
import { ConsoleHistory } from '../console_history';
import { Editor } from '../editor';
import { Settings } from '../settings';
import { Variables } from '../variables';
import {
TopNavMenu,
@ -47,6 +48,7 @@ export function Main() {
const [showingHistory, setShowHistory] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [showVariables, setShowVariables] = useState(false);
const [editorInstance, setEditorInstance] = useState<SenseEditor | null>(null);
@ -89,6 +91,7 @@ export function Main() {
onClickHistory: () => setShowHistory(!showingHistory),
onClickSettings: () => setShowSettings(true),
onClickHelp: () => setShowHelp(!showHelp),
onClickVariables: () => setShowVariables(!showVariables),
})}
/>
</EuiFlexItem>
@ -129,6 +132,8 @@ export function Main() {
<Settings onClose={() => setShowSettings(false)} editorInstance={editorInstance} />
) : null}
{showVariables ? <Variables onClose={() => setShowVariables(false)} /> : null}
{showHelp ? <HelpPanel onClose={() => setShowHelp(false)} /> : null}
</div>
);

View file

@ -0,0 +1,35 @@
/*
* 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 from 'react';
import { DevToolsVariablesFlyout, DevToolsVariable } from '../components';
import { useServicesContext } from '../contexts';
import { StorageKeys } from '../../services';
import { DEFAULT_VARIABLES } from '../../../common/constants';
interface VariablesProps {
onClose: () => void;
}
export function Variables({ onClose }: VariablesProps) {
const {
services: { storage },
} = useServicesContext();
const onSaveVariables = (newVariables: DevToolsVariable[]) => {
storage.set(StorageKeys.VARIABLES, newVariables);
onClose();
};
return (
<DevToolsVariablesFlyout
onClose={onClose}
onSaveVariables={onSaveVariables}
variables={storage.get(StorageKeys.VARIABLES, DEFAULT_VARIABLES)}
/>
);
}

View file

@ -10,7 +10,7 @@ import React, { createContext, useContext, useEffect } from 'react';
import { Observable } from 'rxjs';
import type { NotificationsSetup, CoreTheme, DocLinksStart, HttpSetup } from '@kbn/core/public';
import { AutocompleteInfo, History, Settings, Storage } from '../../services';
import type { AutocompleteInfo, History, Settings, Storage } from '../../services';
import { ObjectStorageClient } from '../../../common/types';
import { MetricsTracker } from '../../types';
import { EsHostService } from '../lib';

View file

@ -12,6 +12,7 @@ jest.mock('../../contexts/editor_context/editor_registry', () => ({
}));
jest.mock('./track', () => ({ track: jest.fn() }));
jest.mock('../../contexts/request_context', () => ({ useRequestActionContext: jest.fn() }));
jest.mock('../../../lib/utils', () => ({ replaceVariables: jest.fn() }));
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
@ -20,6 +21,7 @@ import { ContextValue, ServicesContextProvider } from '../../contexts';
import { serviceContextMock } from '../../contexts/services_context.mock';
import { useRequestActionContext } from '../../contexts/request_context';
import { instance as editorRegistry } from '../../contexts/editor_context/editor_registry';
import * as utils from '../../../lib/utils';
import { sendRequest } from './send_request';
import { useSendCurrentRequest } from './use_send_current_request';
@ -35,6 +37,7 @@ describe('useSendCurrentRequest', () => {
mockContextValue = serviceContextMock.create();
dispatch = jest.fn();
(useRequestActionContext as jest.Mock).mockReturnValue(dispatch);
(utils.replaceVariables as jest.Mock).mockReturnValue(['test']);
});
afterEach(() => {

View file

@ -16,10 +16,13 @@ import { useRequestActionContext, useServicesContext } from '../../contexts';
import { StorageQuotaError } from '../../components/storage_quota_error';
import { sendRequest } from './send_request';
import { track } from './track';
import { replaceVariables } from '../../../lib/utils';
import { StorageKeys } from '../../../services';
import { DEFAULT_VARIABLES } from '../../../../common/constants';
export const useSendCurrentRequest = () => {
const {
services: { history, settings, notifications, trackUiMetric, http, autocompleteInfo },
services: { history, settings, notifications, trackUiMetric, http, autocompleteInfo, storage },
theme$,
} = useServicesContext();
@ -28,7 +31,9 @@ export const useSendCurrentRequest = () => {
return useCallback(async () => {
try {
const editor = registry.getInputEditor();
const requests = await editor.getRequestsInRange();
const variables = storage.get(StorageKeys.VARIABLES, DEFAULT_VARIABLES);
let requests = await editor.getRequestsInRange();
requests = replaceVariables(requests, variables);
if (!requests.length) {
notifications.toasts.add(
i18n.translate('console.notification.error.noRequestSelectedTitle', {
@ -128,6 +133,7 @@ export const useSendCurrentRequest = () => {
}
}
}, [
storage,
dispatch,
http,
settings,

View file

@ -61,21 +61,26 @@ export function InputHighlightRules() {
'start',
'url'
),
addEOL(['whitespace', 'variable.template'], /(\s+)(\${\w+})/, 'start', 'url'),
addEOL(['whitespace', 'url.protocol_host'], /(\s+)(https?:\/\/[^?\/,]+)/, 'start', 'url'),
addEOL(['whitespace', 'url.slash'], /(\s+)(\/)/, 'start', 'url'),
addEOL(['whitespace'], /(\s+)/, 'start', 'url')
),
url: mergeTokens(
addEOL(['variable.template'], /(\${\w+})/, 'start'),
addEOL(['url.part'], /(_sql)/, 'start-sql', 'url-sql'),
addEOL(['url.part'], /([^?\/,\s]+)/, 'start'),
addEOL(['url.comma'], /(,)/, 'start'),
addEOL(['url.slash'], /(\/)/, 'start'),
addEOL(['url.questionmark'], /(\?)/, 'start', 'urlParams')
addEOL(['url.questionmark'], /(\?)/, 'start', 'urlParams'),
addEOL(['whitespace', 'comment.punctuation', 'comment.line'], /(\s+)(\/\/)(.*$)/, 'start')
),
urlParams: mergeTokens(
addEOL(['url.param', 'url.equal', 'variable.template'], /([^&=]+)(=)(\${\w+})/, 'start'),
addEOL(['url.param', 'url.equal', 'url.value'], /([^&=]+)(=)([^&]*)/, 'start'),
addEOL(['url.param'], /([^&=]+)/, 'start'),
addEOL(['url.amp'], /(&)/, 'start')
addEOL(['url.amp'], /(&)/, 'start'),
addEOL(['whitespace', 'comment.punctuation', 'comment.line'], /(\s+)(\/\/)(.*$)/, 'start')
),
'url-sql': mergeTokens(
addEOL(['url.part'], /([^?\/,\s]+)/, 'start-sql'),
@ -129,6 +134,8 @@ export function InputHighlightRules() {
// Add comment rules to json rule set
this.$rules.json.unshift({ include: 'comments' });
this.$rules.json.unshift({ token: 'variable.template', regex: /("\${\w+}")/ });
if (this.constructor === InputHighlightRules) {
this.normalizeRules();
}

View file

@ -248,7 +248,7 @@ export class SenseEditor {
request.url = '';
while (t && t.type && t.type.indexOf('url') === 0) {
while (t && t.type && (t.type.indexOf('url') === 0 || t.type === 'variable.template')) {
request.url += t.value;
t = tokenIter.stepForward();
}
@ -256,6 +256,12 @@ export class SenseEditor {
// if the url row ends with some spaces, skip them.
t = this.parser.nextNonEmptyToken(tokenIter);
}
// If the url row ends with a comment, skip it
while (this.parser.isCommentToken(t)) {
t = tokenIter.stepForward();
}
let bodyStartLineNumber = (t ? 0 : 1) + tokenIter.getCurrentPosition().lineNumber; // artificially increase end of docs.
let dataEndPos: Position;
while (
@ -291,7 +297,6 @@ export class SenseEditor {
}
const expandedRange = await this.expandRangeToRequestEdges(range);
if (!expandedRange) {
return [];
}

View file

@ -29,10 +29,10 @@ export default class RowParser {
return MODE.BETWEEN_REQUESTS;
}
const mode = this.editor.getLineState(lineNumber);
if (!mode) {
return MODE.BETWEEN_REQUESTS;
} // shouldn't really happen
// If another "start" mode is added here because we want to allow for new language highlighting
// please see https://github.com/elastic/kibana/pull/51446 for a discussion on why
// should consider a different approach.
@ -40,6 +40,19 @@ export default class RowParser {
return MODE.IN_REQUEST;
}
let line = (this.editor.getLineValue(lineNumber) || '').trim();
// Check if the line has variables, depending on the request type, (e.g. single line, multi doc requests) return the correct mode
if (line && /(\${\w+})/.test(line)) {
lineNumber++;
line = (this.editor.getLineValue(lineNumber) || '').trim();
if (line.startsWith('{')) {
return MODE.REQUEST_START;
}
// next line is another request
// eslint-disable-next-line no-bitwise
return MODE.REQUEST_START | MODE.REQUEST_END;
}
if (!line || line.startsWith('#') || line.startsWith('//') || line.startsWith('/*')) {
return MODE.BETWEEN_REQUESTS;
} // empty line or a comment waiting for a new req to start
@ -140,4 +153,14 @@ export default class RowParser {
t = tokenIter.stepBackward();
return t;
}
isCommentToken(token: Token | null) {
return (
token &&
token.type &&
(token.type === 'comment.punctuation' ||
token.type === 'comment.line' ||
token.type === 'comment.block')
);
}
}

View file

@ -8,7 +8,11 @@
import _ from 'lodash';
import { XJson } from '@kbn/es-ui-shared-plugin/public';
import type { RequestResult } from '../../application/hooks/use_send_current_request/send_request';
import type {
RequestArgs,
RequestResult,
} from '../../application/hooks/use_send_current_request/send_request';
import type { DevToolsVariable } from '../../application/components';
const { collapseLiteralStrings, expandLiteralStrings } = XJson;
@ -108,3 +112,66 @@ export const getResponseWithMostSevereStatusCode = (requestData: RequestResult[]
.pop();
}
};
export const replaceVariables = (
requests: RequestArgs['requests'],
variables: DevToolsVariable[]
) => {
const urlRegex = /(\${\w+})/g;
const bodyRegex = /("\${\w+}")/g;
return requests.map((req) => {
if (urlRegex.test(req.url)) {
req.url = req.url.replaceAll(urlRegex, (match) => {
// Sanitize variable name
const key = match.replace('${', '').replace('}', '');
const variable = variables.find(({ name }) => name === key);
return variable?.value ?? match;
});
}
if (req.data.length) {
if (bodyRegex.test(req.data[0])) {
const data = req.data[0].replaceAll(bodyRegex, (match) => {
// Sanitize variable name
const key = match.replace('"${', '').replace('}"', '');
const variable = variables.find(({ name }) => name === key);
if (variable) {
// All values must be stringified to send a successful request to ES.
const { value } = variable;
const isStringifiedObject = value.startsWith('{') && value.endsWith('}');
if (isStringifiedObject) {
return value;
}
const isStringifiedNumber = !isNaN(parseFloat(value));
if (isStringifiedNumber) {
return value;
}
const isStringifiedArray = value.startsWith('[') && value.endsWith(']');
if (isStringifiedArray) {
return value;
}
const isStringifiedBool = value === 'true' || value === 'false';
if (isStringifiedBool) {
return value;
}
// At this point the value must be an unstringified string, so we have to stringify it.
// Example: 'stringValue' -> '"stringValue"'
return JSON.stringify(value);
}
return match;
});
req.data = [data];
}
}
return req;
});
};

View file

@ -193,4 +193,67 @@ describe('Utils class', () => {
expect(utils.getResponseWithMostSevereStatusCode(undefined)).toBe(undefined);
});
describe('replaceVariables', () => {
function testVariables(data, variables, expected) {
const result = utils.replaceVariables([data], [variables]);
expect(result).toEqual([expected]);
}
it('should replace variables in url and body', () => {
testVariables(
{ url: '${v1}/search', data: ['{\n "f": "${v1}"\n}'] },
{ name: 'v1', value: 'test' },
{
url: 'test/search',
data: ['{\n "f": "test"\n}'],
}
);
});
describe('with booleans as field value', () => {
testVariables(
{ url: 'test', data: ['{\n "f": "${v2}"\n}'] },
{ name: 'v2', value: 'true' },
{
url: 'test',
data: ['{\n "f": true\n}'],
}
);
});
describe('with objects as field values', () => {
testVariables(
{ url: 'test', data: ['{\n "f": "${v3}"\n}'] },
{ name: 'v3', value: '{"f": "test"}' },
{ url: 'test', data: ['{\n "f": {"f": "test"}\n}'] }
);
});
describe('with arrays as field values', () => {
testVariables(
{ url: 'test', data: ['{\n "f": "${v5}"\n}'] },
{ name: 'v5', value: '[{"t": "test"}]' },
{ url: 'test', data: ['{\n "f": [{"t": "test"}]\n}'] }
);
});
describe('with numbers as field values', () => {
testVariables(
{ url: 'test', data: ['{\n "f": "${v6}"\n}'] },
{ name: 'v6', value: '1' },
{ url: 'test', data: ['{\n "f": 1\n}'] }
);
});
describe('with other variables as field values', () => {
// Currently, variables embedded in other variables' values aren't replaced.
// Once we build this feature, this test will fail and need to be updated.
testVariables(
{ url: 'test', data: ['{\n "f": "${v4}"\n}'] },
{ name: 'v4', value: '{"v1": "${v1}", "v6": "${v6}"}' },
{ url: 'test', data: ['{\n "f": {"v1": "${v1}", "v6": "${v6}"}\n}'] }
);
});
});
});

View file

@ -13,6 +13,7 @@ type IStorageEngine = typeof window.localStorage;
export enum StorageKeys {
WIDTH = 'widths',
FOLDS = 'folds',
VARIABLES = 'variables',
}
export class Storage {

View file

@ -12,10 +12,11 @@ import { FtrProviderContext } from '../../ftr_provider_context';
const DEFAULT_REQUEST = `
GET _search
# Click the Variables button, above, to create your own variables.
GET \${exampleVariable1} // _search
{
"query": {
"match_all": {}
"\${exampleVariable2}": {} // match_all
}
}
@ -51,6 +52,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('default request response should include `"timed_out" : false`', async () => {
const expectedResponseContains = `"timed_out": false`;
await PageObjects.console.selectAllRequests();
await PageObjects.console.clickPlay();
await retry.try(async () => {
const actualResponse = await PageObjects.console.getResponse();

View file

@ -0,0 +1,73 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default ({ getService, getPageObjects }: FtrProviderContext) => {
const retry = getService('retry');
const log = getService('log');
const PageObjects = getPageObjects(['common', 'console', 'header']);
describe('Console variables', function testConsoleVariables() {
this.tags('includeFirefox');
before(async () => {
log.debug('navigateTo console');
await PageObjects.common.navigateToApp('console');
await retry.try(async () => {
await PageObjects.console.collapseHelp();
await PageObjects.console.clearTextArea();
});
});
it('should allow creating a new variable', async () => {
await PageObjects.console.addNewVariable({ name: 'index1', value: 'test' });
const variables = await PageObjects.console.getVariables();
log.debug(variables);
expect(variables).to.contain('index1');
});
it('should allow removing a variable', async () => {
await PageObjects.console.addNewVariable({ name: 'index2', value: 'test' });
await PageObjects.console.removeVariables();
const variables = await PageObjects.console.getVariables();
expect(variables).to.eql([]);
});
describe('with variables in url', () => {
it('should send a successful request', async () => {
await PageObjects.console.addNewVariable({ name: 'index3', value: '_search' });
await PageObjects.console.enterRequest('\n GET ${index3}');
await PageObjects.console.clickPlay();
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.try(async () => {
const status = await PageObjects.console.getResponseStatus();
expect(status).to.eql(200);
});
});
});
describe('with variables in request body', () => {
it('should send a successful request', async () => {
await PageObjects.console.addNewVariable({ name: 'query1', value: '{"match_all": {}}' });
await PageObjects.console.enterRequest('\n GET _search');
await PageObjects.console.pressEnter();
await PageObjects.console.enterText(`{\n\t"query": "\${query1}"`);
await PageObjects.console.pressEnter();
await PageObjects.console.clickPlay();
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.try(async () => {
const status = await PageObjects.console.getResponseStatus();
expect(status).to.eql(200);
});
});
});
});
};

View file

@ -21,6 +21,7 @@ export default function ({ getService, loadTestFile }) {
loadTestFile(require.resolve('./_autocomplete'));
loadTestFile(require.resolve('./_vector_tile'));
loadTestFile(require.resolve('./_comments'));
loadTestFile(require.resolve('./_variables'));
}
});
}

View file

@ -7,6 +7,7 @@
*/
import { Key } from 'selenium-webdriver';
import { asyncForEach } from '@kbn/std';
import { FtrService } from '../ftr_provider_context';
import { WebElementWrapper } from '../services/lib/web_element_wrapper';
@ -14,7 +15,6 @@ export class ConsolePageObject extends FtrService {
private readonly testSubjects = this.ctx.getService('testSubjects');
private readonly retry = this.ctx.getService('retry');
private readonly find = this.ctx.getService('find');
log = this.ctx.getService('log');
public async getVisibleTextFromAceEditor(editor: WebElementWrapper) {
const lines = await editor.findAllByClassName('ace_line_group');
@ -48,6 +48,54 @@ export class ConsolePageObject extends FtrService {
await this.testSubjects.click('consoleSettingsButton');
}
public async openVariablesModal() {
await this.testSubjects.click('consoleVariablesButton');
}
public async closeVariablesModal() {
await this.testSubjects.click('variablesCancelButton');
}
public async addNewVariable({ name, value }: { name: string; value: string }) {
await this.openVariablesModal();
// while the variables form opens/loads this may fail, so retry for a while
await this.retry.try(async () => {
await this.testSubjects.click('variablesAddButton');
const variableNameInputs = await this.testSubjects.findAll('variablesNameInput');
await variableNameInputs[variableNameInputs.length - 1].type(name);
const variableValueInputs = await this.testSubjects.findAll('variablesValueInput');
await variableValueInputs[variableValueInputs.length - 1].type(value);
});
await this.testSubjects.click('variablesSaveButton');
}
public async removeVariables() {
await this.openVariablesModal();
// while the variables form opens/loads this may fail, so retry for a while
await this.retry.try(async () => {
const buttons = await this.testSubjects.findAll('variablesRemoveButton');
await asyncForEach(buttons, async (button) => {
await button.click();
});
});
await this.testSubjects.click('variablesSaveButton');
}
public async getVariables() {
await this.openVariablesModal();
const inputs = await this.testSubjects.findAll('variablesNameInput');
const variables = await Promise.all(
inputs.map(async (input) => await input.getAttribute('value'))
);
await this.closeVariablesModal();
return variables;
}
public async setFontSizeSetting(newSize: number) {
await this.openSettings();