Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tanstack: React Query (master) #1260

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:react/recommended"
"plugin:react/recommended",
"plugin:@tanstack/query/recommended"
],
"root": true,
"ignorePatterns": ["vite.config.mts"],
Expand Down Expand Up @@ -50,6 +51,7 @@
}
},
"rules": {
"@tanstack/query/exhaustive-deps": "warn",
"@typescript-eslint/ban-types": "warn",
"@typescript-eslint/no-base-to-string": "warn",
"@typescript-eslint/consistent-type-imports": "warn",
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
"@mui/styles": "^5.11.9",
"@mui/x-date-pickers": "^5.0.18",
"@mui/x-tree-view": "^7.6.2",
"@tanstack/query-sync-storage-persister": "^5.59.0",
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-query-persist-client": "^5.59.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitejs/plugin-react-swc": "^3.5.0",
"autosuggest-highlight": "^3.3.4",
Expand All @@ -41,6 +44,7 @@
"i18next": "^22.4.9",
"i18next-browser-languagedetector": "^7.0.1",
"lodash": "^4.17.21",
"lz-string": "^1.5.0",
"md5": "^2.3.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.40",
Expand Down Expand Up @@ -101,6 +105,8 @@
"@iconify/icons-simple-icons": "^1.2.56",
"@iconify/react": "^4.1.1",
"@jest/globals": "^29.6.4",
"@tanstack/eslint-plugin-query": "^5.58.1",
"@tanstack/react-query-devtools": "^5.56.2",
"@testing-library/dom": "^7.31.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
Expand Down
17 changes: 10 additions & 7 deletions src/components/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import Routes from 'components/routes/routes';
import Tos from 'components/routes/tos';
import setMomentFRLocale from 'helpers/moment-fr-locale';
import { getProvider } from 'helpers/utils';
import { APIProvider } from 'lib/api/APIProvider';
import React, { useEffect, useState } from 'react';
import { BrowserRouter } from 'react-router-dom';

Expand Down Expand Up @@ -102,13 +103,15 @@ export const MyApp: React.FC<any> = () => {
const myUser: CustomAppUserService = useMyUser();
return (
<BrowserRouter basename="/">
<SafeResultsProvider>
<QuotaProvider>
<AppProvider user={myUser} preferences={myPreferences} theme={myTheme} sitemap={mySitemap}>
<MyAppMain />
</AppProvider>
</QuotaProvider>
</SafeResultsProvider>
<APIProvider>
<SafeResultsProvider>
<QuotaProvider>
<AppProvider user={myUser} preferences={myPreferences} theme={myTheme} sitemap={mySitemap}>
<MyAppMain />
</AppProvider>
</QuotaProvider>
</SafeResultsProvider>
</APIProvider>
</BrowserRouter>
);
};
Expand Down
4 changes: 2 additions & 2 deletions src/helpers/xsrf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* @returns the CSRF token
*
*/
export default function getXSRFCookie() {
export default function getXSRFCookie(): string {
let xsrfToken = null;
if (document.cookie !== undefined) {
try {
Expand All @@ -18,5 +18,5 @@ export default function getXSRFCookie() {
// Ignore... we will return null
}
}
return xsrfToken;
return xsrfToken as string;
}
46 changes: 46 additions & 0 deletions src/lib/api/APIProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { keepPreviousData, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { compress, decompress } from 'lz-string';
import React from 'react';
import { DEFAULT_GC_TIME, DEFAULT_STALE_TIME } from './constants';
import type { APIQueryKey } from './models';

export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: DEFAULT_STALE_TIME,
gcTime: DEFAULT_GC_TIME,
placeholderData: keepPreviousData
}
}
});

type Props = {
children: React.ReactNode;
};

const persister = createSyncStoragePersister({
storage: window.sessionStorage,
serialize: data =>
compress(
JSON.stringify({
...data,
clientState: {
mutations: [],
queries: data.clientState.queries.filter(q => (q.queryKey[0] as APIQueryKey).allowCache)
}
})
),
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
deserialize: data => JSON.parse(decompress(data))
});

export const APIProvider = ({ children }: Props) => (
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister }}>
{children}
<ReactQueryDevtools initialIsOpen={true} />
</PersistQueryClientProvider>
);
9 changes: 9 additions & 0 deletions src/lib/api/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const DEFAULT_RETRY_MS = 10 * 1000;

/** The time in milliseconds after data is considered stale. If set to Infinity, the data will never be considered stale. If set to a function, the function will be executed with the query to compute a staleTime. */
export const DEFAULT_STALE_TIME = 1 * 60 * 1000;

/** The time in milliseconds that unused/inactive cache data remains in memory. When a query's cache becomes unused or inactive, that cache data will be garbage collected after this duration. When different garbage collection times are specified, the longest one will be used. Setting it to Infinity will disable garbage collection. */
export const DEFAULT_GC_TIME = 5 * 60 * 1000;

export const DEFAULT_INVALIDATE_DELAY = 1 * 1000;
21 changes: 21 additions & 0 deletions src/lib/api/invalidateApiQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Query } from '@tanstack/react-query';
import { queryClient } from './APIProvider';
import { DEFAULT_INVALIDATE_DELAY } from './constants';
import type { ApiCallProps } from './utils';

export const invalidateApiQuery = (
filter: (key: ApiCallProps) => boolean,
delay: number = DEFAULT_INVALIDATE_DELAY
) => {
setTimeout(async () => {
await queryClient.invalidateQueries({
predicate: ({ queryKey }: Query<unknown, Error, unknown, [ApiCallProps]>) => {
try {
return typeof queryKey[0] === 'object' && queryKey[0] && filter(queryKey[0]);
} catch (err) {
return false;
}
}
});
}, delay);
};
33 changes: 33 additions & 0 deletions src/lib/api/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export type APIResponse<T = any> = {
api_error_message: string;
api_response: T;
api_server_version: string;
api_status_code: number;
};

export type BlobResponse = {
api_error_message: string;
api_response: unknown;
api_server_version: string;
api_status_code: number;
filename: string;
size: number;
type: string;
};

export type APIReturn<Response> = {
statusCode: number;
serverVersion: string;
data: Response;
error: string;
};

export type APIQueryKey<Body extends object = object> = {
url: string;
contentType: string;
method: string;
body: Body;
reloadOnUnauthorize: boolean;
enabled: boolean;
[key: string]: unknown;
};
19 changes: 19 additions & 0 deletions src/lib/api/updateApiQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Query } from '@tanstack/react-query';
import { queryClient } from './APIProvider';
import type { APIResponse } from './models';
import type { ApiCallProps } from './utils';

export const updateApiQuery = <T>(filter: (key: ApiCallProps) => boolean, update: (prev: T) => T) => {
queryClient.setQueriesData<APIResponse<T>>(
{
predicate: ({ queryKey }: Query<unknown, Error, unknown, [ApiCallProps]>) => {
try {
return typeof queryKey[0] === 'object' && queryKey[0] && filter(queryKey[0]);
} catch (err) {
return false;
}
}
},
prev => ({ ...prev, api_response: update(prev?.api_response) })
);
};
79 changes: 79 additions & 0 deletions src/lib/api/useApiMutation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { UseMutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { DEFAULT_RETRY_MS } from './constants';
import type { APIResponse } from './models';
import { getAPIResponse, useApiCallFn } from './utils';

type Input<Body> = {
url: string;
contentType?: string;
method?: string;
body?: Body;
};

const DEFAULT_INPUT: Input<null> = { url: null, contentType: 'application/json', method: 'GET', body: null };

type Types<TBody = any, TError = Error, TResponse = any, TVariables = any, TContext = unknown, TPrevious = any> = {
body?: TBody;
error?: TError;
response?: TResponse;
input?: TVariables;
context?: TContext;
previous?: TPrevious;
};

type Props<T extends Types> = Omit<
UseMutationOptions<APIResponse<T['response']>, APIResponse<T['error']>, T['input'], T['context']>,
'mutationKey' | 'mutationFn' | 'onSuccess' | 'onMutate' | 'onSettled'
> & {
input: Input<T['body']> | ((input: T['input']) => Input<T['body']>);
reloadOnUnauthorize?: boolean;
retryAfter?: number;
onSuccess?: (props?: {
data: APIResponse<T['response']>;
input: T['input'];
context: T['context'];
}) => Promise<unknown> | unknown;
onFailure?: (props?: {
error: APIResponse<T['error']>;
input: T['input'];
context: T['context'];
}) => Promise<unknown> | unknown;
onEnter?: (props?: { input: T['input'] }) => unknown;
onExit?: (props?: {
data: APIResponse<T['response']>;
error: APIResponse<T['error']>;
input: T['input'];
context: T['context'];
}) => Promise<unknown> | unknown;
};

export const useApiMutation = <T extends Types>({
input = null,
reloadOnUnauthorize = true,
retryAfter = DEFAULT_RETRY_MS,
onSuccess = () => null,
onFailure = () => null,
onEnter = () => null,
onExit = () => null,
...options
}: Props<T>) => {
const apiCallFn = useApiCallFn<APIResponse<T['response']>, T['body']>();

const mutation = useMutation<APIResponse<T['response']>, APIResponse<T['error']>, T['input'], unknown>({
...options,
mutationFn: async (variables: T['input']) =>
apiCallFn({
...DEFAULT_INPUT,
...(typeof input === 'function' ? input(variables) : input),
reloadOnUnauthorize,
retryAfter
}),
onSuccess: (data, variables, context) => onSuccess({ data, input: variables, context }),
onError: (error, variables, context) => onFailure({ error, input: variables, context }),
onMutate: variables => onEnter({ input: variables }),
onSettled: (data, error, variables, context) => onExit({ data, error, input: variables, context })
});

return { ...mutation, ...getAPIResponse(mutation?.data, mutation?.error, mutation?.failureReason) };
};
48 changes: 48 additions & 0 deletions src/lib/api/useApiQuery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DEFAULT_RETRY_MS } from './constants';
import type { APIResponse } from './models';
import type { ApiCallProps } from './utils';
import { getAPIResponse, useApiCallFn } from './utils';

type Types<TBody = any, TError = Error, TResponse = any> = {
body?: TBody;
error?: TError;
response?: TResponse;
};

type Props<T extends Types, TQueryKey extends QueryKey = QueryKey> = Omit<
DefinedInitialDataOptions<APIResponse<T['response']>, APIResponse<T['error']>, APIResponse<T['response']>, TQueryKey>,
'queryKey' | 'initialData' | 'enabled'
> &
ApiCallProps<T['body']>;

export const useApiQuery = <T extends Types>({
url,
contentType = 'application/json',
method = 'GET',
body = null,
allowCache = false,
enabled = true,
reloadOnUnauthorize = true,
retryAfter = DEFAULT_RETRY_MS,
...options
}: Props<T>) => {
const queryClient = useQueryClient();
const apiCallFn = useApiCallFn<T['response'], T['body']>();

const query = useQuery<APIResponse<T['response']>, APIResponse<T['error']>, APIResponse<T['response']>, QueryKey>(
{
...options,
queryKey: [{ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter }],
enabled: Boolean(enabled),
queryFn: async ({ signal }) =>
apiCallFn({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, signal }),
retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502,
retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000))
},
queryClient
);

return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) };
};
Loading