From 53f7b8794b9d3330515128bb9ce4c1e21d89b701 Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Mon, 1 Jun 2026 16:44:51 +0200 Subject: [PATCH 01/38] feat(frontend): Add Tanstack query and migrate currentUser query --- frontend/codegen.ts | 91 ++++++++++---- frontend/package-lock.json | 83 +++++++++++++ frontend/package.json | 2 + frontend/src/api/annotation-task/hooks.ts | 13 +- frontend/src/api/auth/api.ts | 12 +- frontend/src/api/auth/hooks.ts | 25 ++-- frontend/src/api/auth/index.ts | 1 - frontend/src/api/auth/matchers.ts | 9 -- frontend/src/api/graphqlClient.ts | 58 +++++++++ frontend/src/api/queryClient.tsx | 27 ++++ frontend/src/api/queryKeys.ts | 80 ++++++++++++ frontend/src/api/user/api.ts | 5 +- frontend/src/api/user/hooks.ts | 25 ---- frontend/src/api/user/index.ts | 6 +- frontend/src/api/user/matchers.ts | 7 -- frontend/src/api/user/selectors.ts | 15 --- frontend/src/api/user/user.graphql | 10 -- frontend/src/components/layout/Header.tsx | 22 ++-- frontend/src/components/layout/Navbar.tsx | 95 +++++++------- .../CampaignFilters/AnnotatorFilter.tsx | 7 +- .../CampaignFilters/OwnerFilter.tsx | 7 +- .../CampaignFilters/ResetButton.tsx | 8 +- .../CampaignFilters/index.tsx | 7 +- .../features/AnnotationTask/StatusFilter.tsx | 17 ++- .../Annotator/Annotation/AnnotationRow.tsx | 13 +- .../features/Annotator/Annotation/hooks.ts | 18 +-- .../features/Annotator/Annotation/slice.ts | 13 +- .../Annotator/Comment/CommentBloc.tsx | 80 ++++++------ .../features/Annotator/Confidence/slice.ts | 108 ++++++++-------- .../src/features/Annotator/Label/slice.ts | 114 ++++++++--------- .../Annotator/Spectrogram/DownloadButton.tsx | 7 +- frontend/src/features/App/store.ts | 5 +- .../src/features/Audio/DownloadButton.tsx | 6 +- frontend/src/features/Auth/index.ts | 1 - frontend/src/features/Auth/slice.ts | 39 ------ frontend/src/features/Dataset/DatasetInfo.tsx | 8 +- .../src/features/Dataset/DatasetSelect.tsx | 2 +- frontend/src/features/User/UpdateEmail.tsx | 116 +++++++++--------- frontend/src/features/User/api/index.ts | 19 +++ frontend/src/features/User/api/user.graphql | 12 ++ frontend/src/features/User/index.ts | 4 + frontend/src/features/index.ts | 1 + frontend/src/main.tsx | 6 +- frontend/src/routes/(public)/login.tsx | 16 +-- frontend/src/routes/__root.tsx | 4 + frontend/src/routes/_authenticated.tsx | 39 ++++-- frontend/src/routes/_authenticated/_admin.tsx | 11 +- .../src/routes/_authenticated/_superuser.tsx | 11 +- .../_authenticated/_superuser/sql.lazy.tsx | 6 +- .../{account.lazy.tsx => account.tsx} | 21 ++-- .../annotation-campaign/index.tsx | 7 +- frontend/src/service/function.ts | 4 +- frontend/tests/utils/mock/types/fileRange.ts | 4 +- 53 files changed, 771 insertions(+), 556 deletions(-) delete mode 100644 frontend/src/api/auth/matchers.ts create mode 100644 frontend/src/api/graphqlClient.ts create mode 100644 frontend/src/api/queryClient.tsx create mode 100644 frontend/src/api/queryKeys.ts delete mode 100644 frontend/src/api/user/matchers.ts delete mode 100644 frontend/src/api/user/selectors.ts delete mode 100644 frontend/src/features/Auth/index.ts delete mode 100644 frontend/src/features/Auth/slice.ts create mode 100644 frontend/src/features/User/api/index.ts create mode 100644 frontend/src/features/User/api/user.graphql create mode 100644 frontend/src/features/index.ts rename frontend/src/routes/_authenticated/{account.lazy.tsx => account.tsx} (58%) diff --git a/frontend/codegen.ts b/frontend/codegen.ts index 96e59b127..6a66d2901 100644 --- a/frontend/codegen.ts +++ b/frontend/codegen.ts @@ -1,31 +1,72 @@ import type { CodegenConfig } from '@graphql-codegen/cli'; +const scalars = { + DateTime: 'string', + Date: 'string', + BigInt: 'number', + Decimal: 'number', + ID: 'string', +} const config: CodegenConfig = { - schema: 'schema.graphql', - documents: "src/api/**/*.graphql", - ignoreNoDocuments: true, - generates: { - 'src/api/types.gql-generated.ts': { - plugins: [ 'typescript' ], - }, - 'src/': { - preset: 'near-operation-file', - presetConfig: { - baseTypesPath: '/api/types.gql-generated.ts', - }, - plugins: [ - 'typescript-operations', - { - 'typescript-rtk-query': { - importBaseApiFrom: '@/api/baseGqlApi', - importBaseApiAlternateName: 'gqlAPI', - } - } - ], + schema: 'schema.graphql', + ignoreNoDocuments: true, + watch: true, + generates: { + // 1️⃣ Generic TypeScript types (unique source) + 'src/api/types.gql-generated.ts': { + documents: 'src/api/**/*.graphql', + plugins: [ 'typescript' ], + config: { + useTypeImports: true, + skipTypename: false, + dedupeOperationSuffix: true, + scalars, + }, + }, + + // 2️⃣ RTK Query in /api - Near-operation-file + 'src/api/': { + documents: 'src/api/**/*.graphql', + preset: 'near-operation-file', + presetConfig: { + baseTypesPath: 'types.gql-generated.ts', + importTypesNamespace: '_Types', + }, + plugins: [ + 'typescript-operations', + { + 'typescript-rtk-query': { + importBaseApiFrom: '@/api/baseGqlApi', + importBaseApiAlternateName: 'gqlAPI', + }, + }, + ], + config: { + arrayInputCoercion: false, + scalars + }, + }, + + // 3️⃣ React Query in /features - Near-operation-file + 'src/features': { + documents: 'src/features/**/*.graphql', + preset: 'near-operation-file', + presetConfig: { + baseTypesPath: '../api/types.gql-generated.ts', + importTypesNamespace: '_Types', + }, + plugins: [ + 'typescript-operations', + 'typed-document-node', + ], + config: { + useTypeImports: true, + documentMode: 'documentNode', + skipTypename: false, + dedupeOperationSuffix: true, + scalars, + }, + }, }, - '.introspection.json': { - plugins: [ 'introspection' ] - } - }, } export default config; \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a9ca2befd..4b91f4516 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,8 @@ "@reduxjs/toolkit": "^2.2.1", "@rtk-query/graphql-request-base-query": "^2.3.1", "@solar-icons/react": "^1.0.1", + "@tanstack/react-query": "^5.100.14", + "@tanstack/react-query-devtools": "^5.100.14", "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-devtools": "^1.166.13", "@xyflow/react": "^12.7.0", @@ -6685,6 +6687,60 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/query-core": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.14.tgz", + "integrity": "sha512-g96SmSSQecYTYcyuAMRXr895GplJv01UGt7qttQWPOUyZ5EGz5tbRc589bMc2m5BsPFD6O0PCEAHdbDYNP6UBw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@tanstack/query-core": "5.100.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.14.tgz", + "integrity": "sha512-JkP5VDgKOw3t/QSA1OABRHEqx8BuNs5MfvZRooNqdvN57SzTuGq3fKR1a2IH5rqa5HDLUm+FOXUEnB9ueHiLzg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.100.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.100.14", + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-router": { "version": "1.169.2", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.169.2.tgz", @@ -24750,6 +24806,33 @@ "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", "integrity": "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==" }, + "@tanstack/query-core": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==" + }, + "@tanstack/query-devtools": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.14.tgz", + "integrity": "sha512-g96SmSSQecYTYcyuAMRXr895GplJv01UGt7qttQWPOUyZ5EGz5tbRc589bMc2m5BsPFD6O0PCEAHdbDYNP6UBw==" + }, + "@tanstack/react-query": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "peer": true, + "requires": { + "@tanstack/query-core": "5.100.14" + } + }, + "@tanstack/react-query-devtools": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.14.tgz", + "integrity": "sha512-JkP5VDgKOw3t/QSA1OABRHEqx8BuNs5MfvZRooNqdvN57SzTuGq3fKR1a2IH5rqa5HDLUm+FOXUEnB9ueHiLzg==", + "requires": { + "@tanstack/query-devtools": "5.100.14" + } + }, "@tanstack/react-router": { "version": "1.169.2", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.169.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index d6b842ef9..8be5288c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,8 @@ "@reduxjs/toolkit": "^2.2.1", "@rtk-query/graphql-request-base-query": "^2.3.1", "@solar-icons/react": "^1.0.1", + "@tanstack/react-query": "^5.100.14", + "@tanstack/react-query-devtools": "^5.100.14", "@tanstack/react-router": "^1.169.2", "@tanstack/react-router-devtools": "^1.166.13", "@xyflow/react": "^12.7.0", diff --git a/frontend/src/api/annotation-task/hooks.ts b/frontend/src/api/annotation-task/hooks.ts index 1f5c6d5b9..463a9a889 100644 --- a/frontend/src/api/annotation-task/hooks.ts +++ b/frontend/src/api/annotation-task/hooks.ts @@ -7,9 +7,8 @@ import { type ListAnnotationTaskQueryVariables, useCurrentCampaign, useCurrentPhase, - useCurrentUser, } from '@/api'; -import { useParams, useSearch } from '@tanstack/react-router'; +import { useLoaderData, useParams, useSearch } from '@tanstack/react-router'; import { useAppSelector } from '@/features/App'; import { selectAnalysisID } from '@/features/Annotator/Analysis'; import { GetAnnotationTaskQueryVariables } from './annotation-task.generated' @@ -35,17 +34,17 @@ export const useAllAnnotationTasks = (filters: AllTasksFilters, options: { } = {}) => { const { campaignID, phaseType } = useParams({ strict: false }); const { campaign } = useCurrentCampaign() - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) const info = listAnnotationTask.useQuery({ ...filters, campaignID: campaignID ?? '', phaseType: phaseType ?? AnnotationPhaseType.Annotation, - annotatorID: user?.id ?? '', + annotatorID: user.id, limit: PAGE_SIZE, offset: PAGE_SIZE * ((filters.page ?? 1) - 1), }, { - skip: !user || !campaignID || !phaseType || campaign?.isArchived, + skip: !campaignID || !phaseType || campaign?.isArchived, ...options, }) return useMemo(() => ({ @@ -63,7 +62,7 @@ export const useGetAnnotationTaskParams = (): GetAnnotationTaskQueryVariables => select: ({ campaignID, phaseType, spectrogramID }) => ({ campaignID, phaseType, spectrogramID }) }); const analysisID = useAppSelector(selectAnalysisID) - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) const params = useSearch({ strict: false, }); @@ -73,7 +72,7 @@ export const useGetAnnotationTaskParams = (): GetAnnotationTaskQueryVariables => spectrogramID: spectrogramID ?? '', campaignID: campaignID ?? '', phaseType: phaseType ?? AnnotationPhaseType.Annotation, - annotatorID: user?.id ?? '', + annotatorID: user.id, analysisID: analysisID ?? '', }), [ params, campaignID, phaseType, spectrogramID, user, analysisID ]) } diff --git a/frontend/src/api/auth/api.ts b/frontend/src/api/auth/api.ts index 4964eb512..6a4203762 100644 --- a/frontend/src/api/auth/api.ts +++ b/frontend/src/api/auth/api.ts @@ -1,4 +1,6 @@ import { restAPI } from '../baseRestApi'; +import { invalidateEntity, queryKeys } from '@/api/queryKeys'; +import { queryClient } from '@/api/queryClient'; export type Token = string | undefined; @@ -14,15 +16,15 @@ export const AuthRestAPI = restAPI.injectEndpoints({ }), transformResponse: (response: LoginResponse) => { document.cookie = `token=${ response.access };max-age=28000;path=/`; + invalidateEntity(queryKeys.user.current) return response; }, }), - logout: builder.mutation({ + logout: builder.mutation({ queryFn: async () => { document.cookie = 'token=;max-age=0;path=/'; - return { - data: undefined, - } + queryClient.invalidateQueries() + return { data: null } }, }), terms: builder.query({ @@ -35,4 +37,4 @@ export const AuthRestAPI = restAPI.injectEndpoints({ }) }), }), -}) \ No newline at end of file +}) diff --git a/frontend/src/api/auth/hooks.ts b/frontend/src/api/auth/hooks.ts index ca02ace2d..04f483034 100644 --- a/frontend/src/api/auth/hooks.ts +++ b/frontend/src/api/auth/hooks.ts @@ -1,24 +1,27 @@ import { AuthRestAPI } from './api'; import { useCallback } from 'react'; import { gqlAPI, GqlTags } from '@/api/baseGqlApi.ts'; +import { useNavigate } from '@tanstack/react-router'; const { - login, - logout, + login, + logout, } = AuthRestAPI.endpoints export const useLogin = login.useMutation export const useLogout = () => { - const [_method, info] = logout.useMutation() + const [ _method, info ] = logout.useMutation() + const navigate = useNavigate(); - const method = useCallback(() => { - gqlAPI.util.invalidateTags(GqlTags) - return _method() - }, [_method]) + const method = useCallback(async () => { + gqlAPI.util.invalidateTags(GqlTags) + await navigate({ to: '/login' }) + return _method() + }, [ _method, navigate ]) - return { - logout: method, - ...info - } + return { + logout: method, + ...info, + } } \ No newline at end of file diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts index b3dd01db7..7588ab9c9 100644 --- a/frontend/src/api/auth/index.ts +++ b/frontend/src/api/auth/index.ts @@ -1,4 +1,3 @@ export * from './api' export * from './hooks' -export * from './matchers' export * from './middlewares' diff --git a/frontend/src/api/auth/matchers.ts b/frontend/src/api/auth/matchers.ts deleted file mode 100644 index 4fe668889..000000000 --- a/frontend/src/api/auth/matchers.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { AuthRestAPI } from "./api"; - -const { - login, - logout, -} = AuthRestAPI.endpoints - -export const loginFulfilled = login.matchFulfilled -export const logoutFulfilled = logout.matchFulfilled \ No newline at end of file diff --git a/frontend/src/api/graphqlClient.ts b/frontend/src/api/graphqlClient.ts new file mode 100644 index 000000000..884f1e99d --- /dev/null +++ b/frontend/src/api/graphqlClient.ts @@ -0,0 +1,58 @@ +import type { RequestExtendedOptions } from 'graphql-request'; +import { GraphQLClient } from 'graphql-request'; +import { getTokenFromCookie } from '@/api/utils'; + + +/** + * Middleware pour ajouter le token aux requêtes + */ +const requestMiddleware = async (request: any) => { + const token = getTokenFromCookie(); + + if (!token) { + document.cookie = 'token=;max-age=0;path=/'; + throw new Error('Unauthorized'); + } + + if (token) { + request.headers = request.headers ?? {} + request.headers.Authorization = `Bearer ${ token }`; + } + + return request; +} + +/** + * Middleware pour gérer les erreurs GraphQL + */ +const responseMiddleware = (response: any) => { + // Gestion des erreurs d'authentification + const errors = response.errors ?? response.response?.errors + if (errors) { + const hasAuthError = errors.some((err: any) => + err.message?.includes('Unauthorized') || + err.extensions?.code === 'UNAUTHENTICATED', + ); + + if (hasAuthError) { + document.cookie = 'token=;max-age=0;path=/'; + throw new Error('Unauthorized'); + } + + } + + return response; +} + +/** + * Client GraphQL pré-configuré + */ +export const graphqlClient = new GraphQLClient( + process.env.REACT_APP_GRAPHQL_ENDPOINT || '/api/graphql', + { + requestMiddleware, + responseMiddleware, + }, +); + +export type RequestOptions = RequestExtendedOptions; diff --git a/frontend/src/api/queryClient.tsx b/frontend/src/api/queryClient.tsx new file mode 100644 index 000000000..650aa05ec --- /dev/null +++ b/frontend/src/api/queryClient.tsx @@ -0,0 +1,27 @@ +import { QueryClient } from '@tanstack/react-query'; + +/** + * Création du QueryClient avec configuration optimisée + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Temps de cache des données : 5 minutes + staleTime: 5 * 60 * 1000, + // Temps avant garbage collection : 10 minutes + gcTime: 10 * 60 * 1000, + // Nombre de tentatives en cas d'erreur + retry: 1, + // Délai avant nouvelle tentative + retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000), + // Ne refetch pas au focus automatiquement en dev + refetchOnWindowFocus: process.env.NODE_ENV === 'production', + // Ne refetch pas lors de remontage + refetchOnMount: false, + }, + mutations: { + // Les mutations sont critiques, pas de retry automatique + retry: 0, + }, + }, +}); diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts new file mode 100644 index 000000000..9724c3e01 --- /dev/null +++ b/frontend/src/api/queryKeys.ts @@ -0,0 +1,80 @@ +import { queryClient } from './queryClient'; +import type { QueryKey } from '@tanstack/react-query'; + +/** + * Keys factory pour les requêtes GraphQL + * Permet d'invalider les requêtes de manière prévisible + */ +export const queryKeys = { + user: { + all: [ 'user' ] as const, + current: [ 'user', 'current' ] as const, + }, +}; + +/** + * Interface standard pour les réponses paginées + */ +export interface PageInfo { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string | null; + endCursor: string | null; +} + +/** + * Invalide tous les caches pour une entité + */ +export function invalidateEntity(entityKey: QueryKey) { + queryClient.invalidateQueries({ queryKey: entityKey }); +} + +/** + * Invalide les caches liés en cascade + * Exemple : créer un post invalide aussi la liste et l'utilisateur + */ +export function invalidateRelated( + primaryKey: QueryKey, + relatedKeys: QueryKey[], +) { + queryClient.invalidateQueries({ queryKey: primaryKey }); + relatedKeys.forEach((key) => { + queryClient.invalidateQueries({ queryKey: key }); + }); +} + +/** + * Mise à jour optimiste du cache + * Utile pour les mutations sans refetch + */ +export function updateCache( + key: QueryKey, + updater: (old: T) => T, +): void { + const oldData = queryClient.getQueryData(key); + if (oldData) { + queryClient.setQueryData(key, updater(oldData)); + } +} + +/** + * Précharge une requête (useful pour les links au hover) + */ +export async function prefetchQuery( + key: QueryKey, + queryFn: () => Promise, +) { + await queryClient.prefetchQuery({ + queryKey: key, + queryFn, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +} + +/** + * Nettoie le cache complètement + * Utile au logout + */ +export function clearCache() { + queryClient.clear(); +} \ No newline at end of file diff --git a/frontend/src/api/user/api.ts b/frontend/src/api/user/api.ts index f375c29fb..b21d6d345 100644 --- a/frontend/src/api/user/api.ts +++ b/frontend/src/api/user/api.ts @@ -1,10 +1,7 @@ -import { api } from "./user.generated"; +import { api } from './user.generated'; export const UserGqlAPI = api.enhanceEndpoints({ endpoints: { - getCurrentUser: { - providesTags: [ 'CurrentUser' ] - }, listUsers: { providesTags: [ 'User' ] }, diff --git a/frontend/src/api/user/hooks.ts b/frontend/src/api/user/hooks.ts index 2618cc923..1be815ecc 100644 --- a/frontend/src/api/user/hooks.ts +++ b/frontend/src/api/user/hooks.ts @@ -1,23 +1,12 @@ import { useMemo } from 'react'; import { UserGqlAPI } from './api'; -import { AppStore } from '@/features/App'; -import { getTokenFromCookie } from '@/api/utils'; const { - getCurrentUser, listUsers, updateCurrentUserEmail, updateCurrentUserPassword, } = UserGqlAPI.endpoints -export const useCurrentUser = () => { - const info = getCurrentUser.useQuery() - return useMemo(() => ({ - ...info, - user: info?.data?.currentUser, - }), [ info ]) -} - export const useAllUsers = () => { const info = listUsers.useQuery() return useMemo(() => ({ @@ -58,17 +47,3 @@ export const useUpdateCurrentUserPassword = () => { }, [ info ]), } } - -export async function loadUser() { - const getCurrentUserState = getCurrentUser.select()(AppStore.getState()) - const user = getCurrentUserState.data?.currentUser - if (user) return user - - const token = getTokenFromCookie(); - if (!token) return; - - const promise = AppStore.dispatch(UserGqlAPI.endpoints.getCurrentUser.initiate()) - const { data } = await promise - promise.unsubscribe() - return data?.currentUser -} diff --git a/frontend/src/api/user/index.ts b/frontend/src/api/user/index.ts index 0ff430057..b5ce99c54 100644 --- a/frontend/src/api/user/index.ts +++ b/frontend/src/api/user/index.ts @@ -1,10 +1,6 @@ export type { - GetCurrentUserQuery, - GetCurrentUserQueryVariables, ListUsersQuery, ListUsersQueryVariables, } from './user.generated' export * from './hooks' -export * from './matchers' -export * from './middlewares' -export * from './selectors' \ No newline at end of file +export * from './middlewares' \ No newline at end of file diff --git a/frontend/src/api/user/matchers.ts b/frontend/src/api/user/matchers.ts deleted file mode 100644 index 22753d3fb..000000000 --- a/frontend/src/api/user/matchers.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { UserGqlAPI } from './api'; - -const { - getCurrentUser, -} = UserGqlAPI.endpoints - -export const getCurrentUserFulfilled = getCurrentUser.matchFulfilled \ No newline at end of file diff --git a/frontend/src/api/user/selectors.ts b/frontend/src/api/user/selectors.ts deleted file mode 100644 index a327b4009..000000000 --- a/frontend/src/api/user/selectors.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { UserGqlAPI } from './api'; - -export const selectCurrentUser = createSelector( - UserGqlAPI.endpoints.getCurrentUser.select(), - (userQuery) => userQuery.data?.currentUser, -) -export const selectIsLoadingUser = createSelector( - UserGqlAPI.endpoints.getCurrentUser.select(), - (userQuery) => userQuery.isLoading, -) -export const selectIsFetchingUser = createSelector( - UserGqlAPI.endpoints.getCurrentUser.select(), - (userQuery) => userQuery.isUninitialized, -) diff --git a/frontend/src/api/user/user.graphql b/frontend/src/api/user/user.graphql index 6f1ee2441..a4a1ba288 100644 --- a/frontend/src/api/user/user.graphql +++ b/frontend/src/api/user/user.graphql @@ -1,13 +1,3 @@ -query getCurrentUser { - currentUser { - id - displayName - isAdmin - isSuperuser - username - email - } -} query listUsers { allUsers { diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index ad5220195..1bcf8fb1e 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,17 +1,15 @@ import React, { Fragment, ReactNode, useCallback, useMemo, useState } from 'react'; import { useNavigate } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query'; import { IonButton, IonIcon } from '@ionic/react'; import { closeOutline, menuOutline } from 'ionicons/icons/index.js'; import { DocumentationButton, Link } from '@/components/ui'; -import { selectCurrentUser } from '@/api'; - -import { useAppSelector } from '@/features/App'; +import { User } from '@/features'; import logo from '/images/ode_logo_192x192.png'; import styles from './layout.module.scss' -import { selectIsConnected } from '@/features/Auth'; export const Header: React.FC<{ buttons?: ReactNode; @@ -19,22 +17,22 @@ export const Header: React.FC<{ size?: 'small' | 'default'; canNavigate?: () => Promise; }> = ({ children, buttons, size, canNavigate }) => { + const { data: user } = useQuery(User.API.currentQuery) const [ isOpen, setIsOpen ] = useState(false); - const currentUser = useAppSelector(selectCurrentUser) const navigate = useNavigate(); const toggleOpening = useCallback(() => setIsOpen(previous => !previous), []) const onAPLOSEClick = useCallback(async () => { - if (currentUser) { + if (user) { if (!canNavigate || await canNavigate()) { await navigate({ to: `/annotation-campaign` }); } } else { await navigate({ to: `/` }); } - }, [ currentUser, navigate, canNavigate ]) + }, [ user, navigate, canNavigate ]) const onAPLOSEAuxClick = useCallback(() => { window.open('/', '_blank'); @@ -66,16 +64,16 @@ export const Header: React.FC<{ } export const PublicHeader: React.FC = () => { - const isConnected = useAppSelector(selectIsConnected); + const { data: user } = useQuery(User.API.currentQuery) return useMemo(() =>
- - { isConnected ? 'APLOSE' : 'Login' } + + { user ? 'APLOSE' : 'Login' } OSmOSE }/>, - [ isConnected ], + [ user ], ) -} \ No newline at end of file +} diff --git a/frontend/src/components/layout/Navbar.tsx b/frontend/src/components/layout/Navbar.tsx index c1bb8d5c1..6efd43705 100644 --- a/frontend/src/components/layout/Navbar.tsx +++ b/frontend/src/components/layout/Navbar.tsx @@ -1,67 +1,70 @@ import React, { Fragment, useCallback, useState } from 'react'; import { IonButton, IonIcon } from '@ionic/react'; import { closeOutline, menuOutline } from 'ionicons/icons/index.js'; -import { useCurrentUser, useLogout } from '@/api'; +import { useLogout } from '@/api'; import { DocumentationButton, Link } from '@/components/ui'; import styles from './layout.module.scss'; import logo from '/images/ode_logo_192x192.png'; +import { useQuery } from '@tanstack/react-query'; +import { User } from '@/features'; export const Navbar: React.FC<{ className?: string }> = ({ className }) => { - const [ isOpen, setIsOpen ] = useState(false); - const { logout } = useLogout() - const { user } = useCurrentUser() + const [ isOpen, setIsOpen ] = useState(false); + const { logout } = useLogout() + const { data: user } = useQuery(User.API.currentQuery) - const toggleOpening = useCallback(() => { - setIsOpen(previous => !previous); - }, [ setIsOpen ]) + const toggleOpening = useCallback(() => { + setIsOpen(previous => !previous); + }, [ setIsOpen ]) - const close = useCallback(() => setIsOpen(false), [ setIsOpen ]) + const close = useCallback(() => setIsOpen(false), [ setIsOpen ]) - return ( -
+ return ( +
-
- - APLOSE -

APLOSE

- +
+ + APLOSE +

APLOSE

+ - - - -
+ + + +
-
+
-
- - Annotation campaigns - - { user?.isAdmin && - Datasets - } - { user?.isAdmin && - Storage - } -
+
+ + Annotation campaigns + + { user?.isAdmin && + Datasets + } + { user?.isAdmin && + Storage + } +
- { user?.isAdmin && - Admin - } + { user?.isAdmin && + Admin + } - + - { user?.isSuperuser && Ontology } - { user?.isSuperuser && SQL query } + { user?.isSuperuser && Ontology } + { user?.isSuperuser && SQL query } - Account + Account - logout() }> - Logout - -
-
) + logout() }> + Logout + +
+
) } \ No newline at end of file diff --git a/frontend/src/features/AnnotationCampaign/CampaignFilters/AnnotatorFilter.tsx b/frontend/src/features/AnnotationCampaign/CampaignFilters/AnnotatorFilter.tsx index a9ae0acc0..bc938e013 100644 --- a/frontend/src/features/AnnotationCampaign/CampaignFilters/AnnotatorFilter.tsx +++ b/frontend/src/features/AnnotationCampaign/CampaignFilters/AnnotatorFilter.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react'; -import { useCurrentUser } from '@/api'; -import { useNavigate } from '@tanstack/react-router' +import { useLoaderData, useNavigate } from '@tanstack/react-router' import { IonChip, IonIcon } from '@ionic/react'; import { closeCircle } from 'ionicons/icons'; import { Route } from '@/routes/_authenticated/annotation-campaign'; @@ -9,14 +8,14 @@ export const AnnotationCampaignAnnotatorFilter: React.FC = () => { const filter_annotatorID = Route.useSearch({select: ({filter_annotatorID}) => filter_annotatorID}); const navigate = useNavigate(); - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) const toggle = useCallback(() => { navigate({ to: Route.to, search: (prev) => ({ ...prev, - filter_annotatorID: prev?.filter_annotatorID ? null : user?.id + filter_annotatorID: prev?.filter_annotatorID ? null : user.id }), replace: true, }) diff --git a/frontend/src/features/AnnotationCampaign/CampaignFilters/OwnerFilter.tsx b/frontend/src/features/AnnotationCampaign/CampaignFilters/OwnerFilter.tsx index e7b201381..fe3906cb0 100644 --- a/frontend/src/features/AnnotationCampaign/CampaignFilters/OwnerFilter.tsx +++ b/frontend/src/features/AnnotationCampaign/CampaignFilters/OwnerFilter.tsx @@ -1,22 +1,21 @@ import React, { useCallback } from 'react'; -import { useCurrentUser } from '@/api'; import { IonChip, IonIcon } from '@ionic/react'; import { closeCircle } from 'ionicons/icons'; import { Route } from '@/routes/_authenticated/annotation-campaign'; -import { useNavigate } from '@tanstack/react-router'; +import { useLoaderData, useNavigate } from '@tanstack/react-router'; export const AnnotationCampaignOwnerFilter: React.FC = () => { const filter_ownerID = Route.useSearch({ select: ({ filter_ownerID }) => filter_ownerID }); const navigate = useNavigate(); - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) const toggle = useCallback(() => { navigate({ to: Route.to, search: (prev) => ({ ...prev, - filter_ownerID: prev?.filter_ownerID ? null : user?.id, + filter_ownerID: prev?.filter_ownerID ? null : user.id, }), replace: true, }) diff --git a/frontend/src/features/AnnotationCampaign/CampaignFilters/ResetButton.tsx b/frontend/src/features/AnnotationCampaign/CampaignFilters/ResetButton.tsx index 0ce91b99e..423397a9e 100644 --- a/frontend/src/features/AnnotationCampaign/CampaignFilters/ResetButton.tsx +++ b/frontend/src/features/AnnotationCampaign/CampaignFilters/ResetButton.tsx @@ -1,14 +1,14 @@ import React, { Fragment, useCallback, useMemo } from 'react'; -import { type AllCampaignFilters, useCurrentUser } from '@/api'; +import { type AllCampaignFilters } from '@/api'; import { IonButton, IonIcon } from '@ionic/react'; import { refreshOutline } from 'ionicons/icons'; import { Route } from '@/routes/_authenticated/annotation-campaign'; -import { useNavigate } from '@tanstack/react-router'; +import { useLoaderData, useNavigate } from '@tanstack/react-router'; export const AnnotationCampaignResetFiltersButton: React.FC = () => { const searchParams = Route.useSearch(); const navigate = useNavigate(); - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) const canReset = useMemo(() => { return !(!searchParams.search && searchParams.filter_isArchived == false && !searchParams.filter_phase && !!searchParams.filter_annotatorID && !searchParams.filter_ownerID) @@ -20,7 +20,7 @@ export const AnnotationCampaignResetFiltersButton: React.FC = () => { search: null, filter_isArchived: false, filter_phase: null, - filter_annotatorID: user?.id, + filter_annotatorID: user.id, filter_ownerID: null, filter_datasetID: null, } as AllCampaignFilters, diff --git a/frontend/src/features/AnnotationCampaign/CampaignFilters/index.tsx b/frontend/src/features/AnnotationCampaign/CampaignFilters/index.tsx index f01ec3771..311851cba 100644 --- a/frontend/src/features/AnnotationCampaign/CampaignFilters/index.tsx +++ b/frontend/src/features/AnnotationCampaign/CampaignFilters/index.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { IonIcon } from '@ionic/react'; import { addOutline } from 'ionicons/icons/index.js'; -import { useCurrentUser } from '@/api'; import { ActionBar, Link } from '@/components/ui'; import { AnnotationCampaignResetFiltersButton } from './ResetButton'; @@ -10,14 +9,14 @@ import { AnnotationCampaignOwnerFilter } from './OwnerFilter'; import { AnnotationCampaignPhaseTypeFilter } from './PhaseFilter'; import { AnnotationCampaignAnnotatorFilter } from './AnnotatorFilter'; import { Route } from '@/routes/_authenticated/annotation-campaign'; -import { useNavigate } from '@tanstack/react-router'; +import { useLoaderData, useNavigate } from '@tanstack/react-router'; export const AnnotationCampaignListFilterActionBar: React.FC = () => { const search = Route.useSearch({ select: ({search}) => search }); const navigate = useNavigate(); - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) return { }), replace: true, }) } - actionButton={ user?.isAdmin && diff --git a/frontend/src/features/AnnotationTask/StatusFilter.tsx b/frontend/src/features/AnnotationTask/StatusFilter.tsx index ed8a7aa4c..21561d2b9 100644 --- a/frontend/src/features/AnnotationTask/StatusFilter.tsx +++ b/frontend/src/features/AnnotationTask/StatusFilter.tsx @@ -2,15 +2,20 @@ import React, { useCallback } from 'react'; import styles from './styles.module.scss' import { Modal, type ModalProps } from '@/components/ui'; import { Input, Switch } from '@/components/form'; -import { AnnotationTaskStatus, useCurrentUser } from '@/api'; +import { AnnotationTaskStatus } from '@/api'; import { Route } from '@/routes/_authenticated/annotation-campaign/$campaignID/_detailLayout/phase.$phaseType'; -import { useNavigate } from '@tanstack/react-router'; +import { useLoaderData, useNavigate } from '@tanstack/react-router'; export const StatusFilterModal: React.FC void }> = ({ onUpdate, onClose }) => { - const { user } = useCurrentUser() - const { status, onlyAssigned } = Route.useSearch({select: ({status, onlyAssigned}) => ({ status, onlyAssigned })}); + const { user } = useLoaderData({ from: '/_authenticated' }) + const { status, onlyAssigned } = Route.useSearch({ + select: ({ status, onlyAssigned }) => ({ + status, + onlyAssigned, + }), + }); const routeParams = Route.useParams() const navigate = useNavigate(); @@ -56,11 +61,11 @@ export const StatusFilterModal: React.FC - { user?.isAdmin && + { user.isAdmin && } + onChange={ onOnlyAssignedChanged }/> } } \ No newline at end of file diff --git a/frontend/src/features/Annotator/Annotation/AnnotationRow.tsx b/frontend/src/features/Annotator/Annotation/AnnotationRow.tsx index fe487ed12..26584f85c 100644 --- a/frontend/src/features/Annotator/Annotation/AnnotationRow.tsx +++ b/frontend/src/features/Annotator/Annotation/AnnotationRow.tsx @@ -10,7 +10,6 @@ import { useAnnotationTask, useCurrentCampaign, useCurrentPhase, - useCurrentUser, } from '@/api'; import { useGetAnnotations, @@ -32,7 +31,7 @@ import { useKeyDownEvent } from '@/features/UX'; import { useAppDispatch, useAppSelector } from '@/features/App'; import { selectAnnotation } from '@/features/Annotator/Annotation/selectors'; import { UpdateLabelModal } from '@/features/Annotator/Label/UpdateLabelModal'; -import { useParams } from '@tanstack/react-router'; +import { useLoaderData, useParams } from '@tanstack/react-router'; type Spectro = NonNullable type Task = NonNullable @@ -49,7 +48,7 @@ export const AnnotationRow: React.FC<{ annotation: Annotation }> = ({ annotation const removeAnnotation = useRemoveAnnotation() const { annotations } = useAnnotationTask() const focusTime = useFocusCanvasOnTime() - const { user } = useCurrentUser() + const { user } = useLoaderData({ from: '/_authenticated' }) const updateAnnotation = useUpdateAnnotation() const updateLabel = useCallback((label: string) => { @@ -87,7 +86,7 @@ export const AnnotationRow: React.FC<{ annotation: Annotation }> = ({ annotation const onValidate = useCallback((event: MouseEvent) => { event.stopPropagation() validate(annotation); - }, [ annotation ]); + }, [ annotation, validate ]); const onInvalidate = useCallback((event: MouseEvent) => { event.stopPropagation() @@ -126,9 +125,9 @@ export const AnnotationRow: React.FC<{ annotation: Annotation }> = ({ annotation

{ completeInfo?.detectorConfiguration.detector.name }

: - + -

{ completeInfo?.annotator?.displayName } { completeInfo?.annotator?.id === user?.id ? '(self)' : '' }

+

{ completeInfo?.annotator?.displayName } { completeInfo?.annotator?.id === user.id ? '(self)' : '' }

) } @@ -140,7 +139,7 @@ export const AnnotationRow: React.FC<{ annotation: Annotation }> = ({ annotation {/* Validation */ } { phaseType === AnnotationPhaseType.Verification && - { completeInfo?.annotator?.id !== user?.id ? + { completeInfo?.annotator?.id !== user.id ? @@ -68,7 +68,7 @@ export const useGetAnnotation = () => { export const useAddAnnotation = () => { const getNewID = useGetNewAnnotationID() - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) const { phase } = useCurrentPhase() const dispatch = useAppDispatch(); @@ -78,7 +78,7 @@ export const useAddAnnotation = () => { ...annotation, annotationPhase: phase.id, id: getNewID(), - annotator: user?.id, + annotator: user.id, })).payload as Annotation dispatch(focusAnnotation(addedAnnotation)) }, [ dispatch, getNewID, user, phase ]) @@ -139,7 +139,7 @@ export const useInvalidateAnnotation = () => { export const useUpdateAnnotation = () => { const { phaseType } = useParams({ strict: false }); - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) const { phase } = useCurrentPhase() const allAnnotations = useAppSelector(selectAllAnnotations); const defaultConfidence = useAppSelector(selectDefaultConfidence); @@ -153,7 +153,7 @@ export const useUpdateAnnotation = () => { return useCallback((annotation: Annotation, update: Partial) => { if (!phase) return; if (annotation.type === AnnotationType.Weak && 'label' in update) return; - if (phaseType === 'Annotation' || annotation.annotator === user?.id) { + if (phaseType === 'Annotation' || annotation.annotator === user.id) { annotation = dispatch(updateAnnotation({ id: annotation.id, ...update })).payload as Annotation } else { // Verification mode @@ -161,7 +161,7 @@ export const useUpdateAnnotation = () => { ...annotation, // Base is initial annotation ...(annotation.update ?? { id: getNewID() }), // Add existing update info if exist ...update, // Update according provided info - annotator: user?.id, // Current user is this update creator + annotator: user.id, // Current user is this update creator detectorConfiguration: undefined, // Detector is no longer the creator of this update validation: undefined, annotationPhase: phase.id, @@ -186,7 +186,7 @@ export const useUpdateAnnotation = () => { export const useRemoveAnnotation = () => { const { phaseType } = useParams({ strict: false }); - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) const allAnnotations = useAppSelector(selectAllAnnotations); const getAnnotations = useGetAnnotations() @@ -209,7 +209,7 @@ export const useRemoveAnnotation = () => { }) return; } - if (phaseType === 'Annotation' || annotation.annotator === user?.id) { + if (phaseType === 'Annotation' || annotation.annotator === user.id) { dispatch(removeAnnotation(annotation)) const weak = allAnnotations.find(a => a.type === AnnotationType.Weak && a.label === annotation.label && a.id !== annotation.id); if (weak && focusWeak) dispatch(focusAnnotation(weak)) diff --git a/frontend/src/features/Annotator/Annotation/slice.ts b/frontend/src/features/Annotator/Annotation/slice.ts index c21b1f5c8..61cb34676 100644 --- a/frontend/src/features/Annotator/Annotation/slice.ts +++ b/frontend/src/features/Annotator/Annotation/slice.ts @@ -7,11 +7,10 @@ import { AnnotationValidationSerializerInput, getAnnotationTaskFulfilled, GetAnnotationTaskQuery, - getCurrentUserFulfilled, - type GetCurrentUserQuery, } from '@/api'; import type { GetAnnotationTaskQueryVariables } from '@/api/annotation-task/annotation-task.generated'; import { convertGqlToAnnotations } from '@/features/Annotator/Annotation/conversions'; +import { User } from '@/features'; export type Comment = Omit & { id: number } @@ -36,7 +35,6 @@ type AnnotationState = { tempAnnotation?: TempAnnotation; _campaignID?: string - _userID?: string } const initialState: AnnotationState = { @@ -45,7 +43,6 @@ const initialState: AnnotationState = { tempAnnotation: undefined, _campaignID: undefined, - _userID: undefined, } export const AnnotatorAnnotationSlice = createSlice({ @@ -82,11 +79,6 @@ export const AnnotatorAnnotationSlice = createSlice({ }, }, extraReducers: builder => { - builder.addMatcher(getCurrentUserFulfilled, (state: AnnotationState, action: { - payload: GetCurrentUserQuery - }) => { - state._userID = action.payload.currentUser?.id - }) builder.addMatcher(getAnnotationTaskFulfilled, (state: AnnotationState, action: { payload: GetAnnotationTaskQuery, meta: { arg: { originalArgs: GetAnnotationTaskQueryVariables } } @@ -99,7 +91,8 @@ export const AnnotatorAnnotationSlice = createSlice({ ...action.payload.annotationSpectrogramById?.task?.userAnnotations?.results ?? [], ...action.payload.annotationSpectrogramById?.task?.annotationsToCheck?.results ?? [], ].filter(a => a !== null).map(a => a!) ?? [] - state.allAnnotations = convertGqlToAnnotations(annotations, action.meta.arg.originalArgs.phaseType, state._userID) + const user = User.API.currentQueryCache()?.state.data + state.allAnnotations = convertGqlToAnnotations(annotations, action.meta.arg.originalArgs.phaseType, user?.id) const defaultAnnotation = [ ...state.allAnnotations ].reverse().pop(); state.id = defaultAnnotation?.id }) diff --git a/frontend/src/features/Annotator/Comment/CommentBloc.tsx b/frontend/src/features/Annotator/Comment/CommentBloc.tsx index ec3193532..defbf7329 100644 --- a/frontend/src/features/Annotator/Comment/CommentBloc.tsx +++ b/frontend/src/features/Annotator/Comment/CommentBloc.tsx @@ -9,51 +9,51 @@ import { swapHorizontalOutline } from 'ionicons/icons'; import { useAppDispatch, useAppSelector } from '@/features/App'; import { selectFocusedComment } from './selectors'; import { blur, selectAnnotation } from '@/features/Annotator/Annotation'; -import { useCurrentUser } from '@/api'; +import { useLoaderData } from '@tanstack/react-router'; export const CommentBloc: React.FC = () => { - const focusedAnnotation = useAppSelector(selectAnnotation) - const focusedComment = useAppSelector(selectFocusedComment) - const { user } = useCurrentUser(); - const add = useAddComment() - const update = useUpdateComment() - const remove = useRemoveComment() - const dispatch = useAppDispatch() + const focusedAnnotation = useAppSelector(selectAnnotation) + const focusedComment = useAppSelector(selectFocusedComment) + const { user } = useLoaderData({ from: '/_authenticated' }) + const add = useAddComment() + const update = useUpdateComment() + const remove = useRemoveComment() + const dispatch = useAppDispatch() - const updateComment = useCallback((event: ChangeEvent) => { - if (focusedComment) update({ ...focusedComment, comment: event.target.value }) - else add(event.target.value) - }, [ focusedComment, dispatch, update, add ]) + const updateComment = useCallback((event: ChangeEvent) => { + if (focusedComment) update({ ...focusedComment, comment: event.target.value }) + else add(event.target.value) + }, [ focusedComment, dispatch, update, add ]) - const onSelectTask = useCallback(() => dispatch(blur()), [ dispatch ]) + const onSelectTask = useCallback(() => dispatch(blur()), [ dispatch ]) - return -