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 add072df13..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" @@ -38,6 +39,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/__e2e__/reorder-with-buttons.e2e.stories.js b/components/transfer/src/__e2e__/reorder-with-buttons.e2e.stories.js index c6b82f7b54..abbd1e236d 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.bind({}) + HasSomeSelected.story = { + decorators: [ + statefulDecorator({ + initialState: options.slice(0, 8).map(({ value }) => value), + }), + ], +} + +export const HasThreeSelected = renderReorderTransfer.bind({}) + +HasThreeSelected.story = { decorators: [ statefulDecorator({ initialState: options.slice(0, 3).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..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', () => { @@ -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,97 @@ 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')) + return $option + }) + ) + .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/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..1dfce0133f 100644 --- a/components/transfer/src/reordering-actions.js +++ b/components/transfer/src/reordering-actions.js @@ -1,65 +1,136 @@ 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' +import i18n from './locales/index.js' + +const filterActiveTooltip = i18n.t('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..e5f995fc6b 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,73 @@ export const Transfer = ({ {enableOrderChange && ( + onChangeUp={() => { + const highlightedSet = new Set( + highlightedPickedOptions + ) + reorderScrollTargetRef.current = + selected.find((value) => + highlightedSet.has(value) + ) ?? null moveHighlightedPickedOptionUp({ selected, highlightedPickedOptions, onChange, }) - } + }} onChangeDown={() => { + const highlightedSet = new Set( + highlightedPickedOptions + ) + reorderScrollTargetRef.current = + selected.findLast((value) => + highlightedSet.has(value) + ) ?? null moveHighlightedPickedOptionDown({ selected, highlightedPickedOptions, onChange, }) }} + onChangeToTop={() => { + const highlightedSet = new Set( + highlightedPickedOptions + ) + reorderScrollTargetRef.current = + selected.find((value) => + highlightedSet.has(value) + ) ?? null + moveHighlightedPickedOptionToTop({ + selected, + highlightedPickedOptions, + onChange, + }) + }} + onChangeToBottom={() => { + const highlightedSet = new Set( + highlightedPickedOptions + ) + reorderScrollTargetRef.current = + selected.findLast((value) => + highlightedSet.has(value) + ) ?? null + moveHighlightedPickedOptionToBottom({ + selected, + highlightedPickedOptions, + onChange, + }) + }} /> )} 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({}) 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..d405e94a1c 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.at(-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 })