mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solution][Endpoint] Response Console framework support for argument value selectors (#148693)
## Summary - PR adds the ability for Commands to define custom value selectors - components that will be rendered as the value when the Argument name is entered in the Console. These Argument Selectors can then provide the user with a better UX for selecting data that is not easily entered in the console via text input. - Introduces a File picker Argument Selector (not yet being used by real commands) that will be used in upcoming features. - Introduces a new `mustHaveValue` property to the definition of a command's argument. See PR on github for info.
This commit is contained in:
parent
cdab97bd47
commit
9ac065ab02
32 changed files with 1836 additions and 349 deletions
|
@ -92,7 +92,7 @@ export const CommandExecutionOutput = memo<CommandExecutionOutputProps>(
|
|||
return (
|
||||
<CommandOutputContainer>
|
||||
<div>
|
||||
<UserCommandInput input={command.input} isValid={isValid} />
|
||||
<UserCommandInput input={command.inputDisplay} isValid={isValid} />
|
||||
</div>
|
||||
<div>
|
||||
{/* UX desire for 12px (current theme): achieved with EuiSpace sizes - s (8px) + xs (4px) */}
|
||||
|
|
|
@ -12,6 +12,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiResizeObserver } from '@el
|
|||
import styled from 'styled-components';
|
||||
import classNames from 'classnames';
|
||||
import type { EuiResizeObserverProps } from '@elastic/eui/src/components/observer/resize_observer/resize_observer';
|
||||
import type { ExecuteCommandPayload, ConsoleDataState } from '../console_state/types';
|
||||
import { useWithInputShowPopover } from '../../hooks/state_selectors/use_with_input_show_popover';
|
||||
import { EnteredInput } from './lib/entered_input';
|
||||
import type { InputCaptureProps } from './components/input_capture';
|
||||
|
@ -40,6 +41,13 @@ const CommandInputContainer = styled.div`
|
|||
border-bottom-color: ${({ theme: { eui } }) => eui.euiColorDanger};
|
||||
}
|
||||
|
||||
.inputDisplay {
|
||||
& > * {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.textEntered {
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
@ -88,13 +96,17 @@ export interface CommandInputProps extends CommonProps {
|
|||
|
||||
export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ...commonProps }) => {
|
||||
useInputHints();
|
||||
const dispatch = useConsoleStateDispatch();
|
||||
const { rightOfCursor, textEntered, fullTextEntered } = useWithInputTextEntered();
|
||||
const visibleState = useWithInputVisibleState();
|
||||
const [isKeyInputBeingCaptured, setIsKeyInputBeingCaptured] = useState(false);
|
||||
const getTestId = useTestIdGenerator(useDataTestSubj());
|
||||
const dispatch = useConsoleStateDispatch();
|
||||
const { rightOfCursorText, leftOfCursorText, fullTextEntered, enteredCommand, parsedInput } =
|
||||
useWithInputTextEntered();
|
||||
const visibleState = useWithInputVisibleState();
|
||||
const isPopoverOpen = !!useWithInputShowPopover();
|
||||
const [commandToExecute, setCommandToExecute] = useState('');
|
||||
|
||||
const [isKeyInputBeingCaptured, setIsKeyInputBeingCaptured] = useState(false);
|
||||
const [commandToExecute, setCommandToExecute] = useState<ExecuteCommandPayload | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [popoverWidth, setPopoverWidth] = useState('94vw');
|
||||
|
||||
const _focusRef: InputCaptureProps['focusRef'] = useRef(null);
|
||||
|
@ -111,6 +123,10 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
|
|||
|
||||
const disableArrowButton = useMemo(() => fullTextEntered.trim().length === 0, [fullTextEntered]);
|
||||
|
||||
const userInput = useMemo(() => {
|
||||
return new EnteredInput(leftOfCursorText, rightOfCursorText, parsedInput, enteredCommand);
|
||||
}, [enteredCommand, leftOfCursorText, parsedInput, rightOfCursorText]);
|
||||
|
||||
const handleOnResize = useCallback<EuiResizeObserverProps['onResize']>(({ width }) => {
|
||||
if (width > 0) {
|
||||
setPopoverWidth(`${width}px`);
|
||||
|
@ -118,15 +134,12 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
|
|||
}, []);
|
||||
|
||||
const handleSubmitButton = useCallback<MouseEventHandler>(() => {
|
||||
setCommandToExecute(textEntered + rightOfCursor.text);
|
||||
dispatch({
|
||||
type: 'updateInputTextEnteredState',
|
||||
payload: {
|
||||
textEntered: '',
|
||||
rightOfCursor: undefined,
|
||||
},
|
||||
setCommandToExecute({
|
||||
input: userInput.getFullText(true),
|
||||
enteredCommand,
|
||||
parsedInput,
|
||||
});
|
||||
}, [dispatch, textEntered, rightOfCursor.text]);
|
||||
}, [enteredCommand, parsedInput, userInput]);
|
||||
|
||||
const handleOnChangeFocus = useCallback<NonNullable<InputCaptureProps['onChangeFocus']>>(
|
||||
(hasFocus) => {
|
||||
|
@ -163,8 +176,18 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
|
|||
// Update the store with the updated text that was entered
|
||||
dispatch({
|
||||
type: 'updateInputTextEnteredState',
|
||||
payload: ({ textEntered: prevLeftOfCursor, rightOfCursor: prevRightOfCursor }) => {
|
||||
let inputText = new EnteredInput(prevLeftOfCursor, prevRightOfCursor.text);
|
||||
payload: ({
|
||||
leftOfCursorText: prevLeftOfCursor,
|
||||
rightOfCursorText: prevRightOfCursor,
|
||||
enteredCommand: prevEnteredCommand,
|
||||
parsedInput: prevParsedInput,
|
||||
}) => {
|
||||
const inputText = new EnteredInput(
|
||||
prevLeftOfCursor,
|
||||
prevRightOfCursor,
|
||||
prevParsedInput,
|
||||
prevEnteredCommand
|
||||
);
|
||||
|
||||
inputText.addValue(value ?? '', selection);
|
||||
|
||||
|
@ -181,8 +204,12 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
|
|||
|
||||
// ENTER = Execute command and blank out the input area
|
||||
case 13:
|
||||
setCommandToExecute(inputText.getFullText());
|
||||
inputText = new EnteredInput('', '');
|
||||
setCommandToExecute({
|
||||
input: inputText.getFullText(true),
|
||||
enteredCommand: prevEnteredCommand as ConsoleDataState['input']['enteredCommand'],
|
||||
parsedInput: prevParsedInput as ConsoleDataState['input']['parsedInput'],
|
||||
});
|
||||
inputText.clear();
|
||||
break;
|
||||
|
||||
// ARROW LEFT
|
||||
|
@ -207,8 +234,9 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
|
|||
}
|
||||
|
||||
return {
|
||||
textEntered: inputText.getLeftOfCursorText(),
|
||||
rightOfCursor: { text: inputText.getRightOfCursorText() },
|
||||
leftOfCursorText: inputText.getLeftOfCursorText(),
|
||||
rightOfCursorText: inputText.getRightOfCursorText(),
|
||||
argState: inputText.getArgState(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -219,8 +247,17 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
|
|||
// Execute the command if one was ENTER'd.
|
||||
useEffect(() => {
|
||||
if (commandToExecute) {
|
||||
dispatch({ type: 'executeCommand', payload: { input: commandToExecute } });
|
||||
setCommandToExecute('');
|
||||
dispatch({ type: 'executeCommand', payload: commandToExecute });
|
||||
setCommandToExecute(undefined);
|
||||
|
||||
// reset input
|
||||
dispatch({
|
||||
type: 'updateInputTextEnteredState',
|
||||
payload: {
|
||||
leftOfCursorText: '',
|
||||
rightOfCursorText: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [commandToExecute, dispatch]);
|
||||
|
||||
|
@ -248,17 +285,20 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
|
|||
onChangeFocus={handleOnChangeFocus}
|
||||
focusRef={focusRef}
|
||||
>
|
||||
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<div data-test-subj={getTestId('cmdInput-leftOfCursor')}>{textEntered}</div>
|
||||
<EuiFlexGroup
|
||||
responsive={false}
|
||||
alignItems="center"
|
||||
gutterSize="none"
|
||||
className="inputDisplay"
|
||||
>
|
||||
<EuiFlexItem grow={false} data-test-subj={getTestId('cmdInput-leftOfCursor')}>
|
||||
{userInput.getLeftOfCursorRenderingContent()}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<span className="cursor essentialAnimation" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<div data-test-subj={getTestId('cmdInput-rightOfCursor')}>
|
||||
{rightOfCursor.text}
|
||||
</div>
|
||||
<EuiFlexItem data-test-subj={getTestId('cmdInput-rightOfCursor')}>
|
||||
{userInput.getRightOfCursorRenderingContent()}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</InputCapture>
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import styled, { createGlobalStyle } from 'styled-components';
|
||||
import type { EuiTheme } from '@kbn/kibana-react-plugin/common';
|
||||
import { useConsoleStateDispatch } from '../../../hooks/state_selectors/use_console_state_dispatch';
|
||||
import { useWithCommandArgumentState } from '../../../hooks/state_selectors/use_with_command_argument_state';
|
||||
import type { CommandArgDefinition, CommandArgumentValueSelectorProps } from '../../../types';
|
||||
|
||||
const ArgumentSelectorWrapperContainer = styled.span`
|
||||
user-select: none;
|
||||
|
||||
.selectorContainer {
|
||||
max-width: 25vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
// FIXME:PT Delete below. Only here for DEV purposes
|
||||
const DevUxStyles = createGlobalStyle<{ theme: EuiTheme }>`
|
||||
|
||||
body {
|
||||
|
||||
&.style1 .argSelectorWrapper {
|
||||
.style1-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.selectorContainer {
|
||||
border: ${({ theme: { eui } }) => eui.euiBorderThin};
|
||||
border-radius: ${({ theme: { eui } }) => eui.euiBorderRadiusSmall};
|
||||
padding: 0 ${({ theme: { eui } }) => eui.euiSizeXS};
|
||||
}
|
||||
}
|
||||
|
||||
&.style2 {
|
||||
.argSelectorWrapper {
|
||||
border: ${({ theme: { eui } }) => eui.euiBorderThin};
|
||||
border-radius: ${({ theme: { eui } }) => eui.euiBorderRadiusSmall};
|
||||
overflow: hidden;
|
||||
|
||||
& > .euiFlexGroup {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.style2-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.argNameContainer {
|
||||
background-color: ${({ theme: { eui } }) => eui.euiFormInputGroupLabelBackground};
|
||||
}
|
||||
|
||||
.argName {
|
||||
padding-left: ${({ theme: { eui } }) => eui.euiSizeXS};
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.selectorContainer {
|
||||
padding: 0 ${({ theme: { eui } }) => eui.euiSizeXS};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Type to ensure that `SelectorComponent` is defined
|
||||
type ArgDefinitionWithRequiredSelector = Omit<CommandArgDefinition, 'SelectorComponent'> &
|
||||
Pick<Required<CommandArgDefinition>, 'SelectorComponent'>;
|
||||
|
||||
export interface ArgumentSelectorWrapperProps {
|
||||
argName: string;
|
||||
argIndex: number;
|
||||
argDefinition: ArgDefinitionWithRequiredSelector;
|
||||
}
|
||||
|
||||
/**
|
||||
* handles displaying a custom argument value selector and manages its state
|
||||
*/
|
||||
export const ArgumentSelectorWrapper = memo<ArgumentSelectorWrapperProps>(
|
||||
({ argName, argIndex, argDefinition: { SelectorComponent } }) => {
|
||||
const dispatch = useConsoleStateDispatch();
|
||||
const { valueText, value, store } = useWithCommandArgumentState(argName, argIndex);
|
||||
|
||||
const handleSelectorComponentOnChange = useCallback<
|
||||
CommandArgumentValueSelectorProps['onChange']
|
||||
>(
|
||||
(updates) => {
|
||||
dispatch({
|
||||
type: 'updateInputCommandArgState',
|
||||
payload: {
|
||||
name: argName,
|
||||
instance: argIndex,
|
||||
state: updates,
|
||||
},
|
||||
});
|
||||
},
|
||||
[argIndex, argName, dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<ArgumentSelectorWrapperContainer className="eui-displayInlineBlock argSelectorWrapper">
|
||||
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="none">
|
||||
<EuiFlexItem grow={false} className="argNameContainer">
|
||||
<div className="argName">
|
||||
<span>{`--${argName}=`}</span>
|
||||
<span className="style1-hide style2-hide">{'"'}</span>
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{/* `div` below ensures that the `SelectorComponent` does NOT inherit the styles of a `flex` container */}
|
||||
<div className="selectorContainer eui-textTruncate">
|
||||
<SelectorComponent
|
||||
value={value}
|
||||
valueText={valueText ?? ''}
|
||||
argName={argName}
|
||||
argIndex={argIndex}
|
||||
store={store}
|
||||
onChange={handleSelectorComponentOnChange}
|
||||
/>
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} className="style1-hide style2-hide">
|
||||
{'"'}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<DevUxStyles />
|
||||
</ArgumentSelectorWrapperContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
ArgumentSelectorWrapper.displayName = 'ArgumentSelectorWrapper';
|
|
@ -43,7 +43,7 @@ export const CommandInputHistory = memo(() => {
|
|||
const selectableHistoryOptions = useMemo(() => {
|
||||
return inputHistory.map<EuiSelectableProps['options'][number]>((inputItem, index) => {
|
||||
return {
|
||||
label: inputItem.input,
|
||||
label: inputItem.display,
|
||||
key: inputItem.id,
|
||||
data: inputItem,
|
||||
};
|
||||
|
@ -94,7 +94,13 @@ export const CommandInputHistory = memo(() => {
|
|||
dispatch({ type: 'updateInputPlaceholderState', payload: { placeholder: '' } });
|
||||
|
||||
if (selected) {
|
||||
dispatch({ type: 'updateInputTextEnteredState', payload: { textEntered: selected.label } });
|
||||
dispatch({
|
||||
type: 'updateInputTextEnteredState',
|
||||
payload: {
|
||||
leftOfCursorText: (selected.data as InputHistoryItem).input,
|
||||
rightOfCursorText: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
dispatch({ type: 'addFocusToKeyCapture' });
|
||||
|
@ -124,15 +130,18 @@ export const CommandInputHistory = memo(() => {
|
|||
// unloads, if no option from the history was selected, then set the prior text
|
||||
// entered back
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'updateInputTextEnteredState', payload: { textEntered: '' } });
|
||||
dispatch({
|
||||
type: 'updateInputTextEnteredState',
|
||||
payload: { leftOfCursorText: '', rightOfCursorText: '' },
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (!optionWasSelected.current) {
|
||||
dispatch({
|
||||
type: 'updateInputTextEnteredState',
|
||||
payload: {
|
||||
textEntered: priorInputState.textEntered,
|
||||
rightOfCursor: priorInputState.rightOfCursor,
|
||||
leftOfCursorText: priorInputState.leftOfCursorText,
|
||||
rightOfCursorText: priorInputState.rightOfCursorText,
|
||||
},
|
||||
});
|
||||
dispatch({ type: 'updateInputPlaceholderState', payload: { placeholder: '' } });
|
||||
|
|
|
@ -19,6 +19,10 @@ const InputPlaceholderContainer = styled(EuiText)`
|
|||
padding-left: 0.5em;
|
||||
width: 96%;
|
||||
color: ${({ theme: { eui } }) => eui.euiFormControlPlaceholderText};
|
||||
user-select: none;
|
||||
line-height: ${({ theme: { eui } }) => {
|
||||
return `calc(${eui.euiLineHeight}em + 0.5em)`;
|
||||
}};
|
||||
`;
|
||||
|
||||
export const InputPlaceholder = memo(() => {
|
||||
|
|
|
@ -38,7 +38,7 @@ export const useInputHints = () => {
|
|||
const isInputPopoverOpen = Boolean(useWithInputShowPopover());
|
||||
const commandEntered = useWithInputCommandEntered();
|
||||
const commandList = useWithCommandList();
|
||||
const { textEntered } = useWithInputTextEntered();
|
||||
const { leftOfCursorText } = useWithInputTextEntered();
|
||||
|
||||
const commandEnteredDefinition = useMemo<CommandDefinition | undefined>(() => {
|
||||
if (commandEntered) {
|
||||
|
@ -105,10 +105,10 @@ export const useInputHints = () => {
|
|||
dispatch({
|
||||
type: 'updateFooterContent',
|
||||
payload: {
|
||||
value: textEntered || isInputPopoverOpen ? '' : UP_ARROW_ACCESS_HISTORY_HINT,
|
||||
value: leftOfCursorText || isInputPopoverOpen ? '' : UP_ARROW_ACCESS_HISTORY_HINT,
|
||||
},
|
||||
});
|
||||
dispatch({ type: 'setInputState', payload: { value: undefined } });
|
||||
}
|
||||
}, [commandEntered, commandEnteredDefinition, dispatch, isInputPopoverOpen, textEntered]);
|
||||
}, [commandEntered, commandEnteredDefinition, dispatch, isInputPopoverOpen, leftOfCursorText]);
|
||||
};
|
||||
|
|
|
@ -463,4 +463,39 @@ describe('When entering data into the Console input', () => {
|
|||
expect(getRightOfCursorText()).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('and a command argument has a value SelectorComponent defined', () => {
|
||||
it('should insert Selector component when argument name is used', async () => {
|
||||
render();
|
||||
enterCommand('cmd7 --foo', { inputOnly: true });
|
||||
|
||||
expect(getLeftOfCursorText()).toEqual('cmd7 --foo="foo[0]: foo selected"');
|
||||
});
|
||||
|
||||
it('should support using argument multiple times (allowMultiples: true)', async () => {
|
||||
render();
|
||||
enterCommand('cmd7 --foo --foo', { inputOnly: true });
|
||||
|
||||
expect(getLeftOfCursorText()).toEqual(
|
||||
'cmd7 --foo="foo[0]: foo selected" --foo="foo[1]: foo selected"'
|
||||
);
|
||||
});
|
||||
|
||||
it(`should remove entire argument if BACKSPACE key is pressed`, async () => {
|
||||
render();
|
||||
enterCommand('cmd7 --foo', { inputOnly: true });
|
||||
typeKeyboardKey('{backspace}');
|
||||
|
||||
expect(getLeftOfCursorText()).toEqual('cmd7 ');
|
||||
});
|
||||
|
||||
it(`should remove entire argument if DELETE key is pressed`, async () => {
|
||||
render();
|
||||
enterCommand('cmd7 --foo', { inputOnly: true });
|
||||
typeKeyboardKey('{ArrowLeft}');
|
||||
typeKeyboardKey('{Delete}');
|
||||
|
||||
expect(getLeftOfCursorText()).toEqual('cmd7 ');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,322 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EnteredInput } from './entered_input';
|
||||
import { parseCommandInput } from '../../../service/parsed_command_input';
|
||||
import type { CommandDefinition } from '../../..';
|
||||
import { getCommandListMock } from '../../../mocks';
|
||||
import type { EnteredCommand } from '../../console_state/types';
|
||||
|
||||
describe('When using `EnteredInput` class', () => {
|
||||
let enteredInput: EnteredInput;
|
||||
let commandDefinition: CommandDefinition;
|
||||
|
||||
const createEnteredInput = (
|
||||
leftOfCursorText: string = 'cmd1 --comment="hello"',
|
||||
rightOfCursorText: string = '',
|
||||
commandDef: CommandDefinition | undefined = commandDefinition,
|
||||
argValueSelectorState: EnteredCommand['argState'] = {}
|
||||
): EnteredInput => {
|
||||
const parsedInput = parseCommandInput(leftOfCursorText + rightOfCursorText);
|
||||
const enteredCommand: EnteredCommand | undefined = commandDef
|
||||
? {
|
||||
commandDefinition: commandDef,
|
||||
argState: argValueSelectorState,
|
||||
argsWithValueSelectors: undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
enteredInput = new EnteredInput(
|
||||
leftOfCursorText,
|
||||
rightOfCursorText,
|
||||
parsedInput,
|
||||
enteredCommand
|
||||
);
|
||||
|
||||
return enteredInput;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
commandDefinition = getCommandListMock().find((def) => def.name === 'cmd1')!;
|
||||
});
|
||||
|
||||
it('should clear input when calling `clear()`', () => {
|
||||
createEnteredInput('cmd1 --comment="', 'hello"');
|
||||
|
||||
expect(enteredInput.getFullText()).toEqual('cmd1 --comment="hello"');
|
||||
|
||||
enteredInput.clear();
|
||||
|
||||
expect(enteredInput.getFullText()).toEqual('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
leftInput: 'cmd1 --comment="',
|
||||
rightInput: '',
|
||||
valueToAdd: 'n',
|
||||
leftExpected: 'cmd1 --comment="n',
|
||||
rightExpected: '',
|
||||
},
|
||||
{
|
||||
leftInput: 'cmd1 --comment="',
|
||||
rightInput: '"',
|
||||
valueToAdd: 'n',
|
||||
leftExpected: 'cmd1 --comment="n',
|
||||
rightExpected: '"',
|
||||
},
|
||||
{
|
||||
leftInput: '',
|
||||
rightInput: 'cmd1 --comment=""',
|
||||
valueToAdd: 'n',
|
||||
leftExpected: 'n',
|
||||
rightExpected: 'cmd1 --comment=""',
|
||||
},
|
||||
])(
|
||||
'Should add [$valueToAdd] to command left=[$leftInput] right=[$rightInput]',
|
||||
({ leftInput, rightInput, valueToAdd, leftExpected, rightExpected }) => {
|
||||
createEnteredInput(leftInput, rightInput);
|
||||
enteredInput.addValue(valueToAdd);
|
||||
|
||||
expect(enteredInput.getLeftOfCursorText()).toEqual(leftExpected);
|
||||
expect(enteredInput.getRightOfCursorText()).toEqual(rightExpected);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
// Cursor at the end
|
||||
|
||||
// Cursor at the start
|
||||
{
|
||||
leftInput: '',
|
||||
rightInput: 'cmd1 --comment="hello"',
|
||||
valueToAdd: 'n',
|
||||
valueToReplace: 'hello',
|
||||
leftExpected: 'cmd1 --comment="n',
|
||||
rightExpected: '"',
|
||||
},
|
||||
// Cursor in the middle with replacement value on the right
|
||||
{
|
||||
leftInput: 'cmd1 --comment',
|
||||
rightInput: '="hello"',
|
||||
valueToAdd: 'n',
|
||||
valueToReplace: 'hello',
|
||||
leftExpected: 'cmd1 --comment="n',
|
||||
rightExpected: '"',
|
||||
},
|
||||
// Cursor in the middle right between the replacement value
|
||||
{
|
||||
leftInput: 'cmd1 --comment="he',
|
||||
rightInput: 'llo"',
|
||||
valueToAdd: 'n',
|
||||
valueToReplace: 'hello',
|
||||
leftExpected: 'cmd1 --comment="n',
|
||||
rightExpected: '"',
|
||||
},
|
||||
// Cursor at the end of the value that will be replaced
|
||||
{
|
||||
leftInput: 'cmd1 --comment="hello',
|
||||
rightInput: '"',
|
||||
valueToAdd: 'n',
|
||||
valueToReplace: 'hello',
|
||||
leftExpected: 'cmd1 --comment="n',
|
||||
rightExpected: '"',
|
||||
},
|
||||
// Cursor at the start of the value that will be replaced
|
||||
{
|
||||
leftInput: 'cmd1 --comment="',
|
||||
rightInput: 'hello"',
|
||||
valueToAdd: 'n',
|
||||
valueToReplace: 'hello',
|
||||
leftExpected: 'cmd1 --comment="n',
|
||||
rightExpected: '"',
|
||||
},
|
||||
])(
|
||||
'Should replace (via `.addValue()`) [$valueToReplace] with [$valueToAdd] on command left=[$leftInput] right=[$rightInput]',
|
||||
({ leftInput, rightInput, valueToAdd, valueToReplace, rightExpected, leftExpected }) => {
|
||||
createEnteredInput(leftInput, rightInput);
|
||||
enteredInput.addValue(valueToAdd, valueToReplace);
|
||||
|
||||
expect(enteredInput.getLeftOfCursorText()).toEqual(leftExpected);
|
||||
expect(enteredInput.getRightOfCursorText()).toEqual(rightExpected);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
leftInput: 'cmd1 --comment="',
|
||||
rightInput: 'hello"',
|
||||
direction: 'left',
|
||||
leftExpected: 'cmd1 --comment=',
|
||||
rightExpected: '"hello"',
|
||||
},
|
||||
{
|
||||
leftInput: 'cmd1 --comment="',
|
||||
rightInput: 'hello"',
|
||||
direction: 'right',
|
||||
leftExpected: 'cmd1 --comment="h',
|
||||
rightExpected: 'ello"',
|
||||
},
|
||||
{
|
||||
leftInput: 'cmd1 --comment="',
|
||||
rightInput: 'hello"',
|
||||
direction: 'end',
|
||||
leftExpected: 'cmd1 --comment="hello"',
|
||||
rightExpected: '',
|
||||
},
|
||||
{
|
||||
leftInput: 'cmd1 --comment="',
|
||||
rightInput: 'hello"',
|
||||
direction: 'home',
|
||||
leftExpected: '',
|
||||
rightExpected: 'cmd1 --comment="hello"',
|
||||
},
|
||||
])(
|
||||
'should move cursor $direction',
|
||||
({ leftInput, rightInput, direction, leftExpected, rightExpected }) => {
|
||||
createEnteredInput(leftInput, rightInput);
|
||||
enteredInput.moveCursorTo(direction as Parameters<EnteredInput['moveCursorTo']>[0]);
|
||||
|
||||
expect(enteredInput.getLeftOfCursorText()).toEqual(leftExpected);
|
||||
expect(enteredInput.getRightOfCursorText()).toEqual(rightExpected);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
leftInput: 'cmd1 --comment="hello"',
|
||||
rightInput: '',
|
||||
leftExpected: 'cmd1 --comment="hello"',
|
||||
rightExpected: '',
|
||||
},
|
||||
{
|
||||
leftInput: '',
|
||||
rightInput: 'cmd1 --comment="hello"',
|
||||
leftExpected: '',
|
||||
rightExpected: 'md1 --comment="hello"',
|
||||
},
|
||||
{
|
||||
leftInput: 'cmd1 --comment="h',
|
||||
rightInput: 'ello"',
|
||||
leftExpected: 'cmd1 --comment="h',
|
||||
rightExpected: 'llo"',
|
||||
},
|
||||
])(
|
||||
'should remove expected character using `deleteChar()` when command is left=[$leftInput] right=[$rightInput]',
|
||||
({ leftInput, rightInput, leftExpected, rightExpected }) => {
|
||||
createEnteredInput(leftInput, rightInput);
|
||||
|
||||
enteredInput.deleteChar();
|
||||
|
||||
expect(enteredInput.getLeftOfCursorText()).toEqual(leftExpected);
|
||||
expect(enteredInput.getRightOfCursorText()).toEqual(rightExpected);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
leftInput: 'cmd1 --comment="hello"',
|
||||
rightInput: '',
|
||||
leftExpected: 'cmd1 --comment="hello',
|
||||
rightExpected: '',
|
||||
},
|
||||
{
|
||||
leftInput: '',
|
||||
rightInput: 'cmd1 --comment="hello"',
|
||||
leftExpected: '',
|
||||
rightExpected: 'cmd1 --comment="hello"',
|
||||
},
|
||||
{
|
||||
leftInput: 'cmd1 --comment="h',
|
||||
rightInput: 'ello"',
|
||||
leftExpected: 'cmd1 --comment="',
|
||||
rightExpected: 'ello"',
|
||||
},
|
||||
])(
|
||||
'should remove expected character using `backspaceChar()` when command is left=[$leftInput] right=[$rightInput]',
|
||||
({ leftInput, rightInput, leftExpected, rightExpected }) => {
|
||||
createEnteredInput(leftInput, rightInput);
|
||||
|
||||
enteredInput.backspaceChar();
|
||||
|
||||
expect(enteredInput.getLeftOfCursorText()).toEqual(leftExpected);
|
||||
expect(enteredInput.getRightOfCursorText()).toEqual(rightExpected);
|
||||
}
|
||||
);
|
||||
|
||||
describe.each(['deleteChar', 'backspaceChar'])(
|
||||
'and using %s with text selected',
|
||||
(methodName) => {
|
||||
it.each([
|
||||
{
|
||||
leftInput: 'cmd1 --comment="hello"',
|
||||
rightInput: '',
|
||||
valueToReplace: 'hello',
|
||||
leftExpected: 'cmd1 --comment="',
|
||||
rightExpected: '"',
|
||||
},
|
||||
// Cursor at the start
|
||||
{
|
||||
leftInput: '',
|
||||
rightInput: 'cmd1 --comment="hello"',
|
||||
valueToReplace: 'hello',
|
||||
leftExpected: 'cmd1 --comment="',
|
||||
rightExpected: '"',
|
||||
},
|
||||
// Cursor in the middle with replacement value on the right
|
||||
{
|
||||
leftInput: 'cmd1 --comment',
|
||||
rightInput: '="hello"',
|
||||
valueToReplace: 'hello',
|
||||
leftExpected: 'cmd1 --comment="',
|
||||
rightExpected: '"',
|
||||
},
|
||||
// Cursor in the middle right between the replacement value
|
||||
{
|
||||
leftInput: 'cmd1 --comment="he',
|
||||
rightInput: 'llo"',
|
||||
valueToReplace: 'hello',
|
||||
leftExpected: 'cmd1 --comment="',
|
||||
rightExpected: '"',
|
||||
},
|
||||
// Cursor at the end of the value that will be replaced
|
||||
{
|
||||
leftInput: 'cmd1 --comment="hello',
|
||||
rightInput: '"',
|
||||
valueToReplace: 'hello',
|
||||
leftExpected: 'cmd1 --comment="',
|
||||
rightExpected: '"',
|
||||
},
|
||||
// Cursor at the start of the value that will be replaced
|
||||
{
|
||||
leftInput: 'cmd1 --comment="',
|
||||
rightInput: 'hello"',
|
||||
valueToReplace: 'hello',
|
||||
leftExpected: 'cmd1 --comment="',
|
||||
rightExpected: '"',
|
||||
},
|
||||
])(
|
||||
'Should remove selection [$valueToReplace] (via `.deleteChr()`) from command left=[$leftInput] right=[$rightInput]',
|
||||
({ leftInput, rightInput, valueToReplace, rightExpected, leftExpected }) => {
|
||||
createEnteredInput(leftInput, rightInput);
|
||||
|
||||
switch (methodName) {
|
||||
case 'deleteChar':
|
||||
enteredInput.deleteChar(valueToReplace);
|
||||
break;
|
||||
case 'backspaceChar':
|
||||
enteredInput.backspaceChar(valueToReplace);
|
||||
break;
|
||||
}
|
||||
|
||||
expect(enteredInput.getLeftOfCursorText()).toEqual(leftExpected);
|
||||
expect(enteredInput.getRightOfCursorText()).toEqual(rightExpected);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -1,98 +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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class that manages the command entered and how that is displayed to the left and right of the cursor
|
||||
*/
|
||||
export class EnteredInput {
|
||||
constructor(private leftOfCursorText: string, private rightOfCursorText: string) {}
|
||||
|
||||
private replaceSelection(selection: string, newValue: string) {
|
||||
const prevFullTextEntered = this.leftOfCursorText + this.rightOfCursorText;
|
||||
|
||||
this.leftOfCursorText =
|
||||
prevFullTextEntered.substring(0, prevFullTextEntered.indexOf(selection)) + newValue;
|
||||
|
||||
this.rightOfCursorText = prevFullTextEntered.substring(
|
||||
prevFullTextEntered.indexOf(selection) + selection.length
|
||||
);
|
||||
}
|
||||
|
||||
getLeftOfCursorText(): string {
|
||||
return this.leftOfCursorText;
|
||||
}
|
||||
|
||||
getRightOfCursorText(): string {
|
||||
return this.rightOfCursorText;
|
||||
}
|
||||
|
||||
getFullText(): string {
|
||||
return this.leftOfCursorText + this.rightOfCursorText;
|
||||
}
|
||||
|
||||
moveCursorTo(direction: 'left' | 'right' | 'end' | 'home') {
|
||||
switch (direction) {
|
||||
case 'end':
|
||||
this.leftOfCursorText = this.leftOfCursorText + this.rightOfCursorText;
|
||||
this.rightOfCursorText = '';
|
||||
break;
|
||||
|
||||
case 'home':
|
||||
this.rightOfCursorText = this.leftOfCursorText + this.rightOfCursorText;
|
||||
this.leftOfCursorText = '';
|
||||
break;
|
||||
|
||||
case 'left':
|
||||
if (this.leftOfCursorText.length) {
|
||||
// Add last character on the left, to the right side of the cursor
|
||||
this.rightOfCursorText =
|
||||
this.leftOfCursorText.charAt(this.leftOfCursorText.length - 1) + this.rightOfCursorText;
|
||||
|
||||
// Remove the last character from the left (it's now on the right side of cursor)
|
||||
this.leftOfCursorText = this.leftOfCursorText.substring(
|
||||
0,
|
||||
this.leftOfCursorText.length - 1
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'right':
|
||||
if (this.rightOfCursorText.length) {
|
||||
// MOve the first character from the Right side, to the left side of the cursor
|
||||
this.leftOfCursorText = this.leftOfCursorText + this.rightOfCursorText.charAt(0);
|
||||
|
||||
// Remove the first character from the Right side of the cursor (now on the left)
|
||||
this.rightOfCursorText = this.rightOfCursorText.substring(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
addValue(value: string, replaceSelection: string = '') {
|
||||
if (replaceSelection.length && value.length) {
|
||||
this.replaceSelection(replaceSelection, value);
|
||||
} else {
|
||||
this.leftOfCursorText += value;
|
||||
}
|
||||
}
|
||||
|
||||
deleteChar(replaceSelection: string = '') {
|
||||
if (replaceSelection) {
|
||||
this.replaceSelection(replaceSelection, '');
|
||||
} else if (this.rightOfCursorText) {
|
||||
this.rightOfCursorText = this.rightOfCursorText.substring(1);
|
||||
}
|
||||
}
|
||||
|
||||
backspaceChar(replaceSelection: string = '') {
|
||||
if (replaceSelection) {
|
||||
this.replaceSelection(replaceSelection, '');
|
||||
} else if (this.leftOfCursorText) {
|
||||
this.leftOfCursorText = this.leftOfCursorText.substring(0, this.leftOfCursorText.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,295 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import type { ArgumentSelectorWrapperProps } from '../components/argument_selector_wrapper';
|
||||
import { ArgumentSelectorWrapper } from '../components/argument_selector_wrapper';
|
||||
import type { ParsedCommandInterface } from '../../../service/types';
|
||||
import type { ArgSelectorState, EnteredCommand } from '../../console_state/types';
|
||||
|
||||
interface InputCharacter {
|
||||
value: string;
|
||||
renderValue: ReactNode;
|
||||
isArgSelector: boolean;
|
||||
argName: string;
|
||||
argIndex: number; // zero based
|
||||
argState: undefined | ArgSelectorState;
|
||||
}
|
||||
|
||||
const createInputCharacter = (overrides: Partial<InputCharacter> = {}): InputCharacter => {
|
||||
return {
|
||||
value: '',
|
||||
renderValue: null,
|
||||
isArgSelector: false,
|
||||
argName: '',
|
||||
argIndex: 0,
|
||||
argState: undefined,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const getInputCharacters = (input: string): InputCharacter[] => {
|
||||
return input.split('').map((char) => {
|
||||
return createInputCharacter({
|
||||
value: char,
|
||||
renderValue: char,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const toReactJsxFragment = (prefix: string, item: InputCharacter, index: number) => {
|
||||
return <span key={`${prefix}.${index}.${item.value ?? '$'}`}>{item.renderValue}</span>;
|
||||
};
|
||||
|
||||
const toInputCharacterDisplayString = (
|
||||
includeArgSelectorValues: boolean,
|
||||
item: InputCharacter
|
||||
): string => {
|
||||
let response = item.value;
|
||||
|
||||
if (includeArgSelectorValues && item.isArgSelector) {
|
||||
response += `="${item.argState?.valueText ?? ''}"`;
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Class that manages the command entered and how that is displayed to the left and right of the cursor
|
||||
*/
|
||||
export class EnteredInput {
|
||||
private leftOfCursorContent: InputCharacter[];
|
||||
private rightOfCursorContent: InputCharacter[];
|
||||
private canHaveArgValueSelectors: boolean;
|
||||
private argState: undefined | EnteredCommand['argState'];
|
||||
|
||||
constructor(
|
||||
leftOfCursorText: string,
|
||||
rightOfCursorText: string,
|
||||
parsedInput: ParsedCommandInterface,
|
||||
enteredCommand: undefined | EnteredCommand
|
||||
) {
|
||||
this.leftOfCursorContent = getInputCharacters(leftOfCursorText);
|
||||
this.rightOfCursorContent = getInputCharacters(rightOfCursorText);
|
||||
|
||||
this.canHaveArgValueSelectors = Boolean(enteredCommand?.argsWithValueSelectors);
|
||||
|
||||
// Determine if any argument value selector should be inserted
|
||||
if (parsedInput.hasArgs && enteredCommand && enteredCommand.argsWithValueSelectors) {
|
||||
this.argState = enteredCommand.argState;
|
||||
|
||||
const inputPieces = [
|
||||
{
|
||||
input: leftOfCursorText,
|
||||
items: this.leftOfCursorContent,
|
||||
},
|
||||
{
|
||||
input: rightOfCursorText,
|
||||
items: this.rightOfCursorContent,
|
||||
},
|
||||
];
|
||||
|
||||
for (const [argName, argDef] of Object.entries(enteredCommand.argsWithValueSelectors)) {
|
||||
// If the argument has been used, then replace it with the Arguments Selector
|
||||
if (parsedInput.hasArg(argName)) {
|
||||
let argIndex = 0;
|
||||
|
||||
// Loop through the input pieces (left and right side of cursor) looking for the Argument name
|
||||
for (const { input, items } of inputPieces) {
|
||||
const argNameMatch = `--${argName}`;
|
||||
let pos = input.indexOf(argNameMatch);
|
||||
|
||||
while (pos > -1) {
|
||||
const argChrLength = argNameMatch.length;
|
||||
const replaceValues: InputCharacter[] = Array.from(
|
||||
{ length: argChrLength },
|
||||
createInputCharacter
|
||||
);
|
||||
const argState = enteredCommand.argState[argName]?.at(argIndex);
|
||||
|
||||
replaceValues[0] = createInputCharacter({
|
||||
value: argNameMatch,
|
||||
renderValue: (
|
||||
<ArgumentSelectorWrapper
|
||||
argName={argName}
|
||||
argIndex={argIndex}
|
||||
argDefinition={argDef as ArgumentSelectorWrapperProps['argDefinition']}
|
||||
/>
|
||||
),
|
||||
isArgSelector: true,
|
||||
argName,
|
||||
argIndex: argIndex++,
|
||||
argState,
|
||||
});
|
||||
|
||||
items.splice(pos, argChrLength, ...replaceValues);
|
||||
|
||||
pos = input.indexOf(argNameMatch, pos + argChrLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all empty characters (created as a result of inserting any Argument Selector components)
|
||||
this.leftOfCursorContent = this.leftOfCursorContent.filter(({ value }) => value.length > 0);
|
||||
this.rightOfCursorContent = this.rightOfCursorContent.filter(({ value }) => value.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
private replaceSelection(selection: string, newValue: string) {
|
||||
const prevFullTextEntered = this.getFullText();
|
||||
const newValueContent = newValue ? createInputCharacter({ value: newValue }) : undefined;
|
||||
let start = prevFullTextEntered.indexOf(selection);
|
||||
|
||||
const fullContent = [...this.leftOfCursorContent, ...this.rightOfCursorContent];
|
||||
|
||||
// Adjust the `start` to account for arguments that have value selectors.
|
||||
// These arguments, are stored in the `fullContent` array as one single array item instead of
|
||||
// one per-character. The adjustment needs to be done only if the argument appears to the left
|
||||
// of the selection
|
||||
if (this.canHaveArgValueSelectors) {
|
||||
fullContent.forEach((inputCharacter, index) => {
|
||||
if (inputCharacter.isArgSelector && index < start) {
|
||||
start = start - (inputCharacter.value.length - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const removedChars = fullContent.splice(start, selection.length);
|
||||
|
||||
if (newValueContent) {
|
||||
fullContent.splice(start, 0, newValueContent);
|
||||
start++;
|
||||
}
|
||||
|
||||
this.leftOfCursorContent = fullContent.splice(0, start);
|
||||
this.rightOfCursorContent = fullContent;
|
||||
this.removeArgState(removedChars);
|
||||
}
|
||||
|
||||
private removeArgState(argStateList: InputCharacter[]) {
|
||||
if (this.argState) {
|
||||
let argStateWasAdjusted = false;
|
||||
const newArgState = { ...this.argState };
|
||||
|
||||
for (const { argName, argIndex, isArgSelector } of argStateList) {
|
||||
if (isArgSelector && newArgState[argName]?.at(argIndex)) {
|
||||
newArgState[argName] = newArgState[argName].filter((_, index) => {
|
||||
return index !== argIndex;
|
||||
});
|
||||
argStateWasAdjusted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (argStateWasAdjusted) {
|
||||
this.argState = newArgState;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLeftOfCursorText(includeArgSelectorValues: boolean = false): string {
|
||||
return this.leftOfCursorContent
|
||||
.map(toInputCharacterDisplayString.bind(null, includeArgSelectorValues))
|
||||
.join('');
|
||||
}
|
||||
|
||||
getRightOfCursorText(includeArgSelectorValues: boolean = false): string {
|
||||
return this.rightOfCursorContent
|
||||
.map(toInputCharacterDisplayString.bind(null, includeArgSelectorValues))
|
||||
.join('');
|
||||
}
|
||||
|
||||
getFullText(includeArgSelectorValues: boolean = false): string {
|
||||
return (
|
||||
this.getLeftOfCursorText(includeArgSelectorValues) +
|
||||
this.getRightOfCursorText(includeArgSelectorValues)
|
||||
);
|
||||
}
|
||||
|
||||
getLeftOfCursorRenderingContent(): ReactNode {
|
||||
return <>{this.leftOfCursorContent.map(toReactJsxFragment.bind(null, 'left'))}</>;
|
||||
}
|
||||
|
||||
getRightOfCursorRenderingContent(): ReactNode {
|
||||
return <>{this.rightOfCursorContent.map(toReactJsxFragment.bind(null, 'right'))}</>;
|
||||
}
|
||||
|
||||
getArgState(): undefined | EnteredCommand['argState'] {
|
||||
return this.argState;
|
||||
}
|
||||
|
||||
moveCursorTo(direction: 'left' | 'right' | 'end' | 'home') {
|
||||
switch (direction) {
|
||||
case 'end':
|
||||
this.leftOfCursorContent.push(...this.rightOfCursorContent.splice(0));
|
||||
break;
|
||||
|
||||
case 'home':
|
||||
this.rightOfCursorContent.unshift(...this.leftOfCursorContent.splice(0));
|
||||
break;
|
||||
|
||||
case 'left':
|
||||
if (this.leftOfCursorContent.length) {
|
||||
const itemToMove = this.leftOfCursorContent.pop();
|
||||
|
||||
if (itemToMove) {
|
||||
this.rightOfCursorContent.unshift(itemToMove);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'right':
|
||||
if (this.rightOfCursorContent.length) {
|
||||
const itemToMove = this.rightOfCursorContent.shift();
|
||||
|
||||
if (itemToMove) {
|
||||
this.leftOfCursorContent.push(itemToMove);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
addValue(value: string, replaceSelection: string = '') {
|
||||
if (replaceSelection.length && value.length) {
|
||||
this.replaceSelection(replaceSelection, value);
|
||||
} else if (value) {
|
||||
this.leftOfCursorContent.push(createInputCharacter({ value }));
|
||||
}
|
||||
}
|
||||
|
||||
deleteChar(replaceSelection: string = '') {
|
||||
if (replaceSelection) {
|
||||
this.replaceSelection(replaceSelection, '');
|
||||
} else {
|
||||
const removedChar = this.rightOfCursorContent.shift();
|
||||
|
||||
if (removedChar?.isArgSelector) {
|
||||
this.removeArgState([removedChar]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
backspaceChar(replaceSelection: string = '') {
|
||||
if (replaceSelection) {
|
||||
this.replaceSelection(replaceSelection, '');
|
||||
} else {
|
||||
const removedChar = this.leftOfCursorContent.pop();
|
||||
|
||||
if (removedChar?.isArgSelector) {
|
||||
this.removeArgState([removedChar]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.leftOfCursorContent = [];
|
||||
this.rightOfCursorContent = [];
|
||||
this.argState = undefined;
|
||||
}
|
||||
}
|
|
@ -124,7 +124,8 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
|
|||
type: 'updateInputTextEnteredState',
|
||||
payload: () => {
|
||||
return {
|
||||
textEntered: text,
|
||||
leftOfCursorText: text,
|
||||
rightOfCursorText: '',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -17,10 +17,12 @@ interface InputHistoryOfflineStorage {
|
|||
const COMMAND_INPUT_HISTORY_KEY = 'commandInputHistory';
|
||||
|
||||
/**
|
||||
* The current version of the input history offline storage. Will help in the future
|
||||
* if we ever need to "migrate" stored data to a new format
|
||||
* The current version of the input history offline storage.
|
||||
*
|
||||
* NOTE: Changes to this value will likely require some migration to be added
|
||||
* to `migrateHistoryData()` down below.
|
||||
*/
|
||||
const CURRENT_VERSION = 1;
|
||||
const CURRENT_VERSION = 2;
|
||||
|
||||
const getDefaultInputHistoryStorage = (): InputHistoryOfflineStorage => {
|
||||
return {
|
||||
|
@ -41,6 +43,10 @@ export const useStoredInputHistory = (
|
|||
`${storagePrefix}.${COMMAND_INPUT_HISTORY_KEY}`
|
||||
) as InputHistoryOfflineStorage) ?? getDefaultInputHistoryStorage();
|
||||
|
||||
if (storedData.version !== CURRENT_VERSION) {
|
||||
migrateHistoryData(storedData);
|
||||
}
|
||||
|
||||
return storedData.data;
|
||||
}
|
||||
|
||||
|
@ -69,3 +75,19 @@ export const useSaveInputHistoryToStorage = (
|
|||
[storage, storagePrefix]
|
||||
);
|
||||
};
|
||||
|
||||
const migrateHistoryData = (storedData: InputHistoryOfflineStorage) => {
|
||||
const { data, version } = storedData;
|
||||
|
||||
for (const historyItem of data) {
|
||||
// -------------------------------------------
|
||||
// V2:
|
||||
// - adds `display` property
|
||||
// -------------------------------------------
|
||||
if (version < 2) {
|
||||
historyItem.display = historyItem.input;
|
||||
}
|
||||
}
|
||||
|
||||
storedData.version = CURRENT_VERSION;
|
||||
};
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { parseCommandInput } from '../../service/parsed_command_input';
|
||||
import {
|
||||
handleInputAreaState,
|
||||
INPUT_DEFAULT_PLACEHOLDER_TEXT,
|
||||
|
@ -31,16 +32,17 @@ export const initiateState = (
|
|||
managedConsolePriorState?: ConsoleDataState
|
||||
): ConsoleDataState => {
|
||||
const commands = getBuiltinCommands().concat(userCommandList);
|
||||
const state = managedConsolePriorState ?? {
|
||||
const state: ConsoleDataState = managedConsolePriorState ?? {
|
||||
commands,
|
||||
...otherOptions,
|
||||
commandHistory: [],
|
||||
sidePanel: { show: null },
|
||||
footerContent: '',
|
||||
input: {
|
||||
textEntered: '',
|
||||
rightOfCursor: { text: '' },
|
||||
commandEntered: '',
|
||||
leftOfCursorText: '',
|
||||
rightOfCursorText: '',
|
||||
parsedInput: parseCommandInput(''),
|
||||
enteredCommand: undefined,
|
||||
placeholder: INPUT_DEFAULT_PLACEHOLDER_TEXT,
|
||||
showPopover: undefined,
|
||||
history: [],
|
||||
|
@ -102,6 +104,7 @@ export const stateDataReducer: ConsoleStoreReducer = (state, action) => {
|
|||
case 'updateInputTextEnteredState':
|
||||
case 'updateInputPlaceholderState':
|
||||
case 'setInputState':
|
||||
case 'updateInputCommandArgState':
|
||||
newState = handleInputAreaState(state, action);
|
||||
break;
|
||||
|
||||
|
|
|
@ -5,13 +5,10 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
// FIXME:PT breakup module in order to avoid turning off eslint rule below
|
||||
/* eslint-disable complexity */
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { executionTranslations } from './translations';
|
||||
import type { ParsedCommandInterface } from '../../../service/types';
|
||||
import { ConsoleCodeBlock } from '../../console_code_block';
|
||||
import { handleInputAreaState } from './handle_input_area_state';
|
||||
import { HelpCommandArgument } from '../../builtin_commands/help_command_argument';
|
||||
|
@ -21,8 +18,6 @@ import type {
|
|||
ConsoleDataState,
|
||||
ConsoleStoreReducer,
|
||||
} from '../types';
|
||||
import type { ParsedCommandInterface } from '../../../service/parsed_command_input';
|
||||
import { parseCommandInput } from '../../../service/parsed_command_input';
|
||||
import { UnknownCommand } from '../../unknown_comand';
|
||||
import { BadArgument } from '../../bad_argument';
|
||||
import { ValidationError } from '../../validation_error';
|
||||
|
@ -76,7 +71,10 @@ const updateStateWithNewCommandHistoryItem = (
|
|||
): ConsoleDataState => {
|
||||
const updatedState = handleInputAreaState(state, {
|
||||
type: 'updateInputHistoryState',
|
||||
payload: { command: newHistoryItem.command.input },
|
||||
payload: {
|
||||
command: newHistoryItem.command.input,
|
||||
display: newHistoryItem.command.inputDisplay,
|
||||
},
|
||||
});
|
||||
|
||||
updatedState.commandHistory = [...state.commandHistory, newHistoryItem];
|
||||
|
@ -131,16 +129,13 @@ const createCommandHistoryEntry = (
|
|||
export const handleExecuteCommand: ConsoleStoreReducer<
|
||||
ConsoleDataAction & { type: 'executeCommand' }
|
||||
> = (state, action) => {
|
||||
const parsedInput = parseCommandInput(action.payload.input);
|
||||
const { parsedInput, enteredCommand, input: fullInputText } = action.payload;
|
||||
|
||||
if (parsedInput.name === '') {
|
||||
return state;
|
||||
}
|
||||
|
||||
const { commands } = state;
|
||||
const commandDefinition: CommandDefinition | undefined = commands.find(
|
||||
(definition) => definition.name === parsedInput.name
|
||||
);
|
||||
const commandDefinition: CommandDefinition | undefined = enteredCommand?.commandDefinition;
|
||||
|
||||
// Unknown command
|
||||
if (!commandDefinition) {
|
||||
|
@ -149,6 +144,7 @@ export const handleExecuteCommand: ConsoleStoreReducer<
|
|||
createCommandHistoryEntry(
|
||||
{
|
||||
input: parsedInput.input,
|
||||
inputDisplay: fullInputText,
|
||||
args: parsedInput,
|
||||
commandDefinition: {
|
||||
...UnknownCommandDefinition,
|
||||
|
@ -161,28 +157,17 @@ export const handleExecuteCommand: ConsoleStoreReducer<
|
|||
);
|
||||
}
|
||||
|
||||
const command = {
|
||||
const command: Command = {
|
||||
input: parsedInput.input,
|
||||
inputDisplay: fullInputText,
|
||||
args: parsedInput,
|
||||
commandDefinition,
|
||||
};
|
||||
const requiredArgs = getRequiredArguments(commandDefinition.args);
|
||||
const exclusiveOrArgs = getExclusiveOrArgs(commandDefinition.args);
|
||||
|
||||
const exclusiveOrErrorMessage = (
|
||||
<ConsoleCodeBlock>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.console.commandValidation.exclusiveOr"
|
||||
defaultMessage="This command supports only one of the following arguments: {argNames}"
|
||||
values={{
|
||||
argNames: (
|
||||
<ConsoleCodeBlock bold inline>
|
||||
{exclusiveOrArgs.map(toCliArgumentOption).join(', ')}
|
||||
</ConsoleCodeBlock>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</ConsoleCodeBlock>
|
||||
const exclusiveOrErrorMessage = executionTranslations.onlyOneFromExclusiveOr(
|
||||
exclusiveOrArgs.map(toCliArgumentOption).join(', ')
|
||||
);
|
||||
|
||||
// If args were entered, then validate them
|
||||
|
@ -235,12 +220,7 @@ export const handleExecuteCommand: ConsoleStoreReducer<
|
|||
createCommandHistoryEntry(
|
||||
cloneCommandDefinitionWithNewRenderComponent(command, BadArgument),
|
||||
createCommandExecutionState({
|
||||
errorMessage: i18n.translate(
|
||||
'xpack.securitySolution.console.commandValidation.noArgumentsSupported',
|
||||
{
|
||||
defaultMessage: 'Command does not support any arguments',
|
||||
}
|
||||
),
|
||||
errorMessage: executionTranslations.NO_ARGUMENTS_SUPPORTED,
|
||||
}),
|
||||
false
|
||||
)
|
||||
|
@ -256,26 +236,10 @@ export const handleExecuteCommand: ConsoleStoreReducer<
|
|||
createCommandHistoryEntry(
|
||||
cloneCommandDefinitionWithNewRenderComponent(command, BadArgument),
|
||||
createCommandExecutionState({
|
||||
errorMessage: (
|
||||
<ConsoleCodeBlock>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.console.commandValidation.unknownArgument"
|
||||
defaultMessage="The following {command} {countOfInvalidArgs, plural, =1 {argument is} other {arguments are}} not supported by this command: {unknownArgs}"
|
||||
values={{
|
||||
countOfInvalidArgs: unknownInputArgs.length,
|
||||
command: (
|
||||
<ConsoleCodeBlock bold inline>
|
||||
{parsedInput.name}
|
||||
</ConsoleCodeBlock>
|
||||
),
|
||||
unknownArgs: (
|
||||
<ConsoleCodeBlock bold inline>
|
||||
{unknownInputArgs.map(toCliArgumentOption).join(', ')}
|
||||
</ConsoleCodeBlock>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</ConsoleCodeBlock>
|
||||
errorMessage: executionTranslations.unknownArgument(
|
||||
unknownInputArgs.length,
|
||||
parsedInput.name,
|
||||
unknownInputArgs.map(toCliArgumentOption).join(', ')
|
||||
),
|
||||
}),
|
||||
false
|
||||
|
@ -294,15 +258,7 @@ export const handleExecuteCommand: ConsoleStoreReducer<
|
|||
createCommandExecutionState({
|
||||
errorMessage: (
|
||||
<ConsoleCodeBlock>
|
||||
{i18n.translate(
|
||||
'xpack.securitySolution.console.commandValidation.missingRequiredArg',
|
||||
{
|
||||
defaultMessage: 'Missing required argument: {argName}',
|
||||
values: {
|
||||
argName: toCliArgumentOption(requiredArg),
|
||||
},
|
||||
}
|
||||
)}
|
||||
{executionTranslations.missingRequiredArg(requiredArg)}
|
||||
</ConsoleCodeBlock>
|
||||
),
|
||||
}),
|
||||
|
@ -341,15 +297,7 @@ export const handleExecuteCommand: ConsoleStoreReducer<
|
|||
|
||||
createCommandExecutionState({
|
||||
errorMessage: (
|
||||
<ConsoleCodeBlock>
|
||||
{i18n.translate(
|
||||
'xpack.securitySolution.console.commandValidation.unsupportedArg',
|
||||
{
|
||||
defaultMessage: 'Unsupported argument: {argName}',
|
||||
values: { argName: toCliArgumentOption(argName) },
|
||||
}
|
||||
)}
|
||||
</ConsoleCodeBlock>
|
||||
<ConsoleCodeBlock>{executionTranslations.unsupportedArg(argName)}</ConsoleCodeBlock>
|
||||
),
|
||||
}),
|
||||
false
|
||||
|
@ -366,13 +314,7 @@ export const handleExecuteCommand: ConsoleStoreReducer<
|
|||
createCommandExecutionState({
|
||||
errorMessage: (
|
||||
<ConsoleCodeBlock>
|
||||
{i18n.translate(
|
||||
'xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce',
|
||||
{
|
||||
defaultMessage: 'Argument can only be used once: {argName}',
|
||||
values: { argName: toCliArgumentOption(argName) },
|
||||
}
|
||||
)}
|
||||
{executionTranslations.noMultiplesAllowed(argName)}
|
||||
</ConsoleCodeBlock>
|
||||
),
|
||||
}),
|
||||
|
@ -381,6 +323,70 @@ export const handleExecuteCommand: ConsoleStoreReducer<
|
|||
);
|
||||
}
|
||||
|
||||
if (argDefinition.mustHaveValue !== undefined && argDefinition.mustHaveValue !== false) {
|
||||
let dataValidationError = '';
|
||||
|
||||
if (argInput.length === 0) {
|
||||
dataValidationError = executionTranslations.mustHaveValue(argName);
|
||||
} else {
|
||||
argInput.some((argValue, index) => {
|
||||
switch (argDefinition.mustHaveValue) {
|
||||
case true:
|
||||
case 'non-empty-string':
|
||||
if (typeof argValue === 'boolean') {
|
||||
dataValidationError = executionTranslations.mustHaveValue(argName);
|
||||
} else if (
|
||||
argDefinition.mustHaveValue === 'non-empty-string' &&
|
||||
argValue.trim().length === 0
|
||||
) {
|
||||
dataValidationError = executionTranslations.mustHaveValue(argName);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
case 'number-greater-than-zero':
|
||||
{
|
||||
const valueNumber = Number(argValue);
|
||||
|
||||
if (!Number.isSafeInteger(valueNumber)) {
|
||||
dataValidationError = executionTranslations.mustBeNumber(argName);
|
||||
} else {
|
||||
if (argDefinition.mustHaveValue === 'number-greater-than-zero') {
|
||||
if (valueNumber <= 0) {
|
||||
dataValidationError = executionTranslations.mustBeGreaterThanZero(argName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no errors, then update (mutate) the value so that correct
|
||||
// format reaches the execution component
|
||||
if (!dataValidationError) {
|
||||
argInput[index] = valueNumber;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return !!dataValidationError;
|
||||
});
|
||||
}
|
||||
|
||||
if (dataValidationError) {
|
||||
return updateStateWithNewCommandHistoryItem(
|
||||
state,
|
||||
createCommandHistoryEntry(
|
||||
cloneCommandDefinitionWithNewRenderComponent(command, BadArgument),
|
||||
|
||||
createCommandExecutionState({
|
||||
errorMessage: <ConsoleCodeBlock>{dataValidationError}</ConsoleCodeBlock>,
|
||||
}),
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Call validation callback if one was defined for the argument
|
||||
if (argDefinition.validate) {
|
||||
const validationResult = argDefinition.validate(argInput);
|
||||
|
||||
|
@ -392,13 +398,7 @@ export const handleExecuteCommand: ConsoleStoreReducer<
|
|||
createCommandExecutionState({
|
||||
errorMessage: (
|
||||
<ConsoleCodeBlock>
|
||||
{i18n.translate(
|
||||
'xpack.securitySolution.console.commandValidation.invalidArgValue',
|
||||
{
|
||||
defaultMessage: 'Invalid argument value: {argName}. {error}',
|
||||
values: { argName: toCliArgumentOption(argName), error: validationResult },
|
||||
}
|
||||
)}
|
||||
{executionTranslations.argValueValidatorError(argName, validationResult)}
|
||||
</ConsoleCodeBlock>
|
||||
),
|
||||
}),
|
||||
|
@ -416,14 +416,9 @@ export const handleExecuteCommand: ConsoleStoreReducer<
|
|||
createCommandExecutionState({
|
||||
errorMessage: (
|
||||
<ConsoleCodeBlock>
|
||||
{i18n.translate('xpack.securitySolution.console.commandValidation.mustHaveArgs', {
|
||||
defaultMessage: 'Missing required arguments: {requiredArgs}',
|
||||
values: {
|
||||
requiredArgs: requiredArgs
|
||||
.map((argName) => toCliArgumentOption(argName))
|
||||
.join(', '),
|
||||
},
|
||||
})}
|
||||
{executionTranslations.missingArguments(
|
||||
requiredArgs.map((argName) => toCliArgumentOption(argName)).join(', ')
|
||||
)}
|
||||
</ConsoleCodeBlock>
|
||||
),
|
||||
}),
|
||||
|
@ -448,11 +443,7 @@ export const handleExecuteCommand: ConsoleStoreReducer<
|
|||
cloneCommandDefinitionWithNewRenderComponent(command, BadArgument),
|
||||
createCommandExecutionState({
|
||||
errorMessage: (
|
||||
<ConsoleCodeBlock>
|
||||
{i18n.translate('xpack.securitySolution.console.commandValidation.oneArgIsRequired', {
|
||||
defaultMessage: 'At least one argument must be used',
|
||||
})}
|
||||
</ConsoleCodeBlock>
|
||||
<ConsoleCodeBlock>{executionTranslations.MUST_HAVE_AT_LEAST_ONE_ARG}</ConsoleCodeBlock>
|
||||
),
|
||||
}),
|
||||
false
|
||||
|
|
|
@ -7,8 +7,14 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import { getCommandNameFromTextInput } from '../../../service/parsed_command_input';
|
||||
import type { ConsoleDataAction, ConsoleStoreReducer } from '../types';
|
||||
import type { ParsedCommandInterface } from '../../../service/types';
|
||||
import { parseCommandInput } from '../../../service/parsed_command_input';
|
||||
import type {
|
||||
ConsoleDataAction,
|
||||
ConsoleDataState,
|
||||
ConsoleStoreReducer,
|
||||
EnteredCommand,
|
||||
} from '../types';
|
||||
|
||||
export const INPUT_DEFAULT_PLACEHOLDER_TEXT = i18n.translate(
|
||||
'xpack.securitySolution.handleInputAreaState.inputPlaceholderText',
|
||||
|
@ -17,6 +23,21 @@ export const INPUT_DEFAULT_PLACEHOLDER_TEXT = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
const setArgSelectorValueToParsedArgs = (
|
||||
parsedInput: ParsedCommandInterface,
|
||||
enteredCommand: EnteredCommand | undefined
|
||||
) => {
|
||||
if (enteredCommand && enteredCommand.argsWithValueSelectors) {
|
||||
for (const argName of Object.keys(enteredCommand.argsWithValueSelectors)) {
|
||||
if (parsedInput.hasArg(argName)) {
|
||||
const argumentValues = enteredCommand.argState[argName] ?? [];
|
||||
|
||||
parsedInput.args[argName] = argumentValues.map((itemState) => itemState.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type InputAreaStateAction = ConsoleDataAction & {
|
||||
type:
|
||||
| 'updateInputPopoverState'
|
||||
|
@ -24,7 +45,8 @@ type InputAreaStateAction = ConsoleDataAction & {
|
|||
| 'clearInputHistoryState'
|
||||
| 'updateInputTextEnteredState'
|
||||
| 'updateInputPlaceholderState'
|
||||
| 'setInputState';
|
||||
| 'setInputState'
|
||||
| 'updateInputCommandArgState';
|
||||
};
|
||||
|
||||
export const handleInputAreaState: ConsoleStoreReducer<InputAreaStateAction> = (
|
||||
|
@ -50,7 +72,14 @@ export const handleInputAreaState: ConsoleStoreReducer<InputAreaStateAction> = (
|
|||
input: {
|
||||
...state.input,
|
||||
// Keeping the last 100 entries only for now
|
||||
history: [{ id: uuidV4(), input: payload.command }, ...state.input.history.slice(0, 99)],
|
||||
history: [
|
||||
{
|
||||
id: uuidV4(),
|
||||
input: payload.command,
|
||||
display: payload.display ?? payload.command,
|
||||
},
|
||||
...state.input.history.slice(0, 99),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -64,33 +93,70 @@ export const handleInputAreaState: ConsoleStoreReducer<InputAreaStateAction> = (
|
|||
};
|
||||
|
||||
case 'updateInputTextEnteredState':
|
||||
const { textEntered: newTextEntered, rightOfCursor: newRightOfCursor = { text: '' } } =
|
||||
typeof payload === 'function'
|
||||
? payload({
|
||||
textEntered: state.input.textEntered,
|
||||
rightOfCursor: state.input.rightOfCursor,
|
||||
})
|
||||
: payload;
|
||||
const {
|
||||
leftOfCursorText: newTextEntered,
|
||||
rightOfCursorText: newRightOfCursor = '',
|
||||
argState: adjustedArgState,
|
||||
} = typeof payload === 'function' ? payload(state.input) : payload;
|
||||
|
||||
if (
|
||||
state.input.textEntered !== newTextEntered ||
|
||||
state.input.rightOfCursor !== newRightOfCursor
|
||||
state.input.leftOfCursorText !== newTextEntered ||
|
||||
state.input.rightOfCursorText !== newRightOfCursor
|
||||
) {
|
||||
const fullCommandText = newTextEntered + newRightOfCursor.text;
|
||||
const commandEntered =
|
||||
// If the user has typed a command (some text followed by at space),
|
||||
// then parse it to get the command name.
|
||||
fullCommandText.trimStart().indexOf(' ') !== -1
|
||||
? getCommandNameFromTextInput(fullCommandText)
|
||||
: '';
|
||||
const parsedInput = parseCommandInput(newTextEntered + newRightOfCursor);
|
||||
|
||||
let enteredCommand: ConsoleDataState['input']['enteredCommand'] =
|
||||
state.input.enteredCommand;
|
||||
|
||||
if (enteredCommand && adjustedArgState && enteredCommand?.argState !== adjustedArgState) {
|
||||
enteredCommand = {
|
||||
...enteredCommand,
|
||||
argState: adjustedArgState,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine if `enteredCommand` should be re-defined
|
||||
if (
|
||||
(parsedInput.name &&
|
||||
(!enteredCommand || parsedInput.name !== enteredCommand.commandDefinition.name)) ||
|
||||
(!parsedInput.name && enteredCommand)
|
||||
) {
|
||||
enteredCommand = undefined;
|
||||
|
||||
const commandDefinition = state.commands.find((def) => def.name === parsedInput.name);
|
||||
|
||||
if (commandDefinition) {
|
||||
let argsWithValueSelectors: EnteredCommand['argsWithValueSelectors'];
|
||||
|
||||
for (const [argName, argDef] of Object.entries(commandDefinition.args ?? {})) {
|
||||
if (argDef.SelectorComponent) {
|
||||
if (!argsWithValueSelectors) {
|
||||
argsWithValueSelectors = {};
|
||||
}
|
||||
|
||||
argsWithValueSelectors[argName] = argDef;
|
||||
}
|
||||
}
|
||||
|
||||
enteredCommand = {
|
||||
argState: {},
|
||||
commandDefinition,
|
||||
argsWithValueSelectors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update parsed input with any values that were selected via argument selectors
|
||||
setArgSelectorValueToParsedArgs(parsedInput, enteredCommand);
|
||||
|
||||
return {
|
||||
...state,
|
||||
input: {
|
||||
...state.input,
|
||||
textEntered: newTextEntered,
|
||||
rightOfCursor: newRightOfCursor,
|
||||
commandEntered,
|
||||
leftOfCursorText: newTextEntered,
|
||||
rightOfCursorText: newRightOfCursor,
|
||||
parsedInput,
|
||||
enteredCommand,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -119,6 +185,38 @@ export const handleInputAreaState: ConsoleStoreReducer<InputAreaStateAction> = (
|
|||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'updateInputCommandArgState':
|
||||
if (state.input.enteredCommand) {
|
||||
const { name: argName, instance: argInstance, state: newArgState } = payload;
|
||||
const updatedArgState = [...(state.input.enteredCommand.argState[argName] ?? [])];
|
||||
|
||||
updatedArgState[argInstance] = newArgState;
|
||||
|
||||
const updatedEnteredCommand = {
|
||||
...state.input.enteredCommand,
|
||||
argState: {
|
||||
...state.input.enteredCommand.argState,
|
||||
[argName]: updatedArgState,
|
||||
},
|
||||
};
|
||||
|
||||
// store a new version of parsed input that contains the updated selector value
|
||||
const updatedParsedInput = parseCommandInput(
|
||||
state.input.leftOfCursorText + state.input.rightOfCursorText
|
||||
);
|
||||
setArgSelectorValueToParsedArgs(updatedParsedInput, updatedEnteredCommand);
|
||||
|
||||
return {
|
||||
...state,
|
||||
input: {
|
||||
...state.input,
|
||||
parsedInput: updatedParsedInput,
|
||||
enteredCommand: updatedEnteredCommand,
|
||||
},
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// No updates needed. Just return original state
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { ConsoleCodeBlock } from '../../console_code_block';
|
||||
|
||||
export const executionTranslations = Object.freeze({
|
||||
mustHaveValue: (argName: string): string => {
|
||||
return i18n.translate('xpack.securitySolution.console.commandValidation.mustHaveValue', {
|
||||
defaultMessage: 'Argument --{argName} must have a value',
|
||||
values: { argName },
|
||||
});
|
||||
},
|
||||
|
||||
mustBeNumber: (argName: string): string => {
|
||||
return i18n.translate('xpack.securitySolution.console.commandValidation.mustBeNumber', {
|
||||
defaultMessage: 'Argument --${argName} value must be a number',
|
||||
values: { argName },
|
||||
});
|
||||
},
|
||||
|
||||
mustBeGreaterThanZero: (argName: string): string => {
|
||||
return i18n.translate(
|
||||
'xpack.securitySolution.console.commandValidation.mustBeGreaterThanZero',
|
||||
{
|
||||
defaultMessage: 'Argument --{argName} value must be greater than zero',
|
||||
values: { argName },
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
NO_ARGUMENTS_SUPPORTED: i18n.translate(
|
||||
'xpack.securitySolution.console.commandValidation.noArgumentsSupported',
|
||||
{
|
||||
defaultMessage: 'Command does not support any arguments',
|
||||
}
|
||||
),
|
||||
|
||||
missingRequiredArg: (argName: string): string => {
|
||||
return i18n.translate('xpack.securitySolution.console.commandValidation.missingRequiredArg', {
|
||||
defaultMessage: 'Missing required argument: --{argName}',
|
||||
values: {
|
||||
argName,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
unsupportedArg: (argName: string): string => {
|
||||
return i18n.translate('xpack.securitySolution.console.commandValidation.unsupportedArg', {
|
||||
defaultMessage: 'Unsupported argument: --{argName}',
|
||||
values: { argName },
|
||||
});
|
||||
},
|
||||
|
||||
noMultiplesAllowed: (argName: string): string => {
|
||||
return i18n.translate('xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce', {
|
||||
defaultMessage: 'Argument can only be used once: --{argName}',
|
||||
values: { argName },
|
||||
});
|
||||
},
|
||||
|
||||
argValueValidatorError: (argName: string, error: string): string => {
|
||||
return i18n.translate('xpack.securitySolution.console.commandValidation.invalidArgValue', {
|
||||
defaultMessage: 'Invalid argument value: --{argName}. {error}',
|
||||
values: { argName, error },
|
||||
});
|
||||
},
|
||||
|
||||
missingArguments: (missingArgs: string): string => {
|
||||
return i18n.translate('xpack.securitySolution.console.commandValidation.mustHaveArgs', {
|
||||
defaultMessage: 'Missing required arguments: {missingArgs}',
|
||||
values: { missingArgs },
|
||||
});
|
||||
},
|
||||
|
||||
MUST_HAVE_AT_LEAST_ONE_ARG: i18n.translate(
|
||||
'xpack.securitySolution.console.commandValidation.oneArgIsRequired',
|
||||
{
|
||||
defaultMessage: 'At least one argument must be used',
|
||||
}
|
||||
),
|
||||
|
||||
onlyOneFromExclusiveOr: (argNames: string): ReactNode => {
|
||||
return (
|
||||
<ConsoleCodeBlock>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.console.commandValidation.exclusiveOr"
|
||||
defaultMessage="This command supports only one of the following arguments: {argNames}"
|
||||
values={{
|
||||
argNames: (
|
||||
<ConsoleCodeBlock bold inline>
|
||||
{argNames}
|
||||
</ConsoleCodeBlock>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</ConsoleCodeBlock>
|
||||
);
|
||||
},
|
||||
|
||||
unknownArgument: (count: number, commandName: string, unknownArgs: string): ReactNode => {
|
||||
return (
|
||||
<ConsoleCodeBlock>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.console.commandValidation.unknownArgument"
|
||||
defaultMessage="The following {command} {countOfInvalidArgs, plural, =1 {argument is} other {arguments are}} not supported by this command: {unknownArgs}"
|
||||
values={{
|
||||
countOfInvalidArgs: count,
|
||||
command: (
|
||||
<ConsoleCodeBlock bold inline>
|
||||
{commandName}
|
||||
</ConsoleCodeBlock>
|
||||
),
|
||||
unknownArgs: (
|
||||
<ConsoleCodeBlock bold inline>
|
||||
{unknownArgs}
|
||||
</ConsoleCodeBlock>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</ConsoleCodeBlock>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -5,9 +5,17 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { Dispatch, Reducer } from 'react';
|
||||
import type { ParsedCommandInterface } from '../../service/types';
|
||||
import type { CommandInputProps } from '../command_input';
|
||||
import type { Command, CommandDefinition, CommandExecutionComponent } from '../../types';
|
||||
import type {
|
||||
Command,
|
||||
CommandDefinition,
|
||||
CommandExecutionComponent,
|
||||
CommandArgDefinition,
|
||||
} from '../../types';
|
||||
|
||||
export interface ConsoleDataState {
|
||||
/**
|
||||
|
@ -48,17 +56,22 @@ export interface ConsoleDataState {
|
|||
/** state for the command input area */
|
||||
input: {
|
||||
/**
|
||||
* The text the user is typing into the console input area. By default, this
|
||||
* value goes into the left of the cursor position
|
||||
* The left side of the cursor text entered by the user
|
||||
*/
|
||||
textEntered: string; // FIXME:PT convert this to same structure as `rightOfCursor`
|
||||
leftOfCursorText: string;
|
||||
|
||||
rightOfCursor: {
|
||||
text: string;
|
||||
};
|
||||
/**
|
||||
* The right side of the cursor text entered by the user
|
||||
*/
|
||||
rightOfCursorText: string;
|
||||
|
||||
/** The command name that was entered (derived from `textEntered` */
|
||||
commandEntered: string;
|
||||
/**
|
||||
* The parsed user input
|
||||
*/
|
||||
parsedInput: ParsedCommandInterface;
|
||||
|
||||
/** The entered command. Only defined if the command is "known" */
|
||||
enteredCommand: undefined | EnteredCommand;
|
||||
|
||||
/** Placeholder text for the input area **/
|
||||
placeholder: string;
|
||||
|
@ -74,9 +87,36 @@ export interface ConsoleDataState {
|
|||
};
|
||||
}
|
||||
|
||||
/** State that is provided/received to Argument Value Selectors */
|
||||
export interface ArgSelectorState<TState = any> {
|
||||
value: any;
|
||||
valueText: string | undefined;
|
||||
/**
|
||||
* A store (data) for the Argument Selector Component so that it can persist state between
|
||||
* re-renders or between console being opened/closed
|
||||
*/
|
||||
store?: TState;
|
||||
}
|
||||
|
||||
export interface EnteredCommand {
|
||||
commandDefinition: CommandDefinition;
|
||||
|
||||
/** keeps a list of arguments definitions that are defined with a Value Selector component */
|
||||
argsWithValueSelectors: undefined | Record<string, CommandArgDefinition>;
|
||||
|
||||
argState: {
|
||||
// Each arg has an array (just like the parsed input) and keeps the
|
||||
// state that is provided to that instance of the argument on the input.
|
||||
[argName: string]: ArgSelectorState[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface InputHistoryItem {
|
||||
id: string;
|
||||
/** The command that will be used internally if entry is selected again from the popup */
|
||||
input: string;
|
||||
/** The display value in the UI's input history popup */
|
||||
display: string;
|
||||
}
|
||||
|
||||
export interface CommandHistoryItem {
|
||||
|
@ -92,11 +132,20 @@ export interface CommandExecutionState {
|
|||
store: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExecuteCommandPayload {
|
||||
input: string;
|
||||
parsedInput: ParsedCommandInterface;
|
||||
enteredCommand: ConsoleDataState['input']['enteredCommand'];
|
||||
}
|
||||
|
||||
export type ConsoleDataAction =
|
||||
| { type: 'scrollDown' }
|
||||
| { type: 'addFocusToKeyCapture' }
|
||||
| { type: 'removeFocusFromKeyCapture' }
|
||||
| { type: 'executeCommand'; payload: { input: string } }
|
||||
| {
|
||||
type: 'executeCommand';
|
||||
payload: ExecuteCommandPayload;
|
||||
}
|
||||
| { type: 'clear' }
|
||||
| {
|
||||
type: 'showSidePanel';
|
||||
|
@ -119,11 +168,13 @@ export type ConsoleDataAction =
|
|||
}
|
||||
| {
|
||||
type: 'updateInputTextEnteredState';
|
||||
payload: PayloadValueOrFunction<{
|
||||
textEntered: string;
|
||||
/** When omitted, the right side of the cursor value will be blanked out */
|
||||
rightOfCursor?: ConsoleDataState['input']['rightOfCursor'];
|
||||
}>;
|
||||
payload: PayloadValueOrFunction<
|
||||
Pick<ConsoleDataState['input'], 'leftOfCursorText' | 'rightOfCursorText'> & {
|
||||
/** updates (if necessary) to any of the argument's state */
|
||||
argState?: Record<string, ArgSelectorState[]>;
|
||||
},
|
||||
ConsoleDataState['input']
|
||||
>;
|
||||
}
|
||||
| {
|
||||
type: 'updateInputPopoverState';
|
||||
|
@ -146,7 +197,21 @@ export type ConsoleDataAction =
|
|||
| {
|
||||
type: 'updateInputHistoryState';
|
||||
payload: {
|
||||
/** The command that will be used internally if entry is selected again from the popup */
|
||||
command: string;
|
||||
/** The display value in the UI's input history popup. Defaults to `command` */
|
||||
display?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'updateInputCommandArgState';
|
||||
payload: {
|
||||
/** Name of argument */
|
||||
name: string;
|
||||
/** Instance of the argument */
|
||||
instance: number;
|
||||
/** The updated state for the argument */
|
||||
state: ArgSelectorState;
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
@ -154,7 +219,9 @@ export type ConsoleDataAction =
|
|||
payload?: never;
|
||||
};
|
||||
|
||||
type PayloadValueOrFunction<T extends object = object> = T | ((options: Required<T>) => T);
|
||||
type PayloadValueOrFunction<T extends object = object, TCallbackArgs extends object = object> =
|
||||
| T
|
||||
| ((options: TCallbackArgs) => T);
|
||||
|
||||
export interface ConsoleStore {
|
||||
state: ConsoleDataState;
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useConsoleStore } from '../../components/console_state/console_state';
|
||||
import type { ArgSelectorState } from '../../components/console_state/types';
|
||||
|
||||
/**
|
||||
* Returns the Command argument state for a given argument name. Should be used ONLY when a
|
||||
* command has been entered that matches a `CommandDefinition`
|
||||
* @param argName
|
||||
* @param instance
|
||||
*/
|
||||
export const useWithCommandArgumentState = (
|
||||
argName: string,
|
||||
instance: number
|
||||
): ArgSelectorState => {
|
||||
const enteredCommand = useConsoleStore().state.input.enteredCommand;
|
||||
|
||||
return useMemo(() => {
|
||||
const argInstanceState = enteredCommand?.argState[argName]?.at(instance);
|
||||
|
||||
return (
|
||||
argInstanceState ?? {
|
||||
value: undefined,
|
||||
valueText: '',
|
||||
}
|
||||
);
|
||||
}, [argName, enteredCommand, instance]);
|
||||
};
|
|
@ -6,8 +6,13 @@
|
|||
*/
|
||||
|
||||
import { useConsoleStore } from '../../components/console_state/console_state';
|
||||
import type { ConsoleDataState } from '../../components/console_state/types';
|
||||
|
||||
export const useWithInputCommandEntered = (): ConsoleDataState['input']['commandEntered'] => {
|
||||
return useConsoleStore().state.input.commandEntered;
|
||||
/**
|
||||
* Retrieves the command name from the text the user entered. Will only return a value if a space
|
||||
* has been entered, which is the trigger to being able to actually parse out the command name
|
||||
*/
|
||||
export const useWithInputCommandEntered = (): string => {
|
||||
const parsedInput = useConsoleStore().state.input.parsedInput;
|
||||
|
||||
return parsedInput.input.trimStart().indexOf(' ') !== -1 ? parsedInput.name : '';
|
||||
};
|
||||
|
|
|
@ -9,18 +9,24 @@ import { useMemo } from 'react';
|
|||
import { useConsoleStore } from '../../components/console_state/console_state';
|
||||
import type { ConsoleDataState } from '../../components/console_state/types';
|
||||
|
||||
type InputTextEntered = Pick<ConsoleDataState['input'], 'textEntered' | 'rightOfCursor'> & {
|
||||
type InputTextEntered = Pick<
|
||||
ConsoleDataState['input'],
|
||||
'leftOfCursorText' | 'rightOfCursorText' | 'parsedInput' | 'enteredCommand'
|
||||
> & {
|
||||
fullTextEntered: string;
|
||||
};
|
||||
|
||||
export const useWithInputTextEntered = (): InputTextEntered => {
|
||||
const inputState = useConsoleStore().state.input;
|
||||
const { leftOfCursorText, rightOfCursorText, parsedInput, enteredCommand } =
|
||||
useConsoleStore().state.input;
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
textEntered: inputState.textEntered,
|
||||
rightOfCursor: inputState.rightOfCursor,
|
||||
fullTextEntered: inputState.textEntered + inputState.rightOfCursor.text,
|
||||
leftOfCursorText,
|
||||
rightOfCursorText,
|
||||
parsedInput,
|
||||
enteredCommand,
|
||||
fullTextEntered: leftOfCursorText + rightOfCursorText,
|
||||
};
|
||||
}, [inputState.rightOfCursor, inputState.textEntered]);
|
||||
}, [enteredCommand, leftOfCursorText, parsedInput, rightOfCursorText]);
|
||||
};
|
||||
|
|
|
@ -7,12 +7,17 @@
|
|||
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { memo, useEffect } from 'react';
|
||||
import { EuiCode } from '@elastic/eui';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { act } from '@testing-library/react';
|
||||
import { Console } from './console';
|
||||
import type { ConsoleProps, CommandDefinition, CommandExecutionComponent } from './types';
|
||||
import type {
|
||||
ConsoleProps,
|
||||
CommandDefinition,
|
||||
CommandExecutionComponent,
|
||||
CommandArgumentValueSelectorProps,
|
||||
} from './types';
|
||||
import type { AppContextTestRender } from '../../../common/mock/endpoint';
|
||||
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
|
||||
|
||||
|
@ -236,7 +241,35 @@ export const getCommandListMock = (): CommandDefinition[] => {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cmd7',
|
||||
about: 'Command with argument selector',
|
||||
RenderComponent: jest.fn(RenderComponent),
|
||||
args: {
|
||||
foo: {
|
||||
about: 'foo stuff',
|
||||
required: true,
|
||||
allowMultiples: true,
|
||||
SelectorComponent: ArgumentSelectorComponentMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return commands;
|
||||
};
|
||||
|
||||
export const ArgumentSelectorComponentMock = memo<
|
||||
CommandArgumentValueSelectorProps<{ selection: string }>
|
||||
>(({ value, valueText, onChange, argName, argIndex }) => {
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
onChange({ valueText: 'foo selected', value: { selection: 'foo' } });
|
||||
}
|
||||
}, [onChange, value]);
|
||||
|
||||
return (
|
||||
<span data-test-subj="argSelectorValueText">{`${argName}[${argIndex}]: ${valueText}`}</span>
|
||||
);
|
||||
});
|
||||
ArgumentSelectorComponentMock.displayName = 'ArgumentSelectorComponentMock';
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ParsedCommandInterface } from './parsed_command_input';
|
||||
import { parseCommandInput } from './parsed_command_input';
|
||||
import type { ParsedCommandInterface } from './types';
|
||||
|
||||
describe('when using parsed command input utils', () => {
|
||||
describe('when using parseCommandInput()', () => {
|
||||
|
|
|
@ -5,22 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { ParsedCommandInput, ParsedCommandInterface } from './types';
|
||||
import type { CommandDefinition } from '..';
|
||||
|
||||
export type PossibleArgDataTypes = string | boolean;
|
||||
|
||||
export type ParsedArgData<T = PossibleArgDataTypes> = Array<
|
||||
T extends PossibleArgDataTypes ? T : never
|
||||
>;
|
||||
|
||||
interface ParsedCommandInput<TArgs extends object = any> {
|
||||
name: string;
|
||||
args: {
|
||||
[key in keyof TArgs]: ParsedArgData<Required<TArgs>[key]>;
|
||||
};
|
||||
}
|
||||
const parseInputString = (rawInput: string): ParsedCommandInput => {
|
||||
const input = rawInput.trim();
|
||||
const response: ParsedCommandInput = {
|
||||
|
@ -89,22 +76,6 @@ const parseInputString = (rawInput: string): ParsedCommandInput => {
|
|||
return response;
|
||||
};
|
||||
|
||||
export interface ParsedCommandInterface<TArgs extends object = any>
|
||||
extends ParsedCommandInput<TArgs> {
|
||||
input: string;
|
||||
|
||||
/**
|
||||
* Checks if the given argument name was entered by the user
|
||||
* @param argName
|
||||
*/
|
||||
hasArg(argName: string): boolean;
|
||||
|
||||
/**
|
||||
* if any argument was entered
|
||||
*/
|
||||
hasArgs: boolean;
|
||||
}
|
||||
|
||||
class ParsedCommand implements ParsedCommandInterface {
|
||||
public readonly name: string;
|
||||
public readonly args: Record<string, string[]>;
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type PossibleArgDataTypes = string | boolean;
|
||||
export type ParsedArgData<T = PossibleArgDataTypes> = Array<
|
||||
T extends PossibleArgDataTypes ? T : never
|
||||
>;
|
||||
|
||||
export interface ParsedCommandInput<TArgs extends object = any> {
|
||||
name: string;
|
||||
args: {
|
||||
[key in keyof TArgs]: ParsedArgData<Required<TArgs>[key]>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParsedCommandInterface<TArgs extends object = any>
|
||||
extends ParsedCommandInput<TArgs> {
|
||||
input: string;
|
||||
|
||||
/**
|
||||
* Checks if the given argument name was entered by the user
|
||||
* @param argName
|
||||
*/
|
||||
hasArg(argName: string): boolean;
|
||||
|
||||
/**
|
||||
* if any argument was entered
|
||||
*/
|
||||
hasArgs: boolean;
|
||||
}
|
|
@ -9,41 +9,69 @@
|
|||
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import type { CommonProps } from '@elastic/eui';
|
||||
import type { ParsedArgData, ParsedCommandInterface, PossibleArgDataTypes } from './service/types';
|
||||
import type { CommandExecutionResultComponent } from './components/command_execution_result';
|
||||
import type { CommandExecutionState } from './components/console_state/types';
|
||||
import type { CommandExecutionState, ArgSelectorState } from './components/console_state/types';
|
||||
import type { Immutable, MaybeImmutable } from '../../../../common/endpoint/types';
|
||||
import type {
|
||||
ParsedArgData,
|
||||
ParsedCommandInterface,
|
||||
PossibleArgDataTypes,
|
||||
} from './service/parsed_command_input';
|
||||
|
||||
/**
|
||||
* Definition interface for a Command argument
|
||||
*/
|
||||
export interface CommandArgDefinition {
|
||||
/**
|
||||
* If the argument is required to be entered by the user. NOTE that this will only validate that
|
||||
* the user has entered the argument name - it does not validate that the argument must have a
|
||||
* value. Arguments that have no value entered by the user have (by default) a value of
|
||||
* `true` boolean.
|
||||
*/
|
||||
required: boolean;
|
||||
allowMultiples: boolean;
|
||||
about: string;
|
||||
/**
|
||||
* If argument (when used) should have a value defined by the user.
|
||||
* Default is `false` which mean that argument can be entered without any value - internally the
|
||||
* value for the argument will be a boolean `true`.
|
||||
* When set to `true` the argument is expected to have a value that is non-boolean
|
||||
* In addition, the following options can be used with this parameter to further validate the user's input:
|
||||
*
|
||||
* - `non-empty-string`: user's value must be a string whose length is greater than zero. Note that
|
||||
* the value entered will first be `trim()`'d.
|
||||
* - `number`: user's value will be converted to a Number and ensured to be a `safe integer`
|
||||
* - `number-greater-than-zero`: user's value must be a number greater than zero
|
||||
*/
|
||||
mustHaveValue?: boolean | 'non-empty-string' | 'number' | 'number-greater-than-zero';
|
||||
exclusiveOr?: boolean;
|
||||
/**
|
||||
* Validate the individual values given to this argument.
|
||||
* Should return `true` if valid or a string with the error message
|
||||
*/
|
||||
validate?: (argData: ParsedArgData) => true | string;
|
||||
|
||||
/**
|
||||
* If defined, the provided Component will be rendered in place of this argument's value and
|
||||
* it will be up to the Selector to provide the desired interface to the user for selecting
|
||||
* the argument's value.
|
||||
*/
|
||||
SelectorComponent?: CommandArgumentValueSelectorComponent;
|
||||
}
|
||||
|
||||
/** List of arguments for a Command */
|
||||
export interface CommandArgs {
|
||||
[longName: string]: {
|
||||
required: boolean;
|
||||
allowMultiples: boolean;
|
||||
exclusiveOr?: boolean;
|
||||
about: string;
|
||||
/**
|
||||
* Validate the individual values given to this argument.
|
||||
* Should return `true` if valid or a string with the error message
|
||||
*/
|
||||
validate?: (argData: ParsedArgData) => true | string;
|
||||
|
||||
// Selector: Idea is that the schema can plugin in a rich component for the
|
||||
// user to select something (ex. a file)
|
||||
// FIXME: implement selector
|
||||
selector?: ComponentType;
|
||||
};
|
||||
[longName: string]: CommandArgDefinition;
|
||||
}
|
||||
|
||||
export interface CommandDefinition<TMeta = any> {
|
||||
/** Name of the command. This will be the value that the user will enter on the console to access this command */
|
||||
name: string;
|
||||
|
||||
/** Some information about the command */
|
||||
about: ReactNode;
|
||||
|
||||
/**
|
||||
* The Component that will be used to render the Command
|
||||
*/
|
||||
RenderComponent: CommandExecutionComponent;
|
||||
|
||||
/** Will be used to sort the commands when building the output for the `help` command */
|
||||
helpCommandPosition?: number;
|
||||
|
||||
|
@ -57,14 +85,17 @@ export interface CommandDefinition<TMeta = any> {
|
|||
* the console's built in output.
|
||||
*/
|
||||
HelpComponent?: CommandExecutionComponent;
|
||||
|
||||
/**
|
||||
* If defined, the button to add to the text bar will be disabled and the user will not be able to use this command if entered into the console.
|
||||
*/
|
||||
helpDisabled?: boolean;
|
||||
|
||||
/**
|
||||
* If defined, the command will be hidden from in the Help menu and help text. It will warn the user and not execute the command if manually typed in.
|
||||
*/
|
||||
helpHidden?: boolean;
|
||||
|
||||
/**
|
||||
* A store for any data needed when the command is executed.
|
||||
* The entire `CommandDefinition` is passed along to the component
|
||||
|
@ -116,6 +147,11 @@ export interface Command<
|
|||
> {
|
||||
/** The raw input entered by the user */
|
||||
input: string;
|
||||
/**
|
||||
* The input value for display on the UI. This could differ from
|
||||
* `input` when Argument Value Selectors were used.
|
||||
*/
|
||||
inputDisplay: string;
|
||||
/** An object with the arguments entered by the user and their value */
|
||||
args: ParsedCommandInterface<TArgs>;
|
||||
/** The command definition associated with this user command */
|
||||
|
@ -177,6 +213,55 @@ export type CommandExecutionComponent<
|
|||
TMeta = any
|
||||
> = ComponentType<CommandExecutionComponentProps<TArgs, TStore, TMeta>>;
|
||||
|
||||
/**
|
||||
* The component props for an argument `SelectorComponent`
|
||||
*/
|
||||
export interface CommandArgumentValueSelectorProps<TSelection = any, TState = any> {
|
||||
/**
|
||||
* The current value that was selected. This will not be displayed in the UI, but will
|
||||
* be passed on to the command execution as part of the argument's value
|
||||
*/
|
||||
value: TSelection | undefined;
|
||||
|
||||
/**
|
||||
* A string value for display purposes only that describes the selected value. This
|
||||
* will be used when the command is entered and displayed in the console as well as in
|
||||
* the command input history popover
|
||||
*/
|
||||
valueText: string;
|
||||
|
||||
/**
|
||||
* The name of the Argument
|
||||
*/
|
||||
argName: string;
|
||||
|
||||
/**
|
||||
* The index (zero based) of the argument in the current command. This is a zero-based number indicating
|
||||
* which instance of the argument is being rendered.
|
||||
*/
|
||||
argIndex: number;
|
||||
|
||||
/**
|
||||
* A store for the Argument Selector. Should be used for any component state that needs to be
|
||||
* persisted across re-renders by the console.
|
||||
*/
|
||||
store: TState;
|
||||
|
||||
/**
|
||||
* callback for the Value Selector to call and provide the selection value.
|
||||
* This selection value will then be passed along with the argument to the command execution
|
||||
* component.
|
||||
* @param newData
|
||||
*/
|
||||
onChange: (newData: ArgSelectorState<TState>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for rendering an argument's value selector
|
||||
*/
|
||||
export type CommandArgumentValueSelectorComponent =
|
||||
ComponentType<CommandArgumentValueSelectorProps>;
|
||||
|
||||
export interface ConsoleProps extends CommonProps {
|
||||
/**
|
||||
* The list of Commands that will be available in the console for the user to execute
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiFilePicker,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPopover,
|
||||
htmlIdGenerator,
|
||||
} from '@elastic/eui';
|
||||
import type { EuiFilePickerProps } from '@elastic/eui/src/components/form/file_picker/file_picker';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { CommandArgumentValueSelectorProps } from '../console/types';
|
||||
|
||||
const INITIAL_DISPLAY_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.consoleArgumentSelectors.fileSelector.initialDisplayLabel',
|
||||
{ defaultMessage: 'Click to select file' }
|
||||
);
|
||||
|
||||
const OPEN_FILE_PICKER_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.consoleArgumentSelectors.fileSelector.filePickerButtonLabel',
|
||||
{ defaultMessage: 'Open file picker' }
|
||||
);
|
||||
|
||||
const NO_FILE_SELECTED = i18n.translate(
|
||||
'xpack.securitySolution.consoleArgumentSelectors.fileSelector.noFileSelected',
|
||||
{ defaultMessage: 'No file selected' }
|
||||
);
|
||||
|
||||
interface ArgumentFileSelectorState {
|
||||
isPopoverOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Console Argument Selector component that enables the user to select a file from the local machine
|
||||
*/
|
||||
export const ArgumentFileSelector = memo<
|
||||
CommandArgumentValueSelectorProps<File, ArgumentFileSelectorState>
|
||||
>(({ value, valueText, onChange, store: _store }) => {
|
||||
const state = useMemo<ArgumentFileSelectorState>(() => {
|
||||
return _store ?? { isPopoverOpen: true };
|
||||
}, [_store]);
|
||||
|
||||
const setIsPopoverOpen = useCallback(
|
||||
(newValue: boolean) => {
|
||||
onChange({
|
||||
value,
|
||||
valueText,
|
||||
store: {
|
||||
...state,
|
||||
isPopoverOpen: newValue,
|
||||
},
|
||||
});
|
||||
},
|
||||
[onChange, state, value, valueText]
|
||||
);
|
||||
|
||||
const filePickerUUID = useMemo(() => {
|
||||
return htmlIdGenerator('console')();
|
||||
}, []);
|
||||
|
||||
const handleOpenPopover = useCallback(() => {
|
||||
setIsPopoverOpen(true);
|
||||
}, [setIsPopoverOpen]);
|
||||
|
||||
const handleClosePopover = useCallback(() => {
|
||||
setIsPopoverOpen(false);
|
||||
}, [setIsPopoverOpen]);
|
||||
|
||||
const handleFileSelection: EuiFilePickerProps['onChange'] = useCallback(
|
||||
(selectedFiles) => {
|
||||
// Get only the first file selected
|
||||
const file = selectedFiles?.item(0);
|
||||
|
||||
onChange({
|
||||
value: file ?? undefined,
|
||||
valueText: file ? file.name : '',
|
||||
store: {
|
||||
...state,
|
||||
isPopoverOpen: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
[onChange, state]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EuiPopover
|
||||
isOpen={state.isPopoverOpen}
|
||||
closePopover={handleClosePopover}
|
||||
anchorPosition="upCenter"
|
||||
initialFocus={`#${filePickerUUID}`}
|
||||
anchorClassName="popoverAnchor"
|
||||
button={
|
||||
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="none">
|
||||
<EuiFlexItem grow={false} className="eui-textTruncate" onClick={handleOpenPopover}>
|
||||
<div className="eui-textTruncate" title={valueText || NO_FILE_SELECTED}>
|
||||
{valueText || INITIAL_DISPLAY_LABEL}
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
iconType="folderOpen"
|
||||
size="xs"
|
||||
onClick={handleOpenPopover}
|
||||
aria-label={OPEN_FILE_PICKER_LABEL}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
>
|
||||
{state.isPopoverOpen && (
|
||||
<EuiFilePicker
|
||||
id={filePickerUUID}
|
||||
onChange={handleFileSelection}
|
||||
fullWidth
|
||||
display="large"
|
||||
/>
|
||||
)}
|
||||
</EuiPopover>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ArgumentFileSelector.displayName = 'ArgumentFileSelector';
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
export * from './file_selector';
|
|
@ -6,6 +6,8 @@
|
|||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { ParsedArgData } from '../../console/service/types';
|
||||
import { getUploadCommand } from './dev_only';
|
||||
import { ExperimentalFeaturesService } from '../../../../common/experimental_features_service';
|
||||
import type {
|
||||
EndpointCapabilities,
|
||||
|
@ -19,7 +21,6 @@ import { KillProcessActionResult } from '../command_render_components/kill_proce
|
|||
import { SuspendProcessActionResult } from '../command_render_components/suspend_process_action';
|
||||
import { EndpointStatusActionResult } from '../command_render_components/status_action';
|
||||
import { GetProcessesActionResult } from '../command_render_components/get_processes_action';
|
||||
import type { ParsedArgData } from '../../console/service/parsed_command_input';
|
||||
import type { EndpointPrivileges, ImmutableArray } from '../../../../../common/endpoint/types';
|
||||
import {
|
||||
INSUFFICIENT_PRIVILEGES_FOR_COMMAND,
|
||||
|
@ -370,6 +371,14 @@ export const getEndpointConsoleCommands = ({
|
|||
},
|
||||
];
|
||||
|
||||
// FIXME: DELETE PRIOR TO MERGE
|
||||
// for dev purposes only - command only shown if url has `show_upload=`
|
||||
if (location.search.includes('show_upload=')) {
|
||||
consoleCommands.push(
|
||||
getUploadCommand({ endpointAgentId, endpointPrivileges, endpointCapabilities })
|
||||
);
|
||||
}
|
||||
|
||||
// `get-file` is currently behind feature flag
|
||||
if (isGetFileEnabled) {
|
||||
consoleCommands.push({
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import React, { memo, useEffect } from 'react';
|
||||
import type { CommandArgumentValueSelectorProps } from '../../console/types';
|
||||
import type { CommandDefinition } from '../../console';
|
||||
import { ArgumentFileSelector } from '../../console_argument_selectors';
|
||||
|
||||
// FOR DEV PURPOSES ONLY. WILL BE DELETED PRIOR TO MERGE
|
||||
// FIXME:PT DELETE FILE
|
||||
export const getUploadCommand = ({
|
||||
endpointAgentId,
|
||||
endpointPrivileges,
|
||||
endpointCapabilities,
|
||||
}: {
|
||||
endpointAgentId: string;
|
||||
endpointCapabilities: any;
|
||||
endpointPrivileges: any;
|
||||
}): CommandDefinition => {
|
||||
return {
|
||||
name: 'upload',
|
||||
about: 'Upload and execute a file on host machine',
|
||||
RenderComponent: (props) => {
|
||||
window.console.log(`upload command rendering...`);
|
||||
window.console.log(props);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{`ExecuteFileAction DEV MOCK`}</div>
|
||||
<div>
|
||||
<strong>{`File Selected: ${props.command.args.args.file[0].name}`}</strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
endpointId: endpointAgentId,
|
||||
capabilities: endpointCapabilities,
|
||||
privileges: endpointPrivileges,
|
||||
},
|
||||
exampleUsage: 'some example goes here',
|
||||
exampleInstruction: 'some instructions here',
|
||||
args: {
|
||||
file: {
|
||||
about: 'Select the file that should be uploaded and executed',
|
||||
required: true,
|
||||
allowMultiples: false,
|
||||
mustHaveValue: true,
|
||||
validate: () => {
|
||||
// FIXME:PT Validate File was selected
|
||||
return true;
|
||||
},
|
||||
SelectorComponent: ArgumentFileSelector,
|
||||
},
|
||||
|
||||
n: {
|
||||
required: false,
|
||||
allowMultiples: true,
|
||||
mustHaveValue: 'number-greater-than-zero',
|
||||
about: 'just a number greater than zero',
|
||||
},
|
||||
|
||||
nn: {
|
||||
required: false,
|
||||
allowMultiples: true,
|
||||
mustHaveValue: 'number',
|
||||
about: 'just a number',
|
||||
},
|
||||
|
||||
mock: {
|
||||
required: false,
|
||||
allowMultiples: false,
|
||||
about: 'using a selector',
|
||||
SelectorComponent: ArgumentSelectorComponentTest,
|
||||
},
|
||||
|
||||
comment: {
|
||||
required: false,
|
||||
allowMultiples: false,
|
||||
mustHaveValue: 'non-empty-string',
|
||||
about: 'A comment',
|
||||
},
|
||||
},
|
||||
helpGroupLabel: 'DEV',
|
||||
helpGroupPosition: 0,
|
||||
helpCommandPosition: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const ArgumentSelectorComponentTest = memo<
|
||||
CommandArgumentValueSelectorProps<{ selection: string }>
|
||||
>(({ value, valueText, onChange, argIndex, argName }) => {
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
onChange({ valueText: 'foo selected', value: { selection: 'foo' } });
|
||||
}
|
||||
}, [onChange, value]);
|
||||
|
||||
return (
|
||||
<span data-test-subj="argSelectorValueText">{`${argName}[${argIndex}]: ${valueText}`}</span>
|
||||
);
|
||||
});
|
||||
ArgumentSelectorComponentTest.displayName = 'ArgumentSelectorComponentTest';
|
||||
|
||||
document.body.classList.add('style2');
|
|
@ -26737,7 +26737,6 @@
|
|||
"xpack.securitySolution.console.commandValidation.exclusiveOr": "Cette commande ne prend en charge qu'un seul des arguments suivants : {argNames}",
|
||||
"xpack.securitySolution.console.commandValidation.invalidArgValue": "Valeur d'argument non valide : {argName}. {error}",
|
||||
"xpack.securitySolution.console.commandValidation.missingRequiredArg": "Argument requis manquant : {argName}",
|
||||
"xpack.securitySolution.console.commandValidation.mustHaveArgs": "Arguments requis manquants : {requiredArgs}",
|
||||
"xpack.securitySolution.console.commandValidation.unknownArgument": "{countOfInvalidArgs, plural, =1 {Argument} other {Arguments}} de {command} non pris en charge par cette commande : {unknownArgs}",
|
||||
"xpack.securitySolution.console.commandValidation.unsupportedArg": "Argument non pris en charge : {argName}",
|
||||
"xpack.securitySolution.console.sidePanel.helpDescription": "Utilisez le bouton Ajouter ({icon}) pour insérer une action de réponse dans la barre de texte. Le cas échéant, ajoutez des paramètres ou commentaires supplémentaires.",
|
||||
|
|
|
@ -26713,7 +26713,6 @@
|
|||
"xpack.securitySolution.console.commandValidation.exclusiveOr": "このコマンドは次の引数のいずれかのみをサポートします:{argNames}",
|
||||
"xpack.securitySolution.console.commandValidation.invalidArgValue": "無効な引数値:{argName}。{error}",
|
||||
"xpack.securitySolution.console.commandValidation.missingRequiredArg": "不足している必須の引数:{argName}",
|
||||
"xpack.securitySolution.console.commandValidation.mustHaveArgs": "不足している必須の引数:{requiredArgs}",
|
||||
"xpack.securitySolution.console.commandValidation.unknownArgument": "次の{command} {countOfInvalidArgs, plural, other {引数}}はこのコマンドでサポートされていません:{unknownArgs}",
|
||||
"xpack.securitySolution.console.commandValidation.unsupportedArg": "サポートされていない引数:{argName}",
|
||||
"xpack.securitySolution.console.sidePanel.helpDescription": "追加({icon})ボタンを使用して、テキストバーに対応アクションを入力します。必要に応じて、パラメーターまたはコメントを追加します。",
|
||||
|
|
|
@ -26745,7 +26745,6 @@
|
|||
"xpack.securitySolution.console.commandValidation.exclusiveOr": "此命令只支持以下参数之一:{argNames}",
|
||||
"xpack.securitySolution.console.commandValidation.invalidArgValue": "无效的参数值:{argName}。{error}",
|
||||
"xpack.securitySolution.console.commandValidation.missingRequiredArg": "缺少所需参数:{argName}",
|
||||
"xpack.securitySolution.console.commandValidation.mustHaveArgs": "缺少所需参数:{requiredArgs}",
|
||||
"xpack.securitySolution.console.commandValidation.unknownArgument": "此命令不支持以下 {command} {countOfInvalidArgs, plural, other {参数}}:{unknownArgs}",
|
||||
"xpack.securitySolution.console.commandValidation.unsupportedArg": "不支持的参数:{argName}",
|
||||
"xpack.securitySolution.console.sidePanel.helpDescription": "使用添加 ({icon}) 按钮将响应操作填充到文本栏。在必要时添加其他参数或注释。",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue