Skip to content

Commit

Permalink
upcoming: [M3-7614] - Placement Groups Create/Rename Drawers (#10106)
Browse files Browse the repository at this point in the history
* Initial commit: setting up the pieces

* Form conditionals

* Finalize create drawer

* Save work

* Cleanup after rebase

* Cleanup after rebase

* Save progress

* Cleanup and invalidation

* cleanup issue with deps

* Tests

* moar covering

* cleanup

* cleanup 2

* Add drawer to empty state

* Added changeset: Placement Groups Create/Rename Drawers

* Replace enum

* Feedback

* implement hook for reusability

* Feedback

* Moar Feedback

* Moooaaaar Feedback
  • Loading branch information
abailly-akamai authored Jan 30, 2024
1 parent 9781197 commit 3937bc7
Show file tree
Hide file tree
Showing 23 changed files with 724 additions and 73 deletions.
14 changes: 7 additions & 7 deletions packages/api-v4/src/placement-groups/placement-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
assignVMsToPlacementGroupSchema,
createPlacementGroupSchema,
unassignVMsFromPlacementGroupSchema,
updatePlacementGroupSchema,
renamePlacementGroupSchema,
} from '@linode/validation';
import { API_ROOT } from '../constants';

Expand All @@ -19,7 +19,7 @@ import type {
CreatePlacementGroupPayload,
PlacementGroup,
UnassignVMsFromPlacementGroupPayload,
UpdatePlacementGroupPayload,
RenamePlacementGroupPayload,
} from './types';

/**
Expand Down Expand Up @@ -65,23 +65,23 @@ export const createPlacementGroup = (data: CreatePlacementGroupPayload) =>
);

/**
* updatePlacementGroup
* renamePlacementGroup
*
* Update a Placement Group.
* Renames 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 updatePlacementGroup = (
export const renamePlacementGroup = (
placementGroupId: number,
data: UpdatePlacementGroupPayload
data: RenamePlacementGroupPayload
) =>
Request<PlacementGroup>(
setURL(
`${API_ROOT}/placement/groups/${encodeURIComponent(placementGroupId)}`
),
setMethod('PUT'),
setData(data, updatePlacementGroupSchema)
setData(data, renamePlacementGroupSchema)
);

/**
Expand Down
11 changes: 8 additions & 3 deletions packages/api-v4/src/placement-groups/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { Region } from '../regions/types';

export type AffinityType = 'affinity' | 'anti_affinity';
export const AFFINITY_TYPES = {
affinity: 'Affinity',
anti_affinity: 'Anti-affinity',
} as const;

export type AffinityType = keyof typeof AFFINITY_TYPES;

export interface PlacementGroup {
id: number;
Expand All @@ -9,15 +14,15 @@ export interface PlacementGroup {
affinity_type: AffinityType;
compliant: boolean;
linode_ids: number[];
limits: number;
capacity: number;
}

export type CreatePlacementGroupPayload = Pick<
PlacementGroup,
'label' | 'affinity_type' | 'region'
>;

export type UpdatePlacementGroupPayload = Pick<PlacementGroup, 'label'>;
export type RenamePlacementGroupPayload = Pick<PlacementGroup, 'label'>;

/**
* Since the API expects an array of ONE linode id, we'll use a tuple here.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Placement Groups Create/Rename Drawers ([#10106](https://github.com/linode/manager/pull/10106))
11 changes: 7 additions & 4 deletions packages/manager/src/components/RegionSelect/RegionSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => {
RegionSelectOption | null | undefined
>(regionFromSelectedId);

const handleRegionChange = (selection: RegionSelectOption) => {
const handleRegionChange = (selection: RegionSelectOption | null) => {
setSelectedRegion(selection);
handleSelection(selection?.value);
handleSelection(selection?.value || '');
};

React.useEffect(() => {
Expand Down Expand Up @@ -94,8 +94,11 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => {
onChange={(_, selectedOption: RegionSelectOption) => {
handleRegionChange(selectedOption);
}}
onKeyDown={() => {
setSelectedRegion(null);
onKeyDown={(e) => {
if (e.key !== 'Tab') {
setSelectedRegion(null);
handleRegionChange(null);
}
}}
renderOption={(props, option, { selected }) => {
return (
Expand Down
2 changes: 1 addition & 1 deletion packages/manager/src/factories/placementGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import type {

export const placementGroupFactory = Factory.Sync.makeFactory<PlacementGroup>({
affinity_type: Factory.each(() => pickRandom(['affinity', 'anti_affinity'])),
capacity: 10,
compliant: Factory.each(() => pickRandom([true, false])),
id: Factory.each((id) => id),
label: Factory.each((id) => `pg-${id}`),
limits: 10,
linode_ids: Factory.each(() => [
pickRandom([1, 2, 3]),
pickRandom([4, 5, 6]),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { fireEvent } from '@testing-library/react';
import * as React from 'react';

import { renderWithTheme } from 'src/utilities/testHelpers';

import { PlacementGroupsCreateDrawer } from './PlacementGroupsCreateDrawer';

const commonProps = {
onClose: vi.fn(),
onPlacementGroupCreated: vi.fn(),
open: true,
};

describe('PlacementGroupsCreateDrawer', () => {
it('should render and have its fields enabled', () => {
const { getByLabelText } = renderWithTheme(
<PlacementGroupsCreateDrawer
numberOfPlacementGroupsCreated={0}
{...commonProps}
/>
);

expect(getByLabelText('Label')).toBeEnabled();
expect(getByLabelText('Region')).toBeEnabled();
expect(getByLabelText('Affinity Type')).toBeEnabled();
});

it('Affinity Type select should have the correct options', async () => {
const { getByPlaceholderText, getByText } = renderWithTheme(
<PlacementGroupsCreateDrawer
numberOfPlacementGroupsCreated={0}
{...commonProps}
/>
);

const inputElement = getByPlaceholderText('Select an Affinity Type');
fireEvent.focus(inputElement);

fireEvent.change(inputElement, { target: { value: 'Affinity' } });
expect(getByText('Affinity')).toBeInTheDocument();

fireEvent.change(inputElement, { target: { value: 'Anti-affinity' } });
expect(getByText('Anti-affinity')).toBeInTheDocument();
});

it('should disable the submit button when the number of placement groups created is >= to the max', () => {
const { getByTestId } = renderWithTheme(
<PlacementGroupsCreateDrawer
numberOfPlacementGroupsCreated={5}
{...commonProps}
/>
);

expect(getByTestId('submit')).toHaveAttribute('aria-disabled', 'true');
});

it('should populate the region select with the selected region prop', () => {
const { getByLabelText } = renderWithTheme(
<PlacementGroupsCreateDrawer
numberOfPlacementGroupsCreated={0}
selectedRegionId="us-east"
{...commonProps}
/>
);

expect(getByLabelText('Region')).toHaveValue('Newark, NJ (us-east)');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { createPlacementGroupSchema } from '@linode/validation';
import { useFormik } from 'formik';
import { useSnackbar } from 'notistack';
import * as React from 'react';
import { useQueryClient } from 'react-query';

import { Drawer } from 'src/components/Drawer';
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 { PlacementGroupsDrawerContent } from './PlacementGroupsDrawerContent';
import { MAX_NUMBER_OF_PLACEMENT_GROUPS } from './constants';

import type {
PlacementGroupDrawerFormikProps,
PlacementGroupsCreateDrawerProps,
} from './types';

export const PlacementGroupsCreateDrawer = (
props: PlacementGroupsCreateDrawerProps
) => {
const {
numberOfPlacementGroupsCreated,
onClose,
onPlacementGroupCreated,
open,
selectedRegionId,
} = props;
const queryClient = useQueryClient();
const { data: regions } = useRegionsQuery();
const { mutateAsync } = useCreatePlacementGroup();
const { enqueueSnackbar } = useSnackbar();
const {
hasFormBeenSubmitted,
setHasFormBeenSubmitted,
} = useFormValidateOnChange();

const {
errors,
handleBlur,
handleChange,
handleSubmit,
isSubmitting,
resetForm,
setFieldValue,
status,
values,
...rest
} = useFormik({
enableReinitialize: true,
initialValues: {
affinity_type: '' as PlacementGroupDrawerFormikProps['affinity_type'],
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.'
);
});
},
validateOnBlur: false,
validateOnChange: hasFormBeenSubmitted,
validationSchema: createPlacementGroupSchema,
});

return (
<Drawer onClose={onClose} open={open} title="Create Placement Group">
<PlacementGroupsDrawerContent
formik={{
errors,
handleBlur,
handleChange,
handleSubmit,
isSubmitting,
resetForm,
setFieldValue,
status,
values,
...rest,
}}
maxNumberOfPlacementGroups={MAX_NUMBER_OF_PLACEMENT_GROUPS}
mode="create"
numberOfPlacementGroupsCreated={numberOfPlacementGroupsCreated}
onClose={onClose}
open={open}
regions={regions ?? []}
selectedRegionId={selectedRegionId}
setHasFormBeenSubmitted={setHasFormBeenSubmitted}
/>
</Drawer>
);
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AFFINITY_TYPES } from '@linode/api-v4';
import * as React from 'react';
import { useHistory, useParams } from 'react-router-dom';

Expand All @@ -17,7 +18,7 @@ import {
} from 'src/queries/placementGroups';
import { getErrorStringOrDefault } from 'src/utilities/errorUtils';

import { getAffinityLabel, getPlacementGroupLinodeCount } from '../utils';
import { getPlacementGroupLinodeCount } from '../utils';

export const PlacementGroupsDetail = () => {
const flags = useFlags();
Expand All @@ -36,6 +37,10 @@ export const PlacementGroupsDetail = () => {
} = useMutatePlacementGroup(placementGroupId);
const errorText = getErrorStringOrDefault(updatePlacementGroupError ?? '');

if (isLoading) {
return <CircleProgress />;
}

if (!placementGroup) {
return <NotFound />;
}
Expand All @@ -46,10 +51,6 @@ export const PlacementGroupsDetail = () => {
);
}

if (isLoading) {
return <CircleProgress />;
}

const linodeCount = getPlacementGroupLinodeCount(placementGroup);
const tabs = [
{
Expand All @@ -62,11 +63,10 @@ export const PlacementGroupsDetail = () => {
},
];
const { affinity_type, label } = placementGroup;
const affinityLabel = getAffinityLabel(affinity_type);
const tabIndex = tab ? tabs.findIndex((t) => t.routeName.endsWith(tab)) : -1;

const resetEditableLabel = () => {
return `${label} (${affinityLabel})`;
return `${label} (${AFFINITY_TYPES[affinity_type]})`;
};

const handleLabelEdit = (newLabel: string) => {
Expand All @@ -90,7 +90,7 @@ export const PlacementGroupsDetail = () => {
],
onEditHandlers: {
editableTextTitle: label,
editableTextTitleSuffix: ` (${affinityLabel})`,
editableTextTitleSuffix: ` (${AFFINITY_TYPES[affinity_type]})`,
errorText,
onCancel: resetEditableLabel,
onEdit: handleLabelEdit,
Expand Down
Loading

0 comments on commit 3937bc7

Please sign in to comment.