[RAM] Autocomplete (#158454)

## Summary

Solves this issue: https://github.com/elastic/kibana/issues/161763

This PR introduces autocomplete for mustache variables for email
connector(next PR will add it to all connectors) under the feature flag.

We decided keep old solution with button with all searchable options as
well.

How to test:
Create an email connector in kibana.yml:

xpack.actions.preconfigured:
  maildev:
    name: 'email: maildev'
    actionTypeId: '.email'
    config:
      from: 'guskova@example.com'
      host: 'localhost'
      port: '1025'

How it should work:
You start writing in Message window {{ and mustache variable name. And
you should see autocomplete popup with all possible options to choose.
When you click somewhere else, popup should disappeared.


061016a6-b8ca-497b-9bed-b8b012d31a95

e options to choose. When you click somewhere else, popup should
disappeared.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
This commit is contained in:
Julia 2023-08-31 04:04:14 +02:00 committed by GitHub
parent a6aa865dee
commit 0af40a3066
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 890 additions and 101 deletions

View file

@ -1007,6 +1007,7 @@
"suricata-sid-db": "^1.0.2",
"symbol-observable": "^1.2.0",
"tar": "^6.1.15",
"textarea-caret": "^3.1.0",
"tinycolor2": "1.4.1",
"tinygradient": "0.4.3",
"ts-easing": "^0.2.0",
@ -1368,6 +1369,7 @@
"@types/tar": "^6.1.5",
"@types/tempy": "^0.2.0",
"@types/testing-library__jest-dom": "^5.14.7",
"@types/textarea-caret": "^3.0.1",
"@types/tinycolor2": "^1.4.1",
"@types/tough-cookie": "^4.0.2",
"@types/type-detect": "^4.0.1",

View file

@ -9,3 +9,4 @@
export { AlertLifecycleStatusBadge } from './src/alert_lifecycle_status_badge';
export type { AlertLifecycleStatusBadgeProps } from './src/alert_lifecycle_status_badge';
export { MaintenanceWindowCallout } from './src/maintenance_window_callout';
export { AddMessageVariables } from './src/add_message_variables';

View file

@ -10,4 +10,5 @@ module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-alerts-ui-shared'],
setupFilesAfterEnv: ['<rootDir>/packages/kbn-alerts-ui-shared/setup_tests.ts'],
};

View file

@ -0,0 +1,10 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';

View file

@ -1,13 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { AddMessageVariables } from './add_message_variables';
import { AddMessageVariables } from '.';
describe('AddMessageVariables', () => {
test('it renders variables and filter bar', async () => {

View file

@ -1,8 +1,9 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useMemo, useState } from 'react';
@ -23,7 +24,7 @@ import {
} from '@elastic/eui';
import { ActionVariable } from '@kbn/alerting-plugin/common';
import './add_message_variables.scss';
import { TruncatedText } from '../../common/truncated_text';
import { TruncatedText } from './truncated_text';
import * as i18n from './translations';
interface Props {

View file

@ -1,70 +1,71 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
export const LOADING_VARIABLES = i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.loadingMessage',
'alertsUIShared.components.addMessageVariables.loadingMessage',
{
defaultMessage: 'Loading variables',
}
);
export const NO_VARIABLES_FOUND = i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.noVariablesFound',
'alertsUIShared.components.addMessageVariables.noVariablesFound',
{
defaultMessage: 'No variables found',
}
);
export const NO_VARIABLES_AVAILABLE = i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.noVariablesAvailable',
'alertsUIShared.components.addMessageVariables.noVariablesAvailable',
{
defaultMessage: 'No variables available',
}
);
export const DEPRECATED_VARIABLES_ARE_SHOWN = i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.deprecatedVariablesAreShown',
'alertsUIShared.components.addMessageVariables.deprecatedVariablesAreShown',
{
defaultMessage: 'Deprecated variables are shown',
}
);
export const DEPRECATED_VARIABLES_ARE_HIDDEN = i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.deprecatedVariablesAreHidden',
'alertsUIShared.components.addMessageVariables.deprecatedVariablesAreHidden',
{
defaultMessage: 'Deprecated variables are hidden',
}
);
export const HIDE = i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.hideDeprecatedVariables',
'alertsUIShared.components.addMessageVariables.hideDeprecatedVariables',
{
defaultMessage: 'Hide',
}
);
export const SHOW_ALL = i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.showAllDeprecatedVariables',
'alertsUIShared.components.addMessageVariables.showAllDeprecatedVariables',
{
defaultMessage: 'Show all',
}
);
export const ADD_VARIABLE_POPOVER_BUTTON = i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton',
'alertsUIShared.components.addMessageVariables.addVariablePopoverButton',
{
defaultMessage: 'Add variable',
}
);
export const ADD_VARIABLE_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.addMessageVariables.addRuleVariableTitle',
'alertsUIShared.components.addMessageVariables.addRuleVariableTitle',
{
defaultMessage: 'Add variable',
}

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { css } from '@emotion/react';
import { EuiText } from '@elastic/eui';
const LINE_CLAMP = 2;
const styles = {
truncatedText: css`
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: ${LINE_CLAMP};
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
`,
};
const TruncatedTextComponent: React.FC<{ text: string }> = ({ text }) => (
<EuiText size="xs" color="subdued" css={styles.truncatedText}>
{text}
</EuiText>
);
TruncatedTextComponent.displayName = 'TruncatedText';
export const TruncatedText = React.memo(TruncatedTextComponent);

View file

@ -106,7 +106,6 @@ describe('MaintenanceWindowCallout', () => {
{ wrapper: TestProviders }
);
// @ts-expect-error Jest types are incomplete in packages
expect(await findByText('Maintenance window is running')).toBeInTheDocument();
expect(fetchActiveMaintenanceWindowsMock).toHaveBeenCalledTimes(1);
});
@ -119,7 +118,7 @@ describe('MaintenanceWindowCallout', () => {
const { container } = render(<MaintenanceWindowCallout kibanaServices={kibanaServicesMock} />, {
wrapper: TestProviders,
});
// @ts-expect-error Jest types are incomplete in packages
expect(container).toBeEmptyDOMElement();
expect(fetchActiveMaintenanceWindowsMock).toHaveBeenCalledTimes(1);
});
@ -130,7 +129,7 @@ describe('MaintenanceWindowCallout', () => {
const { container } = render(<MaintenanceWindowCallout kibanaServices={kibanaServicesMock} />, {
wrapper: TestProviders,
});
// @ts-expect-error Jest types are incomplete in packages
expect(container).toBeEmptyDOMElement();
expect(fetchActiveMaintenanceWindowsMock).toHaveBeenCalledTimes(1);
});
@ -192,7 +191,7 @@ describe('MaintenanceWindowCallout', () => {
const { container } = render(<MaintenanceWindowCallout kibanaServices={servicesMock} />, {
wrapper: TestProviders,
});
// @ts-expect-error Jest types are incomplete in packages
expect(container).toBeEmptyDOMElement();
});
@ -213,7 +212,7 @@ describe('MaintenanceWindowCallout', () => {
const { findByText } = render(<MaintenanceWindowCallout kibanaServices={servicesMock} />, {
wrapper: TestProviders,
});
// @ts-expect-error Jest types are incomplete in packages
expect(await findByText('Maintenance window is running')).toBeInTheDocument();
});
});

View file

@ -280,6 +280,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.securitySolution.prebuiltRulesPackageVersion (string)',
'xpack.snapshot_restore.slm_ui.enabled (boolean)',
'xpack.snapshot_restore.ui.enabled (boolean)',
'xpack.stack_connectors.enableExperimental (array)',
'xpack.trigger_actions_ui.enableExperimental (array)',
'xpack.trigger_actions_ui.enableGeoTrackingThresholdAlert (boolean)',
'xpack.upgrade_assistant.featureSet.migrateSystemIndices (boolean)',

View file

@ -0,0 +1,52 @@
/*
* 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 type ExperimentalFeatures = typeof allowedExperimentalValues;
/**
* A list of allowed values that can be used in `xpack.stack_connectors.enableExperimental`.
* This object is then used to validate and parse the value entered.
*/
export const allowedExperimentalValues = Object.freeze({
isMustacheAutocompleteOn: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;
type Mutable<T> = { -readonly [P in keyof T]: T[P] };
const InvalidExperimentalValue = class extends Error {};
const allowedKeys = Object.keys(allowedExperimentalValues) as Readonly<ExperimentalConfigKeys>;
/**
* Parses the string value used in `xpack.stack_connectors.enableExperimental` kibana configuration,
* which should be a string of values delimited by a comma (`,`)
*
* @param configValue
* @throws InvalidExperimentalValue
*/
export const parseExperimentalConfigValue = (configValue: string[]): ExperimentalFeatures => {
const enabledFeatures: Mutable<Partial<ExperimentalFeatures>> = {};
for (const value of configValue) {
if (!isValidExperimentalValue(value)) {
throw new InvalidExperimentalValue(`[${value}] is not valid.`);
}
// @ts-expect-error ts upgrade v4.7.4
enabledFeatures[value as keyof ExperimentalFeatures] = true;
}
return {
...allowedExperimentalValues,
...enabledFeatures,
};
};
export const isValidExperimentalValue = (value: string): boolean => {
return allowedKeys.includes(value as keyof ExperimentalFeatures);
};
export const getExperimentalAllowedValues = (): string[] => [...allowedKeys];

View file

@ -0,0 +1,10 @@
/*
* 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 interface StackConnectorsConfigType {
enableExperimental: string[];
}

View file

@ -13,6 +13,7 @@
"requiredPlugins": [
"actions",
"esUiShared",
"kibanaReact",
"triggersActionsUi"
],
"extraPublicDirs": [

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ExperimentalFeatures } from '../../common/experimental_features';
export class ExperimentalFeaturesService {
private static experimentalFeatures?: ExperimentalFeatures;
public static init({ experimentalFeatures }: { experimentalFeatures: ExperimentalFeatures }) {
this.experimentalFeatures = experimentalFeatures;
}
public static get(): ExperimentalFeatures {
if (!this.experimentalFeatures) {
this.throwUninitializedError();
}
return this.experimentalFeatures;
}
private static throwUninitializedError(): never {
throw new Error(
'Experimental features services not initialized - are you trying to import this module from outside of the stack connectors?'
);
}
}

View file

@ -0,0 +1,27 @@
/*
* 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 {
ExperimentalFeatures,
isValidExperimentalValue,
getExperimentalAllowedValues,
} from '../../common/experimental_features';
import { ExperimentalFeaturesService } from './experimental_features_service';
const allowedExperimentalValueKeys = getExperimentalAllowedValues();
export const getIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => {
if (!isValidExperimentalValue(feature)) {
throw new Error(
`Invalid enable value ${feature}. Allowed values are: ${allowedExperimentalValueKeys.join(
', '
)}`
);
}
return ExperimentalFeaturesService.get()[feature];
};

View file

@ -0,0 +1,366 @@
/*
* 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, { useState, useMemo, useCallback, useEffect } from 'react';
import getCaretCoordinates from 'textarea-caret';
import { Properties } from 'csstype';
import {
EuiTextArea,
EuiFormRow,
EuiSelectable,
EuiSelectableOption,
EuiPortal,
EuiHighlight,
EuiOutsideClickDetector,
useEuiTheme,
useEuiBackgroundColor,
} from '@elastic/eui';
import { ActionVariable } from '@kbn/alerting-plugin/common';
import { AddMessageVariables } from '@kbn/alerts-ui-shared';
import { euiThemeVars } from '@kbn/ui-theme';
import { filterSuggestions } from '../lib/filter_suggestions_for_autocomplete';
import { templateActionVariable } from '../lib/template_action_variable';
export interface TextAreaWithAutocompleteProps {
editAction: (property: string, value: any, index: number) => void;
errors?: string[];
index: number;
inputTargetValue?: string;
isDisabled?: boolean;
label: string;
messageVariables?: ActionVariable[];
paramsProperty: string;
}
const selectableListProps = { className: 'euiSelectableMsgAutoComplete' };
export const TextAreaWithAutocomplete: React.FunctionComponent<TextAreaWithAutocompleteProps> = ({
editAction,
errors,
index,
inputTargetValue,
isDisabled = false,
label,
messageVariables,
paramsProperty,
}) => {
const { euiTheme } = useEuiTheme();
const backgroundColor = useEuiBackgroundColor('plain');
const textAreaRef = React.useRef<HTMLTextAreaElement | null>(null);
const selectableRef = React.useRef<EuiSelectable | null>(null);
const [matches, setMatches] = useState<string[]>([]);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, height: 0, width: 0 });
const [isListOpen, setListOpen] = useState(false);
const [autoCompleteIndex, setAutoCompleteIndex] = useState(-1);
const [selectableHasFocus, setSelectableHasFocus] = useState(false);
const [searchWord, setSearchWord] = useState<string>('');
const optionsToShow: EuiSelectableOption[] = useMemo(() => {
return matches?.map((variable) => ({
label: variable,
data: {
description: variable,
},
'data-test-subj': `${variable}-selectableOption`,
}));
}, [matches]);
const closeList = useCallback((doNotResetAutoCompleteIndex = false) => {
if (!doNotResetAutoCompleteIndex) {
setAutoCompleteIndex(-1);
}
setListOpen(false);
setSelectableHasFocus(false);
}, []);
const onOptionPick = useCallback(
(newOptions: EuiSelectableOption[]) => {
if (!textAreaRef.current) return;
const { value, selectionStart, scrollTop } = textAreaRef.current;
const lastSpaceIndex = value.slice(0, selectionStart).lastIndexOf(' ');
const lastOpenDoubleCurlyBracketsIndex = value.slice(0, selectionStart).lastIndexOf('{{');
const currentWordStartIndex = Math.max(lastSpaceIndex, lastOpenDoubleCurlyBracketsIndex);
const checkedElement = newOptions.find(({ checked }) => checked === 'on');
if (checkedElement) {
const newInputText =
value.slice(0, currentWordStartIndex) +
'{{' +
checkedElement.label +
'}}' +
value.slice(selectionStart);
editAction(paramsProperty, newInputText.trim(), index);
setMatches([]);
closeList();
textAreaRef.current.focus();
// We use setTimeout here, because editAction is async function and we need to wait before it executes
setTimeout(() => {
if (textAreaRef.current) {
textAreaRef.current.selectionStart =
currentWordStartIndex + checkedElement.label.length + 4;
textAreaRef.current.selectionEnd = textAreaRef.current.selectionStart;
textAreaRef.current.scrollTop = scrollTop;
}
}, 0);
}
},
[editAction, index, paramsProperty, closeList]
);
const recalcMenuPosition = useCallback(() => {
if (!textAreaRef.current) return;
const newPosition = getCaretCoordinates(
textAreaRef.current,
textAreaRef.current.selectionStart
);
const textAreaClientRect = textAreaRef.current?.getBoundingClientRect();
const top =
textAreaClientRect.top -
textAreaRef.current.scrollTop +
window.scrollY +
newPosition.top +
newPosition.height;
const left = textAreaClientRect.left + window.pageXOffset;
const height = newPosition.height;
const width = textAreaClientRect.width;
setPopupPosition({ top, left, width, height });
setListOpen(true);
}, []);
const onChangeWithMessageVariable = useCallback(() => {
if (!textAreaRef.current) return;
const { value, selectionStart } = textAreaRef.current;
const lastTwoLetter = value.slice(selectionStart - 2, selectionStart);
const currentWord =
autoCompleteIndex !== -1 ? value.slice(autoCompleteIndex, selectionStart) : '';
if (lastTwoLetter === '{{' || currentWord.startsWith('{{')) {
if (lastTwoLetter === '{{') {
setAutoCompleteIndex(selectionStart - 2);
}
const filteredMatches = filterSuggestions({
actionVariablesList: messageVariables
?.filter(({ deprecated }) => !deprecated)
.map(({ name }) => name),
propertyPath: currentWord.slice(2),
});
setSearchWord(currentWord.slice(2));
setMatches(filteredMatches);
setTimeout(() => recalcMenuPosition(), 0);
} else if (lastTwoLetter === '}}') {
closeList();
} else {
setMatches([]);
}
editAction(paramsProperty, value, index);
}, [
autoCompleteIndex,
closeList,
editAction,
index,
messageVariables,
paramsProperty,
recalcMenuPosition,
]);
const textareaOnKeyPress = useCallback(
(event) => {
if (selectableRef.current && isListOpen) {
if (!selectableHasFocus && (event.code === 'ArrowUp' || event.code === 'ArrowDown')) {
event.preventDefault();
event.stopPropagation();
selectableRef.current.onFocus();
setSelectableHasFocus(true);
} else if (event.code === 'ArrowUp') {
event.preventDefault();
event.stopPropagation();
selectableRef.current.incrementActiveOptionIndex(-1);
} else if (event.code === 'ArrowDown') {
event.preventDefault();
event.stopPropagation();
selectableRef.current.incrementActiveOptionIndex(1);
} else if (event.code === 'Escape') {
event.preventDefault();
event.stopPropagation();
closeList();
} else if (event.code === 'Enter' || event.code === 'Space') {
const optionIndex = selectableRef.current.state.activeOptionIndex;
onOptionPick(
optionsToShow.map((ots, idx) => {
if (idx === optionIndex) {
return {
...ots,
checked: 'on',
};
}
return ots;
})
);
closeList();
}
} else {
setSelectableHasFocus((prevValue) => {
if (prevValue) {
return false;
}
return prevValue;
});
}
},
[closeList, isListOpen, onOptionPick, optionsToShow, selectableHasFocus]
);
const clickOutSideTextArea = useCallback(
(event) => {
const box = document
.querySelector('.euiSelectableMsgAutoComplete')
?.getBoundingClientRect() || {
left: 0,
right: 0,
top: 0,
bottom: 0,
};
if (
event.clientX > box.left &&
event.clientX < box.right &&
event.clientY > box.top &&
event.clientY < box.bottom
) {
return;
}
closeList();
},
[closeList]
);
const onSelectMessageVariable = useCallback(
(variable: ActionVariable) => {
if (!textAreaRef.current) return;
const { selectionStart: startPosition, selectionEnd: endPosition } = textAreaRef.current;
const templatedVar = templateActionVariable(variable);
const newValue =
(inputTargetValue ?? '').substring(0, startPosition) +
templatedVar +
(inputTargetValue ?? '').substring(endPosition, (inputTargetValue ?? '').length);
editAction(paramsProperty, newValue, index);
},
[editAction, index, inputTargetValue, paramsProperty]
);
const renderSelectableOption = (option: any) => {
if (searchWord) {
return <EuiHighlight search={searchWord}>{option.label}</EuiHighlight>;
}
return option.label;
};
const selectableStyle: Properties<string | number> = useMemo(
() => ({
position: 'absolute',
top: popupPosition.top,
width: popupPosition.width,
left: popupPosition.left,
border: `${euiTheme.border.width.thin} solid ${euiTheme.border.color}`,
background: backgroundColor,
zIndex: euiThemeVars.euiZLevel1,
}),
[
backgroundColor,
euiTheme.border.color,
euiTheme.border.width.thin,
popupPosition.left,
popupPosition.top,
popupPosition.width,
]
);
const onFocus = useCallback(() => setListOpen(true), []);
const onBlur = useCallback(() => {
if (!inputTargetValue && !isListOpen) {
editAction(paramsProperty, '', index);
}
}, [editAction, index, inputTargetValue, isListOpen, paramsProperty]);
const onClick = useCallback(() => closeList(), [closeList]);
const onScroll = useCallback(
(evt) => {
// FUTURE ENGINEER -> we need to make sure to not close the autocomplete option list
if (selectableRef?.current?.listId !== evt.target?.firstElementChild?.id) {
closeList(true);
}
},
[closeList]
);
useEffect(() => {
window.addEventListener('scroll', onScroll, { passive: true, capture: true });
return () => {
window.removeEventListener('scroll', onScroll, { capture: true });
};
}, [onScroll]);
return (
<EuiFormRow
error={errors}
fullWidth
isDisabled={isDisabled}
isInvalid={errors && errors.length > 0 && inputTargetValue !== undefined}
label={label}
labelAppend={
<AddMessageVariables
messageVariables={messageVariables}
onSelectEventHandler={onSelectMessageVariable}
paramsProperty={paramsProperty}
/>
}
>
<>
<EuiOutsideClickDetector onOutsideClick={clickOutSideTextArea}>
<EuiTextArea
disabled={isDisabled}
inputRef={textAreaRef}
fullWidth
isInvalid={errors && errors.length > 0 && inputTargetValue !== undefined}
name={paramsProperty}
value={inputTargetValue || ''}
data-test-subj={`${paramsProperty}TextArea`}
onChange={onChangeWithMessageVariable}
onFocus={onFocus}
onKeyDown={textareaOnKeyPress}
onBlur={onBlur}
onClick={onClick}
/>
</EuiOutsideClickDetector>
{matches.length > 0 && isListOpen && (
<EuiPortal>
<EuiSelectable
ref={selectableRef}
style={selectableStyle}
height={matches.length > 5 ? 32 * 5.5 : matches.length * 32}
options={optionsToShow}
onChange={onOptionPick}
singleSelection
renderOption={renderSelectableOption}
listProps={selectableListProps}
>
{(list) => list}
</EuiSelectable>
</EuiPortal>
)}
</>
</EuiFormRow>
);
};
// eslint-disable-next-line import/no-default-export
export { TextAreaWithAutocomplete as default };

View file

@ -7,10 +7,35 @@
import React from 'react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { render, fireEvent, screen } from '@testing-library/react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import EmailParamsFields from './email_params';
import { getIsExperimentalFeatureEnabled } from '../../common/get_experimental_features';
jest.mock('@kbn/kibana-react-plugin/public', () => ({
useKibana: jest.fn(),
}));
jest.mock('../../common/get_experimental_features');
const useKibanaMock = useKibana as jest.Mock;
const mockKibana = () => {
useKibanaMock.mockReturnValue({
services: {
triggersActionsUi: triggersActionsUiMock.createStart(),
},
});
};
describe('EmailParamsFields renders', () => {
test('all params fields is rendered', () => {
beforeEach(() => {
jest.clearAllMocks();
mockKibana();
(getIsExperimentalFeatureEnabled as jest.Mock<any, any>).mockImplementation(() => true);
});
test('all params fields is rendered', async () => {
const actionParams = {
cc: [],
bcc: [],
@ -19,21 +44,22 @@ describe('EmailParamsFields renders', () => {
message: 'test message',
};
const wrapper = mountWithIntl(
<EmailParamsFields
actionParams={actionParams}
errors={{ to: [], cc: [], bcc: [], subject: [], message: [] }}
editAction={() => {}}
index={0}
/>
render(
<IntlProvider locale="en">
<EmailParamsFields
actionParams={actionParams}
errors={{ to: [], cc: [], bcc: [], subject: [], message: [] }}
editAction={() => {}}
defaultMessage={'Some default message'}
index={0}
/>
</IntlProvider>
);
expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy();
expect(
wrapper.find('[data-test-subj="toEmailAddressInput"]').first().prop('selectedOptions')
).toStrictEqual([{ label: 'test@test.com' }]);
expect(wrapper.find('[data-test-subj="subjectInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="messageTextArea"]').length > 0).toBeTruthy();
expect(screen.getByTestId('toEmailAddressInput')).toBeVisible();
expect(screen.getByTestId('toEmailAddressInput').textContent).toStrictEqual('test@test.com');
expect(screen.getByTestId('subjectInput')).toBeVisible();
expect(await screen.findByTestId('messageTextArea')).toBeVisible();
});
test('message param field is rendered with default value if not set', () => {
@ -95,36 +121,57 @@ describe('EmailParamsFields renders', () => {
};
const editAction = jest.fn();
const wrapper = mountWithIntl(
<EmailParamsFields
actionParams={actionParams}
errors={{ to: [], cc: [], bcc: [], subject: [], message: [] }}
editAction={editAction}
defaultMessage={'Some default message'}
index={0}
/>
const { rerender } = render(
<IntlProvider locale="en">
<EmailParamsFields
actionParams={actionParams}
errors={{ to: [], cc: [], bcc: [], subject: [], message: [] }}
editAction={editAction}
defaultMessage={'Some default message'}
index={0}
/>
</IntlProvider>
);
expect(editAction).toHaveBeenCalledWith('message', 'Some default message', 0);
// simulate value being updated
const valueToSimulate = 'some new value';
wrapper
.find('[data-test-subj="messageTextArea"]')
.last()
.simulate('change', { target: { value: valueToSimulate } });
expect(editAction).toHaveBeenCalledWith('message', valueToSimulate, 0);
wrapper.setProps({
actionParams: {
...actionParams,
message: valueToSimulate,
},
fireEvent.change(screen.getByTestId('messageTextArea'), {
target: { value: valueToSimulate },
});
// simulate default changing
wrapper.setProps({
defaultMessage: 'Some different default message',
});
expect(editAction).toHaveBeenCalledWith('message', valueToSimulate, 0);
rerender(
<IntlProvider locale="en">
<EmailParamsFields
actionParams={{
...actionParams,
message: valueToSimulate,
}}
errors={{ to: [], cc: [], bcc: [], subject: [], message: [] }}
editAction={editAction}
defaultMessage={'Some default message'}
index={0}
/>
</IntlProvider>
);
rerender(
<IntlProvider locale="en">
<EmailParamsFields
actionParams={{
...actionParams,
message: valueToSimulate,
}}
errors={{ to: [], cc: [], bcc: [], subject: [], message: [] }}
editAction={editAction}
defaultMessage={'Some different default message'}
index={0}
/>
</IntlProvider>
);
expect(editAction).not.toHaveBeenCalledWith('message', 'Some different default message', 0);
});

View file

@ -5,16 +5,18 @@
* 2.0.
*/
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiComboBox, EuiButtonEmpty, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public';
import {
TextAreaWithMessageVariables,
TextFieldWithMessageVariables,
TextAreaWithMessageVariables,
} from '@kbn/triggers-actions-ui-plugin/public';
import { EmailActionParams } from '../types';
import { getIsExperimentalFeatureEnabled } from '../../common/get_experimental_features';
import { TextAreaWithAutocomplete } from '../../components/text_area_with_autocomplete';
const noop = () => {};
@ -31,6 +33,7 @@ export const EmailParamsFields = ({
showEmailSubjectAndMessage = true,
useDefaultMessage,
}: ActionParamsProps<EmailActionParams>) => {
const isMustacheAutocompleteOn = getIsExperimentalFeatureEnabled('isMustacheAutocompleteOn');
const { to, cc, bcc, subject, message } = actionParams;
const toOptions = to ? to.map((label: string) => ({ label })) : [];
const ccOptions = cc ? cc.map((label: string) => ({ label })) : [];
@ -60,6 +63,11 @@ export const EmailParamsFields = ({
const isCCInvalid: boolean = errors.cc !== undefined && errors.cc.length > 0 && cc !== undefined;
const isBCCInvalid: boolean =
errors.bcc !== undefined && errors.bcc.length > 0 && bcc !== undefined;
const TextAreaComponent = useMemo(() => {
return isMustacheAutocompleteOn ? TextAreaWithAutocomplete : TextAreaWithMessageVariables;
}, [isMustacheAutocompleteOn]);
return (
<>
<EuiFormRow
@ -231,7 +239,7 @@ export const EmailParamsFields = ({
</EuiFormRow>
)}
{showEmailSubjectAndMessage && (
<TextAreaWithMessageVariables
<TextAreaComponent
index={index}
editAction={editAction}
messageVariables={messageVariables}

View file

@ -8,4 +8,5 @@
import { PluginInitializerContext } from '@kbn/core/public';
import { StackConnectorsPublicPlugin } from './plugin';
export const plugin = (ctx: PluginInitializerContext) => new StackConnectorsPublicPlugin();
export const plugin = (context: PluginInitializerContext) =>
new StackConnectorsPublicPlugin(context);

View file

@ -0,0 +1,77 @@
/*
* 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 { filterSuggestions } from './filter_suggestions_for_autocomplete';
const defaultActionVariablesList = [
'kibana.alert.id',
'kibana.context.cloud.group',
'context.container',
'context.originalAlertState',
'date',
'rule.spaceId',
'kibana.alertActionGroup',
'tags',
];
describe('Unit tests for filterSuggestions function', () => {
test('should return empty list if actionVariablesList argument is undefined', () => {
expect(filterSuggestions({ propertyPath: 'alert.id' })).toEqual([]);
});
test('should return full sorted list of suggestions if propertyPath is empty string', () => {
expect(
filterSuggestions({ actionVariablesList: defaultActionVariablesList, propertyPath: '' })
).toEqual([
'context',
'context.container',
'context.originalAlertState',
'date',
'kibana',
'kibana.alert',
'kibana.alert.id',
'kibana.alertActionGroup',
'kibana.context',
'kibana.context.cloud',
'kibana.context.cloud.group',
'rule',
'rule.spaceId',
'tags',
]);
});
test('should return sorted of filtered suggestions, v1', () => {
expect(
filterSuggestions({ actionVariablesList: defaultActionVariablesList, propertyPath: 'ki' })
).toEqual([
'kibana',
'kibana.alert',
'kibana.alert.id',
'kibana.alertActionGroup',
'kibana.context',
'kibana.context.cloud',
'kibana.context.cloud.group',
]);
});
test('should return sorted of filtered suggestions, v2', () => {
expect(
filterSuggestions({
actionVariablesList: defaultActionVariablesList,
propertyPath: 'kibana.al',
})
).toEqual(['kibana.alert', 'kibana.alert.id', 'kibana.alertActionGroup']);
});
test('should return sorted of filtered suggestions, v3', () => {
expect(
filterSuggestions({
actionVariablesList: defaultActionVariablesList,
propertyPath: 'kibana.context.cloud.g',
})
).toEqual(['kibana.context.cloud.group']);
});
});

View file

@ -0,0 +1,27 @@
/*
* 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 const filterSuggestions = ({
actionVariablesList,
propertyPath,
}: {
actionVariablesList?: string[];
propertyPath: string;
}) => {
if (!actionVariablesList) return [];
const allSuggestions: string[] = [];
actionVariablesList.forEach((suggestion: string) => {
const splittedWords = suggestion.split('.');
for (let i = 0; i < splittedWords.length; i++) {
const currentSuggestion = splittedWords.slice(0, i + 1).join('.');
if (!allSuggestions.includes(currentSuggestion)) {
allSuggestions.push(currentSuggestion);
}
}
});
return allSuggestions.sort().filter((suggestion) => suggestion.startsWith(propertyPath));
};

View file

@ -0,0 +1,25 @@
/*
* 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 { templateActionVariable } from './template_action_variable';
describe('templateActionVariable', () => {
const actionVariable = {
name: 'myVar',
description: 'My variable description',
};
test('variable returns with double braces by default', () => {
expect(templateActionVariable(actionVariable)).toEqual('{{myVar}}');
});
test('variable returns with triple braces when specified', () => {
expect(
templateActionVariable({ ...actionVariable, useWithTripleBracesInTemplates: true })
).toEqual('{{{myVar}}}');
});
});

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ActionVariable } from '@kbn/alerting-plugin/common';
export function templateActionVariable(variable: ActionVariable) {
return variable.useWithTripleBracesInTemplates
? `{{{${variable.name}}}}`
: `{{${variable.name}}}`;
}

View file

@ -5,10 +5,16 @@
* 2.0.
*/
import { CoreSetup, Plugin } from '@kbn/core/public';
import { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { TriggersAndActionsUIPublicPluginSetup } from '@kbn/triggers-actions-ui-plugin/public';
import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public';
import { registerConnectorTypes } from './connector_types';
import { ExperimentalFeaturesService } from './common/experimental_features_service';
import {
ExperimentalFeatures,
parseExperimentalConfigValue,
} from '../common/experimental_features';
import { StackConnectorsConfigType } from '../common/types';
export type Setup = void;
export type Start = void;
@ -21,6 +27,13 @@ export interface StackConnectorsPublicSetupDeps {
export class StackConnectorsPublicPlugin
implements Plugin<Setup, Start, StackConnectorsPublicSetupDeps>
{
private config: StackConnectorsConfigType;
readonly experimentalFeatures: ExperimentalFeatures;
constructor(ctx: PluginInitializerContext) {
this.config = ctx.config.get();
this.experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental || []);
}
public setup(core: CoreSetup, { triggersActionsUi, actions }: StackConnectorsPublicSetupDeps) {
registerConnectorTypes({
connectorTypeRegistry: triggersActionsUi.actionTypeRegistry,
@ -28,6 +41,7 @@ export class StackConnectorsPublicPlugin
validateEmailAddresses: actions.validateEmailAddresses,
},
});
ExperimentalFeaturesService.init({ experimentalFeatures: this.experimentalFeatures });
}
public start() {}

View file

@ -0,0 +1,49 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import { PluginInitializerContext } from '@kbn/core/server';
import {
ExperimentalFeatures,
getExperimentalAllowedValues,
isValidExperimentalValue,
parseExperimentalConfigValue,
} from '../common/experimental_features';
const allowedExperimentalValues = getExperimentalAllowedValues();
export const configSchema = schema.object({
enableExperimental: schema.arrayOf(schema.string(), {
defaultValue: () => [],
validate(list) {
for (const key of list) {
if (!isValidExperimentalValue(key)) {
return `[${key}] is not allowed. Allowed values are: ${allowedExperimentalValues.join(
', '
)}`;
}
}
},
}),
});
export type ConfigSchema = TypeOf<typeof configSchema>;
export type ConfigType = ConfigSchema & {
experimentalFeatures: ExperimentalFeatures;
};
export const createConfig = (context: PluginInitializerContext): ConfigType => {
const pluginConfig = context.config.get<TypeOf<typeof configSchema>>();
const experimentalFeatures = parseExperimentalConfigValue(pluginConfig.enableExperimental);
return {
...pluginConfig,
experimentalFeatures,
};
};

View file

@ -4,8 +4,16 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PluginInitializerContext } from '@kbn/core/server';
import { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/server';
import { StackConnectorsPlugin } from './plugin';
import { configSchema, ConfigSchema } from './config';
export const config: PluginConfigDescriptor<ConfigSchema> = {
exposeToBrowser: {
enableExperimental: true,
},
schema: configSchema,
};
export const plugin = (initContext: PluginInitializerContext) =>
new StackConnectorsPlugin(initContext);

View file

@ -33,7 +33,10 @@
"@kbn/core-saved-objects-common",
"@kbn/core-http-browser-mocks",
"@kbn/core-saved-objects-api-server-mocks",
"@kbn/alerts-ui-shared",
"@kbn/alerting-plugin",
"@kbn/securitysolution-ecs",
"@kbn/ui-theme",
],
"exclude": [
"target/**/*",

View file

@ -38430,15 +38430,6 @@
"xpack.triggersActionsUI.common.expressionItems.threshold.andLabel": "AND",
"xpack.triggersActionsUI.common.expressionItems.threshold.descriptionLabel": "quand",
"xpack.triggersActionsUI.common.expressionItems.threshold.popoverTitle": "quand",
"xpack.triggersActionsUI.components.addMessageVariables.addRuleVariableTitle": "Ajouter une variable",
"xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton": "Ajouter une variable",
"xpack.triggersActionsUI.components.addMessageVariables.deprecatedVariablesAreHidden": "Les variables déclassées sont masquées",
"xpack.triggersActionsUI.components.addMessageVariables.deprecatedVariablesAreShown": "Les variables déclassées sont affichées",
"xpack.triggersActionsUI.components.addMessageVariables.hideDeprecatedVariables": "Masquer",
"xpack.triggersActionsUI.components.addMessageVariables.loadingMessage": "Chargement des variables",
"xpack.triggersActionsUI.components.addMessageVariables.noVariablesAvailable": "Aucune variable disponible",
"xpack.triggersActionsUI.components.addMessageVariables.noVariablesFound": "Aucune variable trouvée",
"xpack.triggersActionsUI.components.addMessageVariables.showAllDeprecatedVariables": "Afficher tout",
"xpack.triggersActionsUI.components.alertTable.useFetchAlerts.errorMessageText": "Une erreur s'est produite lors de la recherche des alertes",
"xpack.triggersActionsUI.components.alertTable.useFetchBrowserFieldsCapabilities.errorMessageText": "Une erreur s'est produite lors du chargement des champs du navigateur",
"xpack.triggersActionsUI.components.builtinActionTypes.indexAction.chooseLabel": "Choisir…",

View file

@ -38421,15 +38421,6 @@
"xpack.triggersActionsUI.common.expressionItems.threshold.andLabel": "AND",
"xpack.triggersActionsUI.common.expressionItems.threshold.descriptionLabel": "タイミング",
"xpack.triggersActionsUI.common.expressionItems.threshold.popoverTitle": "タイミング",
"xpack.triggersActionsUI.components.addMessageVariables.addRuleVariableTitle": "変数を追加",
"xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton": "変数を追加",
"xpack.triggersActionsUI.components.addMessageVariables.deprecatedVariablesAreHidden": "廃止予定の変数は非表示です",
"xpack.triggersActionsUI.components.addMessageVariables.deprecatedVariablesAreShown": "廃止予定の変数は表示されます",
"xpack.triggersActionsUI.components.addMessageVariables.hideDeprecatedVariables": "非表示",
"xpack.triggersActionsUI.components.addMessageVariables.loadingMessage": "変数を読み込み中",
"xpack.triggersActionsUI.components.addMessageVariables.noVariablesAvailable": "変数がありません",
"xpack.triggersActionsUI.components.addMessageVariables.noVariablesFound": "変数が見つかりません",
"xpack.triggersActionsUI.components.addMessageVariables.showAllDeprecatedVariables": "すべて表示",
"xpack.triggersActionsUI.components.alertTable.useFetchAlerts.errorMessageText": "アラート検索でエラーが発生しました",
"xpack.triggersActionsUI.components.alertTable.useFetchBrowserFieldsCapabilities.errorMessageText": "ブラウザーフィールドの読み込み中にエラーが発生しました",
"xpack.triggersActionsUI.components.builtinActionTypes.indexAction.chooseLabel": "選択…",

View file

@ -38415,15 +38415,6 @@
"xpack.triggersActionsUI.common.expressionItems.threshold.andLabel": "且",
"xpack.triggersActionsUI.common.expressionItems.threshold.descriptionLabel": "当",
"xpack.triggersActionsUI.common.expressionItems.threshold.popoverTitle": "当",
"xpack.triggersActionsUI.components.addMessageVariables.addRuleVariableTitle": "添加变量",
"xpack.triggersActionsUI.components.addMessageVariables.addVariablePopoverButton": "添加变量",
"xpack.triggersActionsUI.components.addMessageVariables.deprecatedVariablesAreHidden": "将隐藏已弃用变量",
"xpack.triggersActionsUI.components.addMessageVariables.deprecatedVariablesAreShown": "将显示已弃用变量",
"xpack.triggersActionsUI.components.addMessageVariables.hideDeprecatedVariables": "隐藏",
"xpack.triggersActionsUI.components.addMessageVariables.loadingMessage": "正在加载变量",
"xpack.triggersActionsUI.components.addMessageVariables.noVariablesAvailable": "无变量可用",
"xpack.triggersActionsUI.components.addMessageVariables.noVariablesFound": "找不到变量",
"xpack.triggersActionsUI.components.addMessageVariables.showAllDeprecatedVariables": "全部显示",
"xpack.triggersActionsUI.components.alertTable.useFetchAlerts.errorMessageText": "搜索告警时发生错误",
"xpack.triggersActionsUI.components.alertTable.useFetchBrowserFieldsCapabilities.errorMessageText": "加载浏览器字段时出错",
"xpack.triggersActionsUI.components.builtinActionTypes.indexAction.chooseLabel": "选择……",

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { JsonEditorWithMessageVariables } from './json_editor_with_message_variables';
export { TextFieldWithMessageVariables } from './text_field_with_message_variables';
export { TextAreaWithMessageVariables } from './text_area_with_message_variables';

View file

@ -11,12 +11,11 @@ import { EuiFormRow, EuiCallOut, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { monaco, XJsonLang } from '@kbn/monaco';
import './add_message_variables.scss';
import { XJson } from '@kbn/es-ui-shared-plugin/public';
import { CodeEditor } from '@kbn/kibana-react-plugin/public';
import { ActionVariable } from '@kbn/alerting-plugin/common';
import { AddMessageVariables } from './add_message_variables';
import { AddMessageVariables } from '@kbn/alerts-ui-shared';
import { templateActionVariable } from '../lib';
const NO_EDITOR_ERROR_TITLE = i18n.translate(

View file

@ -7,9 +7,8 @@
import React, { useState } from 'react';
import { EuiTextArea, EuiFormRow } from '@elastic/eui';
import './add_message_variables.scss';
import { ActionVariable } from '@kbn/alerting-plugin/common';
import { AddMessageVariables } from './add_message_variables';
import { AddMessageVariables } from '@kbn/alerts-ui-shared';
import { templateActionVariable } from '../lib';
interface Props {

View file

@ -7,9 +7,8 @@
import React, { useCallback, useMemo, useState } from 'react';
import { EuiFieldText, EuiFormRow } from '@elastic/eui';
import './add_message_variables.scss';
import { ActionVariable } from '@kbn/alerting-plugin/common';
import { AddMessageVariables } from './add_message_variables';
import { AddMessageVariables } from '@kbn/alerts-ui-shared';
import { templateActionVariable } from '../lib';
interface Props {

View file

@ -43,10 +43,10 @@ export function validateParamsForWarnings(
return publicUrlWarning;
}
} catch (e) {
/*
* do nothing, we don't care if the mustache is invalid
*/
// Better to set the warning msg if you do not know if the mustache template is invalid
return publicUrlWarning;
}
}
return null;
}

View file

@ -9780,6 +9780,11 @@
dependencies:
"@types/jest" "*"
"@types/textarea-caret@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/textarea-caret/-/textarea-caret-3.0.1.tgz#5afd4b1c1b3bacb001d76a1e6ef192c710709a86"
integrity sha512-JjrXYzk4t6dM/5nz1hHkZXmd3xSdJM6mOIDSBUrpg4xThwKNryiu4CqHx81LwUJHxEEoQWHTu4fMV4em+c5bXg==
"@types/through@*":
version "0.0.30"
resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895"
@ -28504,6 +28509,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
textarea-caret@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/textarea-caret/-/textarea-caret-3.1.0.tgz#5d5a35bb035fd06b2ff0e25d5359e97f2655087f"
integrity sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==
throttle-debounce@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.1.0.tgz#257e648f0a56bd9e54fe0f132c4ab8611df4e1d5"