diff --git a/packages/widgets/src/Transfer/EndIntersectionDetector.js b/packages/widgets/src/Transfer/EndIntersectionDetector.js new file mode 100644 index 0000000000..609450c8db --- /dev/null +++ b/packages/widgets/src/Transfer/EndIntersectionDetector.js @@ -0,0 +1,35 @@ +import { IntersectionDetector } from '@dhis2/ui-core' +import propTypes from '@dhis2/prop-types' +import React from 'react' + +export const EndIntersectionDetector = ({ + rootRef, + onEndReached, + dataTest, +}) => ( +
+ isIntersecting && onEndReached()} + /> + + +
+) + +EndIntersectionDetector.propTypes = { + rootRef: propTypes.shape({ + current: propTypes.instanceOf(HTMLElement), + }).isRequired, + onEndReached: propTypes.func.isRequired, + dataTest: propTypes.string, +} diff --git a/packages/widgets/src/Transfer/OptionsContainer.js b/packages/widgets/src/Transfer/OptionsContainer.js new file mode 100644 index 0000000000..cb1d0a86ff --- /dev/null +++ b/packages/widgets/src/Transfer/OptionsContainer.js @@ -0,0 +1,144 @@ +import { CircularLoader } from '@dhis2/ui-core' +import { spacers } from '@dhis2/ui-constants' +import React, { Fragment, useEffect, useRef, useState } from 'react' +import propTypes from '@dhis2/prop-types' + +import { EndIntersectionDetector } from './EndIntersectionDetector.js' + +export const OptionsContainer = ({ + dataTest, + emptyComponent, + onEndReached, + getOptionClickHandlers, + highlightedOptions, + loading, + renderOption, + options, + selectionHandler, + toggleHighlightedOption, +}) => { + const [remountCounter, setRemountCounter] = useState(0) + const [resizeObserver, setResizeObserver] = useState(null) + const optionsRef = useRef(null) + const wrapperRef = useRef(null) + + useEffect(() => { + if (onEndReached && wrapperRef.current) { + // The initial call is irrelevant as there has been + // no resize yet that we want to react to + let firstCall = false + + const observer = new ResizeObserver(() => { + if (!firstCall) { + const newCounter = remountCounter + 1 + setRemountCounter(newCounter) + firstCall = true + } + }) + + observer.observe(wrapperRef.current) + setResizeObserver(observer) + + return () => observer.disconnect() + } + }, [onEndReached, wrapperRef.current, setRemountCounter]) + + return ( +
+ {loading && ( +
+ +
+ )} + +
+
+ {!options.length && emptyComponent} + {options.map(option => { + const highlighted = !!highlightedOptions.find( + highlightedSourceOption => + highlightedSourceOption === option.value + ) + + return ( + + {renderOption({ + ...option, + ...getOptionClickHandlers( + option, + selectionHandler, + toggleHighlightedOption + ), + highlighted, + selected: false, + })} + + ) + })} + + {onEndReached && resizeObserver && ( + + )} +
+
+ + +
+ ) +} + +OptionsContainer.propTypes = { + dataTest: propTypes.string.isRequired, + getOptionClickHandlers: propTypes.func.isRequired, + emptyComponent: propTypes.node, + highlightedOptions: propTypes.arrayOf(propTypes.string), + loading: propTypes.bool, + options: propTypes.arrayOf( + propTypes.shape({ + label: propTypes.string.isRequired, + value: propTypes.string.isRequired, + }) + ), + renderOption: propTypes.func, + selectionHandler: propTypes.func, + toggleHighlightedOption: propTypes.func, + onEndReached: propTypes.func, +} diff --git a/packages/widgets/src/Transfer/PickedOptions.js b/packages/widgets/src/Transfer/PickedOptions.js index 78a41a906d..8e585059b1 100644 --- a/packages/widgets/src/Transfer/PickedOptions.js +++ b/packages/widgets/src/Transfer/PickedOptions.js @@ -1,23 +1,39 @@ +import { spacers } from '@dhis2/ui-constants' import React from 'react' import propTypes from '@dhis2/prop-types' -import { spacers } from '@dhis2/ui-constants' +import { EndIntersectionDetector } from './EndIntersectionDetector.js' export const PickedOptions = ({ children, dataTest, selectedEmptyComponent, + pickedOptionsRef, + onPickedEndReached, }) => ( -
- {!React.Children.count(children) && selectedEmptyComponent} - {children} +
+
+ {!React.Children.count(children) && selectedEmptyComponent} + {children} + + {onPickedEndReached && ( + + )} +
) @@ -25,5 +41,9 @@ export const PickedOptions = ({ PickedOptions.propTypes = { children: propTypes.node.isRequired, dataTest: propTypes.string.isRequired, + pickedOptionsRef: propTypes.shape({ + current: propTypes.instanceOf(HTMLElement), + }), selectedEmptyComponent: propTypes.node, + onPickedEndReached: propTypes.func, } diff --git a/packages/widgets/src/Transfer/SourceOptions.js b/packages/widgets/src/Transfer/SourceOptions.js index bfdd24e7fd..38bb236299 100644 --- a/packages/widgets/src/Transfer/SourceOptions.js +++ b/packages/widgets/src/Transfer/SourceOptions.js @@ -1,23 +1,39 @@ +import { spacers } from '@dhis2/ui-constants' import React from 'react' import propTypes from '@dhis2/prop-types' -import { spacers } from '@dhis2/ui-constants' +import { EndIntersectionDetector } from './EndIntersectionDetector.js' export const SourceOptions = ({ children, dataTest, sourceEmptyPlaceholder, + sourceOptionsRef, + onSourceEndReached, }) => ( -
- {children} - {!React.Children.count(children) && sourceEmptyPlaceholder} +
+
+ {children} + {!React.Children.count(children) && sourceEmptyPlaceholder} + + {onSourceEndReached && ( + + )} +
) @@ -26,4 +42,8 @@ SourceOptions.propTypes = { dataTest: propTypes.string.isRequired, children: propTypes.node, sourceEmptyPlaceholder: propTypes.node, + sourceOptionsRef: propTypes.shape({ + current: propTypes.instanceOf(HTMLElement), + }), + onSourceEndReached: propTypes.func, } diff --git a/packages/widgets/src/Transfer/Transfer.js b/packages/widgets/src/Transfer/Transfer.js index c4467cfb1b..c706e9cfd0 100644 --- a/packages/widgets/src/Transfer/Transfer.js +++ b/packages/widgets/src/Transfer/Transfer.js @@ -1,4 +1,4 @@ -import React, { Fragment, useState } from 'react' +import React from 'react' import propTypes from '@dhis2/prop-types' import { Actions } from './Actions.js' @@ -9,14 +9,13 @@ import { Filter } from './Filter.js' import { LeftFooter } from './LeftFooter.js' import { LeftHeader } from './LeftHeader.js' import { LeftSide } from './LeftSide.js' -import { PickedOptions } from './PickedOptions.js' +import { OptionsContainer } from './OptionsContainer.js' import { RemoveAll } from './RemoveAll.js' import { RemoveIndividual } from './RemoveIndividual.js' import { ReorderingActions } from './ReorderingActions.js' import { RightHeader } from './RightHeader.js' import { RightFooter } from './RightFooter.js' import { RightSide } from './RightSide.js' -import { SourceOptions } from './SourceOptions.js' import { TransferOption } from './TransferOption.js' import { addAllSelectableSourceOptions, @@ -30,6 +29,7 @@ import { moveHighlightedPickedOptionUp, removeAllPickedOptions, removeIndividualPickedOptions, + useFilter, useHighlightedOptions, } from './Transfer/index.js' @@ -68,47 +68,64 @@ export const Transfer = ({ options, onChange, + addAllText, + addIndividualText, className, dataTest, disabled, - sourceEmptyPlaceholder, - selectedEmptyComponent, enableOrderChange, + filterCallback, + filterCallbackPicked, filterLabel, + filterLabelPicked, filterPlaceholder, - filterCallback, + filterPlaceholderPicked, filterable, + filterablePicked, height, + hideFilterInput, + hideFilterInputPicked, initialSearchTerm, - addAllText, - addIndividualText, - removeAllText, - removeIndividualText, + initialSearchTermPicked, leftFooter, leftHeader, + loadingPicked, + loading, maxSelections, optionsWidth, + removeAllText, + removeIndividualText, renderOption, - rightHeader, rightFooter, + rightHeader, searchTerm, + searchTermPicked, selected, + selectedEmptyComponent, selectedWidth, - hideFilterInput, + sourceEmptyPlaceholder, onFilterChange, + onFilterChangePicked, + onEndReached, + onEndReachedPicked, }) => { - /* - * Used in the "Filter" section and for - * limiting the selectable source options - * - * Filter can be controlled & uncontrolled. - * Providing the "onFilterChange" callback will make it a controlled value - */ - const [internalFilter, setInternalFilter] = useState(initialSearchTerm) - const actualFilter = onFilterChange ? searchTerm : internalFilter - const actualFilterCallback = filterable ? filterCallback : identity + /* Source options search value: + * Depending on whether the onFilterChange callback has been provided + * either the internal or external search value is used */ + const { + filterValue: actualFilter, + filter: actualFilterCallback, + setInternalFilter, + } = useFilter({ + initialSearchTerm, + onFilterChange, + externalSearchTerm: searchTerm, + filterable, + filterCallback, + }) /* + * Actual source options: * Extract the not-selected options. * Filters options if filterable is true. */ @@ -118,16 +135,7 @@ export const Transfer = ({ ) /* - * Extract the selected options. Can't use `options.filter` - * because we need to keep the order of `selected` - */ - const pickedOptions = selected - .map(value => options.find(option => value === option.value)) - // filter -> in case a selected value has been provided - // that does not exist as option - .filter(identity) - - /* + * Picked options highlighting: * These are all the highlighted options on the options side. */ const { @@ -140,7 +148,37 @@ export const Transfer = ({ maxSelections, }) + /* Picked options search value: + * Depending on whether the onFilterChangePicked callback has been provided + * either the internal or external search value is used */ + const { + filterValue: actualFilterPicked, + filter: actualFilterPickedCallback, + setInternalFilter: setInternalFilterPicked, + } = useFilter({ + filterable: filterablePicked, + initialSearchTerm: initialSearchTermPicked, + onFilterChange: onFilterChangePicked, + externalSearchTerm: searchTermPicked, + filterCallback: filterCallbackPicked, + }) + /* + * Actual picked options: + * Extract the selected options. Can't use `options.filter` + * because we need to keep the order of `selected` + */ + const pickedOptions = actualFilterPickedCallback( + selected + .map(value => options.find(option => value === option.value)) + // filter -> in case a selected value has been provided + // that does not exist as option + .filter(identity), + actualFilterPicked + ) + + /* + * Source options highlighting: * These are all the highlighted options on the selected side. */ const { @@ -154,6 +192,7 @@ export const Transfer = ({ }) /* + * Source & Picked options: * These are the double click handlers for (de-)selection */ const { @@ -202,32 +241,18 @@ export const Transfer = ({ )} - - {sourceOptions.map(option => { - const highlighted = !!highlightedSourceOptions.find( - highlightedSourceOption => - highlightedSourceOption === option.value - ) - - return ( - - {renderOption({ - ...option, - ...getOptionClickHandlers( - option, - selectSingleOption, - toggleHighlightedSourceOption - ), - highlighted, - selected: false, - })} - - ) - })} - + emptyComponent={sourceEmptyPlaceholder} + getOptionClickHandlers={getOptionClickHandlers} + highlightedOptions={highlightedSourceOptions} + loading={loading} + options={sourceOptions} + renderOption={renderOption} + selectionHandler={selectSingleOption} + toggleHighlightedOption={toggleHighlightedSourceOption} + onEndReached={onEndReached} + /> {leftFooter && ( @@ -290,6 +315,8 @@ export const Transfer = ({ disabled={isRemoveIndividualDisabled} onClick={() => removeIndividualPickedOptions({ + filterablePicked, + pickedOptions, highlightedPickedOptions, onChange, selected, @@ -300,36 +327,39 @@ export const Transfer = ({ - {rightHeader && ( + {(rightHeader || filterablePicked) && ( {rightHeader} + + {filterablePicked && !hideFilterInputPicked && ( + + setInternalFilterPicked(value) + } + /> + )} )} - - {pickedOptions.map(option => { - const highlighted = !!highlightedPickedOptions.find( - value => option.value === value - ) - return ( - - {renderOption({ - ...option, - ...getOptionClickHandlers( - option, - deselectSingleOption, - toggleHighlightedPickedOption - ), - highlighted, - selected: true, - })} - - ) - })} - + {(rightFooter || enableOrderChange) && ( @@ -375,12 +405,14 @@ Transfer.defaultProps = { dataTest: 'dhis2-uicore-transfer', height: '240px', initialSearchTerm: '', + initialSearchTermPicked: '', maxSelections: Infinity, optionsWidth: '320px', renderOption: defaultRenderOption, selected: [], selectedWidth: '320px', filterCallback: defaultFilterCallback, + filterCallbackPicked: defaultFilterCallback, } /** @@ -433,14 +465,22 @@ Transfer.propTypes = { disabled: propTypes.bool, enableOrderChange: propTypes.bool, filterCallback: propTypes.func, + filterCallbackPicked: propTypes.func, filterLabel: propTypes.string, + filterLabelPicked: propTypes.string, filterPlaceholder: propTypes.string, + filterPlaceholderPicked: propTypes.string, filterable: propTypes.bool, + filterablePicked: propTypes.bool, height: propTypes.string, hideFilterInput: propTypes.bool, + hideFilterInputPicked: propTypes.bool, initialSearchTerm: propTypes.string, + initialSearchTermPicked: propTypes.string, leftFooter: propTypes.node, leftHeader: propTypes.node, + loading: propTypes.bool, + loadingPicked: propTypes.bool, maxSelections: propTypes.oneOf([1, Infinity]), optionsWidth: propTypes.string, removeAllText: propTypes.string, @@ -449,9 +489,13 @@ Transfer.propTypes = { rightFooter: propTypes.node, rightHeader: propTypes.node, searchTerm: propTypes.string, + searchTermPicked: propTypes.string, selected: propTypes.arrayOf(propTypes.string), selectedEmptyComponent: propTypes.node, selectedWidth: propTypes.string, sourceEmptyPlaceholder: propTypes.node, + onEndReached: propTypes.func, + onEndReachedPicked: propTypes.func, onFilterChange: propTypes.func, + onFilterChangePicked: propTypes.func, } diff --git a/packages/widgets/src/Transfer/Transfer.stories.js b/packages/widgets/src/Transfer/Transfer.stories.js index 69b9e8cc08..f0faa9d4e6 100644 --- a/packages/widgets/src/Transfer/Transfer.stories.js +++ b/packages/widgets/src/Transfer/Transfer.stories.js @@ -1,25 +1,18 @@ /* eslint-disable react/prop-types */ import { SingleSelectOption, Tab, TabBar } from '@dhis2/ui-core' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { SingleSelectField, Transfer, TransferOption } from '../index.js' -export default { title: 'Transfer' } +const statefulDecorator = ({ initialState = [] } = {}) => fn => + React.createElement(() => { + const [selected, setSelected] = useState(initialState) -const StatefulWrapper = ({ children, initialState }) => { - const [selected, setSelected] = useState(initialState) - - return React.Children.map(children, child => - React.cloneElement(child, { + return fn({ selected, - onChange: ({ selected }) => setSelected(selected), + onChange: payload => setSelected(payload.selected), }) - ) -} - -StatefulWrapper.defaultProps = { - initialState: [], -} + }) const options = [ { @@ -114,80 +107,96 @@ const options = [ }, ] -export const SingleSelection = () => ( - - console.log('Will be overriden')} - options={options} - /> - +export default { title: 'Transfer', decorators: [statefulDecorator()] } + +export const SingleSelection = ({ selected, onChange }) => ( + ) -export const Multiple = () => ( - - console.log('Will be overriden')} - options={options.slice(0, 3)} - /> - +export const Multiple = ({ selected, onChange }) => ( + ) -export const Header = () => ( - - console.log('Will be overriden')} - leftHeader={

Header on the left side

} - rightHeader={

Header on the right side

} - options={options} - /> -
+export const Header = ({ selected, onChange }) => ( + Header on the left side} + rightHeader={

Header on the right side

} + options={options} + /> ) -export const OptionsFooter = () => ( - - console.log('Will be overriden')} - leftFooter={ - - Reload list - - } - options={options} - /> - +export const OptionsFooter = ({ selected, onChange }) => ( + + Reload list + + } + options={options} + /> ) -export const Filtered = () => ( - - console.log('Will be overriden by StatefulWrapper')} - initialSearchTerm="ANC" - leftHeader={

Header on the left side

} - rightHeader={

Header on the right side

} - options={options} - /> -
+export const Filtered = ({ selected, onChange }) => ( + Header on the left side} + rightHeader={

Header on the right side

} + options={options} + /> ) -export const FilterPlaceholder = () => ( - - console.log('Will be overriden by StatefulWrapper')} - options={options} - filterLabel="Filter with placeholder" - filterPlaceholder="Search" - /> - +export const FilteredPicked = ({ selected, onChange }) => ( + Header on the left side} + rightHeader={

Header on the right side

} + options={options} + /> +) + +FilteredPicked.story = { + decorators: [ + statefulDecorator({ + initialState: options.map(({ value }) => value), + }), + ], +} + +export const FilterPlaceholder = ({ selected, onChange }) => ( + ) const renderOption = ({ label, value, onClick, highlighted, selected }) => ( @@ -202,7 +211,7 @@ const renderOption = ({ label, value, onClick, highlighted, selected }) => (

) -export const CustomListOptions = () => ( +const RenderOptionCode = () => ( <> Custom option code: @@ -218,56 +227,64 @@ export const CustomListOptions = () => (

)`}
- value)} - > - - console.log('Will be overriden by StatefulWrapper') - } - renderOption={renderOption} - options={options} - /> - ) -export const IndividualCustomOption = () => ( - - console.log('Will be overriden')} - addAllText="Add all" - addIndividualText="Add individual" - removeAllText="Remove all" - removeIndividualText="Remove individual" - renderOption={args => { - if (args.option.value === options[0].value) { - return renderOption(args) - } +export const CustomListOptions = ({ selected, onChange }) => ( + <> + - return - }} + - + ) -export const CustomButtonText = () => ( - - console.log('Will be overriden')} - addAllText="Add all" - addIndividualText="Add individual" - removeAllText="Remove all" - removeIndividualText="Remove individual" - options={options} - /> - +CustomListOptions.story = { + decorators: [ + statefulDecorator({ + initialState: options.slice(0, 2).map(({ value }) => value), + }), + ], +} + +export const IndividualCustomOption = ({ selected, onChange }) => ( + { + if (option.value === options[0].value) { + return renderOption(option) + } + + return + }} + options={options} + /> +) + +export const CustomButtonText = ({ selected, onChange }) => ( + ) -export const SourceEmptyPlaceholder = () => ( +export const SourceEmptyPlaceholder = ({ onChange }) => ( console.log('Will be overriden')} + onChange={onChange} options={[]} sourceEmptyPlaceholder={

@@ -281,9 +298,10 @@ export const SourceEmptyPlaceholder = () => ( /> ) -export const PickedEmptyComponent = () => ( +export const PickedEmptyComponent = ({ selected, onChange }) => ( console.log('Will be overriden')} + selected={selected} + onChange={onChange} selectedEmptyComponent={

You have not selected anything yet @@ -294,49 +312,50 @@ export const PickedEmptyComponent = () => ( /> ) -export const Reordering = () => ( - value)} - > - null} - options={options.slice(0, 4)} - /> - +export const Reordering = ({ selected, onChange }) => ( + ) -export const IncreasedOptionsHeight = () => ( -

- - - console.log('Will be overriden by StatefulWrapper') - } - height="400px" - leftHeader={

Header on the left side

} - rightHeader={

Header on the right side

} - options={options} - /> -
-
-) +Reordering.story = { + decorators: [ + statefulDecorator({ + initialState: options.slice(0, 4).map(({ value }) => value), + }), + ], +} -export const DifferentWidths = () => ( - +export const IncreasedOptionsHeight = ({ selected, onChange }) => ( +
console.log('Will be overriden by StatefulWrapper')} - initialSearchTerm="Ba" + onChange={onChange} + selected={selected} + height="400px" leftHeader={

Header on the left side

} rightHeader={

Header on the right side

} - optionsWidth="500px" - selectedWidth="240px" options={options} /> - +
+) + +export const DifferentWidths = ({ selected, onChange }) => ( + Header on the left side} + rightHeader={

Header on the right side

} + optionsWidth="500px" + selectedWidth="240px" + options={options} + /> ) const createCustomFilteringInHeader = hideFilterInput => { @@ -443,10 +462,12 @@ const createCustomFilteringInHeader = hideFilterInput => { } // eslint-disable-next-line react/display-name - return () => ( - - - + return ({ selected, onChange }) => ( + ) } @@ -457,3 +478,73 @@ export const CustomFilteringWithFilterInput = createCustomFilteringInHeader( export const CustomFilteringWithoutFilterInput = createCustomFilteringInHeader( true ) + +const sliceLength = 6 + +export const InifiniteLoading = ({ selected, onChange }) => { + const [loading, setLoading] = useState(false) + const [optionsLength, setOptionsLength] = useState(sliceLength) + const [optionsSlice, setOptionsSlice] = useState( + options.slice(0, optionsLength) + ) + + useEffect(() => { + if (sliceLength !== optionsLength) { + setTimeout(() => { + setOptionsSlice(options.slice(0, optionsLength)) + setLoading(false) + }, 1000) + + setLoading(true) + } + }, [optionsLength]) + + return ( + { + if (loading) return + + const newOptionsLength = Math.min( + optionsLength + sliceLength, + options.length + ) + + setOptionsLength(newOptionsLength) + }} + /> + ) +} + +export const LoadingSource = ({ selected, onChange }) => ( + Left header} + leftFooter={

Left footer

} + /> +) + +export const LoadingPicked = ({ selected, onChange }) => ( + Right header} + rightFooter={

Right footer

} + /> +) + +LoadingPicked.story = { + decorators: [ + statefulDecorator({ + initialState: options.slice(0, 2).map(({ value }) => value), + }), + ], +} diff --git a/packages/widgets/src/Transfer/Transfer/index.js b/packages/widgets/src/Transfer/Transfer/index.js index 8e7e3662f8..6304b44698 100644 --- a/packages/widgets/src/Transfer/Transfer/index.js +++ b/packages/widgets/src/Transfer/Transfer/index.js @@ -9,4 +9,5 @@ export * from './moveHighlightedPickedOptionDown.js' export * from './moveHighlightedPickedOptionUp.js' export * from './removeAllPickedOptions.js' export * from './removeIndividualPickedOptions.js' +export * from './useFilter' export * from './useHighlightedOptions.js' diff --git a/packages/widgets/src/Transfer/Transfer/removeIndividualPickedOptions.js b/packages/widgets/src/Transfer/Transfer/removeIndividualPickedOptions.js index d5462487b8..fd5ebc0212 100644 --- a/packages/widgets/src/Transfer/Transfer/removeIndividualPickedOptions.js +++ b/packages/widgets/src/Transfer/Transfer/removeIndividualPickedOptions.js @@ -7,13 +7,35 @@ * @returns {void} */ export const removeIndividualPickedOptions = ({ + filterablePicked, + pickedOptions, highlightedPickedOptions, onChange, selected, setHighlightedPickedOptions, }) => { + /** + * Creates a subset of the highlighted options to reflect a changed + * filter value in case previously highlighted options are now + * hidden. + * + * This enables us to keep items highlighted while searching for + * a particular one. + * + * With this subset we only select the subset when the user + * clicks the "add individuals" button + */ + const filteredHighlightedPickedOptions = filterablePicked + ? highlightedPickedOptions.filter(value => + pickedOptions.find( + filteredOption => filteredOption.value === value + ) + ) + : highlightedPickedOptions + const newSelected = selected.filter( - selectedOption => !highlightedPickedOptions.includes(selectedOption) + selectedOption => + !filteredHighlightedPickedOptions.includes(selectedOption) ) setHighlightedPickedOptions([]) diff --git a/packages/widgets/src/Transfer/Transfer/useFilter.js b/packages/widgets/src/Transfer/Transfer/useFilter.js new file mode 100644 index 0000000000..a0ab34a89c --- /dev/null +++ b/packages/widgets/src/Transfer/Transfer/useFilter.js @@ -0,0 +1,17 @@ +import { useState } from 'react' + +const identity = value => value + +export const useFilter = ({ + initialSearchTerm, + onFilterChange, + externalSearchTerm, + filterable, + filterCallback, +}) => { + const [internalFilter, setInternalFilter] = useState(initialSearchTerm) + const filterValue = onFilterChange ? externalSearchTerm : internalFilter + const filter = filterable ? filterCallback : identity + + return { filterValue, filter, setInternalFilter } +} diff --git a/packages/widgets/src/Transfer/__e2e__/loading_lists.stories.e2e.js b/packages/widgets/src/Transfer/__e2e__/loading_lists.stories.e2e.js new file mode 100644 index 0000000000..8acccb4999 --- /dev/null +++ b/packages/widgets/src/Transfer/__e2e__/loading_lists.stories.e2e.js @@ -0,0 +1,27 @@ +import React from 'react' + +import { Transfer } from '../Transfer' +import { options } from './common/options' + +export default { title: 'Transfer Loading Lists' } + +export const LoadingSource = () => ( + null} options={options} /> +) + +export const LoadingPicked = () => ( + null} + options={options} + /> +) + +export const NotLoadingSource = () => ( + null} options={options} /> +) + +export const NotLoadingPicked = () => ( + null} options={options} /> +) diff --git a/packages/widgets/src/Transfer/__e2e__/notify_at_end_of_list.stories.e2e.js b/packages/widgets/src/Transfer/__e2e__/notify_at_end_of_list.stories.e2e.js new file mode 100644 index 0000000000..aa0f48ca8c --- /dev/null +++ b/packages/widgets/src/Transfer/__e2e__/notify_at_end_of_list.stories.e2e.js @@ -0,0 +1,71 @@ +/* eslint-disable react/prop-types */ +import React from 'react' + +import { Transfer } from '../Transfer' +import { statefulDecorator } from './common/statefulDecorator' +import { options } from './common/options' + +export default { + title: 'Transfer End Of List', + decorators: [statefulDecorator()], +} + +window.onEndReached = window.Cypress + ? window.Cypress.cy.stub() + : () => console.log('onEndReached') + +window.onEndReachedPicked = window.Cypress + ? window.Cypress.cy.stub() + : () => console.log('onEndReachedPicked') + +export const FullSourceList = ({ selected, onChange }) => ( + +) + +export const FullPickedList = ({ selected, onChange }) => ( + +) + +FullPickedList.story = { + decorators: [ + statefulDecorator({ + initialState: options.map(({ value }) => value), + }), + ], +} + +export const PartialSourceList = ({ selected, onChange }) => ( + +) + +export const PartialPickedList = ({ selected, onChange }) => ( + +) + +PartialPickedList.story = { + decorators: [ + statefulDecorator({ + initialState: options.slice(0, 4).map(({ value }) => value), + }), + ], +} diff --git a/packages/widgets/src/Transfer/features/loading_lists.feature b/packages/widgets/src/Transfer/features/loading_lists.feature new file mode 100644 index 0000000000..ef4fb9ba44 --- /dev/null +++ b/packages/widgets/src/Transfer/features/loading_lists.feature @@ -0,0 +1,19 @@ +Feature: The source and picked lists can have a loading state + + Scenario Outline: A list is loading + Given the list is loading + Then the loading indicator should be shown + + Examples: + | type | + | source | + | picked | + + Scenario Outline: A list is not loading + Given the list is not loading + Then the loading indicator should not be shown + + Examples: + | type | + | source | + | picked | diff --git a/packages/widgets/src/Transfer/features/loading_lists/index.js b/packages/widgets/src/Transfer/features/loading_lists/index.js new file mode 100644 index 0000000000..91155c1383 --- /dev/null +++ b/packages/widgets/src/Transfer/features/loading_lists/index.js @@ -0,0 +1,43 @@ +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('the source list is loading', () => { + cy.visitStory('Transfer Loading Lists', 'Loading Source') + cy.wrap('source').as('listType') +}) + +Given('the picked list is loading', () => { + cy.visitStory('Transfer Loading Lists', 'Loading Picked') + cy.wrap('picked').as('listType') +}) + +Given('the source list is not loading', () => { + cy.visitStory('Transfer Loading Lists', 'Not Loading Source') + cy.wrap('source').as('listType') +}) + +Given('the picked list is not loading', () => { + cy.visitStory('Transfer Loading Lists', 'Not Loading Picked') + cy.wrap('picked').as('listType') +}) + +Then('the loading indicator should be shown', () => { + cy.get('@listType').then(listType => { + const listSelector = + listType === 'source' + ? '{transfer-leftside}' + : '{transfer-rightside}' + + cy.get(`${listSelector} .loading`).should('exist') + }) +}) + +Then('the loading indicator should not be shown', () => { + cy.get('@listType').then(listType => { + const listSelector = + listType === 'source' + ? '{transfer-leftside}' + : '{transfer-rightside}' + + cy.get(`${listSelector} .loading`).should('not.exist') + }) +}) diff --git a/packages/widgets/src/Transfer/features/notify_at_end_of_list.feature b/packages/widgets/src/Transfer/features/notify_at_end_of_list.feature new file mode 100644 index 0000000000..4e3f5e1e9a --- /dev/null +++ b/packages/widgets/src/Transfer/features/notify_at_end_of_list.feature @@ -0,0 +1,29 @@ +Feature: The source and picked option lists notify the consumer when the end has been reached + + Scenario Outline: The list is displayed initially and the end is not visible + Given the Transfer has enough items to fill the list completely + Then the callback for reaching the end should not be called + + Examples: + | type | + | source | + | picked | + + Scenario Outline: The list is displayed initially and the end is visible + Given the Transfer does not have enough items to fill the list completely + Then the callback for reaching the end should be called + + Examples: + | type | + | source | + | picked | + + Scenario Outline: The user scrolls down the list to the end + Given the Transfer has enough items to fill the list completely + When the user scroll to the end of the list + Then the callback for reaching the end should be called + + Examples: + | type | + | source | + | picked | diff --git a/packages/widgets/src/Transfer/features/notify_at_end_of_list/index.js b/packages/widgets/src/Transfer/features/notify_at_end_of_list/index.js new file mode 100644 index 0000000000..1e7715f249 --- /dev/null +++ b/packages/widgets/src/Transfer/features/notify_at_end_of_list/index.js @@ -0,0 +1,80 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Cypress.on('uncaught:exception', err => { + // This prevents a benign error: + // This error means that ResizeObserver was not able to deliver all + // observations within a single animation frame. It is benign (your site + // will not break). + // + // Source: https://stackoverflow.com/a/50387233/1319140 + if (err.match(/ResizeObserver loop limit exceeded/)) { + return false + } +}) + +Given( + 'the Transfer has enough items to fill the source list completely', + () => { + cy.visitStory('Transfer End Of List', 'Full Source List') + cy.wrap('source').as('listType') + } +) + +Given( + 'the Transfer has enough items to fill the picked list completely', + () => { + cy.visitStory('Transfer End Of List', 'Full Picked List') + cy.wrap('picked').as('listType') + } +) + +Given( + 'the Transfer does not have enough items to fill the source list completely', + () => { + cy.visitStory('Transfer End Of List', 'Partial Source List') + cy.wrap('source').as('listType') + } +) + +Given( + 'the Transfer does not have enough items to fill the picked list completely', + () => { + cy.visitStory('Transfer End Of List', 'Partial Picked List') + cy.wrap('picked').as('listType') + } +) + +When('the user scroll to the end of the list', () => { + cy.get('@listType').then(listType => { + const listSelector = + listType === 'source' + ? 'transfer-sourceoptions' + : 'transfer-pickedoptions' + + cy.get(`{${listSelector}-endintersectiondetector}`).scrollIntoView() + }) +}) + +Then('the callback for reaching the end should not be called', () => { + cy.all( + () => cy.window(), + () => cy.get('@listType') + ).should(([win, listType]) => { + const callback = + listType === 'source' ? win.onEndReached : win.onEndReachedPicked + + expect(callback).to.not.be.called + }) +}) + +Then('the callback for reaching the end should be called', () => { + cy.all( + () => cy.window(), + () => cy.get('@listType') + ).should(([win, listType]) => { + const callback = + listType === 'source' ? win.onEndReached : win.onEndReachedPicked + + expect(callback).to.be.calledOnce + }) +})