diff --git a/packages/manager/.changeset/pr-10084-added-1705676104397.md b/packages/manager/.changeset/pr-10084-added-1705676104397.md new file mode 100644 index 00000000000..9ec06fbb7e4 --- /dev/null +++ b/packages/manager/.changeset/pr-10084-added-1705676104397.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +RegionMultiSelect Component ([#10084](https://github.com/linode/manager/pull/10084)) diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx new file mode 100644 index 00000000000..2fea491c3fe --- /dev/null +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.stories.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; + +import { regions } from 'src/__data__/regionsData'; +import { Box } from 'src/components/Box'; +import { Flag } from 'src/components/Flag'; +import { + RemovableItem, + RemovableSelectionsList, +} from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; +import { sortByString } from 'src/utilities/sort-by'; + +import { RegionMultiSelect } from './RegionMultiSelect'; +import { StyledFlagContainer } from './RegionSelect.styles'; + +import type { RegionMultiSelectProps } from './RegionSelect.types'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { RegionSelectOption } from 'src/components/RegionSelect/RegionSelect.types'; + +interface SelectedRegionsProps { + onRemove: (data: RegionSelectOption) => void; + selectedRegions: RegionSelectOption[]; +} + +interface LabelComponentProps { + selection: RemovableItem; +} + +const sortRegionOptions = (a: RegionSelectOption, b: RegionSelectOption) => { + return sortByString(a.label, b.label, 'asc'); +}; + +const LabelComponent = ({ selection }: LabelComponentProps) => { + return ( + + + + + {selection.label} + + ); +}; + +const SelectedRegionsList = ({ + onRemove, + selectedRegions, +}: SelectedRegionsProps) => { + const handleRemove = (item: RemovableItem) => { + onRemove(item.data); + }; + + return ( + { + return { ...item, id: index }; + })} + LabelComponent={LabelComponent} + headerText="" + noDataText="" + onRemove={handleRemove} + /> + ); +}; + +export const Default: StoryObj = { + render: (args) => { + const SelectWrapper = () => { + const [selectedRegionsIds, setSelectedRegionsIds] = useState( + [] + ); + + const handleSelectionChange = (selectedIds: string[]) => { + setSelectedRegionsIds(selectedIds); + }; + + return ( + + + + ); + }; + + return SelectWrapper(); + }, +}; + +const meta: Meta = { + args: { + SelectedRegionsList, + currentCapability: 'Linodes', + disabled: false, + errorText: '', + isClearable: false, + label: 'Regions', + placeholder: 'Select Regions or type to search', + regions, + sortRegionOptions, + }, + component: RegionMultiSelect, + title: 'Components/Selects/Region Multi Select', +}; +export default meta; diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx new file mode 100644 index 00000000000..50357013bf2 --- /dev/null +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.test.tsx @@ -0,0 +1,150 @@ +import { fireEvent, screen } from '@testing-library/react'; +import React from 'react'; + +import { regionFactory } from 'src/factories/regions'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RegionMultiSelect } from './RegionMultiSelect'; + +import type { RegionSelectOption } from 'src/components/RegionSelect/RegionSelect.types'; + +const regions = regionFactory.buildList(1, { + id: 'us-east', + label: 'Newark, NJ', +}); + +const regionsNewark = regionFactory.buildList(1, { + id: 'us-east', + label: 'Newark, NJ', +}); +const regionsAtlanta = regionFactory.buildList(1, { + id: 'us-southeast', + label: 'Atlanta, GA', +}); +interface SelectedRegionsProps { + onRemove: (data: RegionSelectOption) => void; + selectedRegions: RegionSelectOption[]; +} +const SelectedRegionsList = ({ + onRemove, + selectedRegions, +}: SelectedRegionsProps) => ( +
    + {selectedRegions.map((region, index) => ( +
  • + {region.label} + +
  • + ))} +
+); + +const mockHandleSelection = vi.fn(); + +describe('RegionMultiSelect', () => { + it('renders correctly with initial props', () => { + renderWithTheme( + + ); + + screen.getByRole('combobox', { name: 'Regions' }); + }); + + it('should be able to select all the regions correctly', () => { + renderWithTheme( + + ); + + // Open the dropdown + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + fireEvent.click(screen.getByRole('option', { name: 'Select All' })); + + // Check if all the option is selected + expect( + screen.getByRole('option', { + name: 'Newark, NJ (us-east)', + }) + ).toHaveAttribute('aria-selected', 'true'); + expect( + screen.getByRole('option', { + name: 'Newark, NJ (us-east)', + }) + ).toHaveAttribute('aria-selected', 'true'); + }); + + it('should be able to deselect all the regions', () => { + renderWithTheme( + + ); + + // Open the dropdown + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + fireEvent.click(screen.getByRole('option', { name: 'Deselect All' })); + + // Check if all the option is deselected selected + expect( + screen.getByRole('option', { + name: 'Newark, NJ (us-east)', + }) + ).toHaveAttribute('aria-selected', 'false'); + expect( + screen.getByRole('option', { + name: 'Newark, NJ (us-east)', + }) + ).toHaveAttribute('aria-selected', 'false'); + }); + + it('should render selected regions correctly', () => { + renderWithTheme( + ( + + )} + currentCapability="Block Storage" + handleSelection={mockHandleSelection} + regions={[...regionsNewark, ...regionsAtlanta]} + selectedIds={[]} + /> + ); + + // Open the dropdown + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + + fireEvent.click(screen.getByRole('option', { name: 'Select All' })); + + // Close the dropdown + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + + // Check if all the options are rendered + expect( + screen.getByRole('listitem', { + name: 'Newark, NJ (us-east)', + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('listitem', { + name: 'Newark, NJ (us-east)', + }) + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx new file mode 100644 index 00000000000..b47e926ac0d --- /dev/null +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -0,0 +1,160 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { StyledListItem } from 'src/components/Autocomplete/Autocomplete.styles'; +import { useFlags } from 'src/hooks/useFlags'; +import { useAccountAvailabilitiesQueryUnpaginated } from 'src/queries/accountAvailability'; + +import { RegionOption } from './RegionOption'; +import { StyledAutocompleteContainer } from './RegionSelect.styles'; +import { + getRegionOptions, + getSelectedRegionsByIds, +} from './RegionSelect.utils'; + +import type { + RegionMultiSelectProps, + RegionSelectOption, +} from './RegionSelect.types'; + +export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { + const { + SelectedRegionsList, + currentCapability, + disabled, + errorText, + handleSelection, + helperText, + isClearable, + label, + onBlur, + placeholder, + regions, + required, + selectedIds, + sortRegionOptions, + width, + } = props; + + const flags = useFlags(); + const { + data: accountAvailability, + isLoading: accountAvailabilityLoading, + } = useAccountAvailabilitiesQueryUnpaginated(flags.dcGetWell); + + const [selectedRegions, setSelectedRegions] = useState( + getSelectedRegionsByIds({ + accountAvailabilityData: accountAvailability, + currentCapability, + regions, + selectedRegionIds: selectedIds ?? [], + }) + ); + + const handleRegionChange = (selection: RegionSelectOption[]) => { + setSelectedRegions(selection); + const selectedIds = selection.map((region) => region.value); + handleSelection(selectedIds); + }; + + useEffect(() => { + setSelectedRegions( + getSelectedRegionsByIds({ + accountAvailabilityData: accountAvailability, + currentCapability, + regions, + selectedRegionIds: selectedIds ?? [], + }) + ); + }, [selectedIds, accountAvailability, currentCapability, regions]); + + const options = useMemo( + () => + getRegionOptions({ + accountAvailabilityData: accountAvailability, + currentCapability, + regions, + }), + [accountAvailability, currentCapability, regions] + ); + + const handleRemoveOption = (optionToRemove: RegionSelectOption) => { + const updatedSelectedOptions = selectedRegions.filter( + (option) => option.value !== optionToRemove.value + ); + const updatedSelectedIds = updatedSelectedOptions.map( + (region) => region.value + ); + setSelectedRegions(updatedSelectedOptions); + handleSelection(updatedSelectedIds); + }; + + return ( + <> + + + Boolean(flags.dcGetWell) && Boolean(option.unavailable) + } + groupBy={(option: RegionSelectOption) => { + return option?.data?.region; + }} + isOptionEqualToValue={( + option: RegionSelectOption, + value: RegionSelectOption + ) => option.value === value.value} + onChange={(_, selectedOption) => + handleRegionChange(selectedOption as RegionSelectOption[]) + } + renderOption={(props, option, { selected }) => { + if (!option.data) { + // Render options like "Select All / Deselect All " + return {option.label}; + } + + // Render regular options + return ( + + ); + }} + textFieldProps={{ + InputProps: { + required, + }, + tooltipText: helperText, + }} + autoHighlight + clearOnBlur + data-testid="region-select" + disableClearable={!isClearable} + disabled={disabled} + errorText={errorText} + label={label ?? 'Regions'} + loading={accountAvailabilityLoading} + multiple + noOptionsText="No results" + onBlur={onBlur} + options={options} + placeholder={placeholder ?? 'Select Regions'} + renderTags={() => null} + value={selectedRegions} + /> + + {selectedRegions.length > 0 && SelectedRegionsList && ( + + )} + + ); +}); diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx new file mode 100644 index 00000000000..5b4279666b5 --- /dev/null +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -0,0 +1,97 @@ +import { visuallyHidden } from '@mui/utils'; +import React from 'react'; + +import { Box } from 'src/components/Box'; +import { Flag } from 'src/components/Flag'; +import { Link } from 'src/components/Link'; +import { Tooltip } from 'src/components/Tooltip'; +import { useFlags } from 'src/hooks/useFlags'; + +import { + SelectedIcon, + StyledFlagContainer, + StyledListItem, +} from './RegionSelect.styles'; +import { RegionSelectOption } from './RegionSelect.types'; + +import type { ListItemComponentsPropsOverrides } from '@mui/material/ListItem'; + +type Props = { + option: RegionSelectOption; + props: React.HTMLAttributes; + selected: boolean; +}; + +export const RegionOption = ({ option, props, selected }: Props) => { + const flags = useFlags(); + const isDisabledMenuItem = + Boolean(flags.dcGetWell) && Boolean(option.unavailable); + + return ( + + There may be limited capacity in this region.{' '} + + Learn more + + . + + ) : ( + '' + ) + } + disableFocusListener={!isDisabledMenuItem} + disableHoverListener={!isDisabledMenuItem} + disableTouchListener={!isDisabledMenuItem} + enterDelay={200} + enterNextDelay={200} + enterTouchDelay={200} + key={option.value} + > + + isDisabledMenuItem + ? e.preventDefault() + : props.onClick + ? props.onClick(e) + : null + } + aria-disabled={undefined} + > + <> + + + + + {option.label} + {isDisabledMenuItem && ( + + Disabled option - There may be limited capacity in this region. + Learn more at + https://www.linode.com/global-infrastructure/availability. + + )} + + {selected && } + + + + ); +}; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 541ccdf6bbe..de565a9086e 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -1,19 +1,14 @@ -import { visuallyHidden } from '@mui/utils'; import * as React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -import { Box } from 'src/components/Box'; import { Flag } from 'src/components/Flag'; -import { Link } from 'src/components/Link'; -import { Tooltip } from 'src/components/Tooltip'; import { useFlags } from 'src/hooks/useFlags'; import { useAccountAvailabilitiesQueryUnpaginated } from 'src/queries/accountAvailability'; +import { RegionOption } from './RegionOption'; import { - SelectedIcon, StyledAutocompleteContainer, StyledFlagContainer, - StyledListItem, } from './RegionSelect.styles'; import { getRegionOptions, getSelectedRegionById } from './RegionSelect.utils'; @@ -21,7 +16,6 @@ import type { RegionSelectOption, RegionSelectProps, } from './RegionSelect.types'; -import type { ListItemComponentsPropsOverrides } from '@mui/material/ListItem'; /** * A specific select for regions. @@ -104,74 +98,13 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { setSelectedRegion(null); }} renderOption={(props, option, { selected }) => { - const isDisabledMenuItem = - Boolean(flags.dcGetWell) && Boolean(option.unavailable); return ( - - There may be limited capacity in this region.{' '} - - Learn more - - . - - ) : ( - '' - ) - } - disableFocusListener={!isDisabledMenuItem} - disableHoverListener={!isDisabledMenuItem} - disableTouchListener={!isDisabledMenuItem} - enterDelay={200} - enterNextDelay={200} - enterTouchDelay={200} + - - isDisabledMenuItem - ? e.preventDefault() - : props.onClick - ? props.onClick(e) - : null - } - aria-disabled={undefined} - > - <> - - - - - {option.label} - {isDisabledMenuItem && ( - - Disabled option - There may be limited capacity in this - region. Learn more at - https://www.linode.com/global-infrastructure/availability. - - )} - - {selected && } - - - + option={option} + props={props} + selected={selected} + /> ); }} textFieldProps={{ diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index 1edd925a586..708957002a8 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -39,6 +39,27 @@ export interface RegionSelectProps width?: number; } +export interface RegionMultiSelectProps + extends Omit< + EnhancedAutocompleteProps, + 'label' | 'onChange' | 'options' + > { + SelectedRegionsList?: React.ComponentType<{ + onRemove: (option: RegionSelectOption) => void; + selectedRegions: RegionSelectOption[]; + }>; + currentCapability: Capabilities | undefined; + handleSelection: (ids: string[]) => void; + helperText?: string; + isClearable?: boolean; + label?: string; + regions: Region[]; + required?: boolean; + selectedIds: string[]; + sortRegionOptions?: (a: RegionSelectOption, b: RegionSelectOption) => number; + width?: number; +} + export interface RegionOptionAvailability { accountAvailabilityData: AccountAvailability[] | undefined; currentCapability: Capabilities | undefined; @@ -56,3 +77,10 @@ export interface GetSelectedRegionById extends RegionOptionAvailability { export interface GetRegionOptionAvailability extends RegionOptionAvailability { region: Region; } + +export interface GetSelectedRegionsByIdsArgs { + accountAvailabilityData: AccountAvailability[] | undefined; + currentCapability: Capabilities | undefined; + regions: Region[]; + selectedRegionIds: string[]; +} diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx index 57359016ddd..f7a4b1c2acc 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx @@ -4,6 +4,7 @@ import { getRegionOptionAvailability, getRegionOptions, getSelectedRegionById, + getSelectedRegionsByIds, } from './RegionSelect.utils'; import type { RegionSelectOption } from './RegionSelect.types'; @@ -168,3 +169,64 @@ describe('getRegionOptionAvailability', () => { expect(result).toBe(false); }); }); + +describe('getSelectedRegionsByIds', () => { + it('should return an array of RegionSelectOptions for the given selectedRegionIds', () => { + const selectedRegionIds = ['us-1', 'ca-1']; + + const result = getSelectedRegionsByIds({ + accountAvailabilityData, + currentCapability: 'Linodes', + regions, + selectedRegionIds, + }); + + const expected = [ + { + data: { + country: 'us', + region: 'North America', + }, + label: 'US Location (us-1)', + unavailable: false, + value: 'us-1', + }, + { + data: { + country: 'ca', + region: 'North America', + }, + label: 'CA Location (ca-1)', + unavailable: false, + value: 'ca-1', + }, + ]; + + expect(result).toEqual(expected); + }); + + it('should exclude regions that are not found in the regions array', () => { + const selectedRegionIds = ['us-1', 'non-existent-region']; + + const result = getSelectedRegionsByIds({ + accountAvailabilityData, + currentCapability: 'Linodes', + regions, + selectedRegionIds, + }); + + const expected = [ + { + data: { + country: 'us', + region: 'North America', + }, + label: 'US Location (us-1)', + unavailable: false, + value: 'us-1', + }, + ]; + + expect(result).toEqual(expected); + }); +}); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts b/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts index ad6ff2d6f62..d5f54ca0304 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts @@ -9,6 +9,7 @@ import type { GetRegionOptionAvailability, GetRegionOptions, GetSelectedRegionById, + GetSelectedRegionsByIdsArgs, RegionSelectOption, } from './RegionSelect.types'; import type { AccountAvailability, Region } from '@linode/api-v4'; @@ -151,3 +152,26 @@ export const getRegionOptionAvailability = ({ return regionWithUnavailability.unavailable.includes(currentCapability); }; + +/** + * This utility function takes an array of region IDs and returns an array of corresponding RegionSelectOption objects. + * + * @returns An array of RegionSelectOption objects corresponding to the selected region IDs. + */ +export const getSelectedRegionsByIds = ({ + accountAvailabilityData, + currentCapability, + regions, + selectedRegionIds, +}: GetSelectedRegionsByIdsArgs): RegionSelectOption[] => { + return selectedRegionIds + .map((selectedRegionId) => + getSelectedRegionById({ + accountAvailabilityData, + currentCapability, + regions, + selectedRegionId, + }) + ) + .filter((region): region is RegionSelectOption => !!region); +}; diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx index 706d4ada54a..3b16017e78f 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx @@ -22,6 +22,10 @@ export type RemovableItem = { } & { [key: string]: any }; export interface RemovableSelectionsListProps { + /** + * The custom label component + */ + LabelComponent?: React.ComponentType<{ selection: RemovableItem }>; /** * The descriptive text to display above the list */ @@ -62,6 +66,7 @@ export const RemovableSelectionsList = ( props: RemovableSelectionsListProps ) => { const { + LabelComponent, headerText, isRemovable = true, maxHeight = 427, @@ -99,9 +104,13 @@ export const RemovableSelectionsList = ( {selectionData.map((selection) => ( - {preferredDataLabel - ? selection[preferredDataLabel] - : selection.label} + {LabelComponent ? ( + + ) : preferredDataLabel ? ( + selection[preferredDataLabel] + ) : ( + selection.label + )} {isRemovable && (