Skip to content

Commit

Permalink
[Security Solution][Endpoint] Response Console framework support for …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
paul-tavares authored Jan 25, 2023
1 parent cdab97b commit 9ac065a
Show file tree
Hide file tree
Showing 32 changed files with 1,835 additions and 348 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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) */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -88,13 +96,17 @@ export interface CommandInputProps extends CommonProps {

export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ...commonProps }) => {
useInputHints();
const getTestId = useTestIdGenerator(useDataTestSubj());
const dispatch = useConsoleStateDispatch();
const { rightOfCursor, textEntered, fullTextEntered } = useWithInputTextEntered();
const { rightOfCursorText, leftOfCursorText, fullTextEntered, enteredCommand, parsedInput } =
useWithInputTextEntered();
const visibleState = useWithInputVisibleState();
const [isKeyInputBeingCaptured, setIsKeyInputBeingCaptured] = useState(false);
const getTestId = useTestIdGenerator(useDataTestSubj());
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);
Expand All @@ -111,22 +123,23 @@ 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`);
}
}, []);

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) => {
Expand Down Expand Up @@ -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);

Expand All @@ -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
Expand All @@ -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(),
};
},
});
Expand All @@ -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]);

Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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: '' } });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Loading

0 comments on commit 9ac065a

Please sign in to comment.