diff --git a/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/DualListOptionItem.js b/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/DualListOptionItem.js new file mode 100644 index 0000000000..0396907487 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/DualListOptionItem.js @@ -0,0 +1,129 @@ +import React, { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Draggable, + DualListSelectorListItem, + Tooltip, +} from '@patternfly/react-core'; + +const TOOLTIP_ENTRY_DELAY = 500; +const TOOLTIP_EXIT_DELAY = 100; + +const DualListOptionItem = ({ + label, + isSelected, + id, + onOptionSelect, + isDraggable, + isDisabled, + showTooltip, +}) => { + const [tooltipTrigger, setTooltipTrigger] = useState(null); + const [tooltipVisible, setTooltipVisible] = useState(false); + const showTimerRef = useRef(); + const hideTimerRef = useRef(); + const isDraggingRef = useRef(false); + + useEffect( + () => () => { + clearTimeout(showTimerRef.current); + clearTimeout(hideTimerRef.current); + }, + [] + ); + + useEffect(() => { + if (!tooltipTrigger || !showTooltip || !isDraggable) { + return undefined; + } + + const onDragStart = () => { + isDraggingRef.current = true; + clearTimeout(showTimerRef.current); + clearTimeout(hideTimerRef.current); + setTooltipVisible(false); + }; + + const onDragEnd = () => { + isDraggingRef.current = false; + }; + + tooltipTrigger.addEventListener('dragstart', onDragStart); + document.addEventListener('mouseup', onDragEnd); + + return () => { + tooltipTrigger.removeEventListener('dragstart', onDragStart); + document.removeEventListener('mouseup', onDragEnd); + }; + }, [tooltipTrigger, showTooltip, isDraggable]); + + const showTooltipHandler = () => { + if (!showTooltip || isDraggingRef.current) { + return; + } + clearTimeout(hideTimerRef.current); + showTimerRef.current = setTimeout(() => { + if (!isDraggingRef.current) { + setTooltipVisible(true); + } + }, TOOLTIP_ENTRY_DELAY); + }; + + const hideTooltipHandler = () => { + clearTimeout(showTimerRef.current); + hideTimerRef.current = setTimeout( + () => setTooltipVisible(false), + TOOLTIP_EXIT_DELAY + ); + }; + + const listItem = ( + + {label} + + ); + + return ( + <> + {isDraggable ? {listItem} : listItem} + {showTooltip && tooltipTrigger && ( + tooltipTrigger} + entryDelay={0} + exitDelay={0} + aria="none" + /> + )} + + ); +}; + +DualListOptionItem.propTypes = { + label: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, + id: PropTypes.string.isRequired, + onOptionSelect: PropTypes.func.isRequired, + isDraggable: PropTypes.bool, + isDisabled: PropTypes.bool, + showTooltip: PropTypes.bool, +}; + +DualListOptionItem.defaultProps = { + isDraggable: false, + isDisabled: false, + showTooltip: true, +}; + +export default DualListOptionItem; diff --git a/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/OrderableDualListSelector.js b/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/OrderableDualListSelector.js new file mode 100644 index 0000000000..cb732f11a2 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/OrderableDualListSelector.js @@ -0,0 +1,266 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + DragDrop, + Droppable, + DualListSelector, + DualListSelectorPane, + DualListSelectorList, + DualListSelectorControlsWrapper, + DualListSelectorControl, +} from '@patternfly/react-core'; +import { + AngleDoubleLeftIcon, + AngleLeftIcon, + AngleDoubleRightIcon, + AngleRightIcon, +} from '@patternfly/react-icons'; + +import { translate as __, sprintf } from '../../../common/I18n'; +import { noop } from '../../../common/helpers'; +import DualListOptionItem from './DualListOptionItem'; +import { + appendUniqueOptions, + moveItems, + normalizeOptions, + optionsToNames, + reorderChosen, +} from './helpers'; + +const defaultSelectState = { availableSelected: [], chosenSelected: [] }; + +/** + * PF5 composable dual list selector with drag-and-drop reordering on the chosen pane. + * The parent owns available and chosen option lists and receives updates via onListChange. + * Options may be strings or { label, value } objects. + */ +const OrderableDualListSelector = ({ + id, + availableOptions, + chosenOptions, + onListChange, + availableOptionsTitle, + chosenOptionsTitle, + isDisabled, + showTooltips, +}) => { + const available = normalizeOptions(availableOptions); + const chosen = normalizeOptions(chosenOptions); + const returnStrings = + (availableOptions.length > 0 || chosenOptions.length > 0) && + [...availableOptions, ...chosenOptions].every( + option => typeof option === 'string' + ); + const [selectState, setSelectState] = useState(defaultSelectState); + const [ignoreNextOptionSelect, setIgnoreNextOptionSelect] = useState(false); + const availableSelectedSet = new Set(selectState.availableSelected); + const chosenSelectedSet = new Set(selectState.chosenSelected); + const selectedSets = { + availableSelected: availableSelectedSet, + chosenSelected: chosenSelectedSet, + }; + + const emitListChange = (nextAvailable, nextChosen) => { + if (returnStrings) { + onListChange(optionsToNames(nextAvailable), optionsToNames(nextChosen)); + return; + } + + onListChange(nextAvailable, nextChosen); + }; + + const isItemSelected = stateName => value => + selectedSets[stateName].has(String(value)); + + const onItemClick = stateName => value => () => { + if (ignoreNextOptionSelect) { + setIgnoreNextOptionSelect(false); + return; + } + + const valueKey = String(value); + if (isItemSelected(stateName)(valueKey)) { + setSelectState({ + ...selectState, + [stateName]: selectState[stateName].filter(item => item !== valueKey), + }); + } else { + setSelectState({ + ...selectState, + [stateName]: [...selectState[stateName], valueKey], + }); + } + }; + + const onMoveSelected = fromAvailable => { + if (fromAvailable) { + const [newAvailable, newChosen] = moveItems( + available, + chosen, + selectState.availableSelected + ); + setSelectState({ ...selectState, availableSelected: [] }); + emitListChange(newAvailable, newChosen); + } else { + const [newChosen, newAvailable] = moveItems( + chosen, + available, + selectState.chosenSelected + ); + setSelectState({ ...selectState, chosenSelected: [] }); + emitListChange(newAvailable, newChosen); + } + }; + + const onMoveAll = fromAvailable => { + if (fromAvailable) { + setSelectState({ ...selectState, availableSelected: [] }); + emitListChange([], appendUniqueOptions(chosen, available)); + } else { + setSelectState({ ...selectState, chosenSelected: [] }); + emitListChange(appendUniqueOptions(available, chosen), []); + } + }; + + const onDrop = (source, dest) => { + if (!dest) { + return false; + } + + const nextChosen = reorderChosen(chosen, source.index, dest.index); + emitListChange(available, nextChosen); + return true; + }; + + return ( + + + + {available.map(option => ( + + ))} + + + + onMoveSelected(true)} + aria-label={__('Add selected')} + > + + + onMoveAll(true)} + aria-label={__('Add all')} + > + + + onMoveAll(false)} + aria-label={__('Remove all')} + > + + + onMoveSelected(false)} + aria-label={__('Remove selected')} + > + + + + { + setIgnoreNextOptionSelect(true); + return true; + }} + onDrop={onDrop} + > + + + + {chosen.map(option => ( + + ))} + + + + + + ); +}; + +OrderableDualListSelector.propTypes = { + id: PropTypes.string.isRequired, + availableOptions: PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, + }), + ]) + ).isRequired, + chosenOptions: PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, + }), + ]) + ).isRequired, + onListChange: PropTypes.func, + availableOptionsTitle: PropTypes.string, + chosenOptionsTitle: PropTypes.string, + isDisabled: PropTypes.bool, + showTooltips: PropTypes.bool, +}; + +OrderableDualListSelector.defaultProps = { + onListChange: noop, + availableOptionsTitle: __('Available options'), + chosenOptionsTitle: __('Chosen options'), + isDisabled: false, + showTooltips: true, +}; + +export default OrderableDualListSelector; diff --git a/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/__tests__/OrderableDualListSelector.test.js b/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/__tests__/OrderableDualListSelector.test.js new file mode 100644 index 0000000000..e38a636214 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/__tests__/OrderableDualListSelector.test.js @@ -0,0 +1,170 @@ +import React, { useState } from 'react'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; + +import OrderableDualListSelector from '../OrderableDualListSelector'; + +// Capture PF DragDrop onDrop so reorder can be exercised without pointer simulation. +const dragDropOnDropRef = { current: null }; + +jest.mock('@patternfly/react-core', () => { + const ReactActual = require('react'); + const actual = jest.requireActual('@patternfly/react-core'); + + return { + ...actual, + DragDrop: props => { + dragDropOnDropRef.current = props.onDrop; + return ReactActual.createElement(actual.DragDrop, props); + }, + }; +}); + +const StatefulDualList = ({ + initialAvailable, + initialChosen, + onListChange = jest.fn(), + ...props +}) => { + const [availableOptions, setAvailableOptions] = useState(initialAvailable); + const [chosenOptions, setChosenOptions] = useState(initialChosen); + + const handleListChange = (nextAvailable, nextChosen) => { + setAvailableOptions(nextAvailable); + setChosenOptions(nextChosen); + onListChange(nextAvailable, nextChosen); + }; + + return ( + + ); +}; + +const defaultProps = { + id: 'ansible-roles', + availableOptions: ['role.a', 'role.b'], + chosenOptions: ['role.c'], + onListChange: jest.fn(), + availableOptionsTitle: 'Available Ansible roles', + chosenOptionsTitle: 'Assigned Ansible roles', +}; + +describe('OrderableDualListSelector', () => { + beforeEach(() => { + jest.clearAllMocks(); + dragDropOnDropRef.current = null; + }); + + it('renders available and chosen options from string lists', () => { + render(); + + expect(screen.getByText('Available Ansible roles')).toBeInTheDocument(); + expect(screen.getByText('Assigned Ansible roles')).toBeInTheDocument(); + expect(screen.getByText('role.a')).toBeInTheDocument(); + expect(screen.getByText('role.b')).toBeInTheDocument(); + expect(screen.getByText('role.c')).toBeInTheDocument(); + }); + + it('moves selected available options to chosen', async () => { + render(); + + await userEvent.click(screen.getByRole('option', { name: 'role.b' })); + await userEvent.click(screen.getByRole('button', { name: 'Add selected' })); + + expect(defaultProps.onListChange).toHaveBeenCalledWith( + ['role.a'], + ['role.c', 'role.b'] + ); + }); + + it('moves selected chosen options to available', async () => { + render(); + + await userEvent.click(screen.getByRole('option', { name: 'role.c' })); + await userEvent.click( + screen.getByRole('button', { name: 'Remove selected' }) + ); + + expect(defaultProps.onListChange).toHaveBeenCalledWith( + ['role.a', 'role.b', 'role.c'], + [] + ); + }); + + it('does not duplicate options when re-adding a removed value', async () => { + const onListChange = jest.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('option', { name: 'role.c' })); + await userEvent.click( + screen.getByRole('button', { name: 'Remove selected' }) + ); + await userEvent.click(screen.getByRole('option', { name: 'role.c' })); + await userEvent.click(screen.getByRole('button', { name: 'Add selected' })); + + expect(onListChange).toHaveBeenLastCalledWith([], ['role.c']); + }); + + it('reorders chosen options on drop', () => { + const onListChange = jest.fn(); + + render( + + ); + + expect(dragDropOnDropRef.current).toEqual(expect.any(Function)); + + let dropResult; + act(() => { + dropResult = dragDropOnDropRef.current({ index: 0 }, { index: 2 }); + }); + + expect(dropResult).toBe(true); + expect(onListChange).toHaveBeenCalledWith( + ['role.a'], + ['role.c', 'role.d', 'role.b'] + ); + }); + + it('does not reorder when drop has no destination', () => { + const onListChange = jest.fn(); + + render( + + ); + + let dropResult; + act(() => { + dropResult = dragDropOnDropRef.current({ index: 0 }, null); + }); + + expect(dropResult).toBe(false); + expect(onListChange).not.toHaveBeenCalled(); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/__tests__/helpers.test.js b/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/__tests__/helpers.test.js new file mode 100644 index 0000000000..7ac2ec924c --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/__tests__/helpers.test.js @@ -0,0 +1,144 @@ +import { + appendUniqueOptions, + chosenValues, + createInitialLists, + moveItems, + namesToOptions, + normalizeOptions, + normalizeSelectedValues, + optionsToNames, + reorderChosen, +} from '../helpers'; + +describe('OrderableDualListSelector helpers', () => { + const options = [ + { label: 'Harddisk', value: 'disk' }, + { label: 'Network', value: 'network' }, + { label: 'CD-ROM', value: 'cdrom' }, + ]; + + describe('normalizeOptions', () => { + it('converts string options to label/value objects', () => { + expect(normalizeOptions(['role.a', 'role.b'])).toEqual([ + { label: 'role.a', value: 'role.a' }, + { label: 'role.b', value: 'role.b' }, + ]); + }); + }); + + describe('namesToOptions and optionsToNames', () => { + it('converts between role names and option objects', () => { + const optionObjects = namesToOptions(['role.a', 'role.b']); + + expect(optionObjects).toEqual([ + { label: 'role.a', value: 'role.a' }, + { label: 'role.b', value: 'role.b' }, + ]); + expect(optionsToNames(optionObjects)).toEqual(['role.a', 'role.b']); + }); + }); + + describe('appendUniqueOptions', () => { + it('does not duplicate options already present in the target list', () => { + const chosen = [{ label: 'Network', value: 'network' }]; + const available = [ + { label: 'Network', value: 'network' }, + { label: 'CD-ROM', value: 'cdrom' }, + ]; + + expect(appendUniqueOptions(chosen, available)).toEqual([ + { label: 'Network', value: 'network' }, + { label: 'CD-ROM', value: 'cdrom' }, + ]); + }); + }); + + describe('moveItems', () => { + it('moves selected options without duplicating existing chosen values', () => { + const available = [ + { label: 'Network', value: 'network' }, + { label: 'CD-ROM', value: 'cdrom' }, + ]; + const chosen = [{ label: 'Network', value: 'network' }]; + + expect(moveItems(available, chosen, ['network', 'cdrom'])).toEqual([ + [], + [ + { label: 'Network', value: 'network' }, + { label: 'CD-ROM', value: 'cdrom' }, + ], + ]); + }); + }); + + describe('reorderChosen', () => { + it('moves an option from source index to destination index', () => { + const chosen = [ + { label: 'Network', value: 'network' }, + { label: 'Harddisk', value: 'disk' }, + { label: 'CD-ROM', value: 'cdrom' }, + ]; + + expect(reorderChosen(chosen, 0, 2).map(option => option.value)).toEqual([ + 'disk', + 'cdrom', + 'network', + ]); + }); + }); + + describe('normalizeSelectedValues', () => { + it('coerces values to strings', () => { + expect(normalizeSelectedValues(['network', 'disk'])).toEqual([ + 'network', + 'disk', + ]); + expect(normalizeSelectedValues('network')).toEqual(['network']); + expect(normalizeSelectedValues(null)).toEqual([]); + }); + }); + + describe('createInitialLists', () => { + it('splits options into available and chosen while preserving order', () => { + const { available, chosen } = createInitialLists(options, [ + 'network', + 'disk', + ]); + + expect(chosen.map(option => option.value)).toEqual(['network', 'disk']); + expect(available.map(option => option.value)).toEqual(['cdrom']); + }); + + it('accepts string options', () => { + const { available, chosen } = createInitialLists( + ['network', 'disk', 'cdrom'], + ['network'] + ); + + expect(chosen).toEqual([{ label: 'network', value: 'network' }]); + expect(available.map(option => option.value)).toEqual(['disk', 'cdrom']); + }); + + it('puts all options in available when nothing is selected', () => { + const { available, chosen } = createInitialLists(options, []); + + expect(chosen).toEqual([]); + expect(available.map(option => option.value)).toEqual([ + 'disk', + 'network', + 'cdrom', + ]); + }); + }); + + describe('chosenValues', () => { + it('returns ordered values from list options', () => { + const chosen = [ + { label: 'Network', value: 'network' }, + { label: 'Harddisk', value: 'disk' }, + ]; + + expect(chosenValues(chosen)).toEqual(['network', 'disk']); + }); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/helpers.js b/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/helpers.js new file mode 100644 index 0000000000..f004076479 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/OrderableDualListSelector/helpers.js @@ -0,0 +1,70 @@ +export const normalizeOption = option => + typeof option === 'string' ? { label: option, value: option } : option; + +export const normalizeOptions = (options = []) => options.map(normalizeOption); + +export const namesToOptions = names => + names.map(name => ({ label: name, value: name })); + +export const optionsToNames = options => + options.map(option => String(option.value)); + +export const appendUniqueOptions = (list1, list2) => { + const existing = new Set(list1.map(option => String(option.value))); + const unique = list2.filter(option => !existing.has(String(option.value))); + + return [...list1, ...unique]; +}; + +export const moveItems = (removeFrom, addTo, selectedValues) => { + const selectedSet = new Set(selectedValues.map(String)); + const newRemoveFrom = removeFrom.filter( + option => !selectedSet.has(String(option.value)) + ); + const moved = selectedValues + .map(value => + removeFrom.find(option => String(option.value) === String(value)) + ) + .filter(Boolean); + + return [newRemoveFrom, appendUniqueOptions(addTo, moved)]; +}; + +export const reorderChosen = (chosenOptions, sourceIndex, destinationIndex) => { + const nextChosen = [...chosenOptions]; + const [removed] = nextChosen.splice(sourceIndex, 1); + nextChosen.splice(destinationIndex, 0, removed); + + return nextChosen; +}; + +export const normalizeSelectedValues = value => { + if (value == null || value === '') { + return []; + } + + if (Array.isArray(value)) { + return value.map(String); + } + + return [String(value)]; +}; + +export const createInitialLists = (options = [], selectedValues = []) => { + const normalizedOptions = normalizeOptions(options); + const optionByValue = Object.fromEntries( + normalizedOptions.map(option => [String(option.value), option]) + ); + const chosen = normalizeSelectedValues(selectedValues) + .map(value => optionByValue[value]) + .filter(Boolean); + const chosenValueSet = new Set(chosen.map(option => String(option.value))); + const available = normalizedOptions.filter( + option => !chosenValueSet.has(String(option.value)) + ); + + return { available, chosen }; +}; + +export const chosenValues = chosenOptions => + chosenOptions.map(option => String(option.value)); diff --git a/webpack/assets/javascripts/react_app/components/common/forms/InputFactory.js b/webpack/assets/javascripts/react_app/components/common/forms/InputFactory.js index 44cfe3478c..520ab97c04 100644 --- a/webpack/assets/javascripts/react_app/components/common/forms/InputFactory.js +++ b/webpack/assets/javascripts/react_app/components/common/forms/InputFactory.js @@ -6,7 +6,7 @@ import { noop } from '../../../common/helpers'; import SearchBar from '../../SearchBar'; import DateTimePicker from '../DateTimePicker/DateTimePicker'; import DatePicker from '../DateTimePicker/DatePicker'; -import OrderableSelect from './OrderableSelect'; +import OrderableDualList from './OrderableDualList/OrderableDualList'; import MemoryAllocationInput from '../../MemoryAllocationInput'; import CounterInput from './CounterInput'; import TimePicker from '../DateTimePicker/TimePicker'; @@ -17,7 +17,7 @@ const inputComponents = { select: Select, date: DatePicker, dateTime: DateTimePicker, - orderableSelect: OrderableSelect, + orderableSelect: OrderableDualList, time: TimePicker, memory: MemoryAllocationInput, counter: CounterInput, @@ -84,6 +84,7 @@ InputFactory.propTypes = { PropTypes.string, PropTypes.number, PropTypes.bool, + PropTypes.array, PropTypes.instanceOf(Date), ]), name: PropTypes.string, diff --git a/webpack/assets/javascripts/react_app/components/common/forms/OrderableDualList/OrderableDualList.js b/webpack/assets/javascripts/react_app/components/common/forms/OrderableDualList/OrderableDualList.js new file mode 100644 index 0000000000..d3fb3c5eb2 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/forms/OrderableDualList/OrderableDualList.js @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { noop } from '../../../../common/helpers'; +import OrderableDualListSelector from '../../OrderableDualListSelector/OrderableDualListSelector'; +import { + createInitialLists, + chosenValues, + normalizeSelectedValues, +} from '../../OrderableDualListSelector/helpers'; + +/** + * Form input wrapper around OrderableDualListSelector for ordered multi-value fields. + * The value can not be changed through props once the component is rendered. + */ +const OrderableDualList = ({ + id, + name, + options, + value, + defaultValue, + disabled, + onChange, + availableOptionsTitle, + chosenOptionsTitle, +}) => { + const initialValue = normalizeSelectedValues( + value != null && value !== '' ? value : defaultValue + ); + const [{ available, chosen }, setLists] = useState(() => + createInitialLists(options, initialValue) + ); + + const handleListChange = (nextAvailable, nextChosen) => { + setLists({ available: nextAvailable, chosen: nextChosen }); + onChange(chosenValues(nextChosen)); + }; + + return ( + <> + + {name && + chosen.map(option => ( + + ))} + + ); +}; + +OrderableDualList.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.object).isRequired, + value: PropTypes.array, + defaultValue: PropTypes.array, + disabled: PropTypes.bool, + onChange: PropTypes.func, + availableOptionsTitle: PropTypes.string, + chosenOptionsTitle: PropTypes.string, +}; + +OrderableDualList.defaultProps = { + name: null, + value: null, + defaultValue: [], + disabled: false, + onChange: noop, + availableOptionsTitle: undefined, + chosenOptionsTitle: undefined, +}; + +export default OrderableDualList; diff --git a/webpack/assets/javascripts/react_app/components/common/forms/OrderableDualList/__tests__/OrderableDualList.test.js b/webpack/assets/javascripts/react_app/components/common/forms/OrderableDualList/__tests__/OrderableDualList.test.js new file mode 100644 index 0000000000..88073579c8 --- /dev/null +++ b/webpack/assets/javascripts/react_app/components/common/forms/OrderableDualList/__tests__/OrderableDualList.test.js @@ -0,0 +1,134 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; + +import OrderableDualList from '../OrderableDualList'; + +// Capture PF DragDrop onDrop so reorder can be exercised without pointer simulation. +const dragDropOnDropRef = { current: null }; + +jest.mock('@patternfly/react-core', () => { + const ReactActual = require('react'); + const actual = jest.requireActual('@patternfly/react-core'); + + return { + ...actual, + DragDrop: props => { + dragDropOnDropRef.current = props.onDrop; + return ReactActual.createElement(actual.DragDrop, props); + }, + }; +}); + +const bootDeviceOptions = [ + { label: 'Harddisk', value: 'disk' }, + { label: 'Network', value: 'network' }, + { label: 'CD-ROM', value: 'cdrom' }, + { label: 'Floppy', value: 'floppy' }, +]; + +const hiddenInputs = container => + [...container.querySelectorAll('input[type="hidden"]')]; + +describe('OrderableDualList', () => { + beforeEach(() => { + dragDropOnDropRef.current = null; + }); + + it('renders hidden inputs in boot order when name is provided', () => { + const { container } = render( + + ); + + const inputs = hiddenInputs(container); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toHaveAttribute('value', 'network'); + expect(inputs[1]).toHaveAttribute('value', 'disk'); + expect(inputs[0]).toHaveAttribute( + 'name', + 'host[compute_attributes][boot_order][]' + ); + }); + + it('moves selected available options into the chosen list', async () => { + const onChange = jest.fn(); + const { container } = render( + + ); + + await userEvent.click(screen.getByRole('option', { name: 'Network' })); + await userEvent.click(screen.getByRole('button', { name: 'Add selected' })); + + expect(onChange).toHaveBeenLastCalledWith(['network']); + + const inputs = hiddenInputs(container); + expect(inputs).toHaveLength(1); + expect(inputs[0]).toHaveAttribute('value', 'network'); + }); + + it('reorders chosen options on drop and updates hidden inputs', () => { + const onChange = jest.fn(); + const { container } = render( + + ); + + expect(dragDropOnDropRef.current).toEqual(expect.any(Function)); + + let dropResult; + act(() => { + dropResult = dragDropOnDropRef.current({ index: 0 }, { index: 2 }); + }); + + expect(dropResult).toBe(true); + expect(onChange).toHaveBeenLastCalledWith(['disk', 'cdrom', 'network']); + + const inputs = hiddenInputs(container); + expect(inputs.map(input => input.getAttribute('value'))).toEqual([ + 'disk', + 'cdrom', + 'network', + ]); + }); + + it('does not reorder when drop has no destination', () => { + const onChange = jest.fn(); + const { container } = render( + + ); + + let dropResult; + act(() => { + dropResult = dragDropOnDropRef.current({ index: 0 }, null); + }); + + expect(dropResult).toBe(false); + expect(onChange).not.toHaveBeenCalled(); + expect(hiddenInputs(container).map(input => input.getAttribute('value'))).toEqual( + ['network', 'disk'] + ); + }); +}); diff --git a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/OrderableSelect.js b/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/OrderableSelect.js deleted file mode 100644 index 9a4cb1ab3f..0000000000 --- a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/OrderableSelect.js +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { set } from 'lodash'; -import { TypeAheadSelect } from 'patternfly-react'; - -import { noop } from '../../../../common/helpers'; -import { orderDragged } from './helpers'; -import { useInternalValue } from './OrderableSelectHooks'; -import OrderableToken from './components/OrderableToken'; - -/** - * Wraps TypeAheadSelect with an Orderable HOC. - * Presumes to be wrapped in a DndProvider context. - * The value can not be changed through props once the component is rendered. - */ -const OrderableSelect = ({ - className, - onChange, - defaultValue, - value, - options, - name, - ...props -}) => { - const [internalValue, setInternalValue] = useInternalValue( - value || defaultValue, - options - ); - const moveDraggedOption = (dragIndex, hoverIndex) => { - setInternalValue(orderDragged(internalValue, dragIndex, hoverIndex)); - }; - - // hack the form-control, which is already in TypeAhead so it would be duplicated - const classesWithoutFormControl = - className && - className - .split(/\s+/) - .filter(el => el !== 'form-control') - .join(' '); - - return ( - ( -
- - {name && } -
- )} - {...props} - className={classesWithoutFormControl} - options={options} - selected={internalValue} - onChange={newValue => { - setInternalValue(newValue); - onChange(newValue); - }} - /> - ); -}; - -OrderableSelect.propTypes = { - options: PropTypes.arrayOf(PropTypes.object).isRequired, - id: PropTypes.string.isRequired, - name: PropTypes.string, - onChange: PropTypes.func, - defaultValue: PropTypes.array, - value: PropTypes.array, - className: PropTypes.string, -}; - -OrderableSelect.defaultProps = { - onChange: noop, - defaultValue: [], - value: null, - name: null, - className: '', -}; - -export default OrderableSelect; diff --git a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/OrderableSelectHooks.js b/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/OrderableSelectHooks.js deleted file mode 100644 index cb8c512882..0000000000 --- a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/OrderableSelectHooks.js +++ /dev/null @@ -1,8 +0,0 @@ -import { useState } from 'react'; - -export const useInternalValue = (value, options) => { - const defaultVal = value - .map(v => options.find(opt => opt.value === v)) - .filter(v => !!v); - return useState(defaultVal); -}; diff --git a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/__tests__/OrderableSelect.test.js b/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/__tests__/OrderableSelect.test.js deleted file mode 100644 index c56603d92b..0000000000 --- a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/__tests__/OrderableSelect.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; -import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; -import { DndProvider } from 'react-dnd'; -import TestBackend from 'react-dnd-test-backend/dist/cjs/TestBackend'; - -import OrderableSelect from '../OrderableSelect'; -import { yesNoOpts } from '../../__fixtures__/Form.fixtures'; - -let backend; -let manager; - -const dndBackendFactory = mngr => { - manager = mngr; - backend = new TestBackend(mngr); - return backend; -}; - -const WrapedInTestContext = props => ( - - - -); - -describe('OrderableSelect', () => { - it('reorders the selected value by dragging', () => { - let selected; - const wrapper = mount( - - ); - const monitor = manager.getMonitor(); - const source = wrapper - .find('#testOrderable-dnk') - .find('DragSource(Orderable(OrderableToken))'); - const target = wrapper - .find('#testOrderable-no') - .find('DropTarget(DragSource(Orderable(OrderableToken)))'); - // hack the client mouse offset - monitor.getClientOffset = jest.fn(() => ({ x: 0, y: 0 })); - - expect(source).toHaveLength(1); - expect(target).toHaveLength(1); - - selected = wrapper.find('Typeahead').prop('selected'); - expect(selected[1].value).toBe('no'); - expect(selected[2].value).toBe('dnk'); - - act(() => { - backend.simulateBeginDrag([source.instance().getHandlerId()]); - backend.simulateHover([target.instance().getHandlerId()]); - backend.simulateDrop(); - backend.simulateEndDrag(); - }); - - // rerender as we expect the hook got different value now - wrapper.update(); - - selected = wrapper.find('Typeahead').prop('selected'); - expect(selected[1].value).toBe('dnk'); - expect(selected[2].value).toBe('no'); - }); - - it('renders inputs if name given', () => { - const value = ['yes', 'no', 'dnk']; - const wrapper = mount( - - ); - const inputs = wrapper.find('input[type="hidden"]'); - expect(inputs).toHaveLength(3); - inputs.forEach((input, idx) => { - expect(input.prop('value')).toBe(value[idx]); - expect(input.prop('name')).toBe('uncertain_select[]'); - }); - }); -}); diff --git a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/__tests__/helpers.test.js b/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/__tests__/helpers.test.js deleted file mode 100644 index 096e6782cd..0000000000 --- a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/__tests__/helpers.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import { orderDragged, makeOnHover } from '../helpers'; - -describe('orderingHelpers', () => { - describe('orderDragged', () => { - it('reorders the given element of the array', () => { - expect(orderDragged([1, 2, 3], 1, 0)).toEqual([2, 1, 3]); - expect(orderDragged([1, 2, 3], 1, 2)).toEqual([1, 3, 2]); - }); - }); - - describe('hoverHandler', () => { - let moveFnc; - let handler; - let monitor; - let component; - - const setup = (direction) => { - const item = { index: 1 }; - const getItem = () => item; - const getClientOffset = () => ({ x: 34, y: 54 }); - const getBoundingClientRect = jest.fn(() => ({ left: 30, right: 40, top: 50, bottom: 60 })); - const getIndex = props => props.index; - const getMoveFnc = props => moveFnc; - - moveFnc = jest.fn(); - handler = makeOnHover(getIndex, getMoveFnc, direction); - monitor = { getItem, getClientOffset }; - component = { getNode: () => ({ getBoundingClientRect }) }; - } - - describe('horizontal movement', () => { - beforeEach(() => { - setup('horizontal') - }); - - it('returns if hovering over dragged item', () => { - handler({ index: 1 }, monitor, component); - expect(moveFnc).not.toBeCalled(); - expect(component.getNode().getBoundingClientRect).not.toBeCalled(); - }); - - it('returns if hovering over item on left, but not crossed half', () => { - monitor.getClientOffset = () => ({ x: 38 }); - handler({ index: 0 }, monitor, component); - expect(component.getNode().getBoundingClientRect).toBeCalled(); - expect(moveFnc).not.toBeCalled(); - }); - - it('returns if hovering over item on right, but not crossed half', () => { - handler({ index: 2 }, monitor, component); - expect(component.getNode().getBoundingClientRect).toBeCalled(); - expect(moveFnc).not.toBeCalled(); - }); - - it('call ordering fnc and sets index of dragged item to target index', () => { - handler({ index: 0 }, monitor, component); - expect(moveFnc).toBeCalledWith(1, 0); - expect(monitor.getItem().index).toEqual(0); - }); - }); - - describe('vertical movement', () => { - beforeEach(() => { - setup('vertical') - }); - - it('returns if hovering over dragged item', () => { - handler({ index: 1 }, monitor, component); - expect(moveFnc).not.toBeCalled(); - expect(component.getNode().getBoundingClientRect).not.toBeCalled(); - }); - - it('returns if hovering over item below, but not crossed half', () => { - monitor.getClientOffset = () => ({ y: 56 }); - handler({ index: 0 }, monitor, component); - expect(component.getNode().getBoundingClientRect).toBeCalled(); - expect(moveFnc).not.toBeCalled(); - }); - - it('returns if hovering over item on above, but not crossed half', () => { - handler({ index: 2 }, monitor, component); - expect(component.getNode().getBoundingClientRect).toBeCalled(); - expect(moveFnc).not.toBeCalled(); - }); - - it('call ordering fnc and sets index of dragged item to target index', () => { - handler({ index: 0 }, monitor, component); - expect(moveFnc).toBeCalledWith(1, 0); - expect(monitor.getItem().index).toEqual(0); - }); - }) - }); -}); diff --git a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/components/OrderableToken.js b/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/components/OrderableToken.js deleted file mode 100644 index 5c9b0889eb..0000000000 --- a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/components/OrderableToken.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { TypeAheadSelect } from 'patternfly-react'; - -import { orderable } from '../helpers'; - -const orderConfig = { - type: 'multiValue', - getItem: props => ({ value: props.data.value }), - getIndex: props => props.data.index, - getMoveFnc: props => props.moveDraggedOption, -}; - -const OrderableToken = ({ - isDragging, - moveDraggedOption, - data, - disabled, - onRemove, - tabIndex, - labelKey, -}) => ( - - {data[labelKey]} - -); - -OrderableToken.propTypes = { - isDragging: PropTypes.bool.isRequired, - moveDraggedOption: PropTypes.func.isRequired, - data: PropTypes.object.isRequired, - labelKey: PropTypes.string.isRequired, - disabled: PropTypes.bool, - tabIndex: PropTypes.number, - onRemove: PropTypes.func, -}; - -OrderableToken.defaultProps = { - disabled: false, - tabIndex: -1, - onRemove: undefined, -}; - -export default orderable(OrderableToken, orderConfig); diff --git a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/helpers.js b/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/helpers.js index 946d1fda07..f547b0ab2f 100644 --- a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/helpers.js +++ b/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/helpers.js @@ -3,6 +3,14 @@ import { DragSource, DropTarget } from 'react-dnd'; import PropTypes from 'prop-types'; import { set } from 'lodash'; +import { deprecate } from '../../../../common/DeprecationService'; + +deprecate( + 'forms/OrderableSelect/helpers', + 'OrderableDualListSelector from foremanReact/components/common/OrderableDualListSelector/OrderableDualListSelector', + '5.1' +); + export const orderDragged = (inputArray, dragIndex, hoverIndex) => { const dragedValue = inputArray[dragIndex]; const ordered = [...inputArray]; diff --git a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/index.js b/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/index.js deleted file mode 100644 index fc39637c90..0000000000 --- a/webpack/assets/javascripts/react_app/components/common/forms/OrderableSelect/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; - -import OrderableSelect from './OrderableSelect'; - -export default props => ( - - - -);