- {!React.Children.count(children) && selectedEmptyComponent}
- {children}
+
)
@@ -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
+ })
+})