From 233a1342b200b44b87b45ede2634a21154e5cd81 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Thu, 18 Apr 2024 09:17:23 -0400 Subject: [PATCH] upcoming: [M3-7978] - RegionSelect disabled option API updates (#10373) * save progress * save progress * save progress * Save progress * fix tests * code cleanup * Improve coverage * moar cleanup * Added changeset: RegionSelect disabled option API updates * Address feedback * Address feedback 2 * fix test --- .../pr-10373-changed-1712939610447.md | 5 ++ .../RegionSelect/RegionMultiSelect.tsx | 4 +- .../components/RegionSelect/RegionOption.tsx | 60 ++++++---------- .../components/RegionSelect/RegionSelect.tsx | 14 +++- .../RegionSelect/RegionSelect.types.ts | 12 +++- .../RegionSelect/RegionSelect.utils.test.tsx | 70 +++++++++++++++---- ...Select.utils.ts => RegionSelect.utils.tsx} | 47 +++++++++---- .../PlacementGroupsCreateDrawer.test.tsx | 30 ++++---- .../PlacementGroupsCreateDrawer.tsx | 46 ++++++++---- .../PlacementGroupsDetailPanel.tsx | 1 - .../PlacementGroupsLanding.tsx | 2 - .../src/features/PlacementGroups/types.ts | 1 - 12 files changed, 192 insertions(+), 100 deletions(-) create mode 100644 packages/manager/.changeset/pr-10373-changed-1712939610447.md rename packages/manager/src/components/RegionSelect/{RegionSelect.utils.ts => RegionSelect.utils.tsx} (83%) diff --git a/packages/manager/.changeset/pr-10373-changed-1712939610447.md b/packages/manager/.changeset/pr-10373-changed-1712939610447.md new file mode 100644 index 00000000000..461c5f98022 --- /dev/null +++ b/packages/manager/.changeset/pr-10373-changed-1712939610447.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +RegionSelect disabled option API updates ([#10373](https://github.com/linode/manager/pull/10373)) diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index 0057809ea12..054878914e1 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -91,6 +91,9 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { <> + Boolean(option.disabledProps?.disabled) + } groupBy={(option: RegionSelectOption) => { return option?.data?.region; }} @@ -134,7 +137,6 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { disableClearable={!isClearable} disabled={disabled} errorText={errorText} - getOptionDisabled={(option: RegionSelectOption) => option.unavailable} label={label ?? 'Regions'} loading={accountAvailabilityLoading} multiple diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index 7186b1cc6c3..e9d5a2a26a5 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -4,7 +4,6 @@ import React from 'react'; import EdgeServer from 'src/assets/icons/entityIcons/edge-server.svg'; 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 { TooltipIcon } from 'src/components/TooltipIcon'; @@ -31,73 +30,58 @@ export const RegionOption = ({ props, selected, }: Props) => { - const isDisabledMenuItem = option.unavailable; + const { className, onClick } = props; + const { data, disabledProps, label, value } = option; + const isRegionDisabled = Boolean(disabledProps?.disabled); + const isRegionDisabledReason = disabledProps?.reason; return ( - There may be limited capacity in this region.{' '} - - Learn more - - . - - ) : ( - '' - ) + isRegionDisabled && isRegionDisabledReason ? isRegionDisabledReason : '' } - disableFocusListener={!isDisabledMenuItem} - disableHoverListener={!isDisabledMenuItem} - disableTouchListener={!isDisabledMenuItem} + disableFocusListener={!isRegionDisabled} + disableHoverListener={!isRegionDisabled} + disableTouchListener={!isRegionDisabled} enterDelay={200} enterNextDelay={200} enterTouchDelay={200} - key={option.value} + key={value} > - isDisabledMenuItem - ? e.preventDefault() - : props.onClick - ? props.onClick(e) - : null + isRegionDisabled ? e.preventDefault() : onClick ? onClick(e) : null } aria-disabled={undefined} + className={isRegionDisabled ? `${className} Mui-disabled` : className} > <> - + - {option.label} + {label} {displayEdgeServerIcon && (  (This region is an Edge site.) )} - {isDisabledMenuItem && ( - - Disabled option - There may be limited capacity in this region. - Learn more at - https://www.linode.com/global-infrastructure/availability. - + {isRegionDisabled && isRegionDisabledReason && ( + {isRegionDisabledReason} )} {selected && } diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 55eb20c369c..ad6e39e8a3b 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -36,6 +36,7 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { currentCapability, disabled, errorText, + handleDisabledRegion, handleSelection, helperText, isClearable, @@ -85,15 +86,25 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { getRegionOptions({ accountAvailabilityData: accountAvailability, currentCapability, + handleDisabledRegion, regionFilter, regions, }), - [accountAvailability, currentCapability, regions, regionFilter] + [ + accountAvailability, + currentCapability, + handleDisabledRegion, + regions, + regionFilter, + ] ); return ( + Boolean(option.disabledProps?.disabled) + } isOptionEqualToValue={( option: RegionSelectOption, { value }: RegionSelectOption @@ -151,7 +162,6 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { disableClearable={!isClearable} disabled={disabled} errorText={errorText} - getOptionDisabled={(option: RegionSelectOption) => option.unavailable} groupBy={(option: RegionSelectOption) => option.data.region} helperText={helperText} label={label ?? 'Region'} diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index 3a5d7dbed3d..e8a32a92190 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -14,9 +14,13 @@ export interface RegionSelectOption { country: Country; region: string; }; + disabledProps?: { + disabled: boolean; + reason?: JSX.Element | string; + tooltipWidth?: number; + }; label: string; site_type: RegionSite; - unavailable: boolean; value: string; } @@ -33,6 +37,9 @@ export interface RegionSelectProps * See `ImageUpload.tsx` for an example of a RegionSelect with an undefined `currentCapability` - there is no capability associated with Images yet. */ currentCapability: Capabilities | undefined; + handleDisabledRegion?: ( + region: Region + ) => RegionSelectOption['disabledProps']; handleSelection: (id: string) => void; helperText?: string; isClearable?: boolean; @@ -71,6 +78,9 @@ export interface RegionMultiSelectProps export interface RegionOptionAvailability { accountAvailabilityData: AccountAvailability[] | undefined; currentCapability: Capabilities | undefined; + handleDisabledRegion?: ( + region: Region + ) => RegionSelectOption['disabledProps']; } export interface GetRegionOptions extends RegionOptionAvailability { diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx index 95f52bf560c..8bdf1472598 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx @@ -1,10 +1,10 @@ import { accountAvailabilityFactory, regionFactory } from 'src/factories'; import { - getRegionOptionAvailability, getRegionOptions, getSelectedRegionById, getSelectedRegionsByIds, + isRegionOptionUnavailable, } from './RegionSelect.utils'; import type { RegionSelectOption } from './RegionSelect.types'; @@ -62,23 +62,29 @@ const expectedRegions: RegionSelectOption[] = [ country: 'us', region: 'North America', }, + disabledProps: { + disabled: false, + }, label: 'US Location (us-1)', site_type: 'core', - unavailable: false, value: 'us-1', }, { data: { country: 'ca', region: 'North America' }, + disabledProps: { + disabled: false, + }, label: 'CA Location (ca-1)', site_type: 'core', - unavailable: false, value: 'ca-1', }, { data: { country: 'jp', region: 'Asia' }, + disabledProps: { + disabled: false, + }, label: 'JP Location (jp-1)', site_type: 'core', - unavailable: false, value: 'jp-1', }, ]; @@ -86,16 +92,20 @@ const expectedRegions: RegionSelectOption[] = [ const expectedEdgeRegions = [ { data: { country: 'us', region: 'North America' }, + disabledProps: { + disabled: false, + }, label: 'Gecko Edge Test (us-edge-1)', site_type: 'edge', - unavailable: false, value: 'us-edge-1', }, { data: { country: 'us', region: 'North America' }, + disabledProps: { + disabled: false, + }, label: 'Gecko Edge Test 2 (us-edge-2)', site_type: 'edge', - unavailable: false, value: 'us-edge-2', }, ]; @@ -179,6 +189,46 @@ describe('getRegionOptions', () => { expect(result).toEqual(expectedRegionsWithEdge); }); + + it('should have its option disabled if the region is unavailable', () => { + const _regions = [ + ...regions, + regionFactory.build({ + capabilities: ['Linodes'], + country: 'us', + id: 'ap-south', + label: 'US Location 2', + }), + ]; + + const result: RegionSelectOption[] = getRegionOptions({ + accountAvailabilityData, + currentCapability: 'Linodes', + regions: _regions, + }); + + const unavailableRegion = result.find( + (region) => region.value === 'ap-south' + ); + + expect(unavailableRegion?.disabledProps?.disabled).toBe(true); + }); + + it('should have its option disabled if `handleDisabledRegion` is passed', () => { + const result: RegionSelectOption[] = getRegionOptions({ + accountAvailabilityData, + currentCapability: 'Linodes', + handleDisabledRegion: (region) => ({ + ...region, + disabled: true, + }), + regions, + }); + + const unavailableRegion = result.find((region) => region.value === 'us-1'); + + expect(unavailableRegion?.disabledProps?.disabled).toBe(true); + }); }); describe('getSelectedRegionById', () => { @@ -200,7 +250,6 @@ describe('getSelectedRegionById', () => { }, label: 'US Location (us-1)', site_type: 'core', - unavailable: false, value: 'us-1', }; @@ -223,7 +272,7 @@ describe('getSelectedRegionById', () => { describe('getRegionOptionAvailability', () => { it('should return true if the region is not available', () => { - const result = getRegionOptionAvailability({ + const result = isRegionOptionUnavailable({ accountAvailabilityData, currentCapability: 'Linodes', region: regionFactory.build({ @@ -235,7 +284,7 @@ describe('getRegionOptionAvailability', () => { }); it('should return false if the region is available', () => { - const result = getRegionOptionAvailability({ + const result = isRegionOptionUnavailable({ accountAvailabilityData, currentCapability: 'Linodes', region: regionFactory.build({ @@ -266,7 +315,6 @@ describe('getSelectedRegionsByIds', () => { }, label: 'US Location (us-1)', site_type: 'core', - unavailable: false, value: 'us-1', }, { @@ -276,7 +324,6 @@ describe('getSelectedRegionsByIds', () => { }, label: 'CA Location (ca-1)', site_type: 'core', - unavailable: false, value: 'ca-1', }, ]; @@ -302,7 +349,6 @@ describe('getSelectedRegionsByIds', () => { }, label: 'US Location (us-1)', site_type: 'core', - unavailable: false, value: 'us-1', }, ]; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx similarity index 83% rename from packages/manager/src/components/RegionSelect/RegionSelect.utils.ts rename to packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx index 89d25b4f99c..3752364c1c1 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -1,5 +1,7 @@ import { CONTINENT_CODE_TO_CONTINENT } from '@linode/api-v4'; +import * as React from 'react'; +import { Link } from 'src/components/Link'; import { getRegionCountryGroup, getSelectedRegion, @@ -20,6 +22,7 @@ const NORTH_AMERICA = CONTINENT_CODE_TO_CONTINENT.NA; /** * Returns an array of OptionType objects for use in the RegionSelect component. + * Handles the disabled state of each region based on the user's account availability or an optional custom handler. * Regions are sorted alphabetically by region, with North America first. * * @returns An array of RegionSelectOption objects @@ -27,6 +30,7 @@ const NORTH_AMERICA = CONTINENT_CODE_TO_CONTINENT.NA; export const getRegionOptions = ({ accountAvailabilityData, currentCapability, + handleDisabledRegion, regionFilter, regions, }: GetRegionOptions): RegionSelectOption[] => { @@ -42,22 +46,46 @@ export const getRegionOptions = ({ ) : filteredRegionsByCapability; + const isRegionUnavailable = (region: Region) => + isRegionOptionUnavailable({ + accountAvailabilityData, + currentCapability, + region, + }); + return filteredRegionsByCapabilityAndSiteType .map((region: Region) => { const group = getRegionCountryGroup(region); + // The region availability is the first check we run, regardless of the handleDisabledRegion function. + // This check always runs, and if the region is unavailable, the region will be disabled. + const disabledProps = isRegionUnavailable(region) + ? { + disabled: true, + reason: ( + <> + There may be limited capacity in this region.{' '} + + Learn more + + . + + ), + } + : handleDisabledRegion?.(region)?.disabled + ? handleDisabledRegion(region) + : { + disabled: false, + }; + return { data: { country: region.country, region: group, }, + disabledProps, label: `${region.label} (${region.id})`, site_type: region.site_type, - unavailable: getRegionOptionAvailability({ - accountAvailabilityData, - currentCapability, - region, - }), value: region.id, }; }) @@ -107,8 +135,6 @@ export const getRegionOptions = ({ * @returns an RegionSelectOption object for the currently selected region. */ export const getSelectedRegionById = ({ - accountAvailabilityData, - currentCapability, regions, selectedRegionId, }: GetSelectedRegionById): RegionSelectOption | undefined => { @@ -127,11 +153,6 @@ export const getSelectedRegionById = ({ }, label: `${selectedRegion.label} (${selectedRegion.id})`, site_type: selectedRegion.site_type, - unavailable: getRegionOptionAvailability({ - accountAvailabilityData, - currentCapability, - region: selectedRegion, - }), value: selectedRegion.id, }; }; @@ -141,7 +162,7 @@ export const getSelectedRegionById = ({ * * @returns a boolean indicating whether the region is available to the user. */ -export const getRegionOptionAvailability = ({ +export const isRegionOptionUnavailable = ({ accountAvailabilityData, currentCapability, region, diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx index 16209b35d87..2ac7e4f4637 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, waitFor } from '@testing-library/react'; import * as React from 'react'; +import { placementGroupFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { PlacementGroupsCreateDrawer } from './PlacementGroupsCreateDrawer'; @@ -13,6 +14,7 @@ const commonProps = { }; const queryMocks = vi.hoisted(() => ({ + useAllPlacementGroupsQuery: vi.fn().mockReturnValue({}), useCreatePlacementGroup: vi.fn().mockReturnValue({ mutateAsync: vi.fn().mockResolvedValue({}), reset: vi.fn(), @@ -23,6 +25,7 @@ vi.mock('src/queries/placementGroups', async () => { const actual = await vi.importActual('src/queries/placementGroups'); return { ...actual, + useAllPlacementGroupsQuery: queryMocks.useAllPlacementGroupsQuery, useCreatePlacementGroup: queryMocks.useCreatePlacementGroup, }; }); @@ -113,22 +116,12 @@ describe('PlacementGroupsCreateDrawer', () => { }); it('should display an error message if the region has reached capacity', async () => { + queryMocks.useAllPlacementGroupsQuery.mockReturnValue({ + data: [placementGroupFactory.build({ region: 'us-west' })], + }); const regionWithoutCapacity = 'Fremont, CA (us-west)'; const { getByPlaceholderText, getByText } = renderWithTheme( - + ); const regionSelect = getByPlaceholderText('Select a Region'); @@ -137,12 +130,15 @@ describe('PlacementGroupsCreateDrawer', () => { target: { value: regionWithoutCapacity }, }); await waitFor(() => { - const selectedRegionOption = getByText(regionWithoutCapacity); - fireEvent.click(selectedRegionOption); + expect(getByText(regionWithoutCapacity)).toBeInTheDocument(); }); await waitFor(() => { - expect(getByText('This region has reached capacity')).toBeInTheDocument(); + expect( + getByText( + 'You’ve reached the limit of placement groups you can create in this region.' + ) + ).toBeInTheDocument(); }); }); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx index f1802e33f8c..ea16d453604 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx @@ -12,9 +12,13 @@ import { Notice } from 'src/components/Notice/Notice'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useFormValidateOnChange } from 'src/hooks/useFormValidateOnChange'; -import { useCreatePlacementGroup } from 'src/queries/placementGroups'; +import { + useAllPlacementGroupsQuery, + useCreatePlacementGroup, +} from 'src/queries/placementGroups'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getFormikErrorsFromAPIErrors } from 'src/utilities/formikErrorUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; @@ -31,7 +35,6 @@ export const PlacementGroupsCreateDrawer = ( props: PlacementGroupsCreateDrawerProps ) => { const { - allPlacementGroups, disabledPlacementGroupCreateButton, onClose, onPlacementGroupCreate, @@ -39,6 +42,7 @@ export const PlacementGroupsCreateDrawer = ( selectedRegionId, } = props; const { data: regions } = useRegionsQuery(); + const { data: allPlacementGroups } = useAllPlacementGroupsQuery(); const { error, mutateAsync } = useCreatePlacementGroup(); const { enqueueSnackbar } = useSnackbar(); const { @@ -120,10 +124,6 @@ export const PlacementGroupsCreateDrawer = ( ); const pgRegionLimitHelperText = `The maximum number of placement groups in this region is: ${selectedRegion?.placement_group_limits?.maximum_pgs_per_customer}`; - const isRegionAtCapacity = hasRegionReachedPlacementGroupCapacity({ - allPlacementGroups, - region: selectedRegion, - }); return ( { + const isRegionAtCapacity = hasRegionReachedPlacementGroupCapacity( + { + allPlacementGroups, + region, + } + ); + + return { + disabled: isRegionAtCapacity, + reason: ( + <> + + You’ve reached the limit of placement groups you can + create in this region. + + + The maximum number of placement groups in this region + is:{' '} + {region.placement_group_limits.maximum_pgs_per_customer} + + + ), + tooltipWidth: 300, + }; + }} handleSelection={(selection) => { handleRegionSelect(selection); }} @@ -209,7 +230,8 @@ export const PlacementGroupsCreateDrawer = ( 'data-testid': 'submit', disabled: isSubmitting || - isRegionAtCapacity || + !values.region || + !values.label || disabledPlacementGroupCreateButton, label: 'Create Placement Group', loading: isSubmitting, diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx index 524908831b7..3e1d8fb1478 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx @@ -152,7 +152,6 @@ export const PlacementGroupsDetailPanel = (props: Props) => { )} setIsCreatePlacementGroupDrawerOpen(false)} onPlacementGroupCreate={handlePlacementGroupCreated} diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx index d5187a39c5c..3afc7fa6abb 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx @@ -139,7 +139,6 @@ export const PlacementGroupsLanding = React.memo(() => { openCreatePlacementGroupDrawer={handleCreatePlacementGroup} /> { pageSize={pagination.pageSize} /> void;