diff --git a/packages/api-v4/src/placement-groups/placement-groups.ts b/packages/api-v4/src/placement-groups/placement-groups.ts index 123149c8589..ca58aa147b7 100644 --- a/packages/api-v4/src/placement-groups/placement-groups.ts +++ b/packages/api-v4/src/placement-groups/placement-groups.ts @@ -1,6 +1,6 @@ import { createPlacementGroupSchema, - renamePlacementGroupSchema, + updatePlacementGroupSchema, } from '@linode/validation'; import { API_ROOT } from '../constants'; @@ -63,14 +63,14 @@ export const createPlacementGroup = (data: CreatePlacementGroupPayload) => ); /** - * renamePlacementGroup + * updatePlacementGroup * - * Renames a Placement Group (updates label). + * Updates a Placement Group (updates label). * * @param placementGroupId { number } The id of the Placement Group to be updated. * @param data { PlacementGroup } The data for the Placement Group. */ -export const renamePlacementGroup = ( +export const updatePlacementGroup = ( placementGroupId: number, data: UpdatePlacementGroupPayload ) => @@ -79,7 +79,7 @@ export const renamePlacementGroup = ( `${API_ROOT}/placement/groups/${encodeURIComponent(placementGroupId)}` ), setMethod('PUT'), - setData(data, renamePlacementGroupSchema) + setData(data, updatePlacementGroupSchema) ); /** diff --git a/packages/manager/.changeset/pr-10205-changed-1709570324014.md b/packages/manager/.changeset/pr-10205-changed-1709570324014.md new file mode 100644 index 00000000000..91816536133 --- /dev/null +++ b/packages/manager/.changeset/pr-10205-changed-1709570324014.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Allow the disabling of the TypeToConfirm input ([#10205](https://github.com/linode/manager/pull/10205)) diff --git a/packages/manager/.changeset/pr-10205-upcoming-features-1709303410944.md b/packages/manager/.changeset/pr-10205-upcoming-features-1709303410944.md new file mode 100644 index 00000000000..20e5c1023ed --- /dev/null +++ b/packages/manager/.changeset/pr-10205-upcoming-features-1709303410944.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update Placement Group Create & Edit Drawers ([#10205](https://github.com/linode/manager/pull/10205)) diff --git a/packages/manager/src/__data__/regionsData.ts b/packages/manager/src/__data__/regionsData.ts index 6e44c8dd739..22f6791a025 100644 --- a/packages/manager/src/__data__/regionsData.ts +++ b/packages/manager/src/__data__/regionsData.ts @@ -12,6 +12,7 @@ export const regions: Region[] = [ 'Vlans', 'Block Storage Migrations', 'Managed Databases', + 'Placement Group', ], country: 'in', id: 'ap-west', @@ -37,6 +38,7 @@ export const regions: Region[] = [ 'Vlans', 'Block Storage Migrations', 'Managed Databases', + 'Placement Group', ], country: 'ca', id: 'ca-central', @@ -62,6 +64,7 @@ export const regions: Region[] = [ 'Vlans', 'Block Storage Migrations', 'Managed Databases', + 'Placement Group', ], country: 'au', id: 'ap-southeast', @@ -89,6 +92,7 @@ export const regions: Region[] = [ 'Managed Databases', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'us', id: 'us-iad', @@ -143,6 +147,7 @@ export const regions: Region[] = [ 'Managed Databases', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'fr', id: 'fr-par', @@ -169,6 +174,7 @@ export const regions: Region[] = [ 'Vlans', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'us', id: 'us-sea', @@ -195,6 +201,7 @@ export const regions: Region[] = [ 'Vlans', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'br', id: 'br-gru', @@ -221,6 +228,7 @@ export const regions: Region[] = [ 'Vlans', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'nl', id: 'nl-ams', @@ -247,6 +255,7 @@ export const regions: Region[] = [ 'Vlans', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'se', id: 'se-sto', @@ -273,6 +282,7 @@ export const regions: Region[] = [ 'Vlans', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'in', id: 'in-maa', @@ -299,6 +309,7 @@ export const regions: Region[] = [ 'Vlans', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'jp', id: 'jp-osa', @@ -325,6 +336,7 @@ export const regions: Region[] = [ 'Vlans', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'it', id: 'it-mil', @@ -351,6 +363,7 @@ export const regions: Region[] = [ 'Vlans', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'us', id: 'us-mia', @@ -377,6 +390,7 @@ export const regions: Region[] = [ 'Vlans', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'id', id: 'id-cgk', @@ -403,6 +417,7 @@ export const regions: Region[] = [ 'Vlans', 'Metadata', 'Premium Plans', + 'Placement Group', ], country: 'us', id: 'us-lax', @@ -428,6 +443,7 @@ export const regions: Region[] = [ 'Vlans', 'Block Storage Migrations', 'Managed Databases', + 'Placement Group', ], country: 'us', id: 'us-central', @@ -452,11 +468,12 @@ export const regions: Region[] = [ 'Cloud Firewall', 'Block Storage Migrations', 'Managed Databases', + 'Placement Group', ], country: 'us', id: 'us-west', label: 'Fremont, CA', - maximum_pgs_per_customer: 5, + maximum_pgs_per_customer: 0, maximum_vms_per_pg: 10, resolvers: { ipv4: @@ -479,6 +496,7 @@ export const regions: Region[] = [ 'Vlans', 'Block Storage Migrations', 'Managed Databases', + 'Placement Group', ], country: 'us', id: 'us-southeast', @@ -507,6 +525,7 @@ export const regions: Region[] = [ 'Vlans', 'Block Storage Migrations', 'Managed Databases', + 'Placement Group', ], country: 'us', id: 'us-east', @@ -532,6 +551,7 @@ export const regions: Region[] = [ 'Vlans', 'Block Storage Migrations', 'Managed Databases', + 'Placement Group', ], country: 'gb', id: 'eu-west', @@ -559,6 +579,7 @@ export const regions: Region[] = [ 'Vlans', 'Block Storage Migrations', 'Managed Databases', + 'Placement Group', ], country: 'sg', id: 'ap-south', @@ -586,6 +607,7 @@ export const regions: Region[] = [ 'Vlans', 'Block Storage Migrations', 'Managed Databases', + 'Placement Group', ], country: 'de', id: 'eu-central', diff --git a/packages/manager/src/components/TextField.tsx b/packages/manager/src/components/TextField.tsx index 99fcc66e9ec..bac96accd5d 100644 --- a/packages/manager/src/components/TextField.tsx +++ b/packages/manager/src/components/TextField.tsx @@ -1,9 +1,9 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown'; +import { Theme, useTheme } from '@mui/material/styles'; import { default as _TextField, StandardTextFieldProps, } from '@mui/material/TextField'; -import { Theme, useTheme } from '@mui/material/styles'; import { clamp } from 'ramda'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx new file mode 100644 index 00000000000..4aa294a486f --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { FormControlLabel } from 'src/components/FormControlLabel'; +import { FormLabel } from 'src/components/FormLabel'; +import { Notice } from 'src/components/Notice/Notice'; +import { Radio } from 'src/components/Radio/Radio'; +import { RadioGroup } from 'src/components/RadioGroup'; +import { Typography } from 'src/components/Typography'; + +import type { FormikHelpers } from 'formik'; + +interface Props { + handleChange: (e: React.ChangeEvent) => void; + setFieldValue: FormikHelpers['setFieldValue']; + value: boolean; +} + +export const PlacementGroupsAffinityEnforcementRadioGroup = (props: Props) => { + const { handleChange, setFieldValue, value } = props; + return ( + + + + Affinity Enforcement + + { + handleChange(event); + setFieldValue('is_strict', event.target.value === 'true'); + }} + id="affinity-enforcement-radio-group" + name="is_strict" + value={value} + > + + Strict. You cannot assign a Linode to your + placement group if it will violate the policy of your selected + Affinity Type (best practice). + + } + control={} + value={true} + /> + + Flexible. You can assign a Linode to your + placement group, even if it violates the policy of your selected + Affinity Type. + + } + control={} + sx={{ mt: 2 }} + value={false} + /> + + + ); +}; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityTypeSelect.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityTypeSelect.tsx new file mode 100644 index 00000000000..2f2260c1297 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityTypeSelect.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Link } from 'src/components/Link'; +import { ListItem } from 'src/components/ListItem'; +import { Tooltip } from 'src/components/Tooltip'; +import { Typography } from 'src/components/Typography'; + +import { affinityTypeOptions } from './utils'; + +import type { FormikHelpers } from 'formik'; + +interface Props { + error: string | undefined; + setFieldValue: FormikHelpers['setFieldValue']; +} + +export const PlacementGroupsAffinityTypeSelect = (props: Props) => { + const { error, setFieldValue } = props; + return ( + option.value === 'anti_affinity' + )} + onChange={(_, value) => { + setFieldValue('affinity_type', value?.value ?? ''); + }} + renderOption={(props, option) => { + const isDisabledMenuItem = option.value === 'affinity'; + + return ( + + Only supporting Anti-affinity host placement groups for Beta.{' '} + 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 + } + component="li" + > + {option.label} + + + ); + }} + textFieldProps={{ + tooltipText: ( + + Linodes in a placement group that use ‘Affinity’ always exist on the + same host. This can help with performance. Linodes in a placement + group that use ‘Anti-affinity: Host’ are never on the same host. Use + this to support a high-availability model. +
+ Learn more. +
+ ), + }} + disableClearable={true} + errorText={error} + label="Affinity Type" + options={affinityTypeOptions} + placeholder="Select an Affinity Type" + /> + ); +}; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.test.tsx index 7a387dfaef2..09770be9be9 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.test.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.test.tsx @@ -59,7 +59,6 @@ describe('PlacementGroupsAssignLinodesDrawer', () => { const { getByText } = renderWithTheme( ({ + useCreatePlacementGroup: vi.fn().mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({}), + reset: vi.fn(), + }), +})); + +vi.mock('src/queries/placementGroups', async () => { + const actual = await vi.importActual('src/queries/placementGroups'); + return { + ...actual, + useCreatePlacementGroup: queryMocks.useCreatePlacementGroup, + }; +}); + describe('PlacementGroupsCreateDrawer', () => { it('should render and have its fields enabled', () => { - const { getByLabelText } = renderWithTheme( - + const { getAllByRole, getByLabelText, getByText } = renderWithTheme( + ); expect(getByLabelText('Label')).toBeEnabled(); expect(getByLabelText('Region')).toBeEnabled(); expect(getByLabelText('Affinity Type')).toBeEnabled(); + expect(getByText('Affinity Enforcement')).toBeInTheDocument(); + + const radioInputs = getAllByRole('radio'); + expect(radioInputs).toHaveLength(2); + expect(radioInputs[0]).toBeChecked(); }); it('Affinity Type select should have the correct options', async () => { const { getByPlaceholderText, getByText } = renderWithTheme( - + ); const inputElement = getByPlaceholderText('Select an Affinity Type'); @@ -46,39 +57,75 @@ describe('PlacementGroupsCreateDrawer', () => { expect(getByText('Anti-affinity')).toBeInTheDocument(); }); - it('should disable the submit button when the number of placement groups created is >= to the max', () => { + it('should populate the region select with the selected region prop', async () => { const { getByTestId } = renderWithTheme( ); - expect(getByTestId('submit')).toHaveAttribute('aria-disabled', 'true'); + await waitFor(() => { + expect(getByTestId('selected-region')).toHaveTextContent( + 'Newark, NJ (us-east)' + ); + }); }); - it('should populate the region select with the selected region prop', async () => { - server.use( - rest.get('*/regions', (req, res, ctx) => { - const regions = regionFactory.buildList(1, { - id: 'us-east', - label: 'Newark, NJ', - capabilities: ['Linodes'], - }); - return res(ctx.json(makeResourcePage(regions))); - }) - ); + it('should call the mutation when the form is submitted', async () => { + const { + getByLabelText, + getByPlaceholderText, + getByRole, + getByText, + } = renderWithTheme(); - const { getByLabelText } = renderWithTheme( - + fireEvent.change(getByLabelText('Label'), { + target: { value: 'my-label' }, + }); + + const regionSelect = getByPlaceholderText('Select a Region'); + fireEvent.focus(regionSelect); + fireEvent.change(regionSelect, { + target: { value: 'Newark, NJ (us-east)' }, + }); + await waitFor(() => { + const selectedRegionOption = getByText('Newark, NJ (us-east)'); + fireEvent.click(selectedRegionOption); + }); + + fireEvent.click(getByRole('button', { name: 'Create Placement Group' })); + + await waitFor(() => { + expect( + queryMocks.useCreatePlacementGroup().mutateAsync + ).toHaveBeenCalledWith({ + affinity_type: 'anti_affinity', + is_strict: true, + label: 'my-label', + region: 'us-east', + }); + }); + }); + + it('should display an error message if the region has reached capacity', async () => { + const regionWithoutCapacity = 'Fremont, CA (us-west)'; + const { getByPlaceholderText, getByText } = renderWithTheme( + ); + const regionSelect = getByPlaceholderText('Select a Region'); + fireEvent.focus(regionSelect); + fireEvent.change(regionSelect, { + target: { value: regionWithoutCapacity }, + }); + await waitFor(() => { + const selectedRegionOption = getByText(regionWithoutCapacity); + fireEvent.click(selectedRegionOption); + }); + await waitFor(() => { - expect(getByLabelText('Region')).toHaveValue('Newark, NJ (us-east)'); + expect(getByText('This region has reached capacity')).toBeInTheDocument(); }); }); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx index 46c709c88e4..1fb84ec35a2 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx @@ -2,45 +2,103 @@ import { createPlacementGroupSchema } from '@linode/validation'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Divider } from 'src/components/Divider'; import { Drawer } from 'src/components/Drawer'; +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 { useFormValidateOnChange } from 'src/hooks/useFormValidateOnChange'; -import { queryKey as placementGroupQueryKey } from 'src/queries/placementGroups'; import { useCreatePlacementGroup } from 'src/queries/placementGroups'; import { useRegionsQuery } from 'src/queries/regions'; -import { getErrorMap } from 'src/utilities/errorUtils'; -import { - handleFieldErrors, - handleGeneralErrors, -} from 'src/utilities/formikErrorUtils'; +import { getFormikErrorsFromAPIErrors } from 'src/utilities/formikErrorUtils'; +import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; -import { MAX_NUMBER_OF_PLACEMENT_GROUPS } from './constants'; -import { PlacementGroupsDrawerContent } from './PlacementGroupsDrawerContent'; +import { PlacementGroupsAffinityEnforcementRadioGroup } from './PlacementGroupsAffinityEnforcementRadioGroup'; +import { PlacementGroupsAffinityTypeSelect } from './PlacementGroupsAffinityTypeSelect'; +import { hasRegionReachedPlacementGroupCapacity } from './utils'; -import type { - PlacementGroupDrawerFormikProps, - PlacementGroupsCreateDrawerProps, -} from './types'; +import type { PlacementGroupsCreateDrawerProps } from './types'; +import type { CreatePlacementGroupPayload, Region } from '@linode/api-v4'; +import type { FormikHelpers } from 'formik'; export const PlacementGroupsCreateDrawer = ( props: PlacementGroupsCreateDrawerProps ) => { const { - numberOfPlacementGroupsCreated, + allPlacementGroups, onClose, - onPlacementGroupCreated, + onPlacementGroupCreate, open, selectedRegionId, } = props; - const queryClient = useQueryClient(); const { data: regions } = useRegionsQuery(); - const { mutateAsync } = useCreatePlacementGroup(); + const { error, mutateAsync } = useCreatePlacementGroup(); const { enqueueSnackbar } = useSnackbar(); const { hasFormBeenSubmitted, setHasFormBeenSubmitted, } = useFormValidateOnChange(); + const [ + hasRegionReachedPGCapacity, + setHasRegionReachedPGCapacity, + ] = React.useState(false); + + const selectedRegionFromProps = regions?.find( + (r) => r.id === selectedRegionId + ); + + const handleRegionSelect = (region: Region['id']) => { + const selectedRegion = regions?.find((r) => r.id === region); + + setFieldValue('region', region); + setHasRegionReachedPGCapacity( + hasRegionReachedPlacementGroupCapacity({ + allPlacementGroups, + region: selectedRegion, + }) + ); + }; + + const handleResetForm = () => { + resetForm(); + setHasFormBeenSubmitted(false); + setHasRegionReachedPGCapacity(false); + }; + + const handleDrawerClose = () => { + onClose(); + handleResetForm(); + }; + + const handleFormSubmit = async ( + values: CreatePlacementGroupPayload, + { setErrors, setStatus }: FormikHelpers + ) => { + setHasFormBeenSubmitted(false); + setStatus(undefined); + setErrors({}); + + try { + const response = await mutateAsync(values); + + enqueueSnackbar(`Placement Group ${values.label} successfully created`, { + variant: 'success', + }); + + if (onPlacementGroupCreate) { + onPlacementGroupCreate(response); + } + handleResetForm(); + onClose(); + } catch (errors) { + setErrors(getFormikErrorsFromAPIErrors(errors)); + scrollErrorIntoView(); + } + }; const { errors, @@ -50,85 +108,96 @@ export const PlacementGroupsCreateDrawer = ( isSubmitting, resetForm, setFieldValue, - status, values, - ...rest } = useFormik({ enableReinitialize: true, initialValues: { - affinity_type: '' as PlacementGroupDrawerFormikProps['affinity_type'], + affinity_type: 'anti_affinity', is_strict: true, label: '', region: selectedRegionId ?? '', }, - onSubmit( - values: PlacementGroupDrawerFormikProps, - { setErrors, setStatus, setSubmitting } - ) { - setHasFormBeenSubmitted(false); - setStatus(undefined); - setErrors({}); - const payload = { ...values }; - - mutateAsync(payload) - .then((response) => { - setSubmitting(false); - queryClient.invalidateQueries([placementGroupQueryKey]); - - enqueueSnackbar( - `Placement Group ${payload.label} successfully created`, - { - variant: 'success', - } - ); - - if (onPlacementGroupCreated) { - onPlacementGroupCreated(response); - } - onClose(); - }) - .catch((err) => { - const mapErrorToStatus = () => - setStatus({ generalError: getErrorMap([], err).none }); - - setSubmitting(false); - handleFieldErrors(setErrors, err); - handleGeneralErrors( - mapErrorToStatus, - err, - 'Error creating Placement Group.' - ); - }); - }, + onSubmit: handleFormSubmit, validateOnBlur: false, validateOnChange: hasFormBeenSubmitted, validationSchema: createPlacementGroupSchema, }); + const generalError = error?.find((e) => !e.field)?.reason; + return ( - - + +
+ + {generalError && } + {selectedRegionFromProps && ( + + Region: + {`${selectedRegionFromProps.label} (${selectedRegionFromProps.id})`} + + )} + +
); }; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDrawerContent.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDrawerContent.test.tsx deleted file mode 100644 index 335425db53d..00000000000 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDrawerContent.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { FormikProps } from 'formik'; -import * as React from 'react'; - -import { placementGroupFactory } from 'src/factories'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { PlacementGroupsDrawerContent } from './PlacementGroupsDrawerContent'; - -import type { PlacementGroupDrawerFormikProps } from './types'; - -describe('PlacementGroupsDrawerContent', () => { - it('should render the right form elements', () => { - const { getByLabelText, getByRole } = renderWithTheme( - - } - mode="create" - onClose={vi.fn()} - open={true} - regions={[]} - selectedPlacementGroup={placementGroupFactory.build()} - setHasFormBeenSubmitted={vi.fn()} - /> - ); - - expect(getByLabelText('Label')).toBeInTheDocument(); - expect(getByLabelText('Region')).toBeInTheDocument(); - expect(getByLabelText('Affinity Type')).toBeInTheDocument(); - expect(getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - expect( - getByRole('button', { name: 'Create Placement Group' }) - ).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDrawerContent.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDrawerContent.tsx deleted file mode 100644 index f7e11486eca..00000000000 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDrawerContent.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import Grid from '@mui/material/Unstable_Grid2'; -import * as React from 'react'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -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 { MAX_NUMBER_OF_LINODES_IN_PLACEMENT_GROUP_MESSAGE } from './constants'; -import { affinityTypeOptions } from './utils'; - -import type { PlacementGroupDrawerFormikProps } from './types'; -import type { PlacementGroup, Region } from '@linode/api-v4'; -import type { FormikProps } from 'formik'; - -interface Props { - formik: FormikProps; - maxNumberOfPlacementGroups?: number; - mode: 'create' | 'rename'; - numberOfPlacementGroupsCreated?: number; - onClose: () => void; - open: boolean; - regions: Region[]; - selectedPlacementGroup?: PlacementGroup; - selectedRegionId?: string; - setHasFormBeenSubmitted: React.Dispatch>; -} - -export const PlacementGroupsDrawerContent = (props: Props) => { - const { - formik, - maxNumberOfPlacementGroups, - mode, - numberOfPlacementGroupsCreated, - onClose, - open, - regions, - selectedPlacementGroup, - selectedRegionId, - setHasFormBeenSubmitted, - } = props; - const { - errors, - handleBlur, - handleChange, - handleSubmit, - isSubmitting, - resetForm, - setFieldValue, - status, - values, - } = formik; - - React.useEffect(() => { - resetForm(); - setHasFormBeenSubmitted(false); - }, [open, resetForm]); - - React.useEffect(() => { - if (isSubmitting) { - setHasFormBeenSubmitted(isSubmitting); - } - }, [isSubmitting]); - - const generalError = status?.generalError; - const isRenameDrawer = mode === 'rename'; - - return ( - - {generalError ? : null} -
- - - { - setFieldValue('region', selection); - }} - currentCapability="Linodes" // TODO VM_Placement: change to Placement Groups when available - disabled={isRenameDrawer || Boolean(selectedRegionId)} - errorText={errors.region} - regions={regions ?? []} - selectedId={selectedRegionId ?? values.region} - /> - { - setFieldValue('affinity_type', value?.value ?? ''); - }} - value={ - affinityTypeOptions.find( - (option) => option.value === values.affinity_type - ) ?? null - } - disabled={isRenameDrawer} - errorText={errors.affinity_type} - label="Affinity Type" - options={affinityTypeOptions} - placeholder="Select an Affinity Type" - /> - = maxNumberOfPlacementGroups - : false, - label: `${isRenameDrawer ? 'Rename' : 'Create'} Placement Group`, - loading: isSubmitting, - tooltipText: MAX_NUMBER_OF_LINODES_IN_PLACEMENT_GROUP_MESSAGE, - type: 'submit', - }} - secondaryButtonProps={{ - 'data-testid': 'cancel', - label: 'Cancel', - onClick: onClose, - }} - sx={{ pt: 4 }} - /> - -
-
- ); -}; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.test.tsx new file mode 100644 index 00000000000..6103a6f9c97 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.test.tsx @@ -0,0 +1,83 @@ +import { fireEvent } from '@testing-library/react'; +import * as React from 'react'; + +import { placementGroupFactory, regionFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { PlacementGroupsEditDrawer } from './PlacementGroupsEditDrawer'; + +const queryMocks = vi.hoisted(() => ({ + useMutatePlacementGroup: vi.fn().mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({}), + reset: vi.fn(), + }), + useParams: vi.fn().mockReturnValue({}), + usePlacementGroupQuery: vi.fn().mockReturnValue({}), + useRegionsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + +vi.mock('src/queries/regions', async () => { + const actual = await vi.importActual('src/queries/regions'); + return { + ...actual, + useRegionsQuery: queryMocks.useRegionsQuery, + }; +}); + +vi.mock('src/queries/placementGroups', async () => { + const actual = await vi.importActual('src/queries/placementGroups'); + return { + ...actual, + useMutatePlacementGroup: queryMocks.useMutatePlacementGroup, + usePlacementGroupQuery: queryMocks.usePlacementGroupQuery, + }; +}); + +describe('PlacementGroupsCreateDrawer', () => { + it('should render, have the proper fields populated with PG values, and have uneditable fields disabled', async () => { + queryMocks.useParams.mockReturnValue({ id: '1' }); + queryMocks.useRegionsQuery.mockReturnValue({ + data: regionFactory.buildList(1, { id: 'us-east', label: 'Newark, NJ' }), + }); + queryMocks.usePlacementGroupQuery.mockReturnValue({ + data: placementGroupFactory.build({ + affinity_type: 'anti_affinity', + id: 1, + label: 'PG-to-edit', + region: 'us-east', + }), + }); + + const { getByLabelText, getByRole, getByText } = renderWithTheme( + + ); + + expect( + getByRole('heading', { + name: 'Edit Placement Group PG-to-edit (Anti-affinity)', + }) + ).toBeInTheDocument(); + expect(getByText('Newark, NJ (us-east)')).toBeInTheDocument(); + expect(getByLabelText('Label')).toBeEnabled(); + expect(getByLabelText('Label')).toHaveValue('PG-to-edit'); + expect(getByRole('button', { name: 'Cancel' })).toBeEnabled(); + + const editButton = getByRole('button', { name: 'Edit' }); + expect(editButton).toBeEnabled(); + fireEvent.click(editButton); + + expect(queryMocks.useMutatePlacementGroup).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.tsx new file mode 100644 index 00000000000..9988464e2d4 --- /dev/null +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsEditDrawer.tsx @@ -0,0 +1,158 @@ +import { AFFINITY_TYPES } from '@linode/api-v4'; +import { updatePlacementGroupSchema } from '@linode/validation'; +import { useFormik } from 'formik'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { useParams } from 'react-router-dom'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Divider } from 'src/components/Divider'; +import { Drawer } from 'src/components/Drawer'; +import { Notice } from 'src/components/Notice/Notice'; +import { Stack } from 'src/components/Stack'; +import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; +import { useFormValidateOnChange } from 'src/hooks/useFormValidateOnChange'; +import { usePlacementGroupData } from 'src/hooks/usePlacementGroupsData'; +import { usePlacementGroupQuery } from 'src/queries/placementGroups'; +import { useMutatePlacementGroup } from 'src/queries/placementGroups'; +import { getFormikErrorsFromAPIErrors } from 'src/utilities/formikErrorUtils'; +import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; + +import { getAffinityEnforcement } from './utils'; + +import type { PlacementGroupsEditDrawerProps } from './types'; +import type { UpdatePlacementGroupPayload } from '@linode/api-v4'; +import type { FormikHelpers } from 'formik'; + +export const PlacementGroupsEditDrawer = ( + props: PlacementGroupsEditDrawerProps +) => { + const { onClose, onPlacementGroupEdit, open } = props; + const { id } = useParams<{ id: string }>(); + const { data: selectedPlacementGroup } = usePlacementGroupQuery(+id); + const { region } = usePlacementGroupData({ + placementGroup: selectedPlacementGroup, + }); + const { error, mutateAsync } = useMutatePlacementGroup( + selectedPlacementGroup?.id ?? -1 + ); + const { enqueueSnackbar } = useSnackbar(); + const { + hasFormBeenSubmitted, + setHasFormBeenSubmitted, + } = useFormValidateOnChange(); + + const handleResetForm = () => { + resetForm(); + setHasFormBeenSubmitted(false); + }; + + const handleDrawerClose = () => { + onClose(); + handleResetForm(); + }; + + const handleFormSubmit = async ( + values: UpdatePlacementGroupPayload, + { setErrors, setStatus }: FormikHelpers + ) => { + setHasFormBeenSubmitted(false); + setStatus(undefined); + setErrors({}); + + try { + const response = await mutateAsync(values); + + enqueueSnackbar(`Placement Group ${values.label} successfully updated`, { + variant: 'success', + }); + + if (onPlacementGroupEdit) { + onPlacementGroupEdit(response); + } + onClose(); + } catch (errors) { + setErrors(getFormikErrorsFromAPIErrors(errors)); + scrollErrorIntoView(); + } + }; + + const { + errors, + handleBlur, + handleChange, + handleSubmit, + isSubmitting, + resetForm, + values, + } = useFormik({ + enableReinitialize: true, + initialValues: { + label: selectedPlacementGroup?.label ?? '', + }, + onSubmit: handleFormSubmit, + validateOnBlur: false, + validateOnChange: hasFormBeenSubmitted, + validationSchema: updatePlacementGroupSchema, + }); + + const generalError = error?.find((e) => !e.field)?.reason; + + if (!selectedPlacementGroup) { + return null; + } + + return ( + + {generalError && } + + Region: + {region ? `${region.label} (${region.id})` : 'Unknown'} + + + Affinity Enforcement: + {getAffinityEnforcement(selectedPlacementGroup.is_strict)} + + +
+ + + + + +
+
+ ); +}; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx index 9728225396e..379418fff88 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx @@ -23,7 +23,7 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { PlacementGroupsCreateDrawer } from '../PlacementGroupsCreateDrawer'; import { PlacementGroupsDeleteModal } from '../PlacementGroupsDeleteModal'; -import { PlacementGroupsRenameDrawer } from '../PlacementGroupsRenameDrawer'; +import { PlacementGroupsEditDrawer } from '../PlacementGroupsEditDrawer'; import { PlacementGroupsLandingEmptyState } from './PlacementGroupsLandingEmptyState'; import { PlacementGroupsRow } from './PlacementGroupsRow'; @@ -34,9 +34,6 @@ const preferenceKey = 'placement-groups'; export const PlacementGroupsLanding = React.memo(() => { const history = useHistory(); const pagination = usePagination(1, preferenceKey); - const [selectedPlacementGroup, setSelectedPlacementGroup] = React.useState< - PlacementGroup | undefined - >(); const [query, setQuery] = React.useState(''); const { handleOrderChange, order, orderBy } = useOrder( { @@ -69,13 +66,11 @@ export const PlacementGroupsLanding = React.memo(() => { history.replace('/placement-groups/create'); }; - const handleRenamePlacementGroup = (placementGroup: PlacementGroup) => { - setSelectedPlacementGroup(placementGroup); - history.replace(`/placement-groups/rename/${placementGroup.id}`); + const handleEditPlacementGroup = (placementGroup: PlacementGroup) => { + history.replace(`/placement-groups/edit/${placementGroup.id}`); }; const handleDeletePlacementGroup = (placementGroup: PlacementGroup) => { - setSelectedPlacementGroup(placementGroup); history.replace(`/placement-groups/delete/${placementGroup.id}`); }; @@ -84,8 +79,8 @@ export const PlacementGroupsLanding = React.memo(() => { }; const isPlacementGroupCreateDrawerOpen = location.pathname.endsWith('create'); - const isPlacementGroupRenameDrawerOpen = location.pathname.includes('rename'); const isPlacementGroupDeleteModalOpen = location.pathname.includes('delete'); + const isPlacementGroupEditDrawerOpen = location.pathname.includes('edit'); if (isLoading) { return ; @@ -98,7 +93,7 @@ export const PlacementGroupsLanding = React.memo(() => { openCreatePlacementGroupDrawer={handleCreatePlacementGroup} /> @@ -180,8 +175,8 @@ export const PlacementGroupsLanding = React.memo(() => { handleDeletePlacementGroup={() => handleDeletePlacementGroup(placementGroup) } - handleRenamePlacementGroup={() => - handleRenamePlacementGroup(placementGroup) + handleEditPlacementGroup={() => + handleEditPlacementGroup(placementGroup) } key={`pg-${placementGroup.id}`} placementGroup={placementGroup} @@ -198,15 +193,13 @@ export const PlacementGroupsLanding = React.memo(() => { pageSize={pagination.pageSize} /> - { }); const handleDeletePlacementGroupMock = vi.fn(); -const handleRenamePlacementGroupMock = vi.fn(); +const handleEditPlacementGroupMock = vi.fn(); describe('PlacementGroupsLanding', () => { it('renders the columns with proper data', () => { @@ -81,7 +81,7 @@ describe('PlacementGroupsLanding', () => { region: 'us-east', })} handleDeletePlacementGroup={handleDeletePlacementGroupMock} - handleRenamePlacementGroup={handleRenamePlacementGroupMock} + handleEditPlacementGroup={handleEditPlacementGroupMock} /> ) ); @@ -94,7 +94,7 @@ describe('PlacementGroupsLanding', () => { '1' ); expect(getByText('Newark, NJ')).toBeInTheDocument(); - expect(getByRole('button', { name: 'Rename' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Edit' })).toBeInTheDocument(); expect(getByRole('button', { name: 'Delete' })).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx index 55d778aae5f..2e70918849a 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx @@ -19,14 +19,14 @@ import type { Action } from 'src/components/ActionMenu/ActionMenu'; interface PlacementGroupsRowProps { handleDeletePlacementGroup: () => void; - handleRenamePlacementGroup: () => void; + handleEditPlacementGroup: () => void; placementGroup: PlacementGroup; } export const PlacementGroupsRow = React.memo( ({ handleDeletePlacementGroup, - handleRenamePlacementGroup, + handleEditPlacementGroup, placementGroup, }: PlacementGroupsRowProps) => { const { affinity_type, id, is_compliant, label } = placementGroup; @@ -35,8 +35,8 @@ export const PlacementGroupsRow = React.memo( }); const actions: Action[] = [ { - onClick: handleRenamePlacementGroup, - title: 'Rename', + onClick: handleEditPlacementGroup, + title: 'Edit', }, { onClick: handleDeletePlacementGroup, diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsRenameDrawer.test.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsRenameDrawer.test.tsx deleted file mode 100644 index d3964b92a2f..00000000000 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsRenameDrawer.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { waitFor } from '@testing-library/react'; -import * as React from 'react'; -import { regionFactory } from 'src/factories'; - -import { placementGroupFactory } from 'src/factories/placementGroups'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { rest, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { PlacementGroupsRenameDrawer } from './PlacementGroupsRenameDrawer'; - -describe('PlacementGroupsCreateDrawer', () => { - it('should render, have the proper fields populated with PG values, and have uneditable fields disabled', async () => { - server.use( - rest.get('*/regions', (req, res, ctx) => { - const regions = regionFactory.buildList(1, { - id: 'us-east', - label: 'Fake Region, NC', - capabilities: ['Linodes'], - }); - return res(ctx.json(makeResourcePage(regions))); - }) - ); - - const { getByLabelText } = renderWithTheme( - - ); - - expect(getByLabelText('Label')).toBeEnabled(); - expect(getByLabelText('Label')).toHaveValue('PG-1'); - - expect(getByLabelText('Region')).toBeDisabled(); - - await waitFor(() => { - expect(getByLabelText('Region')).toHaveValue('Fake Region, NC (us-east)'); - }); - - expect(getByLabelText('Affinity Type')).toBeDisabled(); - expect(getByLabelText('Affinity Type')).toHaveValue('Anti-affinity'); - }); -}); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsRenameDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsRenameDrawer.tsx deleted file mode 100644 index 0a898e3b52f..00000000000 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsRenameDrawer.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { renamePlacementGroupSchema } from '@linode/validation'; -import { useFormik } from 'formik'; -import { useSnackbar } from 'notistack'; -import * as React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; - -import { Drawer } from 'src/components/Drawer'; -import { useFormValidateOnChange } from 'src/hooks/useFormValidateOnChange'; -import { queryKey as placementGroupQueryKey } from 'src/queries/placementGroups'; -import { useMutatePlacementGroup } from 'src/queries/placementGroups'; -import { useRegionsQuery } from 'src/queries/regions'; -import { getErrorMap } from 'src/utilities/errorUtils'; -import { - handleFieldErrors, - handleGeneralErrors, -} from 'src/utilities/formikErrorUtils'; - -import { PlacementGroupsDrawerContent } from './PlacementGroupsDrawerContent'; - -import type { - PlacementGroupDrawerFormikProps, - PlacementGroupsRenameDrawerProps, -} from './types'; - -export const PlacementGroupsRenameDrawer = ( - props: PlacementGroupsRenameDrawerProps -) => { - const { - onClose, - onPlacementGroupRenamed, - open, - selectedPlacementGroup, - } = props; - const queryClient = useQueryClient(); - const { data: regions } = useRegionsQuery(); - const { mutateAsync } = useMutatePlacementGroup( - selectedPlacementGroup?.id ?? -1 - ); - const { enqueueSnackbar } = useSnackbar(); - const { - hasFormBeenSubmitted, - setHasFormBeenSubmitted, - } = useFormValidateOnChange(); - - const { - errors, - handleBlur, - handleChange, - handleSubmit, - isSubmitting, - resetForm, - setFieldValue, - status, - values, - ...rest - } = useFormik({ - enableReinitialize: true, - initialValues: { - affinity_type: selectedPlacementGroup?.affinity_type as PlacementGroupDrawerFormikProps['affinity_type'], - is_strict: true, - label: selectedPlacementGroup?.label ?? '', - region: selectedPlacementGroup?.region ?? '', - }, - onSubmit(values, { setErrors, setStatus, setSubmitting }) { - setHasFormBeenSubmitted(false); - setStatus(undefined); - setErrors({}); - const payload = { ...values }; - // This is a bit of an outlier. We only need to pass the label since that's the only value the API accepts. - // Meanwhile formik is still keeping track of the other values since we're showing them in the UI. - const { label } = payload; - - mutateAsync({ label }) - .then((response) => { - setSubmitting(false); - queryClient.invalidateQueries([placementGroupQueryKey]); - - enqueueSnackbar( - `Placement Group ${payload.label} successfully renamed`, - { - variant: 'success', - } - ); - - if (onPlacementGroupRenamed) { - onPlacementGroupRenamed(response); - } - onClose(); - }) - .catch((err) => { - const mapErrorToStatus = () => - setStatus({ generalError: getErrorMap([], err).none }); - - setSubmitting(false); - handleFieldErrors(setErrors, err); - handleGeneralErrors( - mapErrorToStatus, - err, - 'Error renaming Placement Group.' - ); - }); - }, - validateOnBlur: false, - validateOnChange: hasFormBeenSubmitted, - validationSchema: renamePlacementGroupSchema, - }); - - return ( - - - - ); -}; diff --git a/packages/manager/src/features/PlacementGroups/index.tsx b/packages/manager/src/features/PlacementGroups/index.tsx index b8e2e1f3b2b..aae3d8f8c7d 100644 --- a/packages/manager/src/features/PlacementGroups/index.tsx +++ b/packages/manager/src/features/PlacementGroups/index.tsx @@ -34,7 +34,7 @@ export const PlacementGroups = () => { void; open: boolean; }; export type PlacementGroupsCreateDrawerProps = PlacementGroupsDrawerPropsBase & { - onPlacementGroupCreated?: (placementGroup: PlacementGroup) => void; + allPlacementGroups: PlacementGroup[]; + onPlacementGroupCreate?: (placementGroup: PlacementGroup) => void; selectedRegionId?: string; }; -export type PlacementGroupsRenameDrawerProps = PlacementGroupsDrawerPropsBase & { - onPlacementGroupRenamed?: (placementGroup: PlacementGroup) => void; - selectedPlacementGroup: PlacementGroup | undefined; +export type PlacementGroupsEditDrawerProps = PlacementGroupsDrawerPropsBase & { + onPlacementGroupEdit?: (placementGroup: PlacementGroup) => void; }; -export type PlacementGroupDrawerFormikProps = UpdatePlacementGroupPayload & - CreatePlacementGroupPayload; - export type PlacementGroupsAssignLinodesDrawerProps = PlacementGroupsDrawerPropsBase & { onLinodeAddedToPlacementGroup?: (placementGroup: PlacementGroup) => void; selectedPlacementGroup: PlacementGroup | undefined; diff --git a/packages/manager/src/features/PlacementGroups/utils.test.ts b/packages/manager/src/features/PlacementGroups/utils.test.ts index b18bbbd8f08..14de684c201 100644 --- a/packages/manager/src/features/PlacementGroups/utils.test.ts +++ b/packages/manager/src/features/PlacementGroups/utils.test.ts @@ -6,6 +6,7 @@ import { getLinodesFromAllPlacementGroups, getPlacementGroupLinodeCount, hasPlacementGroupReachedCapacity, + hasRegionReachedPlacementGroupCapacity, } from './utils'; import type { PlacementGroup } from '@linode/api-v4'; @@ -115,3 +116,33 @@ describe('getAffinityEnforcement', () => { expect(getAffinityEnforcement(false)).toBe('Flexible'); }); }); + +describe('hasRegionReachedPlacementGroupCapacity', () => { + it('returns true if the region has reached its placement group capacity', () => { + expect( + hasRegionReachedPlacementGroupCapacity({ + allPlacementGroups: placementGroupFactory.buildList(3, { + region: 'us-east', + }), + region: regionFactory.build({ + id: 'us-east', + maximum_pgs_per_customer: 2, + }), + }) + ).toBe(true); + }); + + it('returns false if the region has not reached its placement group capacity', () => { + expect( + hasRegionReachedPlacementGroupCapacity({ + allPlacementGroups: placementGroupFactory.buildList(3, { + region: 'us-east', + }), + region: regionFactory.build({ + id: 'us-east', + maximum_pgs_per_customer: 4, + }), + }) + ).toBe(false); + }); +}); diff --git a/packages/manager/src/features/PlacementGroups/utils.ts b/packages/manager/src/features/PlacementGroups/utils.ts index 09c23187eca..d8905755b6c 100644 --- a/packages/manager/src/features/PlacementGroups/utils.ts +++ b/packages/manager/src/features/PlacementGroups/utils.ts @@ -11,9 +11,9 @@ import type { * Helper to get the affinity enforcement readable string. */ export const getAffinityEnforcement = ( - affinityType: PlacementGroup['is_strict'] + is_strict: boolean ): AffinityEnforcement => { - return affinityType ? 'Strict' : 'Flexible'; + return is_strict ? 'Strict' : 'Flexible'; }; /** @@ -29,8 +29,11 @@ interface HasPlacementGroupReachedCapacityOptions { placementGroup: PlacementGroup | undefined; region: Region | undefined; } + /** - * Helper to determine if a Placement Group has reached capacity. + * Helper to determine if a Placement Group has reached its linode capacity. + * + * based on the region's `maximum_vms_per_pg`. */ export const hasPlacementGroupReachedCapacity = ({ placementGroup, @@ -45,11 +48,41 @@ export const hasPlacementGroupReachedCapacity = ({ ); }; +interface HasRegionReachedPlacementGroupCapacityOptions { + allPlacementGroups: PlacementGroup[] | undefined; + region: Region | undefined; +} + +/** + * Helper to determine if a region has reached its placement group capacity. + * + * based on the region's `maximum_pgs_per_customer`. + */ +export const hasRegionReachedPlacementGroupCapacity = ({ + allPlacementGroups, + region, +}: HasRegionReachedPlacementGroupCapacityOptions): boolean => { + if (!region || !allPlacementGroups) { + return false; + } + + const { maximum_pgs_per_customer } = region; + const placementGroupsInRegion = allPlacementGroups.filter( + (pg) => pg.region === region.id + ); + + return ( + placementGroupsInRegion.length >= maximum_pgs_per_customer || + maximum_pgs_per_customer === 0 + ); +}; + /** * Helper to populate the affinity_type select options. */ export const affinityTypeOptions = Object.entries(AFFINITY_TYPES).map( ([key, value]) => ({ + disabled: false, label: value, value: key as CreatePlacementGroupPayload['affinity_type'], }) diff --git a/packages/manager/src/queries/placementGroups.ts b/packages/manager/src/queries/placementGroups.ts index 8d9f8b8c2bf..802efb433d9 100644 --- a/packages/manager/src/queries/placementGroups.ts +++ b/packages/manager/src/queries/placementGroups.ts @@ -4,8 +4,8 @@ import { deletePlacementGroup, getPlacementGroup, getPlacementGroups, - renamePlacementGroup, unassignLinodesFromPlacementGroup, + updatePlacementGroup, } from '@linode/api-v4'; import { APIError, @@ -85,7 +85,7 @@ export const useMutatePlacementGroup = (id: number) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data) => renamePlacementGroup(id, data), + mutationFn: (data) => updatePlacementGroup(id, data), onSuccess: (placementGroup) => { queryClient.invalidateQueries([queryKey, 'paginated']); queryClient.setQueryData( diff --git a/packages/validation/src/placement-groups.schema.ts b/packages/validation/src/placement-groups.schema.ts index f8df8600df7..00a41399794 100644 --- a/packages/validation/src/placement-groups.schema.ts +++ b/packages/validation/src/placement-groups.schema.ts @@ -1,4 +1,4 @@ -import { object, string } from 'yup'; +import { boolean, object, string } from 'yup'; const labelValidation = string() .required('Label is required.') @@ -9,8 +9,9 @@ export const createPlacementGroupSchema = object({ label: labelValidation, affinity_type: string().required('Affinity type is required.'), region: string().required('Region is required.'), + is_strict: boolean().required('Is strict is required.'), }); -export const renamePlacementGroupSchema = object({ +export const updatePlacementGroupSchema = object({ label: labelValidation, });