From 607bd6ed45aa28e3d4efa9c6ce770ce265c8fd55 Mon Sep 17 00:00:00 2001 From: regexowl Date: Fri, 26 Jan 2024 10:13:06 +0100 Subject: [PATCH] V2Wizard: Create a folder for Packages step This creates a new folder for the Packages step and copies over needed file: - Packages.tsx --- .../steps/Packages/Packages.tsx | 545 ++++++++++++++++++ 1 file changed, 545 insertions(+) create mode 100644 src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx diff --git a/src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx b/src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx new file mode 100644 index 00000000..fdab5061 --- /dev/null +++ b/src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx @@ -0,0 +1,545 @@ +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, + useGetOscapCustomizationsQuery, +} from '../../../store/imageBuilderApi'; + +const ExactMatch = ({ + pkgList, + search, + chosenPackages, + selectedAvailablePackages, + handleSelectAvailableFunc, +}) => { + const match = pkgList.find((pkg) => pkg.name === search); + return ( + handleSelectAvailableFunc(e, match.name)} + > + + Exact match + {match.name} + {match.summary} + + + + ); +}; + +export const RedHatPackages = ({ defaultArch }) => { + const { getState } = useFormApi(); + const distribution = getState()?.values?.release; + const arch = getState()?.values?.arch; + 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?.contentSourcesEnabled) { + const filteredByArch = distributionInformation.find( + (info) => info.arch === arch + ); + const repoUrls = filteredByArch.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 ( + + ); +}; + +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 ; +}; + +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); + + const oscapProfile = getState()?.values?.['oscap-profile']; + + const { data: customizations, isSuccess: isSuccessCustomizations } = + useGetOscapCustomizationsQuery( + { + distribution: getState()?.values?.['release'], + profile: oscapProfile, + }, + { + skip: !oscapProfile, + } + ); + useEffect(() => { + if (customizations && customizations.packages && isSuccessCustomizations) { + const oscapPackages = {}; + for (const pkg of customizations.packages) { + oscapPackages[pkg] = { name: pkg }; + } + updateState(oscapPackages); + } + }, [customizations, isSuccessCustomizations, updateState]); + + // 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, packagesSearchName, searchResultsComparator]); + + const chosenPackagesDisplayList = useMemo(() => { + const chosenPackagesList = Object.values(chosenPackages) + .filter((pkg) => (pkg.name.includes(filterChosen) ? true : false)) + .sort(searchResultsComparator(filterChosen)); + return chosenPackagesList; + }, [chosenPackages, filterChosen, searchResultsComparator]); + + // 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 = useCallback( + (newChosenPackages) => { + setSelectedAvailablePackages(new Set()); + setSelectedChosenPackages(new Set()); + setChosenPackages(newChosenPackages); + change('selected-packages', Object.values(newChosenPackages)); + }, + [change] + ); + + 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 ( + <> + { + // vv REMOVE WHEN CONTENT REACHABLE AGAIN + currentStep.name === 'packages-content-sources' && ( + + The Content service cannot be reached, please check back later. + + // ^^ REMOVE WHEN CONTENT REACHABLE AGAIN + ) + } + + + setFocus('available')} + onBlur={() => setFocus('')} + onChange={(_, val) => setPackagesSearchName(val)} + submitSearchButtonLabel="Search button for available packages" + onSearch={handleAvailablePackagesSearch} + resetButtonLabel="Clear available packages search" + onClear={handleClearAvailableSearch} + // Temporarily disable search input for custom packages + isDisabled={currentStep.name === 'packages' ? !isSuccess : true} + /> + {availablePackagesDisplayList.length >= 100 && ( + + )} + + } + status={ + selectedAvailablePackages.size > 0 + ? `${selectedAvailablePackages.size} + of ${availablePackagesDisplayList.length} items` + : `${availablePackagesDisplayList.length} items` + } + > + + {availablePackages === undefined ? ( +

+ Search above to add additional +
+ packages to your image +

+ ) : availablePackagesDisplayList.length === 0 ? ( + <> +

+ No results found +

+
+

+ Adjust your search and try again +

+ + ) : availablePackagesDisplayList.length >= 100 ? ( + <> + {availablePackagesDisplayList.some( + (pkg) => pkg.name === packagesSearchName + ) && ( + + )} +

+ Too many results to display +

+
+

+ Please make the search more specific +
+ and try again +

+ + ) : ( + availablePackagesDisplayList.map((pkg) => { + return ( + handleSelectAvailable(e, pkg.name)} + > + + + {pkg.name} + + {pkg.summary} + + + ); + }) + )} +
+
+ + moveSelectedToChosen()} + aria-label="Add selected" + tooltipContent="Add selected" + > + + + = 100 + } + onClick={() => moveAllToChosen()} + aria-label="Add all" + tooltipContent="Add all" + > + + + removeAllFromChosen()} + aria-label="Remove all" + tooltipContent="Remove all" + > + + + removeSelectedFromChosen()} + isDisabled={selectedChosenPackages.size === 0} + aria-label="Remove selected" + tooltipContent="Remove selected" + > + + + + 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 + > + + {Object.values(chosenPackages).length === 0 ? ( +

+ No packages added +

+ ) : chosenPackagesDisplayList.length === 0 ? ( +

+ No packages found +

+ ) : ( + chosenPackagesDisplayList.map((pkg) => { + return ( + handleSelectChosen(e, pkg.name)} + > + + + {pkg.name} + + {pkg.summary} + + + ); + }) + )} +
+
+
+ + ); +}; + +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, +};