From 9ac065ab02ba729f5b44117c9f95e8ddd55866fe Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 25 Jan 2023 16:05:57 -0500 Subject: [PATCH] [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. --- .../components/command_execution_output.tsx | 2 +- .../command_input/command_input.tsx | 94 +++-- .../components/argument_selector_wrapper.tsx | 142 ++++++++ .../components/command_input_history.tsx | 19 +- .../components/input_placeholder.tsx | 4 + .../command_input/hooks/use_input_hints.ts | 6 +- .../integration_tests/command_input.test.tsx | 35 ++ .../command_input/lib/entered_input.test.tsx | 322 ++++++++++++++++++ .../command_input/lib/entered_input.ts | 98 ------ .../command_input/lib/entered_input.tsx | 295 ++++++++++++++++ .../console/components/command_list.tsx | 3 +- .../hooks/use_stored_input_history.ts | 28 +- .../components/console_state/state_reducer.ts | 11 +- .../handle_execute_command.tsx | 189 +++++----- .../handle_input_area_state.ts | 144 ++++++-- .../state_update_handlers/translations.tsx | 131 +++++++ .../console/components/console_state/types.ts | 99 +++++- .../use_with_command_argument_state.ts | 34 ++ .../use_with_input_command_entered.ts | 11 +- .../use_with_input_text_entered.ts | 18 +- .../management/components/console/mocks.tsx | 37 +- .../service/parse_command_input.test.ts | 2 +- .../console/service/parsed_command_input.ts | 31 +- .../components/console/service/types.ts | 36 ++ .../management/components/console/types.ts | 129 +++++-- .../file_selector.tsx | 131 +++++++ .../console_argument_selectors/index.ts | 7 + .../lib/console_commands_definition.ts | 11 +- .../endpoint_responder/lib/dev_only.tsx | 111 ++++++ .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 32 files changed, 1835 insertions(+), 348 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/argument_selector_wrapper.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/translations.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_command_argument_state.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console/service/types.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/console_argument_selectors/file_selector.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console_argument_selectors/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/dev_only.tsx diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx index 4ebf075004b89..7f5d2a638e59c 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_execution_output.tsx @@ -92,7 +92,7 @@ export const CommandExecutionOutput = memo( return (
- +
{/* UX desire for 12px (current theme): achieved with EuiSpace sizes - s (8px) + xs (4px) */} diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx index 0310b0251d5d0..8aeaf9a4c0043 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/command_input.tsx @@ -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(({ 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( + undefined + ); const [popoverWidth, setPopoverWidth] = useState('94vw'); const _focusRef: InputCaptureProps['focusRef'] = useRef(null); @@ -111,6 +123,10 @@ export const CommandInput = memo(({ 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(({ width }) => { if (width > 0) { setPopoverWidth(`${width}px`); @@ -118,15 +134,12 @@ export const CommandInput = memo(({ prompt = '', focusRef, .. }, []); const handleSubmitButton = useCallback(() => { - 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>( (hasFocus) => { @@ -163,8 +176,18 @@ export const CommandInput = memo(({ 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(({ 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(({ 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(({ 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(({ prompt = '', focusRef, .. onChangeFocus={handleOnChangeFocus} focusRef={focusRef} > - - -
{textEntered}
+ + + {userInput.getLeftOfCursorRenderingContent()} - -
- {rightOfCursor.text} -
+ + {userInput.getRightOfCursorRenderingContent()}
diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/argument_selector_wrapper.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/argument_selector_wrapper.tsx new file mode 100644 index 0000000000000..28a5a082c2903 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/argument_selector_wrapper.tsx @@ -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 & + Pick, '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( + ({ 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 ( + + + +
+ {`--${argName}=`} + {'"'} +
+
+ + {/* `div` below ensures that the `SelectorComponent` does NOT inherit the styles of a `flex` container */} +
+ +
+
+ + {'"'} + +
+ + +
+ ); + } +); +ArgumentSelectorWrapper.displayName = 'ArgumentSelectorWrapper'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/command_input_history.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/command_input_history.tsx index fbb5b7360eddd..4e192a80d5b84 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/command_input_history.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/command_input_history.tsx @@ -43,7 +43,7 @@ export const CommandInputHistory = memo(() => { const selectableHistoryOptions = useMemo(() => { return inputHistory.map((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: '' } }); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/input_placeholder.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/input_placeholder.tsx index 40775d5220eed..f0dde8fb95670 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/input_placeholder.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/components/input_placeholder.tsx @@ -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(() => { diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/hooks/use_input_hints.ts b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/hooks/use_input_hints.ts index 8d179708f3850..16abace42ad75 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/hooks/use_input_hints.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/hooks/use_input_hints.ts @@ -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(() => { 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]); }; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/integration_tests/command_input.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/integration_tests/command_input.test.tsx index 54b8a487d2e09..028c4874c1cd6 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/integration_tests/command_input.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/integration_tests/command_input.test.tsx @@ -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 '); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.test.tsx new file mode 100644 index 0000000000000..fa313293d4746 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.test.tsx @@ -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[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); + } + ); + } + ); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.ts b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.ts deleted file mode 100644 index bf809c9bee885..0000000000000 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.ts +++ /dev/null @@ -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); - } - } -} diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.tsx new file mode 100644 index 0000000000000..f6a1b67233355 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_input/lib/entered_input.tsx @@ -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 => { + 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 {item.renderValue}; +}; + +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: ( + + ), + 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; + } +} diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx index 83c5df7397daa..854c66dcc27d7 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx @@ -124,7 +124,8 @@ export const CommandList = memo(({ commands, display = 'defaul type: 'updateInputTextEnteredState', payload: () => { return { - textEntered: text, + leftOfCursorText: text, + rightOfCursorText: '', }; }, }); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/hooks/use_stored_input_history.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/hooks/use_stored_input_history.ts index 8a9249b3dc01f..a1614f2b36683 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/hooks/use_stored_input_history.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/hooks/use_stored_input_history.ts @@ -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; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts index d6c6161da3300..407ae37e2377c 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_reducer.ts @@ -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; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx index 764e7377a497c..94b8c07059f42 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_execute_command.tsx @@ -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 = ( - - - {exclusiveOrArgs.map(toCliArgumentOption).join(', ')} - - ), - }} - /> - + 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: ( - - - {parsedInput.name} - - ), - unknownArgs: ( - - {unknownInputArgs.map(toCliArgumentOption).join(', ')} - - ), - }} - /> - + errorMessage: executionTranslations.unknownArgument( + unknownInputArgs.length, + parsedInput.name, + unknownInputArgs.map(toCliArgumentOption).join(', ') ), }), false @@ -294,15 +258,7 @@ export const handleExecuteCommand: ConsoleStoreReducer< createCommandExecutionState({ errorMessage: ( - {i18n.translate( - 'xpack.securitySolution.console.commandValidation.missingRequiredArg', - { - defaultMessage: 'Missing required argument: {argName}', - values: { - argName: toCliArgumentOption(requiredArg), - }, - } - )} + {executionTranslations.missingRequiredArg(requiredArg)} ), }), @@ -341,15 +297,7 @@ export const handleExecuteCommand: ConsoleStoreReducer< createCommandExecutionState({ errorMessage: ( - - {i18n.translate( - 'xpack.securitySolution.console.commandValidation.unsupportedArg', - { - defaultMessage: 'Unsupported argument: {argName}', - values: { argName: toCliArgumentOption(argName) }, - } - )} - + {executionTranslations.unsupportedArg(argName)} ), }), false @@ -366,13 +314,7 @@ export const handleExecuteCommand: ConsoleStoreReducer< createCommandExecutionState({ errorMessage: ( - {i18n.translate( - 'xpack.securitySolution.console.commandValidation.argSupportedOnlyOnce', - { - defaultMessage: 'Argument can only be used once: {argName}', - values: { argName: toCliArgumentOption(argName) }, - } - )} + {executionTranslations.noMultiplesAllowed(argName)} ), }), @@ -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: {dataValidationError}, + }), + 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: ( - {i18n.translate( - 'xpack.securitySolution.console.commandValidation.invalidArgValue', - { - defaultMessage: 'Invalid argument value: {argName}. {error}', - values: { argName: toCliArgumentOption(argName), error: validationResult }, - } - )} + {executionTranslations.argValueValidatorError(argName, validationResult)} ), }), @@ -416,14 +416,9 @@ export const handleExecuteCommand: ConsoleStoreReducer< createCommandExecutionState({ errorMessage: ( - {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(', ') + )} ), }), @@ -448,11 +443,7 @@ export const handleExecuteCommand: ConsoleStoreReducer< cloneCommandDefinitionWithNewRenderComponent(command, BadArgument), createCommandExecutionState({ errorMessage: ( - - {i18n.translate('xpack.securitySolution.console.commandValidation.oneArgIsRequired', { - defaultMessage: 'At least one argument must be used', - })} - + {executionTranslations.MUST_HAVE_AT_LEAST_ONE_ARG} ), }), false diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_input_area_state.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_input_area_state.ts index 092f74dc66ce1..e703d0f280480 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_input_area_state.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/handle_input_area_state.ts @@ -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 = ( @@ -50,7 +72,14 @@ export const handleInputAreaState: ConsoleStoreReducer = ( 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 = ( }; 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 = ( }; } 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 diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/translations.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/translations.tsx new file mode 100644 index 0000000000000..5922a71ff368b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/state_update_handlers/translations.tsx @@ -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 ( + + + {argNames} + + ), + }} + /> + + ); + }, + + unknownArgument: (count: number, commandName: string, unknownArgs: string): ReactNode => { + return ( + + + {commandName} + + ), + unknownArgs: ( + + {unknownArgs} + + ), + }} + /> + + ); + }, +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts index 291f525196bba..67033eb09886a 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_state/types.ts @@ -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 parsed user input + */ + parsedInput: ParsedCommandInterface; - /** The command name that was entered (derived from `textEntered` */ - commandEntered: string; + /** 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 { + 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; + + 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; } +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 & { + /** updates (if necessary) to any of the argument's state */ + argState?: Record; + }, + 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 | ((options: Required) => T); +type PayloadValueOrFunction = + | T + | ((options: TCallbackArgs) => T); export interface ConsoleStore { state: ConsoleDataState; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_command_argument_state.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_command_argument_state.ts new file mode 100644 index 0000000000000..e7b602d07c405 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_command_argument_state.ts @@ -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]); +}; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_input_command_entered.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_input_command_entered.ts index 827163aa32833..3833ae4a5f3d9 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_input_command_entered.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_input_command_entered.ts @@ -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 : ''; }; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_input_text_entered.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_input_text_entered.ts index 61164ed1f8421..b5bbd1cbe9415 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_input_text_entered.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_with_input_text_entered.ts @@ -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 & { +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]); }; diff --git a/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx index ef550605714e1..b50e5ff108512 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx @@ -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 ( + {`${argName}[${argIndex}]: ${valueText}`} + ); +}); +ArgumentSelectorComponentMock.displayName = 'ArgumentSelectorComponentMock'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/parse_command_input.test.ts b/x-pack/plugins/security_solution/public/management/components/console/service/parse_command_input.test.ts index a4d3a983041fd..c976df72259e6 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/service/parse_command_input.test.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/service/parse_command_input.test.ts @@ -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()', () => { diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts index 78ab197ebd227..65c2f5daadc24 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/service/parsed_command_input.ts @@ -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 = Array< - T extends PossibleArgDataTypes ? T : never ->; - -interface ParsedCommandInput { - name: string; - args: { - [key in keyof TArgs]: ParsedArgData[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 - extends ParsedCommandInput { - 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; diff --git a/x-pack/plugins/security_solution/public/management/components/console/service/types.ts b/x-pack/plugins/security_solution/public/management/components/console/service/types.ts new file mode 100644 index 0000000000000..18f1e2bbae1b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/service/types.ts @@ -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 = Array< + T extends PossibleArgDataTypes ? T : never +>; + +export interface ParsedCommandInput { + name: string; + args: { + [key in keyof TArgs]: ParsedArgData[key]>; + }; +} + +export interface ParsedCommandInterface + extends ParsedCommandInput { + 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; +} diff --git a/x-pack/plugins/security_solution/public/management/components/console/types.ts b/x-pack/plugins/security_solution/public/management/components/console/types.ts index 9102d77d66d09..30f8880486907 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/types.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/types.ts @@ -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 { + /** 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 { * 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; /** The command definition associated with this user command */ @@ -177,6 +213,55 @@ export type CommandExecutionComponent< TMeta = any > = ComponentType>; +/** + * The component props for an argument `SelectorComponent` + */ +export interface CommandArgumentValueSelectorProps { + /** + * 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) => void; +} + +/** + * Component for rendering an argument's value selector + */ +export type CommandArgumentValueSelectorComponent = + ComponentType; + export interface ConsoleProps extends CommonProps { /** * The list of Commands that will be available in the console for the user to execute diff --git a/x-pack/plugins/security_solution/public/management/components/console_argument_selectors/file_selector.tsx b/x-pack/plugins/security_solution/public/management/components/console_argument_selectors/file_selector.tsx new file mode 100644 index 0000000000000..4dda91f2ab232 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console_argument_selectors/file_selector.tsx @@ -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 +>(({ value, valueText, onChange, store: _store }) => { + const state = useMemo(() => { + 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 ( +
+ + +
+ {valueText || INITIAL_DISPLAY_LABEL} +
+
+ + + + + } + > + {state.isPopoverOpen && ( + + )} +
+
+ ); +}); +ArgumentFileSelector.displayName = 'ArgumentFileSelector'; diff --git a/x-pack/plugins/security_solution/public/management/components/console_argument_selectors/index.ts b/x-pack/plugins/security_solution/public/management/components/console_argument_selectors/index.ts new file mode 100644 index 0000000000000..515352120b171 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console_argument_selectors/index.ts @@ -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'; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts index b4068c95d1407..1c57fb6401ec6 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts @@ -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({ diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/dev_only.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/dev_only.tsx new file mode 100644 index 0000000000000..6ec6cdf801092 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/dev_only.tsx @@ -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 ( +
+
{`ExecuteFileAction DEV MOCK`}
+
+ {`File Selected: ${props.command.args.args.file[0].name}`} +
+
+ ); + }, + 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 ( + {`${argName}[${argIndex}]: ${valueText}`} + ); +}); +ArgumentSelectorComponentTest.displayName = 'ArgumentSelectorComponentTest'; + +document.body.classList.add('style2'); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 7bc02c0c7cdf1..e6f96622272ba 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -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.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f7737d7fcf9aa..f4e92ab92abc2 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -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})ボタンを使用して、テキストバーに対応アクションを入力します。必要に応じて、パラメーターまたはコメントを追加します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 2cf614e7aedea..b16bd4fbb2c68 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -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}) 按钮将响应操作填充到文本栏。在必要时添加其他参数或注释。",