mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
3514f42b46
commit
20edc9b02a
25 changed files with 698 additions and 19 deletions
|
@ -8,3 +8,4 @@
|
|||
|
||||
export { MAJOR_VERSION } from './plugin';
|
||||
export { API_BASE_PATH, KIBANA_API_PREFIX } from './api';
|
||||
export { DEFAULT_VARIABLES } from './variables';
|
||||
|
|
14
src/plugins/console/common/constants/variables.ts
Normal file
14
src/plugins/console/common/constants/variables.ts
Normal 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' },
|
||||
];
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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';
|
|
@ -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: '',
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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(() => {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}`;
|
||||
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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}'] }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,6 +13,7 @@ type IStorageEngine = typeof window.localStorage;
|
|||
export enum StorageKeys {
|
||||
WIDTH = 'widths',
|
||||
FOLDS = 'folds',
|
||||
VARIABLES = 'variables',
|
||||
}
|
||||
|
||||
export class Storage {
|
||||
|
|
|
@ -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();
|
||||
|
|
73
test/functional/apps/console/_variables.ts
Normal file
73
test/functional/apps/console/_variables.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue