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 => (
-
-
-
-);