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 && (