Skip to content

Commit

Permalink
[Fleet] Improve combo box for fleet settings (#100603)
Browse files Browse the repository at this point in the history
  • Loading branch information
nchaulet committed May 28, 2021
1 parent e3517ed commit a0622d5
Show file tree
Hide file tree
Showing 6 changed files with 445 additions and 126 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* 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 { HostsInput } from './hosts_input';

function renderInput(value = ['http://host1.com']) {
const renderer = createTestRendererMock();
const mockOnChange = jest.fn();

const utils = renderer.render(
<HostsInput
value={value}
label="HOST LABEL"
helpText="HELP TEXT"
id="ID"
onChange={mockOnChange}
/>
);

return { utils, mockOnChange };
}

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

const addRowEl = await utils.findByText('Add row');
fireEvent.click(addRowEl);
expect(mockOnChange).toHaveBeenCalledWith(['http://host1.com', '']);
});

test('it should allow to remove an host', async () => {
const { utils, mockOnChange } = renderInput(['http://host1.com', 'http://host2.com']);

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

expect(mockOnChange).toHaveBeenCalledWith(['http://host2.com']);
});

test('it should allow to update existing host with single host', async () => {
const { utils, mockOnChange } = renderInput(['http://host1.com']);

const inputEl = await utils.findByDisplayValue('http://host1.com');
fireEvent.change(inputEl, { target: { value: 'http://newhost.com' } });
expect(mockOnChange).toHaveBeenCalledWith(['http://newhost.com']);
});

test('it should allow to update existing host with multiple hosts', async () => {
const { utils, mockOnChange } = renderInput(['http://host1.com', 'http://host2.com']);

const inputEl = await utils.findByDisplayValue('http://host1.com');
fireEvent.change(inputEl, { target: { value: 'http://newhost.com' } });
expect(mockOnChange).toHaveBeenCalledWith(['http://newhost.com', 'http://host2.com']);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/*
* 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, { useMemo, useCallback, useState } from 'react';
import type { ReactNode, FunctionComponent, ChangeEvent } from 'react';
import sytled, { useTheme } from 'styled-components';

import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiFormRow,
EuiFieldText,
EuiDragDropContext,
EuiDroppable,
EuiDraggable,
EuiIcon,
EuiButtonIcon,
EuiSpacer,
EuiFormHelpText,
euiDragDropReorder,
EuiFormErrorText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';

import type { EuiTheme } from '../../../../../../../../src/plugins/kibana_react/common';

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

interface SortableTextFieldProps {
id: string;
index: number;
value: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onDelete: (index: number) => void;
errors?: string[];
autoFocus?: boolean;
}

const DraggableDiv = sytled.div`
margin: ${(props) => props.theme.eui.euiSizeS};
`;

function displayErrors(errors?: string[]) {
return errors?.length
? errors.map((error, errorIndex) => (
<EuiFormErrorText key={errorIndex}>{error}</EuiFormErrorText>
))
: null;
}

const SortableTextField: FunctionComponent<SortableTextFieldProps> = React.memo(
({ id, index, value, onChange, onDelete, autoFocus, errors }) => {
const onDeleteHandler = useCallback(() => {
onDelete(index);
}, [onDelete, index]);

const isInvalid = (errors?.length ?? 0) > 0;
const theme = useTheme() as EuiTheme;

return (
<EuiDraggable
spacing="m"
index={index}
draggableId={id}
customDragHandle={true}
style={{
paddingLeft: 0,
paddingRight: 0,
}}
>
{(provided, state) => (
<EuiFlexGroup
alignItems="center"
gutterSize="none"
responsive={false}
style={
state.isDragging
? { background: theme.eui.euiPanelBackgroundColorModifiers.plain }
: {}
}
>
<EuiFlexItem grow={false}>
<DraggableDiv
{...provided.dragHandleProps}
aria-label={i18n.translate('xpack.fleet.settings.sortHandle', {
defaultMessage: 'Sort host handle',
})}
>
<EuiIcon color="text" type="grab" />
</DraggableDiv>
</EuiFlexItem>
<EuiFlexItem>
<EuiFieldText
fullWidth
compressed
value={value}
onChange={onChange}
autoFocus={autoFocus}
isInvalid={isInvalid}
/>
{displayErrors(errors)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
color="text"
onClick={onDeleteHandler}
iconType="cross"
aria-label={i18n.translate('xpack.fleet.settings.deleteHostButton', {
defaultMessage: 'Delete host',
})}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiDraggable>
);
}
);

export const HostsInput: FunctionComponent<Props> = ({
id,
value,
onChange,
helpText,
label,
isInvalid,
errors,
}) => {
const [autoFocus, setAutoFocus] = useState(false);
const rows = useMemo(
() =>
value.map((host, idx) => ({
value: host,
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const newValue = [...value];
newValue[idx] = e.target.value;

onChange(newValue);
},
})),
[value, onChange]
);

const onDelete = useCallback(
(idx: number) => {
onChange([...value.slice(0, idx), ...value.slice(idx + 1)]);
},
[value, onChange]
);

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

const onDragEndHandler = useCallback(
({ source, destination }) => {
if (source && destination) {
const items = euiDragDropReorder(value, source.index, destination.index);

onChange(items);
}
},
[value, onChange]
);

const globalErrors = useMemo(() => {
return errors && errors.filter((err) => err.index === undefined).map(({ message }) => message);
}, [errors]);

const indexedErrors = useMemo(() => {
if (!errors) {
return [];
}
return errors.reduce((acc, err) => {
if (err.index === undefined) {
return acc;
}

if (!acc[err.index]) {
acc[err.index] = [];
}

acc[err.index].push(err.message);

return acc;
}, [] as string[][]);
}, [errors]);

const isSortable = rows.length > 1;
return (
<EuiFormRow fullWidth label={label} isInvalid={isInvalid}>
<>
<EuiFormHelpText>{helpText}</EuiFormHelpText>
<EuiSpacer size="m" />
<EuiDragDropContext onDragEnd={onDragEndHandler}>
<EuiDroppable droppableId={`${id}Droppable`} spacing="none">
{rows.map((row, idx) => (
<React.Fragment key={idx}>
{isSortable ? (
<SortableTextField
id={`${id}${idx}Draggable`}
index={idx}
onChange={row.onChange}
onDelete={onDelete}
value={row.value}
autoFocus={autoFocus}
errors={indexedErrors[idx]}
/>
) : (
<>
<EuiFieldText
fullWidth
compressed
value={row.value}
onChange={row.onChange}
isInvalid={!!indexedErrors[idx]}
/>
{displayErrors(indexedErrors[idx])}
</>
)}
</React.Fragment>
))}
</EuiDroppable>
</EuiDragDropContext>
{displayErrors(globalErrors)}
<EuiSpacer size="m" />
<EuiButtonEmpty size="xs" flush="left" iconType="plusInCircle" onClick={addRowHandler}>
<FormattedMessage id="xpack.fleet.hostsInput.addRow" defaultMessage="Add row" />
</EuiButtonEmpty>
</>
</EuiFormRow>
);
};
Loading

0 comments on commit a0622d5

Please sign in to comment.