From 0fac45e919bc746ccca0c5dccf503ccd995e389d Mon Sep 17 00:00:00 2001 From: Joseph John Aas Cooper <33054985+cooper-joe@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:42:26 +0200 Subject: [PATCH 1/7] feat(transfer): reorder multiple items, reorder to top and to bottom, disable filtered reorder --- components/transfer/package.json | 1 + components/transfer/src/icons.js | 60 +++++-- components/transfer/src/reordering-actions.js | 154 +++++++++++++----- components/transfer/src/transfer.js | 85 +++++++++- .../get-highlighted-picked-indices.js | 26 +++ components/transfer/src/transfer/index.js | 3 + .../src/transfer/is-reorder-down-disabled.js | 35 +++- .../src/transfer/is-reorder-up-disabled.js | 31 +++- .../move-highlighted-picked-option-down.js | 43 +++-- ...ove-highlighted-picked-option-to-bottom.js | 44 +++++ .../move-highlighted-picked-option-to-top.js | 38 +++++ .../move-highlighted-picked-option-up.js | 36 ++-- 12 files changed, 464 insertions(+), 92 deletions(-) create mode 100644 components/transfer/src/transfer/get-highlighted-picked-indices.js create mode 100644 components/transfer/src/transfer/move-highlighted-picked-option-to-bottom.js create mode 100644 components/transfer/src/transfer/move-highlighted-picked-option-to-top.js diff --git a/components/transfer/package.json b/components/transfer/package.json index add072df13..6b08f1a52d 100644 --- a/components/transfer/package.json +++ b/components/transfer/package.json @@ -38,6 +38,7 @@ "@dhis2-ui/input": "10.13.1", "@dhis2-ui/intersection-detector": "10.13.1", "@dhis2-ui/loader": "10.13.1", + "@dhis2-ui/tooltip": "10.13.1", "@dhis2/ui-constants": "10.13.1", "classnames": "^2.3.1", "prop-types": "^15.7.2" diff --git a/components/transfer/src/icons.js b/components/transfer/src/icons.js index ddbda732c4..2b1d2b145b 100644 --- a/components/transfer/src/icons.js +++ b/components/transfer/src/icons.js @@ -1,4 +1,4 @@ -import { theme } from '@dhis2/ui-constants' +import { colors, theme } from '@dhis2/ui-constants' import PropTypes from 'prop-types' import React from 'react' import css from 'styled-jsx/css' @@ -20,7 +20,7 @@ export const IconAddAll = ({ dataTest, disabled }) => ( height="16" viewBox="0 0 16 16" data-test={dataTest} - fill={disabled ? theme.disabled : '#404B5A'} + fill={disabled ? theme.disabled : colors.grey800} > (
(
(
( - + ) @@ -144,17 +141,14 @@ IconMoveDown.propTypes = { export const IconMoveUp = ({ dataTest, disabled }) => ( - + ) @@ -162,3 +156,39 @@ IconMoveUp.propTypes = { dataTest: PropTypes.string.isRequired, disabled: PropTypes.bool, } + +export const IconMoveToTop = ({ dataTest, disabled }) => ( + + + +) + +IconMoveToTop.propTypes = { + dataTest: PropTypes.string.isRequired, + disabled: PropTypes.bool, +} + +export const IconMoveToBottom = ({ dataTest, disabled }) => ( + + + +) + +IconMoveToBottom.propTypes = { + dataTest: PropTypes.string.isRequired, + disabled: PropTypes.bool, +} diff --git a/components/transfer/src/reordering-actions.js b/components/transfer/src/reordering-actions.js index 21392543aa..ca3cca80a6 100644 --- a/components/transfer/src/reordering-actions.js +++ b/components/transfer/src/reordering-actions.js @@ -1,65 +1,135 @@ import { spacers } from '@dhis2/ui-constants' import { Button } from '@dhis2-ui/button' +import { Tooltip } from '@dhis2-ui/tooltip' import PropTypes from 'prop-types' import React from 'react' -import { IconMoveDown, IconMoveUp } from './icons.js' +import { + IconMoveDown, + IconMoveToBottom, + IconMoveToTop, + IconMoveUp, +} from './icons.js' + +const filterActiveTooltip = 'Reordering not allowed when filtering list' export const ReorderingActions = ({ dataTest, disabledDown, disabledUp, + filterActive, onChangeUp, onChangeDown, -}) => ( -
-
+ ) - div > :global(button):first-child { - margin-inline-start: ${spacers.dp8}; - } - `} -
-) + if (filterActive) { + return ( + + {({ onMouseOver, onMouseOut, onFocus, onBlur, ref }) => + renderButtons({ + ref, + onMouseOver, + onMouseOut, + onFocus, + onBlur, + }) + } + + ) + } + + return renderButtons() +} ReorderingActions.propTypes = { dataTest: PropTypes.string.isRequired, onChangeDown: PropTypes.func.isRequired, + onChangeToBottom: PropTypes.func.isRequired, + onChangeToTop: PropTypes.func.isRequired, onChangeUp: PropTypes.func.isRequired, disabledDown: PropTypes.bool, disabledUp: PropTypes.bool, + filterActive: PropTypes.bool, } diff --git a/components/transfer/src/transfer.js b/components/transfer/src/transfer.js index 2f185ee485..d77a289dfc 100644 --- a/components/transfer/src/transfer.js +++ b/components/transfer/src/transfer.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types' -import React, { useMemo } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' import { Actions } from './actions.js' import { AddAll } from './add-all.js' import { AddIndividual } from './add-individual.js' @@ -24,6 +24,8 @@ import { isReorderDownDisabled, isReorderUpDisabled, moveHighlightedPickedOptionDown, + moveHighlightedPickedOptionToBottom, + moveHighlightedPickedOptionToTop, moveHighlightedPickedOptionUp, removeAllPickedOptions, removeIndividualPickedOptions, @@ -136,6 +138,8 @@ export const Transfer = ({ filterCallback: filterCallbackPicked, }) + const filterActivePicked = Boolean(actualFilterPicked) + /* * Actual picked options: * Extract the selected options. Can't use `options.filter` @@ -196,6 +200,28 @@ export const Transfer = ({ maxSelections, }) + /* + * Reorder scroll-into-view: + * After a reorder move, scroll the moved block so the leading edge + * (top on Up, bottom on Down) is visible inside the picked-side + * scroll container. `block: 'nearest'` means no scroll when the + * element is already fully in view. + */ + const reorderScrollTargetRef = useRef(null) + useEffect(() => { + const target = reorderScrollTargetRef.current + if (target == null) { + return + } + reorderScrollTargetRef.current = null + const element = document.querySelector( + `[data-test="${dataTest}-pickedoptions"] [data-value="${CSS.escape( + target + )}"]` + ) + element?.scrollIntoView({ block: 'nearest' }) + }, [selected, dataTest]) + /** * Disabled button states */ @@ -368,28 +394,81 @@ export const Transfer = ({ {enableOrderChange && ( + onChangeUp={() => { + const highlightedSet = new Set( + highlightedPickedOptions + ) + const orderedHighlighted = selected.filter( + (value) => highlightedSet.has(value) + ) + reorderScrollTargetRef.current = + orderedHighlighted[0] ?? null moveHighlightedPickedOptionUp({ selected, highlightedPickedOptions, onChange, }) - } + }} onChangeDown={() => { + const highlightedSet = new Set( + highlightedPickedOptions + ) + const orderedHighlighted = selected.filter( + (value) => highlightedSet.has(value) + ) + reorderScrollTargetRef.current = + orderedHighlighted[ + orderedHighlighted.length - 1 + ] ?? null moveHighlightedPickedOptionDown({ selected, highlightedPickedOptions, onChange, }) }} + onChangeToTop={() => { + const highlightedSet = new Set( + highlightedPickedOptions + ) + const orderedHighlighted = selected.filter( + (value) => highlightedSet.has(value) + ) + reorderScrollTargetRef.current = + orderedHighlighted[0] ?? null + moveHighlightedPickedOptionToTop({ + selected, + highlightedPickedOptions, + onChange, + }) + }} + onChangeToBottom={() => { + const highlightedSet = new Set( + highlightedPickedOptions + ) + const orderedHighlighted = selected.filter( + (value) => highlightedSet.has(value) + ) + reorderScrollTargetRef.current = + orderedHighlighted[ + orderedHighlighted.length - 1 + ] ?? null + moveHighlightedPickedOptionToBottom({ + selected, + highlightedPickedOptions, + onChange, + }) + }} /> )} diff --git a/components/transfer/src/transfer/get-highlighted-picked-indices.js b/components/transfer/src/transfer/get-highlighted-picked-indices.js new file mode 100644 index 0000000000..2e72b89672 --- /dev/null +++ b/components/transfer/src/transfer/get-highlighted-picked-indices.js @@ -0,0 +1,26 @@ +/** + * Returns the indices, in ascending order, of `highlightedPickedOptions` + * within `selected`. Values in `highlightedPickedOptions` that don't appear + * in `selected` are ignored, so callers can pass a potentially stale + * highlight list without filtering first. + * + * Runs in O(n) over `selected` via a Set lookup. + * + * @param {Object} args + * @param {string[]} args.selected + * @param {string[]} args.highlightedPickedOptions + * @returns {number[]} + */ +export const getHighlightedPickedIndices = ({ + selected, + highlightedPickedOptions, +}) => { + const highlightedSet = new Set(highlightedPickedOptions) + const indices = [] + selected.forEach((value, index) => { + if (highlightedSet.has(value)) { + indices.push(index) + } + }) + return indices +} diff --git a/components/transfer/src/transfer/index.js b/components/transfer/src/transfer/index.js index 8e52264173..bfaf64b8a3 100644 --- a/components/transfer/src/transfer/index.js +++ b/components/transfer/src/transfer/index.js @@ -2,10 +2,13 @@ export * from './add-all-selectable-source-options.js' export * from './add-individual-source-options.js' export * from './create-double-click-handlers.js' export * from './default-filter-callback.js' +export * from './get-highlighted-picked-indices.js' export * from './get-option-click-handlers.js' export * from './is-reorder-down-disabled.js' export * from './is-reorder-up-disabled.js' export * from './move-highlighted-picked-option-down.js' +export * from './move-highlighted-picked-option-to-bottom.js' +export * from './move-highlighted-picked-option-to-top.js' export * from './move-highlighted-picked-option-up.js' export * from './remove-all-picked-options.js' export * from './remove-individual-picked-options.js' diff --git a/components/transfer/src/transfer/is-reorder-down-disabled.js b/components/transfer/src/transfer/is-reorder-down-disabled.js index 10f1042db6..687d8ad9f2 100644 --- a/components/transfer/src/transfer/is-reorder-down-disabled.js +++ b/components/transfer/src/transfer/is-reorder-down-disabled.js @@ -1,11 +1,34 @@ +import { getHighlightedPickedIndices } from './get-highlighted-picked-indices.js' + /** * @param {Object} args - * @param {string} args.highlightedPickedOptions + * @param {string[]} args.highlightedPickedOptions * @param {string[]} args.selected + * @param {boolean} [args.filterActivePicked] reorder is disabled while a filter is applied to the picked side * @returns {bool} */ -export const isReorderDownDisabled = ({ highlightedPickedOptions, selected }) => - // only one item can be moved with the buttons - highlightedPickedOptions.length !== 1 || - // can't move an item down if it's the last one - selected.indexOf(highlightedPickedOptions[0]) === selected.length - 1 +export const isReorderDownDisabled = ({ + highlightedPickedOptions, + selected, + filterActivePicked = false, +}) => { + if (filterActivePicked) { + return true + } + + const indices = getHighlightedPickedIndices({ + selected, + highlightedPickedOptions, + }) + + if (indices.length === 0) { + return true + } + + const lastIndex = selected.length - 1 + + // Flush to the bottom: indices are [len-n, ..., len-1] + return indices.every( + (index, i) => index === lastIndex - (indices.length - 1 - i) + ) +} diff --git a/components/transfer/src/transfer/is-reorder-up-disabled.js b/components/transfer/src/transfer/is-reorder-up-disabled.js index 9386ac169e..9ba4e25140 100644 --- a/components/transfer/src/transfer/is-reorder-up-disabled.js +++ b/components/transfer/src/transfer/is-reorder-up-disabled.js @@ -1,11 +1,30 @@ +import { getHighlightedPickedIndices } from './get-highlighted-picked-indices.js' + /** * @param {Object} args - * @param {string} args.highlightedPickedOptions + * @param {string[]} args.highlightedPickedOptions * @param {string[]} args.selected + * @param {boolean} [args.filterActivePicked] reorder is disabled while a filter is applied to the picked side * @returns {bool} */ -export const isReorderUpDisabled = ({ highlightedPickedOptions, selected }) => - // only one item can be moved with the buttons - highlightedPickedOptions.length !== 1 || - // can't move an item up if it's the first one - selected.indexOf(highlightedPickedOptions[0]) === 0 +export const isReorderUpDisabled = ({ + highlightedPickedOptions, + selected, + filterActivePicked = false, +}) => { + if (filterActivePicked) { + return true + } + + const indices = getHighlightedPickedIndices({ + selected, + highlightedPickedOptions, + }) + + if (indices.length === 0) { + return true + } + + // Flush to the top: indices are [0, 1, ..., n-1] + return indices.every((index, i) => index === i) +} diff --git a/components/transfer/src/transfer/move-highlighted-picked-option-down.js b/components/transfer/src/transfer/move-highlighted-picked-option-down.js index c1cd84c9b9..bd84c42ed4 100644 --- a/components/transfer/src/transfer/move-highlighted-picked-option-down.js +++ b/components/transfer/src/transfer/move-highlighted-picked-option-down.js @@ -1,4 +1,11 @@ +import { getHighlightedPickedIndices } from './get-highlighted-picked-indices.js' + /** + * Moves the highlighted picked options down by one slot as a group. + * If the selection is non-contiguous, the group collapses into a contiguous + * block (preserving relative order) with its bottom edge landing at + * `min(selected.length - 1, bottommostHighlightedIndex + 1)`. + * * @param {Object} args * @param {string[]} args.selected * @param {string[]} args.highlightedPickedOptions @@ -10,21 +17,37 @@ export const moveHighlightedPickedOptionDown = ({ highlightedPickedOptions, onChange, }) => { - const optionIndex = selected.findIndex( - (selectedOption) => selectedOption === highlightedPickedOptions[0] - ) + const indices = getHighlightedPickedIndices({ + selected, + highlightedPickedOptions, + }) + + if (indices.length === 0) { + return + } + + const lastIndex = selected.length - 1 - // Can't move down last or non-existing option - if (optionIndex === -1 || optionIndex > selected.length - 2) { + // Already flush to the bottom — nothing to do + if ( + indices.every( + (index, i) => index === lastIndex - (indices.length - 1 - i) + ) + ) { return } - // swap with next item + const indexSet = new Set(indices) + const highlightedBlock = indices.map((index) => selected[index]) + const remaining = selected.filter((_, index) => !indexSet.has(index)) + const bottommost = indices[indices.length - 1] + const targetBottom = Math.min(lastIndex, bottommost + 1) + const insertPos = targetBottom - (indices.length - 1) + const reordered = [ - ...selected.slice(0, optionIndex), - selected[optionIndex + 1], - selected[optionIndex], - ...selected.slice(optionIndex + 2), + ...remaining.slice(0, insertPos), + ...highlightedBlock, + ...remaining.slice(insertPos), ] onChange({ selected: reordered }) diff --git a/components/transfer/src/transfer/move-highlighted-picked-option-to-bottom.js b/components/transfer/src/transfer/move-highlighted-picked-option-to-bottom.js new file mode 100644 index 0000000000..f7de51b621 --- /dev/null +++ b/components/transfer/src/transfer/move-highlighted-picked-option-to-bottom.js @@ -0,0 +1,44 @@ +import { getHighlightedPickedIndices } from './get-highlighted-picked-indices.js' + +/** + * Moves the highlighted picked options to the very bottom of the list as a + * single contiguous block, preserving their relative order. Non-contiguous + * selections are collapsed. + * + * @param {Object} args + * @param {string[]} args.selected + * @param {string[]} args.highlightedPickedOptions + * @param {Function} args.onChange + * @returns {void} + */ +export const moveHighlightedPickedOptionToBottom = ({ + selected, + highlightedPickedOptions, + onChange, +}) => { + const indices = getHighlightedPickedIndices({ + selected, + highlightedPickedOptions, + }) + + if (indices.length === 0) { + return + } + + const lastIndex = selected.length - 1 + + // Already a contiguous block flush to the bottom — nothing to do + if ( + indices.every( + (index, i) => index === lastIndex - (indices.length - 1 - i) + ) + ) { + return + } + + const indexSet = new Set(indices) + const highlightedBlock = indices.map((index) => selected[index]) + const remaining = selected.filter((_, index) => !indexSet.has(index)) + + onChange({ selected: [...remaining, ...highlightedBlock] }) +} diff --git a/components/transfer/src/transfer/move-highlighted-picked-option-to-top.js b/components/transfer/src/transfer/move-highlighted-picked-option-to-top.js new file mode 100644 index 0000000000..c8300698e3 --- /dev/null +++ b/components/transfer/src/transfer/move-highlighted-picked-option-to-top.js @@ -0,0 +1,38 @@ +import { getHighlightedPickedIndices } from './get-highlighted-picked-indices.js' + +/** + * Moves the highlighted picked options to the very top of the list as a + * single contiguous block, preserving their relative order. Non-contiguous + * selections are collapsed. + * + * @param {Object} args + * @param {string[]} args.selected + * @param {string[]} args.highlightedPickedOptions + * @param {Function} args.onChange + * @returns {void} + */ +export const moveHighlightedPickedOptionToTop = ({ + selected, + highlightedPickedOptions, + onChange, +}) => { + const indices = getHighlightedPickedIndices({ + selected, + highlightedPickedOptions, + }) + + if (indices.length === 0) { + return + } + + // Already a contiguous block flush to the top — nothing to do + if (indices.every((index, i) => index === i)) { + return + } + + const indexSet = new Set(indices) + const highlightedBlock = indices.map((index) => selected[index]) + const remaining = selected.filter((_, index) => !indexSet.has(index)) + + onChange({ selected: [...highlightedBlock, ...remaining] }) +} diff --git a/components/transfer/src/transfer/move-highlighted-picked-option-up.js b/components/transfer/src/transfer/move-highlighted-picked-option-up.js index c38f8c3078..f32f18b4a2 100644 --- a/components/transfer/src/transfer/move-highlighted-picked-option-up.js +++ b/components/transfer/src/transfer/move-highlighted-picked-option-up.js @@ -1,4 +1,11 @@ +import { getHighlightedPickedIndices } from './get-highlighted-picked-indices.js' + /** + * Moves the highlighted picked options up by one slot as a group. + * If the selection is non-contiguous, the group collapses into a contiguous + * block (preserving relative order) with its top edge landing at + * `max(0, topmostHighlightedIndex - 1)`. + * * @param {Object} args * @param {string[]} args.selected * @param {string[]} args.highlightedPickedOptions @@ -10,21 +17,30 @@ export const moveHighlightedPickedOptionUp = ({ highlightedPickedOptions, onChange, }) => { - const optionIndex = selected.findIndex( - (selectedOption) => selectedOption === highlightedPickedOptions[0] - ) + const indices = getHighlightedPickedIndices({ + selected, + highlightedPickedOptions, + }) - // Can't move up option at index 0 or non-existing option - if (optionIndex < 1) { + if (indices.length === 0) { return } - // swap with previous item + // Already flush to the top — nothing to do + if (indices.every((index, i) => index === i)) { + return + } + + const indexSet = new Set(indices) + const highlightedBlock = indices.map((index) => selected[index]) + const remaining = selected.filter((_, index) => !indexSet.has(index)) + const topmost = indices[0] + const insertPos = Math.max(0, topmost - 1) + const reordered = [ - ...selected.slice(0, optionIndex - 1), - selected[optionIndex], - selected[optionIndex - 1], - ...selected.slice(optionIndex + 1), + ...remaining.slice(0, insertPos), + ...highlightedBlock, + ...remaining.slice(insertPos), ] onChange({ selected: reordered }) From 0fb7b45f588e39379dc91adba5e4c66d38794115 Mon Sep 17 00:00:00 2001 From: Joseph John Aas Cooper <33054985+cooper-joe@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:17:30 +0200 Subject: [PATCH 2/7] test(transfer): multiselect, top and bottom reorder tests --- .../reorder-with-buttons.e2e.stories.js | 2 +- .../helper/is-reorder-down-disabled.test.js | 71 ++++++-- .../helper/is-reorder-up-disabled.test.js | 71 ++++++-- ...ove-highlighted-picked-option-down.test.js | 89 ++++++++-- ...ighlighted-picked-option-to-bottom.test.js | 101 +++++++++++ ...e-highlighted-picked-option-to-top.test.js | 101 +++++++++++ .../move-highlighted-picked-option-up.test.js | 89 ++++++++-- .../src/__tests__/reordering-actions.test.js | 165 ++++++++++++++++++ .../src/features/reorder-with-buttons.feature | 102 ++++++++++- .../features/reorder-with-buttons/index.js | 112 +++++++++++- .../transfer/src/transfer.prod.stories.js | 70 +++++++- 11 files changed, 916 insertions(+), 57 deletions(-) create mode 100644 components/transfer/src/__tests__/helper/move-highlighted-picked-option-to-bottom.test.js create mode 100644 components/transfer/src/__tests__/helper/move-highlighted-picked-option-to-top.test.js create mode 100644 components/transfer/src/__tests__/reordering-actions.test.js diff --git a/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js b/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js index c6b82f7b54..489d98cd5e 100644 --- a/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js +++ b/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js @@ -17,7 +17,7 @@ export const HasSomeSelected = (_, { selected, onChange }) => ( HasSomeSelected.story = { decorators: [ statefulDecorator({ - initialState: options.slice(0, 3).map(({ value }) => value), + initialState: options.slice(0, 8).map(({ value }) => value), }), ], } diff --git a/components/transfer/src/__tests__/helper/is-reorder-down-disabled.test.js b/components/transfer/src/__tests__/helper/is-reorder-down-disabled.test.js index d59d0ea5a3..170ffb126f 100644 --- a/components/transfer/src/__tests__/helper/is-reorder-down-disabled.test.js +++ b/components/transfer/src/__tests__/helper/is-reorder-down-disabled.test.js @@ -4,44 +4,93 @@ describe('Transfer - isReorderDownDisabled', () => { const selected = ['foo', 'bar', 'baz'] it('should return true when there are no highlighted picked options', () => { - const highlightedPickedOptions = [] const actual = isReorderDownDisabled({ - highlightedPickedOptions, + highlightedPickedOptions: [], selected, }) expect(actual).toBe(true) }) - it('should return true when there are multiple highlighted picked options', () => { - const highlightedPickedOptions = ['bar', 'foo'] + it('should return true if the last picked option is the only highlighted one', () => { const actual = isReorderDownDisabled({ - highlightedPickedOptions, + highlightedPickedOptions: ['baz'], selected, }) expect(actual).toBe(true) }) - it('should return true if the last picked option is highlighted', () => { - const highlightedPickedOptions = ['baz'] + it('should return false when one picked option is highlighted which is not the last one', () => { + const actual = isReorderDownDisabled({ + highlightedPickedOptions: ['bar'], + selected, + }) + expect(actual).toBe(false) + }) + + it('should return false for a contiguous multi-select not flush to the bottom', () => { const actual = isReorderDownDisabled({ - highlightedPickedOptions, + highlightedPickedOptions: ['foo', 'bar'], + selected, + }) + + expect(actual).toBe(false) + }) + + it('should return true for a contiguous multi-select flush to the bottom', () => { + const actual = isReorderDownDisabled({ + highlightedPickedOptions: ['bar', 'baz'], selected, }) expect(actual).toBe(true) }) - it('should return false when one picked option is highlighted which is not the last one', () => { - const highlightedPickedOptions = ['bar'] + it('should return true when all items are highlighted', () => { + const actual = isReorderDownDisabled({ + highlightedPickedOptions: ['foo', 'bar', 'baz'], + selected, + }) + + expect(actual).toBe(true) + }) + it('should return false for a non-contiguous selection containing the last item', () => { const actual = isReorderDownDisabled({ - highlightedPickedOptions, + highlightedPickedOptions: ['foo', 'baz'], selected, }) expect(actual).toBe(false) }) + + it('should ignore highlighted values that do not exist in selected', () => { + const actual = isReorderDownDisabled({ + highlightedPickedOptions: ['ghost', 'foo'], + selected, + }) + + expect(actual).toBe(false) + }) + + it('should return true when all highlighted values are missing from selected', () => { + const actual = isReorderDownDisabled({ + highlightedPickedOptions: ['ghost'], + selected, + }) + + expect(actual).toBe(true) + }) + + it('should return true when a filter is active on the picked side', () => { + const actual = isReorderDownDisabled({ + highlightedPickedOptions: ['foo'], + selected, + filterActivePicked: true, + }) + + expect(actual).toBe(true) + }) }) diff --git a/components/transfer/src/__tests__/helper/is-reorder-up-disabled.test.js b/components/transfer/src/__tests__/helper/is-reorder-up-disabled.test.js index a6728d7d7e..e2cfc407cf 100644 --- a/components/transfer/src/__tests__/helper/is-reorder-up-disabled.test.js +++ b/components/transfer/src/__tests__/helper/is-reorder-up-disabled.test.js @@ -4,44 +4,93 @@ describe('Transfer - isReorderUpDisabled', () => { const selected = ['foo', 'bar', 'baz'] it('should return true when there are no highlighted picked options', () => { - const highlightedPickedOptions = [] const actual = isReorderUpDisabled({ - highlightedPickedOptions, + highlightedPickedOptions: [], selected, }) expect(actual).toBe(true) }) - it('should return true when there are multiple highlighted picked options', () => { - const highlightedPickedOptions = ['bar', 'baz'] + it('should return true if the first picked option is the only highlighted one', () => { const actual = isReorderUpDisabled({ - highlightedPickedOptions, + highlightedPickedOptions: ['foo'], selected, }) expect(actual).toBe(true) }) - it('should return true if the first picked option is highlighted', () => { - const highlightedPickedOptions = ['foo'] + it('should return false when one picked option is highlighted which is not the first one', () => { + const actual = isReorderUpDisabled({ + highlightedPickedOptions: ['baz'], + selected, + }) + + expect(actual).toBe(false) + }) + + it('should return false for a contiguous multi-select not flush to the top', () => { + const actual = isReorderUpDisabled({ + highlightedPickedOptions: ['bar', 'baz'], + selected, + }) + + expect(actual).toBe(false) + }) + + it('should return true for a contiguous multi-select flush to the top', () => { + const actual = isReorderUpDisabled({ + highlightedPickedOptions: ['foo', 'bar'], + selected, + }) + + expect(actual).toBe(true) + }) + it('should return true when all items are highlighted', () => { const actual = isReorderUpDisabled({ - highlightedPickedOptions, + highlightedPickedOptions: ['foo', 'bar', 'baz'], selected, }) expect(actual).toBe(true) }) - it('should return false when one picked option is highlighted which is not the last one', () => { - const highlightedPickedOptions = ['baz'] + it('should return false for a non-contiguous selection containing the first item', () => { + const actual = isReorderUpDisabled({ + highlightedPickedOptions: ['foo', 'baz'], + selected, + }) + + expect(actual).toBe(false) + }) + it('should ignore highlighted values that do not exist in selected', () => { const actual = isReorderUpDisabled({ - highlightedPickedOptions, + highlightedPickedOptions: ['ghost', 'baz'], selected, }) expect(actual).toBe(false) }) + + it('should return true when all highlighted values are missing from selected', () => { + const actual = isReorderUpDisabled({ + highlightedPickedOptions: ['ghost'], + selected, + }) + + expect(actual).toBe(true) + }) + + it('should return true when a filter is active on the picked side', () => { + const actual = isReorderUpDisabled({ + highlightedPickedOptions: ['baz'], + selected, + filterActivePicked: true, + }) + + expect(actual).toBe(true) + }) }) diff --git a/components/transfer/src/__tests__/helper/move-highlighted-picked-option-down.test.js b/components/transfer/src/__tests__/helper/move-highlighted-picked-option-down.test.js index 10ec71e4e8..5a984d5567 100644 --- a/components/transfer/src/__tests__/helper/move-highlighted-picked-option-down.test.js +++ b/components/transfer/src/__tests__/helper/move-highlighted-picked-option-down.test.js @@ -2,18 +2,15 @@ import { moveHighlightedPickedOptionDown } from '../../transfer/move-highlighted describe('Transfer - moveHighlightedPickedOptionDown', () => { const onChange = jest.fn() - const selected = ['foo', 'bar', 'baz'] afterEach(() => { onChange.mockClear() }) - it('should move the highlighted option down', () => { - const highlighted = ['bar'] - + it('should move a single highlighted option down', () => { moveHighlightedPickedOptionDown({ - selected, - highlightedPickedOptions: highlighted, + selected: ['foo', 'bar', 'baz'], + highlightedPickedOptions: ['bar'], onChange, }) @@ -23,11 +20,9 @@ describe('Transfer - moveHighlightedPickedOptionDown', () => { }) it('should do nothing when trying to move down the last option', () => { - const highlighted = ['baz'] - moveHighlightedPickedOptionDown({ - selected, - highlightedPickedOptions: highlighted, + selected: ['foo', 'bar', 'baz'], + highlightedPickedOptions: ['baz'], onChange, }) @@ -35,14 +30,82 @@ describe('Transfer - moveHighlightedPickedOptionDown', () => { }) it('should do nothing when trying to move down a non-existing option', () => { - const highlighted = ['foobar'] + moveHighlightedPickedOptionDown({ + selected: ['foo', 'bar', 'baz'], + highlightedPickedOptions: ['ghost'], + onChange, + }) + + expect(onChange).toHaveBeenCalledTimes(0) + }) + + it('should shift a contiguous block of highlighted options down as a group', () => { + moveHighlightedPickedOptionDown({ + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['b', 'c'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'd', 'b', 'c', 'e'], + }) + }) + + it('should collapse and shift a non-contiguous selection down in one call', () => { + moveHighlightedPickedOptionDown({ + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['b', 'd'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'c', 'e', 'b', 'd'], + }) + }) + it('should preserve the relative order of highlighted items regardless of input order', () => { moveHighlightedPickedOptionDown({ - selected, - highlightedPickedOptions: highlighted, + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['d', 'b'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'c', 'e', 'b', 'd'], + }) + }) + + it('should collapse a non-contiguous selection containing the last item without shifting past the end', () => { + moveHighlightedPickedOptionDown({ + selected: ['a', 'b', 'c'], + highlightedPickedOptions: ['a', 'c'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['b', 'a', 'c'], + }) + }) + + it('should do nothing when the highlighted block is already flush to the bottom', () => { + moveHighlightedPickedOptionDown({ + selected: ['a', 'b', 'c', 'd'], + highlightedPickedOptions: ['c', 'd'], onChange, }) expect(onChange).toHaveBeenCalledTimes(0) }) + + it('should ignore highlighted values that do not exist in selected', () => { + moveHighlightedPickedOptionDown({ + selected: ['a', 'b', 'c'], + highlightedPickedOptions: ['ghost', 'a'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['b', 'a', 'c'], + }) + }) }) diff --git a/components/transfer/src/__tests__/helper/move-highlighted-picked-option-to-bottom.test.js b/components/transfer/src/__tests__/helper/move-highlighted-picked-option-to-bottom.test.js new file mode 100644 index 0000000000..52aeadebff --- /dev/null +++ b/components/transfer/src/__tests__/helper/move-highlighted-picked-option-to-bottom.test.js @@ -0,0 +1,101 @@ +import { moveHighlightedPickedOptionToBottom } from '../../transfer/move-highlighted-picked-option-to-bottom.js' + +describe('Transfer - moveHighlightedPickedOptionToBottom', () => { + const onChange = jest.fn() + + afterEach(() => { + onChange.mockClear() + }) + + it('should move a single highlighted option to the bottom', () => { + moveHighlightedPickedOptionToBottom({ + selected: ['a', 'b', 'c', 'd'], + highlightedPickedOptions: ['b'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'c', 'd', 'b'], + }) + }) + + it('should move a contiguous block to the bottom as a group', () => { + moveHighlightedPickedOptionToBottom({ + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['b', 'c'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'd', 'e', 'b', 'c'], + }) + }) + + it('should collapse a non-contiguous selection to the bottom preserving relative order', () => { + moveHighlightedPickedOptionToBottom({ + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['b', 'd'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'c', 'e', 'b', 'd'], + }) + }) + + it('should preserve relative order regardless of highlight input order', () => { + moveHighlightedPickedOptionToBottom({ + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['d', 'b'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'c', 'e', 'b', 'd'], + }) + }) + + it('should do nothing when the highlighted block is already flush to the bottom', () => { + moveHighlightedPickedOptionToBottom({ + selected: ['a', 'b', 'c', 'd'], + highlightedPickedOptions: ['c', 'd'], + onChange, + }) + + expect(onChange).toHaveBeenCalledTimes(0) + }) + + it('should still run when the selection includes the bottom item but has a gap', () => { + moveHighlightedPickedOptionToBottom({ + selected: ['a', 'b', 'c', 'd'], + highlightedPickedOptions: ['b', 'd'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'c', 'b', 'd'], + }) + }) + + it('should do nothing when no highlighted options exist in selected', () => { + moveHighlightedPickedOptionToBottom({ + selected: ['a', 'b', 'c'], + highlightedPickedOptions: ['ghost'], + onChange, + }) + + expect(onChange).toHaveBeenCalledTimes(0) + }) + + it('should ignore highlighted values that do not exist in selected', () => { + moveHighlightedPickedOptionToBottom({ + selected: ['a', 'b', 'c'], + highlightedPickedOptions: ['ghost', 'a'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['b', 'c', 'a'], + }) + }) +}) diff --git a/components/transfer/src/__tests__/helper/move-highlighted-picked-option-to-top.test.js b/components/transfer/src/__tests__/helper/move-highlighted-picked-option-to-top.test.js new file mode 100644 index 0000000000..87ba4fcffa --- /dev/null +++ b/components/transfer/src/__tests__/helper/move-highlighted-picked-option-to-top.test.js @@ -0,0 +1,101 @@ +import { moveHighlightedPickedOptionToTop } from '../../transfer/move-highlighted-picked-option-to-top.js' + +describe('Transfer - moveHighlightedPickedOptionToTop', () => { + const onChange = jest.fn() + + afterEach(() => { + onChange.mockClear() + }) + + it('should move a single highlighted option to the top', () => { + moveHighlightedPickedOptionToTop({ + selected: ['a', 'b', 'c', 'd'], + highlightedPickedOptions: ['c'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['c', 'a', 'b', 'd'], + }) + }) + + it('should move a contiguous block to the top as a group', () => { + moveHighlightedPickedOptionToTop({ + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['c', 'd'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['c', 'd', 'a', 'b', 'e'], + }) + }) + + it('should collapse a non-contiguous selection to the top preserving relative order', () => { + moveHighlightedPickedOptionToTop({ + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['b', 'd'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['b', 'd', 'a', 'c', 'e'], + }) + }) + + it('should preserve relative order regardless of highlight input order', () => { + moveHighlightedPickedOptionToTop({ + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['d', 'b'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['b', 'd', 'a', 'c', 'e'], + }) + }) + + it('should do nothing when the highlighted block is already flush to the top', () => { + moveHighlightedPickedOptionToTop({ + selected: ['a', 'b', 'c', 'd'], + highlightedPickedOptions: ['a', 'b'], + onChange, + }) + + expect(onChange).toHaveBeenCalledTimes(0) + }) + + it('should still run when the selection includes the top item but has a gap', () => { + moveHighlightedPickedOptionToTop({ + selected: ['a', 'b', 'c', 'd'], + highlightedPickedOptions: ['a', 'c'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'c', 'b', 'd'], + }) + }) + + it('should do nothing when no highlighted options exist in selected', () => { + moveHighlightedPickedOptionToTop({ + selected: ['a', 'b', 'c'], + highlightedPickedOptions: ['ghost'], + onChange, + }) + + expect(onChange).toHaveBeenCalledTimes(0) + }) + + it('should ignore highlighted values that do not exist in selected', () => { + moveHighlightedPickedOptionToTop({ + selected: ['a', 'b', 'c'], + highlightedPickedOptions: ['ghost', 'c'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['c', 'a', 'b'], + }) + }) +}) diff --git a/components/transfer/src/__tests__/helper/move-highlighted-picked-option-up.test.js b/components/transfer/src/__tests__/helper/move-highlighted-picked-option-up.test.js index df775212c3..9697b372e0 100644 --- a/components/transfer/src/__tests__/helper/move-highlighted-picked-option-up.test.js +++ b/components/transfer/src/__tests__/helper/move-highlighted-picked-option-up.test.js @@ -2,18 +2,15 @@ import { moveHighlightedPickedOptionUp } from '../../transfer/move-highlighted-p describe('Transfer - moveHighlightedPickedOptionUp', () => { const onChange = jest.fn() - const selected = ['foo', 'bar', 'baz'] afterEach(() => { onChange.mockClear() }) - it('should move the highlighted option up', () => { - const highlighted = ['bar'] - + it('should move a single highlighted option up', () => { moveHighlightedPickedOptionUp({ - selected, - highlightedPickedOptions: highlighted, + selected: ['foo', 'bar', 'baz'], + highlightedPickedOptions: ['bar'], onChange, }) @@ -23,11 +20,9 @@ describe('Transfer - moveHighlightedPickedOptionUp', () => { }) it('should do nothing when trying to move up the first option', () => { - const highlighted = ['foo'] - moveHighlightedPickedOptionUp({ - selected, - highlightedPickedOptions: highlighted, + selected: ['foo', 'bar', 'baz'], + highlightedPickedOptions: ['foo'], onChange, }) @@ -35,14 +30,82 @@ describe('Transfer - moveHighlightedPickedOptionUp', () => { }) it('should do nothing when trying to move up a non-existing option', () => { - const highlighted = ['foobar'] + moveHighlightedPickedOptionUp({ + selected: ['foo', 'bar', 'baz'], + highlightedPickedOptions: ['ghost'], + onChange, + }) + + expect(onChange).toHaveBeenCalledTimes(0) + }) + + it('should shift a contiguous block of highlighted options up as a group', () => { + moveHighlightedPickedOptionUp({ + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['c', 'd'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'c', 'd', 'b', 'e'], + }) + }) + + it('should collapse and shift a non-contiguous selection up in one call', () => { + moveHighlightedPickedOptionUp({ + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['b', 'd'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['b', 'd', 'a', 'c', 'e'], + }) + }) + it('should preserve the relative order of highlighted items regardless of input order', () => { moveHighlightedPickedOptionUp({ - selected, - highlightedPickedOptions: highlighted, + selected: ['a', 'b', 'c', 'd', 'e'], + highlightedPickedOptions: ['d', 'b'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['b', 'd', 'a', 'c', 'e'], + }) + }) + + it('should collapse a non-contiguous selection containing the first item without shifting past index 0', () => { + moveHighlightedPickedOptionUp({ + selected: ['a', 'b', 'c'], + highlightedPickedOptions: ['a', 'c'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'c', 'b'], + }) + }) + + it('should do nothing when the highlighted block is already flush to the top', () => { + moveHighlightedPickedOptionUp({ + selected: ['a', 'b', 'c', 'd'], + highlightedPickedOptions: ['a', 'b'], onChange, }) expect(onChange).toHaveBeenCalledTimes(0) }) + + it('should ignore highlighted values that do not exist in selected', () => { + moveHighlightedPickedOptionUp({ + selected: ['a', 'b', 'c'], + highlightedPickedOptions: ['ghost', 'c'], + onChange, + }) + + expect(onChange).toHaveBeenCalledWith({ + selected: ['a', 'c', 'b'], + }) + }) }) diff --git a/components/transfer/src/__tests__/reordering-actions.test.js b/components/transfer/src/__tests__/reordering-actions.test.js new file mode 100644 index 0000000000..2cde1f6e6f --- /dev/null +++ b/components/transfer/src/__tests__/reordering-actions.test.js @@ -0,0 +1,165 @@ +import { mount } from 'enzyme' +import React from 'react' +import { ReorderingActions } from '../reordering-actions.js' + +const findButton = (wrapper, dataTest) => + wrapper.find(`button[data-test="${dataTest}"]`) + +const allButtonHooks = (dataTest) => [ + `${dataTest}-buttonmovetotop`, + `${dataTest}-buttonmoveup`, + `${dataTest}-buttonmovedown`, + `${dataTest}-buttonmovetobottom`, +] + +describe('Transfer - ReorderingActions', () => { + const dataTest = 'test-reorderingactions' + const baseProps = { + dataTest, + onChangeUp: jest.fn(), + onChangeDown: jest.fn(), + onChangeToTop: jest.fn(), + onChangeToBottom: jest.fn(), + } + + afterEach(() => { + Object.values(baseProps).forEach((value) => { + if (jest.isMockFunction(value)) { + value.mockClear() + } + }) + }) + + it('renders all four reorder buttons', () => { + const wrapper = mount() + + expect(findButton(wrapper, `${dataTest}-buttonmovetotop`)).toHaveLength( + 1 + ) + expect(findButton(wrapper, `${dataTest}-buttonmoveup`)).toHaveLength(1) + expect(findButton(wrapper, `${dataTest}-buttonmovedown`)).toHaveLength( + 1 + ) + expect( + findButton(wrapper, `${dataTest}-buttonmovetobottom`) + ).toHaveLength(1) + }) + + it('invokes the corresponding handler for each button', () => { + const wrapper = mount() + + findButton(wrapper, `${dataTest}-buttonmovetotop`).simulate('click') + expect(baseProps.onChangeToTop).toHaveBeenCalledTimes(1) + + findButton(wrapper, `${dataTest}-buttonmoveup`).simulate('click') + expect(baseProps.onChangeUp).toHaveBeenCalledTimes(1) + + findButton(wrapper, `${dataTest}-buttonmovedown`).simulate('click') + expect(baseProps.onChangeDown).toHaveBeenCalledTimes(1) + + findButton(wrapper, `${dataTest}-buttonmovetobottom`).simulate('click') + expect(baseProps.onChangeToBottom).toHaveBeenCalledTimes(1) + }) + + it('does not cross-fire handlers when a single button is clicked', () => { + const wrapper = mount() + + findButton(wrapper, `${dataTest}-buttonmoveup`).simulate('click') + + expect(baseProps.onChangeUp).toHaveBeenCalledTimes(1) + expect(baseProps.onChangeDown).not.toHaveBeenCalled() + expect(baseProps.onChangeToTop).not.toHaveBeenCalled() + expect(baseProps.onChangeToBottom).not.toHaveBeenCalled() + }) + + it('disables both up-side buttons when disabledUp is true', () => { + const wrapper = mount( + + ) + + expect( + findButton(wrapper, `${dataTest}-buttonmovetotop`).prop('disabled') + ).toBe(true) + expect( + findButton(wrapper, `${dataTest}-buttonmoveup`).prop('disabled') + ).toBe(true) + expect( + findButton(wrapper, `${dataTest}-buttonmovedown`).prop('disabled') + ).toBeFalsy() + expect( + findButton(wrapper, `${dataTest}-buttonmovetobottom`).prop( + 'disabled' + ) + ).toBeFalsy() + }) + + it('disables both down-side buttons when disabledDown is true', () => { + const wrapper = mount( + + ) + + expect( + findButton(wrapper, `${dataTest}-buttonmovedown`).prop('disabled') + ).toBe(true) + expect( + findButton(wrapper, `${dataTest}-buttonmovetobottom`).prop( + 'disabled' + ) + ).toBe(true) + expect( + findButton(wrapper, `${dataTest}-buttonmoveup`).prop('disabled') + ).toBeFalsy() + expect( + findButton(wrapper, `${dataTest}-buttonmovetotop`).prop('disabled') + ).toBeFalsy() + }) + + it('does not invoke handlers for disabled buttons', () => { + const wrapper = mount( + + ) + + findButton(wrapper, `${dataTest}-buttonmovetotop`).simulate('click') + findButton(wrapper, `${dataTest}-buttonmoveup`).simulate('click') + findButton(wrapper, `${dataTest}-buttonmovedown`).simulate('click') + findButton(wrapper, `${dataTest}-buttonmovetobottom`).simulate('click') + + expect(baseProps.onChangeToTop).not.toHaveBeenCalled() + expect(baseProps.onChangeUp).not.toHaveBeenCalled() + expect(baseProps.onChangeDown).not.toHaveBeenCalled() + expect(baseProps.onChangeToBottom).not.toHaveBeenCalled() + }) + + it('sets aria-label on every button', () => { + const wrapper = mount() + + const assertLabel = (hook, label) => { + expect(findButton(wrapper, hook).prop('aria-label')).toBe(label) + } + + assertLabel(`${dataTest}-buttonmovetotop`, 'Move selected items to top') + assertLabel(`${dataTest}-buttonmoveup`, 'Move selected items up') + assertLabel(`${dataTest}-buttonmovedown`, 'Move selected items down') + assertLabel( + `${dataTest}-buttonmovetobottom`, + 'Move selected items to bottom' + ) + }) + + it('still renders and wires up all four buttons when filterActive is true', () => { + const wrapper = mount( + + ) + + allButtonHooks(dataTest).forEach((hook) => { + expect(findButton(wrapper, hook)).toHaveLength(1) + }) + + findButton(wrapper, `${dataTest}-buttonmoveup`).simulate('click') + expect(baseProps.onChangeUp).toHaveBeenCalledTimes(1) + }) +}) diff --git a/components/transfer/src/features/reorder-with-buttons.feature b/components/transfer/src/features/reorder-with-buttons.feature index f087aa1d30..6deebe92e3 100644 --- a/components/transfer/src/features/reorder-with-buttons.feature +++ b/components/transfer/src/features/reorder-with-buttons.feature @@ -35,12 +35,104 @@ Feature: Reorder items in the selected list using buttons | 2 | 3 | | 3 | 3 | + Scenario Outline: The user clicks the 'move to top' button with a highlighted item in the selected list + Given the selected list has three items + And the . item is highlighted + When the user clicks the 'move to top' button + Then the highlighted item should be moved to the . place + + Examples: + | previous | next | + | 1 | 1 | + | 2 | 1 | + | 3 | 1 | + + Scenario Outline: The user clicks the 'move to bottom' button with a highlighted item in the selected list + Given the selected list has three items + And the . item is highlighted + When the user clicks the 'move to bottom' button + Then the highlighted item should be moved to the . place + + Examples: + | previous | next | + | 1 | 3 | + | 2 | 3 | + | 3 | 3 | + Scenario: Disable reorder buttons when no items are highlighted Given the selected list has some items And no items are highlighted in the list - Then the 'move up' and 'move down' buttons should be disabled + Then all four reorder buttons should be disabled - Scenario: Disabled reorder buttons when multiple selected items are highlighted - Given the selected list has some items - And more than one item is highlighted in the list - Then the 'move up' and 'move down' buttons should be disabled + # --- Multi-select --- + # + # All four buttons act on the highlighted picked items as a group, + # preserving the group's relative order. Non-contiguous selections + # collapse into a contiguous block before landing at the target edge. + + Scenario: 'move up' shifts a contiguous block of highlighted items up by one slot + Given the selected list has eight items + When the user highlights the items at positions 3 and 4 + And the user clicks the 'move up' button + Then those items should be at positions 2 and 3 + And those items should still be highlighted + + Scenario: 'move up' collapses and shifts a non-contiguous selection in one press + Given the selected list has eight items + When the user highlights the items at positions 2 and 4 + And the user clicks the 'move up' button + Then those items should be at positions 1 and 2 + + Scenario: 'move down' shifts a contiguous block of highlighted items down by one slot + Given the selected list has eight items + When the user highlights the items at positions 2 and 3 + And the user clicks the 'move down' button + Then those items should be at positions 3 and 4 + + Scenario: 'move down' collapses and shifts a non-contiguous selection in one press + Given the selected list has eight items + When the user highlights the items at positions 2 and 4 + And the user clicks the 'move down' button + Then those items should be at positions 4 and 5 + + Scenario: 'move to top' collapses a non-contiguous selection flush to the top + Given the selected list has eight items + When the user highlights the items at positions 3 and 5 + And the user clicks the 'move to top' button + Then those items should be at positions 1 and 2 + + Scenario: 'move to bottom' collapses a non-contiguous selection flush to the bottom + Given the selected list has eight items + When the user highlights the items at positions 2 and 4 + And the user clicks the 'move to bottom' button + Then those items should be at positions 7 and 8 + + Scenario: The highlighted group stays highlighted after moving, allowing chained presses + Given the selected list has eight items + When the user highlights the items at positions 3 and 4 + And the user clicks the 'move up' button + And the user clicks the 'move up' button + Then those items should be at positions 1 and 2 + And those items should still be highlighted + + Scenario: Both up-side buttons are disabled when the highlighted block is flush to the top + Given the selected list has eight items + When the user highlights the items at positions 1 and 2 + Then the 'move up' and 'move to top' buttons should be disabled + And the 'move down' and 'move to bottom' buttons should not be disabled + + Scenario: Both down-side buttons are disabled when the highlighted block is flush to the bottom + Given the selected list has eight items + When the user highlights the items at positions 7 and 8 + Then the 'move down' and 'move to bottom' buttons should be disabled + And the 'move up' and 'move to top' buttons should not be disabled + + Scenario: Up-side buttons remain enabled when a non-contiguous selection contains the top item + Given the selected list has eight items + When the user highlights the items at positions 1 and 3 + Then the 'move up' and 'move to top' buttons should not be disabled + + Scenario: All four reorder buttons are disabled when every picked item is highlighted + Given the selected list has eight items + When the user highlights every item in the selected list + Then all four reorder buttons should be disabled diff --git a/components/transfer/src/features/reorder-with-buttons/index.js b/components/transfer/src/features/reorder-with-buttons/index.js index b555fd9ced..05ada45d19 100644 --- a/components/transfer/src/features/reorder-with-buttons/index.js +++ b/components/transfer/src/features/reorder-with-buttons/index.js @@ -49,6 +49,20 @@ When("the user clicks the 'move down' button", () => { cy.get('{transfer-reorderingactions-buttonmovedown}').click({ force: true }) }) +When("the user clicks the 'move to top' button", () => { + // force, so we click disabled buttons + cy.get('{transfer-reorderingactions-buttonmovetotop}').click({ + force: true, + }) +}) + +When("the user clicks the 'move to bottom' button", () => { + // force, so we click disabled buttons + cy.get('{transfer-reorderingactions-buttonmovetobottom}').click({ + force: true, + }) +}) + Then('the highlighted item should be moved to the {int}. place', (next) => { const index = next - 1 @@ -58,7 +72,11 @@ Then('the highlighted item should be moved to the {int}. place', (next) => { .should('equal', index) }) -Then("the 'move up' and 'move down' buttons should be disabled", () => { +Then('all four reorder buttons should be disabled', () => { + cy.get('{transfer-reorderingactions-buttonmovetotop}').should( + 'have.attr', + 'disabled' + ) cy.get('{transfer-reorderingactions-buttonmoveup}').should( 'have.attr', 'disabled' @@ -67,4 +85,96 @@ Then("the 'move up' and 'move down' buttons should be disabled", () => { 'have.attr', 'disabled' ) + cy.get('{transfer-reorderingactions-buttonmovetobottom}').should( + 'have.attr', + 'disabled' + ) }) + +// --- Multi-select --- + +Given('the selected list has eight items', () => { + cy.visitStory('Transfer Reorder Buttons', 'Has Some Selected') + cy.get('{transfer-pickedoptions} {transferoption}').should('have.length', 8) +}) + +const highlightPositionWithCtrl = (position) => + cy + .get('{transfer-pickedoptions} {transferoption}') + .eq(position - 1) + .then(($option) => { + cy.get('@highlightedValues').then((values) => { + values.push($option.attr('data-value')) + }) + }) + .clickWith('ctrl') + +When( + 'the user highlights the items at positions {int} and {int}', + (first, second) => { + cy.wrap([]).as('highlightedValues') + highlightPositionWithCtrl(first) + highlightPositionWithCtrl(second) + } +) + +When('the user highlights every item in the selected list', () => { + cy.wrap([]).as('highlightedValues') + cy.get('{transfer-pickedoptions} {transferoption}').each(($option) => { + cy.get('@highlightedValues').then((values) => { + values.push($option.attr('data-value')) + }) + cy.wrap($option).clickWith('ctrl') + }) +}) + +Then('those items should be at positions {int} and {int}', (first, second) => { + cy.get('@highlightedValues').then((values) => { + const targetIndices = [first - 1, second - 1] + values.forEach((value, i) => { + cy.get(`[data-value="${value}"]`) + .invoke('index') + .should('equal', targetIndices[i]) + }) + }) +}) + +Then('those items should still be highlighted', () => { + cy.get('@highlightedValues').then((values) => { + values.forEach((value) => { + cy.get(`{transfer-pickedoptions} [data-value="${value}"]`).should( + 'have.class', + 'highlighted' + ) + }) + }) +}) + +const reorderButtonSelectors = { + 'move up': '{transfer-reorderingactions-buttonmoveup}', + 'move down': '{transfer-reorderingactions-buttonmovedown}', + 'move to top': '{transfer-reorderingactions-buttonmovetotop}', + 'move to bottom': '{transfer-reorderingactions-buttonmovetobottom}', +} + +Then( + 'the {string} and {string} buttons should be disabled', + (first, second) => { + cy.get(reorderButtonSelectors[first]).should('have.attr', 'disabled') + cy.get(reorderButtonSelectors[second]).should('have.attr', 'disabled') + } +) + +Then( + 'the {string} and {string} buttons should not be disabled', + (first, second) => { + cy.get(reorderButtonSelectors[first]).should( + 'not.have.attr', + 'disabled' + ) + cy.get(reorderButtonSelectors[second]).should( + 'not.have.attr', + 'disabled' + ) + } +) diff --git a/components/transfer/src/transfer.prod.stories.js b/components/transfer/src/transfer.prod.stories.js index 7acb83ad78..72357e7086 100644 --- a/components/transfer/src/transfer.prod.stories.js +++ b/components/transfer/src/transfer.prod.stories.js @@ -326,8 +326,74 @@ PickedEmptyComponent.args = { export const Reordering = StatefulTemplate.bind({}) Reordering.args = { enableOrderChange: true, - options: options.slice(0, 4), - initiallySelected: options.slice(0, 4).map(({ value }) => value), + options: options.slice(0, 20), + initiallySelected: options.slice(0, 8).map(({ value }) => value), +} + +export const ReorderingWithPickedFilter = StatefulTemplate.bind({}) +ReorderingWithPickedFilter.storyName = 'Reordering with picked filter' +ReorderingWithPickedFilter.args = { + enableOrderChange: true, + filterablePicked: true, + filterPlaceholderPicked: 'Search picked options', + options: options.slice(0, 20), + initiallySelected: options.slice(0, 8).map(({ value }) => value), +} + +const ReorderingWithRightFooterTemplate = ({ + initiallySelected = [], + ...args +}) => { + const [selected, setSelected] = useState(initiallySelected) + const onChange = (payload) => setSelected(payload.selected) + + return ( + Right footer content + } + /> + ) +} +ReorderingWithRightFooterTemplate.propTypes = { + initiallySelected: PropTypes.array, +} + +export const ReorderingWithRightFooterContent = + ReorderingWithRightFooterTemplate.bind({}) +ReorderingWithRightFooterContent.storyName = + 'Reordering with right footer content' +ReorderingWithRightFooterContent.args = { + enableOrderChange: true, + options: options.slice(0, 20), + initiallySelected: options.slice(0, 8).map(({ value }) => value), +} + +export const ReorderingWithCustomOptions = + StatefulTemplateCustomRenderOption.bind({}) +ReorderingWithCustomOptions.storyName = 'Reordering with custom options' +ReorderingWithCustomOptions.args = { + enableOrderChange: true, + options: options.slice(0, 20), + initiallySelected: options.slice(0, 8).map(({ value }) => value), + renderOption: (option) => { + if (option.value === options[0].value) { + return renderOption(option) + } + + if (option.value === options[2].value) { + return renderOption(option) + } + + if (option.value === options[5].value) { + return renderOption(option) + } + + return + }, } export const IncreasedOptionsHeight = StatefulTemplate.bind({}) From 979a4e5bcbae506773b9f2d8d067aaf1d0d677c9 Mon Sep 17 00:00:00 2001 From: Joseph John Aas Cooper <33054985+cooper-joe@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:34:26 +0200 Subject: [PATCH 3/7] chore: test typos --- .../__e2e__/reorder-with-buttons.e2e.stories.js | 17 +++++++++++++++++ .../src/features/reorder-with-buttons/index.js | 7 ++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js b/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js index 489d98cd5e..36125c0642 100644 --- a/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js +++ b/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js @@ -21,3 +21,20 @@ HasSomeSelected.story = { }), ], } + +export const HasThreeSelected = (_, { selected, onChange }) => ( + +) + +HasThreeSelected.story = { + decorators: [ + statefulDecorator({ + initialState: options.slice(0, 3).map(({ value }) => value), + }), + ], +} diff --git a/components/transfer/src/features/reorder-with-buttons/index.js b/components/transfer/src/features/reorder-with-buttons/index.js index 05ada45d19..a3b7cbac93 100644 --- a/components/transfer/src/features/reorder-with-buttons/index.js +++ b/components/transfer/src/features/reorder-with-buttons/index.js @@ -5,7 +5,7 @@ Given('reordering of items is enabled', () => { }) Given('the selected list has three items', () => { - cy.visitStory('Transfer Reorder Buttons', 'Has Some Selected') + cy.visitStory('Transfer Reorder Buttons', 'Has Three Selected') }) Given('the selected list has some items', () => { @@ -102,11 +102,12 @@ const highlightPositionWithCtrl = (position) => cy .get('{transfer-pickedoptions} {transferoption}') .eq(position - 1) - .then(($option) => { + .then(($option) => cy.get('@highlightedValues').then((values) => { values.push($option.attr('data-value')) + return $option }) - }) + ) .clickWith('ctrl') When( From 7ddddc710918559ccfc88140ae9d4aad2237cef6 Mon Sep 17 00:00:00 2001 From: Joseph John Aas Cooper <33054985+cooper-joe@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:38:17 +0200 Subject: [PATCH 4/7] chore: test fix --- .../src/__e2e__/reorder-with-buttons.e2e.stories.js | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js b/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js index 36125c0642..23b7bfc6c8 100644 --- a/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js +++ b/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js @@ -5,7 +5,7 @@ import { statefulDecorator } from './common/stateful-decorator.js' export default { title: 'Transfer Reorder Buttons' } -export const HasSomeSelected = (_, { selected, onChange }) => ( +const renderReorderTransfer = (_, { selected, onChange }) => ( ( /> ) +export const HasSomeSelected = renderReorderTransfer + HasSomeSelected.story = { decorators: [ statefulDecorator({ @@ -22,14 +24,7 @@ HasSomeSelected.story = { ], } -export const HasThreeSelected = (_, { selected, onChange }) => ( - -) +export const HasThreeSelected = renderReorderTransfer HasThreeSelected.story = { decorators: [ From 99dcc7d5f24ddcaf89188e26bf60a2d4dda77e34 Mon Sep 17 00:00:00 2001 From: Joseph John Aas Cooper <33054985+cooper-joe@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:42:48 +0200 Subject: [PATCH 5/7] chore: sonar fix --- components/transfer/src/transfer.js | 32 +++++++------------ .../move-highlighted-picked-option-down.js | 2 +- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/components/transfer/src/transfer.js b/components/transfer/src/transfer.js index d77a289dfc..e5f995fc6b 100644 --- a/components/transfer/src/transfer.js +++ b/components/transfer/src/transfer.js @@ -409,11 +409,10 @@ export const Transfer = ({ const highlightedSet = new Set( highlightedPickedOptions ) - const orderedHighlighted = selected.filter( - (value) => highlightedSet.has(value) - ) reorderScrollTargetRef.current = - orderedHighlighted[0] ?? null + selected.find((value) => + highlightedSet.has(value) + ) ?? null moveHighlightedPickedOptionUp({ selected, highlightedPickedOptions, @@ -424,13 +423,10 @@ export const Transfer = ({ const highlightedSet = new Set( highlightedPickedOptions ) - const orderedHighlighted = selected.filter( - (value) => highlightedSet.has(value) - ) reorderScrollTargetRef.current = - orderedHighlighted[ - orderedHighlighted.length - 1 - ] ?? null + selected.findLast((value) => + highlightedSet.has(value) + ) ?? null moveHighlightedPickedOptionDown({ selected, highlightedPickedOptions, @@ -441,11 +437,10 @@ export const Transfer = ({ const highlightedSet = new Set( highlightedPickedOptions ) - const orderedHighlighted = selected.filter( - (value) => highlightedSet.has(value) - ) reorderScrollTargetRef.current = - orderedHighlighted[0] ?? null + selected.find((value) => + highlightedSet.has(value) + ) ?? null moveHighlightedPickedOptionToTop({ selected, highlightedPickedOptions, @@ -456,13 +451,10 @@ export const Transfer = ({ const highlightedSet = new Set( highlightedPickedOptions ) - const orderedHighlighted = selected.filter( - (value) => highlightedSet.has(value) - ) reorderScrollTargetRef.current = - orderedHighlighted[ - orderedHighlighted.length - 1 - ] ?? null + selected.findLast((value) => + highlightedSet.has(value) + ) ?? null moveHighlightedPickedOptionToBottom({ selected, highlightedPickedOptions, diff --git a/components/transfer/src/transfer/move-highlighted-picked-option-down.js b/components/transfer/src/transfer/move-highlighted-picked-option-down.js index bd84c42ed4..d405e94a1c 100644 --- a/components/transfer/src/transfer/move-highlighted-picked-option-down.js +++ b/components/transfer/src/transfer/move-highlighted-picked-option-down.js @@ -40,7 +40,7 @@ export const moveHighlightedPickedOptionDown = ({ const indexSet = new Set(indices) const highlightedBlock = indices.map((index) => selected[index]) const remaining = selected.filter((_, index) => !indexSet.has(index)) - const bottommost = indices[indices.length - 1] + const bottommost = indices.at(-1) const targetBottom = Math.min(lastIndex, bottommost + 1) const insertPos = targetBottom - (indices.length - 1) From 41b1c9104e93bfb2b5440a0116b16ff054d64a9c Mon Sep 17 00:00:00 2001 From: Joseph John Aas Cooper <33054985+cooper-joe@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:08:36 +0200 Subject: [PATCH 6/7] chore: test fix --- .../transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js b/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js index 23b7bfc6c8..abbd1e236d 100644 --- a/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js +++ b/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js @@ -14,7 +14,7 @@ const renderReorderTransfer = (_, { selected, onChange }) => ( /> ) -export const HasSomeSelected = renderReorderTransfer +export const HasSomeSelected = renderReorderTransfer.bind({}) HasSomeSelected.story = { decorators: [ @@ -24,7 +24,7 @@ HasSomeSelected.story = { ], } -export const HasThreeSelected = renderReorderTransfer +export const HasThreeSelected = renderReorderTransfer.bind({}) HasThreeSelected.story = { decorators: [ From c2f9548db603451ce75cbffeb4a20af3260ad224 Mon Sep 17 00:00:00 2001 From: Kai Vandivier Date: Wed, 6 May 2026 14:26:43 +0200 Subject: [PATCH 7/7] fix(transfer): add i18n --- components/transfer/i18n/en.pot | 24 +++++++++++++++++++ components/transfer/package.json | 1 + components/transfer/src/reordering-actions.js | 11 +++++---- 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 components/transfer/i18n/en.pot diff --git a/components/transfer/i18n/en.pot b/components/transfer/i18n/en.pot new file mode 100644 index 0000000000..3dd991cbc7 --- /dev/null +++ b/components/transfer/i18n/en.pot @@ -0,0 +1,24 @@ +msgid "" +msgstr "" +"Project-Id-Version: i18next-conv\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"POT-Creation-Date: 2026-05-06T12:25:27.868Z\n" +"PO-Revision-Date: 2026-05-06T12:25:27.868Z\n" + +msgid "Reordering not allowed when filtering list" +msgstr "Reordering not allowed when filtering list" + +msgid "Move selected items to top" +msgstr "Move selected items to top" + +msgid "Move selected items up" +msgstr "Move selected items up" + +msgid "Move selected items down" +msgstr "Move selected items down" + +msgid "Move selected items to bottom" +msgstr "Move selected items to bottom" diff --git a/components/transfer/package.json b/components/transfer/package.json index 6b08f1a52d..5be44726c2 100644 --- a/components/transfer/package.json +++ b/components/transfer/package.json @@ -27,6 +27,7 @@ "test": "d2-app-scripts test --jestConfig ../../jest.config.shared.js" }, "peerDependencies": { + "@dhis2/d2-i18n": "^1", "react": "^16.13 || ^18", "react-dom": "^16.13 || ^18", "styled-jsx": "^4" diff --git a/components/transfer/src/reordering-actions.js b/components/transfer/src/reordering-actions.js index ca3cca80a6..1dfce0133f 100644 --- a/components/transfer/src/reordering-actions.js +++ b/components/transfer/src/reordering-actions.js @@ -9,8 +9,9 @@ import { IconMoveToTop, IconMoveUp, } from './icons.js' +import i18n from './locales/index.js' -const filterActiveTooltip = 'Reordering not allowed when filtering list' +const filterActiveTooltip = i18n.t('Reordering not allowed when filtering list') export const ReorderingActions = ({ dataTest, @@ -22,10 +23,10 @@ export const ReorderingActions = ({ onChangeToTop, onChangeToBottom, }) => { - const moveToTopLabel = 'Move selected items to top' - const moveUpLabel = 'Move selected items up' - const moveDownLabel = 'Move selected items down' - const moveToBottomLabel = 'Move selected items to bottom' + const moveToTopLabel = i18n.t('Move selected items to top') + const moveUpLabel = i18n.t('Move selected items up') + const moveDownLabel = i18n.t('Move selected items down') + const moveToBottomLabel = i18n.t('Move selected items to bottom') const renderButtons = (tooltipHandlers = {}) => (