debian-image-builder-frontend/src/Components/CreateImageWizard/formComponents/Packages.js
regexowl 87ff2a24c9 Wizard: Fix the Failed prop type error
Incorrect proptype for `selectedAvailablePackages` was causing following error:
```Warning: Failed prop type: Invalid prop `selectedAvailablePackages` of type `object` supplied to `ExactMatch`, expected an array.```

This fixes the problem.
2023-08-08 13:56:51 +02:00

502 lines
16 KiB
JavaScript

import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import WizardContext from '@data-driven-forms/react-form-renderer/wizard-context';
import {
Alert,
Divider,
DualListSelector,
DualListSelectorControl,
DualListSelectorControlsWrapper,
DualListSelectorList,
DualListSelectorListItem,
DualListSelectorPane,
SearchInput,
TextContent,
} from '@patternfly/react-core';
import {
AngleDoubleLeftIcon,
AngleDoubleRightIcon,
AngleLeftIcon,
AngleRightIcon,
} from '@patternfly/react-icons';
import PropTypes from 'prop-types';
import api from '../../../api';
import { useGetArchitecturesQuery } from '../../../store/imageBuilderApi';
const ExactMatch = ({
pkgList,
search,
chosenPackages,
selectedAvailablePackages,
handleSelectAvailableFunc,
}) => {
const match = pkgList.find((pkg) => pkg.name === search);
return (
<DualListSelectorListItem
data-testid={`exact-match-${match.name}`}
isDisabled={chosenPackages[match.name] ? true : false}
isSelected={selectedAvailablePackages.has(match.name)}
onOptionSelect={(e) => handleSelectAvailableFunc(e, match.name)}
>
<TextContent key={`${match.name}`}>
<small className="pf-u-mb-sm">Exact match</small>
<span className="pf-c-dual-list-selector__item-text">{match.name}</span>
<small>{match.summary}</small>
<Divider />
</TextContent>
</DualListSelectorListItem>
);
};
export const RedHatPackages = ({ defaultArch }) => {
const { getState } = useFormApi();
const distribution = getState()?.values?.release;
const { data: distributionInformation, isSuccess: isSuccessDistroInfo } =
useGetArchitecturesQuery({ distribution });
const getAllPackages = async (packagesSearchName) => {
// if the env is stage beta then use content-sources api
// else use image-builder api
if (getState()?.values?.isBeta) {
const filteredArchx86_64 = distributionInformation.find(
(info) => info.arch === 'x86_64'
);
const repoUrls = filteredArchx86_64.repositories.map(
(repo) => repo.baseurl
);
return await api.getPackagesContentSources(repoUrls, packagesSearchName);
} else {
const args = [
getState()?.values?.release,
getState()?.values?.architecture || defaultArch,
packagesSearchName,
];
const response = await api.getPackages(...args);
let { data } = response;
const { meta } = response;
if (data?.length === meta.count) {
return data;
} else if (data) {
({ data } = await api.getPackages(...args, meta.count));
return data;
}
}
};
return (
<Packages getAllPackages={getAllPackages} isSuccess={isSuccessDistroInfo} />
);
};
export const ContentSourcesPackages = () => {
const { getState } = useFormApi();
const getAllPackages = async (packagesSearchName) => {
const repos = getState()?.values?.['payload-repositories'];
const repoUrls = repos?.map((repo) => repo.baseurl);
return await api.getPackagesContentSources(repoUrls, packagesSearchName);
};
return <Packages getAllPackages={getAllPackages} />;
};
const Packages = ({ getAllPackages, isSuccess }) => {
const { currentStep } = useContext(WizardContext);
const { change, getState } = useFormApi();
const [packagesSearchName, setPackagesSearchName] = useState(undefined);
const [filterChosen, setFilterChosen] = useState('');
const [chosenPackages, setChosenPackages] = useState({});
const [focus, setFocus] = useState('');
const selectedPackages = getState()?.values?.['selected-packages'];
const [availablePackages, setAvailablePackages] = useState(undefined);
const [selectedAvailablePackages, setSelectedAvailablePackages] = useState(
new Set()
);
const [selectedChosenPackages, setSelectedChosenPackages] = useState(
new Set()
);
const firstInputElement = useRef(null);
// this effect only triggers on mount
useEffect(() => {
if (selectedPackages) {
const newChosenPackages = {};
for (const pkg of selectedPackages) {
newChosenPackages[pkg.name] = pkg;
}
setChosenPackages(newChosenPackages);
}
}, []);
useEffect(() => {
if (isSuccess) {
firstInputElement.current?.focus();
}
}, [isSuccess]);
const searchResultsComparator = useCallback((searchTerm) => {
return (a, b) => {
a = a.name.toLowerCase();
b = b.name.toLowerCase();
// check exact match first
if (a === searchTerm) {
return -1;
}
if (b === searchTerm) {
return 1;
}
// check for packages that start with the search term
if (a.startsWith(searchTerm) && !b.startsWith(searchTerm)) {
return -1;
}
if (b.startsWith(searchTerm) && !a.startsWith(searchTerm)) {
return 1;
}
// if both (or neither) start with the search term
// sort alphabetically
if (a < b) {
return -1;
}
if (b < a) {
return 1;
}
return 0;
};
});
const availablePackagesDisplayList = useMemo(() => {
if (availablePackages === undefined) {
return [];
}
const availablePackagesList = Object.values(availablePackages).sort(
searchResultsComparator(packagesSearchName)
);
return availablePackagesList;
}, [availablePackages]);
const chosenPackagesDisplayList = useMemo(() => {
const chosenPackagesList = Object.values(chosenPackages)
.filter((pkg) => (pkg.name.includes(filterChosen) ? true : false))
.sort(searchResultsComparator(filterChosen));
return chosenPackagesList;
}, [chosenPackages, filterChosen]);
// call api to list available packages
const handleAvailablePackagesSearch = async () => {
const packageList = await getAllPackages(packagesSearchName);
// If no packages are found, Image Builder returns null, while
// Content Sources returns an empty array [].
if (packageList) {
const newAvailablePackages = {};
for (const pkg of packageList) {
newAvailablePackages[pkg.name] = pkg;
}
setAvailablePackages(newAvailablePackages);
} else {
setAvailablePackages([]);
}
};
const keydownHandler = (event) => {
if (event.key === 'Enter') {
if (focus === 'available') {
event.stopPropagation();
handleAvailablePackagesSearch();
}
}
};
useEffect(() => {
document.addEventListener('keydown', keydownHandler, true);
return () => {
document.removeEventListener('keydown', keydownHandler, true);
};
});
const updateState = (newChosenPackages) => {
setSelectedAvailablePackages(new Set());
setSelectedChosenPackages(new Set());
setChosenPackages(newChosenPackages);
change('selected-packages', Object.values(newChosenPackages));
};
const moveSelectedToChosen = () => {
const newChosenPackages = { ...chosenPackages };
for (const pkgName of selectedAvailablePackages) {
newChosenPackages[pkgName] = { ...availablePackages[pkgName] };
}
updateState(newChosenPackages);
};
const moveAllToChosen = () => {
const newChosenPackages = { ...chosenPackages, ...availablePackages };
updateState(newChosenPackages);
};
const removeSelectedFromChosen = () => {
const newChosenPackages = {};
for (const pkgName in chosenPackages) {
if (!selectedChosenPackages.has(pkgName)) {
newChosenPackages[pkgName] = { ...chosenPackages[pkgName] };
}
}
updateState(newChosenPackages);
};
const removeAllFromChosen = () => {
const newChosenPackages = {};
updateState(newChosenPackages);
};
const handleSelectAvailable = (event, pkgName) => {
const newSelected = new Set(selectedAvailablePackages);
newSelected.has(pkgName)
? newSelected.delete(pkgName)
: newSelected.add(pkgName);
setSelectedAvailablePackages(newSelected);
};
const handleSelectChosen = (event, pkgName) => {
const newSelected = new Set(selectedChosenPackages);
newSelected.has(pkgName)
? newSelected.delete(pkgName)
: newSelected.add(pkgName);
setSelectedChosenPackages(newSelected);
};
const handleClearAvailableSearch = () => {
setPackagesSearchName('');
setAvailablePackages(undefined);
};
const handleClearChosenSearch = () => {
setFilterChosen('');
};
return (
<DualListSelector>
<DualListSelectorPane
title="Available packages"
searchInput={
<>
<SearchInput
placeholder="Search for a package"
data-testid="search-available-pkgs-input"
value={packagesSearchName}
ref={firstInputElement}
onFocus={() => setFocus('available')}
onBlur={() => setFocus('')}
onChange={(_, val) => setPackagesSearchName(val)}
submitSearchButtonLabel="Search button for available packages"
onSearch={handleAvailablePackagesSearch}
resetButtonLabel="Clear available packages search"
onClear={handleClearAvailableSearch}
isDisabled={currentStep.name === 'packages' ? !isSuccess : false}
/>
{availablePackagesDisplayList.length >= 100 && (
<Alert
title="Over 100 results found. Refine your search."
variant="warning"
isPlain
isInline
/>
)}
</>
}
status={
selectedAvailablePackages.size > 0
? `${selectedAvailablePackages.size}
of ${availablePackagesDisplayList.length} items`
: `${availablePackagesDisplayList.length} items`
}
>
<DualListSelectorList data-testid="available-pkgs-list">
{availablePackages === undefined ? (
<p className="pf-u-text-align-center pf-u-mt-md">
Search above to add additional
<br />
packages to your image
</p>
) : availablePackagesDisplayList.length === 0 ? (
<>
<p className="pf-u-text-align-center pf-u-mt-md pf-u-font-size-lg pf-u-font-weight-bold">
No results found
</p>
<br />
<p className="pf-u-text-align-center pf-u-mt-md">
Adjust your search and try again
</p>
</>
) : availablePackagesDisplayList.length >= 100 ? (
<>
{availablePackagesDisplayList.some(
(pkg) => pkg.name === packagesSearchName
) && (
<ExactMatch
pkgList={availablePackagesDisplayList}
search={packagesSearchName}
chosenPackages={chosenPackages}
selectedAvailablePackages={selectedAvailablePackages}
handleSelectAvailableFunc={handleSelectAvailable}
/>
)}
<p className="pf-u-text-align-center pf-u-mt-md pf-u-font-size-lg pf-u-font-weight-bold">
Too many results to display
</p>
<br />
<p className="pf-u-text-align-center pf-u-mt-md">
Please make the search more specific
<br />
and try again
</p>
</>
) : (
availablePackagesDisplayList.map((pkg) => {
return (
<DualListSelectorListItem
data-testid={`available-pkgs-${pkg.name}`}
key={pkg.name}
isDisabled={chosenPackages[pkg.name] ? true : false}
isSelected={selectedAvailablePackages.has(pkg.name)}
onOptionSelect={(e) => handleSelectAvailable(e, pkg.name)}
>
<TextContent key={`${pkg.name}`}>
<span className="pf-c-dual-list-selector__item-text">
{pkg.name}
</span>
<small>{pkg.summary}</small>
</TextContent>
</DualListSelectorListItem>
);
})
)}
</DualListSelectorList>
</DualListSelectorPane>
<DualListSelectorControlsWrapper aria-label="Selector controls">
<DualListSelectorControl
isDisabled={selectedAvailablePackages.size === 0}
onClick={() => moveSelectedToChosen()}
aria-label="Add selected"
tooltipContent="Add selected"
>
<AngleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={
availablePackagesDisplayList.length === 0 ||
// also disable the "Add all" button if there are too many matches
// (even if there's an exact match)
availablePackagesDisplayList.length >= 100
}
onClick={() => moveAllToChosen()}
aria-label="Add all"
tooltipContent="Add all"
>
<AngleDoubleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={Object.values(chosenPackages).length === 0}
onClick={() => removeAllFromChosen()}
aria-label="Remove all"
tooltipContent="Remove all"
>
<AngleDoubleLeftIcon />
</DualListSelectorControl>
<DualListSelectorControl
onClick={() => removeSelectedFromChosen()}
isDisabled={selectedChosenPackages.size === 0}
aria-label="Remove selected"
tooltipContent="Remove selected"
>
<AngleLeftIcon />
</DualListSelectorControl>
</DualListSelectorControlsWrapper>
<DualListSelectorPane
title="Chosen packages"
searchInput={
<SearchInput
placeholder="Search for a package"
data-testid="search-chosen-pkgs-input"
value={filterChosen}
onFocus={() => setFocus('chosen')}
onBlur={() => setFocus('')}
onChange={(_, val) => setFilterChosen(val)}
resetButtonLabel="Clear chosen packages search"
onClear={handleClearChosenSearch}
/>
}
status={
selectedChosenPackages.size > 0
? `${selectedChosenPackages.size}
of ${chosenPackagesDisplayList.length} items`
: `${chosenPackagesDisplayList.length} items`
}
isChosen
>
<DualListSelectorList data-testid="chosen-pkgs-list">
{Object.values(chosenPackages).length === 0 ? (
<p className="pf-u-text-align-center pf-u-mt-md">
No packages added
</p>
) : chosenPackagesDisplayList.length === 0 ? (
<p className="pf-u-text-align-center pf-u-mt-md">
No packages found
</p>
) : (
chosenPackagesDisplayList.map((pkg) => {
return (
<DualListSelectorListItem
data-testid={`selected-pkgs-${pkg.name}`}
key={pkg.name}
isSelected={selectedChosenPackages.has(pkg.name)}
onOptionSelect={(e) => handleSelectChosen(e, pkg.name)}
>
<TextContent key={`${pkg.name}`}>
<span className="pf-c-dual-list-selector__item-text">
{pkg.name}
</span>
<small>{pkg.summary}</small>
</TextContent>
</DualListSelectorListItem>
);
})
)}
</DualListSelectorList>
</DualListSelectorPane>
</DualListSelector>
);
};
ExactMatch.propTypes = {
pkgList: PropTypes.arrayOf(PropTypes.object),
search: PropTypes.string,
chosenPackages: PropTypes.object,
selectedAvailablePackages: PropTypes.object,
handleSelectAvailableFunc: PropTypes.func,
};
RedHatPackages.propTypes = {
defaultArch: PropTypes.string,
};
Packages.propTypes = {
getAllPackages: PropTypes.func,
isSuccess: PropTypes.bool,
};