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] Improve combo box for fleet settings #100603

Merged
merged 6 commits into from
May 28, 2021
Merged
Show file tree
Hide file tree
Changes from 4 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,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