[Console] UX Improvements for phase 2 (#190698)

This commit is contained in:
Ignacio Rivas 2024-09-17 16:35:20 +03:00 committed by GitHub
parent ebe4686e6c
commit b3a1e5fb8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
187 changed files with 5210 additions and 5986 deletions

View file

@ -44,8 +44,7 @@ enabled:
- test/api_integration/config.js
- test/examples/config.js
- test/functional/apps/bundles/config.ts
- test/functional/apps/console/monaco/config.ts
- test/functional/apps/console/ace/config.ts
- test/functional/apps/console/config.ts
- test/functional/apps/context/config.ts
- test/functional/apps/dashboard_elements/controls/common/config.ts
- test/functional/apps/dashboard_elements/controls/options_list/config.ts

View file

@ -39,6 +39,7 @@ export {
CONSOLE_THEME_ID,
getParsedRequestsProvider,
ConsoleParsedRequestsProvider,
createOutputParser,
} from './src/console';
export type { ParsedRequest } from './src/console';

View file

@ -43,3 +43,5 @@ export const ConsoleOutputLang: LangModuleType = {
export type { ParsedRequest } from './types';
export { getParsedRequestsProvider } from './language';
export { ConsoleParsedRequestsProvider } from './console_parsed_requests_provider';
export { createOutputParser } from './output_parser';

View file

@ -0,0 +1,401 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
/* eslint-disable prettier/prettier,prefer-const,no-throw-literal,camelcase,@typescript-eslint/no-shadow,one-var,object-shorthand,eqeqeq */
export const createOutputParser = () => {
let at, // The index of the current character
ch, // The current character
escapee = {
'"': '"',
'\\': '\\',
'/': '/',
b: '\b',
f: '\f',
n: '\n',
r: '\r',
t: '\t',
},
text,
errors,
addError = function (text) {
errors.push({ text: text, offset: at });
},
responses,
responseStartOffset,
responseEndOffset,
getLastResponse = function() {
return responses.length > 0 ? responses.pop() : {};
},
addResponseStart = function() {
responseStartOffset = at - 1;
responses.push({ startOffset: responseStartOffset });
},
addResponseData = function(data) {
const lastResponse = getLastResponse();
const dataArray = lastResponse.data || [];
dataArray.push(data);
lastResponse.data = dataArray;
responses.push(lastResponse);
responseEndOffset = at - 1;
},
addResponseEnd = function() {
const lastResponse = getLastResponse();
lastResponse.endOffset = responseEndOffset;
responses.push(lastResponse);
},
error = function (m) {
throw {
name: 'SyntaxError',
message: m,
at: at,
text: text,
};
},
reset = function (newAt) {
ch = text.charAt(newAt);
at = newAt + 1;
},
next = function (c) {
if (c && c !== ch) {
error('Expected \'' + c + '\' instead of \'' + ch + '\'');
}
ch = text.charAt(at);
at += 1;
return ch;
},
nextUpTo = function (upTo, errorMessage) {
let currentAt = at,
i = text.indexOf(upTo, currentAt);
if (i < 0) {
error(errorMessage || 'Expected \'' + upTo + '\'');
}
reset(i + upTo.length);
return text.substring(currentAt, i);
},
peek = function (offset) {
return text.charAt(at + offset);
},
number = function () {
let number,
string = '';
if (ch === '-') {
string = '-';
next('-');
}
while (ch >= '0' && ch <= '9') {
string += ch;
next();
}
if (ch === '.') {
string += '.';
while (next() && ch >= '0' && ch <= '9') {
string += ch;
}
}
if (ch === 'e' || ch === 'E') {
string += ch;
next();
if (ch === '-' || ch === '+') {
string += ch;
next();
}
while (ch >= '0' && ch <= '9') {
string += ch;
next();
}
}
number = +string;
if (isNaN(number)) {
error('Bad number');
} else {
return number;
}
},
string = function () {
let hex,
i,
string = '',
uffff;
if (ch === '"') {
// If the current and the next characters are equal to "", empty string or start of triple quoted strings
if (peek(0) === '"' && peek(1) === '"') {
// literal
next('"');
next('"');
return nextUpTo('"""', 'failed to find closing \'"""\'');
} else {
while (next()) {
if (ch === '"') {
next();
return string;
} else if (ch === '\\') {
next();
if (ch === 'u') {
uffff = 0;
for (i = 0; i < 4; i += 1) {
hex = parseInt(next(), 16);
if (!isFinite(hex)) {
break;
}
uffff = uffff * 16 + hex;
}
string += String.fromCharCode(uffff);
} else if (typeof escapee[ch] === 'string') {
string += escapee[ch];
} else {
break;
}
} else {
string += ch;
}
}
}
}
error('Bad string');
},
white = function () {
while (ch) {
// Skip whitespace.
while (ch && ch <= ' ') {
next();
}
// if the current char in iteration is '#' or the char and the next char is equal to '//'
// we are on the single line comment
if (ch === '#' || ch === '/' && peek(0) === '/') {
// Until we are on the new line, skip to the next char
while (ch && ch !== '\n') {
next();
}
} else if (ch === '/' && peek(0) === '*') {
// If the chars starts with '/*', we are on the multiline comment
next();
next();
while (ch && !(ch === '*' && peek(0) === '/')) {
// Until we have closing tags '*/', skip to the next char
next();
}
if (ch) {
next();
next();
}
} else break;
}
},
strictWhite = function () {
while (ch && (ch == ' ' || ch == '\t')) {
next();
}
},
newLine = function () {
if (ch == '\n') next();
},
word = function () {
switch (ch) {
case 't':
next('t');
next('r');
next('u');
next('e');
return true;
case 'f':
next('f');
next('a');
next('l');
next('s');
next('e');
return false;
case 'n':
next('n');
next('u');
next('l');
next('l');
return null;
}
error('Unexpected \'' + ch + '\'');
},
value, // Place holder for the value function.
array = function () {
const array = [];
if (ch === '[') {
next('[');
white();
if (ch === ']') {
next(']');
return array; // empty array
}
while (ch) {
array.push(value());
white();
if (ch === ']') {
next(']');
return array;
}
next(',');
white();
}
}
error('Bad array');
},
object = function () {
let key,
object = {};
if (ch === '{') {
next('{');
white();
if (ch === '}') {
next('}');
return object; // empty object
}
while (ch) {
key = string();
white();
next(':');
if (Object.hasOwnProperty.call(object, key)) {
error('Duplicate key "' + key + '"');
}
object[key] = value();
white();
if (ch === '}') {
next('}');
return object;
}
next(',');
white();
}
}
error('Bad object');
};
value = function () {
white();
switch (ch) {
case '{':
return object();
case '[':
return array();
case '"':
return string();
case '-':
return number();
default:
return ch >= '0' && ch <= '9' ? number() : word();
}
};
let response = function () {
white();
addResponseStart();
// it can be an object
if (ch == '{') {
const parsedObject = object();
addResponseData(parsedObject);
// but it could also be an array of objects
} else if (ch == '[') {
const parsedArray = array();
parsedArray.forEach(item => {
if (typeof item === 'object') {
addResponseData(item);
} else {
error('Array elements must be objects');
}
});
} else {
error('Invalid input');
}
// multi doc response
strictWhite(); // advance to one new line
newLine();
strictWhite();
while (ch == '{') {
// another object
const parsedObject = object();
addResponseData(parsedObject);
strictWhite();
newLine();
strictWhite();
}
addResponseEnd();
},
comment = function () {
while (ch == '#') {
while (ch && ch !== '\n') {
next();
}
white();
}
},
multi_response = function () {
while (ch && ch != '') {
white();
if (!ch) {
continue;
}
try {
comment();
white();
if (!ch) {
continue;
}
response();
white();
} catch (e) {
addError(e.message);
// snap
const substring = text.substr(at);
const nextMatch = substring.search(/[#{]/);
if (nextMatch < 1) return;
reset(at + nextMatch);
}
}
};
return function (source, reviver) {
let result;
text = source;
at = 0;
errors = [];
responses = [];
next();
multi_response();
white();
if (ch) {
addError('Syntax error');
}
result = { errors, responses };
return typeof reviver === 'function'
? (function walk(holder, key) {
let k,
v,
value = holder[key];
if (value && typeof value === 'object') {
for (k in value) {
if (Object.hasOwnProperty.call(value, k)) {
v = walk(value, k);
if (v !== undefined) {
value[k] = v;
} else {
delete value[k];
}
}
}
}
return reviver.call(holder, key, value);
}({ '': result }, ''))
: result;
};
}

View file

@ -0,0 +1,40 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { createOutputParser } from './output_parser';
import { ConsoleOutputParserResult } from './types';
const parser = createOutputParser();
describe('console output parser', () => {
it('returns errors if input is not correct', () => {
const input = 'x';
const parserResult = parser(input) as ConsoleOutputParserResult;
expect(parserResult.responses.length).toBe(1);
// the parser should generate an invalid input error
expect(parserResult.errors).toContainEqual({ text: 'Invalid input', offset: 1 });
});
it('returns parsed responses if the input is correct', () => {
const input = `# 1: GET /my-index/_doc/0 \n { "_index": "my-index" }`;
const { responses, errors } = parser(input) as ConsoleOutputParserResult;
expect(responses.length).toBe(1);
expect(errors.length).toBe(0);
const { data } = responses[0];
const expected = [{ _index: 'my-index' }];
expect(data).toEqual(expected);
});
it('parses several responses', () => {
const input = `# 1: GET /my-index/_doc/0 \n { "_index": "my-index" } \n # 2: GET /my-index/_doc/1 \n { "_index": "my-index" }`;
const { responses } = parser(input) as ConsoleOutputParserResult;
expect(responses.length).toBe(2);
});
});

View file

@ -21,6 +21,16 @@ export interface ConsoleParserResult {
requests: ParsedRequest[];
}
export interface ConsoleOutputParsedResponse {
startOffset: number;
endOffset?: number;
data?: Array<Record<string, unknown>>;
}
export interface ConsoleOutputParserResult {
errors: ErrorAnnotation[];
responses: ConsoleOutputParsedResponse[];
}
export interface ConsoleWorkerDefinition {
getParserResult: (modelUri: string) => ConsoleParserResult | undefined;
}

View file

@ -17,3 +17,5 @@ export const AUTOCOMPLETE_DEFINITIONS_FOLDER = resolve(
export const GENERATED_SUBFOLDER = 'generated';
export const OVERRIDES_SUBFOLDER = 'overrides';
export const MANUAL_SUBFOLDER = 'manual';
export const API_DOCS_LINK = 'https://www.elastic.co/docs/api';

View file

@ -15,6 +15,7 @@ export {
GENERATED_SUBFOLDER,
OVERRIDES_SUBFOLDER,
MANUAL_SUBFOLDER,
API_DOCS_LINK,
} from './autocomplete_definitions';
export { DEFAULT_INPUT_VALUE } from './editor_input';
export { DEFAULT_LANGUAGE, AVAILABLE_LANGUAGES } from './copy_as';

View file

@ -11,13 +11,7 @@ import React, { Component } from 'react';
import { NotificationsSetup } from '@kbn/core/public';
import {
EuiIcon,
EuiContextMenuPanel,
EuiContextMenuItem,
EuiPopover,
EuiLink,
} from '@elastic/eui';
import { EuiContextMenuPanel, EuiContextMenuItem, EuiPopover, EuiButtonIcon } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
@ -115,15 +109,15 @@ export class ConsoleMenu extends Component<Props, State> {
render() {
const button = (
<EuiLink
<EuiButtonIcon
onClick={this.onButtonClick}
data-test-subj="toggleConsoleMenu"
aria-label={i18n.translate('console.requestOptionsButtonAriaLabel', {
defaultMessage: 'Request options',
})}
>
<EuiIcon type="boxesVertical" />
</EuiLink>
iconType="boxesVertical"
iconSize="s"
/>
);
const items = [

View file

@ -0,0 +1,64 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { ReactNode, ReactElement } from 'react';
import { EuiTourStep, PopoverAnchorPosition } from '@elastic/eui';
export interface ConsoleTourStepProps {
step: number;
stepsTotal: number;
isStepOpen: boolean;
title: ReactNode;
content: ReactNode;
onFinish: () => void;
footerAction: ReactNode | ReactNode[];
dataTestSubj: string;
anchorPosition: string;
maxWidth: number;
css?: any;
}
interface Props {
tourStepProps: ConsoleTourStepProps;
children: ReactNode & ReactElement;
}
export const ConsoleTourStep = ({ tourStepProps, children }: Props) => {
const {
step,
isStepOpen,
stepsTotal,
title,
content,
onFinish,
footerAction,
dataTestSubj,
anchorPosition,
maxWidth,
css,
} = tourStepProps;
return (
<EuiTourStep
step={step}
stepsTotal={stepsTotal}
isStepOpen={isStepOpen}
title={title}
content={content}
onFinish={onFinish}
footerAction={footerAction}
data-test-subj={dataTestSubj}
anchorPosition={anchorPosition as PopoverAnchorPosition}
maxWidth={maxWidth}
css={css}
>
{children}
</EuiTourStep>
);
};

View file

@ -8,12 +8,12 @@
*/
import React, { FunctionComponent } from 'react';
import { EuiSkeletonText, EuiPageSection } from '@elastic/eui';
import { EuiLoadingSpinner, EuiPageSection } from '@elastic/eui';
export const EditorContentSpinner: FunctionComponent = () => {
return (
<EuiPageSection className="conApp__editor__spinner">
<EuiSkeletonText lines={10} />
<EuiPageSection alignment="center" grow={true} className="conApp__editor__spinner">
<EuiLoadingSpinner size="xxl" />
</EuiPageSection>
);
};

View file

@ -1,91 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiScreenReaderOnly, withEuiTheme } from '@elastic/eui';
import type { WithEuiThemeProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useRef } from 'react';
import { createReadOnlyAceEditor, CustomAceEditor } from '../models/sense_editor';
// @ts-ignore
import { Mode as InputMode } from '../models/legacy_core_editor/mode/input';
import { Mode as OutputMode } from '../models/legacy_core_editor/mode/output';
interface EditorExampleProps {
panel: string;
example?: string;
theme: WithEuiThemeProps['theme'];
linesOfExampleCode?: number;
mode?: string;
}
const exampleText = `
GET _search
{
"query": {
"match_all": {}
}
}
`;
const EditorExample = ({
panel,
example,
theme,
linesOfExampleCode = 6,
mode = 'input',
}: EditorExampleProps) => {
const inputId = `help-example-${panel}-input`;
const wrapperDivRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<CustomAceEditor>();
useEffect(() => {
if (wrapperDivRef.current) {
editorRef.current = createReadOnlyAceEditor(wrapperDivRef.current);
const editor = editorRef.current;
const editorMode = mode === 'input' ? new InputMode() : new OutputMode();
editor.update((example || exampleText).trim(), editorMode);
editor.session.setUseWorker(false);
editor.setHighlightActiveLine(false);
const textareaElement = wrapperDivRef.current.querySelector('textarea');
if (textareaElement) {
textareaElement.setAttribute('id', inputId);
textareaElement.setAttribute('readonly', 'true');
}
}
return () => {
if (editorRef.current) {
editorRef.current.destroy();
}
};
}, [example, inputId, mode]);
const wrapperDivStyle = {
height: `${parseInt(theme.euiTheme.size.base, 10) * linesOfExampleCode}px`,
margin: `${theme.euiTheme.size.base} 0`,
};
return (
<>
<EuiScreenReaderOnly>
<label htmlFor={inputId}>
{i18n.translate('console.exampleOutputTextarea', {
defaultMessage: 'Dev Tools Console editor example',
})}
</label>
</EuiScreenReaderOnly>
<div ref={wrapperDivRef} className="conApp_example" css={wrapperDivStyle} />
</>
);
};
// eslint-disable-next-line import/no-default-export
export default withEuiTheme(EditorExample);

View file

@ -1,163 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiText,
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiTitle,
EuiSpacer,
EuiLink,
} from '@elastic/eui';
import EditorExample from './editor_example';
import { useServicesContext } from '../contexts';
interface Props {
onClose: () => void;
}
export function HelpPanel(props: Props) {
const { docLinks } = useServicesContext();
return (
<EuiFlyout onClose={props.onClose} data-test-subj="helpFlyout" size="s">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
<FormattedMessage id="console.helpPage.pageTitle" defaultMessage="Help" />
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiText>
<h3>
<FormattedMessage
defaultMessage="Request format"
id="console.helpPage.requestFormatTitle"
/>
</h3>
<p>
<FormattedMessage
id="console.helpPage.requestFormatDescription"
defaultMessage="You can type one or more requests in the editor. Console understands requests in a compact format."
/>
</p>
<p>
<FormattedMessage
id="console.helpPage.learnAboutConsoleAndQueryDslText"
defaultMessage="Learn about {console} and {queryDsl}"
values={{
console: (
<EuiLink href={docLinks.console.guide} target="_blank" external>
Console
</EuiLink>
),
queryDsl: (
<EuiLink href={docLinks.query.queryDsl} target="_blank" external>
Query DSL
</EuiLink>
),
}}
/>
</p>
<EditorExample panel="help" />
<h3>
<FormattedMessage
id="console.helpPage.keyboardCommandsTitle"
defaultMessage="Keyboard commands"
/>
</h3>
<EuiSpacer />
<dl>
<dt>Ctrl/Cmd + I</dt>
<dd>
<FormattedMessage
id="console.helpPage.keyboardCommands.autoIndentDescription"
defaultMessage="Auto indent current request"
/>
</dd>
<dt>Ctrl/Cmd + /</dt>
<dd>
<FormattedMessage
id="console.helpPage.keyboardCommands.openDocumentationDescription"
defaultMessage="Open documentation for current request"
/>
</dd>
<dt>Ctrl + Space</dt>
<dd>
<FormattedMessage
id="console.helpPage.keyboardCommands.openAutoCompleteDescription"
defaultMessage="Open Auto complete (even if not typing)"
/>
</dd>
<dt>Ctrl/Cmd + Enter</dt>
<dd>
<FormattedMessage
id="console.helpPage.keyboardCommands.submitRequestDescription"
defaultMessage="Submit request"
/>
</dd>
<dt>Ctrl/Cmd + Up/Down</dt>
<dd>
<FormattedMessage
id="console.helpPage.keyboardCommands.jumpToPreviousNextRequestDescription"
defaultMessage="Jump to the previous/next request start or end."
/>
</dd>
<dt>Ctrl/Cmd + Alt + L</dt>
<dd>
<FormattedMessage
id="console.helpPage.keyboardCommands.collapseExpandCurrentScopeDescription"
defaultMessage="Collapse/expand current scope."
/>
</dd>
<dt>Ctrl/Cmd + Option + 0</dt>
<dd>
<FormattedMessage
id="console.helpPage.keyboardCommands.collapseAllScopesDescription"
defaultMessage="Collapse all scopes but the current one. Expand by adding a shift."
/>
</dd>
<dt>Down arrow</dt>
<dd>
<FormattedMessage
id="console.helpPage.keyboardCommands.switchFocusToAutoCompleteMenuDescription"
defaultMessage="Switch focus to auto-complete menu. Use arrows to further select a term"
/>
</dd>
<dt>Enter/Tab</dt>
<dd>
<FormattedMessage
id="console.helpPage.keyboardCommands.selectCurrentlySelectedInAutoCompleteMenuDescription"
defaultMessage="Select the currently selected or the top most term in auto-complete menu"
/>
</dd>
<dt>Ctrl/Cmd + L</dt>
<dd>
<FormattedMessage
id="console.helpPage.keyboardCommands.goToLineNumberDescription"
defaultMessage="Go to line number"
/>
</dd>
<dt>Esc</dt>
<dd>
<FormattedMessage
id="console.helpPage.keyboardCommands.closeAutoCompleteMenuDescription"
defaultMessage="Close auto-complete menu"
/>
</dd>
</dl>
</EuiText>
</EuiFlyoutBody>
</EuiFlyout>
);
}

View file

@ -0,0 +1,136 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiPopover,
EuiTitle,
EuiText,
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiSpacer,
} from '@elastic/eui';
import { useServicesContext } from '../contexts';
interface HelpPopoverProps {
button: any;
isOpen: boolean;
closePopover: () => void;
resetTour: () => void;
}
export const HelpPopover = ({ button, isOpen, closePopover, resetTour }: HelpPopoverProps) => {
const { docLinks } = useServicesContext();
return (
<EuiPopover
button={button}
isOpen={isOpen}
closePopover={closePopover}
anchorPosition="downRight"
buffer={4}
ownFocus={false}
data-test-subj="consoleHelpPopover"
>
<EuiTitle size="xs">
<h4>
{i18n.translate('console.helpPopover.title', {
defaultMessage: 'Elastic Console',
})}
</h4>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText style={{ width: 300 }} color="subdued" size="s">
<p>
{i18n.translate('console.helpPopover.description', {
defaultMessage:
'Console is an interactive UI for calling Elasticsearch and Kibana APIs and viewing their responses. Search your data, manage settings, and more, using Query DSL and REST API syntax.',
})}
</p>
</EuiText>
<EuiSpacer />
<EuiFlexGroup gutterSize="m" direction="column">
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<p>
{i18n.translate('console.helpPopover.aboutConsoleLabel', {
defaultMessage: 'About Console',
})}
</p>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="popout"
href={docLinks.console.guide}
target="_blank"
color="text"
aria-label={i18n.translate('console.helpPopover.aboutConsoleButtonAriaLabel', {
defaultMessage: 'About Console link',
})}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<p>
{i18n.translate('console.helpPopover.aboutQueryDSLLabel', {
defaultMessage: 'About Query DSL',
})}
</p>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="popout"
href={docLinks.query.queryDsl}
target="_blank"
color="text"
aria-label={i18n.translate('console.helpPopover.aboutQueryDSLButtonAriaLabel', {
defaultMessage: 'About QueryDSL link',
})}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<p>
{i18n.translate('console.helpPopover.rerunTourLabel', {
defaultMessage: 'Re-run feature tour',
})}
</p>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="refresh"
onClick={resetTour}
color="text"
aria-label={i18n.translate('console.helpPopover.rerunTourButtonAriaLabel', {
defaultMessage: 'Re-run feature tour button',
})}
data-test-subj="consoleRerunTourButton"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopover>
);
};

View file

@ -7,50 +7,14 @@
* License v3.0 only", or the "Server 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';
export { TopNavMenu } from './top_nav_menu';
export { ConsoleMenu } from './console_menu';
export { WelcomePanel } from './welcome_panel';
export type { AutocompleteOptions } 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);
export { OutputPanelEmptyState } from './output_panel_empty_state';
export { HelpPopover } from './help_popover';
export { ShortcutsPopover } from './shortcuts_popover';
export type { DevToolsVariable } from './variables/types';
export { ConsoleTourStep, type ConsoleTourStepProps } from './console_tour_step';

View file

@ -0,0 +1,57 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { FunctionComponent } from 'react';
import { EuiEmptyPrompt, EuiTitle, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useServicesContext } from '../contexts';
export const OutputPanelEmptyState: FunctionComponent = () => {
const { docLinks } = useServicesContext();
return (
<EuiEmptyPrompt
title={
<h2>
<FormattedMessage
id="console.outputEmptyState.title"
defaultMessage="Enter a new request"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="console.outputEmptyState.description"
defaultMessage="When you run a request in the input panel, you will see the output response here."
/>
</p>
}
footer={
<>
<EuiTitle size="xxs">
<h3>
<FormattedMessage
id="console.outputEmptyState.learnMore"
defaultMessage="Want to learn more?"
/>
</h3>
</EuiTitle>
<EuiLink href={docLinks.console.guide} target="_blank">
<FormattedMessage
id="console.outputEmptyState.docsLink"
defaultMessage="Read the Console docs"
/>
</EuiLink>
</>
}
data-test-subj="consoleOutputPanelEmptyState"
/>
);
};

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { withSuspense } from '@kbn/shared-ux-utility';
export { type Props } from './settings_editor';
export { type AutocompleteOptions } from './types';
/**
* The Lazily-loaded `SettingsEditorLazy` component. Consumers should use `React.Suspense` or
* the withSuspense` HOC to load this component.
*/
export const SettingsEditorLazy = React.lazy(() =>
import('./settings_editor').then(({ SettingsEditor }) => ({
default: SettingsEditor,
}))
);
/**
* A `SettingsEditor` component that is wrapped by the `withSuspense` HOC. This component can
* be used directly by consumers and will load the `SettingsEditorLazy` component lazily with
* a predefined fallback and error boundary.
*/
export const SettingsEditor = withSuspense(SettingsEditorLazy);

View file

@ -0,0 +1,371 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { debounce } from 'lodash';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButton,
EuiFieldNumber,
EuiSwitch,
EuiSuperSelect,
EuiTitle,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { SettingsGroup } from './settings_group';
import { SettingsFormRow } from './settings_form_row';
import { DevToolsSettings } from '../../../services';
const DEBOUNCE_DELAY = 500;
const ON_LABEL = i18n.translate('console.settingsPage.onLabel', { defaultMessage: 'On' });
const OFF_LABEL = i18n.translate('console.settingsPage.offLabel', { defaultMessage: 'Off' });
const onceTimeInterval = () =>
i18n.translate('console.settingsPage.refreshInterval.onceTimeInterval', {
defaultMessage: 'Once, when console loads',
});
const everyNMinutesTimeInterval = (value: number) =>
i18n.translate('console.settingsPage.refreshInterval.everyNMinutesTimeInterval', {
defaultMessage: 'Every {value} {value, plural, one {minute} other {minutes}}',
values: { value },
});
const everyHourTimeInterval = () =>
i18n.translate('console.settingsPage.refreshInterval.everyHourTimeInterval', {
defaultMessage: 'Every hour',
});
const PRESETS_IN_MINUTES = [0, 1, 10, 20, 60];
const intervalOptions = PRESETS_IN_MINUTES.map((value) => ({
value: (value * 60000).toString(),
inputDisplay:
value === 0
? onceTimeInterval()
: value === 60
? everyHourTimeInterval()
: everyNMinutesTimeInterval(value),
}));
export interface Props {
onSaveSettings: (newSettings: DevToolsSettings) => void;
refreshAutocompleteSettings: (selectedSettings: DevToolsSettings['autocomplete']) => void;
settings: DevToolsSettings;
}
export const SettingsEditor = (props: Props) => {
const isMounted = useRef(false);
const [fontSize, setFontSize] = useState(props.settings.fontSize);
const [wrapMode, setWrapMode] = useState(props.settings.wrapMode);
const [fields, setFields] = useState(props.settings.autocomplete.fields);
const [indices, setIndices] = useState(props.settings.autocomplete.indices);
const [templates, setTemplates] = useState(props.settings.autocomplete.templates);
const [dataStreams, setDataStreams] = useState(props.settings.autocomplete.dataStreams);
const [polling, setPolling] = useState(props.settings.polling);
const [pollInterval, setPollInterval] = useState(props.settings.pollInterval);
const [tripleQuotes, setTripleQuotes] = useState(props.settings.tripleQuotes);
const [isHistoryEnabled, setIsHistoryEnabled] = useState(props.settings.isHistoryEnabled);
const [isKeyboardShortcutsEnabled, setIsKeyboardShortcutsEnabled] = useState(
props.settings.isKeyboardShortcutsEnabled
);
const [isAccessibilityOverlayEnabled, setIsAccessibilityOverlayEnabled] = useState(
props.settings.isAccessibilityOverlayEnabled
);
const autoCompleteCheckboxes = [
{
id: 'fields',
label: i18n.translate('console.settingsPage.fieldsLabelText', {
defaultMessage: 'Fields',
}),
stateSetter: setFields,
checked: fields,
},
{
id: 'indices',
label: i18n.translate('console.settingsPage.indicesAndAliasesLabelText', {
defaultMessage: 'Indices and aliases',
}),
stateSetter: setIndices,
checked: indices,
},
{
id: 'templates',
label: i18n.translate('console.settingsPage.templatesLabelText', {
defaultMessage: 'Templates',
}),
stateSetter: setTemplates,
checked: templates,
},
{
id: 'dataStreams',
label: i18n.translate('console.settingsPage.dataStreamsLabelText', {
defaultMessage: 'Data streams',
}),
stateSetter: setDataStreams,
checked: dataStreams,
},
];
const saveSettings = () => {
props.onSaveSettings({
fontSize,
wrapMode,
autocomplete: {
fields,
indices,
templates,
dataStreams,
},
polling,
pollInterval,
tripleQuotes,
isHistoryEnabled,
isKeyboardShortcutsEnabled,
isAccessibilityOverlayEnabled,
});
};
const debouncedSaveSettings = debounce(saveSettings, DEBOUNCE_DELAY);
useEffect(() => {
if (isMounted.current) {
debouncedSaveSettings();
} else {
isMounted.current = true;
}
}, [
fontSize,
wrapMode,
fields,
indices,
templates,
dataStreams,
polling,
pollInterval,
tripleQuotes,
isHistoryEnabled,
isKeyboardShortcutsEnabled,
isAccessibilityOverlayEnabled,
debouncedSaveSettings,
]);
const onPollingIntervalChange = useCallback((value: string) => {
const sanitizedValue = parseInt(value, 10);
setPolling(!!sanitizedValue);
setPollInterval(sanitizedValue);
}, []);
const toggleKeyboardShortcuts = useCallback((isEnabled: boolean) => {
setIsKeyboardShortcutsEnabled(isEnabled);
}, []);
const toggleAccessibilityOverlay = useCallback(
(isEnabled: boolean) => setIsAccessibilityOverlayEnabled(isEnabled),
[]
);
const toggleSavingToHistory = useCallback(
(isEnabled: boolean) => setIsHistoryEnabled(isEnabled),
[]
);
return (
<>
<EuiTitle>
<h2>
<FormattedMessage id="console.settingsPage.pageTitle" defaultMessage="Console settings" />
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText color="subdued">
<p>
<FormattedMessage
id="console.settingsPage.pageDescription"
defaultMessage="Customize Console to suit your workflow."
/>
</p>
</EuiText>
{/* GENERAL SETTINGS */}
<SettingsGroup
title={i18n.translate('console.settingsPage.generalSettingsLabel', {
defaultMessage: 'General settings',
})}
/>
<SettingsFormRow
label={i18n.translate('console.settingsPage.saveRequestsToHistoryLabel', {
defaultMessage: 'Save requests to history',
})}
>
<EuiSwitch
checked={isHistoryEnabled}
label={isHistoryEnabled ? ON_LABEL : OFF_LABEL}
onChange={(e) => toggleSavingToHistory(e.target.checked)}
/>
</SettingsFormRow>
<SettingsFormRow
label={i18n.translate('console.settingsPage.enableKeyboardShortcutsLabel', {
defaultMessage: 'Keyboard shortcuts',
})}
>
<EuiSwitch
data-test-subj="enableKeyboardShortcuts"
label={isKeyboardShortcutsEnabled ? ON_LABEL : OFF_LABEL}
checked={isKeyboardShortcutsEnabled}
onChange={(e) => toggleKeyboardShortcuts(e.target.checked)}
/>
</SettingsFormRow>
<SettingsFormRow
label={i18n.translate('console.settingsPage.enableAccessibilityOverlayLabel', {
defaultMessage: 'Accessibility overlay',
})}
>
<EuiSwitch
data-test-subj="enableA11yOverlay"
label={isAccessibilityOverlayEnabled ? ON_LABEL : OFF_LABEL}
checked={isAccessibilityOverlayEnabled}
onChange={(e) => toggleAccessibilityOverlay(e.target.checked)}
/>
</SettingsFormRow>
{/* DISPLAY SETTINGS */}
<SettingsGroup
title={i18n.translate('console.settingsPage.displaySettingsLabel', {
defaultMessage: 'Display',
})}
/>
<SettingsFormRow
label={i18n.translate('console.settingsPage.fontSizeLabel', {
defaultMessage: 'Font size',
})}
>
<EuiFieldNumber
css={{ minWidth: '220px' }}
compressed
data-test-subj="setting-font-size-input"
value={fontSize}
min={6}
max={50}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!val) return;
setFontSize(val);
}}
/>
</SettingsFormRow>
<SettingsFormRow
label={i18n.translate('console.settingsPage.wrapLongLinesLabel', {
defaultMessage: 'Wrap long lines',
})}
>
<EuiSwitch
data-test-subj="settingsWrapLines"
label={wrapMode ? ON_LABEL : OFF_LABEL}
checked={wrapMode}
onChange={(e) => setWrapMode(e.target.checked)}
id="wrapLines"
/>
</SettingsFormRow>
<SettingsFormRow
label={i18n.translate('console.settingsPage.tripleQuotesMessage', {
defaultMessage: 'Triple quotes in output',
})}
>
<EuiSwitch
data-test-subj="tripleQuotes"
label={tripleQuotes ? ON_LABEL : OFF_LABEL}
checked={tripleQuotes}
onChange={(e) => setTripleQuotes(e.target.checked)}
id="tripleQuotes"
/>
</SettingsFormRow>
{/* AUTOCOMPLETE SETTINGS */}
<SettingsGroup
title={i18n.translate('console.settingsPage.autocompleteSettingsLabel', {
defaultMessage: 'Autocomplete',
})}
/>
{autoCompleteCheckboxes.map((opts) => (
<SettingsFormRow key={opts.id} label={opts.label}>
<EuiSwitch
data-test-subj={`autocomplete-settings-${opts.id}`}
label={opts.checked ? ON_LABEL : OFF_LABEL}
checked={opts.checked}
onChange={(e) => opts.stateSetter(e.target.checked)}
/>
</SettingsFormRow>
))}
{/* AUTOCOMPLETE REFRESH SETTINGS */}
{(fields || indices || templates || dataStreams) && (
<>
<SettingsGroup
title={i18n.translate('console.settingsPage.autocompleteRefreshSettingsLabel', {
defaultMessage: 'Autocomplete refresh',
})}
description={i18n.translate(
'console.settingsPage.autocompleteRefreshSettingsDescription',
{
defaultMessage:
'Console refreshes autocomplete suggestions by querying Elasticsearch. Use less frequent refreshes to reduce bandwidth costs.',
}
)}
/>
<SettingsFormRow
label={i18n.translate('console.settingsPage.refreshingDataLabel', {
defaultMessage: 'Refresh frequency',
})}
>
<EuiSuperSelect
css={{ minWidth: '220px' }}
compressed
options={intervalOptions}
valueOfSelected={pollInterval.toString()}
onChange={onPollingIntervalChange}
/>
</SettingsFormRow>
<SettingsFormRow
label={i18n.translate('console.settingsPage.manualRefreshLabel', {
defaultMessage: 'Manually refresh autocomplete suggestions',
})}
>
<EuiButton
iconType="refresh"
size="s"
data-test-subj="autocompletePolling"
id="autocompletePolling"
onClick={() => {
// Only refresh the currently selected settings.
props.refreshAutocompleteSettings({
fields,
indices,
templates,
dataStreams,
});
}}
>
<FormattedMessage
defaultMessage="Refresh"
id="console.settingsPage.refreshButtonLabel"
/>
</EuiButton>
</SettingsFormRow>
</>
)}
</>
);
};

View file

@ -0,0 +1,33 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui';
export interface DevToolsSettingsModalProps {
label: string;
children: React.ReactNode;
}
export const SettingsFormRow = ({ label, children }: DevToolsSettingsModalProps) => {
return (
<EuiFormRow fullWidth>
<EuiFlexGroup alignItems="center" responsive={false}>
<EuiFlexItem>
<EuiFlexGroup gutterSize="xs" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>{label}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>{children}</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
};

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiTitle, EuiSpacer, EuiText, EuiHorizontalRule } from '@elastic/eui';
export interface DevToolsSettingsModalProps {
title: string;
description?: string;
}
export const SettingsGroup = ({ title, description }: DevToolsSettingsModalProps) => {
return (
<>
<EuiSpacer size="l" />
<EuiTitle size="xs">
<h2>{title}</h2>
</EuiTitle>
{description && (
<>
<EuiSpacer size="s" />
<EuiText color="subdued">
<p>{description}</p>
</EuiText>
</>
)}
<EuiHorizontalRule margin="s" />
</>
);
};

View file

@ -7,5 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { Panel } from './panel';
export { PanelsContainer } from './panel_container';
export type AutocompleteOptions = 'fields' | 'indices' | 'templates';

View file

@ -1,391 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import _ from 'lodash';
import React, { Fragment, useState, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButton,
EuiButtonEmpty,
EuiFieldNumber,
EuiFormRow,
EuiCheckboxGroup,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSwitch,
EuiSuperSelect,
} from '@elastic/eui';
import { DevToolsSettings } from '../../services';
import { unregisterCommands } from '../containers/editor/legacy/console_editor/keyboard_shortcuts';
import type { SenseEditor } from '../models';
export type AutocompleteOptions = 'fields' | 'indices' | 'templates';
const onceTimeInterval = () =>
i18n.translate('console.settingsPage.refreshInterval.onceTimeInterval', {
defaultMessage: 'Once, when console loads',
});
const everyNMinutesTimeInterval = (value: number) =>
i18n.translate('console.settingsPage.refreshInterval.everyNMinutesTimeInterval', {
defaultMessage: 'Every {value} {value, plural, one {minute} other {minutes}}',
values: { value },
});
const everyHourTimeInterval = () =>
i18n.translate('console.settingsPage.refreshInterval.everyHourTimeInterval', {
defaultMessage: 'Every hour',
});
const PRESETS_IN_MINUTES = [0, 1, 10, 20, 60];
const intervalOptions = PRESETS_IN_MINUTES.map((value) => ({
value: (value * 60000).toString(),
inputDisplay:
value === 0
? onceTimeInterval()
: value === 60
? everyHourTimeInterval()
: everyNMinutesTimeInterval(value),
}));
export interface DevToolsSettingsModalProps {
onSaveSettings: (newSettings: DevToolsSettings) => void;
onClose: () => void;
refreshAutocompleteSettings: (selectedSettings: DevToolsSettings['autocomplete']) => void;
settings: DevToolsSettings;
editorInstance: SenseEditor | null;
}
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);
const [indices, setIndices] = useState(props.settings.autocomplete.indices);
const [templates, setTemplates] = useState(props.settings.autocomplete.templates);
const [dataStreams, setDataStreams] = useState(props.settings.autocomplete.dataStreams);
const [polling, setPolling] = useState(props.settings.polling);
const [pollInterval, setPollInterval] = useState(props.settings.pollInterval);
const [tripleQuotes, setTripleQuotes] = useState(props.settings.tripleQuotes);
const [isHistoryEnabled, setIsHistoryEnabled] = useState(props.settings.isHistoryEnabled);
const [isKeyboardShortcutsEnabled, setIsKeyboardShortcutsEnabled] = useState(
props.settings.isKeyboardShortcutsEnabled
);
const [isAccessibilityOverlayEnabled, setIsAccessibilityOverlayEnabled] = useState(
props.settings.isAccessibilityOverlayEnabled
);
const autoCompleteCheckboxes = [
{
id: 'fields',
label: i18n.translate('console.settingsPage.fieldsLabelText', {
defaultMessage: 'Fields',
}),
stateSetter: setFields,
},
{
id: 'indices',
label: i18n.translate('console.settingsPage.indicesAndAliasesLabelText', {
defaultMessage: 'Indices and aliases',
}),
stateSetter: setIndices,
},
{
id: 'templates',
label: i18n.translate('console.settingsPage.templatesLabelText', {
defaultMessage: 'Templates',
}),
stateSetter: setTemplates,
},
{
id: 'dataStreams',
label: i18n.translate('console.settingsPage.dataStreamsLabelText', {
defaultMessage: 'Data streams',
}),
stateSetter: setDataStreams,
},
];
const checkboxIdToSelectedMap = {
fields,
indices,
templates,
dataStreams,
};
const onAutocompleteChange = (optionId: AutocompleteOptions) => {
const option = _.find(autoCompleteCheckboxes, (item) => item.id === optionId);
if (option) {
option.stateSetter(!checkboxIdToSelectedMap[optionId]);
}
};
function saveSettings() {
props.onSaveSettings({
fontSize,
wrapMode,
autocomplete: {
fields,
indices,
templates,
dataStreams,
},
polling,
pollInterval,
tripleQuotes,
isHistoryEnabled,
isKeyboardShortcutsEnabled,
isAccessibilityOverlayEnabled,
});
}
const onPollingIntervalChange = useCallback((value: string) => {
const sanitizedValue = parseInt(value, 10);
setPolling(!!sanitizedValue);
setPollInterval(sanitizedValue);
}, []);
const toggleKeyboardShortcuts = useCallback(
(isEnabled: boolean) => {
if (props.editorInstance) {
unregisterCommands(props.editorInstance);
}
setIsKeyboardShortcutsEnabled(isEnabled);
},
[props.editorInstance]
);
const toggleAccessibilityOverlay = useCallback(
(isEnabled: boolean) => setIsAccessibilityOverlayEnabled(isEnabled),
[]
);
const toggleSavingToHistory = useCallback(
(isEnabled: boolean) => setIsHistoryEnabled(isEnabled),
[]
);
// It only makes sense to show polling options if the user needs to fetch any data.
const pollingFields =
fields || indices || templates || dataStreams ? (
<Fragment>
<EuiFormRow
label={
<FormattedMessage
id="console.settingsPage.refreshingDataLabel"
defaultMessage="Refresh frequency"
/>
}
helpText={
<FormattedMessage
id="console.settingsPage.refreshingDataDescription"
defaultMessage="Console refreshes autocomplete suggestions by querying Elasticsearch.
Use less frequent refreshes to reduce bandwidth costs."
/>
}
>
<EuiSuperSelect
options={intervalOptions}
valueOfSelected={pollInterval.toString()}
onChange={onPollingIntervalChange}
/>
</EuiFormRow>
<EuiButton
data-test-subj="autocompletePolling"
id="autocompletePolling"
onClick={() => {
// Only refresh the currently selected settings.
props.refreshAutocompleteSettings({
fields,
indices,
templates,
dataStreams,
});
}}
>
<FormattedMessage
defaultMessage="Refresh autocomplete suggestions"
id="console.settingsPage.refreshButtonLabel"
/>
</EuiButton>
</Fragment>
) : undefined;
return (
<EuiModal
data-test-subj="devToolsSettingsModal"
className="conApp__settingsModal"
onClose={props.onClose}
>
<EuiModalHeader>
<EuiModalHeaderTitle>
<FormattedMessage id="console.settingsPage.pageTitle" defaultMessage="Console settings" />
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFormRow
label={
<FormattedMessage id="console.settingsPage.fontSizeLabel" defaultMessage="Font size" />
}
>
<EuiFieldNumber
autoFocus
data-test-subj="setting-font-size-input"
value={fontSize}
min={6}
max={50}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!val) return;
setFontSize(val);
}}
/>
</EuiFormRow>
<EuiFormRow>
<EuiSwitch
checked={wrapMode}
data-test-subj="settingsWrapLines"
id="wrapLines"
label={
<FormattedMessage
defaultMessage="Wrap long lines"
id="console.settingsPage.wrapLongLinesLabelText"
/>
}
onChange={(e) => setWrapMode(e.target.checked)}
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage
id="console.settingsPage.jsonSyntaxLabel"
defaultMessage="JSON syntax"
/>
}
>
<EuiSwitch
checked={tripleQuotes}
data-test-subj="tripleQuotes"
id="tripleQuotes"
label={
<FormattedMessage
defaultMessage="Use triple quotes in output"
id="console.settingsPage.tripleQuotesMessage"
/>
}
onChange={(e) => setTripleQuotes(e.target.checked)}
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage id="console.settingsPage.historyLabel" defaultMessage="History" />
}
>
<EuiSwitch
checked={isHistoryEnabled}
label={
<FormattedMessage
defaultMessage="Save requests to history"
id="console.settingsPage.saveRequestsToHistoryLabel"
/>
}
onChange={(e) => toggleSavingToHistory(e.target.checked)}
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage
id="console.settingsPage.keyboardShortcutsLabel"
defaultMessage="Keyboard shortcuts"
/>
}
>
<EuiSwitch
checked={isKeyboardShortcutsEnabled}
data-test-subj="enableKeyboardShortcuts"
label={
<FormattedMessage
defaultMessage="Enable keyboard shortcuts"
id="console.settingsPage.enableKeyboardShortcutsLabel"
/>
}
onChange={(e) => toggleKeyboardShortcuts(e.target.checked)}
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage
id="console.settingsPage.accessibilityOverlayLabel"
defaultMessage="Accessibility overlay"
/>
}
>
<EuiSwitch
data-test-subj="enableA11yOverlay"
checked={isAccessibilityOverlayEnabled}
label={
<FormattedMessage
defaultMessage="Enable accessibility overlay"
id="console.settingsPage.enableAccessibilityOverlayLabel"
/>
}
onChange={(e) => toggleAccessibilityOverlay(e.target.checked)}
/>
</EuiFormRow>
<EuiFormRow
labelType="legend"
label={
<FormattedMessage
id="console.settingsPage.autocompleteLabel"
defaultMessage="Autocomplete"
/>
}
>
<EuiCheckboxGroup
options={autoCompleteCheckboxes.map((opts) => {
const { stateSetter, ...rest } = opts;
return rest;
})}
idToSelectedMap={checkboxIdToSelectedMap}
onChange={(e: unknown) => {
onAutocompleteChange(e as AutocompleteOptions);
}}
/>
</EuiFormRow>
{pollingFields}
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty data-test-subj="settingsCancelButton" onClick={props.onClose}>
<FormattedMessage id="console.settingsPage.cancelButtonLabel" defaultMessage="Cancel" />
</EuiButtonEmpty>
<EuiButton fill data-test-subj="settings-save-button" onClick={saveSettings}>
<FormattedMessage id="console.settingsPage.saveButtonLabel" defaultMessage="Save" />
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -7,5 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { Editor } from './editor';
export { EditorOutput } from './editor_output';
export { ShortcutsPopover } from './shortcuts_popover';

View file

@ -0,0 +1,69 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiIcon } from '@elastic/eui';
export const KEYS = {
keyCtrlCmd: i18n.translate('console.shortcutKeys.keyCtrlCmd', {
defaultMessage: 'Ctrl/Cmd',
}),
keyEnter: i18n.translate('console.shortcutKeys.keyEnter', {
defaultMessage: 'Enter',
}),
keyAltOption: i18n.translate('console.shortcutKeys.keyAltOption', {
defaultMessage: 'Alt/Option',
}),
keyOption: i18n.translate('console.shortcutKeys.keyOption', {
defaultMessage: 'Option',
}),
keyShift: i18n.translate('console.shortcutKeys.keyShift', {
defaultMessage: 'Shift',
}),
keyTab: i18n.translate('console.shortcutKeys.keyTab', {
defaultMessage: 'Tab',
}),
keyEsc: i18n.translate('console.shortcutKeys.keyEsc', {
defaultMessage: 'Esc',
}),
keyUp: (
<EuiIcon
type={'sortUp'}
title={i18n.translate('console.shortcutKeys.keyUpArrow', {
defaultMessage: 'Up arrow',
})}
size="s"
/>
),
keyDown: (
<EuiIcon
type={'sortDown'}
title={i18n.translate('console.shortcutKeys.keyDownArrow', {
defaultMessage: 'Down arrow',
})}
size="s"
/>
),
keySlash: i18n.translate('console.shortcutKeys.keySlash', {
defaultMessage: '/',
}),
keySpace: i18n.translate('console.shortcutKeys.keySpace', {
defaultMessage: 'Space',
}),
keyI: i18n.translate('console.shortcutKeys.keyI', {
defaultMessage: 'I',
}),
keyO: i18n.translate('console.shortcutKeys.keyO', {
defaultMessage: 'O',
}),
keyL: i18n.translate('console.shortcutKeys.keyL', {
defaultMessage: 'L',
}),
};

View file

@ -0,0 +1,65 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiCode, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
interface ShortcutLineFlexItemProps {
id: string;
description: string;
keys: any[];
alternativeKeys?: any[];
}
const renderKeys = (keys: string[]) => {
return keys.map((key, index) => (
<span key={index}>
{index > 0 && ' + '}
<EuiCode>{key}</EuiCode>
</span>
));
};
export const ShortcutLineFlexItem = ({
id,
description,
keys,
alternativeKeys,
}: ShortcutLineFlexItemProps) => {
return (
<EuiFlexItem>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiText size="xs">
{i18n.translate('console.shortcutDescription.' + id, {
defaultMessage: description,
})}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs">
{renderKeys(keys)}
{alternativeKeys && (
<>
<strong>
{' '}
{i18n.translate('console.shortcuts.alternativeKeysOrDivider', {
defaultMessage: 'or',
})}{' '}
</strong>
{renderKeys(alternativeKeys)}
</>
)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);
};

View file

@ -0,0 +1,114 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiPopover, EuiTitle, EuiHorizontalRule, EuiFlexGroup, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ShortcutLineFlexItem } from './shortcut_line';
import { KEYS } from './keys';
interface ShortcutsPopoverProps {
button: any;
isOpen: boolean;
closePopover: () => void;
}
export const ShortcutsPopover = ({ button, isOpen, closePopover }: ShortcutsPopoverProps) => {
return (
<EuiPopover
button={button}
isOpen={isOpen}
closePopover={closePopover}
anchorPosition="downRight"
data-test-subj="consoleShortcutsPopover"
>
<EuiTitle size="xxs">
<h5>
{i18n.translate('console.shortcuts.navigationShortcutsSubtitle', {
defaultMessage: 'Navigation shortcuts',
})}
</h5>
</EuiTitle>
<EuiHorizontalRule margin="s" />
<EuiFlexGroup gutterSize="s" direction="column">
<ShortcutLineFlexItem
id="goToLineNumber"
description="Go to line number"
keys={[KEYS.keyCtrlCmd, KEYS.keyL]}
/>
</EuiFlexGroup>
<EuiSpacer />
<EuiTitle size="xxs">
<h5>
{i18n.translate('console.shortcuts.requestShortcutsSubtitle', {
defaultMessage: 'Request shortcuts',
})}
</h5>
</EuiTitle>
<EuiHorizontalRule margin="s" />
<EuiFlexGroup gutterSize="s" direction="column">
<ShortcutLineFlexItem
id="autoindentCurrentRequest"
description="Auto-indent current request"
keys={[KEYS.keyCtrlCmd, KEYS.keyI]}
/>
<ShortcutLineFlexItem
id="jumpToNextRequestEnd"
description="Jump to next request end"
keys={[KEYS.keyCtrlCmd, KEYS.keyDown]}
/>
<ShortcutLineFlexItem
id="jumpToPreviousRequestEnd"
description="Jump to previous request end"
keys={[KEYS.keyCtrlCmd, KEYS.keyUp]}
/>
<ShortcutLineFlexItem
id="openDocumentation"
description="Open documentation for current request"
keys={[KEYS.keyCtrlCmd, KEYS.keySlash]}
/>
<ShortcutLineFlexItem
id="runRequest"
description="Run current request"
keys={[KEYS.keyCtrlCmd, KEYS.keyEnter]}
/>
</EuiFlexGroup>
<EuiSpacer />
<EuiTitle size="xxs">
<h5>
{i18n.translate('console.shortcuts.autocompleteShortcutsSubtitle', {
defaultMessage: 'Autocomplete menu shortcuts',
})}
</h5>
</EuiTitle>
<EuiHorizontalRule margin="s" />
<EuiFlexGroup gutterSize="s" direction="column">
<ShortcutLineFlexItem
id="applyCurrentAutocompleteSuggestion"
description="Apply current or topmost term in menu"
keys={[KEYS.keyEnter]}
alternativeKeys={[KEYS.keyTab]}
/>
<ShortcutLineFlexItem
id="closeAutocompleteMenu"
description="Close autocomplete menu"
keys={[KEYS.keyEsc]}
/>
<ShortcutLineFlexItem
id="navigateAutocompleteMenu"
description="Navigate items in autocomplete menu"
keys={[KEYS.keyDown]}
alternativeKeys={[KEYS.keyUp]}
/>
</EuiFlexGroup>
</EuiPopover>
);
};

View file

@ -9,6 +9,7 @@
import React, { FunctionComponent } from 'react';
import { EuiTabs, EuiTab } from '@elastic/eui';
import { ConsoleTourStep, ConsoleTourStepProps } from './console_tour_step';
export interface TopNavMenuItem {
id: string;
@ -16,28 +17,42 @@ export interface TopNavMenuItem {
description: string;
onClick: () => void;
testId: string;
isSelected: boolean;
tourStep?: number;
}
interface Props {
disabled?: boolean;
items: TopNavMenuItem[];
tourStepProps: ConsoleTourStepProps[];
}
export const TopNavMenu: FunctionComponent<Props> = ({ items, disabled }) => {
export const TopNavMenu: FunctionComponent<Props> = ({ items, disabled, tourStepProps }) => {
return (
<EuiTabs size="s">
<EuiTabs size="s" bottomBorder={false}>
{items.map((item, idx) => {
return (
const tab = (
<EuiTab
key={idx}
disabled={disabled}
onClick={item.onClick}
title={item.label}
data-test-subj={item.testId}
isSelected={item.isSelected}
>
{item.label}
</EuiTab>
);
if (item.tourStep) {
return (
<ConsoleTourStep tourStepProps={tourStepProps[item.tourStep - 1]} key={idx}>
{tab}
</ConsoleTourStep>
);
}
return tab;
})}
</EuiTabs>
);

View file

@ -7,5 +7,25 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export * from './variables_flyout';
export * from './utils';
import React from 'react';
import { withSuspense } from '@kbn/shared-ux-utility';
export { type Props } from './variables_editor';
export { type DevToolsVariable } from './types';
/**
* The Lazily-loaded `VariablesEditorLazy` component. Consumers should use `React.Suspense` or
* the withSuspense` HOC to load this component.
*/
export const VariablesEditorLazy = React.lazy(() =>
import('./variables_editor').then(({ VariablesEditor }) => ({
default: VariablesEditor,
}))
);
/**
* A `VariablesEditor` component that is wrapped by the `withSuspense` HOC. This component can
* be used directly by consumers and will load the `VariablesEditorLazy` component lazily with
* a predefined fallback and error boundary.
*/
export const VariablesEditor = withSuspense(VariablesEditorLazy);

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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export interface DevToolsVariable {
id: string;
name: string;
value: string;
}

View file

@ -7,38 +7,18 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { v4 as uuidv4 } from 'uuid';
import type { DevToolsVariable } from './variables_flyout';
import { type DevToolsVariable } from './types';
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 editVariable = (newVariable: DevToolsVariable, variables: DevToolsVariable[]) => {
return variables.map((variable: DevToolsVariable) => {
return variable.id === newVariable.id ? newVariable : variable;
});
};
export const deleteVariable = (variables: DevToolsVariable[], id: string) => {
return variables.filter((v) => v.id !== id);
};
export const generateEmptyVariableField = (): DevToolsVariable => ({
id: uuidv4(),
name: '',
value: '',
});
export const isValidVariableName = (name: string) => {
/*
* MUST avoid characters that get URL-encoded, because they'll result in unusable variable names.

View file

@ -0,0 +1,255 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { BehaviorSubject } from 'rxjs';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiTitle,
EuiButton,
EuiBasicTable,
EuiButtonIcon,
EuiSpacer,
EuiText,
EuiCode,
useGeneratedHtmlId,
EuiConfirmModal,
type EuiBasicTableColumn,
} from '@elastic/eui';
import { VariableEditorForm } from './variables_editor_form';
import * as utils from './utils';
import { type DevToolsVariable } from './types';
export interface Props {
onSaveVariables: (newVariables: DevToolsVariable[]) => void;
variables: [];
}
export const VariablesEditor = (props: Props) => {
const isMounted = useRef(false);
const [isAddingVariable, setIsAddingVariable] = useState(false);
const [deleteModalForVariable, setDeleteModalForVariable] = useState<string | null>(null);
const [variables, setVariables] = useState<DevToolsVariable[]>(props.variables);
const deleteModalTitleId = useGeneratedHtmlId();
// Use a ref to persist the BehaviorSubject across renders
const itemIdToExpandedRowMap$ = useRef(new BehaviorSubject<Record<string, React.ReactNode>>({}));
// Subscribe to the BehaviorSubject and update local state on change
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<
Record<string, React.ReactNode>
>({});
// Clear the expanded row map and dispose all the expanded rows
const collapseExpandedRows = () => itemIdToExpandedRowMap$.current.next({});
// Subscribe to the BehaviorSubject on mount
useEffect(() => {
const subscription = itemIdToExpandedRowMap$.current.subscribe(setItemIdToExpandedRowMap);
return () => subscription.unsubscribe();
}, []);
// Always save variables when they change
useEffect(() => {
if (isMounted.current) {
props.onSaveVariables(variables);
} else {
isMounted.current = true;
}
}, [variables, props]);
const toggleDetails = (variableId: string) => {
const currentMap = itemIdToExpandedRowMap$.current.getValue();
let itemIdToExpandedRowMapValues = { ...currentMap };
if (itemIdToExpandedRowMapValues[variableId]) {
delete itemIdToExpandedRowMapValues[variableId];
} else {
// Always close the add variable form when editing a variable
setIsAddingVariable(false);
// We only allow one expanded row at a time
itemIdToExpandedRowMapValues = {};
itemIdToExpandedRowMapValues[variableId] = (
<VariableEditorForm
title={i18n.translate('console.variablesPage.editVariableForm.title', {
defaultMessage: 'Edit variable',
})}
onSubmit={(data: DevToolsVariable) => {
const updatedVariables = utils.editVariable(data, variables);
setVariables(updatedVariables);
collapseExpandedRows();
}}
onCancel={() => {
collapseExpandedRows();
}}
defaultValue={variables.find((v) => v.id === variableId)}
/>
);
}
// Update the BehaviorSubject with the new state
itemIdToExpandedRowMap$.current.next(itemIdToExpandedRowMapValues);
};
const deleteVariable = useCallback(
(id: string) => {
const updatedVariables = utils.deleteVariable(variables, id);
setVariables(updatedVariables);
setDeleteModalForVariable(null);
},
[variables, setDeleteModalForVariable]
);
const onAddVariable = (data: DevToolsVariable) => {
setVariables((v: DevToolsVariable[]) => [...v, data]);
setIsAddingVariable(false);
};
const columns: Array<EuiBasicTableColumn<DevToolsVariable>> = [
{
field: 'name',
name: i18n.translate('console.variablesPage.variablesTable.columns.variableHeader', {
defaultMessage: 'Variable name',
}),
'data-test-subj': 'variableNameCell',
render: (name: string) => {
return <EuiCode>{`\$\{${name}\}`}</EuiCode>;
},
},
{
field: 'value',
name: i18n.translate('console.variablesPage.variablesTable.columns.valueHeader', {
defaultMessage: 'Value',
}),
'data-test-subj': 'variableValueCell',
render: (value: string) => <EuiCode>{value}</EuiCode>,
},
{
field: 'id',
name: '',
width: '40px',
isExpander: true,
render: (id: string, variable: DevToolsVariable) => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
return (
<EuiButtonIcon
iconType={itemIdToExpandedRowMapValues[id] ? 'arrowUp' : 'pencil'}
aria-label={i18n.translate('console.variablesPage.variablesTable.columns.editButton', {
defaultMessage: 'Edit {variable}',
values: { variable: variable.name },
})}
color="primary"
onClick={() => toggleDetails(id)}
data-test-subj="variableEditButton"
/>
);
},
},
{
field: 'id',
name: '',
width: '40px',
render: (id: string, variable: DevToolsVariable) => (
<EuiButtonIcon
iconType="trash"
aria-label={i18n.translate('console.variablesPage.variablesTable.columns.deleteButton', {
defaultMessage: 'Delete {variable}',
values: { variable: variable.name },
})}
color="danger"
onClick={() => setDeleteModalForVariable(id)}
data-test-subj="variablesRemoveButton"
/>
),
},
];
return (
<>
<EuiTitle>
<h2>
<FormattedMessage id="console.variablesPage.pageTitle" defaultMessage="Variables" />
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText color="subdued">
<p>
<FormattedMessage
id="console.variablesPage.pageDescription"
defaultMessage="Define reusable placeholders for dynamic values in your queries."
/>
</p>
</EuiText>
<EuiSpacer size="l" />
<EuiBasicTable
items={variables}
columns={columns}
itemId="id"
responsiveBreakpoint={false}
className="conVariablesTable"
data-test-subj="variablesTable"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
noItemsMessage={i18n.translate('console.variablesPage.table.noItemsMessage', {
defaultMessage: 'No variables have been added yet',
})}
/>
{isAddingVariable && (
<VariableEditorForm onSubmit={onAddVariable} onCancel={() => setIsAddingVariable(false)} />
)}
<EuiSpacer size="m" />
<div>
<EuiButton
data-test-subj="variablesAddButton"
iconType="plusInCircle"
onClick={() => {
setIsAddingVariable(true);
collapseExpandedRows();
}}
disabled={isAddingVariable}
>
<FormattedMessage
id="console.variablesPage.addButtonLabel"
defaultMessage="Add variable"
/>
</EuiButton>
</div>
{deleteModalForVariable && (
<EuiConfirmModal
aria-labelledby={deleteModalTitleId}
title={i18n.translate('console.variablesPage.deleteModal.title', {
defaultMessage: 'Are you sure?',
})}
onCancel={() => setDeleteModalForVariable(null)}
onConfirm={() => deleteVariable(deleteModalForVariable)}
cancelButtonText={i18n.translate('console.variablesPage.deleteModal.cancelButtonText', {
defaultMessage: 'Cancel',
})}
confirmButtonText={i18n.translate('console.variablesPage.deleteModal.confirmButtonText', {
defaultMessage: 'Delete variable',
})}
buttonColor="danger"
>
<p>
<FormattedMessage
id="console.variablesPage.deleteModal.description"
defaultMessage="Deleting a variable is irreversible."
/>
</p>
</EuiConfirmModal>
)}
</>
);
};

View file

@ -0,0 +1,184 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { v4 as uuidv4 } from 'uuid';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiSpacer,
EuiPanel,
EuiButtonEmpty,
} from '@elastic/eui';
import {
useForm,
Form,
UseField,
TextField,
FieldConfig,
fieldValidators,
FormConfig,
ValidationFuncArg,
} from '../../../shared_imports';
import { type DevToolsVariable } from './types';
import { isValidVariableName } from './utils';
export interface VariableEditorFormProps {
onSubmit: (data: DevToolsVariable) => void;
onCancel: () => void;
defaultValue?: DevToolsVariable;
title?: string;
}
const fieldsConfig: Record<string, FieldConfig> = {
variableName: {
label: i18n.translate('console.variablesPage.form.variableNameFieldLabel', {
defaultMessage: 'Variable name',
}),
validations: [
{
validator: ({ value }: ValidationFuncArg<any, any>) => {
if (value.trim() === '') {
return {
message: i18n.translate('console.variablesPage.form.variableNameRequiredLabel', {
defaultMessage: 'This is a required field',
}),
};
}
if (!isValidVariableName(value)) {
return {
message: i18n.translate('console.variablesPage.form.variableNameInvalidLabel', {
defaultMessage: 'Only letters, numbers and underscores are allowed',
}),
};
}
},
},
],
},
value: {
label: i18n.translate('console.variablesPage.form.valueFieldLabel', {
defaultMessage: 'Value',
}),
validations: [
{
validator: fieldValidators.emptyField(
i18n.translate('console.variablesPage.form.valueRequiredLabel', {
defaultMessage: 'Value is required',
})
),
},
],
},
};
export const VariableEditorForm = (props: VariableEditorFormProps) => {
const onSubmit: FormConfig['onSubmit'] = async (data, isValid) => {
if (isValid) {
props.onSubmit({
...props.defaultValue,
...data,
...(props.defaultValue ? {} : { id: uuidv4() }),
} as DevToolsVariable);
}
};
const { form } = useForm({ onSubmit, defaultValue: props.defaultValue });
return (
<>
<EuiPanel
paddingSize="l"
hasShadow={false}
borderRadius="none"
grow={false}
css={{ width: '100%' }}
>
<EuiTitle size="xs">
<h2>
{props.title ?? (
<FormattedMessage
defaultMessage="Add a new variable"
id="console.variablesPage.addNewVariableTitle"
/>
)}
</h2>
</EuiTitle>
<EuiSpacer size="l" />
<Form form={form}>
<UseField
config={fieldsConfig.variableName}
path="name"
component={TextField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'nameField',
placeholder: i18n.translate('console.variablesPage.form.namePlaceholderLabel', {
defaultMessage: 'exampleName',
}),
prepend: '${',
append: '}',
},
}}
/>
<UseField
config={fieldsConfig.value}
path="value"
component={TextField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'valueField',
placeholder: i18n.translate('console.variablesPage.form.valuePlaceholderLabel', {
defaultMessage: 'exampleValue',
}),
},
}}
/>
<EuiSpacer size="l" />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => props.onCancel()}>
<FormattedMessage
id="console.variablesPage.addNew.cancelButton"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="save"
onClick={form.submit}
data-test-subj="addNewVariableButton"
>
<FormattedMessage
id="console.variablesPage.addNew.submitButton"
defaultMessage="Save changes"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</Form>
</EuiPanel>
</>
);
};

View file

@ -1,215 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", 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 }) => {
const isInvalid = !utils.isValidVariableName(name);
return (
<EuiFormRow
isInvalid={isInvalid}
error={[
<FormattedMessage
id="console.variablesPage.variablesTable.variableInputError.validCharactersText"
defaultMessage="Only letters, numbers and underscores 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

@ -1,167 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiTitle,
EuiButton,
EuiText,
EuiFlyoutFooter,
EuiCode,
} from '@elastic/eui';
import EditorExample from './editor_example';
import * as examples from '../../../common/constants/welcome_panel';
interface Props {
onDismiss: () => void;
}
export function WelcomePanel(props: Props) {
return (
<EuiFlyout
onClose={props.onDismiss}
data-test-subj="welcomePanel"
size="m"
maxWidth={0}
ownFocus={false}
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="console.welcomePage.pageTitle"
defaultMessage="Send requests with Console"
/>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiText>
<p>
<FormattedMessage
id="console.welcomePage.quickIntroDescription"
defaultMessage="Console understands commands in a cURL-like syntax. Here is a request to the Elasticsearch _search API."
/>
</p>
<EditorExample panel="welcome-example-1" />
<p>
<FormattedMessage
id="console.welcomePage.kibanaAPIsDescription"
defaultMessage="To send a request to a Kibana API, prefix the path with {kibanaApiPrefix}."
values={{
kibanaApiPrefix: <EuiCode>kbn:</EuiCode>,
}}
/>
</p>
<EditorExample
panel="welcome-example-2"
example={examples.kibanaApiExample}
linesOfExampleCode={2}
/>
<h4>
<FormattedMessage
id="console.welcomePage.sendMultipleRequestsTitle"
defaultMessage="Send multiple requests"
/>
</h4>
<p>
<FormattedMessage
id="console.welcomePage.sendMultipleRequestsDescription"
defaultMessage="Select multiple requests and send them together. You'll get responses to all your requests, regardless of whether they succeed or fail."
/>
</p>
<EditorExample
panel="welcome-example-3"
example={examples.multipleRequestsExample}
linesOfExampleCode={22}
mode="output"
/>
<h4>
<FormattedMessage
id="console.welcomePage.addCommentsTitle"
defaultMessage="Add comments in request bodies"
/>
</h4>
<p>
<FormattedMessage
id="console.welcomePage.addCommentsDescription"
defaultMessage="To add a single-line comment, use {hash} or {doubleSlash}. For a multiline comment, mark the beginning with {slashAsterisk} and the end with {asteriskSlash}."
values={{
hash: <EuiCode>#</EuiCode>,
doubleSlash: <EuiCode>//</EuiCode>,
slashAsterisk: <EuiCode>/*</EuiCode>,
asteriskSlash: <EuiCode>*/</EuiCode>,
}}
/>
</p>
<EditorExample
panel="welcome-example-4"
example={examples.commentsExample}
linesOfExampleCode={14}
/>
<h4>
<FormattedMessage
id="console.welcomePage.useVariablesTitle"
defaultMessage="Reuse values with variables"
/>
</h4>
<p>
<FormattedMessage
id="console.welcomePage.useVariablesDescription"
defaultMessage="Define variables in Console, and then use them in your requests in the form of {variableName}."
values={{
// use html tags to render the curly braces
variableName: <EuiCode>$&#123;variableName&#125;</EuiCode>,
}}
/>
</p>
<ol>
<li>
<FormattedMessage
id="console.welcomePage.useVariables.step1"
defaultMessage="Click {variableText}, and then enter the variable name and value."
values={{
variableText: <strong>Variables</strong>,
}}
/>
</li>
<li>
<FormattedMessage
id="console.welcomePage.useVariables.step2"
defaultMessage="Refer to variables in the paths and bodies of your requests as many times as you like."
/>
</li>
</ol>
<EditorExample
panel="welcome-example-5"
example={examples.variablesExample}
linesOfExampleCode={9}
/>
</EuiText>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiButton
fill={true}
fullWidth={false}
data-test-subj="help-close-button"
onClick={props.onDismiss}
>
<FormattedMessage id="console.welcomePage.closeButtonLabel" defaultMessage="Dismiss" />
</EuiButton>
</EuiFlyoutFooter>
</EuiFlyout>
);
}

View file

@ -0,0 +1,47 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import { Settings } from './settings';
import { Variables } from './variables';
export interface Props {
isVerticalLayout: boolean;
}
export function Config({ isVerticalLayout }: Props) {
return (
<EuiPanel
color="subdued"
paddingSize="l"
hasShadow={false}
borderRadius="none"
css={{ height: '100%' }}
data-test-subj="consoleConfigPanel"
>
<EuiFlexGroup
gutterSize="xl"
direction={isVerticalLayout ? 'column' : 'row'}
// Turn off default responsiveness
responsive={false}
>
<EuiFlexItem>
<Settings />
<EuiSpacer size="m" />
</EuiFlexItem>
<EuiFlexItem>
<Variables />
<EuiSpacer size="m" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -7,4 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export * from './output_data';
export { Config } from './config';

View file

@ -9,11 +9,10 @@
import React from 'react';
import { AutocompleteOptions, DevToolsSettingsModal } from '../components';
import { AutocompleteOptions, SettingsEditor } from '../../components/settings';
import { useServicesContext, useEditorActionContext } from '../contexts';
import { DevToolsSettings, Settings as SettingsService } from '../../services';
import type { SenseEditor } from '../models';
import { useServicesContext, useEditorActionContext } from '../../contexts';
import { DevToolsSettings, Settings as SettingsService } from '../../../services';
const getAutocompleteDiff = (
newSettings: DevToolsSettings,
@ -25,12 +24,7 @@ const getAutocompleteDiff = (
}) as AutocompleteOptions[];
};
export interface Props {
onClose: () => void;
editorInstance: SenseEditor | null;
}
export function Settings({ onClose, editorInstance }: Props) {
export function Settings() {
const {
services: { settings, autocompleteInfo },
} = useServicesContext();
@ -92,18 +86,15 @@ export function Settings({ onClose, editorInstance }: Props) {
type: 'updateSettings',
payload: newSettings,
});
onClose();
};
return (
<DevToolsSettingsModal
onClose={onClose}
<SettingsEditor
onSaveSettings={onSaveSettings}
refreshAutocompleteSettings={(selectedSettings) =>
refreshAutocompleteSettings(settings, selectedSettings)
}
settings={settings.toJSON()}
editorInstance={editorInstance}
/>
);
}

View file

@ -8,27 +8,22 @@
*/
import React from 'react';
import { DevToolsVariablesFlyout, DevToolsVariable } from '../components';
import { useServicesContext } from '../contexts';
import { StorageKeys } from '../../services';
import { DEFAULT_VARIABLES } from '../../../common/constants';
import { type DevToolsVariable, VariablesEditor } from '../../components/variables';
import { useServicesContext } from '../../contexts';
import { StorageKeys } from '../../../services';
import { DEFAULT_VARIABLES } from '../../../../common/constants';
interface VariablesProps {
onClose: () => void;
}
export function Variables({ onClose }: VariablesProps) {
export function Variables() {
const {
services: { storage },
} = useServicesContext();
const onSaveVariables = (newVariables: DevToolsVariable[]) => {
storage.set(StorageKeys.VARIABLES, newVariables);
onClose();
};
return (
<DevToolsVariablesFlyout
onClose={onClose}
<VariablesEditor
onSaveVariables={onSaveVariables}
variables={storage.get(StorageKeys.VARIABLES, DEFAULT_VARIABLES)}
/>

View file

@ -1,242 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { memoize } from 'lodash';
import moment from 'moment';
import {
keys,
EuiSpacer,
EuiIcon,
EuiTitle,
EuiFlexItem,
EuiFlexGroup,
EuiButtonEmpty,
EuiButton,
} from '@elastic/eui';
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';
interface Props {
close: () => void;
}
const CHILD_ELEMENT_PREFIX = 'historyReq';
export function ConsoleHistory({ close }: Props) {
const {
services: { history },
config: { isMonacoEnabled },
} = useServicesContext();
const { settings: readOnlySettings } = useEditorReadContext();
const [requests, setPastRequests] = useState<any[]>(history.getHistory());
const clearHistory = useCallback(() => {
history.clearHistory();
setPastRequests(history.getHistory());
}, [history]);
const listRef = useRef<HTMLUListElement | null>(null);
const [viewingReq, setViewingReq] = useState<any>(null);
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const selectedReq = useRef<any>(null);
const describeReq = useMemo(() => {
const _describeReq = (req: { endpoint: string; time: string }) => {
const endpoint = req.endpoint;
const date = moment(req.time);
let formattedDate = date.format('MMM D');
if (date.diff(moment(), 'days') > -7) {
formattedDate = date.fromNow();
}
return `${endpoint} (${formattedDate})`;
};
(_describeReq as any).cache = new WeakMap();
return memoize(_describeReq);
}, []);
const scrollIntoView = useCallback((idx: number) => {
const activeDescendant = listRef.current!.querySelector(`#${CHILD_ELEMENT_PREFIX}${idx}`);
if (activeDescendant) {
activeDescendant.scrollIntoView();
}
}, []);
const initialize = useCallback(() => {
const nextSelectedIndex = 0;
(describeReq as any).cache = new WeakMap();
setViewingReq(requests[nextSelectedIndex]);
selectedReq.current = requests[nextSelectedIndex];
setSelectedIndex(nextSelectedIndex);
scrollIntoView(nextSelectedIndex);
}, [describeReq, requests, scrollIntoView]);
const clear = () => {
clearHistory();
initialize();
};
const restoreRequestFromHistory = useRestoreRequestFromHistory(isMonacoEnabled);
useEffect(() => {
initialize();
}, [initialize]);
useEffect(() => {
const done = history.change(setPastRequests);
return () => done();
}, [history]);
/* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role,jsx-a11y/click-events-have-key-events */
return (
<>
<div className="conHistory">
<EuiTitle size="s">
<h2>{i18n.translate('console.historyPage.pageTitle', { defaultMessage: 'History' })}</h2>
</EuiTitle>
<EuiSpacer size="s" />
<div className="conHistory__body">
<ul
ref={listRef}
onKeyDown={(ev: React.KeyboardEvent) => {
if (ev.key === keys.ENTER) {
restoreRequestFromHistory(selectedReq.current);
return;
}
let currentIdx = selectedIndex;
if (ev.key === keys.ARROW_UP) {
ev.preventDefault();
--currentIdx;
} else if (ev.key === keys.ARROW_DOWN) {
ev.preventDefault();
++currentIdx;
}
const nextSelectedIndex = Math.min(Math.max(0, currentIdx), requests.length - 1);
setViewingReq(requests[nextSelectedIndex]);
selectedReq.current = requests[nextSelectedIndex];
setSelectedIndex(nextSelectedIndex);
scrollIntoView(nextSelectedIndex);
}}
role="listbox"
className="list-group conHistory__reqs"
tabIndex={0}
aria-activedescendant={`${CHILD_ELEMENT_PREFIX}${selectedIndex}`}
aria-label={i18n.translate('console.historyPage.requestListAriaLabel', {
defaultMessage: 'History of sent requests',
})}
>
{requests.map((req, idx) => {
const reqDescription = describeReq(req);
const isSelected = viewingReq === req;
return (
// Ignore a11y issues on li's
<li
key={idx}
id={`${CHILD_ELEMENT_PREFIX}${idx}`}
className={`list-group-item conHistory__req ${
isSelected ? 'conHistory__req-selected' : ''
}`}
onClick={() => {
setViewingReq(req);
selectedReq.current = req;
setSelectedIndex(idx);
}}
role="option"
onMouseEnter={() => setViewingReq(req)}
onMouseLeave={() => setViewingReq(selectedReq.current)}
onDoubleClick={() => restoreRequestFromHistory(selectedReq.current)}
aria-label={i18n.translate('console.historyPage.itemOfRequestListAriaLabel', {
defaultMessage: 'Request: {historyItem}',
values: { historyItem: reqDescription },
})}
aria-selected={isSelected}
>
{reqDescription}
<span className="conHistory__reqIcon">
<EuiIcon type="arrowRight" />
</span>
</li>
);
})}
</ul>
<div className="conHistory__body__spacer" />
{isMonacoEnabled ? (
<HistoryViewerMonaco settings={readOnlySettings} req={viewingReq} />
) : (
<HistoryViewer settings={readOnlySettings} req={viewingReq} />
)}
</div>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="consoleClearHistoryButton"
color="danger"
onClick={() => clear()}
>
{i18n.translate('console.historyPage.clearHistoryButtonLabel', {
defaultMessage: 'Clear',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="consoleHistoryCloseButton"
color="primary"
onClick={() => close()}
>
{i18n.translate('console.historyPage.closehistoryButtonLabel', {
defaultMessage: 'Close',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="consoleHistoryApplyButton"
color="primary"
disabled={!selectedReq}
onClick={() => restoreRequestFromHistory(selectedReq.current)}
>
{i18n.translate('console.historyPage.applyHistoryButtonLabel', {
defaultMessage: 'Apply',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</div>
<EuiSpacer size="s" />
</>
);
}

View file

@ -1,61 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useEffect, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { DevToolsSettings } from '../../../services';
import { subscribeResizeChecker } from '../editor/legacy/subscribe_console_resize_checker';
import * as InputMode from '../../models/legacy_core_editor/mode/input';
const inputMode = new InputMode.Mode();
import * as editor from '../../models/legacy_core_editor';
import { applyCurrentSettings } from '../editor/legacy/console_editor/apply_editor_settings';
import { formatRequestBodyDoc } from '../../../lib/utils';
interface Props {
settings: DevToolsSettings;
req: { method: string; endpoint: string; data: string; time: string } | null;
}
export function HistoryViewer({ settings, req }: Props) {
const divRef = useRef<HTMLDivElement | null>(null);
const viewerRef = useRef<editor.CustomAceEditor | null>(null);
useEffect(() => {
const viewer = editor.createReadOnlyAceEditor(divRef.current!);
viewerRef.current = viewer;
const unsubscribe = subscribeResizeChecker(divRef.current!, viewer);
return () => unsubscribe();
}, []);
useEffect(() => {
applyCurrentSettings(viewerRef.current!, settings);
}, [settings]);
if (viewerRef.current) {
const { current: viewer } = viewerRef;
if (req) {
const indent = true;
const formattedData = req.data ? formatRequestBodyDoc([req.data], indent).data : '';
const s = req.method + ' ' + req.endpoint + '\n' + formattedData;
viewer.update(s, inputMode);
viewer.clearSelection();
} else {
viewer.update(
i18n.translate('console.historyPage.noHistoryTextMessage', {
defaultMessage: 'No history available',
}),
inputMode
);
}
}
return <div className="conHistory__viewer" ref={divRef} />;
}

View file

@ -9,7 +9,7 @@
import React, { useState } from 'react';
import {
EuiIcon,
EuiButtonIcon,
EuiContextMenuPanel,
EuiContextMenuItem,
EuiPopover,
@ -18,16 +18,17 @@ import {
EuiLink,
EuiLoadingSpinner,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { NotificationsSetup } from '@kbn/core/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { LanguageSelectorModal } from './language_selector_modal';
import { convertRequestToLanguage } from '../../../../../../services';
import { convertRequestToLanguage } from '../../../../../services';
import type { EditorRequest } from '../../types';
import { useServicesContext } from '../../../../../contexts';
import { StorageKeys } from '../../../../../../services';
import { DEFAULT_LANGUAGE, AVAILABLE_LANGUAGES } from '../../../../../../../common/constants';
import { useServicesContext } from '../../../../contexts';
import { StorageKeys } from '../../../../../services';
import { DEFAULT_LANGUAGE, AVAILABLE_LANGUAGES } from '../../../../../../common/constants';
interface Props {
getRequests: () => Promise<EditorRequest[]>;
@ -36,6 +37,20 @@ interface Props {
notifications: NotificationsSetup;
}
const styles = {
// Remove the default underline on hover for the context menu items since it
// will also be applied to the language selector button, and apply it only to
// the text in the context menu item.
button: css`
&:hover {
text-decoration: none !important;
.languageSelector {
text-decoration: underline;
}
}
`,
};
const DELAY_FOR_HIDING_SPINNER = 500;
const getLanguageLabelByValue = (value: string) => {
@ -158,15 +173,15 @@ export const ContextMenu = ({
};
const button = (
<EuiLink
<EuiButtonIcon
onClick={() => setIsPopoverOpen((prev) => !prev)}
data-test-subj="toggleConsoleMenu"
aria-label={i18n.translate('console.requestOptionsButtonAriaLabel', {
defaultMessage: 'Request options',
})}
>
<EuiIcon type="boxesVertical" />
</EuiLink>
iconType="boxesVertical"
iconSize="s"
/>
);
const items = [
@ -187,10 +202,11 @@ export const ContextMenu = ({
onCopyAsSubmit();
}}
icon="copyClipboard"
css={styles.button}
>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiFlexGroup gutterSize="xs" alignItems="center">
<EuiFlexGroup gutterSize="xs" alignItems="center" className="languageSelector">
<EuiFlexItem grow={false}>
<FormattedMessage
tagName="span"

View file

@ -24,7 +24,7 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { AVAILABLE_LANGUAGES } from '../../../../../../../common/constants';
import { AVAILABLE_LANGUAGES } from '../../../../../../common/constants';
interface Props {
closeModal: () => void;

View file

@ -7,95 +7,283 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, memo, useEffect, useState } from 'react';
import React, { useRef, useCallback, memo, useEffect, useState } from 'react';
import { debounce } from 'lodash';
import { EuiProgress } from '@elastic/eui';
import {
EuiProgress,
EuiSplitPanel,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiResizableContainer,
} from '@elastic/eui';
import { euiThemeVars } from '@kbn/ui-theme';
import { EditorContentSpinner } from '../../components';
import { Panel, PanelsContainer } from '..';
import { Editor as EditorUI, EditorOutput } from './legacy/console_editor';
import { i18n } from '@kbn/i18n';
import { TextObject } from '../../../../common/text_object';
import {
EditorContentSpinner,
OutputPanelEmptyState,
NetworkRequestStatusBar,
} from '../../components';
import { getAutocompleteInfo, StorageKeys } from '../../../services';
import { useEditorReadContext, useServicesContext, useRequestReadContext } from '../../contexts';
import type { SenseEditor } from '../../models';
import { MonacoEditor, MonacoEditorOutput } from './monaco';
import {
useEditorReadContext,
useServicesContext,
useRequestReadContext,
useRequestActionContext,
useEditorActionContext,
} from '../../contexts';
import { MonacoEditor } from './monaco_editor';
import { MonacoEditorOutput } from './monaco_editor_output';
import { getResponseWithMostSevereStatusCode } from '../../../lib/utils';
const INITIAL_PANEL_WIDTH = 50;
const PANEL_MIN_WIDTH = '100px';
const INITIAL_PANEL_SIZE = 50;
const PANEL_MIN_SIZE = '20%';
const DEBOUNCE_DELAY = 500;
interface Props {
loading: boolean;
setEditorInstance: (instance: SenseEditor) => void;
isVerticalLayout: boolean;
inputEditorValue: string;
setInputEditorValue: (value: string) => void;
}
export const Editor = memo(({ loading, setEditorInstance }: Props) => {
const {
services: { storage },
config: { isMonacoEnabled } = {},
} = useServicesContext();
export const Editor = memo(
({ loading, isVerticalLayout, inputEditorValue, setInputEditorValue }: Props) => {
const {
services: { storage, objectStorageClient },
} = useServicesContext();
const { currentTextObject } = useEditorReadContext();
const { requestInFlight } = useRequestReadContext();
const editorValueRef = useRef<TextObject | null>(null);
const { currentTextObject } = useEditorReadContext();
const {
requestInFlight,
lastResult: { data: requestData, error: requestError },
} = useRequestReadContext();
const [fetchingMappings, setFetchingMappings] = useState(false);
const dispatch = useRequestActionContext();
const editorDispatch = useEditorActionContext();
useEffect(() => {
const subscription = getAutocompleteInfo().mapping.isLoading$.subscribe(setFetchingMappings);
return () => {
subscription.unsubscribe();
};
}, []);
const [fetchingAutocompleteEntities, setFetchingAutocompleteEntities] = useState(false);
const [firstPanelWidth, secondPanelWidth] = storage.get(StorageKeys.WIDTH, [
INITIAL_PANEL_WIDTH,
INITIAL_PANEL_WIDTH,
]);
useEffect(() => {
const debouncedSetFechingAutocompleteEntities = debounce(
setFetchingAutocompleteEntities,
DEBOUNCE_DELAY
);
const subscription = getAutocompleteInfo().isLoading$.subscribe(
debouncedSetFechingAutocompleteEntities
);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
const onPanelWidthChange = useCallback(
debounce((widths: number[]) => {
storage.set(StorageKeys.WIDTH, widths);
}, 300),
[]
);
return () => {
subscription.unsubscribe();
debouncedSetFechingAutocompleteEntities.cancel();
};
}, []);
if (!currentTextObject) return null;
const [firstPanelSize, secondPanelSize] = storage.get(StorageKeys.SIZE, [
INITIAL_PANEL_SIZE,
INITIAL_PANEL_SIZE,
]);
return (
<>
{requestInFlight || fetchingMappings ? (
<div className="conApp__requestProgressBarContainer">
<EuiProgress size="xs" color="accent" position="absolute" />
</div>
) : null}
<PanelsContainer onPanelWidthChange={onPanelWidthChange} resizerClassName="conApp__resizer">
<Panel
style={{ height: '100%', position: 'relative', minWidth: PANEL_MIN_WIDTH }}
initialWidth={firstPanelWidth}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
const onPanelSizeChange = useCallback(
debounce((sizes) => {
storage.set(StorageKeys.SIZE, Object.values(sizes));
}, 300),
[]
);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
const debouncedUpdateLocalStorageValue = useCallback(
debounce((textObject: TextObject) => {
editorValueRef.current = textObject;
objectStorageClient.text.update(textObject);
}, DEBOUNCE_DELAY),
[]
);
useEffect(() => {
return () => {
editorDispatch({
type: 'setCurrentTextObject',
payload: editorValueRef.current!,
});
};
}, [editorDispatch]);
// Always keep the localstorage in sync with the value in the editor
// to avoid losing the text object when the user navigates away from the shell
useEffect(() => {
// Only update when its not empty, this is to avoid setting the localstorage value
// to an empty string that will then be replaced by the example request.
if (inputEditorValue !== '') {
const textObject = {
...currentTextObject,
text: inputEditorValue,
updatedAt: Date.now(),
} as TextObject;
debouncedUpdateLocalStorageValue(textObject);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [inputEditorValue, debouncedUpdateLocalStorageValue]);
const data = getResponseWithMostSevereStatusCode(requestData) ?? requestError;
const isLoading = loading || requestInFlight;
if (!currentTextObject) return null;
return (
<>
{fetchingAutocompleteEntities ? (
<div className="conApp__requestProgressBarContainer">
<EuiProgress size="xs" color="accent" position="absolute" />
</div>
) : null}
<EuiResizableContainer
style={{ height: '100%' }}
direction={isVerticalLayout ? 'vertical' : 'horizontal'}
onPanelWidthChange={(sizes) => onPanelSizeChange(sizes)}
data-test-subj="consoleEditorContainer"
>
{loading ? (
<EditorContentSpinner />
) : isMonacoEnabled ? (
<MonacoEditor initialTextValue={currentTextObject.text} />
) : (
<EditorUI
initialTextValue={currentTextObject.text}
setEditorInstance={setEditorInstance}
/>
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel
initialSize={firstPanelSize}
minSize={PANEL_MIN_SIZE}
tabIndex={0}
paddingSize="none"
>
<EuiSplitPanel.Outer
grow={true}
borderRadius="none"
hasShadow={false}
style={{ height: '100%' }}
>
<EuiSplitPanel.Inner
paddingSize="none"
grow={true}
className="consoleEditorPanel"
style={{ top: 0, height: 'calc(100% - 40px)' }}
>
{loading ? (
<EditorContentSpinner />
) : (
<MonacoEditor
localStorageValue={currentTextObject.text}
value={inputEditorValue}
setValue={setInputEditorValue}
/>
)}
</EuiSplitPanel.Inner>
{!loading && (
<EuiSplitPanel.Inner
grow={false}
paddingSize="s"
css={{
backgroundColor: euiThemeVars.euiFormBackgroundColor,
}}
className="consoleEditorPanel"
>
<EuiButtonEmpty
size="xs"
color="primary"
data-test-subj="clearConsoleInput"
onClick={() => setInputEditorValue('')}
>
{i18n.translate('console.editor.clearConsoleInputButton', {
defaultMessage: 'Clear this input',
})}
</EuiButtonEmpty>
</EuiSplitPanel.Inner>
)}
</EuiSplitPanel.Outer>
</EuiResizablePanel>
<EuiResizableButton
className="conApp__resizerButton"
aria-label={i18n.translate('console.editor.adjustPanelSizeAriaLabel', {
defaultMessage: "Press left/right to adjust panels' sizes",
})}
/>
<EuiResizablePanel
initialSize={secondPanelSize}
minSize={PANEL_MIN_SIZE}
tabIndex={0}
paddingSize="none"
>
<EuiSplitPanel.Outer
borderRadius="none"
hasShadow={false}
style={{ height: '100%' }}
>
<EuiSplitPanel.Inner
paddingSize="none"
css={{ alignContent: 'center', top: 0 }}
className="consoleEditorPanel"
>
{data ? (
<MonacoEditorOutput />
) : isLoading ? (
<EditorContentSpinner />
) : (
<OutputPanelEmptyState />
)}
</EuiSplitPanel.Inner>
{(data || isLoading) && (
<EuiSplitPanel.Inner
grow={false}
paddingSize="s"
css={{
backgroundColor: euiThemeVars.euiFormBackgroundColor,
}}
className="consoleEditorPanel"
>
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
color="primary"
data-test-subj="clearConsoleOutput"
onClick={() => dispatch({ type: 'cleanRequest', payload: undefined })}
>
{i18n.translate('console.editor.clearConsoleOutputButton', {
defaultMessage: 'Clear this output',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<NetworkRequestStatusBar
requestInProgress={requestInFlight}
requestResult={
data
? {
method: data.request.method.toUpperCase(),
endpoint: data.request.path,
statusCode: data.response.statusCode,
statusText: data.response.statusText,
timeElapsedMs: data.response.timeMs,
}
: undefined
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
)}
</EuiSplitPanel.Outer>
</EuiResizablePanel>
</>
)}
</Panel>
<Panel
style={{ height: '100%', position: 'relative', minWidth: PANEL_MIN_WIDTH }}
initialWidth={secondPanelWidth}
>
{loading ? (
<EditorContentSpinner />
) : isMonacoEnabled ? (
<MonacoEditorOutput />
) : (
<EditorOutput />
)}
</Panel>
</PanelsContainer>
</>
);
});
</EuiResizableContainer>
</>
);
}
);

View file

@ -13,7 +13,7 @@ import { IToasts } from '@kbn/core-notifications-browser';
import { decompressFromEncodedURIComponent } from 'lz-string';
import { i18n } from '@kbn/i18n';
import { useEffect } from 'react';
import { DEFAULT_INPUT_VALUE } from '../../../../../../common/constants';
import { DEFAULT_INPUT_VALUE } from '../../../../../common/constants';
interface QueryParams {
load_from: string;
@ -21,7 +21,7 @@ interface QueryParams {
interface SetInitialValueParams {
/** The text value that is initially in the console editor. */
initialTextValue?: string;
localStorageValue?: string;
/** The function that sets the state of the value in the console editor. */
setValue: (value: string) => void;
/** The toasts service. */
@ -45,7 +45,7 @@ export const readLoadFromParam = () => {
* @param params The {@link SetInitialValueParams} to use.
*/
export const useSetInitialValue = (params: SetInitialValueParams) => {
const { initialTextValue, setValue, toasts } = params;
const { localStorageValue, setValue, toasts } = params;
useEffect(() => {
const loadBufferFromRemote = async (url: string) => {
@ -61,7 +61,7 @@ export const useSetInitialValue = (params: SetInitialValueParams) => {
if (parsedURL.origin === 'https://www.elastic.co') {
const resp = await fetch(parsedURL);
const data = await resp.text();
setValue(`${initialTextValue}\n\n${data}`);
setValue(`${localStorageValue}\n\n${data}`);
} else {
toasts.addWarning(
i18n.translate('console.monaco.loadFromDataUnrecognizedUrlErrorMessage', {
@ -107,11 +107,11 @@ export const useSetInitialValue = (params: SetInitialValueParams) => {
if (loadFromParam) {
loadBufferFromRemote(loadFromParam);
} else {
setValue(initialTextValue || DEFAULT_INPUT_VALUE);
setValue(localStorageValue || DEFAULT_INPUT_VALUE);
}
return () => {
window.removeEventListener('hashchange', onHashChange);
};
}, [initialTextValue, setValue, toasts]);
}, [localStorageValue, setValue, toasts]);
};

View file

@ -8,7 +8,7 @@
*/
import { useEffect } from 'react';
import { AutocompleteInfo, Settings } from '../../../../../services';
import { AutocompleteInfo, Settings } from '../../../../services';
interface SetupAutocompletePollingParams {
/** The Console autocomplete service. */

View file

@ -8,7 +8,7 @@
*/
import { useEffect, useRef } from 'react';
import { useSaveCurrentTextObject } from '../../../../hooks';
import { useSaveCurrentTextObject } from '../../../hooks';
import { readLoadFromParam } from './use_set_initial_value';
interface SetupAutosaveParams {

View file

@ -7,5 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { autoIndent, getDocumentation } from './legacy';
export { Editor } from './editor';

View file

@ -1,27 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { DevToolsSettings } from '../../../../../services';
import { CoreEditor } from '../../../../../types';
import { CustomAceEditor } from '../../../../models/legacy_core_editor';
export function applyCurrentSettings(
editor: CoreEditor | CustomAceEditor,
settings: DevToolsSettings
) {
if ((editor as { setStyles?: Function }).setStyles) {
(editor as CoreEditor).setStyles({
wrapLines: settings.wrapMode,
fontSize: settings.fontSize + 'px',
});
} else {
(editor as CustomAceEditor).getSession().setUseWrapMode(settings.wrapMode);
(editor as CustomAceEditor).container.style.fontSize = settings.fontSize + 'px';
}
}

View file

@ -1,50 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
// TODO(jbudz): should be removed when upgrading to TS@4.8
// this is a skip for the errors created when typechecking with isolatedModules
export {};
jest.mock('../../../../contexts/editor_context/editor_registry', () => ({
instance: {
setInputEditor: () => {},
getInputEditor: () => ({
getRequestsInRange: async () => [{ test: 'test' }],
getCoreEditor: () => ({ getCurrentPosition: jest.fn() }),
}),
},
}));
jest.mock('../../../../components/editor_example', () => {});
jest.mock('../../../../models/sense_editor', () => {
return {
create: () => ({
getCoreEditor: () => ({
registerKeyboardShortcut: jest.fn(),
setStyles: jest.fn(),
getContainer: () => ({
focus: () => {},
}),
on: jest.fn(),
addFoldsAtRanges: jest.fn(),
getAllFoldRanges: jest.fn(),
}),
update: jest.fn(),
commands: {
addCommand: () => {},
},
}),
};
});
jest.mock('../../../../hooks/use_send_current_request/send_request', () => ({
sendRequest: jest.fn(),
}));
jest.mock('../../../../../lib/autocomplete/get_endpoint_from_position', () => ({
getEndpointFromPosition: jest.fn(),
}));

View file

@ -1,100 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
jest.mock('../../../../../lib/utils', () => ({ replaceVariables: jest.fn() }));
import './editor.test.mock';
import React from 'react';
import { mount } from 'enzyme';
import { I18nProvider } from '@kbn/i18n-react';
import { act } from 'react-dom/test-utils';
import * as sinon from 'sinon';
import { serviceContextMock } from '../../../../contexts/services_context.mock';
import { nextTick } from '@kbn/test-jest-helpers';
import {
ServicesContextProvider,
EditorContextProvider,
RequestContextProvider,
ContextValue,
} from '../../../../contexts';
// Mocked functions
import { sendRequest } from '../../../../hooks/use_send_current_request/send_request';
import { getEndpointFromPosition } from '../../../../../lib/autocomplete/get_endpoint_from_position';
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;
const sandbox = sinon.createSandbox();
const doMount = () =>
mount(
<I18nProvider>
<ServicesContextProvider value={mockedAppContextValue}>
<RequestContextProvider>
<EditorContextProvider settings={{} as unknown as DevToolsSettings}>
<Editor initialTextValue="" setEditorInstance={() => {}} />
</EditorContextProvider>
</RequestContextProvider>
</ServicesContextProvider>
</I18nProvider>
);
beforeEach(() => {
document.queryCommandSupported = sinon.fake(() => true);
mockedAppContextValue = serviceContextMock.create();
(utils.replaceVariables as jest.Mock).mockReturnValue(['test']);
});
afterEach(() => {
jest.clearAllMocks();
sandbox.restore();
});
it('calls send current request', async () => {
(getEndpointFromPosition as jest.Mock).mockReturnValue({ patterns: [] });
(sendRequest as jest.Mock).mockRejectedValue({});
const editor = doMount();
act(() => {
editor.find('button[data-test-subj~="sendRequestButton"]').simulate('click');
});
await nextTick();
expect(sendRequest).toBeCalledTimes(1);
});
it('opens docs', () => {
const stub = sandbox.stub(consoleMenuActions, 'getDocumentation');
const editor = doMount();
const consoleMenuToggle = editor.find('[data-test-subj~="toggleConsoleMenu"]').last();
consoleMenuToggle.simulate('click');
const docsButton = editor.find('[data-test-subj~="consoleMenuOpenDocs"]').last();
docsButton.simulate('click');
expect(stub.callCount).toBe(1);
});
it('prompts auto-indent', () => {
const stub = sandbox.stub(consoleMenuActions, 'autoIndent');
const editor = doMount();
const consoleMenuToggle = editor.find('[data-test-subj~="toggleConsoleMenu"]').last();
consoleMenuToggle.simulate('click');
const autoIndentButton = editor.find('[data-test-subj~="consoleMenuAutoIndent"]').last();
autoIndentButton.simulate('click');
expect(stub.callCount).toBe(1);
});
});

View file

@ -1,343 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiScreenReaderOnly,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { debounce } from 'lodash';
import { decompressFromEncodedURIComponent } from 'lz-string';
import { parse } from 'query-string';
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
import { ace } from '@kbn/es-ui-shared-plugin/public';
import { ConsoleMenu } from '../../../../components';
import { useEditorReadContext, useServicesContext } from '../../../../contexts';
import {
useSaveCurrentTextObject,
useSendCurrentRequest,
useSetInputEditor,
} from '../../../../hooks';
import * as senseEditor from '../../../../models/sense_editor';
import { autoIndent, getDocumentation } from '../console_menu_actions';
import { subscribeResizeChecker } from '../subscribe_console_resize_checker';
import { applyCurrentSettings } from './apply_editor_settings';
import { registerCommands } from './keyboard_shortcuts';
import type { SenseEditor } from '../../../../models/sense_editor';
import { StorageKeys } from '../../../../../services';
import { DEFAULT_INPUT_VALUE } from '../../../../../../common/constants';
const { useUIAceKeyboardMode } = ace;
export interface EditorProps {
initialTextValue: string;
setEditorInstance: (instance: SenseEditor) => void;
}
interface QueryParams {
load_from: string;
}
const abs: CSSProperties = {
position: 'absolute',
top: '0',
left: '0',
bottom: '0',
right: '0',
};
const inputId = 'ConAppInputTextarea';
function EditorUI({ initialTextValue, setEditorInstance }: EditorProps) {
const {
services: {
history,
notifications,
settings: settingsService,
esHostService,
http,
autocompleteInfo,
storage,
},
docLinkVersion,
...startServices
} = useServicesContext();
const { settings } = useEditorReadContext();
const setInputEditor = useSetInputEditor();
const sendCurrentRequest = useSendCurrentRequest();
const saveCurrentTextObject = useSaveCurrentTextObject();
const editorRef = useRef<HTMLDivElement | null>(null);
const editorInstanceRef = useRef<senseEditor.SenseEditor | null>(null);
const [textArea, setTextArea] = useState<HTMLTextAreaElement | null>(null);
useUIAceKeyboardMode(textArea, startServices, settings.isAccessibilityOverlayEnabled);
const openDocumentation = useCallback(async () => {
const documentation = await getDocumentation(editorInstanceRef.current!, docLinkVersion);
if (!documentation) {
return;
}
window.open(documentation, '_blank');
}, [docLinkVersion]);
useEffect(() => {
editorInstanceRef.current = senseEditor.create(editorRef.current!);
const editor = editorInstanceRef.current;
const textareaElement = editorRef.current!.querySelector('textarea');
if (textareaElement) {
textareaElement.setAttribute('id', inputId);
textareaElement.setAttribute('data-test-subj', 'console-textarea');
}
const readQueryParams = () => {
const [, queryString] = (window.location.hash || window.location.search || '').split('?');
return parse(queryString || '', { sort: false }) as Required<QueryParams>;
};
const loadBufferFromRemote = (url: string) => {
const coreEditor = editor.getCoreEditor();
// Normalize and encode the URL to avoid issues with spaces and other special characters.
const encodedUrl = new URL(url).toString();
if (/^https?:\/\//.test(encodedUrl)) {
const loadFrom: Record<string, any> = {
url,
// Having dataType here is required as it doesn't allow jQuery to `eval` content
// coming from the external source thereby preventing XSS attack.
dataType: 'text',
kbnXsrfToken: false,
};
if (/https?:\/\/api\.github\.com/.test(url)) {
loadFrom.headers = { Accept: 'application/vnd.github.v3.raw' };
}
// Fire and forget.
$.ajax(loadFrom).done(async (data) => {
// when we load data from another Api we also must pass history
await editor.update(`${initialTextValue}\n ${data}`, true);
editor.moveToNextRequestEdge(false);
coreEditor.clearSelection();
editor.highlightCurrentRequestsAndUpdateActionBar();
coreEditor.getContainer().focus();
});
}
// If we have a data URI instead of HTTP, LZ-decode it. This enables
// opening requests in Console from anywhere in Kibana.
if (/^data:/.test(url)) {
const data = decompressFromEncodedURIComponent(url.replace(/^data:text\/plain,/, ''));
// Show a toast if we have a failure
if (data === null || data === '') {
notifications.toasts.addWarning(
i18n.translate('console.loadFromDataUriErrorMessage', {
defaultMessage: 'Unable to load data from the load_from query parameter in the URL',
})
);
return;
}
editor.update(data, true);
editor.moveToNextRequestEdge(false);
coreEditor.clearSelection();
editor.highlightCurrentRequestsAndUpdateActionBar();
coreEditor.getContainer().focus();
}
};
// Support for loading a console snippet from a remote source, like support docs.
const onHashChange = debounce(() => {
const { load_from: url } = readQueryParams();
if (!url) {
return;
}
loadBufferFromRemote(url);
}, 200);
window.addEventListener('hashchange', onHashChange);
const initialQueryParams = readQueryParams();
if (initialQueryParams.load_from) {
loadBufferFromRemote(initialQueryParams.load_from);
} else {
editor.update(initialTextValue || DEFAULT_INPUT_VALUE);
}
function setupAutosave() {
let timer: number;
const saveDelay = 500;
editor.getCoreEditor().on('change', () => {
if (timer) {
clearTimeout(timer);
}
timer = window.setTimeout(saveCurrentState, saveDelay);
});
}
function saveCurrentState() {
try {
const content = editor.getCoreEditor().getValue();
saveCurrentTextObject(content);
} catch (e) {
// Ignoring saving error
}
}
function restoreFolds() {
if (editor) {
const foldRanges = storage.get(StorageKeys.FOLDS, []);
editor.getCoreEditor().addFoldsAtRanges(foldRanges);
}
}
restoreFolds();
function saveFoldsOnChange() {
if (editor) {
editor.getCoreEditor().on('changeFold', () => {
const foldRanges = editor.getCoreEditor().getAllFoldRanges();
storage.set(StorageKeys.FOLDS, foldRanges);
});
}
}
saveFoldsOnChange();
setInputEditor(editor);
setTextArea(editorRef.current!.querySelector('textarea'));
autocompleteInfo.retrieve(settingsService, settingsService.getAutocomplete());
const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor);
if (!initialQueryParams.load_from) {
// Don't setup autosaving editor content when we pre-load content
// This prevents losing the user's current console content when
// `loadFrom` query param is used for a console session
setupAutosave();
}
return () => {
unsubscribeResizer();
autocompleteInfo.clearSubscriptions();
window.removeEventListener('hashchange', onHashChange);
if (editorInstanceRef.current) {
// Close autocomplete popup on unmount
editorInstanceRef.current?.getCoreEditor().detachCompleter();
editorInstanceRef.current.getCoreEditor().destroy();
}
};
}, [
notifications.toasts,
saveCurrentTextObject,
initialTextValue,
history,
setInputEditor,
settingsService,
http,
autocompleteInfo,
storage,
]);
useEffect(() => {
const { current: editor } = editorInstanceRef;
applyCurrentSettings(editor!.getCoreEditor(), settings);
// Preserve legacy focus behavior after settings have updated.
editor!.getCoreEditor().getContainer().focus();
}, [settings]);
useEffect(() => {
const { isKeyboardShortcutsEnabled } = settings;
if (isKeyboardShortcutsEnabled) {
registerCommands({
senseEditor: editorInstanceRef.current!,
sendCurrentRequest,
openDocumentation,
});
}
}, [openDocumentation, settings, sendCurrentRequest]);
useEffect(() => {
const { current: editor } = editorInstanceRef;
if (editor) {
setEditorInstance(editor);
}
}, [setEditorInstance]);
return (
<div style={abs} data-test-subj="console-application" className="conApp">
<div className="conApp__editor">
<ul className="conApp__autoComplete" id="autocomplete" />
<EuiFlexGroup
className="conApp__editorActions"
id="ConAppEditorActions"
gutterSize="none"
responsive={false}
>
<EuiFlexItem>
<EuiToolTip
content={i18n.translate('console.sendRequestButtonTooltipContent', {
defaultMessage: 'Click to send request',
})}
>
<EuiLink
color="primary"
onClick={sendCurrentRequest}
data-test-subj="sendRequestButton"
aria-label={i18n.translate('console.sendRequestButtonTooltipAriaLabel', {
defaultMessage: 'Click to send request',
})}
>
<EuiIcon type="play" />
</EuiLink>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<ConsoleMenu
getCurl={() => {
return editorInstanceRef.current!.getRequestsAsCURL(esHostService.getHost());
}}
getDocumentation={() => {
return getDocumentation(editorInstanceRef.current!, docLinkVersion);
}}
autoIndent={(event) => {
autoIndent(editorInstanceRef.current!, event);
}}
notifications={notifications}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiScreenReaderOnly>
<label htmlFor={inputId}>
{i18n.translate('console.inputTextarea', {
defaultMessage: 'Dev Tools Console',
})}
</label>
</EuiScreenReaderOnly>
<div
ref={editorRef}
id="ConAppEditor"
className="conApp__editorContent"
data-test-subj="request-editor"
/>
</div>
</div>
);
}
export const Editor = React.memo(EditorUI);

View file

@ -1,125 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { VectorTile } from '@mapbox/vector-tile';
import Protobuf from 'pbf';
import { EuiScreenReaderOnly } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useRef } from 'react';
import { convertMapboxVectorTileToJson } from './mapbox_vector_tile';
import { Mode } from '../../../../models/legacy_core_editor/mode/output';
// Ensure the modes we might switch to dynamically are available
import 'brace/mode/text';
import 'brace/mode/hjson';
import 'brace/mode/yaml';
import {
useEditorReadContext,
useRequestReadContext,
useServicesContext,
} from '../../../../contexts';
import { createReadOnlyAceEditor, CustomAceEditor } from '../../../../models/legacy_core_editor';
import { subscribeResizeChecker } from '../subscribe_console_resize_checker';
import { applyCurrentSettings } from './apply_editor_settings';
import { isJSONContentType, isMapboxVectorTile, safeExpandLiteralStrings } from '../../utilities';
function modeForContentType(contentType?: string) {
if (!contentType) {
return 'ace/mode/text';
}
if (isJSONContentType(contentType) || isMapboxVectorTile(contentType)) {
// Using hjson will allow us to use comments in editor output and solves the problem with error markers
return 'ace/mode/hjson';
} else if (contentType.indexOf('application/yaml') >= 0) {
return 'ace/mode/yaml';
}
return 'ace/mode/text';
}
function EditorOutputUI() {
const editorRef = useRef<null | HTMLDivElement>(null);
const editorInstanceRef = useRef<null | CustomAceEditor>(null);
const { services } = useServicesContext();
const { settings: readOnlySettings } = useEditorReadContext();
const {
lastResult: { data, error },
} = useRequestReadContext();
const inputId = 'ConAppOutputTextarea';
useEffect(() => {
editorInstanceRef.current = createReadOnlyAceEditor(editorRef.current!);
const unsubscribe = subscribeResizeChecker(editorRef.current!, editorInstanceRef.current);
const textarea = editorRef.current!.querySelector('textarea')!;
textarea.setAttribute('id', inputId);
textarea.setAttribute('readonly', 'true');
return () => {
unsubscribe();
editorInstanceRef.current!.destroy();
};
}, [services.settings]);
useEffect(() => {
const editor = editorInstanceRef.current!;
if (data) {
const isMultipleRequest = data.length > 1;
const mode = isMultipleRequest
? new Mode()
: modeForContentType(data[0].response.contentType);
editor.update(
data
.map((result) => {
const { value, contentType } = result.response;
let editorOutput;
if (readOnlySettings.tripleQuotes && isJSONContentType(contentType)) {
editorOutput = safeExpandLiteralStrings(value as string);
} else if (isMapboxVectorTile(contentType)) {
const vectorTile = new VectorTile(new Protobuf(value as ArrayBuffer));
const vectorTileJson = convertMapboxVectorTileToJson(vectorTile);
editorOutput = safeExpandLiteralStrings(vectorTileJson as string);
} else {
editorOutput = value;
}
return editorOutput;
})
.join('\n'),
mode
);
} else if (error) {
const mode = modeForContentType(error.response.contentType);
editor.update(error.response.value as string, mode);
} else {
editor.update('');
}
}, [readOnlySettings, data, error]);
useEffect(() => {
applyCurrentSettings(editorInstanceRef.current!, readOnlySettings);
}, [readOnlySettings]);
return (
<>
<EuiScreenReaderOnly>
<label htmlFor={inputId}>
{i18n.translate('console.outputTextarea', {
defaultMessage: 'Dev Tools Console output',
})}
</label>
</EuiScreenReaderOnly>
<div ref={editorRef} className="conApp__output" data-test-subj="response-editor">
<div className="conApp__outputContent" id="ConAppOutput" />
</div>
</>
);
}
export const EditorOutput = React.memo(EditorOutputUI);

View file

@ -1,92 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { throttle } from 'lodash';
import { SenseEditor } from '../../../../models/sense_editor';
interface Actions {
senseEditor: SenseEditor;
sendCurrentRequest: () => void;
openDocumentation: () => void;
}
const COMMANDS = {
SEND_TO_ELASTICSEARCH: 'send to Elasticsearch',
OPEN_DOCUMENTATION: 'open documentation',
AUTO_INDENT_REQUEST: 'auto indent request',
MOVE_TO_PREVIOUS_REQUEST: 'move to previous request start or end',
MOVE_TO_NEXT_REQUEST: 'move to next request start or end',
GO_TO_LINE: 'gotoline',
};
export function registerCommands({ senseEditor, sendCurrentRequest, openDocumentation }: Actions) {
const throttledAutoIndent = throttle(() => senseEditor.autoIndent(), 500, {
leading: true,
trailing: true,
});
const coreEditor = senseEditor.getCoreEditor();
coreEditor.registerKeyboardShortcut({
keys: { win: 'Ctrl-Enter', mac: 'Command-Enter' },
name: COMMANDS.SEND_TO_ELASTICSEARCH,
fn: () => {
sendCurrentRequest();
},
});
coreEditor.registerKeyboardShortcut({
name: COMMANDS.OPEN_DOCUMENTATION,
keys: { win: 'Ctrl-/', mac: 'Command-/' },
fn: () => {
openDocumentation();
},
});
coreEditor.registerKeyboardShortcut({
name: COMMANDS.AUTO_INDENT_REQUEST,
keys: { win: 'Ctrl-I', mac: 'Command-I' },
fn: () => {
throttledAutoIndent();
},
});
coreEditor.registerKeyboardShortcut({
name: COMMANDS.MOVE_TO_PREVIOUS_REQUEST,
keys: { win: 'Ctrl-Up', mac: 'Command-Up' },
fn: () => {
senseEditor.moveToPreviousRequestEdge();
},
});
coreEditor.registerKeyboardShortcut({
name: COMMANDS.MOVE_TO_NEXT_REQUEST,
keys: { win: 'Ctrl-Down', mac: 'Command-Down' },
fn: () => {
senseEditor.moveToNextRequestEdge(false);
},
});
coreEditor.registerKeyboardShortcut({
name: COMMANDS.GO_TO_LINE,
keys: { win: 'Ctrl-L', mac: 'Command-L' },
fn: (editor) => {
const line = parseInt(prompt('Enter line number') ?? '', 10);
if (!isNaN(line)) {
editor.gotoLine(line);
}
},
});
}
export function unregisterCommands(senseEditor: SenseEditor) {
const coreEditor = senseEditor.getCoreEditor();
Object.values(COMMANDS).forEach((command) => {
coreEditor.unregisterKeyboardShortcut(command);
});
}

View file

@ -1,39 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getEndpointFromPosition } from '../../../../lib/autocomplete/get_endpoint_from_position';
import { SenseEditor } from '../../../models/sense_editor';
export async function autoIndent(editor: SenseEditor, event: React.MouseEvent) {
event.preventDefault();
await editor.autoIndent();
editor.getCoreEditor().getContainer().focus();
}
export function getDocumentation(
editor: SenseEditor,
docLinkVersion: string
): Promise<string | null> {
return editor.getRequestsInRange().then((requests) => {
if (!requests || requests.length === 0) {
return null;
}
const position = requests[0].range.end;
position.column = position.column - 1;
const endpoint = getEndpointFromPosition(editor.getCoreEditor(), position, editor.parser);
if (endpoint && endpoint.documentation && endpoint.documentation.indexOf('http') !== -1) {
return endpoint.documentation
.replace('/master/', `/${docLinkVersion}/`)
.replace('/current/', `/${docLinkVersion}/`)
.replace('/{branch}/', `/${docLinkVersion}/`);
} else {
return null;
}
});
}

View file

@ -1,11 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { EditorOutput, Editor } from './console_editor';
export { getDocumentation, autoIndent } from './console_menu_actions';

View file

@ -1,28 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ResizeChecker } from '@kbn/kibana-utils-plugin/public';
export function subscribeResizeChecker(el: HTMLElement, ...editors: any[]) {
const checker = new ResizeChecker(el);
checker.on('resize', () =>
editors.forEach((e) => {
if (e.getCoreEditor) {
e.getCoreEditor().resize();
} else {
e.resize();
}
if (e.updateActionsBar) {
e.updateActionsBar();
}
})
);
return () => checker.destroy();
}

View file

@ -1,11 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { MonacoEditor } from './monaco_editor';
export { MonacoEditorOutput } from './monaco_editor_output';

View file

@ -8,18 +8,19 @@
*/
import React, { CSSProperties, useCallback, useMemo, useRef, useState, useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiToolTip } from '@elastic/eui';
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 { useSetInputEditor } from '../../hooks';
import { ContextMenu } from './components';
import {
useServicesContext,
useEditorReadContext,
useRequestActionContext,
} from '../../../contexts';
useEditorActionContext,
} from '../../contexts';
import {
useSetInitialValue,
useSetupAutocompletePolling,
@ -32,10 +33,12 @@ import { MonacoEditorActionsProvider } from './monaco_editor_actions_provider';
import { getSuggestionProvider } from './monaco_editor_suggestion_provider';
export interface EditorProps {
initialTextValue: string;
localStorageValue: string | undefined;
value: string;
setValue: (value: string) => void;
}
export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
export const MonacoEditor = ({ localStorageValue, value, setValue }: EditorProps) => {
const context = useServicesContext();
const {
services: { notifications, settings: settingsService, autocompleteInfo },
@ -43,7 +46,11 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
config: { isDevMode },
} = context;
const { toasts } = notifications;
const { settings } = useEditorReadContext();
const {
settings,
restoreRequestFromHistory: requestToRestoreFromHistory,
fileToImport,
} = useEditorReadContext();
const [editorInstance, setEditorInstace] = useState<
monaco.editor.IStandaloneCodeEditor | undefined
>();
@ -53,6 +60,7 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
const { registerKeyboardCommands, unregisterKeyboardCommands } = useKeyboardCommandsUtils();
const dispatch = useRequestActionContext();
const editorDispatch = useEditorActionContext();
const actionsProvider = useRef<MonacoEditorActionsProvider | null>(null);
const [editorActionsCss, setEditorActionsCss] = useState<CSSProperties>({});
@ -117,18 +125,40 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
const suggestionProvider = useMemo(() => {
return getSuggestionProvider(actionsProvider);
}, []);
const [value, setValue] = useState(initialTextValue);
useSetInitialValue({ initialTextValue, setValue, toasts });
useSetInitialValue({ localStorageValue, setValue, toasts });
useSetupAutocompletePolling({ autocompleteInfo, settingsService });
useSetupAutosave({ value });
// Restore the request from history if there is one
const updateEditor = useCallback(async () => {
if (requestToRestoreFromHistory) {
editorDispatch({ type: 'clearRequestToRestore' });
await actionsProvider.current?.appendRequestToEditor(
requestToRestoreFromHistory,
dispatch,
context
);
}
// Import a request file if one is provided
if (fileToImport) {
editorDispatch({ type: 'setFileToImport', payload: null });
await actionsProvider.current?.importRequestsToEditor(fileToImport);
}
}, [fileToImport, requestToRestoreFromHistory, dispatch, context, editorDispatch]);
useEffect(() => {
updateEditor();
}, [updateEditor]);
return (
<div
css={css`
width: 100%;
height: 100%;
`}
ref={divRef}
data-test-subj="consoleMonacoEditorContainer"
@ -136,29 +166,30 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
<EuiFlexGroup
className="conApp__editorActions"
id="ConAppEditorActions"
gutterSize="none"
gutterSize="xs"
responsive={false}
style={editorActionsCss}
justifyContent="center"
alignItems="center"
>
<EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate('console.monaco.sendRequestButtonTooltipContent', {
defaultMessage: 'Click to send request',
})}
>
<EuiLink
color="primary"
<EuiButtonIcon
iconType="playFilled"
onClick={sendRequestsCallback}
data-test-subj="sendRequestButton"
aria-label={i18n.translate('console.monaco.sendRequestButtonTooltipAriaLabel', {
defaultMessage: 'Click to send request',
})}
>
<EuiIcon type="play" />
</EuiLink>
iconSize={'s'}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem grow={false}>
<ContextMenu
getRequests={getRequestsCallback}
getDocumentation={getDocumenationLink}

View file

@ -29,7 +29,7 @@ jest.mock('@kbn/monaco', () => {
};
});
jest.mock('../../../../services', () => {
jest.mock('../../../services', () => {
return {
getStorage: () => ({
get: () => [],
@ -40,7 +40,7 @@ jest.mock('../../../../services', () => {
};
});
jest.mock('../../../../lib/autocomplete/engine', () => {
jest.mock('../../../lib/autocomplete/engine', () => {
return {
populateContext: (...args: any) => {
mockPopulateContext(args);

View file

@ -13,11 +13,11 @@ import { ConsoleParsedRequestsProvider, getParsedRequestsProvider, monaco } from
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { XJson } from '@kbn/es-ui-shared-plugin/public';
import { isQuotaExceededError } from '../../../../services/history';
import { DEFAULT_VARIABLES } from '../../../../../common/constants';
import { getStorage, StorageKeys } from '../../../../services';
import { sendRequest } from '../../../hooks';
import { Actions } from '../../../stores/request';
import { isQuotaExceededError } from '../../../services/history';
import { DEFAULT_VARIABLES } from '../../../../common/constants';
import { getStorage, StorageKeys } from '../../../services';
import { sendRequest } from '../../hooks';
import { Actions } from '../../stores/request';
import {
AutocompleteType,
@ -40,8 +40,9 @@ import {
} from './utils';
import type { AdjustedParsedRequest } from './types';
import { StorageQuotaError } from '../../../components/storage_quota_error';
import { ContextValue } from '../../../contexts';
import { type RequestToRestore, RestoreMethod } from '../../../types';
import { StorageQuotaError } from '../../components/storage_quota_error';
import { ContextValue } from '../../contexts';
import { containsComments, indentData } from './utils/requests_utils';
const AUTO_INDENTATION_ACTION_LABEL = 'Apply indentations';
@ -120,7 +121,8 @@ export class MonacoEditorActionsProvider {
const offset = this.editor.getTopForLineNumber(lineNumber) - this.editor.getScrollTop();
this.setEditorActionsCss({
visibility: 'visible',
top: offset,
// Move position down by 1 px so that the action buttons panel doesn't cover the top border of the selected block
top: offset + 1,
});
}
}
@ -147,7 +149,7 @@ export class MonacoEditorActionsProvider {
range: selectedRange,
options: {
isWholeLine: true,
className: SELECTED_REQUESTS_CLASSNAME,
blockClassName: SELECTED_REQUESTS_CLASSNAME,
},
},
]);
@ -160,6 +162,11 @@ export class MonacoEditorActionsProvider {
private async getSelectedParsedRequests(): Promise<AdjustedParsedRequest[]> {
const model = this.editor.getModel();
if (!model) {
return [];
}
const selection = this.editor.getSelection();
if (!model || !selection) {
return Promise.resolve([]);
@ -173,6 +180,9 @@ export class MonacoEditorActionsProvider {
startLineNumber: number,
endLineNumber: number
): Promise<AdjustedParsedRequest[]> {
if (!model) {
return [];
}
const parsedRequests = await this.parsedRequestsProvider.getRequests();
const selectedRequests: AdjustedParsedRequest[] = [];
for (const [index, parsedRequest] of parsedRequests.entries()) {
@ -243,9 +253,17 @@ export class MonacoEditorActionsProvider {
const { toasts } = notifications;
try {
const allRequests = await this.getRequests();
// if any request doesnt have a method then we gonna treat it as a non-valid
// request
const requests = allRequests.filter((request) => request.method);
const selectedRequests = await this.getSelectedParsedRequests();
const requests = allRequests
// if any request doesnt have a method then we gonna treat it as a non-valid
// request
.filter((request) => request.method)
// map the requests to the original line number
.map((request, index) => ({
...request,
lineNumber: selectedRequests[index].startLineNumber,
}));
// If we do have requests but none have methods we are not sending the request
if (allRequests.length > 0 && !requests.length) {
@ -479,9 +497,6 @@ 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) {
@ -679,4 +694,82 @@ export class MonacoEditorActionsProvider {
this.editor.trigger(TRIGGER_SUGGESTIONS_ACTION_LABEL, TRIGGER_SUGGESTIONS_HANDLER_ID, {});
}
}
/*
* This function cleares out the editor content and replaces it with the provided requests
*/
public async importRequestsToEditor(requestsToImport: string) {
const model = this.editor.getModel();
if (!model) {
return;
}
const edit: monaco.editor.IIdentifiedSingleEditOperation = {
range: model.getFullModelRange(),
text: requestsToImport,
forceMoveMarkers: true,
};
this.editor.executeEdits('restoreFromHistory', [edit]);
}
/*
* This function inserts a request after the last request in the editor
*/
public async appendRequestToEditor(
req: RequestToRestore,
dispatch: Dispatch<Actions>,
context: ContextValue
) {
const model = this.editor.getModel();
if (!model) {
return;
}
// 1 - Create an edit operation to insert the request after the last request
const lastLineNumber = model.getLineCount();
const column = model.getLineMaxColumn(lastLineNumber);
const edit: monaco.editor.IIdentifiedSingleEditOperation = {
range: {
startLineNumber: lastLineNumber,
startColumn: column,
endLineNumber: lastLineNumber,
endColumn: column,
},
text: `\n\n${req.request}`,
forceMoveMarkers: true,
};
this.editor.executeEdits('restoreFromHistory', [edit]);
// 2 - Since we add two new lines, the cursor should be at the beginning of the new request
const beginningOfNewReq = lastLineNumber + 2;
const selectedRequests = await this.getRequestsBetweenLines(
model,
beginningOfNewReq,
beginningOfNewReq
);
// We can assume that there is only one request given that we only add one
// request at a time.
const restoredRequest = selectedRequests[0];
// 3 - Set the cursor to the beginning of the new request,
this.editor.setSelection({
startLineNumber: restoredRequest.startLineNumber,
startColumn: 1,
endLineNumber: restoredRequest.startLineNumber,
endColumn: 1,
});
// 4 - Scroll to the beginning of the new request
this.editor.setScrollPosition({
scrollTop: this.editor.getTopForLineNumber(restoredRequest.startLineNumber),
});
// 5 - Optionally send the request
if (req.restoreMethod === RestoreMethod.RESTORE_AND_EXECUTE) {
this.sendRequests(dispatch, context);
}
}
}

View file

@ -7,26 +7,44 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';
import React, {
CSSProperties,
FunctionComponent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { CodeEditor } from '@kbn/code-editor';
import { css } from '@emotion/react';
import { VectorTile } from '@mapbox/vector-tile';
import Protobuf from 'pbf';
import { i18n } from '@kbn/i18n';
import { EuiScreenReaderOnly } from '@elastic/eui';
import { CONSOLE_THEME_ID, CONSOLE_OUTPUT_LANG_ID, monaco } from '@kbn/monaco';
import { getStatusCodeDecorations } from './utils';
import { useEditorReadContext, useRequestReadContext } from '../../../contexts';
import { convertMapboxVectorTileToJson } from '../legacy/console_editor/mapbox_vector_tile';
import {
EuiScreenReaderOnly,
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiToolTip,
} from '@elastic/eui';
import { CONSOLE_THEME_ID, CONSOLE_OUTPUT_LANG_ID, monaco } from '@kbn/monaco';
import {
getStatusCodeDecorations,
isJSONContentType,
isMapboxVectorTile,
safeExpandLiteralStrings,
languageForContentType,
} from '../utilities';
convertMapboxVectorTileToJson,
} from './utils';
import { useEditorReadContext, useRequestReadContext, useServicesContext } from '../../contexts';
import { MonacoEditorOutputActionsProvider } from './monaco_editor_output_actions_provider';
import { useResizeCheckerUtils } from './hooks';
export const MonacoEditorOutput: FunctionComponent = () => {
const context = useServicesContext();
const {
services: { notifications },
} = context;
const { settings: readOnlySettings } = useEditorReadContext();
const {
lastResult: { data },
@ -37,8 +55,14 @@ export const MonacoEditorOutput: FunctionComponent = () => {
const { setupResizeChecker, destroyResizeChecker } = useResizeCheckerUtils();
const lineDecorations = useRef<monaco.editor.IEditorDecorationsCollection | null>(null);
const actionsProvider = useRef<MonacoEditorOutputActionsProvider | null>(null);
const [editorActionsCss, setEditorActionsCss] = useState<CSSProperties>({});
const editorDidMountCallback = useCallback(
(editor: monaco.editor.IStandaloneCodeEditor) => {
const provider = new MonacoEditorOutputActionsProvider(editor, setEditorActionsCss);
actionsProvider.current = provider;
setupResizeChecker(divRef.current!, editor);
lineDecorations.current = editor.createDecorationsCollection();
},
@ -83,19 +107,71 @@ export const MonacoEditorOutput: FunctionComponent = () => {
// If there are multiple responses, add decorations for their status codes
const decorations = getStatusCodeDecorations(data);
lineDecorations.current?.set(decorations);
// Highlight first line of the output editor
actionsProvider.current?.selectFirstLine();
}
} else {
setValue('');
}
}, [readOnlySettings, data, value]);
const copyOutputCallback = useCallback(async () => {
const selectedText = (await actionsProvider.current?.getParsedOutput()) as string;
try {
if (!window.navigator?.clipboard) {
throw new Error('Could not copy to clipboard!');
}
await window.navigator.clipboard.writeText(selectedText);
notifications.toasts.addSuccess({
title: i18n.translate('console.outputPanel.copyOutputToast', {
defaultMessage: 'Selected output copied to clipboard',
}),
});
} catch (e) {
notifications.toasts.addDanger({
title: i18n.translate('console.outputPanel.copyOutputToastFailedMessage', {
defaultMessage: 'Could not copy selected output to clipboard',
}),
});
}
}, [notifications.toasts]);
return (
<div
css={css`
width: 100%;
height: 100%;
`}
ref={divRef}
>
<EuiFlexGroup
className="conApp__outputActions"
responsive={false}
style={editorActionsCss}
justifyContent="center"
alignItems="center"
>
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate('console.outputPanel.copyOutputButtonTooltipContent', {
defaultMessage: 'Click to copy to clipboard',
})}
>
<EuiButtonIcon
iconType="copyClipboard"
onClick={copyOutputCallback}
data-test-subj="copyOutputButton"
aria-label={i18n.translate('console.outputPanel.copyOutputButtonTooltipAriaLabel', {
defaultMessage: 'Click to copy to clipboard',
})}
iconSize={'s'}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
<EuiScreenReaderOnly>
<label htmlFor={'ConAppOutputTextarea'}>
{i18n.translate('console.monaco.outputTextarea', {

View file

@ -0,0 +1,185 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { CSSProperties } from 'react';
import { debounce } from 'lodash';
import { monaco } from '@kbn/monaco';
import { createOutputParser } from '@kbn/monaco/src/console/output_parser';
import {
getRequestEndLineNumber,
getRequestStartLineNumber,
SELECTED_REQUESTS_CLASSNAME,
} from './utils';
import type { AdjustedParsedRequest } from './types';
const DEBOUNCE_HIGHLIGHT_WAIT_MS = 200;
const OFFSET_EDITOR_ACTIONS = 1;
export class MonacoEditorOutputActionsProvider {
private highlightedLines: monaco.editor.IEditorDecorationsCollection;
constructor(
private editor: monaco.editor.IStandaloneCodeEditor,
private setEditorActionsCss: (css: CSSProperties) => void
) {
this.highlightedLines = this.editor.createDecorationsCollection();
this.editor.focus();
const debouncedHighlightRequests = debounce(
() => this.highlightRequests(),
DEBOUNCE_HIGHLIGHT_WAIT_MS,
{
leading: true,
}
);
debouncedHighlightRequests();
// init all listeners
editor.onDidChangeCursorPosition(async (event) => {
await debouncedHighlightRequests();
});
editor.onDidScrollChange(async (event) => {
await debouncedHighlightRequests();
});
editor.onDidChangeCursorSelection(async (event) => {
await debouncedHighlightRequests();
});
editor.onDidContentSizeChange(async (event) => {
await debouncedHighlightRequests();
});
}
private updateEditorActions(lineNumber?: number) {
// if no request is currently selected, hide the actions buttons
if (!lineNumber) {
this.setEditorActionsCss({
visibility: 'hidden',
});
} else {
// if a request is selected, the actions buttons are placed at lineNumberOffset - scrollOffset
const offset = this.editor.getTopForLineNumber(lineNumber) - this.editor.getScrollTop();
this.setEditorActionsCss({
visibility: 'visible',
// Add a little bit of padding to the top of the actions buttons so that
// it doesnt overlap with the selected request delimiter
top: offset + OFFSET_EDITOR_ACTIONS,
});
}
}
private async highlightRequests(): Promise<void> {
// get the requests in the selected range
const parsedRequests = await this.getSelectedParsedOutput();
// if any requests are selected, highlight the lines and update the position of actions buttons
if (parsedRequests.length > 0) {
// display the actions buttons on the 1st line of the 1st selected request
const selectionStartLineNumber = parsedRequests[0].startLineNumber;
this.updateEditorActions(selectionStartLineNumber); // highlight the lines from the 1st line of the first selected request
// to the last line of the last selected request
const selectionEndLineNumber = parsedRequests[parsedRequests.length - 1].endLineNumber;
const selectedRange = new monaco.Range(
selectionStartLineNumber,
1,
selectionEndLineNumber,
this.editor.getModel()?.getLineMaxColumn(selectionEndLineNumber) ?? 1
);
this.highlightedLines.set([
{
range: selectedRange,
options: {
isWholeLine: true,
blockClassName: SELECTED_REQUESTS_CLASSNAME,
},
},
]);
} else {
// if no requests are selected, hide actions buttons and remove highlighted lines
this.updateEditorActions();
this.highlightedLines.clear();
}
}
private async getSelectedParsedOutput(): Promise<AdjustedParsedRequest[]> {
const model = this.editor.getModel();
const selection = this.editor.getSelection();
if (!model || !selection) {
return Promise.resolve([]);
}
const { startLineNumber, endLineNumber } = selection;
return this.getRequestsBetweenLines(model, startLineNumber, endLineNumber);
}
private async getRequestsBetweenLines(
model: monaco.editor.ITextModel,
startLineNumber: number,
endLineNumber: number
): Promise<AdjustedParsedRequest[]> {
const parser = createOutputParser();
const parsedRequests = await parser(model.getValue(), undefined).responses;
const selectedRequests: AdjustedParsedRequest[] = [];
for (const [index, parsedRequest] of parsedRequests.entries()) {
const requestStartLineNumber = getRequestStartLineNumber(parsedRequest, model);
const requestEndLineNumber = getRequestEndLineNumber({
parsedRequest,
nextRequest: parsedRequests.at(index + 1),
model,
startLineNumber,
});
if (requestStartLineNumber > endLineNumber) {
// request is past the selection, no need to check further requests
break;
}
if (requestEndLineNumber < startLineNumber) {
// request is before the selection, do nothing
} else {
// request is selected
selectedRequests.push({
...parsedRequest,
startLineNumber: requestStartLineNumber,
endLineNumber: requestEndLineNumber,
});
}
}
return selectedRequests;
}
// Set the cursor to the first line of the editor
public selectFirstLine() {
this.editor.setSelection(new monaco.Selection(0, 0, 0, 0));
}
public async getParsedOutput(): Promise<string> {
const model = this.editor.getModel();
if (!model) {
return '';
}
let selectedRequestsString = '';
const selectedRequests = await this.getSelectedParsedOutput();
for (const request of selectedRequests) {
const dataString = model
.getValueInRange({
startLineNumber: request.startLineNumber,
startColumn: 1,
endLineNumber: request.endLineNumber,
endColumn: model.getLineMaxColumn(request.endLineNumber),
})
.trim();
selectedRequestsString += dataString + '\n';
}
return selectedRequestsString;
}
}

View file

@ -14,14 +14,14 @@ import { monaco } from '@kbn/monaco';
const mockPopulateContext = jest.fn();
jest.mock('../../../../../lib/autocomplete/engine', () => {
jest.mock('../../../../lib/autocomplete/engine', () => {
return {
populateContext: (...args: any) => {
mockPopulateContext(args);
},
};
});
import { AutoCompleteContext } from '../../../../../lib/autocomplete/types';
import { AutoCompleteContext } from '../../../../lib/autocomplete/types';
import {
getDocumentationLinkFromAutocomplete,
getUrlPathCompletionItems,

View file

@ -14,13 +14,13 @@ import {
getGlobalAutocompleteComponents,
getTopLevelUrlCompleteComponents,
getUnmatchedEndpointComponents,
} from '../../../../../lib/kb';
} from '../../../../lib/kb';
import {
AutoCompleteContext,
type DataAutoCompleteRulesOneOf,
ResultTerm,
} from '../../../../../lib/autocomplete/types';
import { populateContext } from '../../../../../lib/autocomplete/engine';
} from '../../../../lib/autocomplete/types';
import { populateContext } from '../../../../lib/autocomplete/engine';
import type { EditorRequest } from '../types';
import { parseBody, parseLine, parseUrl } from './tokens_utils';
import {

View file

@ -35,3 +35,10 @@ export {
} from './autocomplete_utils';
export { getLineTokens, containsUrlParams } from './tokens_utils';
export { getStatusCodeDecorations } from './status_code_decoration_utils';
export {
isMapboxVectorTile,
languageForContentType,
safeExpandLiteralStrings,
isJSONContentType,
} from './output_data';
export { convertMapboxVectorTileToJson } from './mapbox_vector_tile';

View file

@ -8,7 +8,7 @@
*/
import { monaco, ParsedRequest } from '@kbn/monaco';
import type { MetricsTracker } from '../../../../../types';
import type { MetricsTracker } from '../../../../types';
import {
getAutoIndentedRequests,
getCurlRequest,

View file

@ -9,9 +9,9 @@
import { monaco, ParsedRequest } from '@kbn/monaco';
import { parse } from 'hjson';
import { constructUrl } from '../../../../../lib/es';
import type { MetricsTracker } from '../../../../../types';
import type { DevToolsVariable } from '../../../../components';
import { constructUrl } from '../../../../lib/es';
import type { MetricsTracker } from '../../../../types';
import type { DevToolsVariable } from '../../../components';
import type { EditorRequest, AdjustedParsedRequest } from '../types';
import {
urlVariableTemplateRegex,

View file

@ -13,7 +13,7 @@ import {
WARNING_STATUS_BADGE_CLASSNAME,
DANGER_STATUS_BADGE_CLASSNAME,
} from './constants';
import { RequestResult } from '../../../../hooks/use_send_current_request/send_request';
import { RequestResult } from '../../../hooks/use_send_current_request/send_request';
describe('getStatusCodeDecorations', () => {
it('correctly returns all decorations on full data', () => {

View file

@ -8,7 +8,7 @@
*/
import { monaco } from '@kbn/monaco';
import { RequestResult } from '../../../../hooks/use_send_current_request/send_request';
import { RequestResult } from '../../../hooks/use_send_current_request/send_request';
import {
DEFAULT_STATUS_BADGE_CLASSNAME,
SUCCESS_STATUS_BADGE_CLASSNAME,

View file

@ -118,7 +118,7 @@ interface ConsoleWrapperProps
export const ConsoleWrapper = (props: ConsoleWrapperProps) => {
const [dependencies, setDependencies] = useState<ConsoleDependencies | null>(null);
const { core, usageCollection, onKeyDown, isMonacoEnabled, isDevMode, isOpen } = props;
const { core, usageCollection, onKeyDown, isDevMode, isOpen } = props;
useEffect(() => {
if (dependencies === null && isOpen) {
@ -169,7 +169,6 @@ export const ConsoleWrapper = (props: ConsoleWrapperProps) => {
autocompleteInfo,
},
config: {
isMonacoEnabled,
isDevMode,
},
}}
@ -179,7 +178,7 @@ export const ConsoleWrapper = (props: ConsoleWrapperProps) => {
{isOpen ? (
<div className="embeddableConsole__content" data-test-subj="consoleEmbeddedBody">
<EuiWindowEvent event="keydown" handler={onKeyDown} />
<Main hideWelcome />
<Main isEmbeddable={true} />
</div>
) : (
<span />

View file

@ -70,7 +70,6 @@ export const EmbeddableConsole = ({
usageCollection,
setDispatch,
alternateView,
isMonacoEnabled,
isDevMode,
getConsoleHeight,
setConsoleHeight,
@ -199,7 +198,6 @@ export const EmbeddableConsole = ({
core={core}
usageCollection={usageCollection}
onKeyDown={onKeyDown}
isMonacoEnabled={isMonacoEnabled}
isDevMode={isDevMode}
/>
) : null}

View file

@ -0,0 +1,306 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, useEffect, useState } from 'react';
import { FixedSizeList } from 'react-window';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import {
EuiSpacer,
EuiTitle,
EuiText,
EuiPanel,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
useEuiTheme,
EuiAutoSizer,
EuiSplitPanel,
EuiButtonEmpty,
EuiFormFieldset,
EuiCheckableCard,
EuiResizableContainer,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { SHELL_TAB_ID } from '../main';
import { HistoryEmptyPrompt } from './history_empty';
import { useServicesContext } from '../../contexts';
import { useEditorActionContext } from '../../contexts/editor_context';
import { HistoryViewer } from './history_viewer_monaco';
import { useEditorReadContext } from '../../contexts/editor_context';
import { getFormattedRequest } from '../../lib';
import { ESRequest, RestoreMethod } from '../../../types';
const CHILD_ELEMENT_PREFIX = 'historyReq';
interface HistoryProps {
data: string;
endpoint: string;
method: string;
time: string;
}
interface RowProps {
index: number;
style: React.CSSProperties;
data: HistoryProps[];
}
const CheckeableCardLabel = ({ historyItem }: { historyItem: HistoryProps }) => {
const date = moment(historyItem.time);
let formattedDate = date.format('MMM D');
if (date.diff(moment(), 'days') > -7) {
formattedDate = date.fromNow();
}
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiText size="s">
<b>{historyItem.endpoint}</b>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{formattedDate}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
};
interface Props {
isVerticalLayout: boolean;
}
export function History({ isVerticalLayout }: Props) {
const { euiTheme } = useEuiTheme();
const {
services: { history, routeHistory },
} = useServicesContext();
const dispatch = useEditorActionContext();
const { settings: readOnlySettings } = useEditorReadContext();
const [requests, setPastRequests] = useState<HistoryProps[]>(history.getHistory());
const clearHistory = useCallback(() => {
history.clearHistory();
setPastRequests(history.getHistory());
}, [history]);
const [viewingReq, setViewingReq] = useState<any>(null);
const initialize = useCallback(() => {
const nextSelectedIndex = 0;
setViewingReq(requests[nextSelectedIndex]);
}, [requests]);
const clear = () => {
clearHistory();
initialize();
};
const restoreRequestFromHistory = useCallback(
(restoreMethod: RestoreMethod) => {
routeHistory?.push(`/console/${SHELL_TAB_ID}`);
const formattedRequest = getFormattedRequest(viewingReq as ESRequest);
dispatch({
type: 'setRequestToRestore',
payload: {
request: formattedRequest,
restoreMethod,
},
});
},
[viewingReq, dispatch, routeHistory]
);
useEffect(() => {
initialize();
}, [initialize]);
const Row = useCallback(
({ data, index, style }: RowProps) => (
<EuiFormFieldset key={index} data-test-subj="historyItemFieldset" style={style}>
<EuiCheckableCard
id={`${CHILD_ELEMENT_PREFIX}${index}`}
label={<CheckeableCardLabel historyItem={data[index]} />}
data-test-subj={`historyItem-${index}`}
checkableType="radio"
checked={viewingReq === data[index]}
onChange={() => {
setViewingReq(data[index]);
}}
/>
<EuiSpacer size="s" />
</EuiFormFieldset>
),
[viewingReq, setViewingReq]
);
return (
<EuiPanel
color="subdued"
paddingSize="none"
hasShadow={false}
borderRadius="none"
css={{ height: '100%' }}
data-test-subj="consoleHistoryPanel"
>
<EuiResizableContainer
style={{ height: '100%' }}
direction={isVerticalLayout ? 'vertical' : 'horizontal'}
>
{(EuiResizablePanel, EuiResizableButton) => (
<>
<EuiResizablePanel
initialSize={50}
minSize="30%"
tabIndex={0}
borderRadius="none"
hasShadow={false}
paddingSize="none"
>
<EuiSplitPanel.Outer
grow
color="subdued"
css={{
height: '100%',
paddingRight: euiTheme.size.s,
}}
>
<EuiSplitPanel.Inner paddingSize="m">
<EuiFlexGroup direction="column" gutterSize="none" css={{ height: '100%' }}>
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
<EuiTitle>
<h2>
<FormattedMessage
id="console.historyPage.pageTitle"
defaultMessage="History"
/>
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText color="subdued">
<p>
<FormattedMessage
id="console.historyPage.pageDescription"
defaultMessage="Revisit and reuse your past queries"
/>
</p>
</EuiText>
<EuiSpacer size="l" />
</EuiFlexItem>
<EuiFlexItem grow={true} css={{ height: '100%' }}>
{requests.length === 0 && <HistoryEmptyPrompt />}
{requests.length > 0 && (
<EuiAutoSizer>
{({ height, width }) => (
<FixedSizeList
height={height}
itemCount={requests.length}
itemSize={62}
itemData={requests}
width={width}
>
{Row}
</FixedSizeList>
)}
</EuiAutoSizer>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner
grow={false}
color="subdued"
paddingSize="s"
css={{ paddingTop: euiTheme.size.l }}
>
<EuiText>
<EuiButtonEmpty
size="xs"
color="primary"
data-test-subj="consoleClearHistoryButton"
onClick={clear}
>
<FormattedMessage
id="console.historyPage.clearHistoryButtonLabel"
defaultMessage="Clear all history"
/>
</EuiButtonEmpty>
</EuiText>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
</EuiResizablePanel>
<EuiResizableButton className="conApp__resizerButton" />
<EuiResizablePanel initialSize={50} minSize="15%" tabIndex={0} paddingSize="none">
<EuiSplitPanel.Outer
color="subdued"
css={{ height: '100%' }}
borderRadius="none"
hasShadow={false}
>
<EuiSplitPanel.Inner
paddingSize="none"
css={{ top: 0 }}
className="consoleEditorPanel"
>
<HistoryViewer settings={readOnlySettings} req={viewingReq} />
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner grow={false} className="consoleEditorPanel">
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="consoleHistoryAddAndRunButton"
color="primary"
iconType="play"
disabled={!viewingReq}
onClick={() => restoreRequestFromHistory(RestoreMethod.RESTORE_AND_EXECUTE)}
>
{i18n.translate('console.historyPage.addAndRunButtonLabel', {
defaultMessage: 'Add and run',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="consoleHistoryApplyButton"
fill
color="primary"
iconType="plusInCircle"
disabled={!viewingReq}
onClick={() => restoreRequestFromHistory(RestoreMethod.RESTORE)}
>
{i18n.translate('console.historyPage.applyHistoryButtonLabel', {
defaultMessage: 'Add',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
</EuiResizablePanel>
</>
)}
</EuiResizableContainer>
</EuiPanel>
);
}

View file

@ -0,0 +1,61 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiLink, EuiEmptyPrompt } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useServicesContext } from '../../contexts';
export function HistoryEmptyPrompt() {
const { docLinks } = useServicesContext();
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiEmptyPrompt
title={
<h2>
{i18n.translate('console.historyPage.emptyPromptTitle', {
defaultMessage: 'No queries yet',
})}
</h2>
}
titleSize="xs"
body={
<p>
{i18n.translate('console.historyPage.emptyPromptBody', {
defaultMessage:
'This history panel will display any past queries youve run for review and reuse.',
})}
</p>
}
footer={
<EuiTitle size="xxs">
<div>
<h3>
<FormattedMessage
id="console.historyPage.emptyPromptFooterLabel"
defaultMessage="Want to learn more?"
/>
</h3>
<EuiLink href={docLinks.console.guide} target="_blank">
<FormattedMessage
id="console.historyPage.emptyPromptFooterLink"
defaultMessage="Read Console documentation"
/>
</EuiLink>
</div>
</EuiTitle>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -14,7 +14,7 @@ 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';
import { useResizeCheckerUtils } from '../editor/hooks';
export const HistoryViewer = ({
settings,
@ -43,13 +43,14 @@ export const HistoryViewer = ({
renderedHistoryRequest = req.method + ' ' + req.endpoint + '\n' + formattedData;
} else {
renderedHistoryRequest = i18n.translate('console.historyPage.monaco.noHistoryTextMessage', {
defaultMessage: 'No history available',
defaultMessage: '# No history available to display',
});
}
return (
<div
css={css`
width: 100%;
height: 100%;
`}
ref={divRef}
>

View file

@ -7,4 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { ConsoleHistory } from './console_history';
export { History } from './history';

View file

@ -8,4 +8,3 @@
*/
export { Main } from './main';
export { Panel, PanelsContainer } from './split_panel';

View file

@ -0,0 +1,30 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export const SHELL_TAB_ID = 'shell';
export const HISTORY_TAB_ID = 'history';
export const CONFIG_TAB_ID = 'config';
export const SHELL_TOUR_STEP = 1;
export const EDITOR_TOUR_STEP = 2;
export const HISTORY_TOUR_STEP = 3;
export const CONFIG_TOUR_STEP = 4;
export const FILES_TOUR_STEP = 5;
// Key used for storing tour state in local storage
export const TOUR_STORAGE_KEY = 'consoleTour';
export const INITIAL_TOUR_CONFIG = {
currentTourStep: 1,
isTourActive: true,
tourPopoverWidth: 360,
tourSubtitle: 'Console onboarding', // Used for state in local storage
};
export const EXPORT_FILE_NAME = 'console_export';

View file

@ -0,0 +1,82 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import {
EuiButton,
EuiButtonEmpty,
EuiTourStepProps,
EuiTourActions,
EuiTourState,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ConsoleTourStepProps } from '../../components';
export const getConsoleTourStepProps = (
stateTourStepProps: EuiTourStepProps[],
actions: EuiTourActions,
tourState: EuiTourState
): ConsoleTourStepProps[] => {
return stateTourStepProps.map((step: EuiTourStepProps) => {
const nextTourStep = () => {
if (tourState.currentTourStep < 5) {
actions.incrementStep();
}
};
return {
step: step.step,
stepsTotal: step.stepsTotal,
isStepOpen: step.step === tourState.currentTourStep && tourState.isTourActive,
title: step.title,
content: step.content,
anchorPosition: step.anchorPosition,
dataTestSubj: step['data-test-subj'],
maxWidth: step.maxWidth,
css: step.css,
onFinish: () => actions.finishTour(false),
footerAction:
step.step === step.stepsTotal ? (
<EuiButton
color="success"
size="s"
onClick={() => actions.finishTour()}
data-test-subj="consoleCompleteTourButton"
>
{i18n.translate('console.tour.completeTourButton', {
defaultMessage: 'Complete',
})}
</EuiButton>
) : (
[
<EuiButtonEmpty
size="s"
color="text"
onClick={() => actions.finishTour()}
data-test-subj="consoleSkipTourButton"
>
{i18n.translate('console.tour.skipTourButton', {
defaultMessage: 'Skip tour',
})}
</EuiButtonEmpty>,
<EuiButton
color="success"
size="s"
onClick={nextTourStep}
data-test-subj="consoleNextTourStepButton"
>
{i18n.translate('console.tour.nextStepButton', {
defaultMessage: 'Next',
})}
</EuiButton>,
]
),
} as ConsoleTourStepProps;
});
};

View file

@ -8,23 +8,39 @@
*/
import { i18n } from '@kbn/i18n';
import {
SHELL_TAB_ID,
HISTORY_TAB_ID,
CONFIG_TAB_ID,
SHELL_TOUR_STEP,
CONFIG_TOUR_STEP,
HISTORY_TOUR_STEP,
} from './constants';
interface Props {
onClickHistory: () => void;
onClickSettings: () => void;
onClickHelp: () => void;
onClickVariables: () => void;
selectedTab: string;
setSelectedTab: (id: string) => void;
}
export function getTopNavConfig({
onClickHistory,
onClickSettings,
onClickHelp,
onClickVariables,
}: Props) {
export function getTopNavConfig({ selectedTab, setSelectedTab }: Props) {
return [
{
id: 'history',
id: SHELL_TAB_ID,
label: i18n.translate('console.topNav.shellTabLabel', {
defaultMessage: 'Shell',
}),
description: i18n.translate('console.topNav.shellTabDescription', {
defaultMessage: 'Shell',
}),
onClick: () => {
setSelectedTab(SHELL_TAB_ID);
},
testId: 'consoleShellButton',
isSelected: selectedTab === SHELL_TAB_ID,
tourStep: SHELL_TOUR_STEP,
},
{
id: HISTORY_TAB_ID,
label: i18n.translate('console.topNav.historyTabLabel', {
defaultMessage: 'History',
}),
@ -32,48 +48,26 @@ export function getTopNavConfig({
defaultMessage: 'History',
}),
onClick: () => {
onClickHistory();
setSelectedTab(HISTORY_TAB_ID);
},
testId: 'consoleHistoryButton',
isSelected: selectedTab === HISTORY_TAB_ID,
tourStep: HISTORY_TOUR_STEP,
},
{
id: 'settings',
label: i18n.translate('console.topNav.settingsTabLabel', {
defaultMessage: 'Settings',
id: CONFIG_TAB_ID,
label: i18n.translate('console.topNav.configTabLabel', {
defaultMessage: 'Config',
}),
description: i18n.translate('console.topNav.settingsTabDescription', {
defaultMessage: 'Settings',
description: i18n.translate('console.topNav.configTabDescription', {
defaultMessage: 'Config',
}),
onClick: () => {
onClickSettings();
setSelectedTab(CONFIG_TAB_ID);
},
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', {
defaultMessage: 'Help',
}),
description: i18n.translate('console.topNav.helpTabDescription', {
defaultMessage: 'Help',
}),
onClick: () => {
onClickHelp();
},
testId: 'consoleHelpButton',
testId: 'consoleConfigButton',
isSelected: selectedTab === CONFIG_TAB_ID,
tourStep: CONFIG_TOUR_STEP,
},
];
}

View file

@ -0,0 +1,122 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiStatelessTourSteps, EuiLink, EuiText } from '@elastic/eui';
import { DocLinksStart } from '@kbn/core-doc-links-browser';
const commonProps = {
stepsTotal: 5,
maxWidth: 400,
};
export function getTourSteps(docLinks: DocLinksStart['links']) {
return [
{
step: 1,
title: i18n.translate('console.tour.shellStepTitle', {
defaultMessage: 'Welcome to the Console',
}),
content: (
<EuiText>
{i18n.translate('console.tour.shellStepContent', {
defaultMessage:
'Console is an interactive UI for calling Elasticsearch and Kibana APIs and viewing their responses. Use Query DSL syntax to search your data, manage settings, and more.',
})}
</EuiText>
),
anchorPosition: 'downLeft',
'data-test-subj': 'shellTourStep',
...commonProps,
},
{
step: 2,
title: i18n.translate('console.tour.editorStepTitle', {
defaultMessage: 'Get started querying',
}),
content: (
<EuiText>
<FormattedMessage
id="console.tour.editorStepContent"
defaultMessage="Enter a request in this input pane, and view the response in the adjacent output pane. For more details, visit {queryDslDocs}."
values={{
queryDslDocs: (
<EuiLink href={docLinks.query.queryDsl} target="_blank">
QueryDSL documentation
</EuiLink>
),
}}
/>
</EuiText>
),
anchorPosition: 'rightUp',
'data-test-subj': 'editorTourStep',
css: {
position: 'absolute',
top: '40%',
left: '15%',
transform: 'translateY(10px)',
},
...commonProps,
},
{
step: 3,
title: i18n.translate('console.tour.historyStepTitle', {
defaultMessage: 'Revisit past queries',
}),
content: (
<EuiText>
{i18n.translate('console.tour.historyStepContent', {
defaultMessage:
'The history panel keeps track of your past queries, making it easy to review and rerun them.',
})}
</EuiText>
),
anchorPosition: 'rightUp',
'data-test-subj': 'historyTourStep',
...commonProps,
},
{
step: 4,
title: i18n.translate('console.tour.configStepTitle', {
defaultMessage: 'Customize your toolbox',
}),
content: (
<EuiText>
{i18n.translate('console.tour.configStepContent', {
defaultMessage:
'Fine-tune your Consoles settings and create variables to personalize your workflow.',
})}
</EuiText>
),
anchorPosition: 'rightUp',
'data-test-subj': 'configTourStep',
...commonProps,
},
{
step: 5,
title: i18n.translate('console.tour.filesStepTitle', {
defaultMessage: 'Manage Console files',
}),
content: (
<EuiText>
{i18n.translate('console.tour.filesStepContent', {
defaultMessage:
'Easily export your Console requests to a file, or import ones youve saved previously.',
})}
</EuiText>
),
anchorPosition: 'downRight',
'data-test-subj': 'filesTourStep',
...commonProps,
},
] as EuiStatelessTourSteps;
}

View file

@ -0,0 +1,43 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
export const MAIN_PANEL_LABELS = {
consolePageHeading: i18n.translate('console.pageHeading', {
defaultMessage: 'Console',
}),
importButton: i18n.translate('console.importButtonLabel', {
defaultMessage: 'Import requests',
}),
importButtonTooltip: i18n.translate('console.importButtonTooltipLabel', {
defaultMessage: 'Import requests from a file into the editor',
}),
exportButton: i18n.translate('console.exportButton', {
defaultMessage: 'Export requests',
}),
exportButtonTooltip: i18n.translate('console.exportButtonTooltipLabel', {
defaultMessage: 'Export all console requests to a TXT file',
}),
helpButton: i18n.translate('console.helpButtonTooltipContent', {
defaultMessage: 'Help',
}),
shortcutsButton: i18n.translate('console.shortcutsButtonAriaLabel', {
defaultMessage: 'Keyboard shortcuts',
}),
variablesButton: i18n.translate('console.variablesButton', {
defaultMessage: 'Variables',
}),
openFullscrenButton: i18n.translate('console.openFullscreenButton', {
defaultMessage: 'Open this console as a full page experience',
}),
closeFullscrenButton: i18n.translate('console.closeFullscreenButton', {
defaultMessage: 'Close full page experience',
}),
};

View file

@ -0,0 +1,67 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiConfirmModal } from '@elastic/eui';
import { useEditorActionContext } from '../../contexts';
import { useServicesContext } from '../../contexts';
interface ImportConfirmModalProps {
onClose: () => void;
fileContent: string;
}
export const ImportConfirmModal = ({ onClose, fileContent }: ImportConfirmModalProps) => {
const dispatch = useEditorActionContext();
const {
services: { notifications },
} = useServicesContext();
const onConfirmImport = useCallback(() => {
// Import the file content
dispatch({
type: 'setFileToImport',
payload: fileContent as string,
});
notifications.toasts.addSuccess(
i18n.translate('console.notification.fileImportedSuccessfully', {
defaultMessage: `The file you selected has been imported successfully.`,
})
);
onClose();
}, [fileContent, onClose, dispatch, notifications.toasts]);
return (
<EuiConfirmModal
data-test-subj="importConfirmModal"
title={i18n.translate('console.importConfirmModal.title', {
defaultMessage: 'Import and replace requests?',
})}
onCancel={onClose}
onConfirm={onConfirmImport}
cancelButtonText={i18n.translate('console.importConfirmModal.cancelButton', {
defaultMessage: 'Cancel',
})}
confirmButtonText={i18n.translate('console.importConfirmModal.confirmButton', {
defaultMessage: 'Import and replace',
})}
buttonColor="primary"
defaultFocusedButton="confirm"
>
<p>
{i18n.translate('console.importConfirmModal.body', {
defaultMessage: `Importing this file will replace all current requests in the editor.`,
})}
</p>
</EuiConfirmModal>
);
};

View file

@ -8,3 +8,4 @@
*/
export { Main } from './main';
export * from './constants';

View file

@ -7,61 +7,177 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPageTemplate,
EuiSplitPanel,
EuiToolTip,
useEuiTour,
EuiButtonEmpty,
EuiHorizontalRule,
EuiScreenReaderOnly,
useIsWithinBreakpoints,
useEuiOverflowScroll,
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiPageTemplate } from '@elastic/eui';
import { ConsoleHistory } from '../console_history';
import { downloadFileAs } from '@kbn/share-plugin/public';
import { getConsoleTourStepProps } from './get_console_tour_step_props';
import { useServicesContext } from '../../contexts';
import { MAIN_PANEL_LABELS } from './i18n';
import { NavIconButton } from './nav_icon_button';
import { Editor } from '../editor';
import { Settings } from '../settings';
import { Variables } from '../variables';
import { Config } from '../config';
import {
useEditorReadContext,
useEditorActionContext,
useRequestActionContext,
} from '../../contexts';
import {
TopNavMenu,
WelcomePanel,
HelpPanel,
SomethingWentWrongCallout,
NetworkRequestStatusBar,
HelpPopover,
ShortcutsPopover,
ConsoleTourStep,
ConsoleTourStepProps,
} from '../../components';
import { useServicesContext, useEditorReadContext, useRequestReadContext } from '../../contexts';
import { History } from '../history';
import { useDataInit } from '../../hooks';
import { getTopNavConfig } from './get_top_nav';
import type { SenseEditor } from '../../models/sense_editor';
import { getResponseWithMostSevereStatusCode } from '../../../lib/utils';
import { getTourSteps } from './get_tour_steps';
import { ImportConfirmModal } from './import_confirm_modal';
import {
SHELL_TAB_ID,
HISTORY_TAB_ID,
CONFIG_TAB_ID,
EDITOR_TOUR_STEP,
TOUR_STORAGE_KEY,
INITIAL_TOUR_CONFIG,
FILES_TOUR_STEP,
EXPORT_FILE_NAME,
} from './constants';
export interface MainProps {
hideWelcome?: boolean;
interface MainProps {
currentTabProp?: string;
isEmbeddable?: boolean;
}
export function Main({ hideWelcome = false }: MainProps) {
// 2MB limit (2 * 1024 * 1024 bytes)
const MAX_FILE_UPLOAD_SIZE = 2 * 1024 * 1024;
export function Main({ currentTabProp, isEmbeddable = false }: MainProps) {
const dispatch = useEditorActionContext();
const requestDispatch = useRequestActionContext();
const { currentView } = useEditorReadContext();
const currentTab = currentTabProp ?? currentView;
const [isShortcutsOpen, setIsShortcutsOpen] = useState(false);
const [isHelpOpen, setIsHelpOpen] = useState(false);
const [isFullscreenOpen, setIsFullScreen] = useState(false);
const [isConfirmImportOpen, setIsConfirmImportOpen] = useState<string | null>(null);
const { euiTheme } = useEuiTheme();
const {
services: { storage },
docLinks,
services: { notifications, routeHistory },
} = useServicesContext();
const { ready: editorsReady } = useEditorReadContext();
const isVerticalLayout = useIsWithinBreakpoints(['xs', 's', 'm']);
const {
requestInFlight: requestInProgress,
lastResult: { data: requestData, error: requestError },
} = useRequestReadContext();
const storageTourState = localStorage.getItem(TOUR_STORAGE_KEY);
const initialTourState = storageTourState ? JSON.parse(storageTourState) : INITIAL_TOUR_CONFIG;
const [tourStepProps, actions, tourState] = useEuiTour(getTourSteps(docLinks), initialTourState);
const [showWelcome, setShowWelcomePanel] = useState(
() => storage.get('version_welcome_shown') !== '@@SENSE_REVISION' && !hideWelcome
useEffect(() => {
localStorage.setItem(TOUR_STORAGE_KEY, JSON.stringify(tourState));
}, [tourState]);
// Clean up request output when switching tabs
useEffect(() => {
requestDispatch({ type: 'cleanRequest', payload: undefined });
}, [currentView, requestDispatch]);
const consoleTourStepProps: ConsoleTourStepProps[] = getConsoleTourStepProps(
tourStepProps,
actions,
tourState
);
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);
const renderConsoleHistory = () => {
return editorsReady ? <ConsoleHistory close={() => setShowHistory(false)} /> : null;
};
const { done, error, retry } = useDataInit();
const { currentTextObject } = useEditorReadContext();
const [inputEditorValue, setInputEditorValue] = useState<string>(currentTextObject?.text ?? '');
const updateTab = (tab: string) => {
if (routeHistory) {
routeHistory?.push(`/console/${tab}`);
} else {
dispatch({ type: 'setCurrentView', payload: tab });
}
};
const toggleFullscreen = () => {
const isEnabled = !isFullscreenOpen;
setIsFullScreen(isEnabled);
if (isEnabled) {
document.querySelector('#consoleRoot')?.requestFullscreen();
} else {
document.exitFullscreen();
}
};
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
const file = files && files[0];
// Clear the input value so that a file can be imported again
event.target.value = '';
if (file) {
if (file.size > MAX_FILE_UPLOAD_SIZE) {
notifications.toasts.addWarning(
i18n.translate('console.notification.error.fileTooBigMessage', {
defaultMessage: `File size exceeds the 2MB limit.`,
})
);
return;
}
const reader = new FileReader();
reader.onerror = () => {
notifications.toasts.addWarning(
i18n.translate('console.notification.error.failedToReadFile', {
defaultMessage: `Failed to read the file you selected.`,
})
);
};
reader.onload = (e) => {
const fileContent = e?.target?.result;
if (fileContent) {
setIsConfirmImportOpen(fileContent as string);
} else {
notifications.toasts.addWarning(
i18n.translate('console.notification.error.fileImportNoContent', {
defaultMessage: `The file you selected doesn't appear to have any content. Please select a different file.`,
})
);
}
};
reader.readAsText(file);
}
};
const scrollablePanelStyle = css`
${useEuiOverflowScroll('y', false)}
`;
if (error) {
return (
<EuiPageTemplate.EmptyPrompt color="danger">
@ -70,76 +186,171 @@ export function Main({ hideWelcome = false }: MainProps) {
);
}
const data = getResponseWithMostSevereStatusCode(requestData) ?? requestError;
const shortcutsButton = (
<NavIconButton
iconType="keyboard"
onClick={() => setIsShortcutsOpen(!isShortcutsOpen)}
ariaLabel={MAIN_PANEL_LABELS.shortcutsButton}
dataTestSubj="consoleShortcutsButton"
toolTipContent={MAIN_PANEL_LABELS.shortcutsButton}
/>
);
const helpButton = (
<NavIconButton
iconType="questionInCircle"
onClick={() => setIsHelpOpen(!isHelpOpen)}
ariaLabel={MAIN_PANEL_LABELS.helpButton}
dataTestSubj="consoleHelpButton"
toolTipContent={MAIN_PANEL_LABELS.helpButton}
/>
);
return (
<div id="consoleRoot">
<EuiFlexGroup
className="consoleContainer"
gutterSize="none"
direction="column"
responsive={false}
>
<EuiFlexItem grow={false}>
<EuiTitle className="euiScreenReaderOnly">
<h1>
{i18n.translate('console.pageHeading', {
defaultMessage: 'Console',
})}
</h1>
</EuiTitle>
<EuiFlexGroup gutterSize="none">
<div id="consoleRoot" className={`consoleContainer${isEmbeddable ? '--embeddable' : ''}`}>
<EuiScreenReaderOnly>
<h1>{MAIN_PANEL_LABELS.consolePageHeading}</h1>
</EuiScreenReaderOnly>
<EuiSplitPanel.Outer grow={true} borderRadius={isEmbeddable ? 'none' : 'm'}>
<EuiSplitPanel.Inner grow={false} className="consoleTabs">
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem>
<TopNavMenu
disabled={!done}
items={getTopNavConfig({
onClickHistory: () => setShowHistory(!showingHistory),
onClickSettings: () => setShowSettings(true),
onClickHelp: () => setShowHelp(!showHelp),
onClickVariables: () => setShowVariables(!showVariables),
selectedTab: currentTab,
setSelectedTab: (tab) => updateTab(tab),
})}
tourStepProps={consoleTourStepProps}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} className="conApp__tabsExtension">
<NetworkRequestStatusBar
requestInProgress={requestInProgress}
requestResult={
data
? {
method: data.request.method.toUpperCase(),
endpoint: data.request.path,
statusCode: data.response.statusCode,
statusText: data.response.statusText,
timeElapsedMs: data.response.timeMs,
<EuiFlexItem grow={false}>
<ConsoleTourStep tourStepProps={consoleTourStepProps[FILES_TOUR_STEP - 1]}>
<>
<EuiToolTip content={MAIN_PANEL_LABELS.exportButtonTooltip}>
<EuiButtonEmpty
iconType="exportAction"
onClick={() =>
downloadFileAs(EXPORT_FILE_NAME, {
content: inputEditorValue,
type: 'text/plain',
})
}
: undefined
}
size="xs"
data-test-subj="consoleExportButton"
>
{MAIN_PANEL_LABELS.exportButton}
</EuiButtonEmpty>
</EuiToolTip>
<>
<EuiToolTip content={MAIN_PANEL_LABELS.importButtonTooltip}>
<EuiButtonEmpty
iconType="importAction"
onClick={() => document.getElementById('importConsoleFile')?.click()}
size="xs"
data-test-subj="consoleImportButton"
>
{MAIN_PANEL_LABELS.importButton}
</EuiButtonEmpty>
</EuiToolTip>
{/* This input is hidden by CSS in the UI, but the NavIcon button activates it */}
<input
type="file"
accept="text/*"
multiple={false}
name="consoleSnippets"
id="importConsoleFile"
onChange={onFileChange}
/>
</>
</>
</ConsoleTourStep>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ShortcutsPopover
button={shortcutsButton}
isOpen={isShortcutsOpen}
closePopover={() => setIsShortcutsOpen(false)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<HelpPopover
button={helpButton}
isOpen={isHelpOpen}
closePopover={() => setIsHelpOpen(false)}
resetTour={() => {
setIsHelpOpen(false);
updateTab(SHELL_TAB_ID);
actions.resetTour();
}}
/>
</EuiFlexItem>
{isEmbeddable && (
<EuiFlexItem grow={false}>
<NavIconButton
iconType={isFullscreenOpen ? 'fullScreenExit' : 'fullScreen'}
onClick={toggleFullscreen}
ariaLabel={
isFullscreenOpen
? MAIN_PANEL_LABELS.closeFullscrenButton
: MAIN_PANEL_LABELS.openFullscrenButton
}
dataTestSubj="consoleToggleFullscreenButton"
toolTipContent={
isFullscreenOpen
? MAIN_PANEL_LABELS.closeFullscrenButton
: MAIN_PANEL_LABELS.openFullscrenButton
}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
{showingHistory ? <EuiFlexItem grow={false}>{renderConsoleHistory()}</EuiFlexItem> : null}
<EuiFlexItem>
<Editor loading={!done} setEditorInstance={setEditorInstance} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiHorizontalRule margin="none" />
<EuiSplitPanel.Inner
paddingSize="none"
css={[scrollablePanelStyle, { backgroundColor: euiTheme.colors.body }]}
>
{currentTab === SHELL_TAB_ID && (
<Editor
loading={!done}
isVerticalLayout={isVerticalLayout}
inputEditorValue={inputEditorValue}
setInputEditorValue={setInputEditorValue}
/>
)}
{currentTab === HISTORY_TAB_ID && <History isVerticalLayout={isVerticalLayout} />}
{currentTab === CONFIG_TAB_ID && <Config isVerticalLayout={isVerticalLayout} />}
</EuiSplitPanel.Inner>
<EuiHorizontalRule margin="none" className="consoleVariablesBottomBar" />
<EuiSplitPanel.Inner
paddingSize="xs"
grow={false}
className="consoleVariablesBottomBar"
color="plain"
>
<EuiButtonEmpty
onClick={() => updateTab(CONFIG_TAB_ID)}
iconType="editorCodeBlock"
size="xs"
color="text"
>
{MAIN_PANEL_LABELS.variablesButton}
</EuiButtonEmpty>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
{done && showWelcome ? (
<WelcomePanel
onDismiss={() => {
storage.set('version_welcome_shown', '@@SENSE_REVISION');
setShowWelcomePanel(false);
}}
{/* Empty container for Editor Tour Step */}
<ConsoleTourStep tourStepProps={consoleTourStepProps[EDITOR_TOUR_STEP - 1]}>
<div />
</ConsoleTourStep>
{isConfirmImportOpen && (
<ImportConfirmModal
onClose={() => setIsConfirmImportOpen(null)}
fileContent={isConfirmImportOpen}
/>
) : null}
{showSettings ? (
<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,38 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import React from 'react';
interface NavIconButtonProps {
iconType: string;
onClick: () => void;
ariaLabel: string;
dataTestSubj: string;
toolTipContent: string;
}
export const NavIconButton = ({
iconType,
onClick,
ariaLabel,
dataTestSubj,
toolTipContent,
}: NavIconButtonProps) => {
return (
<EuiToolTip position="top" content={toolTipContent}>
<EuiButtonIcon
iconType={iconType}
onClick={onClick}
aria-label={ariaLabel}
data-test-subj={dataTestSubj}
/>
</EuiToolTip>
);
};

Some files were not shown because too many files have changed in this diff Show more