CreateImageWizard: update packages to use custom callbacks

PF4 now allows more control over the DualListSelector. This component is
rewritten to use custom callbacks which allow us to display a more
customized version of the DualListSelector. Currently, the component is
visually identical to the existing implemention except for the addition
of a search button to filter the chosen packages.
This commit is contained in:
Jacob Kozol 2021-10-28 13:42:58 +02:00 committed by Tom Gundersen
parent 4bcbd0adbc
commit 58f866088e
2 changed files with 192 additions and 63 deletions

View file

@ -1,79 +1,211 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, } from 'react';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import { DualListSelector, Button, TextContent } from '@patternfly/react-core';
import api from '../../../api';
import PropTypes from 'prop-types';
import {
Button,
DualListSelector,
DualListSelectorPane,
DualListSelectorList,
DualListSelectorListItem,
DualListSelectorControlsWrapper,
DualListSelectorControl,
SearchInput,
TextContent
} from '@patternfly/react-core';
import { AngleDoubleLeftIcon, AngleLeftIcon, AngleDoubleRightIcon, AngleRightIcon } from '@patternfly/react-icons';
const mapPackagesToComponent = (packages) => packages.map((pack, key) => (
<TextContent key={ `${pack.name}-${key}` }>
<span className="pf-c-dual-list-selector__item-text">{ pack.name }</span>
<small>{ pack.summary }</small>
</TextContent>
));
const mapComponentToPackage = (component) => ({
name: component.props.children[0].props.children,
summary: component.props.children[1].props.children
});
// the fields isHidden and isSelected should not be included in the package list sent for image creation
const removePackagesDisplayFields = (packages) => packages.map((pack) => ({
name: pack.name,
summary: pack.summary,
}));
const Packages = ({ defaultArch, ...props }) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const packagesSearchName = useRef();
const [ packagesAvailable, setPackagesAvailable ] = useState([]);
const [ packagesSelected, setPackagesSelected ] = useState([]);
const [ filterSelected, setFilterSelected ] = useState('');
const [ packagesChosen, setPackagesChosen ] = useState([]);
const [ filterChosen, setFilterChosen ] = useState('');
useEffect(() => {
setPackagesSelected(mapPackagesToComponent(getState()?.values?.[input.name] || []));
}, []);
const packageListChange = (newAvailablePackages, newChosenPackages) => {
const chosenPkgs = newChosenPackages.map(mapComponentToPackage);
setPackagesAvailable(newAvailablePackages);
setPackagesSelected(newChosenPackages);
change(input.name, chosenPkgs);
};
const handlePackagesSearch = async () => {
// call api to list available packages
const handlePackagesAvailableSearch = async () => {
const { data } = await api.getPackages(
getState()?.values?.release,
getState()?.values?.architecture || defaultArch,
packagesSearchName.current
);
setPackagesAvailable(mapPackagesToComponent(data || []));
setPackagesAvailable(data);
};
return <DualListSelector
className="pf-u-mt-sm"
isSearchable
availableOptionsActions={ [
<Button
aria-label="Search button for available packages"
key="availableSearchButton"
data-testid="search-pkgs-button"
onClick={ handlePackagesSearch }>
Search
</Button>
] }
availableOptions={ packagesAvailable }
availableOptionsTitle="Available packages"
chosenOptions={ packagesSelected.filter((item) => mapComponentToPackage(item)?.name?.includes(filterSelected)) }
chosenOptionsTitle="Chosen packages"
addSelected={ packageListChange }
removeSelected={ packageListChange }
addAll={ (available, chosen) => packageListChange([], chosen.concat(available)) }
removeAll= { (newAvailablePackages) => packageListChange(
newAvailablePackages,
packagesSelected.filter((item) => !mapComponentToPackage(item)?.name?.includes(filterSelected))
) }
onAvailableOptionsSearchInputChanged={ (val) => {
packagesSearchName.current = val;
} }
onChosenOptionsSearchInputChanged={ (val) => setFilterSelected(val) }
filterOption={ () => true }
id="basicSelectorWithSearch" />;
// filter displayed selected packages
const handlePackagesChosenSearch = () => {
const filteredPackagesChosen = packagesChosen.map((pack) => {
if (!pack.name.includes(filterChosen)) {
pack.isHidden = true;
} else {
pack.isHidden = false;
}
return pack;
});
setPackagesChosen(filteredPackagesChosen);
};
// move selected packages
const moveSelected = (fromAvailable) => {
const sourcePackages = fromAvailable ? packagesAvailable : packagesChosen;
const destinationPackages = fromAvailable ? packagesChosen : packagesAvailable;
const updatedSourcePackages = sourcePackages.filter((pack) => {
if (pack.selected) {
pack.selected = false;
destinationPackages.push(pack);
return false;
}
return true;
});
if (fromAvailable) {
setPackagesAvailable(updatedSourcePackages);
setPackagesChosen([ ...destinationPackages ]);
} else {
setPackagesChosen(updatedSourcePackages);
setPackagesAvailable([ ...destinationPackages ]);
}
// set the steps field to the current chosen packages list
change(input.name, removePackagesDisplayFields(packagesChosen));
};
// move all packages
const moveAll = (fromAvailable) => {
if (fromAvailable) {
setPackagesChosen([ ...packagesAvailable.filter(pack => !pack.isHidden), ...packagesChosen ]);
setPackagesAvailable([ ...packagesAvailable.filter(pack => pack.isHidden) ]);
} else {
setPackagesAvailable([ ...packagesChosen.filter(pack => !pack.isHidden), ...packagesAvailable ]);
setPackagesChosen([ ...packagesChosen.filter(pack => pack.isHidden) ]);
}
// set the steps field to the current chosen packages list
change(input.name, removePackagesDisplayFields(packagesChosen));
};
const onOptionSelect = (event, index, isChosen) => {
if (isChosen) {
const newChosen = [ ...packagesChosen ];
newChosen[index].selected = !packagesChosen[index].selected;
setPackagesChosen(newChosen);
} else {
const newAvailable = [ ...packagesAvailable ];
newAvailable[index].selected = !packagesAvailable[index].selected;
setPackagesAvailable(newAvailable);
}
};
return (
<DualListSelector>
<DualListSelectorPane
title="Available packages"
searchInput={ <SearchInput
data-testid="search-available-pkgs-input"
value={ packagesSearchName.current }
onChange={ (val) => {
packagesSearchName.current = val;
} } /> }
actions={ [
<Button
aria-label="Search button for available packages"
key="availableSearchButton"
data-testid="search-available-pkgs-button"
onClick={ handlePackagesAvailableSearch }>
Search
</Button>
] }>
<DualListSelectorList>
{packagesAvailable.map((pack, index) => {
return !pack.isHidden ? (
<DualListSelectorListItem
key={ index }
isSelected={ pack.selected }
onOptionSelect={ (e) => onOptionSelect(e, index, false) }>
<TextContent key={ `${pack.name}-${index}` }>
<span className="pf-c-dual-list-selector__item-text">{ pack.name }</span>
<small>{ pack.summary }</small>
</TextContent>
</DualListSelectorListItem>
) : null;
})}
</DualListSelectorList>
</DualListSelectorPane>
<DualListSelectorControlsWrapper
aria-label="Selector controls">
<DualListSelectorControl
isDisabled={ !packagesAvailable.some(option => option.selected) }
onClick={ () => moveSelected(true) }
aria-label="Add selected"
tooltipContent="Add selected">
<AngleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={ packagesAvailable.length === 0 }
onClick={ () => moveAll(true) }
aria-label="Add all"
tooltipContent="Add all">
<AngleDoubleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={ packagesChosen.length === 0 }
onClick={ () => moveAll(false) }
aria-label="Remove all"
tooltipContent="Remove all">
<AngleDoubleLeftIcon />
</DualListSelectorControl>
<DualListSelectorControl
onClick={ () => moveSelected(false) }
isDisabled={ !packagesChosen.some(option => option.selected) }
aria-label="Remove selected"
tooltipContent="Remove selected">
<AngleLeftIcon />
</DualListSelectorControl>
</DualListSelectorControlsWrapper>
<DualListSelectorPane
title="Chosen packages"
searchInput={ <SearchInput
value={ filterChosen }
onChange={ (val) => setFilterChosen(val) } /> }
actions={ [
<Button
aria-label="Search button for selected packages"
key="selectedSearchButton"
data-testid="search-selected-pkgs-button"
onClick={ handlePackagesChosenSearch }>
Search
</Button>
] }
isChosen>
<DualListSelectorList>
{packagesChosen.map((pack, index) => {
return !pack.isHidden ? (
<DualListSelectorListItem
key={ index }
isSelected={ pack.selected }
onOptionSelect={ (e) => onOptionSelect(e, index, true) }>
<TextContent key={ `${pack.name}-${index}` }>
<span className="pf-c-dual-list-selector__item-text">{ pack.name }</span>
<small>{ pack.summary }</small>
</TextContent>
</DualListSelectorListItem>
) : null;
})}
</DualListSelectorList>
</DualListSelectorPane>
</DualListSelector>
);
};
Packages.propTypes = {

View file

@ -425,10 +425,7 @@ describe('Step Packages', () => {
});
test('should display search bar and button', () => {
const search = screen.getByRole('searchbox', { name: 'Available search input' });
search.click();
userEvent.type(search, 'test');
userEvent.type(screen.getByTestId('search-available-pkgs-input'), 'test');
screen.getByRole('button', {
name: 'Search button for available packages'
@ -545,8 +542,8 @@ describe('Click through all steps', () => {
});
screen.getByText('Add optional additional packages to your image by searching available packages.');
userEvent.type(screen.getByRole('searchbox', { name: /Available search input/ }), 'test');
screen.getByTestId('search-pkgs-button').click();
userEvent.type(screen.getByTestId('search-available-pkgs-input'), 'test');
screen.getByTestId('search-available-pkgs-button').click();
await expect(getPackages).toHaveBeenCalledTimes(1);
screen.getByRole('option', { name: /testPkg test package summary/ }).click();
screen.getByRole('button', { name: /Add selected/ }).click();