diff --git a/packages/manager/.changeset/pr-10302-tech-stories-1710961356698.md b/packages/manager/.changeset/pr-10302-tech-stories-1710961356698.md new file mode 100644 index 00000000000..6b97f5b1d59 --- /dev/null +++ b/packages/manager/.changeset/pr-10302-tech-stories-1710961356698.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Use query key factory for image queries ([#10302](https://github.com/linode/manager/pull/10302)) diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index bb93e408ac2..bd921824f7d 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -8,8 +8,8 @@ import { authenticate } from 'support/api/authentication'; import { fbtVisible, getClick } from 'support/helpers'; import { mockDeleteImage, + mockGetAllImages, mockGetCustomImages, - mockGetImage, mockUpdateImage, } from 'support/intercepts/images'; import { ui } from 'support/ui'; @@ -249,10 +249,12 @@ describe('machine image', () => { cy.wait('@imageUpload').then((xhr) => { const imageId = xhr.response?.body.image.id; assertProcessing(label, imageId); - mockGetImage(label, imageId, 'available').as('getImage'); + mockGetAllImages([ + imageFactory.build({ label, id: imageId, status: 'available' }), + ]).as('getImages'); eventIntercept(label, imageId, status); ui.toast.assertMessage(uploadMessage); - cy.wait('@getImage'); + cy.wait('@getImages'); ui.toast.assertMessage(availableMessage); cy.get(`[data-qa-image-cell="${imageId}"]`).within(() => { fbtVisible(label); diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index 7d120b9480e..a6babc2ea01 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx @@ -26,7 +26,7 @@ import { import { uploadImageFile } from 'src/features/Images/requests'; import { Dispatch } from 'src/hooks/types'; import { useCurrentToken } from 'src/hooks/useAuthentication'; -import { queryKey, useUploadImageQuery } from 'src/queries/images'; +import { imageQueries, useUploadImageMutation } from 'src/queries/images'; import { redirectToLogin } from 'src/session'; import { setPendingUpload } from 'src/store/pendingUpload'; import { sendImageUploadEvent } from 'src/utilities/analytics'; @@ -86,7 +86,7 @@ export const ImageUploader = React.memo((props: ImageUploaderProps) => { const { enqueueSnackbar } = useSnackbar(); const [uploadToURL, setUploadToURL] = React.useState(''); const queryClient = useQueryClient(); - const { mutateAsync: uploadImage } = useUploadImageQuery({ + const { mutateAsync: uploadImage } = useUploadImageMutation({ cloud_init: isCloudInit ? isCloudInit : undefined, description: description ? description : undefined, label, @@ -234,7 +234,8 @@ export const ImageUploader = React.memo((props: ImageUploaderProps) => { redirectToLogin('/images'); }, 3000); } else { - queryClient.invalidateQueries([`${queryKey}-list`]); + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.invalidateQueries(imageQueries.all._def); history.push('/images'); } }; diff --git a/packages/manager/src/features/Images/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding.tsx index 1bc17a33f5c..d7de09b26f4 100644 --- a/packages/manager/src/features/Images/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding.tsx @@ -1,12 +1,12 @@ import { Event, Image, ImageStatus } from '@linode/api-v4'; import { APIError } from '@linode/api-v4/lib/types'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; +import { useQueryClient } from '@tanstack/react-query'; import produce from 'immer'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import { useHistory } from 'react-router-dom'; +import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { CircleProgress } from 'src/components/CircleProgress'; @@ -29,17 +29,16 @@ import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { listToItemsByID } from 'src/queries/base'; +import { + isEventImageUpload, + isEventInProgressDiskImagize, +} from 'src/queries/events/event.helpers'; import { useEventsInfiniteQuery } from 'src/queries/events/events'; import { - queryKey, - removeImageFromCache, + imageQueries, useDeleteImageMutation, useImagesQuery, } from 'src/queries/images'; -import { - isEventImageUpload, - isEventInProgressDiskImagize, -} from 'src/queries/events/event.helpers'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import ImageRow, { ImageWithEvent } from './ImageRow'; @@ -105,12 +104,8 @@ export const ImagesLanding: React.FC = () => { const queryClient = useQueryClient(); - // Pagination, order, and query hooks for manual/custom images - const paginationForManualImages = usePagination( - 1, - `${queryKey}-manual`, - 'manual' - ); + const paginationForManualImages = usePagination(1, 'images-manual', 'manual'); + const { handleOrderChange: handleManualImagesOrderChange, order: manualImagesOrder, @@ -120,7 +115,7 @@ export const ImagesLanding: React.FC = () => { order: 'asc', orderBy: 'label', }, - `${queryKey}-manual-order`, + 'images-manual-order', 'manual' ); @@ -148,7 +143,7 @@ export const ImagesLanding: React.FC = () => { // Pagination, order, and query hooks for automatic/recovery images const paginationForAutomaticImages = usePagination( 1, - `${queryKey}-automatic`, + 'images-automatic', 'automatic' ); const { @@ -160,7 +155,7 @@ export const ImagesLanding: React.FC = () => { order: 'asc', orderBy: 'label', }, - `${queryKey}-automatic-order`, + 'images-automatic-order', 'automatic' ); @@ -284,7 +279,7 @@ export const ImagesLanding: React.FC = () => { imageLabel: string, imageDescription: string ) => { - removeImageFromCache(queryClient); + queryClient.invalidateQueries(imageQueries.paginated._def); history.push('/images/create/upload', { imageDescription, imageLabel, @@ -292,7 +287,7 @@ export const ImagesLanding: React.FC = () => { }; const onCancelFailedClick = () => { - removeImageFromCache(queryClient); + queryClient.invalidateQueries(imageQueries.paginated._def); }; const openForEdit = (label: string, description: string, imageID: string) => { diff --git a/packages/manager/src/queries/events/event.helpers.ts b/packages/manager/src/queries/events/event.helpers.ts index 29c6dd979c1..fe1e643d8eb 100644 --- a/packages/manager/src/queries/events/event.helpers.ts +++ b/packages/manager/src/queries/events/event.helpers.ts @@ -1,17 +1,11 @@ import { Event, EventAction, Filter } from '@linode/api-v4'; -export const isLongPendingEvent = (event: Event): boolean => { - const { action, status } = event; - return status === 'scheduled' && action === 'image_upload'; -}; - export const isInProgressEvent = (event: Event) => { - const { percent_complete } = event; - if (percent_complete === null || isLongPendingEvent(event)) { + if (event.percent_complete === null) { return false; - } else { - return percent_complete !== null && percent_complete < 100; } + + return event.percent_complete < 100; }; export const isEventInProgressDiskImagize = (event: Event): boolean => { diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index 2b684ba2828..fcd52758f8a 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -16,185 +16,137 @@ import { Params, ResourcePage, } from '@linode/api-v4/lib/types'; -import { - QueryClient, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; -import { doesItemExistInPaginatedStore, updateInPaginatedStore } from './base'; import { profileQueries } from './profile'; -export const queryKey = 'images'; +export const getAllImages = ( + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getImages({ ...params, ...passedParams }, { ...filter, ...passedFilter }) + )().then((data) => data.data); + +export const imageQueries = createQueryKeys('images', { + all: (params: Params = {}, filters: Filter = {}) => ({ + queryFn: () => getAllImages(params, filters), + queryKey: [params, filters], + }), + image: (imageId: string) => ({ + queryFn: () => getImage(imageId), + queryKey: [imageId], + }), + paginated: (params: Params, filters: Filter) => ({ + queryFn: () => getImages(params, filters), + queryKey: [params, filters], + }), +}); export const useImagesQuery = (params: Params, filters: Filter) => - useQuery, APIError[]>( - [`${queryKey}-list`, params, filters], - () => getImages(params, filters), - { keepPreviousData: true } - ); + useQuery, APIError[]>({ + ...imageQueries.paginated(params, filters), + keepPreviousData: true, + }); -// Get specific Image -export const useImageQuery = (imageID: string, enabled = true) => - useQuery([queryKey, imageID], () => getImage(imageID), { +export const useImageQuery = (imageId: string, enabled = true) => + useQuery({ + ...imageQueries.image(imageId), enabled, }); -// Create Image export const useCreateImageMutation = () => { const queryClient = useQueryClient(); - return useMutation( - ({ cloud_init, description, diskID, label }) => { + return useMutation({ + mutationFn: ({ cloud_init, description, diskID, label }) => { return createImage(diskID, label, description, cloud_init); }, - { - onSuccess() { - queryClient.invalidateQueries([`${queryKey}-list`]); - // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries(profileQueries.grants.queryKey); - }, - } - ); + onSuccess(image) { + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.setQueryData( + imageQueries.image(image.id).queryKey, + image + ); + // If a restricted user creates an entity, we must make sure grants are up to date. + queryClient.invalidateQueries(profileQueries.grants.queryKey); + }, + }); }; -// Update Image export const useUpdateImageMutation = () => { const queryClient = useQueryClient(); return useMutation< Image, APIError[], { description?: string; imageId: string; label?: string } - >( - ({ description, imageId, label }) => + >({ + mutationFn: ({ description, imageId, label }) => updateImage(imageId, label, description), - { - onSuccess(image) { - updateInPaginatedStore( - [`${queryKey}-list`], - image.id, - image, - queryClient - ); - }, - } - ); + onSuccess(image) { + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.setQueryData( + imageQueries.image(image.id).queryKey, + image + ); + }, + }); }; -// Delete Image export const useDeleteImageMutation = () => { const queryClient = useQueryClient(); return useMutation<{}, APIError[], { imageId: string }>( ({ imageId }) => deleteImage(imageId), { - onSuccess() { - queryClient.invalidateQueries([`${queryKey}-list`]); + onSuccess(_, variables) { + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.removeQueries( + imageQueries.image(variables.imageId).queryKey + ); }, } ); }; -// Remove Image from cache -export const removeImageFromCache = (queryClient: QueryClient) => - queryClient.invalidateQueries([`${queryKey}-list`]); - -// Get all Images -export const getAllImages = ( - passedParams: Params = {}, - passedFilter: Filter = {} -) => - getAll((params, filter) => - getImages({ ...params, ...passedParams }, { ...filter, ...passedFilter }) - )().then((data) => data.data); - export const useAllImagesQuery = ( params: Params = {}, filters: Filter = {}, enabled = true ) => - useQuery( - [`${queryKey}-all`, params, filters], - () => getAllImages(params, filters), - { - enabled, - } - ); + useQuery({ + ...imageQueries.all(params, filters), + enabled, + }); -export const useUploadImageQuery = (payload: ImageUploadPayload) => - useMutation(() => uploadImage(payload)); +export const useUploadImageMutation = (payload: ImageUploadPayload) => + useMutation({ + mutationFn: () => uploadImage(payload), + }); export const imageEventsHandler = ({ event, queryClient, }: EventHandlerData) => { - const { action, entity, status } = event; - - // Keep the getAll query up to date so that when we have to use it, it contains accurate data - queryClient.invalidateQueries([`${queryKey}-all`]); - - switch (action) { - case 'image_delete': - if ( - doesItemExistInPaginatedStore( - [`${queryKey}-list`], - entity!.id, - queryClient - ) - ) { - queryClient.invalidateQueries([`${queryKey}-list`]); - } - return; - - /** - * Not ideal, but we don't have a choice: disk_imagize entity is the Linode - * where the disk resides, not the image (as one would expect). - */ - case 'disk_imagize': - if (status === 'failed' && event.secondary_entity) { - updateInPaginatedStore( - [`${queryKey}-list`], - event.secondary_entity.id, - {}, - queryClient - ); - return; - } - - if ( - ['finished', 'notification'].includes(status) && - event.secondary_entity - ) { - updateInPaginatedStore( - [`${queryKey}-list`], - `private/${event.secondary_entity.id}`, - { - status: 'available', - }, - queryClient - ); - return; - } - - case 'image_upload': - if (event.status === 'finished') { - // eslint-disable-next-line no-unused-expressions - (async () => - await getImage(`private/${event.entity?.id}`).then(() => { - updateInPaginatedStore( - [`${queryKey}-list`], - `private/${event.entity?.id}`, - { - status: 'available', - }, - queryClient - ); - }))(); - } - - default: - return; + if (['failed', 'finished', 'notification'].includes(event.status)) { + queryClient.invalidateQueries(imageQueries.all._def); + queryClient.invalidateQueries(imageQueries.paginated._def); + + if (event.entity) { + /* + * Image event entities look like this: + * "entity": { + * "label": "test-1", + * "id": 23802090, + * "type": "image", + * "url": "/v4/images/private/23802090" + * }, + */ + + const imageId = `private/${event.entity.id}`; + queryClient.invalidateQueries(imageQueries.image(imageId).queryKey); + } } };