-
Notifications
You must be signed in to change notification settings - Fork 8.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[Fleet] Improve combo box for fleet settings (#100603)
- Loading branch information
Showing
6 changed files
with
445 additions
and
126 deletions.
There are no files selected for viewing
68 changes: 68 additions & 0 deletions
68
...k/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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']); | ||
}); |
247 changes: 247 additions & 0 deletions
247
x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/hosts_input.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
Oops, something went wrong.