diff --git a/docs/development-guide/05-fetching-data.md b/docs/development-guide/05-fetching-data.md index ec26c115cbd..f5780d41405 100644 --- a/docs/development-guide/05-fetching-data.md +++ b/docs/development-guide/05-fetching-data.md @@ -79,33 +79,47 @@ This works, but has a few disadvantages: A better way to fetch data is to use React Query. It address the issues listed above and has many additional features. -To fetch data with React Query, check to see if the API method you want to use has a query written for it in `packages/manager/src/queries`. If not, feel free to write one. It should look something like this: +To fetch data with React Query: -```ts -import { getProfile, Profile } from "@linode/api-v4/lib/profile"; -import { APIError } from "@linode/api-v4/lib/types"; -import { useQuery } from "react-query"; +- Create an `@linode/api-v4` function that calls the intended Linode API endpoint. +- Create a query key factory that uses the newly created `@linode/api-v4` function. +- Create a hook that wraps `useQuery` and uses the query key factory. -const queryKey = "profile"; +```ts +import { useQuery } from "@tanstack/react-query"; +import { getProfile } from "@linode/api-v4"; +import type { APIError, Profile } from "@linode/api-v4"; + +const profileQueries = createQueryKeys('profile', { + profile: { + queryFn: getProfile, + queryKey: null, + }, +}); export const useProfile = () => - useQuery(queryKey, getProfile); + useQuery(profileQueries.profile); ``` The first time `useProfile()` is called, the data is fetched from the API. On subsequent calls, the data is retrieved from the in-memory cache. -`useQuery` accepts a third "options" parameter, which can be used to specify cache time (among others things). For example, to specify that the cache should never expire for this query: +`useQuery` accepts options which can be used to specify cache time (among others things). For example, to specify that the cache should never expire for this query: ```ts import { queryPresets } from "src/queries/base"; -// ...other imports + +const profileQueries = createQueryKeys('profile', { + profile: { + queryFn: getProfile, + queryKey: null, + }, +}) export const useProfile = () => - useQuery( - queryKey, - getProfile, - queryPresets.oneTimeFetch - ); + useQuery({ + ...profileQueries.profile, + ...queryPresets.oneTimeFetch, + }); ``` Loading and error states are managed by React Query. The earlier username display example becomes greatly simplified: diff --git a/docs/tooling/react-query.md b/docs/tooling/react-query.md new file mode 100644 index 00000000000..1986b1fbc27 --- /dev/null +++ b/docs/tooling/react-query.md @@ -0,0 +1,135 @@ +# React Query + +[TanStack Query](https://tanstack.com/query/latest) (formerly React Query) is Cloud Manager's primary tool for fetching and caching API data. For a quick introduction, read our [Fetching Data](../development-guide/05-fetching-data.md#react-query) development guide. + +## Query Keys + +React Query's cache is a simple key-value store. Query Keys are serializable strings that uniquely identify a query's data in the cache. You can read more about the concept [here](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) in the TanStack Query docs. + +Because of Cloud Manager's complexity, we use [`@lukemorales/query-key-factory`](https://github.com/lukemorales/query-key-factory) to manage our query keys. This package allows us to define query key _factories_ that enable typesafe standardized query keys that can be reused and referenced throughout the application. + +### Examples + +#### Simple Query + +```ts +import { useQuery } from "@tanstack/react-query"; +import { getProfile } from "@linode/api-v4"; +import type { APIError, Profile } from "@linode/api-v4"; + +const profileQueries = createQueryKeys('profile', { + profile: { + queryFn: getProfile, + queryKey: null, + }, +}); + +export const useProfile = () => + useQuery(profileQueries.profile); +``` + +#### Query with parameters + +> [!important] +> Queries that have parameters should always include the parameters in the `queryKey` + +```ts +import { useQuery } from "@tanstack/react-query"; +import { getLinode } from "@linode/api-v4"; +import type { APIError, Linode } from "@linode/api-v4"; + +const linodeQueries = createQueryKeys('linodes', { + linode: (id: number) => ({ + queryFn: () => getLinode(id), + queryKey: [id], + }), +}); + +export const useLinodeQuery = (id: number) => + useQuery(linodeQueries.linode(1)); +``` + +## Maintaining the Cache + +> A significant challenge of React Query is keeping the client state in sync with the server. + +The two easiest ways of updating the cache using React Query are +- Using `invalidateQueries` to mark data as stale (which will trigger a refetch the next time the query is mounted) +- Using `setQueryData` to manually update the cache + +### `invalidateQueries` + +This will mark data as stale in the React Query cache, which will cause Cloud Manager to refetch the data if the corresponding query is mounted. + +Use `invalidateQueries` when: +- You are dealing with any *paginated data* (because order may have changed) +- You want fresh data from the API + +> [!note] +> When using `invalidateQueries`, use a query key factory to ensure you are invalidating the data at the correct query key. + +### `setQueryData` + +Use this if you have data readily available to put in the cache. This often happens when you make a PUT request. + +### Example + +This example shows how we keep the cache up to date when performing create / update / delete operations +on an entity. + +```ts +import { useQuery, useMutation } from "@tanstack/react-query"; +import { getLinode, getLinodes, updateLinode, deleteLinode, createLinode } from "@linode/api-v4"; +import type { APIError, Linode, ResourcePage } from "@linode/api-v4"; + +const linodeQueries = createQueryKeys('linodes', { + linode: (id: number) => ({ + queryFn: () => getLinode(id), + queryKey: [id], + }), + linodes: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getLinodes(params, filter), + queryKey: [params, filter], + }), +}); + +export const useLinodeQuery = (id: number) => + useQuery(linodeQueries.linode(1)); + +export const useLinodeUpdateMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation>({ + mutationFn: (data) => updateLinode(id, data), + onSuccess(linode) { + // Invalidate all paginated pages in the cache. + queryClient.invalidateQueries(linodeQueries.linodes._def); + // Because we have the updated Linode, we can manually set the cache for the `useLinode` query. + queryClient.setQueryData(linodeQueries.linode(id).queryKey, linode); + }, + }); +} + +export const useDeleteLinodeMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteLinode(id), + onSuccess() { + queryClient.removeQueries(linodeQueries.linode(id).queryKey); + queryClient.invalidateQueries(linodeQueries.linodes._def); + }, + }); +}; + +export const useCreateLinodeMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createLinode, + onSuccess(linode) { + // Invalidate all paginated pages in the cache. We don't know what page the new Linode will be on. + queryClient.invalidateQueries(linodeQueries.linodes._def); + // Because we have the new Linode, we can manually set the cache for the `useLinode` query. + queryClient.setQueryData(linodeQueries.linode(id).queryKey, linode); + }, + }); +} +``` \ No newline at end of file diff --git a/packages/manager/.changeset/pr-10241-tech-stories-1709241014115.md b/packages/manager/.changeset/pr-10241-tech-stories-1709241014115.md new file mode 100644 index 00000000000..db5b12af0fb --- /dev/null +++ b/packages/manager/.changeset/pr-10241-tech-stories-1709241014115.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Use `@lukemorales/query-key-factory` for Profile Queries ([#10241](https://github.com/linode/manager/pull/10241)) diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index dee0962bcfa..860799908c5 100644 --- a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts @@ -179,6 +179,8 @@ describe('Personal access tokens', () => { .click(); }); + mockGetPersonalAccessTokens([newToken]).as('getTokens'); + ui.drawer .findByTitle('Edit Personal Access Token') .should('be.visible') @@ -197,7 +199,8 @@ describe('Personal access tokens', () => { }); // Confirm that token has been renamed, initiate revocation. - cy.wait('@updateToken'); + cy.wait(['@updateToken', '@getTokens']); + cy.findByText(newToken.label) .should('be.visible') .closest('tr') diff --git a/packages/manager/package.json b/packages/manager/package.json index 064df438018..9d1918a6ca6 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -18,6 +18,7 @@ "@emotion/styled": "^11.11.0", "@linode/api-v4": "*", "@linode/validation": "*", + "@lukemorales/query-key-factory": "^1.3.4", "@mui/icons-material": "^5.14.7", "@mui/material": "^5.14.7", "@paypal/react-paypal-js": "^7.8.3", diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx index f2ab35d106b..351dc9ba7a8 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx @@ -12,7 +12,7 @@ import { LinkButton } from 'src/components/LinkButton'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { - queryKey, + profileQueries, updateProfileData, useProfile, useSendPhoneVerificationCodeMutation, @@ -98,7 +98,7 @@ export const PhoneVerification = ({ ); } else { // Cloud Manager does not know about the country, so lets refetch the user's phone number so we know it's displaying correctly - queryClient.invalidateQueries([queryKey]); + queryClient.invalidateQueries(profileQueries.profile().queryKey); } // reset form states diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx index 655b8c5c1f7..e36913a0852 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx @@ -6,7 +6,6 @@ import { useQueryClient } from '@tanstack/react-query'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { queryKey } from 'src/queries/profile'; import { useSecurityQuestions } from 'src/queries/securityQuestions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; @@ -20,6 +19,7 @@ import { StyledRootContainer, } from './TwoFactor.styles'; import { TwoFactorToggle } from './TwoFactorToggle'; +import { profileQueries } from 'src/queries/profile'; export interface TwoFactorProps { disabled?: boolean; @@ -61,7 +61,7 @@ export const TwoFactor = (props: TwoFactorProps) => { */ const handleEnableSuccess = (scratchCode: string) => { // Refetch Profile with React Query so profile is up to date - queryClient.invalidateQueries([queryKey]); + queryClient.invalidateQueries(profileQueries.profile().queryKey); setSuccess('Two-factor authentication has been enabled.'); setShowQRCode(false); setTwoFactorEnabled(true); diff --git a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx index f4971810641..bebbc1f6b8e 100644 --- a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx @@ -33,11 +33,11 @@ import { withQueryClient, } from 'src/containers/withQueryClient.container'; import { StackScriptForm } from 'src/features/StackScripts/StackScriptForm/StackScriptForm'; -import { queryKey } from 'src/queries/profile'; import { filterImagesByType } from 'src/store/image/image.helpers'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { storage } from 'src/utilities/storage'; +import { profileQueries } from 'src/queries/profile'; interface State { apiResponse?: StackScript; @@ -359,7 +359,7 @@ export class StackScriptCreate extends React.Component { return; } if (profile.data?.restricted) { - queryClient.invalidateQueries([queryKey, 'grants']); + queryClient.invalidateQueries(profileQueries.grants.queryKey); } this.setState({ isSubmitting: false }); this.resetAllFields(); diff --git a/packages/manager/src/hooks/useInitialRequests.ts b/packages/manager/src/hooks/useInitialRequests.ts index c9dfc9d702e..f837a4ebb99 100644 --- a/packages/manager/src/hooks/useInitialRequests.ts +++ b/packages/manager/src/hooks/useInitialRequests.ts @@ -1,11 +1,12 @@ import { getAccountInfo, getAccountSettings } from '@linode/api-v4/lib/account'; -import { getProfile, getUserPreferences } from '@linode/api-v4/lib/profile'; -import * as React from 'react'; +import { getUserPreferences } from '@linode/api-v4/lib/profile'; import { useQueryClient } from '@tanstack/react-query'; +import * as React from 'react'; import { useAuthentication } from 'src/hooks/useAuthentication'; import { usePendingUpload } from 'src/hooks/usePendingUpload'; import { queryKey as accountQueryKey } from 'src/queries/account'; +import { profileQueries } from 'src/queries/profile'; import { redirectToLogin } from 'src/session'; /** @@ -66,10 +67,7 @@ export const useInitialRequests = () => { }), // Username and whether a user is restricted - queryClient.prefetchQuery({ - queryFn: () => getProfile(), - queryKey: ['profile'], - }), + queryClient.prefetchQuery(profileQueries.profile()), // Is a user managed queryClient.prefetchQuery({ diff --git a/packages/manager/src/queries/base.ts b/packages/manager/src/queries/base.ts index 69cb1a482a0..02527f4ddd4 100644 --- a/packages/manager/src/queries/base.ts +++ b/packages/manager/src/queries/base.ts @@ -4,15 +4,12 @@ import { QueryClient, QueryKey, UseMutationOptions, - UseQueryOptions, } from '@tanstack/react-query'; // ============================================================================= // Config // ============================================================================= -type QueryConfigTypes = 'longLived' | 'noRetry' | 'oneTimeFetch' | 'shortLived'; - -export const queryPresets: Record> = { +export const queryPresets = { longLived: { cacheTime: 10 * 60 * 1000, refetchOnMount: true, diff --git a/packages/manager/src/queries/databases.ts b/packages/manager/src/queries/databases.ts index 57133d704ab..74a92804604 100644 --- a/packages/manager/src/queries/databases.ts +++ b/packages/manager/src/queries/databases.ts @@ -41,7 +41,7 @@ import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; import { queryPresets, updateInPaginatedStore } from './base'; -import { queryKey as PROFILE_QUERY_KEY } from './profile'; +import { profileQueries } from './profile'; export const queryKey = 'databases'; @@ -116,7 +116,7 @@ export const useCreateDatabaseMutation = () => { // Add database to the cache queryClient.setQueryData([queryKey, data.id], data); // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries([PROFILE_QUERY_KEY, 'grants']); + queryClient.invalidateQueries(profileQueries.grants.queryKey); }, } ); diff --git a/packages/manager/src/queries/domains.ts b/packages/manager/src/queries/domains.ts index 66644bc4e4c..a9728762bce 100644 --- a/packages/manager/src/queries/domains.ts +++ b/packages/manager/src/queries/domains.ts @@ -24,8 +24,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { getAll } from 'src/utilities/getAll'; -import { queryKey as PROFILE_QUERY_KEY } from './profile'; import { EventHandlerData } from 'src/hooks/useEventHandlers'; +import { profileQueries } from './profile'; export const queryKey = 'domains'; @@ -57,7 +57,7 @@ export const useCreateDomainMutation = () => { queryClient.invalidateQueries([queryKey, 'paginated']); queryClient.setQueryData([queryKey, 'domain', domain.id], domain); // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries([PROFILE_QUERY_KEY, 'grants']); + queryClient.invalidateQueries(profileQueries.grants.queryKey); }, }); }; diff --git a/packages/manager/src/queries/firewalls.ts b/packages/manager/src/queries/firewalls.ts index f38d81cb532..0ff8a8adbed 100644 --- a/packages/manager/src/queries/firewalls.ts +++ b/packages/manager/src/queries/firewalls.ts @@ -27,7 +27,7 @@ import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { getAll } from 'src/utilities/getAll'; import { updateInPaginatedStore } from './base'; -import { queryKey as PROFILE_QUERY_KEY } from './profile'; +import { profileQueries } from './profile'; export const queryKey = 'firewall'; @@ -123,7 +123,7 @@ export const useCreateFirewall = () => { queryClient.invalidateQueries([queryKey, 'paginated']); queryClient.setQueryData([queryKey, 'firewall', firewall.id], firewall); // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries([PROFILE_QUERY_KEY, 'grants']); + queryClient.invalidateQueries(profileQueries.grants.queryKey); }, } ); diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index f81ad4c3524..2b684ba2828 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -23,11 +23,11 @@ import { useQueryClient, } from '@tanstack/react-query'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; import { doesItemExistInPaginatedStore, updateInPaginatedStore } from './base'; -import { queryKey as PROFILE_QUERY_KEY } from './profile'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; +import { profileQueries } from './profile'; export const queryKey = 'images'; @@ -55,7 +55,7 @@ export const useCreateImageMutation = () => { onSuccess() { queryClient.invalidateQueries([`${queryKey}-list`]); // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries([PROFILE_QUERY_KEY, 'grants']); + queryClient.invalidateQueries(profileQueries.grants.queryKey); }, } ); diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index 2c2e23b5a9a..55b58a48b03 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -36,7 +36,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { getAll } from 'src/utilities/getAll'; import { queryPresets, updateInPaginatedStore } from './base'; -import { queryKey as PROFILE_QUERY_KEY } from './profile'; +import { profileQueries } from './profile'; export const queryKey = `kubernetes`; @@ -141,7 +141,7 @@ export const useCreateKubernetesClusterMutation = () => { onSuccess() { queryClient.invalidateQueries([`${queryKey}-list`]); // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries([PROFILE_QUERY_KEY, 'grants']); + queryClient.invalidateQueries(profileQueries.grants.queryKey); }, } ); diff --git a/packages/manager/src/queries/linodes/linodes.ts b/packages/manager/src/queries/linodes/linodes.ts index de623245f43..a9c7a9e0a15 100644 --- a/packages/manager/src/queries/linodes/linodes.ts +++ b/packages/manager/src/queries/linodes/linodes.ts @@ -40,7 +40,7 @@ import { manuallySetVPCConfigInterfacesToActive } from 'src/utilities/configs'; import { queryKey as accountNotificationsQueryKey } from '../accountNotifications'; import { queryPresets } from '../base'; -import { queryKey as PROFILE_QUERY_KEY } from '../profile'; +import { profileQueries } from '../profile'; import { getAllLinodeKernelsRequest, getAllLinodesRequest } from './requests'; export const queryKey = 'linodes'; @@ -159,7 +159,7 @@ export const useCreateLinodeMutation = () => { linode ); // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries([PROFILE_QUERY_KEY, 'grants']); + queryClient.invalidateQueries(profileQueries.grants.queryKey); }, }); }; diff --git a/packages/manager/src/queries/nodebalancers.ts b/packages/manager/src/queries/nodebalancers.ts index 6acb041d798..145478dbefe 100644 --- a/packages/manager/src/queries/nodebalancers.ts +++ b/packages/manager/src/queries/nodebalancers.ts @@ -23,13 +23,13 @@ import { Params, ResourcePage, } from '@linode/api-v4/lib/types'; -import { DateTime } from 'luxon'; import { useInfiniteQuery, useMutation, useQuery, useQueryClient, } from '@tanstack/react-query'; +import { DateTime } from 'luxon'; import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { queryKey as firewallsQueryKey } from 'src/queries/firewalls'; @@ -38,7 +38,7 @@ import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; import { itemInListCreationHandler, itemInListMutationHandler } from './base'; -import { queryKey as PROFILE_QUERY_KEY } from './profile'; +import { profileQueries } from './profile'; export const queryKey = 'nodebalancers'; @@ -114,7 +114,7 @@ export const useNodebalancerCreateMutation = () => { queryClient.invalidateQueries([queryKey]); queryClient.setQueryData([queryKey, 'nodebalancer', data.id], data); // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries([PROFILE_QUERY_KEY, 'grants']); + queryClient.invalidateQueries(profileQueries.grants.queryKey); }, } ); diff --git a/packages/manager/src/queries/placementGroups.ts b/packages/manager/src/queries/placementGroups.ts index 8d9f8b8c2bf..0c1b671a87e 100644 --- a/packages/manager/src/queries/placementGroups.ts +++ b/packages/manager/src/queries/placementGroups.ts @@ -17,7 +17,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { getAll } from 'src/utilities/getAll'; -import { queryKey as PROFILE_QUERY_KEY } from './profile'; +import { profileQueries } from './profile'; import type { AssignLinodesToPlacementGroupPayload, @@ -76,7 +76,7 @@ export const useCreatePlacementGroup = () => { placementGroup ); // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries([PROFILE_QUERY_KEY, 'grants']); + queryClient.invalidateQueries(profileQueries.grants.queryKey); }, }); }; diff --git a/packages/manager/src/queries/profile.ts b/packages/manager/src/queries/profile.ts index 00e37373147..98772f1c161 100644 --- a/packages/manager/src/queries/profile.ts +++ b/packages/manager/src/queries/profile.ts @@ -17,6 +17,8 @@ import { updateProfile, updateSSHKey, verifyPhoneNumberCode, + getAppTokens, + getPersonalAccessTokens, } from '@linode/api-v4/lib/profile'; import { APIError, @@ -24,6 +26,7 @@ import { Params, ResourcePage, } from '@linode/api-v4/lib/types'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; import { QueryClient, useMutation, @@ -31,65 +34,82 @@ import { useQueryClient, } from '@tanstack/react-query'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; + import { Grants } from '../../../api-v4/lib'; import { queryKey as accountQueryKey } from './account'; import { queryPresets } from './base'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; import type { RequestOptions } from '@linode/api-v4'; -export const queryKey = 'profile'; - -export const useProfile = (options?: RequestOptions) => { - const key = [ - queryKey, - options?.headers ? { headers: options.headers } : null, - ]; - - return useQuery( - key, - () => getProfile({ headers: options?.headers }), - { - ...queryPresets.oneTimeFetch, - } - ); +export const profileQueries = createQueryKeys('profile', { + appTokens: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getAppTokens(params, filter), + queryKey: [params, filter], + }), + grants: { + queryFn: listGrants, + queryKey: null, + }, + personalAccessTokens: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getPersonalAccessTokens(params, filter), + queryKey: [params, filter], + }), + profile: (options: RequestOptions = {}) => ({ + queryFn: () => getProfile(options), + queryKey: [options], + }), + sshKeys: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getSSHKeys(params, filter), + queryKey: [params, filter], + }), + trustedDevices: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getTrustedDevices(params, filter), + queryKey: [params, filter], + }), +}); + +export const useProfile = (options: RequestOptions = {}) => { + return useQuery({ + ...profileQueries.profile(options), + ...queryPresets.oneTimeFetch, + }); }; export const useMutateProfile = () => { const queryClient = useQueryClient(); - return useMutation>( - (data) => updateProfile(data), - { onSuccess: (newData) => updateProfileData(newData, queryClient) } - ); + return useMutation>({ + mutationFn: updateProfile, + onSuccess: (newData) => updateProfileData(newData, queryClient), + }); }; export const updateProfileData = ( newData: Partial, queryClient: QueryClient ): void => { - queryClient.setQueryData([queryKey, null], (oldData: Profile) => ({ - ...oldData, - ...newData, - })); + queryClient.setQueryData( + profileQueries.profile().queryKey, + (oldData: Profile) => ({ + ...oldData, + ...newData, + }) + ); }; export const useGrants = () => { const { data: profile } = useProfile(); - return useQuery([queryKey, 'grants'], listGrants, { + return useQuery({ + ...profileQueries.grants, ...queryPresets.oneTimeFetch, enabled: Boolean(profile?.restricted), }); }; -export const getProfileData = (queryClient: QueryClient) => - queryClient.getQueryData([queryKey, null]); - -export const getGrantData = (queryClient: QueryClient) => - queryClient.getQueryData([queryKey, 'grants']); - export const useSMSOptOutMutation = () => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(smsOptOut, { + return useMutation<{}, APIError[]>({ + mutationFn: smsOptOut, onSuccess: () => { updateProfileData({ verified_phone_number: null }, queryClient); }, @@ -97,62 +117,56 @@ export const useSMSOptOutMutation = () => { }; export const useSendPhoneVerificationCodeMutation = () => - useMutation<{}, APIError[], SendPhoneVerificationCodePayload>( - sendCodeToPhoneNumber - ); + useMutation<{}, APIError[], SendPhoneVerificationCodePayload>({ + mutationFn: sendCodeToPhoneNumber, + }); export const useVerifyPhoneVerificationCodeMutation = () => - useMutation<{}, APIError[], VerifyVerificationCodePayload>( - verifyPhoneNumberCode - ); + useMutation<{}, APIError[], VerifyVerificationCodePayload>({ + mutationFn: verifyPhoneNumberCode, + }); export const useSSHKeysQuery = ( params?: Params, filter?: Filter, enabled = true ) => - useQuery( - [queryKey, 'ssh-keys', 'paginated', params, filter], - () => getSSHKeys(params, filter), - { - enabled, - keepPreviousData: true, - } - ); + useQuery({ + ...profileQueries.sshKeys(params, filter), + enabled, + keepPreviousData: true, + }); export const useCreateSSHKeyMutation = () => { const queryClient = useQueryClient(); - return useMutation( - createSSHKey, - { - onSuccess() { - queryClient.invalidateQueries([queryKey, 'ssh-keys']); - // also invalidate the /account/users data because that endpoint returns some SSH key data - queryClient.invalidateQueries([accountQueryKey, 'users']); - }, - } - ); + return useMutation({ + mutationFn: createSSHKey, + onSuccess() { + queryClient.invalidateQueries(profileQueries.sshKeys._def); + // also invalidate the /account/users data because that endpoint returns some SSH key data + queryClient.invalidateQueries([accountQueryKey, 'users']); + }, + }); }; export const useUpdateSSHKeyMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => updateSSHKey(id, data), - { - onSuccess() { - queryClient.invalidateQueries([queryKey, 'ssh-keys']); - // also invalidate the /account/users data because that endpoint returns some SSH key data - queryClient.invalidateQueries([accountQueryKey, 'users']); - }, - } - ); + return useMutation({ + mutationFn: (data) => updateSSHKey(id, data), + onSuccess() { + queryClient.invalidateQueries(profileQueries.sshKeys._def); + // also invalidate the /account/users data because that endpoint returns some SSH key data + queryClient.invalidateQueries([accountQueryKey, 'users']); + }, + }); }; export const useDeleteSSHKeyMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteSSHKey(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteSSHKey(id), onSuccess() { - queryClient.invalidateQueries([queryKey, 'ssh-keys']); + queryClient.invalidateQueries(profileQueries.sshKeys._def); // also invalidate the /account/users data because that endpoint returns some SSH key data queryClient.invalidateQueries([accountQueryKey, 'users']); }, @@ -163,34 +177,33 @@ export const sshKeyEventHandler = (event: EventHandlerData) => { // This event handler is a bit agressive and will over-fetch, but UX will // be great because this will ensure Cloud has up to date data all the time. - event.queryClient.invalidateQueries([queryKey, 'ssh-keys']); + event.queryClient.invalidateQueries(profileQueries.sshKeys._def); // also invalidate the /account/users data because that endpoint returns some SSH key data event.queryClient.invalidateQueries([accountQueryKey, 'users']); }; export const useTrustedDevicesQuery = (params?: Params, filter?: Filter) => - useQuery, APIError[]>( - [queryKey, 'trusted-devices', 'paginated', params, filter], - () => getTrustedDevices(params, filter), - { - keepPreviousData: true, - } - ); + useQuery, APIError[]>({ + ...profileQueries.trustedDevices(params, filter), + keepPreviousData: true, + }); export const useRevokeTrustedDeviceMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteTrustedDevice(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteTrustedDevice(id), onSuccess() { - queryClient.invalidateQueries([queryKey, 'trusted-devices']); + queryClient.invalidateQueries(profileQueries.trustedDevices._def); }, }); }; export const useDisableTwoFactorMutation = () => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(disableTwoFactor, { + return useMutation<{}, APIError[]>({ + mutationFn: disableTwoFactor, onSuccess() { - queryClient.invalidateQueries([queryKey, null]); + queryClient.invalidateQueries(profileQueries.profile().queryKey); // also invalidate the /account/users data because that endpoint returns 2FA status for each user queryClient.invalidateQueries([accountQueryKey, 'users']); }, diff --git a/packages/manager/src/queries/tokens.ts b/packages/manager/src/queries/tokens.ts index 7d87614cc2e..814913ec76a 100644 --- a/packages/manager/src/queries/tokens.ts +++ b/packages/manager/src/queries/tokens.ts @@ -2,8 +2,6 @@ import { createPersonalAccessToken, deleteAppToken, deletePersonalAccessToken, - getAppTokens, - getPersonalAccessTokens, updatePersonalAccessToken, } from '@linode/api-v4/lib/profile'; import { Token, TokenRequest } from '@linode/api-v4/lib/profile/types'; @@ -15,15 +13,14 @@ import { } from '@linode/api-v4/lib/types'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { updateInPaginatedStore } from './base'; -import { queryKey } from './profile'; import { EventHandlerData } from 'src/hooks/useEventHandlers'; +import { profileQueries } from './profile'; + export const useAppTokensQuery = (params?: Params, filter?: Filter) => { return useQuery, APIError[]>({ + ...profileQueries.appTokens(params, filter), keepPreviousData: true, - queryFn: () => getAppTokens(params, filter), - queryKey: [queryKey, 'app-tokens', params, filter], }); }; @@ -33,38 +30,28 @@ export const usePersonalAccessTokensQuery = ( ) => { return useQuery, APIError[]>({ keepPreviousData: true, - queryFn: () => getPersonalAccessTokens(params, filter), - queryKey: [queryKey, 'personal-access-tokens', params, filter], + ...profileQueries.personalAccessTokens(params, filter), }); }; export const useCreatePersonalAccessTokenMutation = () => { const queryClient = useQueryClient(); - return useMutation( - createPersonalAccessToken, - { - onSuccess: () => { - queryClient.invalidateQueries([queryKey, 'personal-access-tokens']); - }, - } - ); + return useMutation({ + mutationFn: createPersonalAccessToken, + onSuccess: () => { + queryClient.invalidateQueries(profileQueries.personalAccessTokens._def); + }, + }); }; export const useUpdatePersonalAccessTokenMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation>( - (data) => updatePersonalAccessToken(id, data), - { - onSuccess: (token) => { - updateInPaginatedStore( - [queryKey, 'personal-access-tokens'], - id, - token, - queryClient - ); - }, - } - ); + return useMutation>({ + mutationFn: (data) => updatePersonalAccessToken(id, data), + onSuccess: () => { + queryClient.invalidateQueries(profileQueries.personalAccessTokens._def); + }, + }); }; export const useRevokePersonalAccessTokenMutation = (id: number) => { @@ -72,11 +59,9 @@ export const useRevokePersonalAccessTokenMutation = (id: number) => { return useMutation<{}, APIError[]>(() => deletePersonalAccessToken(id), { onSuccess() { // Wait 1 second to invalidate cache after deletion because API needs time - setTimeout( - () => - queryClient.invalidateQueries([queryKey, 'personal-access-tokens']), - 1000 - ); + setTimeout(() => { + queryClient.invalidateQueries(profileQueries.personalAccessTokens._def); + }, 1000); }, }); }; @@ -87,7 +72,7 @@ export const useRevokeAppAccessTokenMutation = (id: number) => { onSuccess() { // Wait 1 second to invalidate cache after deletion because API needs time setTimeout( - () => queryClient.invalidateQueries([queryKey, 'app-tokens']), + () => queryClient.invalidateQueries(profileQueries.appTokens._def), 1000 ); }, @@ -95,5 +80,6 @@ export const useRevokeAppAccessTokenMutation = (id: number) => { }; export function tokenEventHandler({ queryClient }: EventHandlerData) { - queryClient.invalidateQueries([queryKey, 'personal-access-tokens']); + queryClient.invalidateQueries(profileQueries.appTokens._def); + queryClient.invalidateQueries(profileQueries.personalAccessTokens._def); } diff --git a/packages/manager/src/queries/volumes.ts b/packages/manager/src/queries/volumes.ts index 0a36f854eb6..10961f4ee7c 100644 --- a/packages/manager/src/queries/volumes.ts +++ b/packages/manager/src/queries/volumes.ts @@ -29,7 +29,7 @@ import { getAll } from 'src/utilities/getAll'; import { queryKey as notificationsQueryKey } from './accountNotifications'; import { updateInPaginatedStore } from './base'; -import { queryKey as PROFILE_QUERY_KEY } from './profile'; +import { profileQueries } from './profile'; export const queryKey = 'volumes'; @@ -128,7 +128,7 @@ export const useCreateVolumeMutation = () => { onSuccess() { queryClient.invalidateQueries([queryKey]); // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries([PROFILE_QUERY_KEY, 'grants']); + queryClient.invalidateQueries(profileQueries.grants.queryKey); }, }); }; diff --git a/yarn.lock b/yarn.lock index fa2ab05d4d7..c57c7c9d5aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1887,6 +1887,11 @@ resolved "https://registry.yarnpkg.com/@linode/eslint-plugin-cloud-manager/-/eslint-plugin-cloud-manager-0.0.3.tgz#dcb78ab36065bf0fb71106a586c1f3f88dbf840a" integrity sha512-tW0CVkou/UzsAfvVbyxsvdwgXspcKMuJZPnGJJnlAGzFa7CVSURJiefYRbWjn2EFeoehgGvW4mGt30JNhhY+3g== +"@lukemorales/query-key-factory@^1.3.4": + version "1.3.4" + resolved "https://registry.yarnpkg.com/@lukemorales/query-key-factory/-/query-key-factory-1.3.4.tgz#d14001dbd781b024df93ca73bd785db590924486" + integrity sha512-A3frRDdkmaNNQi6mxIshsDk4chRXWoXa05US8fBo4kci/H+lVmujS6QrwQLLGIkNIRFGjMqp2uKjC4XsLdydRw== + "@mdx-js/react@^2.1.5": version "2.3.0" resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-2.3.0.tgz#4208bd6d70f0d0831def28ef28c26149b03180b3"