Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Fleet] Better input for multi text input in agent policy builder #101020

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* 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 from 'react';
import { fireEvent, act } from '@testing-library/react';

import { createTestRendererMock } from '../../../../mock';

import { MultiTextInput } from './multi_text_input';

function renderInput(value = ['value1']) {
const renderer = createTestRendererMock();
const mockOnChange = jest.fn();

const utils = renderer.render(<MultiTextInput value={value} onChange={mockOnChange} />);

return { utils, mockOnChange };
}

test('it should allow to add a new value', async () => {
const { utils, mockOnChange } = renderInput();

const addRowEl = await utils.findByText('Add row');
fireEvent.click(addRowEl);

expect(mockOnChange).toHaveBeenCalledWith(['value1']);

const inputEl = await utils.findByDisplayValue('');
expect(inputEl).toBeDefined();

fireEvent.change(inputEl, { target: { value: 'value2' } });

expect(mockOnChange).toHaveBeenCalledWith(['value1', 'value2']);
});

test('it should not show the delete button if there only one row', async () => {
const { utils } = renderInput(['value1']);

await act(async () => {
const deleteRowEl = await utils.container.querySelector('[aria-label="Delete row"]');
expect(deleteRowEl).toBeNull();
});
});

test('it should allow to update existing value', async () => {
const { utils, mockOnChange } = renderInput(['value1', 'value2']);

const inputEl = await utils.findByDisplayValue('value1');
expect(inputEl).toBeDefined();

fireEvent.change(inputEl, { target: { value: 'value1updated' } });

expect(mockOnChange).toHaveBeenCalledWith(['value1updated', 'value2']);
});

test('it should allow to remove a row', async () => {
const { utils, mockOnChange } = renderInput(['value1', 'value2']);

await act(async () => {
const deleteRowEl = await utils.container.querySelector('[aria-label="Delete row"]');
if (!deleteRowEl) {
throw new Error('Delete row button not found');
}
fireEvent.click(deleteRowEl);
});

expect(mockOnChange).toHaveBeenCalledWith(['value2']);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/*
* 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, { useCallback, useState, useEffect } from 'react';
import type { FunctionComponent, ChangeEvent } from 'react';

import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiFieldText,
EuiButtonIcon,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';

interface Props {
value: string[];
onChange: (newValue: string[]) => void;
onBlur?: () => void;
errors?: Array<{ message: string; index?: number }>;
isInvalid?: boolean;
isDisabled?: boolean;
}

interface RowProps {
index: number;
value: string;
onChange: (index: number, value: string) => void;
onDelete: (index: number) => void;
onBlur?: () => void;
autoFocus?: boolean;
isDisabled?: boolean;
showDeleteButton?: boolean;
}

const Row: FunctionComponent<RowProps> = ({
index,
value,
onChange,
onDelete,
onBlur,
autoFocus,
isDisabled,
showDeleteButton,
}) => {
const onDeleteHandler = useCallback(() => {
onDelete(index);
}, [onDelete, index]);

const onChangeHandler = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange(index, e.target.value);
},
[onChange, index]
);

return (
<EuiFlexGroup alignItems="center" gutterSize="none" responsive={false}>
<EuiFlexItem>
<EuiFieldText
fullWidth
value={value}
onChange={onChangeHandler}
autoFocus={autoFocus}
disabled={isDisabled}
onBlur={onBlur}
/>
</EuiFlexItem>
{showDeleteButton && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="text"
onClick={onDeleteHandler}
iconType="cross"
disabled={isDisabled}
aria-label={i18n.translate('xpack.fleet.multiTextInput.deleteRowButton', {
defaultMessage: 'Delete row',
})}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

function defaultValue(value: string[]) {
return value.length > 0 ? value : [''];
}

export const MultiTextInput: FunctionComponent<Props> = ({
jen-huang marked this conversation as resolved.
Show resolved Hide resolved
value,
onChange,
onBlur,
isInvalid,
isDisabled,
errors,
}) => {
const [autoFocus, setAutoFocus] = useState(false);
const [rows, setRows] = useState(() => defaultValue(value));
const [previousRows, setPreviousRows] = useState(rows);

useEffect(() => {
if (previousRows === rows) {
return;
}
setPreviousRows(rows);
if (rows[rows.length - 1] === '') {
onChange(rows.slice(0, rows.length - 1));
} else {
onChange(rows);
}
}, [onChange, previousRows, rows]);

const onDeleteHandler = useCallback(
(idx: number) => {
setRows([...rows.slice(0, idx), ...rows.slice(idx + 1)]);
},
[rows]
);

const onChangeHandler = useCallback(
(idx: number, newValue: string) => {
const newRows = [...rows];
newRows[idx] = newValue;
setRows(newRows);
},
[rows]
);

const addRowHandler = useCallback(() => {
setAutoFocus(true);
setRows([...rows, '']);
}, [rows]);

return (
<>
<EuiFlexGroup gutterSize="s" direction="column">
{rows.map((row, idx) => (
<EuiFlexItem key={idx}>
<Row
index={idx}
onChange={onChangeHandler}
onDelete={onDeleteHandler}
onBlur={onBlur}
value={row}
autoFocus={autoFocus}
isDisabled={isDisabled}
showDeleteButton={rows.length > 1}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiButtonEmpty
disabled={isDisabled}
size="xs"
flush="left"
iconType="plusInCircle"
onClick={addRowHandler}
>
<FormattedMessage id="xpack.fleet.multiTextInput.addRow" defaultMessage="Add row" />
</EuiButtonEmpty>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
EuiFormRow,
EuiSwitch,
EuiFieldText,
EuiComboBox,
EuiText,
EuiCodeEditor,
EuiTextArea,
Expand All @@ -23,6 +22,7 @@ import type { RegistryVarsEntry } from '../../../../types';

import 'brace/mode/yaml';
import 'brace/theme/textmate';
import { MultiTextInput } from './multi_text_input';

export const PackagePolicyInputVarField: React.FunctionComponent<{
varDef: RegistryVarsEntry;
Expand All @@ -41,16 +41,9 @@ export const PackagePolicyInputVarField: React.FunctionComponent<{
const field = useMemo(() => {
if (multi) {
return (
<EuiComboBox
noSuggestions
isInvalid={isInvalid}
selectedOptions={value.map((val: string) => ({ label: val }))}
onCreateOption={(newVal: any) => {
onChange([...value, newVal]);
}}
onChange={(newVals: any[]) => {
onChange(newVals.map((val) => val.label));
}}
<MultiTextInput
value={value}
onChange={onChange}
onBlur={() => setIsDirty(true)}
isDisabled={frozen}
/>
Expand Down