From 70342823686d902a37e78ad7bbc5313e3723a61a Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Wed, 26 Jun 2024 12:56:46 -0400 Subject: [PATCH 01/17] save progress --- packages/api-v4/src/images/images.ts | 15 +- packages/api-v4/src/images/types.ts | 9 +- .../ImageRegions/ImageRegionRow.tsx | 60 ++++++++ .../ImageRegions/ManageImageRegionsDrawer.tsx | 131 ++++++++++++++++++ .../Images/ImagesLanding/ImagesLanding.tsx | 6 +- packages/manager/src/queries/images.ts | 17 +++ 6 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx create mode 100644 packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index b012fe396fa..110f19158ed 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -18,6 +18,7 @@ import type { Image, ImageUploadPayload, UpdateImagePayload, + UpdateImageRegionsPayload, UploadImageResponse, } from './types'; @@ -99,16 +100,14 @@ export const uploadImage = (data: ImageUploadPayload) => { }; /** - * Selects the regions to which this image will be replicated. + * updateImageRegions * - * @param imageId { string } ID of the Image to look up. - * @param regions { string[] } ID of regions to replicate to. Must contain at least one valid region. + * Selects the regions to which this image will be replicated. */ -export const updateImageRegions = (imageId: string, regions: string[]) => { - const data = { - regions, - }; - +export const updateImageRegions = ( + imageId: string, + data: UpdateImageRegionsPayload +) => { return Request( setURL(`${API_ROOT}/images/${encodeURIComponent(imageId)}/regions`), setMethod('POST'), diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index e25fb28f9a2..cd3b34db673 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -8,7 +8,7 @@ export type ImageCapabilities = 'cloud-init' | 'distributed-images'; type ImageType = 'manual' | 'automatic'; -type ImageRegionStatus = +export type ImageRegionStatus = | 'creating' | 'pending' | 'available' @@ -154,3 +154,10 @@ export interface ImageUploadPayload extends BaseImagePayload { label: string; region: string; } + +export interface UpdateImageRegionsPayload { + /** + * An array of region ids + */ + regions: string[]; +} diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx new file mode 100644 index 00000000000..0a1da6d5c3e --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx @@ -0,0 +1,60 @@ +import Close from '@mui/icons-material/Close'; +import React from 'react'; + +import { Box } from 'src/components/Box'; +import { Flag } from 'src/components/Flag'; +import { IconButton } from 'src/components/IconButton'; +import { Stack } from 'src/components/Stack'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import type { ImageRegion, ImageRegionStatus } from '@linode/api-v4'; +import type { Status } from 'src/components/StatusIcon/StatusIcon'; + +interface Props extends ImageRegion { + onRemove: () => void; +} + +export const ImageRegionRow = (props: Props) => { + const { onRemove, region, status } = props; + + const { data: regions } = useRegionsQuery(); + + const actualRegion = regions?.find((r) => r.id === region); + + return ( + + + + {actualRegion?.label ?? region} + + + {status} + + + + + + + ); +}; + +const IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS: Record< + ImageRegionStatus, + Status +> = { + available: 'active', + creating: 'other', + pending: 'other', + 'pending deletion': 'other', + 'pending replication': 'other', + replicating: 'other', + timedout: 'inactive', +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx new file mode 100644 index 00000000000..c7ab65f97c4 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx @@ -0,0 +1,131 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { updateImageRegionsSchema } from '@linode/validation'; +import React, { useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useUpdateImageRegionsMutation } from 'src/queries/images'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import { ImageRegionRow } from './ImageRegionRow'; + +import type { Image, UpdateImageRegionsPayload } from '@linode/api-v4'; + +interface Props { + image: Image | undefined; + onClose: () => void; +} + +export const ManageImageRegionsDrawer = (props: Props) => { + const { image, onClose } = props; + const open = image !== undefined; + + const imageRegionIds = useMemo( + () => image?.regions.map(({ region }) => region) ?? [], + [image] + ); + + const { data: regions } = useRegionsQuery(); + const { mutateAsync } = useUpdateImageRegionsMutation(image?.id ?? ''); + + const { + formState: { errors, isSubmitting }, + handleSubmit, + reset, + setError, + setValue, + watch, + } = useForm({ + defaultValues: { regions: imageRegionIds }, + resolver: yupResolver(updateImageRegionsSchema), + }); + + useEffect(() => { + if (imageRegionIds) { + reset({ regions: imageRegionIds }); + } + }, [imageRegionIds]); + + const onSubmit = async (data: UpdateImageRegionsPayload) => { + try { + await mutateAsync(data); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + } + }; + + const values = watch(); + + return ( + + {errors.root?.message && ( + + )} + + Custom images are billed monthly, at $.10/GB. Check out this guide for + details on managing your Linux system's disk space. + +
+ setValue('regions', regionIds)} + regions={regions ?? []} + selectedIds={values.regions} + /> + + Image will be available in these regions ({values.regions.length}) + + + + {values.regions.map((regionId) => ( + + setValue( + 'regions', + values.regions.filter((r) => r !== regionId) + ) + } + status={ + image?.regions.find( + (regionItem) => regionItem.region === regionId + )?.status ?? 'pending replication' + } + key={regionId} + region={regionId} + /> + ))} + + + + +
+ ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 039a71711c7..2b1c833bb71 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -45,6 +45,7 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { getEventsForImages } from '../utils'; import { EditImageDrawer } from './EditImageDrawer'; +import { ManageImageRegionsDrawer } from './ImageRegions/ManageImageRegionsDrawer'; import { ImageRow } from './ImageRow'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; @@ -213,7 +214,6 @@ export const ImagesLanding = () => { ); const [ - // @ts-expect-error This will be unused until the regions drawer is implemented manageRegionsDrawerImage, setManageRegionsDrawerImage, ] = React.useState(); @@ -603,6 +603,10 @@ export const ImagesLanding = () => { image={rebuildDrawerImage} onClose={() => setRebuildDrawerImage(undefined)} /> + setManageRegionsDrawerImage(undefined)} + /> { }); }; +export const useUpdateImageRegionsMutation = (imageId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => updateImageRegions(imageId, data), + onSuccess(image) { + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.invalidateQueries(imageQueries.all._def); + queryClient.setQueryData( + imageQueries.image(image.id).queryKey, + image + ); + }, + }); +}; + export const imageEventsHandler = ({ event, queryClient, From 5bb66147e21732e1e2c0a76dcc02c8eac07cf91d Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Wed, 26 Jun 2024 13:24:54 -0400 Subject: [PATCH 02/17] save progress --- .../RegionSelect/RegionMultiSelect.tsx | 2 ++ .../ImageRegions/ManageImageRegionsDrawer.tsx | 25 ++++++++++++++++--- packages/manager/src/mocks/serverHandlers.ts | 16 ++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index 4ba6d5879a7..2d3126e008a 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -67,6 +67,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { selectedIds, sortRegionOptions, width, + onClose, } = props; const { @@ -171,6 +172,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { options={regionOptions} placeholder={placeholder ?? 'Select Regions'} value={selectedRegions} + onClose={onClose} /> {selectedRegions.length > 0 && SelectedRegionsList && ( diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx index c7ab65f97c4..e7cd637a660 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx @@ -1,6 +1,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { updateImageRegionsSchema } from '@linode/validation'; -import React, { useEffect, useMemo } from 'react'; +import { useSnackbar } from 'notistack'; +import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -31,9 +32,12 @@ export const ManageImageRegionsDrawer = (props: Props) => { [image] ); + const { enqueueSnackbar } = useSnackbar(); const { data: regions } = useRegionsQuery(); const { mutateAsync } = useUpdateImageRegionsMutation(image?.id ?? ''); + const [selectedRegions, setSelectedRegions] = useState([]); + const { formState: { errors, isSubmitting }, handleSubmit, @@ -55,6 +59,12 @@ export const ManageImageRegionsDrawer = (props: Props) => { const onSubmit = async (data: UpdateImageRegionsPayload) => { try { await mutateAsync(data); + + enqueueSnackbar('Image regions successfully updated.', { + variant: 'success', + }); + + onClose(); } catch (errors) { for (const error of errors) { if (error.field) { @@ -83,12 +93,19 @@ export const ManageImageRegionsDrawer = (props: Props) => {
{ + setValue('regions', [...selectedRegions, ...values.regions]); + setSelectedRegions([]); + }} + regions={(regions ?? []).filter( + (r) => !values.regions.includes(r.id) + )} currentCapability="Object Storage" errorText={errors.regions?.message} label="Add Regions" - onChange={(regionIds) => setValue('regions', regionIds)} - regions={regions ?? []} - selectedIds={values.regions} + onChange={setSelectedRegions} + placeholder="Select Regions" + selectedIds={selectedRegions} /> Image will be available in these regions ({values.regions.length}) diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 0fb9b0203b3..8baf5c6b85e 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -107,6 +107,7 @@ import type { ObjectStorageKeyRequest, SecurityQuestionsPayload, TokenRequest, + UpdateImageRegionsPayload, User, VolumeStatus, } from '@linode/api-v4'; @@ -676,6 +677,21 @@ export const handlers = [ ) ); }), + http.post( + '*/v4/images/:id/regions', + async ({ request }) => { + const data = await request.json(); + + const image = imageFactory.build(); + + image.regions = data.regions.map((regionId) => ({ + region: regionId, + status: 'pending replication', + })); + + return HttpResponse.json(image); + } + ), http.get('*/linode/types', () => { return HttpResponse.json( From 098811604ceea2fcdc257fc3a3f467ea481c91e7 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Wed, 26 Jun 2024 13:40:11 -0400 Subject: [PATCH 03/17] save progress --- .../ManageImageRegionsDrawer.test.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx new file mode 100644 index 00000000000..e3307fddf13 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +import { imageFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ManageImageRegionsDrawer } from './ManageImageRegionsDrawer'; + +describe('ManageImageRegionsDrawer', () => { + it('should not render when no image is passed via props', () => { + const { container } = renderWithTheme( + + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render a header', () => { + const image = imageFactory.build(); + const { getByText } = renderWithTheme( + + ); + + expect(getByText(`Manage Regions for ${image.label}`)).toBeVisible(); + }); + + it('should render a header', () => { + const image = imageFactory.build(); + const { getByText } = renderWithTheme( + + ); + + expect(getByText(`Manage Regions for ${image.label}`)).toBeVisible(); + }); +}); From 4145be3330015bd083fa4b605eb6f54a2e3a4c30 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Wed, 26 Jun 2024 13:56:02 -0400 Subject: [PATCH 04/17] begin adding unit testing --- .../ManageImageRegionsDrawer.test.tsx | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx index e3307fddf13..21815e2edd1 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx @@ -1,6 +1,8 @@ import React from 'react'; -import { imageFactory } from 'src/factories'; +import { imageFactory, regionFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { ManageImageRegionsDrawer } from './ManageImageRegionsDrawer'; @@ -23,12 +25,36 @@ describe('ManageImageRegionsDrawer', () => { expect(getByText(`Manage Regions for ${image.label}`)).toBeVisible(); }); - it('should render a header', () => { - const image = imageFactory.build(); - const { getByText } = renderWithTheme( + it('should render existing regions and their statuses', async () => { + const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' }); + + const image = imageFactory.build({ + regions: [ + { + region: 'us-east', + status: 'available', + }, + { + region: 'us-west', + status: 'pending replication', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region1, region2])); + }) + ); + + const { findByText } = renderWithTheme( ); - expect(getByText(`Manage Regions for ${image.label}`)).toBeVisible(); + await findByText('Newark, NJ'); + await findByText('available'); + await findByText('Place, CA'); + await findByText('pending replication'); }); }); From 17e112941d4fde592c6e3263118a65f033ecba86 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Wed, 26 Jun 2024 17:12:34 -0400 Subject: [PATCH 05/17] add more unit testing --- .../ImageRegions/ImageRegionRow.test.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.test.tsx diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.test.tsx new file mode 100644 index 00000000000..7c8d8bd17f8 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.test.tsx @@ -0,0 +1,42 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { regionFactory } from 'src/factories/regions'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ImageRegionRow } from './ImageRegionRow'; + +describe('ImageRegionRow', () => { + it('renders a region label and status', async () => { + const region = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { findByText, getByText } = renderWithTheme( + + ); + + expect(getByText('creating')).toBeVisible(); + expect(await findByText('Newark, NJ')).toBeVisible(); + }); + + it('calls onRemove when the remove button is clicked', async () => { + const onRemove = vi.fn(); + + const { getByLabelText } = renderWithTheme( + + ); + + const removeButton = getByLabelText('Remove us-east'); + + await userEvent.click(removeButton); + + expect(onRemove).toHaveBeenCalled(); + }); +}); From bfccbd45efa87b41eb6f0ea5650fd2e41c1059f3 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 27 Jun 2024 12:57:48 -0400 Subject: [PATCH 06/17] update how image is passed via props --- .../Images/ImagesLanding/EditImageDrawer.tsx | 19 +- .../ImageRegions/ImageRegionRow.tsx | 13 +- .../ManageImageRegionsDrawer.test.tsx | 12 +- .../ImageRegions/ManageImageRegionsDrawer.tsx | 35 +++- .../Images/ImagesLanding/ImagesLanding.tsx | 179 +++++++++--------- .../ImagesLanding/RebuildImageDrawer.tsx | 5 +- 6 files changed, 136 insertions(+), 127 deletions(-) diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx index 582a7738462..09c2d02e8b2 100644 --- a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx @@ -8,7 +8,6 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; -import { usePrevious } from 'src/hooks/usePrevious'; import { useUpdateImageMutation } from 'src/queries/images'; import { useImageAndLinodeGrantCheck } from '../utils'; @@ -18,18 +17,17 @@ import type { APIError, Image, UpdateImagePayload } from '@linode/api-v4'; interface Props { image: Image | undefined; onClose: () => void; + open: boolean; } export const EditImageDrawer = (props: Props) => { - const { image, onClose } = props; + const { image, onClose, open } = props; const { canCreateImage } = useImageAndLinodeGrantCheck(); - // Prevent content from disappearing when closing drawer - const prevImage = usePrevious(image); const defaultValues = { - description: image?.description ?? prevImage?.description ?? undefined, - label: image?.label ?? prevImage?.label, - tags: image?.tags ?? prevImage?.tags, + description: image?.description ?? undefined, + label: image?.label, + tags: image?.tags, }; const { @@ -78,12 +76,7 @@ export const EditImageDrawer = (props: Props) => { }); return ( - + {!canCreateImage && ( void; + region: string; + status: ExtendedImageRegionStatus; } export const ImageRegionRow = (props: Props) => { @@ -47,14 +51,15 @@ export const ImageRegionRow = (props: Props) => { }; const IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS: Record< - ImageRegionStatus, + ExtendedImageRegionStatus, Status > = { available: 'active', creating: 'other', pending: 'other', 'pending deletion': 'other', - 'pending replication': 'other', + 'pending replication': 'inactive', replicating: 'other', timedout: 'inactive', + unsaved: 'inactive', }; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx index 21815e2edd1..efda453989a 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx @@ -8,9 +8,13 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { ManageImageRegionsDrawer } from './ManageImageRegionsDrawer'; describe('ManageImageRegionsDrawer', () => { - it('should not render when no image is passed via props', () => { + it('should not render when open is false', () => { const { container } = renderWithTheme( - + ); expect(container).toBeEmptyDOMElement(); @@ -19,7 +23,7 @@ describe('ManageImageRegionsDrawer', () => { it('should render a header', () => { const image = imageFactory.build(); const { getByText } = renderWithTheme( - + ); expect(getByText(`Manage Regions for ${image.label}`)).toBeVisible(); @@ -49,7 +53,7 @@ describe('ManageImageRegionsDrawer', () => { ); const { findByText } = renderWithTheme( - + ); await findByText('Newark, NJ'); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx index e7cd637a660..e409bfd3355 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx @@ -21,11 +21,11 @@ import type { Image, UpdateImageRegionsPayload } from '@linode/api-v4'; interface Props { image: Image | undefined; onClose: () => void; + open: boolean; } export const ManageImageRegionsDrawer = (props: Props) => { - const { image, onClose } = props; - const open = image !== undefined; + const { image, onClose, open } = props; const imageRegionIds = useMemo( () => image?.regions.map(({ region }) => region) ?? [], @@ -34,14 +34,14 @@ export const ManageImageRegionsDrawer = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { data: regions } = useRegionsQuery(); - const { mutateAsync } = useUpdateImageRegionsMutation(image?.id ?? ''); + const { mutateAsync, reset } = useUpdateImageRegionsMutation(image?.id ?? ''); const [selectedRegions, setSelectedRegions] = useState([]); const { formState: { errors, isSubmitting }, handleSubmit, - reset, + reset: resetForm, setError, setValue, watch, @@ -51,10 +51,11 @@ export const ManageImageRegionsDrawer = (props: Props) => { }); useEffect(() => { - if (imageRegionIds) { - reset({ regions: imageRegionIds }); + if (open) { + resetForm({ regions: imageRegionIds }); + reset(); } - }, [imageRegionIds]); + }, [open]); const onSubmit = async (data: UpdateImageRegionsPayload) => { try { @@ -94,7 +95,9 @@ export const ManageImageRegionsDrawer = (props: Props) => { { - setValue('regions', [...selectedRegions, ...values.regions]); + setValue('regions', [...selectedRegions, ...values.regions], { + shouldValidate: true, + }); setSelectedRegions([]); }} regions={(regions ?? []).filter( @@ -110,8 +113,20 @@ export const ManageImageRegionsDrawer = (props: Props) => { Image will be available in these regions ({values.regions.length}) - + ({ + backgroundColor: theme.palette.background.paper, + p: 2, + py: 1, + })} + variant="outlined" + > + {values.regions.length === 0 && ( + + No Regions Selected + + )} {values.regions.map((regionId) => ( @@ -123,7 +138,7 @@ export const ManageImageRegionsDrawer = (props: Props) => { status={ image?.regions.find( (regionItem) => regionItem.region === regionId - )?.status ?? 'pending replication' + )?.status ?? 'unsaved' } key={regionId} region={regionId} diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 2b1c833bb71..33b497d5b8c 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -24,7 +24,6 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; @@ -51,8 +50,7 @@ import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; import type { Handlers as ImageHandlers } from './ImagesActionMenu'; -import type { Image, ImageStatus } from '@linode/api-v4'; -import type { APIError } from '@linode/api-v4/lib/types'; +import type { ImageStatus } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; const searchQueryKey = 'query'; @@ -213,12 +211,18 @@ export const ImagesLanding = () => { imageEvents ); + const [selectedImageId, setSelectedImageId] = React.useState(); + const [ - manageRegionsDrawerImage, - setManageRegionsDrawerImage, - ] = React.useState(); - const [editDrawerImage, setEditDrawerImage] = React.useState(); - const [rebuildDrawerImage, setRebuildDrawerImage] = React.useState(); + isManageRegionsDrawerOpen, + setIsManageRegionsDrawerOpen, + ] = React.useState(false); + const [isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false); + const [isRebuildDrawerOpen, setIsRebuildDrawerOpen] = React.useState(false); + + const selectedImage = + manualImages?.data.find((i) => i.id === selectedImageId) ?? + automaticImages?.data.find((i) => i.id === selectedImageId); const [dialog, setDialogState] = React.useState( defaultDialogState @@ -312,24 +316,6 @@ export const ImagesLanding = () => { }); }; - const getActions = () => { - return ( - - ); - }; - const resetSearch = () => { queryParams.delete(searchQueryKey); history.push({ search: queryParams.toString() }); @@ -345,61 +331,44 @@ export const ImagesLanding = () => { onCancelFailed: onCancelFailedClick, onDelete: openDialog, onDeploy: deployNewLinode, - onEdit: setEditDrawerImage, + onEdit: (image) => { + setSelectedImageId(image.id); + setIsEditDrawerOpen(true); + }, onManageRegions: multiRegionsEnabled - ? setManageRegionsDrawerImage + ? (image) => { + setSelectedImageId(image.id); + setIsManageRegionsDrawerOpen(true); + } : undefined, - onRestore: setRebuildDrawerImage, + onRestore: (image) => { + setSelectedImageId(image.id); + setIsRebuildDrawerOpen(true); + }, onRetry: onRetryClick, }; - const renderError = (_: APIError[]) => { + if (manualImagesLoading || automaticImagesLoading) { + return ; + } + + if (manualImagesError || automaticImagesError) { return ( ); - }; - - const renderLoading = () => { - return ; - }; - - const renderEmpty = () => { - return ; - }; - - if (manualImagesLoading || automaticImagesLoading) { - return renderLoading(); - } - - /** Error State */ - if (manualImagesError) { - return renderError(manualImagesError); } - if (automaticImagesError) { - return renderError(automaticImagesError); - } - - /** Empty States */ if ( - !manualImages.data.length && - !automaticImages.data.length && + manualImages.results === 0 && + automaticImages.results === 0 && !imageLabelFromParam ) { - return renderEmpty(); + return ; } - const noManualImages = ( - - ); - - const noAutomaticImages = ( - - ); - const isFetching = manualImagesIsFetching || automaticImagesIsFetching; return ( @@ -501,17 +470,21 @@ export const ImagesLanding = () => { - {manualImages.data.length > 0 - ? manualImages.data.map((manualImage) => ( - - )) - : noManualImages} + {manualImages.results === 0 && ( + + )} + {manualImages.data.map((manualImage) => ( + + ))} { - {isFetching ? ( - - ) : automaticImages.data.length > 0 ? ( - automaticImages.data.map((automaticImage) => ( - - )) - ) : ( - noAutomaticImages + {automaticImages.results === 0 && ( + )} + {automaticImages.data.map((automaticImage) => ( + + ))} { /> setEditDrawerImage(undefined)} + image={selectedImage} + onClose={() => setIsEditDrawerOpen(false)} + open={isEditDrawerOpen} /> setRebuildDrawerImage(undefined)} + image={selectedImage} + onClose={() => setIsRebuildDrawerOpen(false)} + open={isRebuildDrawerOpen} /> setManageRegionsDrawerImage(undefined)} + image={selectedImage} + onClose={() => setIsManageRegionsDrawerOpen(false)} + open={isManageRegionsDrawerOpen} /> + } title={ dialogAction === 'cancel' ? 'Cancel Upload' : `Delete Image ${dialog.image}` } - actions={getActions} onClose={closeDialog} open={dialog.open} > diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx index c5d49a70a8a..dc2bf134a93 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx @@ -18,10 +18,11 @@ import type { Image } from '@linode/api-v4'; interface Props { image: Image | undefined; onClose: () => void; + open: boolean; } export const RebuildImageDrawer = (props: Props) => { - const { image, onClose } = props; + const { image, onClose, open } = props; const history = useHistory(); const { @@ -54,7 +55,7 @@ export const RebuildImageDrawer = (props: Props) => { From a7087255266420236fc1a449c04051652c5bb772 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 27 Jun 2024 13:30:05 -0400 Subject: [PATCH 07/17] add test and adjust regions filter --- .../ManageImageRegionsDrawer.test.tsx | 39 +++++++++++++++++++ .../ImageRegions/ManageImageRegionsDrawer.tsx | 4 +- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx index efda453989a..b4d140d0f15 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx @@ -1,3 +1,4 @@ +import userEvent from '@testing-library/user-event'; import React from 'react'; import { imageFactory, regionFactory } from 'src/factories'; @@ -61,4 +62,42 @@ describe('ManageImageRegionsDrawer', () => { await findByText('Place, CA'); await findByText('pending replication'); }); + + it('should render a status of "unsaved" when a new region is selected', async () => { + const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' }); + + const image = imageFactory.build({ + regions: [ + { + region: 'us-east', + status: 'available', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region1, region2])); + }) + ); + + const { findByText, getByLabelText, getByText } = renderWithTheme( + + ); + + const regionSelect = getByLabelText('Add Regions'); + + // Open the Region Select + await userEvent.click(regionSelect); + + // Select new region + await userEvent.click(await findByText('us-west', { exact: false })); + + // Close the Region Multi-Select to that selections are committed to the list + await userEvent.type(regionSelect, '{escape}'); + + expect(getByText('Place, CA')).toBeVisible(); + expect(getByText('unsaved')).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx index e409bfd3355..901a1cee3bc 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx @@ -101,9 +101,9 @@ export const ManageImageRegionsDrawer = (props: Props) => { setSelectedRegions([]); }} regions={(regions ?? []).filter( - (r) => !values.regions.includes(r.id) + (r) => !values.regions.includes(r.id) && r.site_type === 'core' )} - currentCapability="Object Storage" + currentCapability={undefined} errorText={errors.regions?.message} label="Add Regions" onChange={setSelectedRegions} From 1945d9d868a4da9944b8dfd3a34a7604e8f70b98 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 27 Jun 2024 13:41:01 -0400 Subject: [PATCH 08/17] dial in --- .../ImageRegions/ManageImageRegionsDrawer.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx index 901a1cee3bc..1fa554ca007 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx @@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; +import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; @@ -39,7 +40,7 @@ export const ManageImageRegionsDrawer = (props: Props) => { const [selectedRegions, setSelectedRegions] = useState([]); const { - formState: { errors, isSubmitting }, + formState: { errors, isDirty, isSubmitting }, handleSubmit, reset: resetForm, setError, @@ -89,13 +90,17 @@ export const ManageImageRegionsDrawer = (props: Props) => { )} - Custom images are billed monthly, at $.10/GB. Check out this guide for - details on managing your Linux system's disk space. + Custom images are billed monthly, at $.10/GB. Check out{' '} + + this guide + {' '} + for details on managing your Linux system's disk space. { setValue('regions', [...selectedRegions, ...values.regions], { + shouldDirty: true, shouldValidate: true, }); setSelectedRegions([]); @@ -132,7 +137,8 @@ export const ManageImageRegionsDrawer = (props: Props) => { onRemove={() => setValue( 'regions', - values.regions.filter((r) => r !== regionId) + values.regions.filter((r) => r !== regionId), + { shouldDirty: true, shouldValidate: true } ) } status={ @@ -148,6 +154,7 @@ export const ManageImageRegionsDrawer = (props: Props) => { Date: Thu, 27 Jun 2024 14:29:44 -0400 Subject: [PATCH 09/17] more testing --- .../ManageImageRegionsDrawer.test.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx index b4d140d0f15..914a7da6a06 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx @@ -30,6 +30,22 @@ describe('ManageImageRegionsDrawer', () => { expect(getByText(`Manage Regions for ${image.label}`)).toBeVisible(); }); + it('should render a save button and a cancel button', () => { + const image = imageFactory.build(); + const { getByText } = renderWithTheme( + + ); + + const cancelButton = getByText('Cancel').closest('button'); + const saveButton = getByText('Save').closest('button'); + + expect(cancelButton).toBeVisible(); + expect(cancelButton).toBeEnabled(); + + expect(saveButton).toBeVisible(); + expect(saveButton).toBeDisabled(); // The save button should become enabled when regions are changed + }); + it('should render existing regions and their statuses', async () => { const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' }); @@ -86,6 +102,13 @@ describe('ManageImageRegionsDrawer', () => { ); + const saveButton = getByText('Save').closest('button'); + + expect(saveButton).toBeVisible(); + + // Verify the save button is disabled because no changes have been made + expect(saveButton).toBeDisabled(); + const regionSelect = getByLabelText('Add Regions'); // Open the Region Select @@ -99,5 +122,8 @@ describe('ManageImageRegionsDrawer', () => { expect(getByText('Place, CA')).toBeVisible(); expect(getByText('unsaved')).toBeVisible(); + + // Verify the save button is enabled because changes have been made + expect(saveButton).toBeEnabled(); }); }); From 55e8b663c674d57d402b7450a6b3157686051acc Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 27 Jun 2024 17:38:27 -0400 Subject: [PATCH 10/17] add cypress test --- .../core/images/manage-image-regions.spec.ts | 213 ++++++++++++++++++ .../cypress/support/intercepts/images.ts | 24 +- .../ImageRegions/ManageImageRegionsDrawer.tsx | 12 +- 3 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts new file mode 100644 index 00000000000..73b0d6635a5 --- /dev/null +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -0,0 +1,213 @@ +import { imageFactory, regionFactory } from 'src/factories'; +import { + mockGetCustomImages, + mockGetRecoveryImages, + mockUpdateImageRegions, +} from 'support/intercepts/images'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import type { Image } from '@linode/api-v4'; + +describe('Manage Image Regions', () => { + /** + * Adds two new regions to an Image (region3 and region4) + * and removes one existing region (region 1). + */ + it("updates an Image's regions", () => { + const region1 = regionFactory.build({ site_type: 'core' }); + const region2 = regionFactory.build({ site_type: 'core' }); + const region3 = regionFactory.build({ site_type: 'core' }); + const region4 = regionFactory.build({ site_type: 'core' }); + + const image = imageFactory.build({ + size: 50, + total_size: 100, + capabilities: ['distributed-images'], + regions: [ + { region: region1.id, status: 'available' }, + { region: region2.id, status: 'available' }, + ], + }); + + mockGetRegions([region1, region2, region3, region4]).as('getRegions'); + mockGetCustomImages([image]).as('getImages'); + mockGetRecoveryImages([]); + + cy.visitWithLogin('/images'); + cy.wait(['@getImages', '@getRegions']); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Verify total size is rendered + cy.findByText(`${image.total_size} MB`).should('be.visible'); + + // Verify capabilities are rendered + cy.findByText('Distributed').should('be.visible'); + + // Verify the first region is rendered + cy.findByText(region1.label + ',').should('be.visible'); + + // Click the "+1" + cy.findByText('+1').should('be.visible').should('be.enabled').click(); + }); + + // Verify the Manage Regions drawer opens and contains basic content + ui.drawer + .findByTitle(`Manage Regions for ${image.label}`) + .should('be.visible') + .within(() => { + // Verify the Image regions render + cy.findByText(region1.label).should('be.visible'); + cy.findByText(region2.label).should('be.visible'); + + cy.findByText('Image will be available in these regions (2)').should( + 'be.visible' + ); + + // Verify the "Save" button is disabled because no changes have been made + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.disabled'); + + // Close the Manage Regions drawer + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Open the Image's action menu + ui.actionMenu + .findByTitle(`Action menu for Image ${image.label}`) + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Click "Manage Regions" option in the action menu + ui.actionMenuItem + .findByTitle('Manage Regions') + .should('be.visible') + .should('be.enabled') + .click(); + + // Open the Regions Multi-Select + cy.findByLabelText('Add Regions') + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify "Select All" shows up as an option + ui.autocompletePopper + .findByTitle('Select All') + .should('be.visible') + .should('be.enabled'); + + // Verify region3 shows up as an option and select it + ui.autocompletePopper + .findByTitle(`${region3.label} (${region3.id})`) + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify region4 shows up as an option and select it + ui.autocompletePopper + .findByTitle(`${region4.label} (${region4.id})`) + .should('be.visible') + .should('be.enabled') + .click(); + + const updatedImage: Image = { + ...image, + total_size: 150, + regions: [ + { region: region2.id, status: 'available' }, + { region: region3.id, status: 'pending replication' }, + { region: region4.id, status: 'pending replication' }, + ], + }; + + // mock the POST /v4/images/:id:regions response + mockUpdateImageRegions(image.id, updatedImage); + + // mock the updated paginated response + mockGetCustomImages([updatedImage]); + + // Click outside of the Region Multi-Select to commit the selection to the list + ui.drawer + .findByTitle(`Manage Regions for ${image.label}`) + .click() + .within(() => { + // Verify the existing image regions render + cy.findByText(region1.label).should('be.visible'); + cy.findByText(region2.label).should('be.visible'); + + // Verify the newly selected image regions render + cy.findByText(region3.label).should('be.visible'); + cy.findByText(region4.label).should('be.visible'); + cy.findAllByText('unsaved').should('be.visible'); + + // Verify the count in now 3 + cy.findByText('Image will be available in these regions (4)').should( + 'be.visible' + ); + + // Verify the "Save" button is enabled because a new region is selected + ui.button.findByTitle('Save').should('be.visible').should('be.enabled'); + + // Remove region1 + cy.findByLabelText(`Remove ${region1.id}`) + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify the image isn't shown in the list after being removed + cy.findByText(region1.label).should('not.exist'); + + // Verify the count is now 2 + cy.findByText('Image will be available in these regions (3)').should( + 'be.visible' + ); + + // Save changes + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + + // "Unsaved" regions should transition to "pending replication" because + // they are now returned by the API + cy.findAllByText('pending replication').should('be.visible'); + + // The save button should become disabled now that changes have been saved + ui.button.findByTitle('Save').should('be.disabled'); + + // The save button should become disabled now that changes have been saved + ui.button.findByTitle('Save').should('be.disabled'); + + cy.findByLabelText('Close drawer').click(); + }); + + ui.toast.assertMessage('Image regions successfully updated.'); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Verify the new size is shown + cy.findByText('150 MB'); + + // Verify the first region is rendered + cy.findByText(region2.label + ',').should('be.visible'); + + // Verify the regions count is now "+2" + cy.findByText('+2').should('be.visible').should('be.enabled'); + }); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/images.ts b/packages/manager/cypress/support/intercepts/images.ts index a9a38804c06..6e78c4178a1 100644 --- a/packages/manager/cypress/support/intercepts/images.ts +++ b/packages/manager/cypress/support/intercepts/images.ts @@ -54,9 +54,7 @@ export const mockGetCustomImages = ( const filters = getFilters(req); if (filters?.type === 'manual') { req.reply(paginateResponse(images)); - return; } - req.continue(); }); }; @@ -74,9 +72,7 @@ export const mockGetRecoveryImages = ( const filters = getFilters(req); if (filters?.type === 'automatic') { req.reply(paginateResponse(images)); - return; } - req.continue(); }); }; @@ -130,3 +126,23 @@ export const mockDeleteImage = (id: string): Cypress.Chainable => { const encodedId = encodeURIComponent(id); return cy.intercept('DELETE', apiMatcher(`images/${encodedId}`), {}); }; + +/** + * Intercepts PUT request to update an image and mocks the response. + * + * @param id - ID of image being updated. + * @param updatedImage - Updated image with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateImageRegions = ( + id: string, + updatedImage: Image +): Cypress.Chainable => { + const encodedId = encodeURIComponent(id); + return cy.intercept( + 'POST', + apiMatcher(`images/${encodedId}/regions`), + updatedImage + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx index 1fa554ca007..f58940fb4b0 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx @@ -52,11 +52,9 @@ export const ManageImageRegionsDrawer = (props: Props) => { }); useEffect(() => { - if (open) { - resetForm({ regions: imageRegionIds }); - reset(); - } - }, [open]); + resetForm({ regions: imageRegionIds }); + reset(); + }, [imageRegionIds, open]); const onSubmit = async (data: UpdateImageRegionsPayload) => { try { @@ -65,8 +63,6 @@ export const ManageImageRegionsDrawer = (props: Props) => { enqueueSnackbar('Image regions successfully updated.', { variant: 'success', }); - - onClose(); } catch (errors) { for (const error of errors) { if (error.field) { @@ -99,7 +95,7 @@ export const ManageImageRegionsDrawer = (props: Props) => { { - setValue('regions', [...selectedRegions, ...values.regions], { + setValue('regions', [...values.regions, ...selectedRegions], { shouldDirty: true, shouldValidate: true, }); From d6589c4ef5246c811d6490ac6d2632f9b5ada407 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 27 Jun 2024 17:44:12 -0400 Subject: [PATCH 11/17] fix spelling --- .../cypress/e2e/core/images/manage-image-regions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts index 73b0d6635a5..663125cd190 100644 --- a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -153,7 +153,7 @@ describe('Manage Image Regions', () => { cy.findByText(region4.label).should('be.visible'); cy.findAllByText('unsaved').should('be.visible'); - // Verify the count in now 3 + // Verify the count is now 3 cy.findByText('Image will be available in these regions (4)').should( 'be.visible' ); From df01586a02739e68dda8f8a0f38ec8be419f3f13 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 27 Jun 2024 17:49:02 -0400 Subject: [PATCH 12/17] Added changeset: Add Manage Image Regions Drawer --- .../.changeset/pr-10617-upcoming-features-1719524941884.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md diff --git a/packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md b/packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md new file mode 100644 index 00000000000..5047c2d920a --- /dev/null +++ b/packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Manage Image Regions Drawer ([#10617](https://github.com/linode/manager/pull/10617)) From 25d7be099f6733c171701596e73693a12a2fd7dd Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 28 Jun 2024 11:54:10 -0400 Subject: [PATCH 13/17] update cypress jsdoc --- packages/manager/cypress/support/intercepts/images.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/manager/cypress/support/intercepts/images.ts b/packages/manager/cypress/support/intercepts/images.ts index 6e78c4178a1..9e4a0f7a2bb 100644 --- a/packages/manager/cypress/support/intercepts/images.ts +++ b/packages/manager/cypress/support/intercepts/images.ts @@ -128,9 +128,9 @@ export const mockDeleteImage = (id: string): Cypress.Chainable => { }; /** - * Intercepts PUT request to update an image and mocks the response. + * Intercepts POST request to update an image's regions and mocks the response. * - * @param id - ID of image being updated. + * @param id - ID of image * @param updatedImage - Updated image with which to mock response. * * @returns Cypress chainable. From 79a10d3cec9604cc59ff3044ea72bb53ccb58889 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 28 Jun 2024 11:56:02 -0400 Subject: [PATCH 14/17] Added changeset: Update `updateImageRegions` to accept `UpdateImageRegionsPayload` instead of `regions: string[]` --- packages/api-v4/.changeset/pr-10617-changed-1719590161430.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/api-v4/.changeset/pr-10617-changed-1719590161430.md diff --git a/packages/api-v4/.changeset/pr-10617-changed-1719590161430.md b/packages/api-v4/.changeset/pr-10617-changed-1719590161430.md new file mode 100644 index 00000000000..1f7c25a4e35 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10617-changed-1719590161430.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Update `updateImageRegions` to accept `UpdateImageRegionsPayload` instead of `regions: string[]` ([#10617](https://github.com/linode/manager/pull/10617)) From 9d8c0d6ba452ce21670d7a55ac1d1fdd178f7c19 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 28 Jun 2024 12:03:14 -0400 Subject: [PATCH 15/17] add `Readonly` utility type to `IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS` --- .../Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx index 8ff5df767ca..a3a1ccd292b 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx @@ -50,9 +50,8 @@ export const ImageRegionRow = (props: Props) => { ); }; -const IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS: Record< - ExtendedImageRegionStatus, - Status +const IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS: Readonly< + Record > = { available: 'active', creating: 'other', From 9ea4837e1736a696c3dbb73b86af13f918e7d1e0 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Fri, 28 Jun 2024 15:56:29 -0400 Subject: [PATCH 16/17] rearchitecture so that `useEffect` is not needed --- .../ImageRegions/ManageImageRegionsDrawer.tsx | 166 ------------------ ...st.tsx => ManageImageRegionsForm.test.tsx} | 22 +-- .../ImageRegions/ManageImageRegionsForm.tsx | 150 ++++++++++++++++ .../Images/ImagesLanding/ImagesLanding.tsx | 14 +- 4 files changed, 165 insertions(+), 187 deletions(-) delete mode 100644 packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx rename packages/manager/src/features/Images/ImagesLanding/ImageRegions/{ManageImageRegionsDrawer.test.tsx => ManageImageRegionsForm.test.tsx} (84%) create mode 100644 packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx deleted file mode 100644 index f58940fb4b0..00000000000 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { updateImageRegionsSchema } from '@linode/validation'; -import { useSnackbar } from 'notistack'; -import React, { useEffect, useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; -import { Link } from 'src/components/Link'; -import { Notice } from 'src/components/Notice/Notice'; -import { Paper } from 'src/components/Paper'; -import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; -import { Stack } from 'src/components/Stack'; -import { Typography } from 'src/components/Typography'; -import { useUpdateImageRegionsMutation } from 'src/queries/images'; -import { useRegionsQuery } from 'src/queries/regions/regions'; - -import { ImageRegionRow } from './ImageRegionRow'; - -import type { Image, UpdateImageRegionsPayload } from '@linode/api-v4'; - -interface Props { - image: Image | undefined; - onClose: () => void; - open: boolean; -} - -export const ManageImageRegionsDrawer = (props: Props) => { - const { image, onClose, open } = props; - - const imageRegionIds = useMemo( - () => image?.regions.map(({ region }) => region) ?? [], - [image] - ); - - const { enqueueSnackbar } = useSnackbar(); - const { data: regions } = useRegionsQuery(); - const { mutateAsync, reset } = useUpdateImageRegionsMutation(image?.id ?? ''); - - const [selectedRegions, setSelectedRegions] = useState([]); - - const { - formState: { errors, isDirty, isSubmitting }, - handleSubmit, - reset: resetForm, - setError, - setValue, - watch, - } = useForm({ - defaultValues: { regions: imageRegionIds }, - resolver: yupResolver(updateImageRegionsSchema), - }); - - useEffect(() => { - resetForm({ regions: imageRegionIds }); - reset(); - }, [imageRegionIds, open]); - - const onSubmit = async (data: UpdateImageRegionsPayload) => { - try { - await mutateAsync(data); - - enqueueSnackbar('Image regions successfully updated.', { - variant: 'success', - }); - } catch (errors) { - for (const error of errors) { - if (error.field) { - setError(error.field, { message: error.reason }); - } else { - setError('root', { message: error.reason }); - } - } - } - }; - - const values = watch(); - - return ( - - {errors.root?.message && ( - - )} - - Custom images are billed monthly, at $.10/GB. Check out{' '} - - this guide - {' '} - for details on managing your Linux system's disk space. - - - { - setValue('regions', [...values.regions, ...selectedRegions], { - shouldDirty: true, - shouldValidate: true, - }); - setSelectedRegions([]); - }} - regions={(regions ?? []).filter( - (r) => !values.regions.includes(r.id) && r.site_type === 'core' - )} - currentCapability={undefined} - errorText={errors.regions?.message} - label="Add Regions" - onChange={setSelectedRegions} - placeholder="Select Regions" - selectedIds={selectedRegions} - /> - - Image will be available in these regions ({values.regions.length}) - - ({ - backgroundColor: theme.palette.background.paper, - p: 2, - py: 1, - })} - variant="outlined" - > - - {values.regions.length === 0 && ( - - No Regions Selected - - )} - {values.regions.map((regionId) => ( - - setValue( - 'regions', - values.regions.filter((r) => r !== regionId), - { shouldDirty: true, shouldValidate: true } - ) - } - status={ - image?.regions.find( - (regionItem) => regionItem.region === regionId - )?.status ?? 'unsaved' - } - key={regionId} - region={regionId} - /> - ))} - - - - - - ); -}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx similarity index 84% rename from packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx index 914a7da6a06..5c1fa9ec70c 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsDrawer.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx @@ -6,25 +6,13 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { ManageImageRegionsDrawer } from './ManageImageRegionsDrawer'; +import { ManageImageRegionsForm } from './ManageImageRegionsForm'; describe('ManageImageRegionsDrawer', () => { - it('should not render when open is false', () => { - const { container } = renderWithTheme( - - ); - - expect(container).toBeEmptyDOMElement(); - }); - it('should render a header', () => { const image = imageFactory.build(); const { getByText } = renderWithTheme( - + ); expect(getByText(`Manage Regions for ${image.label}`)).toBeVisible(); @@ -33,7 +21,7 @@ describe('ManageImageRegionsDrawer', () => { it('should render a save button and a cancel button', () => { const image = imageFactory.build(); const { getByText } = renderWithTheme( - + ); const cancelButton = getByText('Cancel').closest('button'); @@ -70,7 +58,7 @@ describe('ManageImageRegionsDrawer', () => { ); const { findByText } = renderWithTheme( - + ); await findByText('Newark, NJ'); @@ -99,7 +87,7 @@ describe('ManageImageRegionsDrawer', () => { ); const { findByText, getByLabelText, getByText } = renderWithTheme( - + ); const saveButton = getByText('Save').closest('button'); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx new file mode 100644 index 00000000000..f50c82a36aa --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx @@ -0,0 +1,150 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { updateImageRegionsSchema } from '@linode/validation'; +import { useSnackbar } from 'notistack'; +import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useUpdateImageRegionsMutation } from 'src/queries/images'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import { ImageRegionRow } from './ImageRegionRow'; + +import type { Image, UpdateImageRegionsPayload } from '@linode/api-v4'; + +interface Props { + image: Image | undefined; + onClose: () => void; +} + +export const ManageImageRegionsForm = (props: Props) => { + const { image, onClose } = props; + + const imageRegionIds = image?.regions.map(({ region }) => region) ?? []; + + const { enqueueSnackbar } = useSnackbar(); + const { data: regions } = useRegionsQuery(); + const { mutateAsync } = useUpdateImageRegionsMutation(image?.id ?? ''); + + const [selectedRegions, setSelectedRegions] = useState([]); + + const { + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + setError, + setValue, + watch, + } = useForm({ + defaultValues: { regions: imageRegionIds }, + resolver: yupResolver(updateImageRegionsSchema), + values: { regions: imageRegionIds }, + }); + + const onSubmit = async (data: UpdateImageRegionsPayload) => { + try { + await mutateAsync(data); + + enqueueSnackbar('Image regions successfully updated.', { + variant: 'success', + }); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + } + }; + + const values = watch(); + + return ( +
+ {errors.root?.message && ( + + )} + + Custom images are billed monthly, at $.10/GB. Check out{' '} + + this guide + {' '} + for details on managing your Linux system's disk space. + + { + setValue('regions', [...values.regions, ...selectedRegions], { + shouldDirty: true, + shouldValidate: true, + }); + setSelectedRegions([]); + }} + regions={(regions ?? []).filter( + (r) => !values.regions.includes(r.id) && r.site_type === 'core' + )} + currentCapability={undefined} + errorText={errors.regions?.message} + label="Add Regions" + onChange={setSelectedRegions} + placeholder="Select Regions" + selectedIds={selectedRegions} + /> + + Image will be available in these regions ({values.regions.length}) + + ({ + backgroundColor: theme.palette.background.paper, + p: 2, + py: 1, + })} + variant="outlined" + > + + {values.regions.length === 0 && ( + + No Regions Selected + + )} + {values.regions.map((regionId) => ( + + setValue( + 'regions', + values.regions.filter((r) => r !== regionId), + { shouldDirty: true, shouldValidate: true } + ) + } + status={ + image?.regions.find( + (regionItem) => regionItem.region === regionId + )?.status ?? 'unsaved' + } + key={regionId} + region={regionId} + /> + ))} + + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 33b497d5b8c..28d72c8abb5 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -10,6 +10,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { CircleProgress } from 'src/components/CircleProgress'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { Drawer } from 'src/components/Drawer'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; import { IconButton } from 'src/components/IconButton'; @@ -44,7 +45,7 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { getEventsForImages } from '../utils'; import { EditImageDrawer } from './EditImageDrawer'; -import { ManageImageRegionsDrawer } from './ImageRegions/ManageImageRegionsDrawer'; +import { ManageImageRegionsForm } from './ImageRegions/ManageImageRegionsForm'; import { ImageRow } from './ImageRow'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; @@ -578,11 +579,16 @@ export const ImagesLanding = () => { onClose={() => setIsRebuildDrawerOpen(false)} open={isRebuildDrawerOpen} /> - setIsManageRegionsDrawerOpen(false)} open={isManageRegionsDrawerOpen} - /> + title={`Manage Regions for ${selectedImage?.label}`} + > + setIsManageRegionsDrawerOpen(false)} + /> +
Date: Fri, 28 Jun 2024 16:04:05 -0400 Subject: [PATCH 17/17] update unit tests --- .../ImageRegions/ManageImageRegionsForm.test.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx index 5c1fa9ec70c..c3623e4d789 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx @@ -9,15 +9,6 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { ManageImageRegionsForm } from './ManageImageRegionsForm'; describe('ManageImageRegionsDrawer', () => { - it('should render a header', () => { - const image = imageFactory.build(); - const { getByText } = renderWithTheme( - - ); - - expect(getByText(`Manage Regions for ${image.label}`)).toBeVisible(); - }); - it('should render a save button and a cancel button', () => { const image = imageFactory.build(); const { getByText } = renderWithTheme(