From 93c4685b4f2f17fdaf9a350dfc311cfb99356df8 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Thu, 3 Oct 2024 13:17:36 +0000 Subject: [PATCH 01/21] First pass at implementing tanstack react query --- .eslintrc | 3 +- package.json | 6 ++ src/components/app/app.tsx | 17 ++-- src/helpers/xsrf.tsx | 4 +- src/lib/api/APIProvider.tsx | 25 +++++ src/lib/api/useAPIBootstrap.tsx | 0 src/lib/api/useDownloadBlob.tsx | 0 src/lib/api/useMyMutation.tsx | 62 ++++++++++++ src/lib/api/useMyQuery.tsx | 62 ++++++++++++ src/lib/api/utils.ts | 166 ++++++++++++++++++++++++++++++++ yarn.lock | 103 ++++++++++++++++++++ 11 files changed, 438 insertions(+), 10 deletions(-) create mode 100644 src/lib/api/APIProvider.tsx create mode 100644 src/lib/api/useAPIBootstrap.tsx create mode 100644 src/lib/api/useDownloadBlob.tsx create mode 100644 src/lib/api/useMyMutation.tsx create mode 100644 src/lib/api/useMyQuery.tsx create mode 100644 src/lib/api/utils.ts diff --git a/.eslintrc b/.eslintrc index 5068e0603..4a59c00fd 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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"], diff --git a/package.json b/package.json index 526870636..c243b0d08 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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", diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index 0dca7954d..da68ae9ef 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -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'; @@ -102,13 +103,15 @@ export const MyApp: React.FC = () => { const myUser: CustomAppUserService = useMyUser(); return ( - - - - - - - + + + + + + + + + ); }; diff --git a/src/helpers/xsrf.tsx b/src/helpers/xsrf.tsx index 47878f7ef..845a906da 100644 --- a/src/helpers/xsrf.tsx +++ b/src/helpers/xsrf.tsx @@ -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 { @@ -18,5 +18,5 @@ export default function getXSRFCookie() { // Ignore... we will return null } } - return xsrfToken; + return xsrfToken as string; } diff --git a/src/lib/api/APIProvider.tsx b/src/lib/api/APIProvider.tsx new file mode 100644 index 000000000..162c63a0b --- /dev/null +++ b/src/lib/api/APIProvider.tsx @@ -0,0 +1,25 @@ +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; +import { 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'; + +export const queryClient = new QueryClient(); + +type Props = { + children: React.ReactNode; +}; + +const persister = createSyncStoragePersister({ + storage: window.sessionStorage, + serialize: data => compress(JSON.stringify(data)), + deserialize: data => JSON.parse(decompress(data)) +}); + +export const APIProvider = ({ children }: Props) => ( + + {children} + + +); diff --git a/src/lib/api/useAPIBootstrap.tsx b/src/lib/api/useAPIBootstrap.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/api/useDownloadBlob.tsx b/src/lib/api/useDownloadBlob.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/api/useMyMutation.tsx b/src/lib/api/useMyMutation.tsx new file mode 100644 index 000000000..3c9c7989c --- /dev/null +++ b/src/lib/api/useMyMutation.tsx @@ -0,0 +1,62 @@ +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; +import { queryClient } from './APIProvider'; +import type { APIResponseProps } from './utils'; +import { DEFAULT_RETRY_MS, useQueryFn } from './utils'; + +interface Props + extends Omit, 'mutationKey' | 'mutationFn'> { + url: string; + contentType?: string; + method?: string; + body?: TVariables; + reloadOnUnauthorize?: boolean; + allowCache?: boolean; + retryAfter?: number; + enabled?: boolean; + invalidateFn?: () => void; +} + +export const useMyMutation = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + reloadOnUnauthorize = true, + allowCache = false, + retryAfter = DEFAULT_RETRY_MS, + enabled = true, + onSuccess = () => null, + invalidateFn = () => null, + ...options +}: Props, APIResponseProps, Params>) => { + const queryFn = useQueryFn(); + + const mutation = useMutation, APIResponseProps, Params>({ + ...options, + mutationKey: [{ url, allowCache, method, contentType, body, reloadOnUnauthorize, retryAfter, enabled }], + mutationFn: async () => + queryFn({ url, contentType, method, body, allowCache, reloadOnUnauthorize, retryAfter, enabled }), + + onSuccess: async (data, variable, context) => { + onSuccess(data, variable, context); + + await queryClient.invalidateQueries({ + predicate: q => { + const d = JSON.parse(q.queryHash); + console.log(q); + console.log(JSON.parse(q.queryHash)); + return d[0] === q.queryKey[0]; + } + }); + } + }); + + return { + ...mutation, + statusCode: mutation?.data?.api_status_code || mutation?.error?.api_status_code || null, + serverVersion: mutation?.data?.api_server_version || mutation?.error?.api_server_version || null, + data: mutation?.data?.api_response || null, + error: mutation?.data?.api_error_message || null + }; +}; diff --git a/src/lib/api/useMyQuery.tsx b/src/lib/api/useMyQuery.tsx new file mode 100644 index 000000000..7db8ccc89 --- /dev/null +++ b/src/lib/api/useMyQuery.tsx @@ -0,0 +1,62 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { queryClient } from './APIProvider'; +import type { APIResponseProps } from './utils'; +import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME, useQueryFn } from './utils'; + +interface Props + extends Omit, 'queryKey' | 'initialData'> { + initialData?: null | TData; + url: string; + contentType?: string; + method?: string; + body?: Body; + reloadOnUnauthorize?: boolean; + allowCache?: boolean; + retryAfter?: number; + disableClearData?: boolean; +} + +export const useMyQuery = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + reloadOnUnauthorize = true, + allowCache = false, + retryAfter = DEFAULT_RETRY_MS, + staleTime = DEFAULT_STALE_TIME, + gcTime = DEFAULT_GC_TIME, + ...options +}: Props, APIResponseProps, APIResponseProps, QueryKey, Body>) => { + const queryFn = useQueryFn(); + + // const key = useMemo(() => [url, allowCache, method, contentType, reloadOnUnauthorize, retryAfter, body], []); + + const query = useQuery, APIResponseProps, APIResponseProps>( + { + ...options, + placeholderData: keepPreviousData, + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)), + + queryKey: [{ url, contentType, method, body, allowCache, reloadOnUnauthorize, retryAfter }], + // queryKey: [{ url, allowCache, method, contentType, body, reloadOnUnauthorize, retryAfter }], + // queryKey: [url, allowCache, method, contentType, body, reloadOnUnauthorize, retryAfter], + // initialData, + // staleTime: allowCache ? 1000 * 60 * 60 * 24 * 365 : 0, + staleTime, + gcTime, + queryFn: async () => queryFn({ url, contentType, method, body, allowCache, reloadOnUnauthorize, retryAfter }) + }, + queryClient + ); + + return { + ...query, + statusCode: query?.data?.api_status_code || query?.error?.api_status_code || null, + serverVersion: query?.data?.api_server_version || query?.error?.api_server_version || null, + data: query?.data?.api_response || null, + error: query?.data?.api_error_message || null + }; +}; diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts new file mode 100644 index 000000000..de1d3b4b0 --- /dev/null +++ b/src/lib/api/utils.ts @@ -0,0 +1,166 @@ +import useALContext from 'components/hooks/useALContext'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import useQuota from 'components/hooks/useQuota'; +import getXSRFCookie from 'helpers/xsrf'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +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 RETRY_DELAY = 10 * 1000; + +export type APIResponseProps = { + api_error_message: string; + api_response: T; + api_server_version: string; + api_status_code: number; +}; + +export const isAPIData = (value: object): value is APIResponseProps => + value !== undefined && + value !== null && + 'api_response' in value && + 'api_error_message' in value && + 'api_server_version' in value && + 'api_status_code' in value; + +type ApiCallProps = { + url: string; + contentType?: string; + method?: string; + body?: Body; + allowCache?: boolean; + reloadOnUnauthorize?: boolean; + retryAfter?: number; + enabled?: boolean; +}; + +export const useQueryFn = () => { + const { t } = useTranslation(); + const { showErrorMessage, closeSnackbar } = useMySnackbar(); + const { configuration: systemConfig } = useALContext(); + const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); + + return useCallback( + async ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + reloadOnUnauthorize = true, + allowCache = false, + retryAfter = DEFAULT_RETRY_MS, + enabled = true + }: ApiCallProps) => { + // Reject if the query is not enabled + if (!enabled) { + return Promise.reject(null); + } + + // Check the cache + const cachedURL = sessionStorage.getItem(url); + if (allowCache && cachedURL) { + const apiData = JSON.parse(cachedURL) as APIResponseProps; + return Promise.resolve(apiData); + } + + // fetching the API's data + const res = await fetch(url, { + method, + credentials: 'same-origin', + headers: { 'Content-Type': contentType, 'X-XSRF-TOKEN': getXSRFCookie() }, + body: (!body ? null : contentType === 'application/json' ? JSON.stringify(body) : body) as RequestInit['body'] + }); + + // Setting the API quota + const apiQuota = res.headers.get('X-Remaining-Quota-Api'); + if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); + + // Setting the Submission quota + const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); + if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); + + // Handle an unreachable API + if (res.status === 502) { + return Promise.reject({ + api_error_message: t('api.unreachable'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 502 + }); + } + + // Handle an invalid json format + const json = (await res.json()) as APIResponseProps; + + console.log(json); + + if (!isAPIData(json)) { + showErrorMessage(t('api.invalid')); + return Promise.reject({ + api_error_message: t('api.invalid'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 400 + }); + } + + // Handle an unauthorized or an unavailable service request + if ( + (json.api_status_code === 401 && reloadOnUnauthorize) || + (json.api_status_code === 503 && + json.api_error_message.includes('quota') && + json.api_error_message.includes('daily') && + json.api_error_message.includes('API')) + ) { + window.location.reload(); + return Promise.reject(json); + } + + // Handle a bad Gateway or an unavailable service request + if ( + json.api_status_code === 502 || + (json.api_status_code === 503 && + json.api_error_message.includes('quota') && + !json.api_error_message.includes('submission')) + ) { + console.log('retry'); + // Retryable status responses + if (json.api_status_code === 502) showErrorMessage(json.api_error_message, 30000); + return Promise.reject(json); + } + + // Handle all non-successful request + if (json.api_status_code !== 200) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + showErrorMessage(json.api_error_message); + return Promise.reject(json); + } + + // Handle successful request + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + + // Cache success status + if (allowCache) { + try { + sessionStorage.setItem(url, JSON.stringify(json)); + } catch (error) { + // We could not store into the Session Storage, this means that it is full + // Let's delete the oldest quarter of items to free up some space + [...Array(Math.floor(sessionStorage.length / 4))].forEach(_ => { + sessionStorage.removeItem(sessionStorage.key(0)); + }); + } + } + + return Promise.resolve(json); + }, + [closeSnackbar, setApiQuotaremaining, setSubmissionQuotaremaining, showErrorMessage, systemConfig.system.version, t] + ); +}; diff --git a/yarn.lock b/yarn.lock index a8b21b634..4fba0e01c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2289,6 +2289,64 @@ dependencies: "@swc/counter" "^0.1.3" +"@tanstack/eslint-plugin-query@^5.58.1": + version "5.58.1" + resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.58.1.tgz#92893d6d0f895de1cafec5b21386154bcaf66d48" + integrity sha512-hJR3N5ilK60gCgDWr7pWHV/vDiDVczT95F8AGIcg1gf9117aLPK+LDu+xP2JuEWpWKpsQ6OpWdVMim9kKlMybw== + dependencies: + "@typescript-eslint/utils" "^8.3.0" + +"@tanstack/query-core@5.56.2": + version "5.56.2" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.56.2.tgz#2def2fb0290cd2836bbb08afb0c175595bb8109b" + integrity sha512-gor0RI3/R5rVV3gXfddh1MM+hgl0Z4G7tj6Xxpq6p2I03NGPaJ8dITY9Gz05zYYb/EJq9vPas/T4wn9EaDPd4Q== + +"@tanstack/query-core@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.59.0.tgz#d8323f1c6eb0e573ab0aa85a7b7690d0c263818a" + integrity sha512-WGD8uIhX6/deH/tkZqPNcRyAhDUqs729bWKoByYHSogcshXfFbppOdTER5+qY7mFvu8KEFJwT0nxr8RfPTVh0Q== + +"@tanstack/query-devtools@5.56.1": + version "5.56.1" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.56.1.tgz#319c362dd19c6cfe005e74a8777baefa4a4f72de" + integrity sha512-xnp9jq/9dHfSCDmmf+A5DjbIjYqbnnUL2ToqlaaviUQGRTapXQ8J+GxusYUu1IG0vZMaWdiVUA4HRGGZYAUU+A== + +"@tanstack/query-persist-client-core@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.59.0.tgz#a896386edde3531fec8c0a29aeae9f4ac4681f4c" + integrity sha512-uGXnTgck1AX2xXDVj417vtQD4Sz3J1D5iPxVhfUc7f/fkY9Qad2X7Id9mZUtll1/m9z55DfHoXMXlx5H1JK6fQ== + dependencies: + "@tanstack/query-core" "5.59.0" + +"@tanstack/query-sync-storage-persister@^5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.59.0.tgz#e3d66a36c25de94b89e563b4b669796fd36b19ae" + integrity sha512-PBQ6Chg/rgRQuQcTaFV/GiCDZdzZvbODKrpti+0fOPjKipEtodGRqtvYsPlf1Y7yb4AbZTECAKUQFjCv/gZVaA== + dependencies: + "@tanstack/query-core" "5.59.0" + "@tanstack/query-persist-client-core" "5.59.0" + +"@tanstack/react-query-devtools@^5.56.2": + version "5.56.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.56.2.tgz#c129cdb811927085434ea27691e4b7f605eb4128" + integrity sha512-7nINJtRZZVwhTTyDdMIcSaXo+EHMLYJu1S2e6FskvvD5prx87LlAXXWZDfU24Qm4HjshEtM5lS3HIOszNGblcw== + dependencies: + "@tanstack/query-devtools" "5.56.1" + +"@tanstack/react-query-persist-client@^5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.59.0.tgz#a73bfaf49fdc8d2502c6dc7661e2eb6ad9beeb8f" + integrity sha512-pUZxMXyy8Atv0rKzbRuCjC2wkQ6uTnKqlKtZ7djwv8r89tF1O5+p4DRCpMzQ+8w/qPRo9yMu/nDgGPcEZ1Fr8Q== + dependencies: + "@tanstack/query-persist-client-core" "5.59.0" + +"@tanstack/react-query@^5.56.2": + version "5.56.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.56.2.tgz#3a0241b9d010910905382f5e99160997b8795f91" + integrity sha512-SR0GzHVo6yzhN72pnRhkEFRAHMsUo5ZPzAxfTMvUxFIDVS6W9LYUp6nXW3fcHVdg0ZJl8opSH85jqahvm6DSVg== + dependencies: + "@tanstack/query-core" "5.56.2" + "@testing-library/dom@^7.31.2": version "7.31.2" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.31.2.tgz#df361db38f5212b88555068ab8119f5d841a8c4a" @@ -2705,6 +2763,14 @@ "@typescript-eslint/types" "7.8.0" "@typescript-eslint/visitor-keys" "7.8.0" +"@typescript-eslint/scope-manager@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz#30b23a6ae5708bd7882e40675ef2f1b2beac741f" + integrity sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg== + dependencies: + "@typescript-eslint/types" "8.8.0" + "@typescript-eslint/visitor-keys" "8.8.0" + "@typescript-eslint/type-utils@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a" @@ -2735,6 +2801,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.8.0.tgz#1fd2577b3ad883b769546e2d1ef379f929a7091d" integrity sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw== +"@typescript-eslint/types@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.8.0.tgz#08ea5df6c01984d456056434641491fbf7a1bf43" + integrity sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw== + "@typescript-eslint/typescript-estree@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" @@ -2762,6 +2833,20 @@ semver "^7.6.0" ts-api-utils "^1.3.0" +"@typescript-eslint/typescript-estree@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz#072eaab97fdb63513fabfe1cf271812affe779e3" + integrity sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw== + dependencies: + "@typescript-eslint/types" "8.8.0" + "@typescript-eslint/visitor-keys" "8.8.0" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" + "@typescript-eslint/utils@5.62.0", "@typescript-eslint/utils@^5.58.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" @@ -2789,6 +2874,16 @@ "@typescript-eslint/typescript-estree" "7.8.0" semver "^7.6.0" +"@typescript-eslint/utils@^8.3.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.8.0.tgz#bd8607e3a68c461b69169c7a5824637dc9e8b3f1" + integrity sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@typescript-eslint/scope-manager" "8.8.0" + "@typescript-eslint/types" "8.8.0" + "@typescript-eslint/typescript-estree" "8.8.0" + "@typescript-eslint/visitor-keys@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" @@ -2805,6 +2900,14 @@ "@typescript-eslint/types" "7.8.0" eslint-visitor-keys "^3.4.3" +"@typescript-eslint/visitor-keys@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz#f93965abd38c82a1a1f5574290a50d02daf1cd2e" + integrity sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g== + dependencies: + "@typescript-eslint/types" "8.8.0" + eslint-visitor-keys "^3.4.3" + "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" From 3e892d8b5f8575edee5e09d00ba8e088e542cf3d Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Fri, 4 Oct 2024 19:12:50 +0000 Subject: [PATCH 02/21] More changes --- src/lib/api/useMyMutation.tsx | 68 ++++++++++-------- src/lib/api/useMyQuery.tsx | 29 +++----- src/lib/api/utils.ts | 129 ++++++++++++++++++++-------------- 3 files changed, 124 insertions(+), 102 deletions(-) diff --git a/src/lib/api/useMyMutation.tsx b/src/lib/api/useMyMutation.tsx index 3c9c7989c..2e2c5dfee 100644 --- a/src/lib/api/useMyMutation.tsx +++ b/src/lib/api/useMyMutation.tsx @@ -1,23 +1,29 @@ import type { UseMutationOptions } from '@tanstack/react-query'; -import { useMutation } from '@tanstack/react-query'; -import { queryClient } from './APIProvider'; -import type { APIResponseProps } from './utils'; -import { DEFAULT_RETRY_MS, useQueryFn } from './utils'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { APIQueryKey, APIResponseProps } from './utils'; +import { DEFAULT_INVALIDATE_DELAY, DEFAULT_RETRY_MS, getAPIResponse, useAPICall } from './utils'; -interface Props +interface Props extends Omit, 'mutationKey' | 'mutationFn'> { url: string; contentType?: string; method?: string; - body?: TVariables; + body?: Body; reloadOnUnauthorize?: boolean; allowCache?: boolean; retryAfter?: number; enabled?: boolean; - invalidateFn?: () => void; + invalidateProps?: { + delay?: number; + filter: (key: APIQueryKey) => boolean; + }; + queryDataProps?: { + filter: (key: APIQueryKey) => boolean; + update: (old: TData) => TData; + }; } -export const useMyMutation = ({ +export const useMyMutation = ({ url, contentType = 'application/json', method = 'GET', @@ -27,36 +33,38 @@ export const useMyMutation = null, - invalidateFn = () => null, + invalidateProps = { delay: null, filter: null }, + queryDataProps = { filter: null, update: () => null }, ...options -}: Props, APIResponseProps, Params>) => { - const queryFn = useQueryFn(); +}: Props, APIResponseProps, null, unknown, Body>) => { + const queryClient = useQueryClient(); + const apiCall = useAPICall(); - const mutation = useMutation, APIResponseProps, Params>({ + const mutation = useMutation, APIResponseProps, unknown>({ ...options, mutationKey: [{ url, allowCache, method, contentType, body, reloadOnUnauthorize, retryAfter, enabled }], - mutationFn: async () => - queryFn({ url, contentType, method, body, allowCache, reloadOnUnauthorize, retryAfter, enabled }), + mutationFn: async () => apiCall({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }), - onSuccess: async (data, variable, context) => { + onSuccess: async (data, variable: null, context) => { onSuccess(data, variable, context); - await queryClient.invalidateQueries({ - predicate: q => { - const d = JSON.parse(q.queryHash); - console.log(q); - console.log(JSON.parse(q.queryHash)); - return d[0] === q.queryKey[0]; - } - }); + if (queryDataProps?.filter && queryDataProps?.update) { + queryClient.setQueriesData( + { + predicate: q => queryDataProps?.filter((JSON.parse(q.queryHash) as [APIQueryKey])[0]) + }, + queryDataProps.update + ); + } + + if (invalidateProps?.filter) { + await new Promise(resolve => setTimeout(resolve, invalidateProps?.delay || DEFAULT_INVALIDATE_DELAY)); + await queryClient.invalidateQueries({ + predicate: q => invalidateProps.filter((JSON.parse(q.queryHash) as [APIQueryKey])[0]) + }); + } } }); - return { - ...mutation, - statusCode: mutation?.data?.api_status_code || mutation?.error?.api_status_code || null, - serverVersion: mutation?.data?.api_server_version || mutation?.error?.api_server_version || null, - data: mutation?.data?.api_response || null, - error: mutation?.data?.api_error_message || null - }; + return { ...mutation, ...getAPIResponse(mutation?.data, mutation?.error, mutation?.failureReason) }; }; diff --git a/src/lib/api/useMyQuery.tsx b/src/lib/api/useMyQuery.tsx index 7db8ccc89..0dd07191b 100644 --- a/src/lib/api/useMyQuery.tsx +++ b/src/lib/api/useMyQuery.tsx @@ -1,8 +1,7 @@ import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; -import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { queryClient } from './APIProvider'; +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; import type { APIResponseProps } from './utils'; -import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME, useQueryFn } from './utils'; +import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME, getAPIResponse, useAPICall } from './utils'; interface Props extends Omit, 'queryKey' | 'initialData'> { @@ -12,24 +11,23 @@ interface Props({ +export const useMyQuery = ({ url, contentType = 'application/json', method = 'GET', body = null, reloadOnUnauthorize = true, - allowCache = false, retryAfter = DEFAULT_RETRY_MS, staleTime = DEFAULT_STALE_TIME, gcTime = DEFAULT_GC_TIME, ...options }: Props, APIResponseProps, APIResponseProps, QueryKey, Body>) => { - const queryFn = useQueryFn(); + const queryClient = useQueryClient(); + const apiCall = useAPICall(); // const key = useMemo(() => [url, allowCache, method, contentType, reloadOnUnauthorize, retryAfter, body], []); @@ -39,24 +37,13 @@ export const useMyQuery = failureCount < 1 || error?.api_status_code === 502, retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)), - - queryKey: [{ url, contentType, method, body, allowCache, reloadOnUnauthorize, retryAfter }], - // queryKey: [{ url, allowCache, method, contentType, body, reloadOnUnauthorize, retryAfter }], - // queryKey: [url, allowCache, method, contentType, body, reloadOnUnauthorize, retryAfter], - // initialData, - // staleTime: allowCache ? 1000 * 60 * 60 * 24 * 365 : 0, + queryKey: [{ url, contentType, method, body, reloadOnUnauthorize, retryAfter }], staleTime, gcTime, - queryFn: async () => queryFn({ url, contentType, method, body, allowCache, reloadOnUnauthorize, retryAfter }) + queryFn: async () => apiCall({ url, contentType, method, body, reloadOnUnauthorize, retryAfter }) }, queryClient ); - return { - ...query, - statusCode: query?.data?.api_status_code || query?.error?.api_status_code || null, - serverVersion: query?.data?.api_server_version || query?.error?.api_server_version || null, - data: query?.data?.api_response || null, - error: query?.data?.api_error_message || null - }; + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; }; diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts index de1d3b4b0..37b9255f9 100644 --- a/src/lib/api/utils.ts +++ b/src/lib/api/utils.ts @@ -13,7 +13,7 @@ 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 RETRY_DELAY = 10 * 1000; +export const DEFAULT_INVALIDATE_DELAY = 1 * 1000; export type APIResponseProps = { api_error_message: string; @@ -22,6 +22,22 @@ export type APIResponseProps = { api_status_code: number; }; +export type APIReturn = { + statusCode: number; + serverVersion: string; + data: Response; + error: string; +}; + +export type APIQueryKey = { + url: string; + contentType: string; + method: string; + body: Body; + reloadOnUnauthorize: boolean; + enabled: boolean; +}; + export const isAPIData = (value: object): value is APIResponseProps => value !== undefined && value !== null && @@ -30,18 +46,33 @@ export const isAPIData = (value: object): value is APIResponseProps => 'api_server_version' in value && 'api_status_code' in value; +const getValue = >( + key: K, + ...responses: APIResponseProps[] +): APIResponseProps[K] => responses?.find(r => !!r?.[key])?.[key] || null; + +export const getAPIResponse = ( + data: APIResponseProps, + error: APIResponseProps, + failureReason: APIResponseProps +) => ({ + statusCode: getValue('api_status_code', data, error, failureReason), + serverVersion: getValue('api_server_version', data, error, failureReason), + data: getValue('api_response', data, error, failureReason) as R, + error: getValue('api_error_message', data, error, failureReason) as E +}); + type ApiCallProps = { url: string; contentType?: string; method?: string; body?: Body; - allowCache?: boolean; reloadOnUnauthorize?: boolean; retryAfter?: number; enabled?: boolean; }; -export const useQueryFn = () => { +export const useAPICall = () => { const { t } = useTranslation(); const { showErrorMessage, closeSnackbar } = useMySnackbar(); const { configuration: systemConfig } = useALContext(); @@ -54,28 +85,25 @@ export const useQueryFn = () => { method = 'GET', body = null, reloadOnUnauthorize = true, - allowCache = false, retryAfter = DEFAULT_RETRY_MS, enabled = true }: ApiCallProps) => { // Reject if the query is not enabled - if (!enabled) { - return Promise.reject(null); - } + if (!enabled) return Promise.reject(null); - // Check the cache - const cachedURL = sessionStorage.getItem(url); - if (allowCache && cachedURL) { - const apiData = JSON.parse(cachedURL) as APIResponseProps; - return Promise.resolve(apiData); - } + // // Check the cache + // const cachedURL = sessionStorage.getItem(url); + // if (allowCache && cachedURL) { + // const apiData = JSON.parse(cachedURL) as APIResponseProps; + // return Promise.resolve(apiData); + // } // fetching the API's data const res = await fetch(url, { method, credentials: 'same-origin', headers: { 'Content-Type': contentType, 'X-XSRF-TOKEN': getXSRFCookie() }, - body: (!body ? null : contentType === 'application/json' ? JSON.stringify(body) : body) as RequestInit['body'] + body: (!body ? null : contentType === 'application/json' ? JSON.stringify(body) : body) as BodyInit }); // Setting the API quota @@ -88,6 +116,7 @@ export const useQueryFn = () => { // Handle an unreachable API if (res.status === 502) { + showErrorMessage(t('api.unreachable'), 10000); return Promise.reject({ api_error_message: t('api.unreachable'), api_response: '', @@ -96,11 +125,9 @@ export const useQueryFn = () => { }); } - // Handle an invalid json format const json = (await res.json()) as APIResponseProps; - console.log(json); - + // Check for an invalid json format if (!isAPIData(json)) { showErrorMessage(t('api.invalid')); return Promise.reject({ @@ -111,56 +138,56 @@ export const useQueryFn = () => { }); } - // Handle an unauthorized or an unavailable service request - if ( - (json.api_status_code === 401 && reloadOnUnauthorize) || - (json.api_status_code === 503 && - json.api_error_message.includes('quota') && - json.api_error_message.includes('daily') && - json.api_error_message.includes('API')) - ) { + const { api_status_code: statusCode, api_error_message: error } = json; + + // // Reload when the user has exceeded their daily API call quota. + if (statusCode === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { window.location.reload(); return Promise.reject(json); } - // Handle a bad Gateway or an unavailable service request - if ( - json.api_status_code === 502 || - (json.api_status_code === 503 && - json.api_error_message.includes('quota') && - !json.api_error_message.includes('submission')) - ) { - console.log('retry'); - // Retryable status responses - if (json.api_status_code === 502) showErrorMessage(json.api_error_message, 30000); + // Reject if the user has exceeded their daily submissions quota. + if (statusCode === 503 && ['quota', 'submission'].every(v => error.includes(v))) { return Promise.reject(json); } - // Handle all non-successful request - if (json.api_status_code !== 200) { - if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); - showErrorMessage(json.api_error_message); + // Reload when the user is not logged in + if (statusCode === 401 && reloadOnUnauthorize) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if API Server is unavailable and should attempt to retry + if (statusCode === 502) { + showErrorMessage(json.api_error_message, 30000); return Promise.reject(json); } // Handle successful request if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); - // Cache success status - if (allowCache) { - try { - sessionStorage.setItem(url, JSON.stringify(json)); - } catch (error) { - // We could not store into the Session Storage, this means that it is full - // Let's delete the oldest quarter of items to free up some space - [...Array(Math.floor(sessionStorage.length / 4))].forEach(_ => { - sessionStorage.removeItem(sessionStorage.key(0)); - }); - } + // Handle all non-successful request + if (statusCode !== 200) { + showErrorMessage(json.api_error_message); + return Promise.reject(json); } + // // Cache success status + // if (allowCache) { + // try { + // sessionStorage.setItem(url, JSON.stringify(json)); + // } catch (error) { + // // We could not store into the Session Storage, this means that it is full + // // Let's delete the oldest quarter of items to free up some space + // [...Array(Math.floor(sessionStorage.length / 4))].forEach(_ => { + // sessionStorage.removeItem(sessionStorage.key(0)); + // }); + // } + // } + return Promise.resolve(json); }, - [closeSnackbar, setApiQuotaremaining, setSubmissionQuotaremaining, showErrorMessage, systemConfig.system.version, t] + // eslint-disable-next-line react-hooks/exhaustive-deps + [setApiQuotaremaining, setSubmissionQuotaremaining, systemConfig.system.version, t] ); }; From 3340303fd40958a86cc0e9953403afa3e016a6bf Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Sun, 6 Oct 2024 15:44:19 +0000 Subject: [PATCH 03/21] More changes --- src/lib/api/constants.ts | 0 src/lib/api/models.ts | 0 src/lib/api/useMyInfiniteQuery.tsx | 68 ++++++++++++++++++++++++++++++ src/lib/api/useMyMutation.tsx | 16 +++---- src/lib/api/useMyQuery.tsx | 48 ++++++++++++++++----- src/lib/api/utils.ts | 5 ++- 6 files changed, 115 insertions(+), 22 deletions(-) create mode 100644 src/lib/api/constants.ts create mode 100644 src/lib/api/models.ts create mode 100644 src/lib/api/useMyInfiniteQuery.tsx diff --git a/src/lib/api/constants.ts b/src/lib/api/constants.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/api/models.ts b/src/lib/api/models.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/api/useMyInfiniteQuery.tsx b/src/lib/api/useMyInfiniteQuery.tsx new file mode 100644 index 000000000..a38143f14 --- /dev/null +++ b/src/lib/api/useMyInfiniteQuery.tsx @@ -0,0 +1,68 @@ +import type { DefinedInitialDataInfiniteOptions, InfiniteData, QueryKey } from '@tanstack/react-query'; +import { keepPreviousData, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import type { APIResponseProps } from './utils'; +import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME, useDefaultQueryFn } from './utils'; + +// type DefinedInitialDataInfiniteOptions, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown> + +interface Props + extends Omit< + DefinedInitialDataInfiniteOptions, + 'queryKey' | 'initialData' + > { + initialData?: null | TData; + url: string; + contentType?: string; + method?: string; + body?: Body; + reloadOnUnauthorize?: boolean; + retryAfter?: number; + disableClearData?: boolean; +} + +export const useMyInfiniteQuery = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + staleTime = DEFAULT_STALE_TIME, + gcTime = DEFAULT_GC_TIME, + ...options +}: Props< + APIResponseProps, + APIResponseProps, + InfiniteData, unknown>, + QueryKey, + unknown, + Body +>) => { + const queryClient = useQueryClient(); + const queryFn = useDefaultQueryFn(); + + const query = useInfiniteQuery< + APIResponseProps, + APIResponseProps, + InfiniteData, unknown> + >( + { + ...options, + placeholderData: keepPreviousData, + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)), + queryKey: [{ url, contentType, method, body, reloadOnUnauthorize, retryAfter }], + staleTime, + gcTime, + + queryFn: async ({ pageParam }) => + queryFn({ url, contentType, method, body: { ...body, offset: pageParam }, reloadOnUnauthorize, retryAfter }) + }, + queryClient + ); + + console.log(query); + + return { ...query }; + // return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; +}; diff --git a/src/lib/api/useMyMutation.tsx b/src/lib/api/useMyMutation.tsx index 2e2c5dfee..44d8e1c66 100644 --- a/src/lib/api/useMyMutation.tsx +++ b/src/lib/api/useMyMutation.tsx @@ -1,7 +1,7 @@ import type { UseMutationOptions } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { APIQueryKey, APIResponseProps } from './utils'; -import { DEFAULT_INVALIDATE_DELAY, DEFAULT_RETRY_MS, getAPIResponse, useAPICall } from './utils'; +import { DEFAULT_INVALIDATE_DELAY, DEFAULT_RETRY_MS, getAPIResponse, useDefaultQueryFn } from './utils'; interface Props extends Omit, 'mutationKey' | 'mutationFn'> { @@ -36,23 +36,21 @@ export const useMyMutation = ({ invalidateProps = { delay: null, filter: null }, queryDataProps = { filter: null, update: () => null }, ...options -}: Props, APIResponseProps, null, unknown, Body>) => { +}: Props, APIResponseProps, void, unknown, Body>) => { const queryClient = useQueryClient(); - const apiCall = useAPICall(); + const queryFn = useDefaultQueryFn(); - const mutation = useMutation, APIResponseProps, unknown>({ + const mutation = useMutation, APIResponseProps, void, unknown>({ ...options, mutationKey: [{ url, allowCache, method, contentType, body, reloadOnUnauthorize, retryAfter, enabled }], - mutationFn: async () => apiCall({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }), + mutationFn: async () => queryFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }), - onSuccess: async (data, variable: null, context) => { + onSuccess: async (data, variable, context) => { onSuccess(data, variable, context); if (queryDataProps?.filter && queryDataProps?.update) { queryClient.setQueriesData( - { - predicate: q => queryDataProps?.filter((JSON.parse(q.queryHash) as [APIQueryKey])[0]) - }, + { predicate: q => queryDataProps?.filter((JSON.parse(q.queryHash) as [APIQueryKey])[0]) }, queryDataProps.update ); } diff --git a/src/lib/api/useMyQuery.tsx b/src/lib/api/useMyQuery.tsx index 0dd07191b..45fb21de9 100644 --- a/src/lib/api/useMyQuery.tsx +++ b/src/lib/api/useMyQuery.tsx @@ -1,10 +1,15 @@ import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { APIResponseProps } from './utils'; -import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME, getAPIResponse, useAPICall } from './utils'; +import Throttler from 'commons/addons/utils/throttler'; +import { useEffect, useMemo, useState } from 'react'; +import type { APIQueryKey, APIResponseProps } from './utils'; +import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME, getAPIResponse, useDefaultQueryFn } from './utils'; interface Props - extends Omit, 'queryKey' | 'initialData'> { + extends Omit< + DefinedInitialDataOptions, + 'queryKey' | 'initialData' | 'enabled' + > { initialData?: null | TData; url: string; contentType?: string; @@ -13,6 +18,8 @@ interface Props({ @@ -24,26 +31,45 @@ export const useMyQuery = ({ retryAfter = DEFAULT_RETRY_MS, staleTime = DEFAULT_STALE_TIME, gcTime = DEFAULT_GC_TIME, + throttleTime = null, + enabled, ...options }: Props, APIResponseProps, APIResponseProps, QueryKey, Body>) => { const queryClient = useQueryClient(); - const apiCall = useAPICall(); + const queryFn = useDefaultQueryFn(); - // const key = useMemo(() => [url, allowCache, method, contentType, reloadOnUnauthorize, retryAfter, body], []); + const [queryKey, setQueryKey] = useState(null); + const [isThrottling, setIsThrottling] = useState(!!throttleTime); + + const throttler = useMemo(() => (!throttleTime ? null : new Throttler(throttleTime)), [throttleTime]); const query = useQuery, APIResponseProps, APIResponseProps>( { ...options, - placeholderData: keepPreviousData, - retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, - retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)), - queryKey: [{ url, contentType, method, body, reloadOnUnauthorize, retryAfter }], + queryKey: [queryKey], staleTime, gcTime, - queryFn: async () => apiCall({ url, contentType, method, body, reloadOnUnauthorize, retryAfter }) + enabled: enabled && !!queryKey && !isThrottling, + queryFn: async () => queryFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter }), + placeholderData: keepPreviousData, + 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) }; + useEffect(() => { + if (!throttler) { + setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); + } else { + setIsThrottling(true); + throttler.delay(() => { + setIsThrottling(false); + setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttler, url]); + + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason), isThrottling }; }; diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts index 37b9255f9..9e710e5bd 100644 --- a/src/lib/api/utils.ts +++ b/src/lib/api/utils.ts @@ -29,13 +29,14 @@ export type APIReturn = { error: string; }; -export type APIQueryKey = { +export type APIQueryKey = { url: string; contentType: string; method: string; body: Body; reloadOnUnauthorize: boolean; enabled: boolean; + [key: string]: unknown; }; export const isAPIData = (value: object): value is APIResponseProps => @@ -72,7 +73,7 @@ type ApiCallProps = { enabled?: boolean; }; -export const useAPICall = () => { +export const useDefaultQueryFn = () => { const { t } = useTranslation(); const { showErrorMessage, closeSnackbar } = useMySnackbar(); const { configuration: systemConfig } = useALContext(); From 5b82f89fb085485b3daedbf3c4e1c78480b40815 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Sun, 6 Oct 2024 15:54:50 +0000 Subject: [PATCH 04/21] Cleaned the code --- src/lib/api/constants.ts | 9 ++++ src/lib/api/models.ts | 33 +++++++++++++++ src/lib/api/useMyInfiniteQuery.tsx | 57 +++++++++++++++++-------- src/lib/api/useMyMutation.tsx | 14 +++--- src/lib/api/useMyQuery.tsx | 13 +++--- src/lib/api/utils.ts | 68 +++++------------------------- 6 files changed, 106 insertions(+), 88 deletions(-) diff --git a/src/lib/api/constants.ts b/src/lib/api/constants.ts index e69de29bb..fa30f5bf9 100644 --- a/src/lib/api/constants.ts +++ b/src/lib/api/constants.ts @@ -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; diff --git a/src/lib/api/models.ts b/src/lib/api/models.ts index e69de29bb..7c584109f 100644 --- a/src/lib/api/models.ts +++ b/src/lib/api/models.ts @@ -0,0 +1,33 @@ +export type APIResponse = { + api_error_message: string; + api_response: T; + api_server_version: string; + api_status_code: number; +}; + +export type APIReturn = { + statusCode: number; + serverVersion: string; + data: Response; + error: string; +}; + +export type APIQueryKey = { + url: string; + contentType: string; + method: string; + body: Body; + reloadOnUnauthorize: boolean; + enabled: boolean; + [key: string]: unknown; +}; + +export type ApiCallProps = { + url: string; + contentType?: string; + method?: string; + body?: Body; + reloadOnUnauthorize?: boolean; + retryAfter?: number; + enabled?: boolean; +}; diff --git a/src/lib/api/useMyInfiniteQuery.tsx b/src/lib/api/useMyInfiniteQuery.tsx index a38143f14..a760840cf 100644 --- a/src/lib/api/useMyInfiniteQuery.tsx +++ b/src/lib/api/useMyInfiniteQuery.tsx @@ -1,9 +1,10 @@ import type { DefinedInitialDataInfiniteOptions, InfiniteData, QueryKey } from '@tanstack/react-query'; import { keepPreviousData, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; -import type { APIResponseProps } from './utils'; -import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME, useDefaultQueryFn } from './utils'; - -// type DefinedInitialDataInfiniteOptions, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown> +import Throttler from 'commons/addons/utils/throttler'; +import { useEffect, useMemo, useState } from 'react'; +import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME } from './constants'; +import type { APIQueryKey, APIResponse } from './models'; +import { useApiCallFn } from './utils'; interface Props extends Omit< @@ -18,6 +19,8 @@ interface Props({ @@ -29,39 +32,57 @@ export const useMyInfiniteQuery = ({ retryAfter = DEFAULT_RETRY_MS, staleTime = DEFAULT_STALE_TIME, gcTime = DEFAULT_GC_TIME, + throttleTime = null, + enabled, ...options }: Props< - APIResponseProps, - APIResponseProps, - InfiniteData, unknown>, + APIResponse, + APIResponse, + InfiniteData, unknown>, QueryKey, unknown, Body >) => { const queryClient = useQueryClient(); - const queryFn = useDefaultQueryFn(); + const apiCallFn = useApiCallFn(); + + const [queryKey, setQueryKey] = useState(null); + const [isThrottling, setIsThrottling] = useState(!!throttleTime); + + const throttler = useMemo(() => (!throttleTime ? null : new Throttler(throttleTime)), [throttleTime]); const query = useInfiniteQuery< - APIResponseProps, - APIResponseProps, - InfiniteData, unknown> + APIResponse, + APIResponse, + InfiniteData, unknown> >( { ...options, - placeholderData: keepPreviousData, - retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, - retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)), - queryKey: [{ url, contentType, method, body, reloadOnUnauthorize, retryAfter }], + queryKey: [queryKey], staleTime, gcTime, - + enabled: enabled && !!queryKey && !isThrottling, queryFn: async ({ pageParam }) => - queryFn({ url, contentType, method, body: { ...body, offset: pageParam }, reloadOnUnauthorize, retryAfter }) + apiCallFn({ url, contentType, method, body: { ...body, offset: pageParam }, reloadOnUnauthorize, retryAfter }), + placeholderData: keepPreviousData, + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) }, queryClient ); - console.log(query); + useEffect(() => { + if (!throttler) { + setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); + } else { + setIsThrottling(true); + throttler.delay(() => { + setIsThrottling(false); + setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttler, url]); return { ...query }; // return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; diff --git a/src/lib/api/useMyMutation.tsx b/src/lib/api/useMyMutation.tsx index 44d8e1c66..5adbdd716 100644 --- a/src/lib/api/useMyMutation.tsx +++ b/src/lib/api/useMyMutation.tsx @@ -1,7 +1,8 @@ import type { UseMutationOptions } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import type { APIQueryKey, APIResponseProps } from './utils'; -import { DEFAULT_INVALIDATE_DELAY, DEFAULT_RETRY_MS, getAPIResponse, useDefaultQueryFn } from './utils'; +import { DEFAULT_INVALIDATE_DELAY, DEFAULT_RETRY_MS } from './constants'; +import type { APIQueryKey, APIResponse } from './models'; +import { getAPIResponse, useApiCallFn } from './utils'; interface Props extends Omit, 'mutationKey' | 'mutationFn'> { @@ -36,15 +37,14 @@ export const useMyMutation = ({ invalidateProps = { delay: null, filter: null }, queryDataProps = { filter: null, update: () => null }, ...options -}: Props, APIResponseProps, void, unknown, Body>) => { +}: Props, APIResponse, void, unknown, Body>) => { const queryClient = useQueryClient(); - const queryFn = useDefaultQueryFn(); + const apiCallFn = useApiCallFn(); - const mutation = useMutation, APIResponseProps, void, unknown>({ + const mutation = useMutation, APIResponse, void, unknown>({ ...options, mutationKey: [{ url, allowCache, method, contentType, body, reloadOnUnauthorize, retryAfter, enabled }], - mutationFn: async () => queryFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }), - + mutationFn: async () => apiCallFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }), onSuccess: async (data, variable, context) => { onSuccess(data, variable, context); diff --git a/src/lib/api/useMyQuery.tsx b/src/lib/api/useMyQuery.tsx index 45fb21de9..9a3595831 100644 --- a/src/lib/api/useMyQuery.tsx +++ b/src/lib/api/useMyQuery.tsx @@ -2,8 +2,9 @@ import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query' import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; import Throttler from 'commons/addons/utils/throttler'; import { useEffect, useMemo, useState } from 'react'; -import type { APIQueryKey, APIResponseProps } from './utils'; -import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME, getAPIResponse, useDefaultQueryFn } from './utils'; +import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME } from './constants'; +import type { APIQueryKey, APIResponse } from './models'; +import { getAPIResponse, useApiCallFn } from './utils'; interface Props extends Omit< @@ -34,23 +35,23 @@ export const useMyQuery = ({ throttleTime = null, enabled, ...options -}: Props, APIResponseProps, APIResponseProps, QueryKey, Body>) => { +}: Props, APIResponse, APIResponse, QueryKey, Body>) => { const queryClient = useQueryClient(); - const queryFn = useDefaultQueryFn(); + const apiCallFn = useApiCallFn(); const [queryKey, setQueryKey] = useState(null); const [isThrottling, setIsThrottling] = useState(!!throttleTime); const throttler = useMemo(() => (!throttleTime ? null : new Throttler(throttleTime)), [throttleTime]); - const query = useQuery, APIResponseProps, APIResponseProps>( + const query = useQuery, APIResponse, APIResponse>( { ...options, queryKey: [queryKey], staleTime, gcTime, enabled: enabled && !!queryKey && !isThrottling, - queryFn: async () => queryFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter }), + queryFn: async () => apiCallFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter }), placeholderData: keepPreviousData, retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts index 9e710e5bd..06d8181b2 100644 --- a/src/lib/api/utils.ts +++ b/src/lib/api/utils.ts @@ -4,42 +4,10 @@ import useQuota from 'components/hooks/useQuota'; import getXSRFCookie from 'helpers/xsrf'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse, ApiCallProps } from './models'; -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; - -export type APIResponseProps = { - api_error_message: string; - api_response: T; - api_server_version: string; - api_status_code: number; -}; - -export type APIReturn = { - statusCode: number; - serverVersion: string; - data: Response; - error: string; -}; - -export type APIQueryKey = { - url: string; - contentType: string; - method: string; - body: Body; - reloadOnUnauthorize: boolean; - enabled: boolean; - [key: string]: unknown; -}; - -export const isAPIData = (value: object): value is APIResponseProps => +export const isAPIData = (value: object): value is APIResponse => value !== undefined && value !== null && 'api_response' in value && @@ -47,33 +15,19 @@ export const isAPIData = (value: object): value is APIResponseProps => 'api_server_version' in value && 'api_status_code' in value; -const getValue = >( +const getValue = >( key: K, - ...responses: APIResponseProps[] -): APIResponseProps[K] => responses?.find(r => !!r?.[key])?.[key] || null; - -export const getAPIResponse = ( - data: APIResponseProps, - error: APIResponseProps, - failureReason: APIResponseProps -) => ({ + ...responses: APIResponse[] +): APIResponse[K] => responses?.find(r => !!r?.[key])?.[key] || null; + +export const getAPIResponse = (data: APIResponse, error: APIResponse, failureReason: APIResponse) => ({ statusCode: getValue('api_status_code', data, error, failureReason), serverVersion: getValue('api_server_version', data, error, failureReason), data: getValue('api_response', data, error, failureReason) as R, error: getValue('api_error_message', data, error, failureReason) as E }); -type ApiCallProps = { - url: string; - contentType?: string; - method?: string; - body?: Body; - reloadOnUnauthorize?: boolean; - retryAfter?: number; - enabled?: boolean; -}; - -export const useDefaultQueryFn = () => { +export const useApiCallFn = () => { const { t } = useTranslation(); const { showErrorMessage, closeSnackbar } = useMySnackbar(); const { configuration: systemConfig } = useALContext(); @@ -95,7 +49,7 @@ export const useDefaultQueryFn = () => { // // Check the cache // const cachedURL = sessionStorage.getItem(url); // if (allowCache && cachedURL) { - // const apiData = JSON.parse(cachedURL) as APIResponseProps; + // const apiData = JSON.parse(cachedURL) as APIResponse; // return Promise.resolve(apiData); // } @@ -126,7 +80,7 @@ export const useDefaultQueryFn = () => { }); } - const json = (await res.json()) as APIResponseProps; + const json = (await res.json()) as APIResponse; // Check for an invalid json format if (!isAPIData(json)) { From b0c46da3ae19541af72ff4f3130291e5c8ea0af1 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Sun, 6 Oct 2024 16:52:07 +0000 Subject: [PATCH 05/21] More changes --- src/lib/api/models.ts | 20 +-- src/lib/api/useBootstrap.tsx | 76 +++++++++ src/lib/api/useDownloadBlob.tsx | 76 +++++++++ src/lib/api/utils.ts | 269 +++++++++++++++++++++++++++++--- 4 files changed, 408 insertions(+), 33 deletions(-) create mode 100644 src/lib/api/useBootstrap.tsx diff --git a/src/lib/api/models.ts b/src/lib/api/models.ts index 7c584109f..6588707cf 100644 --- a/src/lib/api/models.ts +++ b/src/lib/api/models.ts @@ -5,6 +5,16 @@ export type APIResponse = { 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 = { statusCode: number; serverVersion: string; @@ -21,13 +31,3 @@ export type APIQueryKey = { enabled: boolean; [key: string]: unknown; }; - -export type ApiCallProps = { - url: string; - contentType?: string; - method?: string; - body?: Body; - reloadOnUnauthorize?: boolean; - retryAfter?: number; - enabled?: boolean; -}; diff --git a/src/lib/api/useBootstrap.tsx b/src/lib/api/useBootstrap.tsx new file mode 100644 index 000000000..05a7dab5e --- /dev/null +++ b/src/lib/api/useBootstrap.tsx @@ -0,0 +1,76 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; +import Throttler from 'commons/addons/utils/throttler'; +import { useEffect, useMemo, useState } from 'react'; +import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME } from './constants'; +import type { APIQueryKey, APIResponse } from './models'; +import { getAPIResponse, useApiCallFn } from './utils'; + +interface Props + extends Omit< + DefinedInitialDataOptions, + 'queryKey' | 'initialData' | 'enabled' + > { + initialData?: null | TData; + url: string; + contentType?: string; + method?: string; + body?: Body; + reloadOnUnauthorize?: boolean; + retryAfter?: number; + disableClearData?: boolean; + throttleTime?: number; + enabled?: boolean; +} + +export const useBootstrap = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + staleTime = DEFAULT_STALE_TIME, + gcTime = DEFAULT_GC_TIME, + throttleTime = null, + enabled, + ...options +}: Props, APIResponse, APIResponse, QueryKey, Body>) => { + const queryClient = useQueryClient(); + const apiCallFn = useApiCallFn(); + + const [queryKey, setQueryKey] = useState(null); + const [isThrottling, setIsThrottling] = useState(!!throttleTime); + + const throttler = useMemo(() => (!throttleTime ? null : new Throttler(throttleTime)), [throttleTime]); + + const query = useQuery, APIResponse, APIResponse>( + { + ...options, + queryKey: [queryKey], + staleTime, + gcTime, + enabled: enabled && !!queryKey && !isThrottling, + queryFn: async () => apiCallFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter }), + placeholderData: keepPreviousData, + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) + }, + queryClient + ); + + useEffect(() => { + if (!throttler) { + setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); + } else { + setIsThrottling(true); + throttler.delay(() => { + setIsThrottling(false); + setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttler, url]); + + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason), isThrottling }; +}; diff --git a/src/lib/api/useDownloadBlob.tsx b/src/lib/api/useDownloadBlob.tsx index e69de29bb..b91bc4dfc 100644 --- a/src/lib/api/useDownloadBlob.tsx +++ b/src/lib/api/useDownloadBlob.tsx @@ -0,0 +1,76 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; +import Throttler from 'commons/addons/utils/throttler'; +import { useEffect, useMemo, useState } from 'react'; +import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME } from './constants'; +import type { APIQueryKey, APIResponse, BlobResponse } from './models'; +import { getAPIResponse, useDownloadBlobFn } from './utils'; + +interface Props + extends Omit< + DefinedInitialDataOptions, + 'queryKey' | 'initialData' | 'enabled' + > { + initialData?: null | TData; + url: string; + contentType?: string; + method?: string; + body?: Body; + reloadOnUnauthorize?: boolean; + retryAfter?: number; + disableClearData?: boolean; + throttleTime?: number; + enabled?: boolean; +} + +export const useMyQuery = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + staleTime = DEFAULT_STALE_TIME, + gcTime = DEFAULT_GC_TIME, + throttleTime = null, + enabled, + ...options +}: Props, BlobResponse, QueryKey>) => { + const queryClient = useQueryClient(); + const blobDownloadFn = useDownloadBlobFn(); + + const [queryKey, setQueryKey] = useState(null); + const [isThrottling, setIsThrottling] = useState(!!throttleTime); + + const throttler = useMemo(() => (!throttleTime ? null : new Throttler(throttleTime)), [throttleTime]); + + const query = useQuery, APIResponse>( + { + ...options, + queryKey: [queryKey], + staleTime, + gcTime, + enabled: enabled && !!queryKey && !isThrottling, + queryFn: async () => blobDownloadFn({ url, reloadOnUnauthorize, retryAfter }), + placeholderData: keepPreviousData, + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) + }, + queryClient + ); + + useEffect(() => { + if (!throttler) { + setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); + } else { + setIsThrottling(true); + throttler.delay(() => { + setIsThrottling(false); + setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttler, url]); + + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason), isThrottling }; +}; diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts index 06d8181b2..7b083e85d 100644 --- a/src/lib/api/utils.ts +++ b/src/lib/api/utils.ts @@ -1,11 +1,15 @@ import useALContext from 'components/hooks/useALContext'; +import type { LoginParamsProps } from 'components/hooks/useMyAPI'; import useMySnackbar from 'components/hooks/useMySnackbar'; import useQuota from 'components/hooks/useQuota'; +import type { Configuration } from 'components/models/base/config'; +import type { WhoAmIProps } from 'components/models/ui/user'; +import { getFileName } from 'helpers/utils'; import getXSRFCookie from 'helpers/xsrf'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { DEFAULT_RETRY_MS } from './constants'; -import type { APIResponse, ApiCallProps } from './models'; +import type { APIResponse, BlobResponse } from './models'; export const isAPIData = (value: object): value is APIResponse => value !== undefined && @@ -27,6 +31,138 @@ export const getAPIResponse = (data: APIResponse, error: APIResponse error: getValue('api_error_message', data, error, failureReason) as E }); +type BootstrapProps = { + switchRenderedApp: (value: string) => void; + setConfiguration: (cfg: Configuration) => void; + setLoginParams: (params: LoginParamsProps) => void; + setUser: (user: WhoAmIProps) => void; + setReady: (layout: boolean, borealis: boolean) => void; + retryAfter?: number; +}; + +export const useBootstrapFn = () => { + const { t } = useTranslation(); + const { showErrorMessage, closeSnackbar } = useMySnackbar(); + const { configuration: systemConfig } = useALContext(); + const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); + + return useCallback( + async ({ + switchRenderedApp, + setConfiguration, + setLoginParams, + setUser, + setReady, + retryAfter = DEFAULT_RETRY_MS + }: BootstrapProps) => { + // fetching the API's data + const res = await fetch('/api/v4/user/whoami/', { + method: 'GET', + credentials: 'same-origin', + headers: { 'X-XSRF-TOKEN': getXSRFCookie() } + }); + + // Setting the API quota + const apiQuota = res.headers.get('X-Remaining-Quota-Api'); + if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); + + // Setting the Submission quota + const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); + if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); + + // Handle an unreachable API + if (res.status === 502) { + showErrorMessage(t('api.unreachable'), 10000); + return Promise.reject({ + api_error_message: t('api.unreachable'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 502 + }); + } + + const json = (await res.json()) as APIResponse; + + // Check for an invalid json format + if (!isAPIData(json)) { + showErrorMessage(t('api.invalid'), 30000); + switchRenderedApp('load'); + return Promise.reject({ + api_error_message: t('api.invalid'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 400 + }); + } + + const { api_error_message: error } = json; + + // Forbiden response indicate that the user's account is locked. + if (res.status === 403) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + setConfiguration(json.api_response as Configuration); + switchRenderedApp('locked'); + return Promise.reject(json); + } + + // Unauthorized response indicate that the user is not logged in. + if (res.status === 401) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + localStorage.setItem('loginParams', JSON.stringify(json.api_response)); + sessionStorage.clear(); + setLoginParams(json.api_response as LoginParamsProps); + switchRenderedApp('login'); + return Promise.reject(json); + } + + // Daily quota error, stop everything! + if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + switchRenderedApp('quota'); + return Promise.reject(json); + } + + // Handle all non-successful request + if (res.status !== 200) { + showErrorMessage(json.api_error_message); + return Promise.reject(json); + } + + if (res.status === 200) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + + const user = json.api_response as WhoAmIProps; + + // Set the current user + setUser(user); + + // Mark the interface ready + setReady(true, user.configuration.ui.api_proxies.includes('borealis')); + + // Render appropriate page + if (!user.agrees_with_tos && user.configuration.ui.tos) { + switchRenderedApp('tos'); + } else { + switchRenderedApp('routes'); + } + + return Promise.resolve(json); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [setApiQuotaremaining, setSubmissionQuotaremaining, systemConfig.system.version, t] + ); +}; + +type ApiCallProps = { + url: string; + contentType?: string; + method?: string; + body?: Body; + reloadOnUnauthorize?: boolean; + retryAfter?: number; + enabled?: boolean; +}; + export const useApiCallFn = () => { const { t } = useTranslation(); const { showErrorMessage, closeSnackbar } = useMySnackbar(); @@ -46,13 +182,6 @@ export const useApiCallFn = () => { // Reject if the query is not enabled if (!enabled) return Promise.reject(null); - // // Check the cache - // const cachedURL = sessionStorage.getItem(url); - // if (allowCache && cachedURL) { - // const apiData = JSON.parse(cachedURL) as APIResponse; - // return Promise.resolve(apiData); - // } - // fetching the API's data const res = await fetch(url, { method, @@ -93,9 +222,10 @@ export const useApiCallFn = () => { }); } - const { api_status_code: statusCode, api_error_message: error } = json; + const statusCode = res.status; + const { api_error_message: error } = json; - // // Reload when the user has exceeded their daily API call quota. + // Reload when the user has exceeded their daily API call quota. if (statusCode === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { window.location.reload(); return Promise.reject(json); @@ -127,22 +257,115 @@ export const useApiCallFn = () => { return Promise.reject(json); } - // // Cache success status - // if (allowCache) { - // try { - // sessionStorage.setItem(url, JSON.stringify(json)); - // } catch (error) { - // // We could not store into the Session Storage, this means that it is full - // // Let's delete the oldest quarter of items to free up some space - // [...Array(Math.floor(sessionStorage.length / 4))].forEach(_ => { - // sessionStorage.removeItem(sessionStorage.key(0)); - // }); - // } - // } - return Promise.resolve(json); }, // eslint-disable-next-line react-hooks/exhaustive-deps [setApiQuotaremaining, setSubmissionQuotaremaining, systemConfig.system.version, t] ); }; + +type DownloadBlobProps = { + url: string; + reloadOnUnauthorize?: boolean; + retryAfter?: number; + enabled?: boolean; +}; + +export const useDownloadBlobFn = () => { + const { t } = useTranslation(); + const { showErrorMessage, closeSnackbar } = useMySnackbar(); + const { configuration: systemConfig } = useALContext(); + const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); + + return useCallback( + async ({ url, enabled, reloadOnUnauthorize, retryAfter }: DownloadBlobProps) => { + // Reject if the query is not enabled + if (!enabled) return Promise.reject(null); + + // fetching the API's data + const res = await fetch(url, { + method: 'GET', + credentials: 'same-origin', + headers: { 'X-XSRF-TOKEN': getXSRFCookie() } + }); + + // Setting the API quota + const apiQuota = res.headers.get('X-Remaining-Quota-Api'); + if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); + + // Setting the Submission quota + const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); + if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); + + // Handle an unreachable API + if (res.status === 502) { + showErrorMessage(t('api.unreachable'), 10000); + return Promise.reject({ + api_error_message: t('api.unreachable'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 502 + }); + } + + const json = (await res.json()) as APIResponse; + + // Check for an invalid json format + if (!isAPIData(json)) { + showErrorMessage(t('api.invalid')); + return Promise.reject({ + api_error_message: t('api.invalid'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 400 + }); + } + + const { api_status_code: statusCode, api_error_message: error } = json; + + // Reload when the user has exceeded their daily API call quota. + if (statusCode === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if the user has exceeded their daily submissions quota. + if (statusCode === 503 && ['quota', 'submission'].every(v => error.includes(v))) { + return Promise.reject(json); + } + + // Reload when the user is not logged in + if (statusCode === 401 && reloadOnUnauthorize) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if API Server is unavailable and should attempt to retry + if (statusCode === 502) { + showErrorMessage(json.api_error_message, 30000); + return Promise.reject(json); + } + + // Handle successful request + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + + // Handle all non-successful request + if (statusCode !== 200) { + showErrorMessage(json.api_error_message); + return Promise.reject(json); + } + + return Promise.resolve({ + api_error_message: '', + api_response: res.body, + api_server_version: systemConfig.system.version, + api_status_code: res.status, + filename: getFileName(res.headers.get('Content-Disposition')), + size: parseInt(res.headers.get('Content-Length')), + type: res.headers.get('Content-Type') + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [setApiQuotaremaining, setSubmissionQuotaremaining, systemConfig.system.version, t] + ); +}; From 3dfb8c46c4a6792d0d742c05184d48f03c611805 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Tue, 8 Oct 2024 01:16:08 +0000 Subject: [PATCH 06/21] Changes --- src/lib/api/APIProvider.tsx | 21 +++++++++- src/lib/api/useDownloadBlob.tsx | 38 ++++-------------- src/lib/api/useMyInfiniteQuery.tsx | 8 ++-- src/lib/api/useMyQuery.tsx | 35 +++++++--------- src/lib/api/useThrottledState.tsx | 31 +++++++++++++++ src/lib/api/utils.ts | 64 ++++++++++++++++++------------ 6 files changed, 115 insertions(+), 82 deletions(-) create mode 100644 src/lib/api/useThrottledState.tsx diff --git a/src/lib/api/APIProvider.tsx b/src/lib/api/APIProvider.tsx index 162c63a0b..c33294d4b 100644 --- a/src/lib/api/APIProvider.tsx +++ b/src/lib/api/APIProvider.tsx @@ -4,8 +4,15 @@ 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 type { APIQueryKey } from './models'; -export const queryClient = new QueryClient(); +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false + } + } +}); type Props = { children: React.ReactNode; @@ -13,7 +20,17 @@ type Props = { const persister = createSyncStoragePersister({ storage: window.sessionStorage, - serialize: data => compress(JSON.stringify(data)), + 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)) }); diff --git a/src/lib/api/useDownloadBlob.tsx b/src/lib/api/useDownloadBlob.tsx index b91bc4dfc..962a40f0e 100644 --- a/src/lib/api/useDownloadBlob.tsx +++ b/src/lib/api/useDownloadBlob.tsx @@ -1,10 +1,8 @@ import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; -import Throttler from 'commons/addons/utils/throttler'; -import { useEffect, useMemo, useState } from 'react'; import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME } from './constants'; -import type { APIQueryKey, APIResponse, BlobResponse } from './models'; -import { getAPIResponse, useDownloadBlobFn } from './utils'; +import type { APIResponse, BlobResponse } from './models'; +import { getBlobResponse, useDownloadBlobFn } from './utils'; interface Props extends Omit< @@ -21,36 +19,27 @@ interface Props disableClearData?: boolean; throttleTime?: number; enabled?: boolean; + allowCache?: boolean; } export const useMyQuery = ({ url, - contentType = 'application/json', - method = 'GET', - body = null, reloadOnUnauthorize = true, retryAfter = DEFAULT_RETRY_MS, staleTime = DEFAULT_STALE_TIME, gcTime = DEFAULT_GC_TIME, - throttleTime = null, - enabled, + allowCache = false, ...options }: Props, BlobResponse, QueryKey>) => { const queryClient = useQueryClient(); const blobDownloadFn = useDownloadBlobFn(); - const [queryKey, setQueryKey] = useState(null); - const [isThrottling, setIsThrottling] = useState(!!throttleTime); - - const throttler = useMemo(() => (!throttleTime ? null : new Throttler(throttleTime)), [throttleTime]); - - const query = useQuery, APIResponse>( + const query = useQuery, BlobResponse>( { ...options, - queryKey: [queryKey], + queryKey: [{ url, reloadOnUnauthorize, retryAfter, allowCache }], staleTime, gcTime, - enabled: enabled && !!queryKey && !isThrottling, queryFn: async () => blobDownloadFn({ url, reloadOnUnauthorize, retryAfter }), placeholderData: keepPreviousData, retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, @@ -59,18 +48,5 @@ export const useMyQuery = ({ queryClient ); - useEffect(() => { - if (!throttler) { - setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); - } else { - setIsThrottling(true); - throttler.delay(() => { - setIsThrottling(false); - setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttler, url]); - - return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason), isThrottling }; + return { ...query, ...getBlobResponse(query?.data, query?.error, query?.failureReason) }; }; diff --git a/src/lib/api/useMyInfiniteQuery.tsx b/src/lib/api/useMyInfiniteQuery.tsx index a760840cf..745364f1f 100644 --- a/src/lib/api/useMyInfiniteQuery.tsx +++ b/src/lib/api/useMyInfiniteQuery.tsx @@ -21,6 +21,7 @@ interface Props({ @@ -34,6 +35,7 @@ export const useMyInfiniteQuery = ({ gcTime = DEFAULT_GC_TIME, throttleTime = null, enabled, + allowCache = false, ...options }: Props< APIResponse, @@ -73,16 +75,16 @@ export const useMyInfiniteQuery = ({ useEffect(() => { if (!throttler) { - setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); + setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled, allowCache }); } else { setIsThrottling(true); throttler.delay(() => { setIsThrottling(false); - setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); + setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled, allowCache }); }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttler, url]); + }, [JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttler, url, allowCache]); return { ...query }; // return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; diff --git a/src/lib/api/useMyQuery.tsx b/src/lib/api/useMyQuery.tsx index 9a3595831..83438b937 100644 --- a/src/lib/api/useMyQuery.tsx +++ b/src/lib/api/useMyQuery.tsx @@ -1,9 +1,9 @@ import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; -import Throttler from 'commons/addons/utils/throttler'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME } from './constants'; import type { APIQueryKey, APIResponse } from './models'; +import { useThrottledState } from './useThrottledState'; import { getAPIResponse, useApiCallFn } from './utils'; interface Props @@ -21,6 +21,7 @@ interface Props({ @@ -34,24 +35,29 @@ export const useMyQuery = ({ gcTime = DEFAULT_GC_TIME, throttleTime = null, enabled, + allowCache = false, ...options }: Props, APIResponse, APIResponse, QueryKey, Body>) => { const queryClient = useQueryClient(); const apiCallFn = useApiCallFn(); - const [queryKey, setQueryKey] = useState(null); - const [isThrottling, setIsThrottling] = useState(!!throttleTime); + const queryKey = useMemo( + () => ({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled, allowCache }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [allowCache, JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, url] + ); - const throttler = useMemo(() => (!throttleTime ? null : new Throttler(throttleTime)), [throttleTime]); + const [throttledKey, isThrottling] = useThrottledState(queryKey, throttleTime); const query = useQuery, APIResponse, APIResponse>( { ...options, - queryKey: [queryKey], + queryKey: [throttledKey], staleTime, gcTime, - enabled: enabled && !!queryKey && !isThrottling, - queryFn: async () => apiCallFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter }), + enabled: enabled && !!throttledKey && !isThrottling, + queryFn: async ({ signal }) => + apiCallFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, signal }), placeholderData: keepPreviousData, retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) @@ -59,18 +65,5 @@ export const useMyQuery = ({ queryClient ); - useEffect(() => { - if (!throttler) { - setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); - } else { - setIsThrottling(true); - throttler.delay(() => { - setIsThrottling(false); - setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttler, url]); - return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason), isThrottling }; }; diff --git a/src/lib/api/useThrottledState.tsx b/src/lib/api/useThrottledState.tsx new file mode 100644 index 000000000..ba5e6a6ea --- /dev/null +++ b/src/lib/api/useThrottledState.tsx @@ -0,0 +1,31 @@ +import Throttler from 'commons/addons/utils/throttler'; +import { useEffect, useMemo, useState } from 'react'; + +export const useThrottledState = (state: T, time: number = null, initialState: T = null) => { + const [value, setValue] = useState(initialState); + const [isThrottling, setIsThrottling] = useState(true); + + const throttler = useMemo(() => (!time ? null : new Throttler(time)), [time]); + + useEffect(() => { + let ignore = false; + + if (!time) { + if (ignore) return; + setValue(state); + setIsThrottling(false); + } else { + setIsThrottling(true); + throttler.delay(() => { + if (ignore) return; + setIsThrottling(false); + setValue(state); + }); + } + return () => { + ignore = true; + }; + }, [state, throttler, time]); + + return [value, isThrottling]; +}; diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts index 7b083e85d..42b2c1533 100644 --- a/src/lib/api/utils.ts +++ b/src/lib/api/utils.ts @@ -19,18 +19,25 @@ export const isAPIData = (value: object): value is APIResponse => 'api_server_version' in value && 'api_status_code' in value; -const getValue = >( - key: K, - ...responses: APIResponse[] -): APIResponse[K] => responses?.find(r => !!r?.[key])?.[key] || null; +const getValue = (key, ...responses) => responses?.find(r => !!r?.[key])?.[key] || null; export const getAPIResponse = (data: APIResponse, error: APIResponse, failureReason: APIResponse) => ({ - statusCode: getValue('api_status_code', data, error, failureReason), - serverVersion: getValue('api_server_version', data, error, failureReason), + statusCode: getValue('api_status_code', data, error, failureReason) as number, + serverVersion: getValue('api_server_version', data, error, failureReason) as string, data: getValue('api_response', data, error, failureReason) as R, error: getValue('api_error_message', data, error, failureReason) as E }); +export const getBlobResponse = (data: BlobResponse, error: APIResponse, failureReason: APIResponse) => ({ + statusCode: getValue('api_status_code', data, error, failureReason) as number, + serverVersion: getValue('api_server_version', data, error, failureReason) as string, + data: getValue('api_response', data, error, failureReason) as R, + error: getValue('api_error_message', data, error, failureReason) as E, + filename: getValue('filename', data, error, failureReason) as string, + size: getValue('size', data, error, failureReason) as number, + type: getValue('type', data, error, failureReason) as string +}); + type BootstrapProps = { switchRenderedApp: (value: string) => void; setConfiguration: (cfg: Configuration) => void; @@ -38,6 +45,7 @@ type BootstrapProps = { setUser: (user: WhoAmIProps) => void; setReady: (layout: boolean, borealis: boolean) => void; retryAfter?: number; + signal?: AbortSignal; }; export const useBootstrapFn = () => { @@ -53,13 +61,15 @@ export const useBootstrapFn = () => { setLoginParams, setUser, setReady, - retryAfter = DEFAULT_RETRY_MS + retryAfter = DEFAULT_RETRY_MS, + signal = null }: BootstrapProps) => { // fetching the API's data const res = await fetch('/api/v4/user/whoami/', { method: 'GET', credentials: 'same-origin', - headers: { 'X-XSRF-TOKEN': getXSRFCookie() } + headers: { 'X-XSRF-TOKEN': getXSRFCookie() }, + signal }); // Setting the API quota @@ -161,6 +171,7 @@ type ApiCallProps = { reloadOnUnauthorize?: boolean; retryAfter?: number; enabled?: boolean; + signal?: AbortSignal; }; export const useApiCallFn = () => { @@ -177,7 +188,8 @@ export const useApiCallFn = () => { body = null, reloadOnUnauthorize = true, retryAfter = DEFAULT_RETRY_MS, - enabled = true + enabled = true, + signal = null }: ApiCallProps) => { // Reject if the query is not enabled if (!enabled) return Promise.reject(null); @@ -187,7 +199,8 @@ export const useApiCallFn = () => { method, credentials: 'same-origin', headers: { 'Content-Type': contentType, 'X-XSRF-TOKEN': getXSRFCookie() }, - body: (!body ? null : contentType === 'application/json' ? JSON.stringify(body) : body) as BodyInit + body: (!body ? null : contentType === 'application/json' ? JSON.stringify(body) : body) as BodyInit, + signal }); // Setting the API quota @@ -222,28 +235,27 @@ export const useApiCallFn = () => { }); } - const statusCode = res.status; const { api_error_message: error } = json; // Reload when the user has exceeded their daily API call quota. - if (statusCode === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { window.location.reload(); return Promise.reject(json); } // Reject if the user has exceeded their daily submissions quota. - if (statusCode === 503 && ['quota', 'submission'].every(v => error.includes(v))) { + if (res.status === 503 && ['quota', 'submission'].every(v => error.includes(v))) { return Promise.reject(json); } // Reload when the user is not logged in - if (statusCode === 401 && reloadOnUnauthorize) { + if (res.status === 401 && reloadOnUnauthorize) { window.location.reload(); return Promise.reject(json); } // Reject if API Server is unavailable and should attempt to retry - if (statusCode === 502) { + if (res.status === 502) { showErrorMessage(json.api_error_message, 30000); return Promise.reject(json); } @@ -252,7 +264,7 @@ export const useApiCallFn = () => { if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); // Handle all non-successful request - if (statusCode !== 200) { + if (res.status !== 200) { showErrorMessage(json.api_error_message); return Promise.reject(json); } @@ -269,16 +281,17 @@ type DownloadBlobProps = { reloadOnUnauthorize?: boolean; retryAfter?: number; enabled?: boolean; + signal?: AbortSignal; }; -export const useDownloadBlobFn = () => { +export const useDownloadBlobFn = () => { const { t } = useTranslation(); const { showErrorMessage, closeSnackbar } = useMySnackbar(); const { configuration: systemConfig } = useALContext(); const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); return useCallback( - async ({ url, enabled, reloadOnUnauthorize, retryAfter }: DownloadBlobProps) => { + async ({ url, enabled, reloadOnUnauthorize, retryAfter, signal = null }: DownloadBlobProps) => { // Reject if the query is not enabled if (!enabled) return Promise.reject(null); @@ -286,7 +299,8 @@ export const useDownloadBlobFn = () => { const res = await fetch(url, { method: 'GET', credentials: 'same-origin', - headers: { 'X-XSRF-TOKEN': getXSRFCookie() } + headers: { 'X-XSRF-TOKEN': getXSRFCookie() }, + signal }); // Setting the API quota @@ -321,27 +335,27 @@ export const useDownloadBlobFn = () => { }); } - const { api_status_code: statusCode, api_error_message: error } = json; + const { api_error_message: error } = json; // Reload when the user has exceeded their daily API call quota. - if (statusCode === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { window.location.reload(); return Promise.reject(json); } // Reject if the user has exceeded their daily submissions quota. - if (statusCode === 503 && ['quota', 'submission'].every(v => error.includes(v))) { + if (res.status === 503 && ['quota', 'submission'].every(v => error.includes(v))) { return Promise.reject(json); } // Reload when the user is not logged in - if (statusCode === 401 && reloadOnUnauthorize) { + if (res.status === 401 && reloadOnUnauthorize) { window.location.reload(); return Promise.reject(json); } // Reject if API Server is unavailable and should attempt to retry - if (statusCode === 502) { + if (res.status === 502) { showErrorMessage(json.api_error_message, 30000); return Promise.reject(json); } @@ -350,7 +364,7 @@ export const useDownloadBlobFn = () => { if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); // Handle all non-successful request - if (statusCode !== 200) { + if (res.status !== 200) { showErrorMessage(json.api_error_message); return Promise.reject(json); } From 9cc6e6277985f92162ed48a6c63cb99553169362 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Tue, 8 Oct 2024 12:35:53 +0000 Subject: [PATCH 07/21] Changes --- src/lib/api/APIProvider.tsx | 8 +- src/lib/api/useAPIBootstrap.tsx | 0 ...initeQuery.tsx => useApiInfiniteQuery.tsx} | 2 +- .../{useMyMutation.tsx => useApiMutation.tsx} | 26 +- src/lib/api/useApiQuery.tsx | 52 ++++ src/lib/api/useBootstrap.tsx | 214 ++++++++++----- src/lib/api/useDownloadBlob.tsx | 150 +++++++++-- src/lib/api/useMyQuery.tsx | 69 ----- src/lib/api/useThrottledState.tsx | 10 +- src/lib/api/utils.ts | 243 +----------------- 10 files changed, 362 insertions(+), 412 deletions(-) delete mode 100644 src/lib/api/useAPIBootstrap.tsx rename src/lib/api/{useMyInfiniteQuery.tsx => useApiInfiniteQuery.tsx} (97%) rename src/lib/api/{useMyMutation.tsx => useApiMutation.tsx} (74%) create mode 100644 src/lib/api/useApiQuery.tsx delete mode 100644 src/lib/api/useMyQuery.tsx diff --git a/src/lib/api/APIProvider.tsx b/src/lib/api/APIProvider.tsx index c33294d4b..9207b82de 100644 --- a/src/lib/api/APIProvider.tsx +++ b/src/lib/api/APIProvider.tsx @@ -1,15 +1,19 @@ import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; -import { QueryClient } from '@tanstack/react-query'; +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 + refetchOnWindowFocus: false, + staleTime: DEFAULT_STALE_TIME, + gcTime: DEFAULT_GC_TIME, + placeholderData: keepPreviousData } } }); diff --git a/src/lib/api/useAPIBootstrap.tsx b/src/lib/api/useAPIBootstrap.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/lib/api/useMyInfiniteQuery.tsx b/src/lib/api/useApiInfiniteQuery.tsx similarity index 97% rename from src/lib/api/useMyInfiniteQuery.tsx rename to src/lib/api/useApiInfiniteQuery.tsx index 745364f1f..9ba5d11a4 100644 --- a/src/lib/api/useMyInfiniteQuery.tsx +++ b/src/lib/api/useApiInfiniteQuery.tsx @@ -24,7 +24,7 @@ interface Props({ +export const useApiInfiniteQuery = ({ url, contentType = 'application/json', method = 'GET', diff --git a/src/lib/api/useMyMutation.tsx b/src/lib/api/useApiMutation.tsx similarity index 74% rename from src/lib/api/useMyMutation.tsx rename to src/lib/api/useApiMutation.tsx index 5adbdd716..ce1b1b6df 100644 --- a/src/lib/api/useMyMutation.tsx +++ b/src/lib/api/useApiMutation.tsx @@ -4,8 +4,10 @@ import { DEFAULT_INVALIDATE_DELAY, DEFAULT_RETRY_MS } from './constants'; import type { APIQueryKey, APIResponse } from './models'; import { getAPIResponse, useApiCallFn } from './utils'; -interface Props - extends Omit, 'mutationKey' | 'mutationFn'> { +type Props = Omit< + UseMutationOptions, APIResponse, TVariables, TContext>, + 'mutationKey' | 'mutationFn' +> & { url: string; contentType?: string; method?: string; @@ -20,11 +22,16 @@ interface Props }; queryDataProps?: { filter: (key: APIQueryKey) => boolean; - update: (old: TData) => TData; + update: (old: TData, response: TData) => TData; }; -} +}; + +// export const test = (({}) => void, ...options) => { + +// return null +// } -export const useMyMutation = ({ +export const useApiMutation = ({ url, contentType = 'application/json', method = 'GET', @@ -35,9 +42,9 @@ export const useMyMutation = ({ enabled = true, onSuccess = () => null, invalidateProps = { delay: null, filter: null }, - queryDataProps = { filter: null, update: () => null }, + queryDataProps = { filter: null, update: null }, ...options -}: Props, APIResponse, void, unknown, Body>) => { +}: Props) => { const queryClient = useQueryClient(); const apiCallFn = useApiCallFn(); @@ -49,9 +56,10 @@ export const useMyMutation = ({ onSuccess(data, variable, context); if (queryDataProps?.filter && queryDataProps?.update) { - queryClient.setQueriesData( + queryClient.setQueriesData>( { predicate: q => queryDataProps?.filter((JSON.parse(q.queryHash) as [APIQueryKey])[0]) }, - queryDataProps.update + prev => + !queryDataProps.update ? prev : { ...prev, api_response: queryDataProps.update(prev?.api_response, data) } ); } diff --git a/src/lib/api/useApiQuery.tsx b/src/lib/api/useApiQuery.tsx new file mode 100644 index 000000000..1ed6fdeec --- /dev/null +++ b/src/lib/api/useApiQuery.tsx @@ -0,0 +1,52 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { useThrottledState } from './useThrottledState'; +import type { ApiCallProps } from './utils'; +import { getAPIResponse, useApiCallFn } from './utils'; + +type Props = Omit< + DefinedInitialDataOptions, + 'queryKey' | 'initialData' | 'enabled' +> & + ApiCallProps; + +export const useApiQuery = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + allowCache = false, + enabled = true, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + throttleTime = null, + ...options +}: Props, APIResponse, APIResponse, QueryKey, Body>) => { + const queryClient = useQueryClient(); + const apiCallFn = useApiCallFn(); + + const queryKey = useMemo>( + () => ({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, throttleTime }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [allowCache, JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttleTime, url] + ); + + const [throttledKey, isThrottling] = useThrottledState(queryKey, throttleTime); + + const query = useQuery, APIResponse, APIResponse>( + { + ...options, + queryKey: [throttledKey], + enabled: Boolean(enabled) && !!throttledKey && !isThrottling, + queryFn: async ({ signal }) => apiCallFn({ signal, ...throttledKey }), + 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), isThrottling }; +}; diff --git a/src/lib/api/useBootstrap.tsx b/src/lib/api/useBootstrap.tsx index 05a7dab5e..97ddc056f 100644 --- a/src/lib/api/useBootstrap.tsx +++ b/src/lib/api/useBootstrap.tsx @@ -1,76 +1,166 @@ import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; -import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; -import Throttler from 'commons/addons/utils/throttler'; -import { useEffect, useMemo, useState } from 'react'; -import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME } from './constants'; -import type { APIQueryKey, APIResponse } from './models'; -import { getAPIResponse, useApiCallFn } from './utils'; - -interface Props - extends Omit< - DefinedInitialDataOptions, - 'queryKey' | 'initialData' | 'enabled' - > { - initialData?: null | TData; - url: string; - contentType?: string; - method?: string; - body?: Body; - reloadOnUnauthorize?: boolean; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import useALContext from 'components/hooks/useALContext'; +import type { LoginParamsProps } from 'components/hooks/useMyAPI'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import useQuota from 'components/hooks/useQuota'; +import type { Configuration } from 'components/models/base/config'; +import type { WhoAmIProps } from 'components/models/ui/user'; +import getXSRFCookie from 'helpers/xsrf'; +import { useTranslation } from 'react-i18next'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { getAPIResponse, isAPIData } from './utils'; + +type Props = Omit< + DefinedInitialDataOptions, + 'queryKey' | 'initialData' | 'enabled' +> & { + switchRenderedApp: (value: string) => void; + setConfiguration: (cfg: Configuration) => void; + setLoginParams: (params: LoginParamsProps) => void; + setUser: (user: WhoAmIProps) => void; + setReady: (layout: boolean, borealis: boolean) => void; retryAfter?: number; - disableClearData?: boolean; - throttleTime?: number; - enabled?: boolean; -} - -export const useBootstrap = ({ - url, - contentType = 'application/json', - method = 'GET', - body = null, - reloadOnUnauthorize = true, +}; + +export const useBootstrap = ({ + switchRenderedApp, + setConfiguration, + setLoginParams, + setUser, + setReady, retryAfter = DEFAULT_RETRY_MS, - staleTime = DEFAULT_STALE_TIME, - gcTime = DEFAULT_GC_TIME, - throttleTime = null, - enabled, ...options -}: Props, APIResponse, APIResponse, QueryKey, Body>) => { - const queryClient = useQueryClient(); - const apiCallFn = useApiCallFn(); - - const [queryKey, setQueryKey] = useState(null); - const [isThrottling, setIsThrottling] = useState(!!throttleTime); +}: Props< + APIResponse, + APIResponse, + APIResponse, + QueryKey +>) => { + const { t } = useTranslation(); + const { showErrorMessage, closeSnackbar } = useMySnackbar(); + const { configuration: systemConfig } = useALContext(); + const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); - const throttler = useMemo(() => (!throttleTime ? null : new Throttler(throttleTime)), [throttleTime]); + const queryClient = useQueryClient(); - const query = useQuery, APIResponse, APIResponse>( + const query = useQuery< + APIResponse, + APIResponse, + APIResponse + >( { ...options, - queryKey: [queryKey], - staleTime, - gcTime, - enabled: enabled && !!queryKey && !isThrottling, - queryFn: async () => apiCallFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter }), - placeholderData: keepPreviousData, + queryKey: [ + { + url: '/api/v4/user/whoami/', + contentType: 'application/json', + method: 'GET', + allowCache: false, + retryAfter + } + ], retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, - retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)), + queryFn: async ({ signal }) => { + // fetching the API's data + const res = await fetch('/api/v4/user/whoami/', { + method: 'GET', + credentials: 'same-origin', + headers: { 'X-XSRF-TOKEN': getXSRFCookie() }, + signal + }); + + // Setting the API quota + const apiQuota = res.headers.get('X-Remaining-Quota-Api'); + if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); + + // Setting the Submission quota + const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); + if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); + + // Handle an unreachable API + if (res.status === 502) { + showErrorMessage(t('api.unreachable'), 10000); + return Promise.reject({ + api_error_message: t('api.unreachable'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 502 + }); + } + + const json = (await res.json()) as APIResponse; + + // Check for an invalid json format + if (!isAPIData(json)) { + showErrorMessage(t('api.invalid'), 30000); + switchRenderedApp('load'); + return Promise.reject({ + api_error_message: t('api.invalid'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 400 + }); + } + + const { api_error_message: error } = json; + + // Forbiden response indicate that the user's account is locked. + if (res.status === 403) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + setConfiguration(json.api_response as Configuration); + switchRenderedApp('locked'); + return Promise.reject(json); + } + + // Unauthorized response indicate that the user is not logged in. + if (res.status === 401) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + localStorage.setItem('loginParams', JSON.stringify(json.api_response)); + sessionStorage.clear(); + setLoginParams(json.api_response as LoginParamsProps); + switchRenderedApp('login'); + return Promise.reject(json); + } + + // Daily quota error, stop everything! + if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + switchRenderedApp('quota'); + return Promise.reject(json); + } + + // Handle all non-successful request + if (res.status !== 200) { + showErrorMessage(json.api_error_message); + return Promise.reject(json); + } + + if (res.status === 200) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + + const user = json.api_response as WhoAmIProps; + + // Set the current user + setUser(user); + + // Mark the interface ready + setReady(true, user.configuration.ui.api_proxies.includes('borealis')); + + // Render appropriate page + if (!user.agrees_with_tos && user.configuration.ui.tos) { + switchRenderedApp('tos'); + } else { + switchRenderedApp('routes'); + } + + return Promise.resolve(json); + } + } }, queryClient ); - useEffect(() => { - if (!throttler) { - setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); - } else { - setIsThrottling(true); - throttler.delay(() => { - setIsThrottling(false); - setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttler, url]); - - return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason), isThrottling }; + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; }; diff --git a/src/lib/api/useDownloadBlob.tsx b/src/lib/api/useDownloadBlob.tsx index 962a40f0e..6a46bad98 100644 --- a/src/lib/api/useDownloadBlob.tsx +++ b/src/lib/api/useDownloadBlob.tsx @@ -1,49 +1,145 @@ import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; -import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; -import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME } from './constants'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import useALContext from 'components/hooks/useALContext'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import useQuota from 'components/hooks/useQuota'; +import { getFileName } from 'helpers/utils'; +import getXSRFCookie from 'helpers/xsrf'; +import { useTranslation } from 'react-i18next'; +import { DEFAULT_RETRY_MS } from './constants'; import type { APIResponse, BlobResponse } from './models'; -import { getBlobResponse, useDownloadBlobFn } from './utils'; - -interface Props - extends Omit< - DefinedInitialDataOptions, - 'queryKey' | 'initialData' | 'enabled' - > { - initialData?: null | TData; +import { getBlobResponse, isAPIData } from './utils'; + +type Props = Omit< + DefinedInitialDataOptions, + 'queryKey' | 'initialData' | 'enabled' +> & { url: string; - contentType?: string; - method?: string; - body?: Body; + allowCache?: boolean; + enabled?: boolean; reloadOnUnauthorize?: boolean; retryAfter?: number; - disableClearData?: boolean; - throttleTime?: number; - enabled?: boolean; - allowCache?: boolean; -} +}; export const useMyQuery = ({ url, reloadOnUnauthorize = true, retryAfter = DEFAULT_RETRY_MS, - staleTime = DEFAULT_STALE_TIME, - gcTime = DEFAULT_GC_TIME, allowCache = false, + enabled, ...options }: Props, BlobResponse, QueryKey>) => { + const { t } = useTranslation(); + const { showErrorMessage, closeSnackbar } = useMySnackbar(); + const { configuration: systemConfig } = useALContext(); + const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); + const queryClient = useQueryClient(); - const blobDownloadFn = useDownloadBlobFn(); const query = useQuery, BlobResponse>( { ...options, - queryKey: [{ url, reloadOnUnauthorize, retryAfter, allowCache }], - staleTime, - gcTime, - queryFn: async () => blobDownloadFn({ url, reloadOnUnauthorize, retryAfter }), - placeholderData: keepPreviousData, + queryKey: [ + { + url, + method: 'GET', + allowCache, + enabled, + reloadOnUnauthorize, + retryAfter, + systemVersion: systemConfig.system.version + } + ], retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, - retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)), + queryFn: async ({ signal }) => { + // Reject if the query is not enabled + if (!enabled) return Promise.reject(null); + + // fetching the API's data + const res = await fetch(url, { + method: 'GET', + credentials: 'same-origin', + headers: { 'X-XSRF-TOKEN': getXSRFCookie() }, + signal + }); + + // Setting the API quota + const apiQuota = res.headers.get('X-Remaining-Quota-Api'); + if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); + + // Setting the Submission quota + const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); + if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); + + // Handle an unreachable API + if (res.status === 502) { + showErrorMessage(t('api.unreachable'), 10000); + return Promise.reject({ + api_error_message: t('api.unreachable'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 502 + }); + } + + const json = (await res.json()) as APIResponse; + + // Check for an invalid json format + if (!isAPIData(json)) { + showErrorMessage(t('api.invalid')); + return Promise.reject({ + api_error_message: t('api.invalid'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 400 + }); + } + + const { api_error_message: error } = json; + + // Reload when the user has exceeded their daily API call quota. + if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if the user has exceeded their daily submissions quota. + if (res.status === 503 && ['quota', 'submission'].every(v => error.includes(v))) { + return Promise.reject(json); + } + + // Reload when the user is not logged in + if (res.status === 401 && reloadOnUnauthorize) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if API Server is unavailable and should attempt to retry + if (res.status === 502) { + showErrorMessage(json.api_error_message, 30000); + return Promise.reject(json); + } + + // Handle successful request + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + + // Handle all non-successful request + if (res.status !== 200) { + showErrorMessage(json.api_error_message); + return Promise.reject(json); + } + + return Promise.resolve({ + api_error_message: '', + api_response: res.body, + api_server_version: systemConfig.system.version, + api_status_code: res.status, + filename: getFileName(res.headers.get('Content-Disposition')), + size: parseInt(res.headers.get('Content-Length')), + type: res.headers.get('Content-Type') + }); + } }, queryClient ); diff --git a/src/lib/api/useMyQuery.tsx b/src/lib/api/useMyQuery.tsx deleted file mode 100644 index 83438b937..000000000 --- a/src/lib/api/useMyQuery.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; -import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useMemo } from 'react'; -import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME } from './constants'; -import type { APIQueryKey, APIResponse } from './models'; -import { useThrottledState } from './useThrottledState'; -import { getAPIResponse, useApiCallFn } from './utils'; - -interface Props - extends Omit< - DefinedInitialDataOptions, - 'queryKey' | 'initialData' | 'enabled' - > { - initialData?: null | TData; - url: string; - contentType?: string; - method?: string; - body?: Body; - reloadOnUnauthorize?: boolean; - retryAfter?: number; - disableClearData?: boolean; - throttleTime?: number; - enabled?: boolean; - allowCache?: boolean; -} - -export const useMyQuery = ({ - url, - contentType = 'application/json', - method = 'GET', - body = null, - reloadOnUnauthorize = true, - retryAfter = DEFAULT_RETRY_MS, - staleTime = DEFAULT_STALE_TIME, - gcTime = DEFAULT_GC_TIME, - throttleTime = null, - enabled, - allowCache = false, - ...options -}: Props, APIResponse, APIResponse, QueryKey, Body>) => { - const queryClient = useQueryClient(); - const apiCallFn = useApiCallFn(); - - const queryKey = useMemo( - () => ({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled, allowCache }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [allowCache, JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, url] - ); - - const [throttledKey, isThrottling] = useThrottledState(queryKey, throttleTime); - - const query = useQuery, APIResponse, APIResponse>( - { - ...options, - queryKey: [throttledKey], - staleTime, - gcTime, - enabled: enabled && !!throttledKey && !isThrottling, - queryFn: async ({ signal }) => - apiCallFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, signal }), - placeholderData: keepPreviousData, - 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), isThrottling }; -}; diff --git a/src/lib/api/useThrottledState.tsx b/src/lib/api/useThrottledState.tsx index ba5e6a6ea..bc742aaa3 100644 --- a/src/lib/api/useThrottledState.tsx +++ b/src/lib/api/useThrottledState.tsx @@ -1,9 +1,13 @@ import Throttler from 'commons/addons/utils/throttler'; import { useEffect, useMemo, useState } from 'react'; -export const useThrottledState = (state: T, time: number = null, initialState: T = null) => { - const [value, setValue] = useState(initialState); - const [isThrottling, setIsThrottling] = useState(true); +export const useThrottledState = ( + state: T, + time: number = null, + initialState: T = null +): [T, boolean] => { + const [value, setValue] = useState(initialState); + const [isThrottling, setIsThrottling] = useState(true); const throttler = useMemo(() => (!time ? null : new Throttler(time)), [time]); diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts index 42b2c1533..fcd99e046 100644 --- a/src/lib/api/utils.ts +++ b/src/lib/api/utils.ts @@ -1,10 +1,6 @@ import useALContext from 'components/hooks/useALContext'; -import type { LoginParamsProps } from 'components/hooks/useMyAPI'; import useMySnackbar from 'components/hooks/useMySnackbar'; import useQuota from 'components/hooks/useQuota'; -import type { Configuration } from 'components/models/base/config'; -import type { WhoAmIProps } from 'components/models/ui/user'; -import { getFileName } from 'helpers/utils'; import getXSRFCookie from 'helpers/xsrf'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -38,140 +34,17 @@ export const getBlobResponse = (data: BlobResponse, error: APIResponse, type: getValue('type', data, error, failureReason) as string }); -type BootstrapProps = { - switchRenderedApp: (value: string) => void; - setConfiguration: (cfg: Configuration) => void; - setLoginParams: (params: LoginParamsProps) => void; - setUser: (user: WhoAmIProps) => void; - setReady: (layout: boolean, borealis: boolean) => void; - retryAfter?: number; - signal?: AbortSignal; -}; - -export const useBootstrapFn = () => { - const { t } = useTranslation(); - const { showErrorMessage, closeSnackbar } = useMySnackbar(); - const { configuration: systemConfig } = useALContext(); - const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); - - return useCallback( - async ({ - switchRenderedApp, - setConfiguration, - setLoginParams, - setUser, - setReady, - retryAfter = DEFAULT_RETRY_MS, - signal = null - }: BootstrapProps) => { - // fetching the API's data - const res = await fetch('/api/v4/user/whoami/', { - method: 'GET', - credentials: 'same-origin', - headers: { 'X-XSRF-TOKEN': getXSRFCookie() }, - signal - }); - - // Setting the API quota - const apiQuota = res.headers.get('X-Remaining-Quota-Api'); - if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); - - // Setting the Submission quota - const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); - if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); - - // Handle an unreachable API - if (res.status === 502) { - showErrorMessage(t('api.unreachable'), 10000); - return Promise.reject({ - api_error_message: t('api.unreachable'), - api_response: '', - api_server_version: systemConfig.system.version, - api_status_code: 502 - }); - } - - const json = (await res.json()) as APIResponse; - - // Check for an invalid json format - if (!isAPIData(json)) { - showErrorMessage(t('api.invalid'), 30000); - switchRenderedApp('load'); - return Promise.reject({ - api_error_message: t('api.invalid'), - api_response: '', - api_server_version: systemConfig.system.version, - api_status_code: 400 - }); - } - - const { api_error_message: error } = json; - - // Forbiden response indicate that the user's account is locked. - if (res.status === 403) { - if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); - setConfiguration(json.api_response as Configuration); - switchRenderedApp('locked'); - return Promise.reject(json); - } - - // Unauthorized response indicate that the user is not logged in. - if (res.status === 401) { - if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); - localStorage.setItem('loginParams', JSON.stringify(json.api_response)); - sessionStorage.clear(); - setLoginParams(json.api_response as LoginParamsProps); - switchRenderedApp('login'); - return Promise.reject(json); - } - - // Daily quota error, stop everything! - if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { - switchRenderedApp('quota'); - return Promise.reject(json); - } - - // Handle all non-successful request - if (res.status !== 200) { - showErrorMessage(json.api_error_message); - return Promise.reject(json); - } - - if (res.status === 200) { - if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); - - const user = json.api_response as WhoAmIProps; - - // Set the current user - setUser(user); - - // Mark the interface ready - setReady(true, user.configuration.ui.api_proxies.includes('borealis')); - - // Render appropriate page - if (!user.agrees_with_tos && user.configuration.ui.tos) { - switchRenderedApp('tos'); - } else { - switchRenderedApp('routes'); - } - - return Promise.resolve(json); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [setApiQuotaremaining, setSubmissionQuotaremaining, systemConfig.system.version, t] - ); -}; - -type ApiCallProps = { +export type ApiCallProps = { url: string; contentType?: string; method?: string; body?: Body; + allowCache?: boolean; + enabled?: boolean; reloadOnUnauthorize?: boolean; retryAfter?: number; - enabled?: boolean; signal?: AbortSignal; + throttleTime?: number; }; export const useApiCallFn = () => { @@ -275,111 +148,3 @@ export const useApiCallFn = () => { [setApiQuotaremaining, setSubmissionQuotaremaining, systemConfig.system.version, t] ); }; - -type DownloadBlobProps = { - url: string; - reloadOnUnauthorize?: boolean; - retryAfter?: number; - enabled?: boolean; - signal?: AbortSignal; -}; - -export const useDownloadBlobFn = () => { - const { t } = useTranslation(); - const { showErrorMessage, closeSnackbar } = useMySnackbar(); - const { configuration: systemConfig } = useALContext(); - const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); - - return useCallback( - async ({ url, enabled, reloadOnUnauthorize, retryAfter, signal = null }: DownloadBlobProps) => { - // Reject if the query is not enabled - if (!enabled) return Promise.reject(null); - - // fetching the API's data - const res = await fetch(url, { - method: 'GET', - credentials: 'same-origin', - headers: { 'X-XSRF-TOKEN': getXSRFCookie() }, - signal - }); - - // Setting the API quota - const apiQuota = res.headers.get('X-Remaining-Quota-Api'); - if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); - - // Setting the Submission quota - const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); - if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); - - // Handle an unreachable API - if (res.status === 502) { - showErrorMessage(t('api.unreachable'), 10000); - return Promise.reject({ - api_error_message: t('api.unreachable'), - api_response: '', - api_server_version: systemConfig.system.version, - api_status_code: 502 - }); - } - - const json = (await res.json()) as APIResponse; - - // Check for an invalid json format - if (!isAPIData(json)) { - showErrorMessage(t('api.invalid')); - return Promise.reject({ - api_error_message: t('api.invalid'), - api_response: '', - api_server_version: systemConfig.system.version, - api_status_code: 400 - }); - } - - const { api_error_message: error } = json; - - // Reload when the user has exceeded their daily API call quota. - if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { - window.location.reload(); - return Promise.reject(json); - } - - // Reject if the user has exceeded their daily submissions quota. - if (res.status === 503 && ['quota', 'submission'].every(v => error.includes(v))) { - return Promise.reject(json); - } - - // Reload when the user is not logged in - if (res.status === 401 && reloadOnUnauthorize) { - window.location.reload(); - return Promise.reject(json); - } - - // Reject if API Server is unavailable and should attempt to retry - if (res.status === 502) { - showErrorMessage(json.api_error_message, 30000); - return Promise.reject(json); - } - - // Handle successful request - if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); - - // Handle all non-successful request - if (res.status !== 200) { - showErrorMessage(json.api_error_message); - return Promise.reject(json); - } - - return Promise.resolve({ - api_error_message: '', - api_response: res.body, - api_server_version: systemConfig.system.version, - api_status_code: res.status, - filename: getFileName(res.headers.get('Content-Disposition')), - size: parseInt(res.headers.get('Content-Length')), - type: res.headers.get('Content-Type') - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [setApiQuotaremaining, setSubmissionQuotaremaining, systemConfig.system.version, t] - ); -}; From 53fa891957755f3dd3388dcb5e4e44216b517d70 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Tue, 8 Oct 2024 13:44:53 +0000 Subject: [PATCH 08/21] Added sorting on the al.score in the Alerts components --- src/components/routes/alerts/components/Filters.tsx | 3 ++- src/locales/en/alerts.json | 1 + src/locales/fr/alerts.json | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/routes/alerts/components/Filters.tsx b/src/components/routes/alerts/components/Filters.tsx index 3d3f71abb..182f314d3 100644 --- a/src/components/routes/alerts/components/Filters.tsx +++ b/src/components/routes/alerts/components/Filters.tsx @@ -91,7 +91,8 @@ export const SORT_OPTIONS: Option[] = [ { value: 'reporting_ts', label: 'alerted_ts' }, { value: 'owner', label: 'owner' }, { value: 'priority', label: 'priority' }, - { value: 'status', label: 'status' } + { value: 'status', label: 'status' }, + { value: 'al.score', label: 'score' } ]; export const TC_OPTIONS: Option[] = [ diff --git a/src/locales/en/alerts.json b/src/locales/en/alerts.json index e44b6a8fa..606d55203 100644 --- a/src/locales/en/alerts.json +++ b/src/locales/en/alerts.json @@ -6,6 +6,7 @@ "actions.takeownershipdiag.content.single": "You are about to take ownership of this single alert with an ID of ", "actions.takeownershipdiag.header": "Take Ownership", "actions.takeownershipdiag.properties": "Search Properties", + "al.score": "System's verdict", "al_results": "Submission Results", "alert_id": "Alert ID", "alert_info": "Alert info", diff --git a/src/locales/fr/alerts.json b/src/locales/fr/alerts.json index 6d71c8765..7a95ac334 100644 --- a/src/locales/fr/alerts.json +++ b/src/locales/fr/alerts.json @@ -7,6 +7,7 @@ "actions.takeownershipdiag.content.single": "Vous êtes sur le point de de devenir le propriétaire de cette alerte unique avec l'identifiant ", "actions.takeownershipdiag.header": "Prendre possession", "actions.takeownershipdiag.properties": "Paramètres de recherche", + "al.score": "Verdict du système", "al_results": "Résultats de soumission", "alert_id": "ID de l'alerte", "alert_info": "Informations sur l'alerte", From c4b59b673a6245cf98681c7f3b292f4dfd8023a4 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Tue, 8 Oct 2024 13:53:06 +0000 Subject: [PATCH 09/21] Changed the names --- src/components/routes/alerts/components/Filters.tsx | 2 +- src/locales/en/alerts.json | 2 +- src/locales/fr/alerts.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/routes/alerts/components/Filters.tsx b/src/components/routes/alerts/components/Filters.tsx index 182f314d3..126e06986 100644 --- a/src/components/routes/alerts/components/Filters.tsx +++ b/src/components/routes/alerts/components/Filters.tsx @@ -92,7 +92,7 @@ export const SORT_OPTIONS: Option[] = [ { value: 'owner', label: 'owner' }, { value: 'priority', label: 'priority' }, { value: 'status', label: 'status' }, - { value: 'al.score', label: 'score' } + { value: 'al.score', label: 'al.score' } ]; export const TC_OPTIONS: Option[] = [ diff --git a/src/locales/en/alerts.json b/src/locales/en/alerts.json index 606d55203..6cdca3325 100644 --- a/src/locales/en/alerts.json +++ b/src/locales/en/alerts.json @@ -6,7 +6,7 @@ "actions.takeownershipdiag.content.single": "You are about to take ownership of this single alert with an ID of ", "actions.takeownershipdiag.header": "Take Ownership", "actions.takeownershipdiag.properties": "Search Properties", - "al.score": "System's verdict", + "al.score": "Score", "al_results": "Submission Results", "alert_id": "Alert ID", "alert_info": "Alert info", diff --git a/src/locales/fr/alerts.json b/src/locales/fr/alerts.json index 7a95ac334..75d4746a3 100644 --- a/src/locales/fr/alerts.json +++ b/src/locales/fr/alerts.json @@ -7,7 +7,7 @@ "actions.takeownershipdiag.content.single": "Vous êtes sur le point de de devenir le propriétaire de cette alerte unique avec l'identifiant ", "actions.takeownershipdiag.header": "Prendre possession", "actions.takeownershipdiag.properties": "Paramètres de recherche", - "al.score": "Verdict du système", + "al.score": "Score", "al_results": "Résultats de soumission", "alert_id": "ID de l'alerte", "alert_info": "Informations sur l'alerte", From 07647d4def3fe5f39a444ba7a5df2efefde97568 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Wed, 9 Oct 2024 04:06:23 +0000 Subject: [PATCH 10/21] more changes --- src/lib/api/invalidateApiQuery.ts | 13 +++++ src/lib/api/updateApiQuery.ts | 19 ++++++ src/lib/api/useApiMutation.tsx | 97 ++++++++++++++++--------------- src/lib/api/useApiQuery.tsx | 20 ++++--- src/lib/api/utils.ts | 6 +- 5 files changed, 97 insertions(+), 58 deletions(-) create mode 100644 src/lib/api/invalidateApiQuery.ts create mode 100644 src/lib/api/updateApiQuery.ts diff --git a/src/lib/api/invalidateApiQuery.ts b/src/lib/api/invalidateApiQuery.ts new file mode 100644 index 000000000..9bb624164 --- /dev/null +++ b/src/lib/api/invalidateApiQuery.ts @@ -0,0 +1,13 @@ +import { queryClient } from './APIProvider'; +import { DEFAULT_INVALIDATE_DELAY } from './constants'; +import type { APIQueryKey } from './models'; + +export const invalidateApiQuery = async ( + filter: (key: APIQueryKey) => boolean, + delay: number = DEFAULT_INVALIDATE_DELAY +) => { + await new Promise(resolve => setTimeout(resolve, delay)); + await queryClient.invalidateQueries({ + predicate: q => filter((JSON.parse(q.queryHash) as [APIQueryKey])[0]) + }); +}; diff --git a/src/lib/api/updateApiQuery.ts b/src/lib/api/updateApiQuery.ts new file mode 100644 index 000000000..948dc462e --- /dev/null +++ b/src/lib/api/updateApiQuery.ts @@ -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 = (filter: (key: ApiCallProps) => boolean, update: (prev: T) => T) => { + queryClient.setQueriesData>( + { + predicate: ({ queryKey }: Query) => { + try { + return typeof queryKey[0] === 'object' && queryKey[0] && filter(queryKey[0]); + } catch (err) { + return false; + } + } + }, + prev => ({ ...prev, api_response: update(prev?.api_response) }) + ); +}; diff --git a/src/lib/api/useApiMutation.tsx b/src/lib/api/useApiMutation.tsx index ce1b1b6df..44720b09b 100644 --- a/src/lib/api/useApiMutation.tsx +++ b/src/lib/api/useApiMutation.tsx @@ -1,72 +1,73 @@ -import type { UseMutationOptions } from '@tanstack/react-query'; +import type { Query, UseMutationOptions } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { DEFAULT_INVALIDATE_DELAY, DEFAULT_RETRY_MS } from './constants'; -import type { APIQueryKey, APIResponse } from './models'; -import { getAPIResponse, useApiCallFn } from './utils'; +import type { APIResponse } from './models'; +import { ApiCallProps, getAPIResponse, useApiCallFn } from './utils'; -type Props = Omit< - UseMutationOptions, APIResponse, TVariables, TContext>, - 'mutationKey' | 'mutationFn' -> & { +type Input = { url: string; contentType?: string; method?: string; body?: Body; - reloadOnUnauthorize?: boolean; - allowCache?: boolean; - retryAfter?: number; - enabled?: boolean; - invalidateProps?: { - delay?: number; - filter: (key: APIQueryKey) => boolean; - }; - queryDataProps?: { - filter: (key: APIQueryKey) => boolean; - update: (old: TData, response: TData) => TData; - }; }; -// export const test = (({}) => void, ...options) => { +const DEFAULT_INPUT: Input = { url: null, contentType: 'application/json', method: 'GET', body: null }; -// return null -// } +type Types = { + body?: TBody; + error?: TError; + response?: TResponse; + input?: TVariables; + context?: TContext; + previous?: TPrevious; +}; -export const useApiMutation = ({ - url, - contentType = 'application/json', - method = 'GET', - body = null, +type Props = Omit< + UseMutationOptions, APIResponse, T['input'], T['context']>, + 'mutationKey' | 'mutationFn' | 'onSuccess' +> & { + input: Input | ((input: T['input']) => Input); + reloadOnUnauthorize?: boolean; + retryAfter?: number; + invalidateDelay?: number; + onInvalidate?: (key: ApiCallProps) => boolean; + onSuccess?: (props?: { data?: APIResponse; input?: T['input']; context?: T['context'] }) => void; +}; + +export const useApiMutation = ({ + input = null, reloadOnUnauthorize = true, - allowCache = false, retryAfter = DEFAULT_RETRY_MS, - enabled = true, + invalidateDelay = DEFAULT_INVALIDATE_DELAY, + onInvalidate = null, onSuccess = () => null, - invalidateProps = { delay: null, filter: null }, - queryDataProps = { filter: null, update: null }, ...options -}: Props) => { +}: Props) => { const queryClient = useQueryClient(); - const apiCallFn = useApiCallFn(); + const apiCallFn = useApiCallFn, T['body']>(); - const mutation = useMutation, APIResponse, void, unknown>({ + const mutation = useMutation, APIResponse, T['input'], unknown>({ ...options, - mutationKey: [{ url, allowCache, method, contentType, body, reloadOnUnauthorize, retryAfter, enabled }], - mutationFn: async () => apiCallFn({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled }), + mutationFn: async (variables: T['input']) => + apiCallFn({ + ...DEFAULT_INPUT, + ...(typeof input === 'function' ? input(variables) : input), + reloadOnUnauthorize, + retryAfter + }), onSuccess: async (data, variable, context) => { - onSuccess(data, variable, context); - - if (queryDataProps?.filter && queryDataProps?.update) { - queryClient.setQueriesData>( - { predicate: q => queryDataProps?.filter((JSON.parse(q.queryHash) as [APIQueryKey])[0]) }, - prev => - !queryDataProps.update ? prev : { ...prev, api_response: queryDataProps.update(prev?.api_response, data) } - ); - } + void new Promise(() => onSuccess({ data, input: variable, context })); - if (invalidateProps?.filter) { - await new Promise(resolve => setTimeout(resolve, invalidateProps?.delay || DEFAULT_INVALIDATE_DELAY)); + if (typeof onInvalidate === 'function') { + await new Promise(resolve => setTimeout(resolve, invalidateDelay)); await queryClient.invalidateQueries({ - predicate: q => invalidateProps.filter((JSON.parse(q.queryHash) as [APIQueryKey])[0]) + predicate: ({ queryKey }: Query) => { + try { + return typeof queryKey[0] === 'object' && queryKey[0] && onInvalidate(queryKey[0]); + } catch (err) { + return false; + } + } }); } } diff --git a/src/lib/api/useApiQuery.tsx b/src/lib/api/useApiQuery.tsx index 1ed6fdeec..2fe2276f5 100644 --- a/src/lib/api/useApiQuery.tsx +++ b/src/lib/api/useApiQuery.tsx @@ -7,13 +7,19 @@ import { useThrottledState } from './useThrottledState'; import type { ApiCallProps } from './utils'; import { getAPIResponse, useApiCallFn } from './utils'; -type Props = Omit< - DefinedInitialDataOptions, +type Types = { + body?: TBody; + error?: TError; + response?: TResponse; +}; + +type Props = Omit< + DefinedInitialDataOptions, APIResponse, APIResponse, TQueryKey>, 'queryKey' | 'initialData' | 'enabled' > & - ApiCallProps; + ApiCallProps; -export const useApiQuery = ({ +export const useApiQuery = ({ url, contentType = 'application/json', method = 'GET', @@ -24,9 +30,9 @@ export const useApiQuery = ({ retryAfter = DEFAULT_RETRY_MS, throttleTime = null, ...options -}: Props, APIResponse, APIResponse, QueryKey, Body>) => { +}: Props) => { const queryClient = useQueryClient(); - const apiCallFn = useApiCallFn(); + const apiCallFn = useApiCallFn(); const queryKey = useMemo>( () => ({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, throttleTime }), @@ -36,7 +42,7 @@ export const useApiQuery = ({ const [throttledKey, isThrottling] = useThrottledState(queryKey, throttleTime); - const query = useQuery, APIResponse, APIResponse>( + const query = useQuery, APIResponse, APIResponse, QueryKey>( { ...options, queryKey: [throttledKey], diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts index fcd99e046..547f8eb4f 100644 --- a/src/lib/api/utils.ts +++ b/src/lib/api/utils.ts @@ -34,7 +34,7 @@ export const getBlobResponse = (data: BlobResponse, error: APIResponse, type: getValue('type', data, error, failureReason) as string }); -export type ApiCallProps = { +export type ApiCallProps = { url: string; contentType?: string; method?: string; @@ -47,7 +47,7 @@ export type ApiCallProps = { throttleTime?: number; }; -export const useApiCallFn = () => { +export const useApiCallFn = () => { const { t } = useTranslation(); const { showErrorMessage, closeSnackbar } = useMySnackbar(); const { configuration: systemConfig } = useALContext(); @@ -72,7 +72,7 @@ export const useApiCallFn = () => { method, credentials: 'same-origin', headers: { 'Content-Type': contentType, 'X-XSRF-TOKEN': getXSRFCookie() }, - body: (!body ? null : contentType === 'application/json' ? JSON.stringify(body) : body) as BodyInit, + body: (body === null ? null : contentType === 'application/json' ? JSON.stringify(body) : body) as BodyInit, signal }); From 4e1bd7829eee4b69acfa37eaebb902a1aeb9d003 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Wed, 9 Oct 2024 04:28:38 +0000 Subject: [PATCH 11/21] changes --- src/lib/api/useApiInfiniteQuery.tsx | 102 +++++++++++----------------- 1 file changed, 40 insertions(+), 62 deletions(-) diff --git a/src/lib/api/useApiInfiniteQuery.tsx b/src/lib/api/useApiInfiniteQuery.tsx index 9ba5d11a4..4cbccb152 100644 --- a/src/lib/api/useApiInfiniteQuery.tsx +++ b/src/lib/api/useApiInfiniteQuery.tsx @@ -1,91 +1,69 @@ import type { DefinedInitialDataInfiniteOptions, InfiniteData, QueryKey } from '@tanstack/react-query'; -import { keepPreviousData, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; -import Throttler from 'commons/addons/utils/throttler'; -import { useEffect, useMemo, useState } from 'react'; -import { DEFAULT_GC_TIME, DEFAULT_RETRY_MS, DEFAULT_STALE_TIME } from './constants'; -import type { APIQueryKey, APIResponse } from './models'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { useThrottledState } from './useThrottledState'; +import type { ApiCallProps } from './utils'; import { useApiCallFn } from './utils'; -interface Props - extends Omit< - DefinedInitialDataInfiniteOptions, - 'queryKey' | 'initialData' - > { - initialData?: null | TData; - url: string; - contentType?: string; - method?: string; - body?: Body; - reloadOnUnauthorize?: boolean; - retryAfter?: number; - disableClearData?: boolean; - throttleTime?: number; - enabled?: boolean; - allowCache?: boolean; -} +type Types = { + body?: TBody; + error?: TError; + response?: TResponse; +}; + +type Props = Omit< + DefinedInitialDataInfiniteOptions< + APIResponse, + APIResponse, + APIResponse, + TQueryKey + >, + 'queryKey' | 'initialData' | 'enabled' +> & + ApiCallProps; -export const useApiInfiniteQuery = ({ +export const useApiInfiniteQuery = ({ url, contentType = 'application/json', method = 'GET', body = null, + allowCache = false, + enabled = true, reloadOnUnauthorize = true, retryAfter = DEFAULT_RETRY_MS, - staleTime = DEFAULT_STALE_TIME, - gcTime = DEFAULT_GC_TIME, throttleTime = null, - enabled, - allowCache = false, ...options -}: Props< - APIResponse, - APIResponse, - InfiniteData, unknown>, - QueryKey, - unknown, - Body ->) => { +}: Props) => { const queryClient = useQueryClient(); - const apiCallFn = useApiCallFn(); + const apiCallFn = useApiCallFn(); - const [queryKey, setQueryKey] = useState(null); - const [isThrottling, setIsThrottling] = useState(!!throttleTime); + const queryKey = useMemo>( + () => ({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, throttleTime }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [allowCache, JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttleTime, url] + ); - const throttler = useMemo(() => (!throttleTime ? null : new Throttler(throttleTime)), [throttleTime]); + const [throttledKey, isThrottling] = useThrottledState(queryKey, throttleTime); const query = useInfiniteQuery< - APIResponse, - APIResponse, - InfiniteData, unknown> + APIResponse, + APIResponse, + InfiniteData, unknown> >( { ...options, - queryKey: [queryKey], - staleTime, - gcTime, - enabled: enabled && !!queryKey && !isThrottling, - queryFn: async ({ pageParam }) => - apiCallFn({ url, contentType, method, body: { ...body, offset: pageParam }, reloadOnUnauthorize, retryAfter }), - placeholderData: keepPreviousData, + queryKey: [throttledKey], + enabled: Boolean(enabled) && !!throttledKey && !isThrottling, + queryFn: async ({ pageParam, signal }) => + apiCallFn({ ...throttledKey, body: { ...throttledKey.body, offset: pageParam }, signal }), retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) }, queryClient ); - useEffect(() => { - if (!throttler) { - setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled, allowCache }); - } else { - setIsThrottling(true); - throttler.delay(() => { - setIsThrottling(false); - setQueryKey({ url, contentType, method, body, reloadOnUnauthorize, retryAfter, enabled, allowCache }); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttler, url, allowCache]); - return { ...query }; // return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; }; From 450cac3ec4eac0ed3b91a5aaa1f3d914be7ce7be Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Wed, 9 Oct 2024 04:29:22 +0000 Subject: [PATCH 12/21] changes --- src/lib/api/useApiMutation.tsx | 3 ++- .../api/{useApiInfiniteQuery.tsx => useInfiniteApiQuery.tsx} | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename src/lib/api/{useApiInfiniteQuery.tsx => useInfiniteApiQuery.tsx} (97%) diff --git a/src/lib/api/useApiMutation.tsx b/src/lib/api/useApiMutation.tsx index 44720b09b..91108a709 100644 --- a/src/lib/api/useApiMutation.tsx +++ b/src/lib/api/useApiMutation.tsx @@ -2,7 +2,8 @@ import type { Query, UseMutationOptions } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { DEFAULT_INVALIDATE_DELAY, DEFAULT_RETRY_MS } from './constants'; import type { APIResponse } from './models'; -import { ApiCallProps, getAPIResponse, useApiCallFn } from './utils'; +import type { ApiCallProps } from './utils'; +import { getAPIResponse, useApiCallFn } from './utils'; type Input = { url: string; diff --git a/src/lib/api/useApiInfiniteQuery.tsx b/src/lib/api/useInfiniteApiQuery.tsx similarity index 97% rename from src/lib/api/useApiInfiniteQuery.tsx rename to src/lib/api/useInfiniteApiQuery.tsx index 4cbccb152..55a817763 100644 --- a/src/lib/api/useApiInfiniteQuery.tsx +++ b/src/lib/api/useInfiniteApiQuery.tsx @@ -24,7 +24,7 @@ type Props = Omit< > & ApiCallProps; -export const useApiInfiniteQuery = ({ +export const useInfiniteApiQuery = ({ url, contentType = 'application/json', method = 'GET', From b4abcb799f164289f7f5e8cd6d517dd044ee3972 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Wed, 9 Oct 2024 04:43:23 +0000 Subject: [PATCH 13/21] changes --- src/lib/api/useInfiniteApiQuery.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/lib/api/useInfiniteApiQuery.tsx b/src/lib/api/useInfiniteApiQuery.tsx index 55a817763..c4c386f53 100644 --- a/src/lib/api/useInfiniteApiQuery.tsx +++ b/src/lib/api/useInfiniteApiQuery.tsx @@ -8,7 +8,7 @@ import type { ApiCallProps } from './utils'; import { useApiCallFn } from './utils'; type Types = { - body?: TBody; + body?: TBody & { offset: number }; error?: TError; response?: TResponse; }; @@ -17,14 +17,14 @@ type Props = Omit< DefinedInitialDataInfiniteOptions< APIResponse, APIResponse, - APIResponse, + InfiniteData, unknown>, TQueryKey >, 'queryKey' | 'initialData' | 'enabled' > & ApiCallProps; -export const useInfiniteApiQuery = ({ +export const useApiInfiniteQuery = ({ url, contentType = 'application/json', method = 'GET', @@ -65,5 +65,4 @@ export const useInfiniteApiQuery = ({ ); return { ...query }; - // return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; }; From 27cee4a9aa46091c5517034bbeb36a3e10038ab3 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Wed, 9 Oct 2024 14:09:09 +0000 Subject: [PATCH 14/21] Removed the '*' on no query and filters --- src/components/routes/alerts.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/routes/alerts.tsx b/src/components/routes/alerts.tsx index ac36eef26..9b8ef1c46 100644 --- a/src/components/routes/alerts.tsx +++ b/src/components/routes/alerts.tsx @@ -164,7 +164,7 @@ const WrappedAlertsContent = () => { const q = search.get('q'); const fq = search.get('fq'); - const values = (!q && !fq.length ? ['*'] : q ? [q] : []).concat(fq); + const values = (!q && !fq.length ? [''] : q ? [q] : []).concat(fq); const query = values .map(v => ([' or ', ' and '].some(a => v.toLowerCase().includes(a)) ? `(${v})` : v)) .join(' AND '); From df83298ca99cfa2ec55ca03b8004c2d6484a8407 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Wed, 9 Oct 2024 14:11:40 +0000 Subject: [PATCH 15/21] Fixed the logic of the body value --- src/components/hooks/useMyAPI.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/hooks/useMyAPI.tsx b/src/components/hooks/useMyAPI.tsx index 2f0c6413b..392792ff7 100644 --- a/src/components/hooks/useMyAPI.tsx +++ b/src/components/hooks/useMyAPI.tsx @@ -217,7 +217,7 @@ const useMyAPI = (): UseMyAPIReturn => { 'Content-Type': contentType, 'X-XSRF-TOKEN': getXSRFCookie() }, - body: (!body ? null : contentType === 'application/json' ? JSON.stringify(body) : body) as BodyInit + body: (body !== null ? (contentType === 'application/json' ? JSON.stringify(body) : body) : null) as BodyInit }; // Run enter callback From c9c6485460b62afe832361fbbea51ade5cbc358b Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Wed, 9 Oct 2024 15:32:20 +0000 Subject: [PATCH 16/21] Reverted toSorted() to plain sort() --- .../routes/admin/service_detail/general.tsx | 8 +++++++- src/components/routes/admin/services.tsx | 15 ++++++++------- .../routes/alerts/utils/SearchParams.tsx | 2 +- src/components/routes/dashboard.tsx | 18 ++++++++++++++++-- 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/components/routes/admin/service_detail/general.tsx b/src/components/routes/admin/service_detail/general.tsx index d0058c42b..0e55d7856 100644 --- a/src/components/routes/admin/service_detail/general.tsx +++ b/src/components/routes/admin/service_detail/general.tsx @@ -373,7 +373,13 @@ const ServiceGeneral = ({ isOptionEqualToValue={(option, value) => option.toUpperCase() === value.toUpperCase()} onChange={(_e, values) => { setModified(true); - setService(s => ({ ...s, recursion_prevention: values.toSorted() })); + setService(s => ({ + ...s, + recursion_prevention: (() => { + values.sort(); + return values; + })() + })); }} renderInput={params => } renderOption={(props, option, state) => ( diff --git a/src/components/routes/admin/services.tsx b/src/components/routes/admin/services.tsx index c3bfca550..6f1f7d3df 100644 --- a/src/components/routes/admin/services.tsx +++ b/src/components/routes/admin/services.tsx @@ -70,13 +70,14 @@ export default function Services() { const isXL = useMediaQuery(theme.breakpoints.only('xl')); - const serviceNames = useMemo( - () => - (serviceFeeds || []) - .reduce((prev: string[], item) => (item?.summary ? [...prev, item.summary] : prev), []) - .toSorted(), - [serviceFeeds] - ); + const serviceNames = useMemo(() => { + const values = (serviceFeeds || []).reduce( + (prev: string[], item) => (item?.summary ? [...prev, item.summary] : prev), + [] + ); + values.sort(); + return values; + }, [serviceFeeds]); const handleAddService = () => { apiCall({ diff --git a/src/components/routes/alerts/utils/SearchParams.tsx b/src/components/routes/alerts/utils/SearchParams.tsx index aff35d8de..3e57f8556 100644 --- a/src/components/routes/alerts/utils/SearchParams.tsx +++ b/src/components/routes/alerts/utils/SearchParams.tsx @@ -229,8 +229,8 @@ export class ArrayParam extends BaseParam { } private append(prev: URLSearchParams, values: string[][]): void { + values.sort((a, b) => a.at(-1).localeCompare(b.at(-1))); values - .toSorted((a, b) => a.at(-1).localeCompare(b.at(-1))) .map(value => this.fromPrefix(value)) .forEach(v => { prev.append(this.key, v); diff --git a/src/components/routes/dashboard.tsx b/src/components/routes/dashboard.tsx index 8fa855330..fd94552e2 100644 --- a/src/components/routes/dashboard.tsx +++ b/src/components/routes/dashboard.tsx @@ -400,9 +400,23 @@ const WrappedDispatcherCard = ({ dispatcher, up, down, handleStatusChange, statu {dispatcher.initialized ? (
{up.length === 0 && down.length === 0 && {t('no_services')}} - {up.length !== 0 && {up.toSorted().join(' | ')}} + {up.length !== 0 && ( + + {(() => { + const sorted = up.sort(); + return sorted.join(' | '); + })()} + + )} {up.length !== 0 && down.length !== 0 && :: } - {down.length !== 0 && {down.toSorted().join(' | ')}} + {down.length !== 0 && ( + + {(() => { + const sorted = down.sort(); + return sorted.join(' | '); + })()} + + )}
) : (
From 2792cea88e794ba65cac9eea34ad4533da63f8ce Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Wed, 9 Oct 2024 15:43:00 +0000 Subject: [PATCH 17/21] Moved the Typescript test first cause thats the most important. --- pipelines/azure-test.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pipelines/azure-test.yaml b/pipelines/azure-test.yaml index b9f74e7af..757a47560 100644 --- a/pipelines/azure-test.yaml +++ b/pipelines/azure-test.yaml @@ -33,6 +33,11 @@ jobs: yarn install displayName: Install assemblyline-ui-frontend + - script: | + set -xv # Echo commands before they are run + npm run tsc + displayName: TypeScript + - script: | set -xv # Echo commands before they are run npm run ci-test @@ -43,11 +48,6 @@ jobs: npm run ci-lint displayName: ESLint - - script: | - set -xv # Echo commands before they are run - npm run tsc - displayName: TypeScript - - task: PublishCodeCoverageResults@2 inputs: codeCoverageTool: Cobertura From 9757fad828d40ed5b5bc26ae692a2d3e084a70c0 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Wed, 9 Oct 2024 17:22:20 +0000 Subject: [PATCH 18/21] Changes to the sort --- .../routes/admin/service_detail/general.tsx | 8 +------- src/components/routes/admin/services.tsx | 15 +++++++-------- .../routes/alerts/utils/SearchParams.tsx | 2 +- src/components/routes/dashboard.tsx | 18 ++---------------- 4 files changed, 11 insertions(+), 32 deletions(-) diff --git a/src/components/routes/admin/service_detail/general.tsx b/src/components/routes/admin/service_detail/general.tsx index 0e55d7856..4fdbb041a 100644 --- a/src/components/routes/admin/service_detail/general.tsx +++ b/src/components/routes/admin/service_detail/general.tsx @@ -373,13 +373,7 @@ const ServiceGeneral = ({ isOptionEqualToValue={(option, value) => option.toUpperCase() === value.toUpperCase()} onChange={(_e, values) => { setModified(true); - setService(s => ({ - ...s, - recursion_prevention: (() => { - values.sort(); - return values; - })() - })); + setService(s => ({ ...s, recursion_prevention: [...values].sort() })); }} renderInput={params => } renderOption={(props, option, state) => ( diff --git a/src/components/routes/admin/services.tsx b/src/components/routes/admin/services.tsx index 6f1f7d3df..ace31f208 100644 --- a/src/components/routes/admin/services.tsx +++ b/src/components/routes/admin/services.tsx @@ -70,14 +70,13 @@ export default function Services() { const isXL = useMediaQuery(theme.breakpoints.only('xl')); - const serviceNames = useMemo(() => { - const values = (serviceFeeds || []).reduce( - (prev: string[], item) => (item?.summary ? [...prev, item.summary] : prev), - [] - ); - values.sort(); - return values; - }, [serviceFeeds]); + const serviceNames = useMemo( + () => + (serviceFeeds || []) + .reduce((prev: string[], item) => (item?.summary ? [...prev, item.summary] : prev), []) + .sort(), + [serviceFeeds] + ); const handleAddService = () => { apiCall({ diff --git a/src/components/routes/alerts/utils/SearchParams.tsx b/src/components/routes/alerts/utils/SearchParams.tsx index 3e57f8556..583bf5433 100644 --- a/src/components/routes/alerts/utils/SearchParams.tsx +++ b/src/components/routes/alerts/utils/SearchParams.tsx @@ -229,8 +229,8 @@ export class ArrayParam extends BaseParam { } private append(prev: URLSearchParams, values: string[][]): void { - values.sort((a, b) => a.at(-1).localeCompare(b.at(-1))); values + .sort((a, b) => a.at(-1).localeCompare(b.at(-1))) .map(value => this.fromPrefix(value)) .forEach(v => { prev.append(this.key, v); diff --git a/src/components/routes/dashboard.tsx b/src/components/routes/dashboard.tsx index fd94552e2..244d8ec37 100644 --- a/src/components/routes/dashboard.tsx +++ b/src/components/routes/dashboard.tsx @@ -400,23 +400,9 @@ const WrappedDispatcherCard = ({ dispatcher, up, down, handleStatusChange, statu {dispatcher.initialized ? (
{up.length === 0 && down.length === 0 && {t('no_services')}} - {up.length !== 0 && ( - - {(() => { - const sorted = up.sort(); - return sorted.join(' | '); - })()} - - )} + {up.length !== 0 && {up.sort().join(' | ')}} {up.length !== 0 && down.length !== 0 && :: } - {down.length !== 0 && ( - - {(() => { - const sorted = down.sort(); - return sorted.join(' | '); - })()} - - )} + {down.length !== 0 && {down.sort().join(' | ')}}
) : (
From 0e62fc020b3e52f03429fe6ef27b287c4e6e465f Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Wed, 9 Oct 2024 19:37:16 +0000 Subject: [PATCH 19/21] Added a "Show more" button to incrementaly show more files in the file tree --- .../routes/submission/detail/file_tree.tsx | 175 ++++++++++-------- src/locales/en/submission/detail.json | 1 + src/locales/fr/submission/detail.json | 1 + 3 files changed, 100 insertions(+), 77 deletions(-) diff --git a/src/components/routes/submission/detail/file_tree.tsx b/src/components/routes/submission/detail/file_tree.tsx index e5d56d5a6..0e553e746 100644 --- a/src/components/routes/submission/detail/file_tree.tsx +++ b/src/components/routes/submission/detail/file_tree.tsx @@ -2,17 +2,20 @@ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import ArrowRightIcon from '@mui/icons-material/ArrowRight'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; -import { Box, Collapse, Divider, IconButton, Skeleton, Tooltip, Typography, useTheme } from '@mui/material'; +import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; +import { Box, Button, Collapse, Divider, IconButton, Skeleton, Tooltip, Typography, useTheme } from '@mui/material'; import makeStyles from '@mui/styles/makeStyles'; import useHighlighter from 'components/hooks/useHighlighter'; import useSafeResults from 'components/hooks/useSafeResults'; -import { SubmissionTree, Tree } from 'components/models/ui/submission'; +import type { SubmissionTree, Tree } from 'components/models/ui/submission'; import Verdict from 'components/visual/Verdict'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { Link } from 'react-router-dom'; +const MAX_FILE_COUNT = 500 as const; + const useStyles = makeStyles(theme => ({ file_item: { cursor: 'pointer', @@ -106,92 +109,110 @@ type FileTreeProps = { }; const WrappedFileTree: React.FC = ({ tree, sid, defaultForceShown, force = false }) => { - const { t } = useTranslation('submissionDetail'); + const { t } = useTranslation(['submissionDetail']); const theme = useTheme(); const classes = useStyles(); const navigate = useNavigate(); const { isHighlighted } = useHighlighter(); const { showSafeResults } = useSafeResults(); - const [forcedShown, setForcedShown] = React.useState>([...defaultForceShown]); + + const [forcedShown, setForcedShown] = useState([...defaultForceShown]); + const [maxChildCount, setMaxChildCount] = useState(MAX_FILE_COUNT); + + const files = useMemo<[string, Tree][]>( + () => + Object.entries(tree) + .sort((a: [string, Tree], b: [string, Tree]) => (a[1].name.join() > b[1].name.join() ? 1 : -1)) + .reduce( + (prev, [sha256, item]: [string, Tree]) => + !isVisible(tree[sha256], defaultForceShown, isHighlighted, showSafeResults) || + (item.score < 0 && !showSafeResults && !force) || + prev.length > maxChildCount + ? [...prev] + : [...prev, [sha256, item]], + [] as [string, Tree][] + ), + [defaultForceShown, force, isHighlighted, maxChildCount, showSafeResults, tree] + ); return ( <> - {Object.entries(tree) - .sort((a: [string, Tree], b: [string, Tree]) => { - return a[1].name.join() > b[1].name.join() ? 1 : -1; - }) - .map(([sha256, item], i) => { - return !isVisible(tree[sha256], defaultForceShown, isHighlighted, showSafeResults) || - (item.score < 0 && !showSafeResults && !force) ? null : ( -
-
- {item.children && - Object.values(item.children).some(c => !isVisible(c, forcedShown, isHighlighted, showSafeResults)) ? ( - - { - setForcedShown([...forcedShown, ...Object.keys(item.children)]); - }} - > - - - - ) : item.children && Object.keys(item.children).some(key => forcedShown.includes(key)) ? ( - - { - const excluded = Object.keys(item.children); - setForcedShown(forcedShown.filter(val => !excluded.includes(val))); - }} - > - - - - ) : ( - - )} - { - e.preventDefault(); - if (item.sha256) - navigate(`/submission/detail/${sid}/${item.sha256}?name=${encodeURI(item.name[0])}`); + {files.map(([sha256, item], i) => ( +
+
+ {item.children && + Object.values(item.children).some(c => !isVisible(c, forcedShown, isHighlighted, showSafeResults)) ? ( + + { + setForcedShown([...forcedShown, ...Object.keys(item.children)]); }} - style={{ - wordBreak: 'break-word', - backgroundColor: isHighlighted(sha256) - ? theme.palette.mode === 'dark' - ? '#343a44' - : '#d8e3ea' - : null + > + + + + ) : item.children && Object.keys(item.children).some(key => forcedShown.includes(key)) ? ( + + { + const excluded = Object.keys(item.children); + setForcedShown(forcedShown.filter(val => !excluded.includes(val))); }} > -
- - - :: - - - - {item.name.sort().join(' | ')} - - {`[${item.type}]`} - -
- + +
+
+ ) : ( + + )} + { + e.preventDefault(); + if (item.sha256) navigate(`/submission/detail/${sid}/${item.sha256}?name=${encodeURI(item.name[0])}`); + }} + style={{ + wordBreak: 'break-word', + backgroundColor: isHighlighted(sha256) ? (theme.palette.mode === 'dark' ? '#343a44' : '#d8e3ea') : null + }} + > +
+ + + :: + + + + {item.name.sort().join(' | ')} + + {`[${item.type}]`} +
-
- -
-
- ); - })} + +
+
+ +
+
+ ))} + {files.length <= maxChildCount ? null : ( + + + + )} ); }; diff --git a/src/locales/en/submission/detail.json b/src/locales/en/submission/detail.json index ff6fb4aa2..8a8850db8 100644 --- a/src/locales/en/submission/detail.json +++ b/src/locales/en/submission/detail.json @@ -74,6 +74,7 @@ "resubmit.carbon_copy": "Use the same parameters", "resubmit.dynamic": "Resubmit for Dynamic analysis", "resubmit.modify": "Adjust parameters before submission", + "show_more": "Show more...", "submit.success": "Submission successfully resubmitted. You will be redirected to it...", "times.completed": "Completed Time", "times.submitted": "Start Time", diff --git a/src/locales/fr/submission/detail.json b/src/locales/fr/submission/detail.json index a8b9817ba..a071227f2 100644 --- a/src/locales/fr/submission/detail.json +++ b/src/locales/fr/submission/detail.json @@ -74,6 +74,7 @@ "resubmit.carbon_copy": "Utiliser les mêmes paramètres", "resubmit.dynamic": "Resoumettre pour analyse dynamique", "resubmit.modify": "Ajustez les paramètres avant la soumission", + "show_more": "Afficher plus...", "submit.success": "Soumission soumis à nouveau avec succès. Vous y serez redirigé ...", "times.completed": "Temps terminé", "times.submitted": "Temps de début", From a773b6cd30fedcecfe1f3180f3d2f34b56060b21 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Wed, 9 Oct 2024 19:37:57 +0000 Subject: [PATCH 20/21] minor change --- src/components/routes/submission/detail/file_tree.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/routes/submission/detail/file_tree.tsx b/src/components/routes/submission/detail/file_tree.tsx index 0e553e746..a5dab8811 100644 --- a/src/components/routes/submission/detail/file_tree.tsx +++ b/src/components/routes/submission/detail/file_tree.tsx @@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { Link } from 'react-router-dom'; -const MAX_FILE_COUNT = 500 as const; +const MAX_FILE_COUNT = 500; const useStyles = makeStyles(theme => ({ file_item: { From 83ddd63cfc376368cfd046b0e1dbca1f0d521947 Mon Sep 17 00:00:00 2001 From: cccs-nr Date: Thu, 10 Oct 2024 02:43:09 +0000 Subject: [PATCH 21/21] More changes --- .eslintrc | 1 + src/lib/api/invalidateApiQuery.ts | 22 +++++++---- src/lib/api/useApiMutation.tsx | 55 +++++++++++++------------- src/lib/api/useApiQuery.tsx | 20 +++------- src/lib/api/useThrottledApiQuery.tsx | 58 ++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 49 deletions(-) create mode 100644 src/lib/api/useThrottledApiQuery.tsx diff --git a/.eslintrc b/.eslintrc index 4a59c00fd..3208aaf48 100644 --- a/.eslintrc +++ b/.eslintrc @@ -51,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", diff --git a/src/lib/api/invalidateApiQuery.ts b/src/lib/api/invalidateApiQuery.ts index 9bb624164..4fe3294d7 100644 --- a/src/lib/api/invalidateApiQuery.ts +++ b/src/lib/api/invalidateApiQuery.ts @@ -1,13 +1,21 @@ +import type { Query } from '@tanstack/react-query'; import { queryClient } from './APIProvider'; import { DEFAULT_INVALIDATE_DELAY } from './constants'; -import type { APIQueryKey } from './models'; +import type { ApiCallProps } from './utils'; -export const invalidateApiQuery = async ( - filter: (key: APIQueryKey) => boolean, +export const invalidateApiQuery = ( + filter: (key: ApiCallProps) => boolean, delay: number = DEFAULT_INVALIDATE_DELAY ) => { - await new Promise(resolve => setTimeout(resolve, delay)); - await queryClient.invalidateQueries({ - predicate: q => filter((JSON.parse(q.queryHash) as [APIQueryKey])[0]) - }); + setTimeout(async () => { + await queryClient.invalidateQueries({ + predicate: ({ queryKey }: Query) => { + try { + return typeof queryKey[0] === 'object' && queryKey[0] && filter(queryKey[0]); + } catch (err) { + return false; + } + } + }); + }, delay); }; diff --git a/src/lib/api/useApiMutation.tsx b/src/lib/api/useApiMutation.tsx index 91108a709..11daaeca5 100644 --- a/src/lib/api/useApiMutation.tsx +++ b/src/lib/api/useApiMutation.tsx @@ -1,8 +1,7 @@ -import type { Query, UseMutationOptions } from '@tanstack/react-query'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { DEFAULT_INVALIDATE_DELAY, DEFAULT_RETRY_MS } from './constants'; +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 type { ApiCallProps } from './utils'; import { getAPIResponse, useApiCallFn } from './utils'; type Input = { @@ -25,26 +24,40 @@ type Types = Omit< UseMutationOptions, APIResponse, T['input'], T['context']>, - 'mutationKey' | 'mutationFn' | 'onSuccess' + 'mutationKey' | 'mutationFn' | 'onSuccess' | 'onMutate' | 'onSettled' > & { input: Input | ((input: T['input']) => Input); reloadOnUnauthorize?: boolean; retryAfter?: number; - invalidateDelay?: number; - onInvalidate?: (key: ApiCallProps) => boolean; - onSuccess?: (props?: { data?: APIResponse; input?: T['input']; context?: T['context'] }) => void; + onSuccess?: (props?: { + data: APIResponse; + input: T['input']; + context: T['context']; + }) => Promise | unknown; + onFailure?: (props?: { + error: APIResponse; + input: T['input']; + context: T['context']; + }) => Promise | unknown; + onEnter?: (props?: { input: T['input'] }) => unknown; + onExit?: (props?: { + data: APIResponse; + error: APIResponse; + input: T['input']; + context: T['context']; + }) => Promise | unknown; }; export const useApiMutation = ({ input = null, reloadOnUnauthorize = true, retryAfter = DEFAULT_RETRY_MS, - invalidateDelay = DEFAULT_INVALIDATE_DELAY, - onInvalidate = null, onSuccess = () => null, + onFailure = () => null, + onEnter = () => null, + onExit = () => null, ...options }: Props) => { - const queryClient = useQueryClient(); const apiCallFn = useApiCallFn, T['body']>(); const mutation = useMutation, APIResponse, T['input'], unknown>({ @@ -56,22 +69,10 @@ export const useApiMutation = ({ reloadOnUnauthorize, retryAfter }), - onSuccess: async (data, variable, context) => { - void new Promise(() => onSuccess({ data, input: variable, context })); - - if (typeof onInvalidate === 'function') { - await new Promise(resolve => setTimeout(resolve, invalidateDelay)); - await queryClient.invalidateQueries({ - predicate: ({ queryKey }: Query) => { - try { - return typeof queryKey[0] === 'object' && queryKey[0] && onInvalidate(queryKey[0]); - } catch (err) { - return false; - } - } - }); - } - } + 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) }; diff --git a/src/lib/api/useApiQuery.tsx b/src/lib/api/useApiQuery.tsx index 2fe2276f5..1eebdeeea 100644 --- a/src/lib/api/useApiQuery.tsx +++ b/src/lib/api/useApiQuery.tsx @@ -1,9 +1,7 @@ import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useMemo } from 'react'; import { DEFAULT_RETRY_MS } from './constants'; import type { APIResponse } from './models'; -import { useThrottledState } from './useThrottledState'; import type { ApiCallProps } from './utils'; import { getAPIResponse, useApiCallFn } from './utils'; @@ -28,31 +26,23 @@ export const useApiQuery = ({ enabled = true, reloadOnUnauthorize = true, retryAfter = DEFAULT_RETRY_MS, - throttleTime = null, ...options }: Props) => { const queryClient = useQueryClient(); const apiCallFn = useApiCallFn(); - const queryKey = useMemo>( - () => ({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, throttleTime }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [allowCache, JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttleTime, url] - ); - - const [throttledKey, isThrottling] = useThrottledState(queryKey, throttleTime); - const query = useQuery, APIResponse, APIResponse, QueryKey>( { ...options, - queryKey: [throttledKey], - enabled: Boolean(enabled) && !!throttledKey && !isThrottling, - queryFn: async ({ signal }) => apiCallFn({ signal, ...throttledKey }), + 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), isThrottling }; + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; }; diff --git a/src/lib/api/useThrottledApiQuery.tsx b/src/lib/api/useThrottledApiQuery.tsx new file mode 100644 index 000000000..f3de507ed --- /dev/null +++ b/src/lib/api/useThrottledApiQuery.tsx @@ -0,0 +1,58 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { useThrottledState } from './useThrottledState'; +import type { ApiCallProps } from './utils'; +import { getAPIResponse, useApiCallFn } from './utils'; + +type Types = { + body?: TBody; + error?: TError; + response?: TResponse; +}; + +type Props = Omit< + DefinedInitialDataOptions, APIResponse, APIResponse, TQueryKey>, + 'queryKey' | 'initialData' | 'enabled' +> & + ApiCallProps; + +export const useThrottledApiQuery = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + allowCache = false, + enabled = true, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + throttleTime = null, + ...options +}: Props) => { + const queryClient = useQueryClient(); + const apiCallFn = useApiCallFn(); + + const queryKey = useMemo>( + () => ({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, throttleTime }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [allowCache, JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttleTime, url] + ); + + const [throttledKey, isThrottling] = useThrottledState(queryKey, throttleTime); + + const query = useQuery, APIResponse, APIResponse, QueryKey>( + { + ...options, + queryKey: [throttledKey], + enabled: Boolean(enabled) && !!throttledKey && !isThrottling, + queryFn: async ({ signal }) => apiCallFn({ signal, ...throttledKey }), + 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), isThrottling }; +};