[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:
Paul Tavares 2023-01-25 16:05:57 -05:00 committed by GitHub
parent cdab97bd47
commit 9ac065ab02
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1836 additions and 349 deletions

View file

@ -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) */}

View file

@ -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>

View file

@ -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';

View file

@ -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: '' } });

View file

@ -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(() => {

View file

@ -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]);
};

View file

@ -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 ');
});
});
});

View file

@ -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);
}
);
}
);
});

View file

@ -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);
}
}
}

View file

@ -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;
}
}

View file

@ -124,7 +124,8 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
type: 'updateInputTextEnteredState',
payload: () => {
return {
textEntered: text,
leftOfCursorText: text,
rightOfCursorText: '',
};
},
});

View file

@ -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;
};

View file

@ -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;

View file

@ -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

View file

@ -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

View file

@ -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>
);
},
});

View file

@ -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;

View file

@ -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]);
};

View file

@ -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 : '';
};

View file

@ -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]);
};

View file

@ -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';

View file

@ -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()', () => {

View file

@ -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[]>;

View file

@ -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;
}

View file

@ -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

View file

@ -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';

View file

@ -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';

View file

@ -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({

View file

@ -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');

View file

@ -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.",

View file

@ -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})ボタンを使用して、テキストバーに対応アクションを入力します。必要に応じて、パラメーターまたはコメントを追加します。",

View file

@ -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}) 按钮将响应操作填充到文本栏。在必要时添加其他参数或注释。",