diff --git a/frontend/codegen.ts b/frontend/codegen.ts index 96e59b127..8a70feb65 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, + 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, + arrayInputCoercion: false, + 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-campaign/api.ts b/frontend/src/api/annotation-campaign/api.ts deleted file mode 100644 index 84d42a80c..000000000 --- a/frontend/src/api/annotation-campaign/api.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { api } from './annotation-campaign.generated' - -export const AnnotationCampaignGqlAPI = api.enhanceEndpoints({ - endpoints: { - listCampaigns: { - providesTags: (_result, _error, args) => [ { - type: 'Campaign', - id: JSON.stringify(args), - } ], - }, - getCampaign: { - // @ts-expect-error: result and error are unused - providesTags: (result, error, { id }) => [ { type: 'Campaign', id } ], - }, - createCampaign: { - invalidatesTags: [ 'Campaign' ], - }, - archiveCampaign: { - invalidatesTags: (_result, _error, { id }) => [ 'Campaign', { type: 'Campaign', id } ], - }, - updateCampaignFeaturedLabels: { - invalidatesTags: (_result, _error, { id }) => [ 'Campaign', { type: 'Campaign', id } ], - }, - }, -}) \ No newline at end of file diff --git a/frontend/src/api/annotation-campaign/hooks.ts b/frontend/src/api/annotation-campaign/hooks.ts deleted file mode 100644 index 9cd736ca5..000000000 --- a/frontend/src/api/annotation-campaign/hooks.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { AnnotationCampaignGqlAPI } from './api' -import { useCallback, useMemo } from 'react'; -import { - AnnotationPhaseType, - type CreateCampaignMutationVariables, - type ListCampaignsQueryVariables, - type UpdateCampaignFeaturedLabelsMutationVariables, -} from '@/api'; -import type { GqlError } from '@/api/utils'; -import { useParams } from '@tanstack/react-router'; - -// API - -const { - listCampaigns, - getCampaign, - createCampaign, - updateCampaignFeaturedLabels, - archiveCampaign, -} = AnnotationCampaignGqlAPI.endpoints - -export type AllCampaignFilters = ListCampaignsQueryVariables - -export const useAllCampaigns = (filters: AllCampaignFilters) => { - const info = listCampaigns.useQuery(filters) - return useMemo(() => ({ - ...info, - allCampaigns: info.data?.allAnnotationCampaigns?.results.filter(r => r !== null).map(c => c!), - }), [ info ]); -} - -export const useCurrentCampaign = () => { - const { campaignID } = useParams({ strict: false }) - const info = getCampaign.useQuery({ - id: campaignID ?? '', - }, { skip: !campaignID }) - const phases = useMemo(() => info.data?.annotationCampaignById?.phases?.results.map(p => p!), [ info ]) - return useMemo(() => ({ - ...info, - campaign: info?.data?.annotationCampaignById, - phases, - annotationPhase: phases?.find(p => p!.phase === AnnotationPhaseType.Annotation), - verificationPhase: phases?.find(p => p!.phase === AnnotationPhaseType.Verification), - allAnalysis: info?.data?.annotationCampaignById?.analysis.edges.map(e => e?.node).filter(n => !!n), - }), [ info, phases ]) -} - -export const useCreateCampaign = () => { - const [ method, info ] = createCampaign.useMutation(); - - return { - createCampaign: method, - ...useMemo(() => { - const formErrors = (info.data?.createAnnotationCampaign?.errors ?? []) as GqlError[] - return { - ...info, - campaign: info.data?.createAnnotationCampaign?.annotationCampaign, - isSuccess: info.isSuccess && formErrors.length === 0, - formErrors, - } - }, [ info ]), - } -} - -export const useUpdateCampaignFeaturedLabels = () => { - const { campaignID } = useParams({ strict: false }) - const { campaign } = useCurrentCampaign() - const [ method, info ] = updateCampaignFeaturedLabels.useMutation(); - - const update = useCallback(async (variables: Pick) => { - if (!campaignID || !campaign) return; - await method({ - ...variables, - id: campaignID, - labelSetID: campaign.labelSet!.id, - confidenceSetID: campaign.confidenceSet?.id, - allowPointAnnotation: campaign.allowPointAnnotation, - }).unwrap() - }, [ method, campaignID, campaign ]) - - return { - updateCampaignFeaturedLabels: update, - ...useMemo(() => { - const formErrors = (info.data?.updateAnnotationCampaign?.errors ?? []) as GqlError[] - return { - ...info, - isSuccess: info.isSuccess && formErrors.length === 0, - formErrors, - } - }, [ info ]), - } -} - -export const useArchiveCampaign = () => { - const [ method, info ] = archiveCampaign.useMutation(); - return { archiveCampaign: method, ...info } -} diff --git a/frontend/src/api/annotation-campaign/index.ts b/frontend/src/api/annotation-campaign/index.ts deleted file mode 100644 index a0fa4f535..000000000 --- a/frontend/src/api/annotation-campaign/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type { - GetCampaignQuery, - GetCampaignQueryVariables, - ListCampaignsQuery, - ListCampaignsQueryVariables, - CreateCampaignMutation, - CreateCampaignMutationVariables, - UpdateCampaignFeaturedLabelsMutation, - UpdateCampaignFeaturedLabelsMutationVariables, - ArchiveCampaignMutation, - ArchiveCampaignMutationVariables, -} from './annotation-campaign.generated' -export * from './hooks' -export * from './matchers' -export * from './selector' diff --git a/frontend/src/api/annotation-campaign/matchers.ts b/frontend/src/api/annotation-campaign/matchers.ts deleted file mode 100644 index 79b49cb15..000000000 --- a/frontend/src/api/annotation-campaign/matchers.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AnnotationCampaignGqlAPI } from "./api"; - -const { - getCampaign -} = AnnotationCampaignGqlAPI.endpoints - -export const getCampaignFulfilled = getCampaign.matchFulfilled \ No newline at end of file diff --git a/frontend/src/api/annotation-campaign/selector.ts b/frontend/src/api/annotation-campaign/selector.ts deleted file mode 100644 index 1a899381a..000000000 --- a/frontend/src/api/annotation-campaign/selector.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { AppState } from '@/features/App'; -import { AnnotationCampaignGqlAPI } from './api' - -export const selectCampaign = (state: AppState, id: string) => - AnnotationCampaignGqlAPI.endpoints.getCampaign.select({ id })(state) diff --git a/frontend/src/api/annotation-file-range/api.ts b/frontend/src/api/annotation-file-range/api.ts deleted file mode 100644 index ef3fdb5cc..000000000 --- a/frontend/src/api/annotation-file-range/api.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { api } from './annotation-file-range.generated' - -export const AnnotationFileRangeGqlAPI = api.enhanceEndpoints({ - endpoints: { - listFileRanges: { - // @ts-expect-error: result and error are unused - providesTags: (result, error, args) => [ { - type: 'AnnotationFileRange', - id: JSON.stringify(args) - } ], - }, - updateFileRanges: { - invalidatesTags: [ 'AnnotationFileRange', 'AnnotationTask', 'Campaign', 'AnnotationPhase' ] - } - } -}) \ No newline at end of file diff --git a/frontend/src/api/annotation-file-range/hooks.ts b/frontend/src/api/annotation-file-range/hooks.ts deleted file mode 100644 index 3b403d91c..000000000 --- a/frontend/src/api/annotation-file-range/hooks.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { AnnotationFileRangeGqlAPI } from './api' -import { type AnnotationFileRangeInput, AnnotationPhaseType } from '@/api'; -import { useCallback, useMemo } from 'react'; -import { useParams } from '@tanstack/react-router'; - -const { - listFileRanges, - updateFileRanges, -} = AnnotationFileRangeGqlAPI.endpoints - -export const useAllFileRanges = () => { - const { campaignID, phaseType } = useParams({ strict: false }); - const info = listFileRanges.useQuery({ - campaignID: campaignID ?? '', - phaseType: phaseType ?? AnnotationPhaseType.Annotation, - }, { - skip: !campaignID || !phaseType, - }) - return useMemo(() => ({ - ...info, - allFileRanges: info.data?.allAnnotationFileRanges?.results.filter(r => r !== null).map(r => ({ - ...r!, - firstFileIndex: r.firstFileIndex + 1, - lastFileIndex: r.lastFileIndex + 1, - })), - }), [ info ]) -} - -export const useUpdateFileRanges = () => { - const { campaignID, phaseType } = useParams({ strict: false }); - const [ method, info ] = updateFileRanges.useMutation(); - - const update = useCallback(async ({ - fileRanges, - force, - }: { - fileRanges: Array; - force?: boolean; - }) => { - if (!phaseType || !campaignID) return; - await method({ - campaignID, - phaseType, - fileRanges: fileRanges.map(fr => ({ - id: (fr.id && +fr.id > -1) ? fr.id : undefined, - annotatorId: fr.annotatorId, - lastFileIndex: fr.lastFileIndex - 1, - firstFileIndex: fr.firstFileIndex - 1, - } as AnnotationFileRangeInput)), - force, - }).unwrap() - }, [ method, campaignID, phaseType ]) - - return { - updateFileRanges: update, - ...useMemo(() => { - const formErrors = info.data?.updateAnnotationPhaseFileRanges?.errors ?? [] - return { - ...info, - isSuccess: info.isSuccess && formErrors.length === 0, - formErrors, - } - }, [ info ]), - } - -} \ No newline at end of file diff --git a/frontend/src/api/annotation-file-range/index.ts b/frontend/src/api/annotation-file-range/index.ts deleted file mode 100644 index 85bafdda4..000000000 --- a/frontend/src/api/annotation-file-range/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { - ListFileRangesQuery, ListFileRangesQueryVariables, - UpdateFileRangesMutation, UpdateFileRangesMutationVariables, -} from './annotation-file-range.generated' -export * from './hooks' diff --git a/frontend/src/api/annotation-phase/api.ts b/frontend/src/api/annotation-phase/api.ts deleted file mode 100644 index d257f167f..000000000 --- a/frontend/src/api/annotation-phase/api.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { api } from './annotation-phase.generated' - -export const AnnotationPhaseGqlAPI = api.enhanceEndpoints({ - endpoints: { - getAnnotationPhase: { - providesTags: result => [ - { type: 'AnnotationPhase', id: result?.annotationPhaseByCampaignPhase?.id } - ] - }, - endPhase: { - // @ts-expect-error: result and error are unused - invalidatesTags: (result, error, { campaignID }) => [ { - type: 'Campaign', - id: campaignID - } ] - }, - createAnnotationPhase: { - // @ts-expect-error: result and error are unused - invalidatesTags: (result, error, { campaignID }) => [ { - type: 'Campaign', - id: campaignID - }, 'Campaign' ] - }, - createVerificationPhase: { - // @ts-expect-error: result and error are unused - invalidatesTags: (result, error, { campaignID }) => [ { - type: 'Campaign', - id: campaignID - }, 'Campaign' ] - }, - } -}) \ No newline at end of file diff --git a/frontend/src/api/annotation-phase/hooks.ts b/frontend/src/api/annotation-phase/hooks.ts deleted file mode 100644 index b2bf8b659..000000000 --- a/frontend/src/api/annotation-phase/hooks.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { AnnotationPhaseGqlAPI } from './api'; -import { useMemo } from 'react'; -import { AnnotationPhaseType } from '@/api'; -import { useParams } from '@tanstack/react-router'; - -const { - getAnnotationPhase, - endPhase, - createAnnotationPhase, - createVerificationPhase, -} = AnnotationPhaseGqlAPI.endpoints - -export const useCurrentPhase = () => { - const { campaignID, phaseType } = useParams({ strict: false }); - const info = getAnnotationPhase.useQuery({ - campaignID: campaignID ?? '', - phase: phaseType ?? AnnotationPhaseType.Annotation, - }, { skip: !campaignID || !phaseType }) - return useMemo(() => ({ - ...info, - phase: info.data?.annotationPhaseByCampaignPhase, - }), [ info ]) -} - -export const useEndPhase = () => { - const [ method, info ] = endPhase.useMutation() - return { - endPhase: method, - ...info, - } -} - -export const useCreateAnnotationPhase = () => { - const [ method, info ] = createAnnotationPhase.useMutation() - return { - createAnnotationPhase: method, - ...useMemo(() => { - const formErrors = info.data?.updateAnnotationCampaign?.errors ?? [] - return { - ...info, - isSuccess: info.isSuccess && formErrors.length === 0, - formErrors, - } - }, [ info ]), - } -} - -export const useCreateVerificationPhase = () => { - const [ method, info ] = createVerificationPhase.useMutation() - return { - createVerificationPhase: method, - ...info, - } -} \ No newline at end of file diff --git a/frontend/src/api/annotation-phase/index.ts b/frontend/src/api/annotation-phase/index.ts deleted file mode 100644 index c885e8426..000000000 --- a/frontend/src/api/annotation-phase/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type { - GetAnnotationPhaseQuery, GetAnnotationPhaseQueryVariables, - CreateAnnotationPhaseMutation, CreateAnnotationPhaseMutationVariables, - CreateVerificationPhaseMutation, CreateVerificationPhaseMutationVariables, - EndPhaseMutation, EndPhaseMutationVariables, -} from './annotation-phase.generated' -export * from './hooks' diff --git a/frontend/src/api/annotation-task/api.ts b/frontend/src/api/annotation-task/api.ts deleted file mode 100644 index 0f0b6d804..000000000 --- a/frontend/src/api/annotation-task/api.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { api } from './annotation-task.generated' - -export const AnnotationTaskGqlAPI = api.enhanceEndpoints({ - endpoints: { - listAnnotationTask: { - // @ts-expect-error: result and error are unused - providesTags: (result, error, args) => [ { - type: 'AnnotationTask', - id: JSON.stringify(args), - } ], - }, - getAnnotationTask: { - // @ts-expect-error: result and error are unused - providesTags: (result, error, args) => [ { - type: 'AnnotationTask', - id: JSON.stringify(args), - } ], - }, - submitTask: { - invalidatesTags: [ 'AnnotationPhase' ], - }, - }, -}) diff --git a/frontend/src/api/annotation-task/hooks.ts b/frontend/src/api/annotation-task/hooks.ts deleted file mode 100644 index 1f5c6d5b9..000000000 --- a/frontend/src/api/annotation-task/hooks.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { AnnotationTaskGqlAPI } from './api'; -import { useCallback, useMemo } from 'react'; -import { - AnnotationCommentInput, - AnnotationInput, - AnnotationPhaseType, - type ListAnnotationTaskQueryVariables, - useCurrentCampaign, - useCurrentPhase, - useCurrentUser, -} from '@/api'; -import { useParams, useSearch } from '@tanstack/react-router'; -import { useAppSelector } from '@/features/App'; -import { selectAnalysisID } from '@/features/Annotator/Analysis'; -import { GetAnnotationTaskQueryVariables } from './annotation-task.generated' - -export const PAGE_SIZE = 20; - -// API - -const { - listAnnotationTask, - getAnnotationTask, - submitTask, -} = AnnotationTaskGqlAPI.endpoints - -export type AllTasksFilters = - Pick - & { - page: number -} - -export const useAllAnnotationTasks = (filters: AllTasksFilters, options: { - refetchOnMountOrArgChange?: boolean -} = {}) => { - const { campaignID, phaseType } = useParams({ strict: false }); - const { campaign } = useCurrentCampaign() - const { user } = useCurrentUser(); - - const info = listAnnotationTask.useQuery({ - ...filters, - campaignID: campaignID ?? '', - phaseType: phaseType ?? AnnotationPhaseType.Annotation, - annotatorID: user?.id ?? '', - limit: PAGE_SIZE, - offset: PAGE_SIZE * ((filters.page ?? 1) - 1), - }, { - skip: !user || !campaignID || !phaseType || campaign?.isArchived, - ...options, - }) - return useMemo(() => ({ - ...info, - allSpectrograms: info.data?.allAnnotationSpectrograms?.results.filter(r => r !== null), - resumeSpectrogramID: info.data?.allAnnotationSpectrograms?.resumeSpectrogramId, - page: filters.page, - pageCount: Math.ceil((info.data?.allAnnotationSpectrograms?.totalCount ?? 0) / PAGE_SIZE), - }), [ info, filters.page ]) -} - -export const useGetAnnotationTaskParams = (): GetAnnotationTaskQueryVariables => { - const { campaignID, phaseType, spectrogramID } = useParams({ - strict: false, - select: ({ campaignID, phaseType, spectrogramID }) => ({ campaignID, phaseType, spectrogramID }) - }); - const analysisID = useAppSelector(selectAnalysisID) - const { user } = useCurrentUser(); - const params = useSearch({ - strict: false, - }); - - return useMemo(() => ({ - ...params, - spectrogramID: spectrogramID ?? '', - campaignID: campaignID ?? '', - phaseType: phaseType ?? AnnotationPhaseType.Annotation, - annotatorID: user?.id ?? '', - analysisID: analysisID ?? '', - }), [ params, campaignID, phaseType, spectrogramID, user, analysisID ]) -} - -export const useAnnotationTask = (options: { - refetchOnMountOrArgChange?: boolean, -} = {}) => { - const { phase } = useCurrentPhase() - const params = useGetAnnotationTaskParams() - - const info = getAnnotationTask.useQuery(params, { - ...options, - skip: !params.annotatorID || !params.campaignID || !params.spectrogramID || !params.phaseType || !params.analysisID, - }) - return useMemo(() => ({ - ...info, - spectrogram: info.data?.annotationSpectrogramById, - paths: info.data?.spectrogramPaths, - navigationInfo: info.data?.allAnnotationSpectrograms, - annotations: [ - ...info.data?.annotationSpectrogramById?.task?.userAnnotations?.results ?? [], - ...info.data?.annotationSpectrogramById?.task?.annotationsToCheck?.results ?? [], - ].filter(r => !!r).map(r => r!), - }), [ info, phase ]) -} - -export const useSubmitTask = () => { - const { campaignID, phaseType, spectrogramID } = useParams({ strict: false }); - const { phase } = useCurrentPhase() - const [ method, info ] = submitTask.useMutation() - - const submit = useCallback(async (annotations: AnnotationInput[], - taskComments: AnnotationCommentInput[], - start: Date) => { - if (!campaignID || !phaseType || !spectrogramID) return; - await method({ - campaignID, - phase: phaseType, - spectrogramID, - annotations, - taskComments, - startedAt: start.toISOString(), - endedAt: new Date().toISOString(), - }).unwrap() - }, [ method, campaignID, phaseType, spectrogramID, phase ]); - - return useMemo(() => { - const error = info.error ?? info.data?.submitAnnotationTask?.annotationErrors ?? info.data?.submitAnnotationTask?.taskCommentsErrors; - return { - ...info, - submitTask: submit, - isSuccess: info.isSuccess && !error, - isError: !!error, - error, - } - }, [ submit, info ]) -} diff --git a/frontend/src/api/annotation-task/index.ts b/frontend/src/api/annotation-task/index.ts deleted file mode 100644 index 8ee77f7d5..000000000 --- a/frontend/src/api/annotation-task/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type { - GetAnnotationTaskQuery, GetAnnotationTaskQueryVariables, - ListAnnotationTaskQuery, ListAnnotationTaskQueryVariables, -} from './annotation-task.generated' -export * from './hooks' -export * from './matchers' -export * from './selector' diff --git a/frontend/src/api/annotation-task/matchers.ts b/frontend/src/api/annotation-task/matchers.ts deleted file mode 100644 index 3a4ff0c4d..000000000 --- a/frontend/src/api/annotation-task/matchers.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AnnotationTaskGqlAPI, } from "./api"; - -const { - getAnnotationTask, -} = AnnotationTaskGqlAPI.endpoints - -export const getAnnotationTaskFulfilled = getAnnotationTask.matchFulfilled diff --git a/frontend/src/api/annotation-task/selector.ts b/frontend/src/api/annotation-task/selector.ts deleted file mode 100644 index 3fe72387d..000000000 --- a/frontend/src/api/annotation-task/selector.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type AppState } from '@/features/App'; -import { AnnotationTaskGqlAPI } from './api' -import { GetAnnotationTaskQueryVariables } from './annotation-task.generated' - -export const selectTask = (state: AppState, variables: GetAnnotationTaskQueryVariables) => - AnnotationTaskGqlAPI.endpoints.getAnnotationTask.select(variables)(state) 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/channel-configuration/api.ts b/frontend/src/api/channel-configuration/api.ts deleted file mode 100644 index 2fdddb9fd..000000000 --- a/frontend/src/api/channel-configuration/api.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { api } from './channel-configuration.generated' - -export const ChannelConfigurationGqlAPI = api.enhanceEndpoints({ - endpoints: { - listChannelConfigurations: { - // @ts-expect-error: result and error are unused - providesTags: (result, error, args) => [ { type: 'ChannelConfiguration', id: JSON.stringify(args) } ] - }, - } -}) \ No newline at end of file diff --git a/frontend/src/api/channel-configuration/channel-configuration.graphql b/frontend/src/api/channel-configuration/channel-configuration.graphql deleted file mode 100644 index 932cd27ab..000000000 --- a/frontend/src/api/channel-configuration/channel-configuration.graphql +++ /dev/null @@ -1,40 +0,0 @@ -query listChannelConfigurations($datasetID: ID) { - allChannelConfigurations(datasetId: $datasetID) { - results { - deployment { - name - campaign { - name - } - site { - name - } - project { - name - } - } - recorderSpecification { - recorder { - serialNumber - model { - name - } - } - hydrophone { - serialNumber - model { - name - } - } - } - detectorSpecification { - detector { - serialNumber - model { - name - } - } - } - } - } -} diff --git a/frontend/src/api/channel-configuration/hooks.ts b/frontend/src/api/channel-configuration/hooks.ts deleted file mode 100644 index d77201f58..000000000 --- a/frontend/src/api/channel-configuration/hooks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ChannelConfigurationGqlAPI } from './api'; -import { useMemo } from 'react'; -import { ListChannelConfigurationsQueryVariables } from './channel-configuration.generated'; - -const { - listChannelConfigurations, -} = ChannelConfigurationGqlAPI.endpoints - -export const useAllChannelConfigurations = (variables: ListChannelConfigurationsQueryVariables) => { - const info = listChannelConfigurations.useQuery(variables); - return useMemo(() => ({ - ...info, - allChannelConfigurations: info.data?.allChannelConfigurations?.results.filter(s => s !== null).map(s => s!), - }), [ info ]); -} diff --git a/frontend/src/api/channel-configuration/index.ts b/frontend/src/api/channel-configuration/index.ts deleted file mode 100644 index d7920b592..000000000 --- a/frontend/src/api/channel-configuration/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { - ListChannelConfigurationsQuery, - ListChannelConfigurationsQueryVariables, -} from './channel-configuration.generated' -export * from './hooks' diff --git a/frontend/src/api/dataset/api.ts b/frontend/src/api/dataset/api.ts deleted file mode 100644 index 67a8c555c..000000000 --- a/frontend/src/api/dataset/api.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { api } from './dataset.generated' - -export const DatasetGqlAPI = api.enhanceEndpoints({ - endpoints: { - listDatasets: { - providesTags: [ 'Dataset' ], - }, - getDatasetByID: { - // @ts-expect-error: result and error are unused - providesTags: (result, error, args) => [ { type: 'DetailedDataset', id: JSON.stringify(args) } ] - }, - listDatasetsAndAnalysis: { providesTags: [ 'DatasetsAndAnalysis' ] }, - } -}) diff --git a/frontend/src/api/dataset/dataset.graphql b/frontend/src/api/dataset/dataset.graphql deleted file mode 100644 index 62f90f509..000000000 --- a/frontend/src/api/dataset/dataset.graphql +++ /dev/null @@ -1,59 +0,0 @@ -query listDatasets { - allDatasets(orderBy: "-createdAt") { - results { - id - name - path - description - createdAt - legacy - analysisCount - spectrogramCount - start - end - annotationCampaigns { - edges { - node { - id - name - isArchived - } - } - } - } - } -} - -query getDatasetByID($id: ID!) { - datasetById(id: $id) { - id - name - path - description - start - end - createdAt - legacy - owner { - displayName - } - } -} - -query listDatasetsAndAnalysis { - allDatasets(orderBy: "name") { - results { - id - name - spectrogramAnalysis(orderBy: "name") { - results { - id - name - colormap { - name - } - } - } - } - } -} diff --git a/frontend/src/api/dataset/hooks.ts b/frontend/src/api/dataset/hooks.ts deleted file mode 100644 index 4bf0ec234..000000000 --- a/frontend/src/api/dataset/hooks.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { DatasetGqlAPI } from './api' -import { useMemo } from 'react'; - -const { - listDatasets, - listDatasetsAndAnalysis, - getDatasetByID, -} = DatasetGqlAPI.endpoints - -export const useDataset = ({ id }: { id?: string }) => { - const info = getDatasetByID.useQuery({ - id: id ?? '', - }, { skip: !id }) - return useMemo(() => ({ ...info, dataset: info.data?.datasetById }), [info]) -} - -export const useAllDatasets = () => { - const info = listDatasets.useQuery() - return useMemo(() => ({ - ...info, - allDatasets: info.data?.allDatasets?.results.filter(d => d !== null).map(d => d!), - }), [info]) -} - -export const useAllDatasetsAndAnalysis = () => { - const info = listDatasetsAndAnalysis.useQuery() - return useMemo(() => ({ - ...info, - allDatasets: info.data?.allDatasets?.results.filter(d => d !== null).map(d => d!), - }), [info]) -} \ No newline at end of file diff --git a/frontend/src/api/dataset/index.ts b/frontend/src/api/dataset/index.ts deleted file mode 100644 index 757014ca9..000000000 --- a/frontend/src/api/dataset/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type { - GetDatasetByIdQuery, - GetDatasetByIdQueryVariables, - ListDatasetsAndAnalysisQuery, - ListDatasetsAndAnalysisQueryVariables, - ListDatasetsQuery, - ListDatasetsQueryVariables, -} from './dataset.generated' -export * from './hooks' diff --git a/frontend/src/api/download/hooks.ts b/frontend/src/api/download/hooks.ts index 0a4ee1538..bf0cfc0fe 100644 --- a/frontend/src/api/download/hooks.ts +++ b/frontend/src/api/download/hooks.ts @@ -1,6 +1,6 @@ import { DownloadRestAPI } from '@/api/download/api'; import { useCallback } from 'react'; -import { useCurrentCampaign, useCurrentPhase } from '@/api'; +import { useLoaderData } from '@tanstack/react-router'; const { downloadAnalysis, @@ -12,13 +12,12 @@ const { export const useDownloadAnalysis = downloadAnalysis.useMutation export const useDownloadAnnotations = () => { - const { campaign } = useCurrentCampaign(); - const { phase } = useCurrentPhase(); + const { campaign } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) + const { phase } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/_detailLayout/phase/$phaseType' }) const [ method, info ] = downloadAnnotations.useMutation() return { downloadAnnotations: useCallback(() => { - if (!campaign || !phase) return; return method({ phaseID: phase.id, campaignName: campaign.name, @@ -29,13 +28,12 @@ export const useDownloadAnnotations = () => { } export const useDownloadProgress = () => { - const { campaign } = useCurrentCampaign(); - const { phase } = useCurrentPhase(); + const { campaign } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) + const { phase } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/_detailLayout/phase/$phaseType' }) const [ method, info ] = downloadProgress.useMutation() return { downloadProgress: useCallback(() => { - if (!campaign || !phase) return; return method({ phaseID: phase.id, campaignName: campaign.name, 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/index.ts b/frontend/src/api/index.ts index 9489fd6d1..6c27eaf27 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,16 +1,6 @@ export * from './types.gql-generated' export * from './annotation' -export * from './annotation-campaign' -export * from './annotation-file-range' -export * from './annotation-phase' -export * from './annotation-task' export * from './auth' -export * from './channel-configuration' export * from './confidence-set' -export * from './dataset' export * from './label-set' -export * from './ontology' -export * from './spectrogram-analysis' -export * from './user' -export * from './storage' diff --git a/frontend/src/api/ontology/api.ts b/frontend/src/api/ontology/api.ts deleted file mode 100644 index 0acfbe75e..000000000 --- a/frontend/src/api/ontology/api.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { api } from './ontology.generated'; - -export const OntologyGqlAPI = api.enhanceEndpoints({ - endpoints: { - getAllSounds: { - providesTags: [ 'Sound' ] - }, - getDetailedSoundByID: { - providesTags: result => result?.soundById ? [ { type: 'Sound' as const, id: result.soundById.id } ] : [] - }, - updateSound: { - invalidatesTags: result => result?.postSound?.sound?.id ? [ { - type: 'Sound', - id: result.postSound.sound.id - }, 'Sound' ] : [ 'Sound' ] - }, - createSound: { - invalidatesTags: [ 'Sound' ] - }, - deleteSound: { - invalidatesTags: (_1, _2, { id }) => [ { type: 'Sound' as const, id }, 'Sound' ] - }, - getAllSources: { - providesTags: [ 'Source' ] - }, - getDetailedSourceByID: { - providesTags: result => result?.sourceById ? [ { type: 'Sound' as const, id: result.sourceById.id } ] : [] - }, - updateSource: { - invalidatesTags: result => result?.postSource?.source?.id ? [ { - type: 'Source', - id: result.postSource.source.id - }, 'Source' ] : [ 'Source' ], - }, - createSource: { - invalidatesTags: [ 'Source' ], - }, - deleteSource: { - invalidatesTags: (_1, _2, { id }) => [ { type: 'Source' as const, id }, 'Source' ] - } - } -}) \ No newline at end of file diff --git a/frontend/src/api/ontology/hooks.ts b/frontend/src/api/ontology/hooks.ts deleted file mode 100644 index 2fd63b9af..000000000 --- a/frontend/src/api/ontology/hooks.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { OntologyGqlAPI } from "./api"; -import { useMemo } from "react"; - -const { - getAllSources, - getDetailedSourceByID, - deleteSource, - createSource, - updateSource, - - getAllSounds, - getDetailedSoundByID, - deleteSound, - createSound, - updateSound, -} = OntologyGqlAPI.endpoints - -export const useAllSources = ({ skip }: { skip: boolean } = { skip: false }) => { - const info = getAllSources.useQuery({}, { skip }); - return useMemo(() => ({ - ...info, - allSources: info.data?.allSources?.results.filter(s => s !== null) - }), [ info ]); -} - -export const useSourceCRUD = () => { - const [ create ] = createSource.useMutation(); - const [ update ] = updateSource.useMutation(); - const [ remove ] = deleteSource.useMutation(); - return { create, update, remove } -} - -export const useSource = ({ id, skip }: { id?: string, skip: boolean } = { skip: false }) => { - const info = getDetailedSourceByID.useQuery({ id: id ?? '' }, { skip: !id || skip }); - return useMemo(() => ({ ...info, source: info.data?.sourceById }), [ info ]) -} - -export const useAllSounds = ({ skip }: { skip: boolean } = { skip: false }) => { - const info = getAllSounds.useQuery({}, { skip }); - return useMemo(() => ({ - ...info, - allSounds: info.data?.allSounds?.results.filter(s => s !== null) - }), [ info ]); -} -export const useSoundCRUD = () => { - const [ create ] = createSound.useMutation(); - const [ update ] = updateSound.useMutation(); - const [ remove ] = deleteSound.useMutation(); - return { create, update, remove } -} - -export const useSound = ({ id, skip }: { id?: string, skip: boolean } = { skip: false }) => { - const info = getDetailedSoundByID.useQuery({ id: id ?? '' }, { skip: !id || skip }); - return useMemo(() => ({ ...info, sound: info.data?.soundById }), [ info ]) -} \ No newline at end of file diff --git a/frontend/src/api/ontology/index.ts b/frontend/src/api/ontology/index.ts deleted file mode 100644 index fc78d3512..000000000 --- a/frontend/src/api/ontology/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './hooks' 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..33ef2523d --- /dev/null +++ b/frontend/src/api/queryKeys.ts @@ -0,0 +1,152 @@ +import { queryClient } from './queryClient'; +import type { QueryKey } from '@tanstack/react-query'; +import type { BrowseStorageQueryVariables, SearchStorageQueryVariables } from '@/features/Storage'; +import type { GetDatasetByIdQueryVariables } from '@/features/Dataset'; +import type { FileRangesForPhaseQueryVariables } from '@/features/AnnotationFileRange'; +import type { AllCampaignsQueryVariables, GetCampaignQueryVariables } from '@/features/AnnotationCampaign'; +import type { AllSpectrogramAnalysisQueryVariables } from '@/features/SpectrogramAnalysis'; +import type { GetDetailedSoundByIdQueryVariables, GetDetailedSourceByIdQueryVariables } from '@/features/Ontology'; +import type { GetAnnotationPhaseQueryVariables } from '@/features/AnnotationPhase'; +import type { + AllAnnotationSpectrogramsQueryVariables, + GetAnnotationSpectrogramPathsQueryVariables, + GetAnnotationSpectrogramQueryVariables, +} from '@/features/AnnotationSpectrogram'; +import type { AnnotationPhaseType } from '@/api/types.gql-generated'; + +/** + * Keys factory pour les requêtes GraphQL + * Permet d'invalider les requêtes de manière prévisible + */ +export const queryKeys = { + campaign: { + base: [ 'campaign' ], + all: (variables: AllCampaignsQueryVariables) => [ 'campaign', variables ] as const, + byId: (variables: GetCampaignQueryVariables) => [ 'campaign', variables.id ] as const, + }, + phase: { + get: ({ campaignID, phase }: GetAnnotationPhaseQueryVariables) => [ 'phase', campaignID, phase ] as const, + }, + fileRange: { + forPhase: (variables: FileRangesForPhaseQueryVariables) => [ 'file-range', 'for phase', variables.campaignID, variables.phaseType ] as const, + }, + spectrogram: { + base: [ 'spectrogram' ], + baseForPhase: ({ + campaignID, + phaseType, + }: { + campaignID: string, phaseType: AnnotationPhaseType + }) => [ 'spectrogram', campaignID, phaseType ] as const, + all: ({ + campaignID, + phaseType, + ...variables + }: AllAnnotationSpectrogramsQueryVariables) => [ 'spectrogram', campaignID, phaseType, variables ] as const, + get: ({ + campaignID, + phaseType, + spectrogramID, + ...variables + }: Pick + & Partial>) => + [ 'spectrogram', campaignID, phaseType, spectrogramID, variables ] as const, + getPath: ({ + spectrogramID, + analysisID, + }: GetAnnotationSpectrogramPathsQueryVariables) => [ 'spectrogram', 'path', spectrogramID, analysisID ] as const, + }, + dataset: { + all: [ 'dataset' ] as const, + byId: (variables: GetDatasetByIdQueryVariables) => [ 'dataset', variables.id ] as const, + listWithAnalysis: [ 'dataset', 'analysis' ] as const, + }, + analysis: { + all: (variables: AllSpectrogramAnalysisQueryVariables) => [ 'analysis', variables ] as const, + }, + ontology: { + sound: { + all: [ 'ontology', 'sound' ] as const, + byId: (variables: GetDetailedSoundByIdQueryVariables) => [ 'ontology', 'sound', variables.id ] as const, + }, + source: { + all: [ 'ontology', 'source' ] as const, + byId: (variables: GetDetailedSourceByIdQueryVariables) => [ 'ontology', 'source', variables.id ] as const, + }, + }, + storage: { + browse: (variables: BrowseStorageQueryVariables) => [ 'storage', 'browse', variables.path.split('/') ] as const, + search: (variables: SearchStorageQueryVariables) => [ 'storage', 'search', variables.path.split('/') ] as const, + }, + 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/spectrogram-analysis/api.ts b/frontend/src/api/spectrogram-analysis/api.ts deleted file mode 100644 index f5f7bc680..000000000 --- a/frontend/src/api/spectrogram-analysis/api.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { api, type ListSpectrogramAnalysisQueryVariables } from './spectrogram-analysis.generated'; - -export function listSpectrogramAnalysisTag(args: ListSpectrogramAnalysisQueryVariables | void) { - return { type: 'SpectrogramAnalysis', id: `${ args?.datasetID ?? '' }-${ args?.annotationCampaignID ?? '' }` } -} - -export const SpectrogramAnalysisGqlAPI = api.enhanceEndpoints({ - endpoints: { - listSpectrogramAnalysis: { - providesTags: (_result, _error, args) => - [ listSpectrogramAnalysisTag(args) ], - }, - }, -}) diff --git a/frontend/src/api/spectrogram-analysis/hooks.ts b/frontend/src/api/spectrogram-analysis/hooks.ts deleted file mode 100644 index fd17342ff..000000000 --- a/frontend/src/api/spectrogram-analysis/hooks.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { SpectrogramAnalysisGqlAPI } from './api'; -import { ListSpectrogramAnalysisQueryVariables } from '@/api'; -import { useMemo } from 'react'; - -const { - listSpectrogramAnalysis, -} = SpectrogramAnalysisGqlAPI.endpoints - -export const useAllSpectrogramAnalysis = (variables: ListSpectrogramAnalysisQueryVariables | void) => { - const info = listSpectrogramAnalysis.useQuery(variables) - return useMemo(() => ({ - ...info, - allSpectrogramAnalysis: info.data?.allSpectrogramAnalysis?.results.filter(r => r !== null), - }), [info]) -} diff --git a/frontend/src/api/spectrogram-analysis/index.ts b/frontend/src/api/spectrogram-analysis/index.ts deleted file mode 100644 index 5e956eb98..000000000 --- a/frontend/src/api/spectrogram-analysis/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { - ListSpectrogramAnalysisQuery, - ListSpectrogramAnalysisQueryVariables, -} from './spectrogram-analysis.generated' -export * from './hooks' diff --git a/frontend/src/api/spectrogram-analysis/spectrogram-analysis.graphql b/frontend/src/api/spectrogram-analysis/spectrogram-analysis.graphql deleted file mode 100644 index 82e63a3e9..000000000 --- a/frontend/src/api/spectrogram-analysis/spectrogram-analysis.graphql +++ /dev/null @@ -1,30 +0,0 @@ -query listSpectrogramAnalysis( - $datasetID: ID - $annotationCampaignID: ID -) { - allSpectrogramAnalysis( - orderBy: "-createdAt" - dataset: $datasetID - annotationCampaigns_Id: $annotationCampaignID - ) { - results { - id - name - description - createdAt - legacy - dataDuration - start - end - fft { - samplingFrequency - nfft - windowSize - overlap - } - spectrograms { - totalCount - } - } - } -} diff --git a/frontend/src/api/storage/api.ts b/frontend/src/api/storage/api.ts deleted file mode 100644 index 761255cdf..000000000 --- a/frontend/src/api/storage/api.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { api } from './storage.generated' - - -export const StorageGqlAPI = api.enhanceEndpoints({ - endpoints: { - importDatasetFromStorage: { - invalidatesTags: [ 'Dataset', 'DatasetsAndAnalysis' ], - }, - }, -}) diff --git a/frontend/src/api/storage/hooks.ts b/frontend/src/api/storage/hooks.ts deleted file mode 100644 index 0326dc572..000000000 --- a/frontend/src/api/storage/hooks.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { BrowseStorageQuery, BrowseStorageQueryVariables, SearchStorageQueryVariables } from './storage.generated' -import { StorageGqlAPI } from './api' -import { useMemo } from 'react'; - -const { - browseStorage, - searchStorage, - importDatasetFromStorage, -} = StorageGqlAPI.endpoints - -function _mapData(data?: BrowseStorageQuery) { - return data?.browse?.filter(d => d !== null) -} - -export const useBrowseStorage = (vars?: BrowseStorageQueryVariables, options?: { skip?: boolean }) => { - const info = browseStorage.useQuery({ path: vars?.path ?? '' }, options) - return useMemo(() => ({ ...info, subfolders: _mapData(info.data) }), [ info ]) -} - -export const useSearchStorage = (vars: SearchStorageQueryVariables, options?: { skip?: boolean }) => { - const info = searchStorage.useQuery(vars, options) - return useMemo(() => ({ ...info, item: info.data?.search }), [ info ]) -} - -export const useImportDatasetFromStorage = () => { - const [ method, { isSuccess, ...info } ] = importDatasetFromStorage.useMutation() - return { isSuccess, ...info, importDataset: method } -} diff --git a/frontend/src/api/storage/index.ts b/frontend/src/api/storage/index.ts deleted file mode 100644 index c8cbd7ee0..000000000 --- a/frontend/src/api/storage/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './hooks' -export * from './types' -export * from './slice' diff --git a/frontend/src/api/storage/slice.ts b/frontend/src/api/storage/slice.ts deleted file mode 100644 index 6c7d41ca8..000000000 --- a/frontend/src/api/storage/slice.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { createSelector, createSlice } from '@reduxjs/toolkit'; -import { StorageGqlAPI } from './api'; -import type { - BrowseStorageQuery, - BrowseStorageQueryVariables, - ImportDatasetFromStorageMutation, - SearchStorageQuery, -} from './storage.generated'; -import type { StorageItem } from './types'; -import { useEffect, useMemo } from 'react'; -import { useAppSelector } from '@/features/App'; -import { AnnotationCampaignGqlAPI } from '@/api/annotation-campaign/api'; -import type { CreateCampaignMutation } from '@/api'; - -export const StorageSlice = createSlice({ - name: 'storage', - initialState: { - record: {} as Record, - parents: {} as Record>, - invalidatedPath: [] as Array, - invalidatedListPaths: [] as Array, - }, - reducers: {}, - extraReducers: builder => { - - builder.addMatcher(StorageGqlAPI.endpoints.browseStorage.matchFulfilled, - (state, action: { - payload: BrowseStorageQuery, - meta: { arg: { originalArgs: void | BrowseStorageQueryVariables } } - }) => { - for (const item of action.payload.browse ?? []) { - if (!item) continue - state.record[item.path] = item - } - let parentPath = '' - if (action.meta.arg.originalArgs) - parentPath = action.meta.arg.originalArgs.path ?? '' - state.parents[parentPath] = action.payload.browse?.filter(r => !!r).map(r => r?.path) ?? [] - state.invalidatedListPaths = state.invalidatedListPaths.filter(p => p !== parentPath) - }) - - builder.addMatcher(StorageGqlAPI.endpoints.searchStorage.matchFulfilled, - (state, action: { payload: SearchStorageQuery }) => { - if (!action.payload.search) return - state.record[action.payload.search.path] = action.payload.search - state.invalidatedPath = state.invalidatedPath.filter(p => p !== action.payload.search?.path) - }) - - builder.addMatcher(StorageGqlAPI.endpoints.importDatasetFromStorage.matchFulfilled, - (state, action: { payload: ImportDatasetFromStorageMutation }) => { - const path = action.payload.importDataset?.dataset.path - if (!path) return - state.invalidatedPath = [ ...state.invalidatedPath, path ] - state.invalidatedListPaths = [ ...state.invalidatedListPaths, path ] - }) - - builder.addMatcher(AnnotationCampaignGqlAPI.endpoints.createCampaign.matchFulfilled, - (state, action: { payload: CreateCampaignMutation }) => { - const path = action.payload.createAnnotationCampaign?.annotationCampaign?.dataset.path - if (!path) return - state.invalidatedPath = [ ...state.invalidatedPath, path ] - state.invalidatedListPaths = [ ...state.invalidatedListPaths, path ] - }) - - }, - selectors: { - selectRecord: state => state.record, - selectParents: state => state.parents, - selectInvalidatedPath: state => state.invalidatedPath, - selectInvalidatedListPath: state => state.invalidatedListPaths, - }, -}) - -const selectRecord = createSelector(state => state, StorageSlice.selectors.selectRecord) -const selectParents = createSelector(state => state, StorageSlice.selectors.selectParents) -const selectInvalidatedPath = createSelector(state => state, StorageSlice.selectors.selectInvalidatedPath) -const selectInvalidatedListPath = createSelector(state => state, StorageSlice.selectors.selectInvalidatedListPath) - -export const useStorageSearch = (path: string): StorageItem | undefined => { - const record = useAppSelector(selectRecord) - const invalidatedPath = useAppSelector(selectInvalidatedPath) - - const [ search ] = StorageGqlAPI.endpoints.searchStorage.useLazyQuery() - useEffect(() => { - if (invalidatedPath.includes(path)) search({ path }) - if (!record[path]) search({ path }) - }, [ invalidatedPath, path, record ]); - - return useMemo(() => record[path], [ record, path ]); -} - -export const useStorageBrowse = (path: string = '') => { - const record = useAppSelector(selectRecord) - const parents = useAppSelector(selectParents) - const invalidatedListPaths = useAppSelector(selectInvalidatedListPath) - const children = useMemo(() => { - const children = parents[path] - if (children === undefined) return undefined; - return Object.values(record).filter(r => children?.includes(r.path)) - }, [ record, path, parents ]); - - const [ browse ] = StorageGqlAPI.endpoints.browseStorage.useLazyQuery() - useEffect(() => { - if (invalidatedListPaths.includes(path)) browse({ path }) - if (children === undefined) browse({ path }) - }, [ invalidatedListPaths, path, children ]); - - return children -} - diff --git a/frontend/src/api/storage/storage.graphql b/frontend/src/api/storage/storage.graphql deleted file mode 100644 index e3c078fd6..000000000 --- a/frontend/src/api/storage/storage.graphql +++ /dev/null @@ -1,101 +0,0 @@ -query browseStorage($path: String!) { - browse(path: $path) { - ... on FolderNode { - __typename - name - path - error - stack - } - ... on DatasetStorageNode { - __typename - name - path - importStatus - model { - id - annotationCampaigns { - edges { - node { - isArchived - } - } - } - } - error - stack - } - ... on AnalysisStorageNode { - __typename - name - path - importStatus - model { - annotationCampaigns { - edges { - node { - isArchived - } - } - } - } - error - stack - } - } -} - -query searchStorage($path: String!) { - search(path: $path) { - ... on FolderNode { - __typename - name - path - error - stack - } - ... on DatasetStorageNode { - __typename - name - path - importStatus - model { - id - annotationCampaigns { - edges { - node { - isArchived - } - } - } - } - error - stack - } - ... on AnalysisStorageNode { - __typename - name - path - importStatus - model { - annotationCampaigns { - edges { - node { - isArchived - } - } - } - } - error - stack - } - } -} - -mutation importDatasetFromStorage($datasetPath: String!, $analysisPath: String) { - importDataset(datasetPath: $datasetPath, analysisPath: $analysisPath) { - dataset { - path - } - } -} diff --git a/frontend/src/api/storage/types.ts b/frontend/src/api/storage/types.ts deleted file mode 100644 index 09c7a8190..000000000 --- a/frontend/src/api/storage/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { BrowseStorageQuery } from '@/api/storage/storage.generated'; - -type N = NonNullable -export type StorageItem = N[number]> -export type StorageFolder = Extract -export type StorageDataset = Extract -export type StorageAnalysis = Extract diff --git a/frontend/src/api/user/api.ts b/frontend/src/api/user/api.ts deleted file mode 100644 index f375c29fb..000000000 --- a/frontend/src/api/user/api.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { api } from "./user.generated"; - -export const UserGqlAPI = api.enhanceEndpoints({ - endpoints: { - getCurrentUser: { - providesTags: [ 'CurrentUser' ] - }, - listUsers: { - providesTags: [ 'User' ] - }, - updateCurrentUserEmail: { - invalidatesTags: [ 'CurrentUser' ] - } - } -}) \ No newline at end of file diff --git a/frontend/src/api/user/hooks.ts b/frontend/src/api/user/hooks.ts deleted file mode 100644 index 2618cc923..000000000 --- a/frontend/src/api/user/hooks.ts +++ /dev/null @@ -1,74 +0,0 @@ -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(() => ({ - ...info, - users: info?.data?.allUsers?.results.filter(r => r !== null) ?? [], - groups: info?.data?.allUserGroups?.results.filter(r => r !== null) ?? [], - }), [ info ]) -} - -export const useUpdateCurrentUserEmail = () => { - const [ updateEmail, info ] = updateCurrentUserEmail.useMutation(); - - return { - updateEmail, - ...useMemo(() => { - const formErrors = info.data?.currentUserUpdate?.errors ?? [] - return { - ...info, - isSuccess: info.isSuccess && formErrors.length === 0, - formErrors, - } - }, [ info ]), - } -} - -export const useUpdateCurrentUserPassword = () => { - const [ updatePassword, info ] = updateCurrentUserPassword.useMutation(); - - return { - updatePassword, - ...useMemo(() => { - const formErrors = info.data?.userUpdatePassword?.errors ?? [] - return { - ...info, - isSuccess: info.isSuccess && formErrors.length === 0, - formErrors, - } - }, [ 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 deleted file mode 100644 index 0ff430057..000000000 --- a/frontend/src/api/user/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 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/middlewares.ts b/frontend/src/api/user/middlewares.ts deleted file mode 100644 index 00a6cd2c6..000000000 --- a/frontend/src/api/user/middlewares.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createListenerMiddleware } from '@reduxjs/toolkit'; -import { gqlAPI } from '@/api/baseGqlApi'; - - -export const getUserOnLoginMiddleware = createListenerMiddleware() -getUserOnLoginMiddleware.startListening({ - predicate: (action: any) => { - if (!action.meta?.arg) return false; - return action.meta.arg.endpointName === 'login' && action.meta.requestStatus === 'fulfilled'; - }, - effect: (_, api) => { - api.dispatch(gqlAPI.util.invalidateTags([ 'CurrentUser' ])) - }, -}) 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/utils.ts b/frontend/src/api/utils.ts index f1d2d22a8..f2643e96f 100644 --- a/frontend/src/api/utils.ts +++ b/frontend/src/api/utils.ts @@ -37,4 +37,8 @@ export async function getLoader( info = await promise promise.unsubscribe() return info +} + +export function cleanGqlList(data?: Array | null): Array { + return data?.filter(d => !!d).map(d => d!) ?? [] } \ No newline at end of file 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/components/ui/Link.tsx b/frontend/src/components/ui/Link.tsx index f74369dbc..7e7d72b58 100644 --- a/frontend/src/components/ui/Link.tsx +++ b/frontend/src/components/ui/Link.tsx @@ -10,12 +10,12 @@ export type LinkProps = { replace?: boolean; target?: HTMLAttributeAnchorTarget | undefined; onClick?: () => void; // Override to avoir param type mismatch -} & React.ComponentProps & Partial> +} & React.ComponentProps & Partial> export const Link: React.FC = ({ children, href, target, - to, params, search, + to, params, search, preload, className, onClick, replace = false, @@ -35,12 +35,12 @@ export const Link: React.FC = ({ href={ href } onClick={ onClick } children={ button }/> - if (to !== undefined) return return - }, [ button, className, target, href, onClick, to, replace, params, search ]) + }, [ button, className, target, href, onClick, to, replace, params, search, preload ]) } diff --git a/frontend/src/features/AnnotationCampaign/ArchiveButton.tsx b/frontend/src/features/AnnotationCampaign/ArchiveButton.tsx index 4e1b7eddd..16918bccf 100644 --- a/frontend/src/features/AnnotationCampaign/ArchiveButton.tsx +++ b/frontend/src/features/AnnotationCampaign/ArchiveButton.tsx @@ -2,43 +2,44 @@ import React, { Fragment, useCallback } from 'react'; import { IonButton, IonIcon } from '@ionic/react'; import { archiveOutline } from 'ionicons/icons/index.js'; import { useAlert } from '@/components/ui'; -import { useArchiveCampaign, useCurrentCampaign } from '@/api'; +import { archiveMutation } from './api'; +import { useLoaderData } from '@tanstack/react-router'; +import { useMutation } from '@tanstack/react-query'; export const AnnotationCampaignArchiveButton: React.FC = () => { - const { campaign, phases } = useCurrentCampaign() - const { archiveCampaign } = useArchiveCampaign() - const alert = useAlert(); + const { campaign, phases } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) + const { mutate: archiveCampaign } = useMutation(archiveMutation) + const alert = useAlert(); - const archive = useCallback(async () => { - if (!phases || !campaign) return; - if (phases.length === 0) { - return alert.showAlert({ - type: 'Warning', - message: 'The campaign is empty.\nAre you sure you want to archive this campaign?', - actions: [ { - label: 'Archive', - callback: () => archiveCampaign(campaign), - } ], - }) - } - const progress = phases.reduce((previousValue, p) => previousValue + ((p.isOpen ? p.completedTasksCount : p.tasksCount) ?? 0), 0); - const total = phases.reduce((previousValue, p) => previousValue + (p.tasksCount ?? 0), 0); - if (progress < total) { - // If annotators haven't finished yet, ask for confirmation - return alert.showAlert({ - type: 'Warning', - message: 'There is still unfinished annotations.\nAre you sure you want to archive this campaign?', - actions: [ { - label: 'Archive', - callback: () => archiveCampaign(campaign), - } ], - }); - } else archiveCampaign(campaign) - }, [ campaign, phases, archiveCampaign, alert ]); + const archive = useCallback(async () => { + if (phases.length === 0) { + return alert.showAlert({ + type: 'Warning', + message: 'The campaign is empty.\nAre you sure you want to archive this campaign?', + actions: [ { + label: 'Archive', + callback: () => archiveCampaign(campaign), + } ], + }) + } + const progress = phases.reduce((previousValue, p) => previousValue + ((p.isOpen ? p.completedTasksCount : p.tasksCount) ?? 0), 0); + const total = phases.reduce((previousValue, p) => previousValue + (p.tasksCount ?? 0), 0); + if (progress < total) { + // If annotators haven't finished yet, ask for confirmation + return alert.showAlert({ + type: 'Warning', + message: 'There is still unfinished annotations.\nAre you sure you want to archive this campaign?', + actions: [ { + label: 'Archive', + callback: () => archiveCampaign(campaign), + } ], + }); + } else archiveCampaign(campaign) + }, [ campaign, phases, archiveCampaign, alert ]); - if (!campaign || campaign.isArchived || !campaign.isEditable || !campaign.isUserAllowedToManage) return - return - - Archive - + if (campaign.isArchived || !campaign.isEditable || !campaign.isUserAllowedToManage) return + return + + Archive + } diff --git a/frontend/src/features/AnnotationCampaign/CampaignCard.tsx b/frontend/src/features/AnnotationCampaign/CampaignCard.tsx index f0026f6a6..53ec62c85 100644 --- a/frontend/src/features/AnnotationCampaign/CampaignCard.tsx +++ b/frontend/src/features/AnnotationCampaign/CampaignCard.tsx @@ -1,61 +1,25 @@ -import React, { useMemo } from 'react'; -import { Link as RouterLink, type LinkComponentProps, useSearch } from '@tanstack/react-router' -import { IonBadge, IonIcon, IonNote, IonSkeletonText } from '@ionic/react'; +import React from 'react'; +import { Link as RouterLink, type LinkComponentProps } from '@tanstack/react-router' +import { IonBadge, IonIcon, IonNote } from '@ionic/react'; import { Color } from '@ionic/core'; import { crop } from 'ionicons/icons/index.js'; -import { GraphQLErrorText, Progress, SkeletonProgress } from '@/components/ui'; - -import { AllCampaignFilters, type ListCampaignsQuery, useAllCampaigns } from '@/api'; +import { Progress } from '@/components/ui'; import { dateToString, pluralize } from '@/service/function'; import styles from './styles.module.scss'; +import { type AllCampaignsQuery } from './api'; -type Campaign = NonNullable['results'][number]>; -export const Cards: React.FC<{ filters?: AllCampaignFilters }> = ({ filters }) => { - const searchParams = useSearch({ strict: false }); - const { - allCampaigns, - isFetching, - error, - } = useAllCampaigns({ ...searchParams, ...filters }); - - return useMemo(() => { - if (isFetching) - return
- { Array.from(new Array(3)).map((_, i) => ) } -
- - if (error) - return - - if (!allCampaigns || allCampaigns.length === 0) - return No campaigns - - return
- { allCampaigns?.map(c => ) } -
- }, [allCampaigns, isFetching, error]) -} - -const SkeletonCard: React.FC = React.memo(() =>
+type Campaign = NonNullable['results'][number]>; -
- - - - - -
+export const Cards: React.FC<{ campaigns?: Campaign[] }> = React.memo(({ campaigns }) => { + if (!campaigns || campaigns.length === 0) + return No campaigns -
- - + return
+ { campaigns?.map(c => ) }
- - - -
) +}) const NOW = Date.now() @@ -84,32 +48,33 @@ const Card: React.FC<{ campaign: Campaign }> = React.memo(({ campaign }) => { } - return - {/*
*/} + return + {/*
*/ } -
- -

{ campaign.name }

-

{ campaign.datasetName }

-
+
+ +

{ campaign.name }

+

{ campaign.datasetName }

+
-
- -

Phase{ pluralize(campaign.phases?.results) }:

-

{ campaign.phases && campaign.phases?.results.length > 0 ? campaign.phases?.results.map(p => p?.phase).join(', ') : 'No phase' }

-
+
+ +

Phase{ pluralize(campaign.phases?.results) }:

+

{ campaign.phases && campaign.phases?.results.length > 0 ? campaign.phases?.results.map(p => p?.phase).join(', ') : 'No phase' }

+
- { campaign.userTasksCount > 0 && } + { campaign.userTasksCount > 0 && } - { campaign.tasksCount > 0 && } + { campaign.tasksCount > 0 && } - {/*
*/} + {/*
*/ }
}) \ 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..0c721256b 100644 --- a/frontend/src/features/AnnotationCampaign/CampaignFilters/AnnotatorFilter.tsx +++ b/frontend/src/features/AnnotationCampaign/CampaignFilters/AnnotatorFilter.tsx @@ -1,22 +1,23 @@ import React, { useCallback } from 'react'; -import { useCurrentUser } from '@/api'; -import { useNavigate } from '@tanstack/react-router' +import { useLoaderData, useNavigate, useSearch } from '@tanstack/react-router' import { IonChip, IonIcon } from '@ionic/react'; import { closeCircle } from 'ionicons/icons'; -import { Route } from '@/routes/_authenticated/annotation-campaign'; export const AnnotationCampaignAnnotatorFilter: React.FC = () => { - const filter_annotatorID = Route.useSearch({select: ({filter_annotatorID}) => filter_annotatorID}); + const filter_annotatorID = useSearch({ + from: '/_authenticated/annotation-campaign/', + select: ({ filter_annotatorID }) => filter_annotatorID, + }); const navigate = useNavigate(); - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) const toggle = useCallback(() => { navigate({ - to: Route.to, + to: '/annotation-campaign', 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/ArchiveFilter.tsx b/frontend/src/features/AnnotationCampaign/CampaignFilters/ArchiveFilter.tsx index 8e597b6a6..461d30dc9 100644 --- a/frontend/src/features/AnnotationCampaign/CampaignFilters/ArchiveFilter.tsx +++ b/frontend/src/features/AnnotationCampaign/CampaignFilters/ArchiveFilter.tsx @@ -1,18 +1,20 @@ import React, { useCallback, useMemo } from 'react'; import { IonChip, IonIcon } from '@ionic/react'; import { closeCircle, swapHorizontal } from 'ionicons/icons'; -import { Route } from '@/routes/_authenticated/annotation-campaign'; -import { useNavigate } from '@tanstack/react-router'; +import { useNavigate, useSearch } from '@tanstack/react-router'; export const AnnotationCampaignArchiveFilter: React.FC = () => { - const filter_isArchived = Route.useSearch({ select: ({ filter_isArchived }) => filter_isArchived }); + const filter_isArchived = useSearch({ + from: '/_authenticated/annotation-campaign/', + select: ({ filter_isArchived }) => filter_isArchived, + }); const navigate = useNavigate(); const exists = useMemo(() => filter_isArchived !== undefined && filter_isArchived !== null, [ filter_isArchived ]) const toggle = useCallback(() => { navigate({ - to: Route.to, + to: '/annotation-campaign', search: (prev) => ({ ...prev, filter_isArchived: prev?.filter_isArchived ? null : prev?.filter_isArchived === false, diff --git a/frontend/src/features/AnnotationCampaign/CampaignFilters/OwnerFilter.tsx b/frontend/src/features/AnnotationCampaign/CampaignFilters/OwnerFilter.tsx index e7b201381..2979aabe5 100644 --- a/frontend/src/features/AnnotationCampaign/CampaignFilters/OwnerFilter.tsx +++ b/frontend/src/features/AnnotationCampaign/CampaignFilters/OwnerFilter.tsx @@ -1,22 +1,23 @@ 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, useSearch } from '@tanstack/react-router'; export const AnnotationCampaignOwnerFilter: React.FC = () => { - const filter_ownerID = Route.useSearch({ select: ({ filter_ownerID }) => filter_ownerID }); + const filter_ownerID = useSearch({ + from: '/_authenticated/annotation-campaign/', + select: ({ filter_ownerID }) => filter_ownerID, + }); const navigate = useNavigate(); - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) const toggle = useCallback(() => { navigate({ - to: Route.to, + to: '/annotation-campaign', 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/PhaseFilter.tsx b/frontend/src/features/AnnotationCampaign/CampaignFilters/PhaseFilter.tsx index dd9ae372b..ff14049c4 100644 --- a/frontend/src/features/AnnotationCampaign/CampaignFilters/PhaseFilter.tsx +++ b/frontend/src/features/AnnotationCampaign/CampaignFilters/PhaseFilter.tsx @@ -2,16 +2,15 @@ import React, { useCallback } from 'react'; import { AnnotationPhaseType } 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 { useNavigate, useSearch } from '@tanstack/react-router'; export const AnnotationCampaignPhaseTypeFilter: React.FC = () => { - const filter_phase = Route.useSearch({select: ({ filter_phase }) => filter_phase }); + const filter_phase = useSearch({from: '/_authenticated/annotation-campaign/', select: ({ filter_phase }) => filter_phase }); const navigate = useNavigate(); const toggle = useCallback(() => { navigate({ - to: Route.to, + to: '/annotation-campaign', search: (prev) => ({ ...prev, filter_phase: !prev?.filter_phase ? AnnotationPhaseType.Verification : null, diff --git a/frontend/src/features/AnnotationCampaign/CampaignFilters/ResetButton.tsx b/frontend/src/features/AnnotationCampaign/CampaignFilters/ResetButton.tsx index 0ce91b99e..640e33066 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 AllCampaignsQueryVariables } 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,10 +20,10 @@ 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, + } as AllCampaignsQueryVariables, }) }, [ navigate, user ]) diff --git a/frontend/src/features/AnnotationCampaign/CampaignFilters/index.tsx b/frontend/src/features/AnnotationCampaign/CampaignFilters/index.tsx index f01ec3771..4ba8a247c 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'; @@ -9,29 +8,31 @@ import { AnnotationCampaignArchiveFilter } from './ArchiveFilter'; 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, useSearch } from '@tanstack/react-router'; export const AnnotationCampaignListFilterActionBar: React.FC = () => { - const search = Route.useSearch({ select: ({search}) => search }); + const search = useSearch({ + from: '/_authenticated/annotation-campaign/', + select: ({ search }) => search, + }); const navigate = useNavigate(); - const { user } = useCurrentUser(); + const { user } = useLoaderData({ from: '/_authenticated' }) return navigate({ - to: Route.to, + to: '/annotation-campaign', search: (prev) => ({ ...prev, search, }), replace: true, }) } - actionButton={ user?.isAdmin && + actionButton={ user.isAdmin && New annotation campaign }> diff --git a/frontend/src/features/AnnotationCampaign/CampaignInfo.tsx b/frontend/src/features/AnnotationCampaign/CampaignInfo.tsx index 79bf165b6..1dcc0cc26 100644 --- a/frontend/src/features/AnnotationCampaign/CampaignInfo.tsx +++ b/frontend/src/features/AnnotationCampaign/CampaignInfo.tsx @@ -9,6 +9,7 @@ export const CampaignName: React.FC<{ }> = ({ children, id, link }) => useMemo(() => { if (link && id) return { children } return

{ children }

}, [ children, id, link ]) diff --git a/frontend/src/api/annotation-campaign/annotation-campaign.graphql b/frontend/src/features/AnnotationCampaign/api/annotation-campaign.graphql similarity index 90% rename from frontend/src/api/annotation-campaign/annotation-campaign.graphql rename to frontend/src/features/AnnotationCampaign/api/annotation-campaign.graphql index e014c172c..8472ebfb0 100644 --- a/frontend/src/api/annotation-campaign/annotation-campaign.graphql +++ b/frontend/src/features/AnnotationCampaign/api/annotation-campaign.graphql @@ -1,10 +1,26 @@ -query listCampaigns( +fragment BaseCampaign on AnnotationCampaignNode { + id + name + deadline + isArchived + datasetName + phases { + results { + phase + } + } + tasksCount + completedTasksCount + userTasksCount + userCompletedTasksCount +} + +query allCampaigns( $search: String $filter_isArchived: Boolean $filter_phase: AnnotationPhaseType $filter_ownerID: ID $filter_annotatorID: ID - $filter_datasetID: ID ) { allAnnotationCampaigns( isArchived: $filter_isArchived @@ -12,26 +28,10 @@ query listCampaigns( ownerId: $filter_ownerID phases_AnnotationFileRanges_AnnotatorId: $filter_annotatorID search: $search - analysis_DatasetId: $filter_datasetID orderBy: "name" ) { - results { - id - name - deadline - isArchived - datasetName - phases { - results { - phase - } - } - tasksCount - completedTasksCount - userTasksCount - userCompletedTasksCount - } + results { ...BaseCampaign } } } diff --git a/frontend/src/features/AnnotationCampaign/api/index.ts b/frontend/src/features/AnnotationCampaign/api/index.ts new file mode 100644 index 000000000..d678cf7f2 --- /dev/null +++ b/frontend/src/features/AnnotationCampaign/api/index.ts @@ -0,0 +1,65 @@ +import { mutationOptions, queryOptions } from '@tanstack/react-query'; +import { queryKeys } from '@/api/queryKeys'; +import { graphqlClient } from '@/api/graphqlClient'; +import { cleanGqlList } from '@/api/utils'; +import { + AllCampaignsDocument, + type AllCampaignsQuery, + type AllCampaignsQueryVariables, + ArchiveCampaignDocument, + type ArchiveCampaignMutation, + type ArchiveCampaignMutationVariables, + CreateCampaignDocument, + type CreateCampaignMutation, + type CreateCampaignMutationVariables, + GetCampaignDocument, + type GetCampaignQuery, + type GetCampaignQueryVariables, + UpdateCampaignFeaturedLabelsDocument, + type UpdateCampaignFeaturedLabelsMutation, + type UpdateCampaignFeaturedLabelsMutationVariables, +} from './annotation-campaign.generated' +import { queryClient } from '@/api/queryClient'; + +export const allQuery = (variables: AllCampaignsQueryVariables) => queryOptions({ + queryKey: queryKeys.campaign.all(variables), + queryFn: () => graphqlClient.request(AllCampaignsDocument, variables) + .then(data => cleanGqlList(data.allAnnotationCampaigns?.results)), +}) + +export const byIdQuery = (variables: GetCampaignQueryVariables) => queryOptions({ + queryKey: queryKeys.campaign.byId(variables), + queryFn: () => graphqlClient.request(GetCampaignDocument, variables) + .then(data => ({ + campaign: data.annotationCampaignById, + phases: cleanGqlList(data.annotationCampaignById?.phases?.results), + analysis: cleanGqlList(data.annotationCampaignById?.analysis?.edges.map(e => e?.node)), + confidences: cleanGqlList(data.annotationCampaignById?.confidenceSet?.confidenceIndicators), + labels: cleanGqlList(data.annotationCampaignById?.labelSet?.labels), + })), +}) + +export const createMutation = mutationOptions({ + mutationFn: (variables: CreateCampaignMutationVariables) => graphqlClient.request(CreateCampaignDocument, variables) + .then(data => data.createAnnotationCampaign), + onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.campaign.base }), +}) + + +export const archiveMutation = mutationOptions({ + mutationFn: (variables: ArchiveCampaignMutationVariables) => graphqlClient.request(ArchiveCampaignDocument, variables), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.campaign.byId({ id: variables.id }) }) + queryClient.invalidateQueries({ queryKey: queryKeys.campaign.base }) + }, +}) + +export const updateFeaturedLabelsMutation = mutationOptions({ + mutationFn: (variables: UpdateCampaignFeaturedLabelsMutationVariables) => graphqlClient.request(UpdateCampaignFeaturedLabelsDocument, variables), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.campaign.byId({ id: variables.id }) }) + }, +}) + + +export type * from './annotation-campaign.generated' diff --git a/frontend/src/features/AnnotationCampaign/index.ts b/frontend/src/features/AnnotationCampaign/index.ts index 95aa4dde3..06b95f60f 100644 --- a/frontend/src/features/AnnotationCampaign/index.ts +++ b/frontend/src/features/AnnotationCampaign/index.ts @@ -1,3 +1,6 @@ +export * as API from './api' +export type * from './api' + export * from './ArchiveButton' export * from './CampaignCard' export * from './CampaignFilters' diff --git a/frontend/src/features/AnnotationFileRange/FileRangeActionBar.tsx b/frontend/src/features/AnnotationFileRange/FileRangeActionBar.tsx index a1cb55629..e5f26c66e 100644 --- a/frontend/src/features/AnnotationFileRange/FileRangeActionBar.tsx +++ b/frontend/src/features/AnnotationFileRange/FileRangeActionBar.tsx @@ -4,19 +4,17 @@ import { IonButton, IonIcon } from '@ionic/react'; import { peopleOutline, playOutline, refreshOutline } from 'ionicons/icons/index.js'; import { ActionBar, Button, Link, Progress, TooltipOverlay, useModal } from '@/components/ui'; import { ImportAnnotationsButton } from '@/features/AnnotationPhase'; -import { useAllAnnotationTasks, useCurrentPhase } from '@/api'; import { FileRangeProgressModal } from '@/features/AnnotationFileRange'; import { useOpenAnnotator } from '@/features/Annotator/Navigation'; import { analytics } from 'ionicons/icons'; 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 FileRangeActionBar: React.FC = () => { const searchParams = Route.useSearch(); const routeParams = Route.useParams() const navigate = useNavigate(); - const { phase } = useCurrentPhase() - const { allSpectrograms, resumeSpectrogramID } = useAllAnnotationTasks(searchParams) + const { phase, spectrograms, resumeSpectrogramId } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/_detailLayout/phase/$phaseType' }) const openAnnotator = useOpenAnnotator() const updateSearch = useCallback((input: string) => { @@ -45,14 +43,14 @@ export const FileRangeActionBar: React.FC = () => { const resumeBtnTooltip: string = useMemo(() => { if (hasFilters) return 'Cannot resume if filters are activated' - if (!allSpectrograms || allSpectrograms.length === 0) return 'No files to annotate' + if (!spectrograms || spectrograms.length === 0) return 'No files to annotate' return 'Resume annotation' - }, [ hasFilters, allSpectrograms ]) + }, [ hasFilters, spectrograms ]) const resume = useCallback(() => { - if (!resumeSpectrogramID) return; - openAnnotator(resumeSpectrogramID, { resume: true }) - }, [ resumeSpectrogramID, openAnnotator ]) + if (!resumeSpectrogramId) return; + openAnnotator(resumeSpectrogramId, { resume: true }) + }, [ resumeSpectrogramId, openAnnotator ]) const progressModal = useModal(FileRangeProgressModal) @@ -62,10 +60,11 @@ export const FileRangeActionBar: React.FC = () => { onSearchChange={ updateSearch } actionButton={
- { (hasFilters || searchParams.onlyAssigned) && - - Reset - } + { (hasFilters || searchParams.onlyAssigned) && + + + Reset + }
{ phase && phase.userTasksCount && phase.userTasksCount > 0 ? @@ -103,7 +102,7 @@ export const FileRangeActionBar: React.FC = () => { {/* Resume */ } { resumeBtnTooltip }

} anchor="right">
- { (isPostingPhase || isFetchingCampaign) && } + { (isPostingPhase) && } @@ -138,14 +126,16 @@ export const AnnotationPhaseCreateAnnotationModal: React.FC<{ export const AnnotationPhaseCreateVerificationModal: React.FC<{ onClose: () => void; }> = ({ onClose }) => { - const { campaign, phases, isFetching, refetch } = useCurrentCampaign() - const { isLoading: isPostingPhase, error, createVerificationPhase } = useCreateVerificationPhase() + const { campaign } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) + const { + isPending: isPostingPhase, + error, + mutateAsync: createVerificationPhase, + } = useMutation(createVerificationMutation) const navigate = useNavigate() const create = useCallback(async () => { - if (!campaign) return; - await createVerificationPhase({ campaignID: campaign.id }).unwrap() - await refetch().unwrap() + await createVerificationPhase({ campaignID: campaign.id }) navigate({ to: '/annotation-campaign/$campaignID/phase/$phaseType', params: { @@ -155,11 +145,10 @@ export const AnnotationPhaseCreateVerificationModal: React.FC<{ search: { page: 1 }, }) onClose() - }, [ campaign, createVerificationPhase, navigate, onClose, refetch ]) + }, [ campaign, createVerificationPhase, navigate, onClose ]) const createAndImport = useCallback(async () => { - if (!campaign) return; - await createVerificationPhase({ campaignID: campaign.id }).unwrap() + await createVerificationPhase({ campaignID: campaign.id }) navigate({ to: '/annotation-campaign/$campaignID/phase/$phaseType/import-annotations', params: { @@ -170,7 +159,7 @@ export const AnnotationPhaseCreateVerificationModal: React.FC<{ onClose() }, [ campaign, createVerificationPhase, navigate, onClose ]) - if (campaign?.isArchived || !phases) return + if (campaign.isArchived) return return @@ -179,7 +168,7 @@ export const AnnotationPhaseCreateVerificationModal: React.FC<{

Annotations come from the "Annotation" phase and may be created manually or imported (e.g., from an automatic detector).

- { error && } + { error && }
@@ -188,12 +177,11 @@ export const AnnotationPhaseCreateVerificationModal: React.FC<{
- { (isPostingPhase || isFetching) && } + { isPostingPhase && } diff --git a/frontend/src/features/AnnotationPhase/PhaseTab.tsx b/frontend/src/features/AnnotationPhase/PhaseTab.tsx index 4c7a7e9a1..5b2ff27f1 100644 --- a/frontend/src/features/AnnotationPhase/PhaseTab.tsx +++ b/frontend/src/features/AnnotationPhase/PhaseTab.tsx @@ -1,14 +1,18 @@ import React, { Fragment, useCallback, useMemo } from 'react'; -import { IonIcon, IonSkeletonText } from '@ionic/react'; +import { IonIcon } from '@ionic/react'; import { addOutline, closeOutline } from 'ionicons/icons/index.js'; import { Button, Tab, useAlert, useModal } from '@/components/ui'; -import { AnnotationPhaseType, useCurrentCampaign, useEndPhase } from '@/api'; +import { AnnotationPhaseType } from '@/api'; import { AnnotationPhaseCreateAnnotationModal, AnnotationPhaseCreateVerificationModal } from './PhaseCreateModal' -import { useParams } from '@tanstack/react-router'; +import { useLoaderData, useParams } from '@tanstack/react-router'; +import { useMutation } from '@tanstack/react-query'; +import { endMutation } from './api' +import { queryClient } from '@/api/queryClient'; +import { queryKeys } from '@/api/queryKeys'; export const AnnotationPhaseTab: React.FC<{ phaseType: AnnotationPhaseType }> = ({ phaseType: phaseType }) => { - const { campaignID, phaseType: currentPhaseType } = useParams({ strict: false }); - const { campaign, phases, isFetching } = useCurrentCampaign() + const { phaseType: currentPhaseType } = useParams({ strict: false }); + const { campaign, phases } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) const phase = useMemo(() => phases?.find(p => p.phase === phaseType), [ phases, phaseType ]) const alert = useAlert(); @@ -40,9 +44,22 @@ export const AnnotationPhaseTab: React.FC<{ phaseType: AnnotationPhaseType }> = } }, [ phases, annotationModal, verificationModal, alert, phaseType ]) - const { endPhase } = useEndPhase() + const onSuccess = useCallback(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.campaign.byId({ id: campaign.id }) }) + queryClient.invalidateQueries({ queryKey: queryKeys.campaign.base }) + queryClient.invalidateQueries({ + queryKey: queryKeys.phase.get({ + campaignID: campaign.id, + phase: phaseType, + }), + }) + }, [ campaign, phaseType ]) + const { mutate: endPhase } = useMutation({ + ...endMutation, + onSuccess, + }) const end = useCallback(async () => { - if (!phase || !campaignID) return; + if (!phase) return; if (phase.completedTasksCount < phase.tasksCount) { // If annotators haven't finished yet, ask for confirmation return alert.showAlert({ @@ -50,21 +67,15 @@ export const AnnotationPhaseTab: React.FC<{ phaseType: AnnotationPhaseType }> = message: 'There is still unprocessed files.\nAre you sure you want to end this phase?', actions: [ { label: 'End', - callback: () => endPhase({ id: phase.id, campaignID }), + callback: () => endPhase({ id: phase.id }), } ], }); - } else endPhase({ id: phase.id, campaignID }) - }, [ endPhase, phase, campaignID, alert ]); + } else endPhase({ id: phase.id }) + }, [ endPhase, phase, campaign, alert ]); - if (!campaign) return - if (isFetching) - return - - if (phase) return + params={ { campaignID: campaign.id, phaseType } } active={ currentPhaseType === phaseType }> { phaseType } { campaign.isEditable && campaign.isUserAllowedToManage && currentPhaseType === phaseType && phase?.isOpen && diff --git a/frontend/src/api/annotation-phase/annotation-phase.graphql b/frontend/src/features/AnnotationPhase/api/annotation-phase.graphql similarity index 89% rename from frontend/src/api/annotation-phase/annotation-phase.graphql rename to frontend/src/features/AnnotationPhase/api/annotation-phase.graphql index b25a82fd1..945d1a3a1 100644 --- a/frontend/src/api/annotation-phase/annotation-phase.graphql +++ b/frontend/src/features/AnnotationPhase/api/annotation-phase.graphql @@ -1,6 +1,25 @@ -# campaignPk is used to invalidate getCampaign tags -# noinspection GraphQLSchemaValidation -mutation endPhase($id: ID!, $campaignID: ID!) { +query getAnnotationPhase( + $campaignID: ID! + $phase: AnnotationPhaseType! +) { + annotationPhaseByCampaignPhase( + campaignId: $campaignID + phaseType: $phase + ) { + id + phase + isEditable + isUserAllowedToManage + endedAt + hasAnnotations + tasksCount + completedTasksCount + userTasksCount + userCompletedTasksCount + } +} + +mutation endPhase($id: ID!) { endAnnotationPhase(id: $id) { ok } @@ -35,24 +54,3 @@ mutation createVerificationPhase($campaignID: ID!) { id } } - -query getAnnotationPhase( - $campaignID: ID! - $phase: AnnotationPhaseType! -) { - annotationPhaseByCampaignPhase( - campaignId: $campaignID - phaseType: $phase - ) { - id - phase - isEditable - isUserAllowedToManage - endedAt - hasAnnotations - tasksCount - completedTasksCount - userTasksCount - userCompletedTasksCount - } -} diff --git a/frontend/src/features/AnnotationPhase/api/index.ts b/frontend/src/features/AnnotationPhase/api/index.ts new file mode 100644 index 000000000..3faafa376 --- /dev/null +++ b/frontend/src/features/AnnotationPhase/api/index.ts @@ -0,0 +1,46 @@ +import { mutationOptions, queryOptions } from '@tanstack/react-query'; +import { queryKeys } from '@/api/queryKeys'; +import { graphqlClient } from '@/api/graphqlClient'; +import { + CreateAnnotationPhaseDocument, + type CreateAnnotationPhaseMutation, + type CreateAnnotationPhaseMutationVariables, + CreateVerificationPhaseDocument, + type CreateVerificationPhaseMutation, + type CreateVerificationPhaseMutationVariables, + EndPhaseDocument, + type EndPhaseMutation, + type EndPhaseMutationVariables, + GetAnnotationPhaseDocument, + type GetAnnotationPhaseQuery, + type GetAnnotationPhaseQueryVariables, +} from './annotation-phase.generated' +import { queryClient } from '@/api/queryClient'; + +export const getQuery = (variables: GetAnnotationPhaseQueryVariables) => queryOptions({ + queryKey: queryKeys.phase.get(variables), + queryFn: () => graphqlClient.request(GetAnnotationPhaseDocument, variables) + .then(data => data.annotationPhaseByCampaignPhase), +}) + +export const endMutation = mutationOptions({ + mutationFn: (variables: EndPhaseMutationVariables) => graphqlClient.request(EndPhaseDocument, variables), +}) + +export const createAnnotationMutation = mutationOptions({ + mutationFn: (variables: CreateAnnotationPhaseMutationVariables) => graphqlClient.request(CreateAnnotationPhaseDocument, variables), + onSuccess: (_data, { campaignID }) => { + queryClient.invalidateQueries({ queryKey: queryKeys.campaign.byId({ id: campaignID }) }) + queryClient.invalidateQueries({ queryKey: queryKeys.campaign.base }) + }, +}) + +export const createVerificationMutation = mutationOptions({ + mutationFn: (variables: CreateVerificationPhaseMutationVariables) => graphqlClient.request(CreateVerificationPhaseDocument, variables), + onSuccess: (_data, { campaignID }) => { + queryClient.invalidateQueries({ queryKey: queryKeys.campaign.byId({ id: campaignID }) }) + queryClient.invalidateQueries({ queryKey: queryKeys.campaign.base }) + }, +}) + +export type * from './annotation-phase.generated' \ No newline at end of file diff --git a/frontend/src/features/AnnotationPhase/index.ts b/frontend/src/features/AnnotationPhase/index.ts index 6be9e2669..bfb968baa 100644 --- a/frontend/src/features/AnnotationPhase/index.ts +++ b/frontend/src/features/AnnotationPhase/index.ts @@ -1,3 +1,6 @@ +export * as API from './api' +export type * from './api' + export * from './ImportAnnotationsButton' export * from './PhaseTab' export * from './PhaseCreateModal' diff --git a/frontend/src/features/AnnotationSpectrogram/SpectrogramRow.tsx b/frontend/src/features/AnnotationSpectrogram/SpectrogramRow.tsx index c25a1a634..9f741507a 100644 --- a/frontend/src/features/AnnotationSpectrogram/SpectrogramRow.tsx +++ b/frontend/src/features/AnnotationSpectrogram/SpectrogramRow.tsx @@ -5,7 +5,6 @@ import { AnnotationTaskNode, AnnotationTaskStatus, type Maybe, - useCurrentPhase, } from '@/api'; import React, { Fragment, useMemo } from 'react'; import { Button, Td, Th, Tr } from '@/components/ui'; @@ -14,6 +13,7 @@ import { checkmarkCircle, chevronForwardOutline, ellipseOutline } from 'ionicons import { useOpenAnnotator } from '@/features/Annotator/Navigation'; import { formatTime } from '@/service/function'; import styles from './styles.module.scss' +import { useLoaderData } from '@tanstack/react-router'; export const SpectrogramRow: React.FC<{ spectrogram: Pick, @@ -22,7 +22,7 @@ export const SpectrogramRow: React.FC<{ annotationsToCheck?: Maybe>; validAnnotationsToCheck?: Maybe>; }> = ({ spectrogram, task, userAnnotations, annotationsToCheck, validAnnotationsToCheck }) => { - const { phase } = useCurrentPhase() + const { phase } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/_detailLayout/phase/$phaseType' }) const openAnnotator = useOpenAnnotator() const submitted = useMemo(() => task?.status === AnnotationTaskStatus.Finished, [ task ]) diff --git a/frontend/src/api/annotation-task/annotation-task.graphql b/frontend/src/features/AnnotationSpectrogram/api/annotation-spectrogram.graphql similarity index 89% rename from frontend/src/api/annotation-task/annotation-task.graphql rename to frontend/src/features/AnnotationSpectrogram/api/annotation-spectrogram.graphql index 9b43b4b24..7acd405df 100644 --- a/frontend/src/api/annotation-task/annotation-task.graphql +++ b/frontend/src/features/AnnotationSpectrogram/api/annotation-spectrogram.graphql @@ -1,4 +1,9 @@ -query listAnnotationTask( +fragment Comment on AnnotationCommentNode { + id + comment +} + +query allAnnotationSpectrograms( $annotatorID: ID! $campaignID: ID! $phaseType: AnnotationPhaseType! @@ -101,11 +106,10 @@ query listAnnotationTask( } } -query getAnnotationTask( +query getAnnotationSpectrogram( $spectrogramID: ID! $annotatorID: ID! $campaignID: ID! - $analysisID: ID! $phaseType: AnnotationPhaseType! # Filtering @@ -122,10 +126,6 @@ query getAnnotationTask( $annotationAnnotator: ID $withAcousticFeatures: Boolean ) { - spectrogramPaths(spectrogramId: $spectrogramID, analysisId: $analysisID) { - audioPath - spectrogramPath - } annotationSpectrogramById(id: $spectrogramID) { id filename @@ -144,10 +144,7 @@ query getAnnotationTask( author: $annotatorID, annotationPhase_Phase: $phaseType ) { - results { - id - comment - } + results { ... Comment } } userAnnotations { @@ -179,10 +176,7 @@ query getAnnotationTask( displayName } comments(author: $annotatorID) { - results{ - id - comment - } + results { ... Comment } } validations(annotator: $annotatorID) { results { @@ -241,10 +235,7 @@ query getAnnotationTask( displayName } comments(author: $annotatorID) { - results{ - id - comment - } + results { ... Comment } } validations(annotator: $annotatorID) { results { @@ -312,33 +303,13 @@ query getAnnotationTask( } } -mutation submitTask ( - $campaignID: ID! +query getAnnotationSpectrogramPaths( $spectrogramID: ID! - $phase: AnnotationPhaseType! - $annotations: [AnnotationInput]! - $taskComments: [AnnotationCommentInput]! - - $startedAt: DateTime! - $endedAt: DateTime! + $analysisID: ID! ) { - submitAnnotationTask( - spectrogramId: $spectrogramID - phaseType: $phase - campaignId: $campaignID - startedAt: $startedAt - endedAt: $endedAt - annotations: $annotations - taskComments: $taskComments - ) { - ok - annotationErrors { - field - messages - } - taskCommentsErrors { - field - messages - } + spectrogramPaths(spectrogramId: $spectrogramID, analysisId: $analysisID) { + audioPath + spectrogramPath } } + diff --git a/frontend/src/features/AnnotationSpectrogram/api/index.ts b/frontend/src/features/AnnotationSpectrogram/api/index.ts new file mode 100644 index 000000000..29f2e1527 --- /dev/null +++ b/frontend/src/features/AnnotationSpectrogram/api/index.ts @@ -0,0 +1,53 @@ +import { queryOptions } from '@tanstack/react-query'; +import { queryKeys } from '@/api/queryKeys'; +import { graphqlClient } from '@/api/graphqlClient'; +import { + AllAnnotationSpectrogramsDocument, + type AllAnnotationSpectrogramsQuery, + type AllAnnotationSpectrogramsQueryVariables, + GetAnnotationSpectrogramDocument, + GetAnnotationSpectrogramPathsDocument, + type GetAnnotationSpectrogramPathsQuery, + type GetAnnotationSpectrogramPathsQueryVariables, + type GetAnnotationSpectrogramQuery, + type GetAnnotationSpectrogramQueryVariables, +} from './annotation-spectrogram.generated' +import { cleanGqlList } from '@/api/utils'; + +export const allQuery = (variables: AllAnnotationSpectrogramsQueryVariables) => queryOptions({ + queryKey: queryKeys.spectrogram.all(variables), + queryFn: () => graphqlClient.request(AllAnnotationSpectrogramsDocument, variables) + .then(data => ({ + spectrograms: cleanGqlList(data.allAnnotationSpectrograms?.results), + totalCount: data.allAnnotationSpectrograms?.totalCount, + resumeId: data.allAnnotationSpectrograms?.resumeSpectrogramId, + })), +}) + +export const getQuery = (variables: GetAnnotationSpectrogramQueryVariables) => queryOptions({ + queryKey: queryKeys.spectrogram.get(variables), + queryFn: () => graphqlClient.request(GetAnnotationSpectrogramDocument, variables) + .then(data => ({ + spectrogram: data.annotationSpectrogramById, + annotations: [ + ...cleanGqlList(data.annotationSpectrogramById?.task?.userAnnotations?.results), + ...cleanGqlList(data.annotationSpectrogramById?.task?.annotationsToCheck?.results), + ], + info: data.allAnnotationSpectrograms, + isEditionAuthorized: data.annotationSpectrogramById?.isAssigned, + })), +}) + +export const getPathQuery = (variables: GetAnnotationSpectrogramPathsQueryVariables) => queryOptions({ + queryKey: queryKeys.spectrogram.getPath(variables), + queryFn: () => graphqlClient.request(GetAnnotationSpectrogramPathsDocument, variables) + .then(data => data.spectrogramPaths), +}) + +export type AllSpectrogramsFilters = + Pick + & { + page: number +} + +export type * from './annotation-spectrogram.generated' \ No newline at end of file diff --git a/frontend/src/features/AnnotationSpectrogram/index.ts b/frontend/src/features/AnnotationSpectrogram/index.ts index f78a3abdd..9f7c34687 100644 --- a/frontend/src/features/AnnotationSpectrogram/index.ts +++ b/frontend/src/features/AnnotationSpectrogram/index.ts @@ -1 +1,4 @@ +export * as API from './api' +export type * from './api' + export * from './SpectrogramRow' diff --git a/frontend/src/features/AnnotationTask/AnnotationsFilter.tsx b/frontend/src/features/AnnotationTask/AnnotationsFilter.tsx index 3e4ebae21..2861634be 100644 --- a/frontend/src/features/AnnotationTask/AnnotationsFilter.tsx +++ b/frontend/src/features/AnnotationTask/AnnotationsFilter.tsx @@ -1,5 +1,5 @@ import React, { Fragment, useCallback } from 'react'; -import { AnnotationPhaseType, useCurrentCampaign } from '@/api'; +import { AnnotationPhaseType } from '@/api'; import { ConfidenceSelect } from '@/features/Confidence'; import { LabelSelect } from '@/features/Labels'; import { BooleanSwitch } from '@/components/form'; @@ -8,7 +8,7 @@ import { Modal, type ModalProps } from '@/components/ui'; import { DetectorSelect } from '@/features/Detector'; import { UserSelect } from '@/features/User'; 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 AnnotationsFilterModal: React.FC { if (input && withAnnotations) return; @@ -145,31 +145,28 @@ export const AnnotationsFilterModal: React.FC + onSelected={ setLabel }/> - { campaign?.confidenceSet && } { routeParams.phaseType === AnnotationPhaseType.Verification && + onSelected={ setDetector }/> + onSelected={ setAnnotator }/> } diff --git a/frontend/src/features/AnnotationTask/DateFilter.tsx b/frontend/src/features/AnnotationTask/DateFilter.tsx index fa7cf2c88..b5066de1c 100644 --- a/frontend/src/features/AnnotationTask/DateFilter.tsx +++ b/frontend/src/features/AnnotationTask/DateFilter.tsx @@ -3,10 +3,10 @@ import { Button, Modal, type ModalProps } from '@/components/ui'; import { Input } from '@/components/form'; import { IonIcon } from '@ionic/react'; import { closeOutline } from 'ionicons/icons/index.js'; -import { AllTasksFilters } from '@/api'; import styles from './styles.module.scss' import { Route } from '@/routes/_authenticated/annotation-campaign/$campaignID/_detailLayout/phase.$phaseType'; import { useNavigate } from '@tanstack/react-router'; +import type { AllSpectrogramsFilters } from '@/features/AnnotationSpectrogram'; function getDateString(event: ChangeEvent): string | undefined { @@ -43,7 +43,7 @@ export const DateFilterModal: React.FC) => { + const update = useCallback((update: Partial) => { navigate({ to: Route.to, params: routeParams, 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/AnnotationTask/api/annotation-task.graphql b/frontend/src/features/AnnotationTask/api/annotation-task.graphql new file mode 100644 index 000000000..1b144f1b7 --- /dev/null +++ b/frontend/src/features/AnnotationTask/api/annotation-task.graphql @@ -0,0 +1,30 @@ +mutation submitTask ( + $campaignID: ID! + $spectrogramID: ID! + $phase: AnnotationPhaseType! + $annotations: [AnnotationInput]! + $taskComments: [AnnotationCommentInput]! + + $startedAt: DateTime! + $endedAt: DateTime! +) { + submitAnnotationTask( + spectrogramId: $spectrogramID + phaseType: $phase + campaignId: $campaignID + startedAt: $startedAt + endedAt: $endedAt + annotations: $annotations + taskComments: $taskComments + ) { + ok + annotationErrors { + field + messages + } + taskCommentsErrors { + field + messages + } + } +} diff --git a/frontend/src/features/AnnotationTask/api/index.ts b/frontend/src/features/AnnotationTask/api/index.ts new file mode 100644 index 000000000..941a91868 --- /dev/null +++ b/frontend/src/features/AnnotationTask/api/index.ts @@ -0,0 +1,20 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { + SubmitTaskDocument, + type SubmitTaskMutation, + type SubmitTaskMutationVariables, +} from './annotation-task.generated'; +import { graphqlClient } from '@/api/graphqlClient'; +import { queryClient } from '@/api/queryClient'; +import { queryKeys } from '@/api/queryKeys'; + +export const submitMutation = mutationOptions({ + mutationFn: (variables: SubmitTaskMutationVariables) => graphqlClient.request(SubmitTaskDocument, variables), + onSuccess: (_data, { campaignID, phase, spectrogramID }) => { + console.debug('invalidate', queryKeys.spectrogram.get({ campaignID, phaseType: phase, spectrogramID })) + queryClient.invalidateQueries({ type: 'all', queryKey: queryKeys.spectrogram.get({ campaignID, phaseType: phase, spectrogramID }) }) + queryClient.invalidateQueries({ queryKey: queryKeys.phase.get({ campaignID, phase }) }) + }, +}) + +export type * from './annotation-task.generated' \ No newline at end of file diff --git a/frontend/src/features/AnnotationTask/index.ts b/frontend/src/features/AnnotationTask/index.ts index e3e0e7ce2..3b6d72508 100644 --- a/frontend/src/features/AnnotationTask/index.ts +++ b/frontend/src/features/AnnotationTask/index.ts @@ -1,3 +1,6 @@ +export * as API from './api' +export type * from './api' + export * from './AnnotationsFilter' export * from './DateFilter' export * from './StatusFilter' diff --git a/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx b/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx index 903f25a00..9b208de48 100644 --- a/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx +++ b/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx @@ -2,7 +2,7 @@ import React, { Fragment, useCallback, useEffect, useMemo, useRef } from 'react' import styles from './styles.module.scss'; import { type ExtendedDivPosition, Table, Tbody, useExtendedDiv } from '@/components/ui'; import { IoRemoveCircleOutline } from 'react-icons/io5'; -import { AnnotationType, useCurrentCampaign } from '@/api'; +import { AnnotationType } from '@/api'; import { useTimeScale } from '@/features/Annotator/Axis'; import { useAppDispatch, useAppSelector } from '@/features/App'; import { selectAnnotation } from '@/features/Annotator/Annotation/selectors'; @@ -15,11 +15,12 @@ import { Duration } from '@/features/Annotator/AcousticFeatures/Duration'; import { NonLinearPhenomena } from '@/features/Annotator/AcousticFeatures/NonLinearPhenomena'; import { Checks } from '@/features/Annotator/AcousticFeatures/Checks'; import { useWindowWidth } from '@/features/Annotator/Canvas'; +import { useLoaderData } from '@tanstack/react-router'; export const AcousticFeatures: React.FC<{ scrollLeft: number }> = ({ scrollLeft }) => { const focusedAnnotation = useAppSelector(selectAnnotation) const getAnnotation = useGetAnnotation() - const { campaign } = useCurrentCampaign() + const { campaign } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) const timeScale = useTimeScale() const dispatch = useAppDispatch(); @@ -70,7 +71,7 @@ export const AcousticFeatures: React.FC<{ scrollLeft: number }> = ({ scrollLeft return useMemo(() => { if (!focusedAnnotation) return ; - if (!campaign?.labelsWithAcousticFeatures?.find(l => l?.name === focusedAnnotation.label)) return ; + if (!campaign.labelsWithAcousticFeatures?.find(l => l?.name === focusedAnnotation.label)) return ; if (focusedAnnotation.type !== AnnotationType.Box) return ; return
= ({ annotation }) => { - const { spectrogram } = useAnnotationTask() - const { phase } = useCurrentPhase() + const { phase } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/phase/$phaseType' }) + const { spectrogram } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/phase/$phaseType/spectrogram/$spectrogramID' }) const updateAnnotation = useUpdateAnnotation() const onDurationUpdate = useCallback((value: number) => { @@ -26,10 +26,10 @@ export const Duration: React.FC<{ annotation: Annotation }> = ({ annotation }) = return Duration -
- + onDurationUpdate(+e.currentTarget.value) }/> s diff --git a/frontend/src/features/Annotator/AcousticFeatures/Frequency.tsx b/frontend/src/features/Annotator/AcousticFeatures/Frequency.tsx index a80d317ca..bb3a0d302 100644 --- a/frontend/src/features/Annotator/AcousticFeatures/Frequency.tsx +++ b/frontend/src/features/Annotator/AcousticFeatures/Frequency.tsx @@ -2,14 +2,13 @@ import React, { Fragment, useCallback, useMemo } from 'react'; import { type Annotation, type Features, useUpdateAnnotation } from '@/features/Annotator/Annotation'; import { Th, Tr } from '@/components/ui'; import { BooleanRow, InputRow, NoteRow } from '@/features/Annotator/AcousticFeatures/Rows'; -import { useAppSelector } from '@/features/App'; -import { selectAnalysis } from '@/features/Annotator/Analysis'; import { useUpdateAnnotationFeatures } from '@/features/Annotator/AcousticFeatures/hooks'; -import { useCurrentPhase } from '@/api'; +import { useAnnotatorAnalysis } from '@/features/Annotator/Analysis/hooks'; +import { useLoaderData } from '@tanstack/react-router'; export const Frequency: React.FC<{ annotation: Annotation }> = ({ annotation }) => { - const { phase } = useCurrentPhase() - const analysis = useAppSelector(selectAnalysis) + const { phase } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/phase/$phaseType' }) + const analysis = useAnnotatorAnalysis() const maxFrequency = useMemo(() => (analysis?.fft.samplingFrequency ?? 0) / 2, [ analysis ]) const updateAnnotation = useUpdateAnnotation() diff --git a/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx b/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx index 74fd7b42d..ee2ea9bd9 100644 --- a/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx +++ b/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx @@ -1,14 +1,15 @@ import React, { useCallback, useMemo } from 'react'; import { Select } from '@/components/form'; -import { useAppDispatch, useAppSelector } from '@/features/App'; -import { selectAllAnalysis, selectAnalysis } from './selectors'; +import { useAppDispatch } from '@/features/App'; import { Analysis, setAnalysis } from './slice'; import { frequencyToString } from '@/service/function'; +import { useLoaderData } from '@tanstack/react-router'; +import { useAnnotatorAnalysis } from '@/features/Annotator/Analysis/hooks'; export const AnalysisSelect: React.FC = () => { - const allAnalysis = useAppSelector(selectAllAnalysis) - const analysis = useAppSelector(selectAnalysis) + const { analysis: allAnalysis } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) + const analysis = useAnnotatorAnalysis() const dispatch = useAppDispatch() const set = useCallback((value?: Analysis) => { diff --git a/frontend/src/features/Annotator/Analysis/hooks.ts b/frontend/src/features/Annotator/Analysis/hooks.ts new file mode 100644 index 000000000..89a698b47 --- /dev/null +++ b/frontend/src/features/Annotator/Analysis/hooks.ts @@ -0,0 +1,10 @@ +import { useLoaderData } from '@tanstack/react-router'; +import { useAppSelector } from '@/features/App'; +import { selectAnalysisID } from './selectors'; +import { useMemo } from 'react'; + +export const useAnnotatorAnalysis = () => { + const { analysis } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) + const id = useAppSelector(selectAnalysisID) + return useMemo(() => analysis.find(a => a.id === id), [ analysis, id ]) +} \ No newline at end of file diff --git a/frontend/src/features/Annotator/Analysis/selectors.ts b/frontend/src/features/Annotator/Analysis/selectors.ts index 1b8a10457..7cf7c9cf3 100644 --- a/frontend/src/features/Annotator/Analysis/selectors.ts +++ b/frontend/src/features/Annotator/Analysis/selectors.ts @@ -1,6 +1,5 @@ import { createSelector } from '@reduxjs/toolkit'; import { AnnotatorAnalysisSlice } from './slice' -import { selectAnnotatorCampaign } from '@/features/Annotator/selectors'; import type { AppState } from '@/features/App'; @@ -8,18 +7,3 @@ export const selectAnalysisID = createSelector( (state: AppState) => state.annotator, AnnotatorAnalysisSlice.selectors.selectID, ) - - -export const selectAllAnalysis = createSelector( - selectAnnotatorCampaign, - (campaignQuery) => - campaignQuery.data?.annotationCampaignById?.analysis.edges.filter(e => e?.node).map(e => e!.node!) ?? [], -) - -export const selectAnalysis = createSelector( - [ - selectAllAnalysis, - selectAnalysisID, - ], - (allAnalysis, analysisID) => allAnalysis.find(a => a.id === analysisID), -) diff --git a/frontend/src/features/Annotator/Analysis/slice.ts b/frontend/src/features/Annotator/Analysis/slice.ts index 16dc2df40..3edbccc74 100644 --- a/frontend/src/features/Annotator/Analysis/slice.ts +++ b/frontend/src/features/Annotator/Analysis/slice.ts @@ -1,58 +1,31 @@ import { createSlice } from '@reduxjs/toolkit'; -import type { GetCampaignQueryVariables } from '@/api'; -import { ColormapNode, getCampaignFulfilled, type GetCampaignQuery, SpectrogramAnalysisNode } from '@/api'; +import { ColormapNode, SpectrogramAnalysisNode } from '@/api'; export type Analysis = Pick & { - colormap: Pick; + colormap: Pick; } | undefined type AnalysisState = { - id?: string; + id?: string; - _campaignID?: string; -} - -export function getDefaultAnalysisID({ data, id }: { data: GetCampaignQuery, id?: string }) { - const allAnalysis = data?.annotationCampaignById?.analysis.edges.filter(e => !!e?.node).map(e => e!.node!) - // Select default analysis when none existing is selected - if (!allAnalysis || allAnalysis.length === 0 || allAnalysis.find(a => a.id === id)) return id; - const baseScaleAnalysis = allAnalysis.find(a => - !a.frequencyScaleParts || a.frequencyScaleParts.length == 0 || - (a.frequencyScaleParts.length == 1 && a.frequencyScaleParts[0]!.minValue == 0 && a.frequencyScaleParts[0]!.maxValue == a.fft.samplingFrequency / 2) - ); - const minID = Math.min(...allAnalysis.map(a => +a!.id))?.toString(); - if (minID) return baseScaleAnalysis?.id ?? minID - return id + _campaignID?: string; } export const AnnotatorAnalysisSlice = createSlice({ - name: 'AnnotatorAnalysis', - initialState: { - _campaignID: undefined, - } as AnalysisState, - reducers: { - setAnalysis: (state, action: { payload: Analysis }) => { - state.id = action.payload?.id + name: 'AnnotatorAnalysis', + initialState: { + _campaignID: undefined, + } as AnalysisState, + reducers: { + setAnalysis: (state, action: { payload: Analysis }) => { + state.id = action.payload?.id + }, + }, + selectors: { + selectID: state => state.id, }, - }, - extraReducers: builder => { - builder.addMatcher(getCampaignFulfilled, (state: AnalysisState, action: { - payload: GetCampaignQuery - meta: { arg: { originalArgs: GetCampaignQueryVariables } } - }) => { - if (state._campaignID !== action.payload.annotationCampaignById?.id) { - state._campaignID = action.payload.annotationCampaignById?.id - state.id = getDefaultAnalysisID({ data: action.payload }) - } else { - state.id = getDefaultAnalysisID({ data: action.payload, id: state.id }) - } - }) - }, - selectors: { - selectID: state => state.id, - }, }) export const { - setAnalysis, + setAnalysis, } = AnnotatorAnalysisSlice.actions diff --git a/frontend/src/features/Annotator/Annotation/AnnotationRow.tsx b/frontend/src/features/Annotator/Annotation/AnnotationRow.tsx index fe487ed12..1ea9ce99d 100644 --- a/frontend/src/features/Annotator/Annotation/AnnotationRow.tsx +++ b/frontend/src/features/Annotator/Annotation/AnnotationRow.tsx @@ -3,15 +3,7 @@ import { type Annotation, focusAnnotation } from './slice'; import { Td, Th, Tr, useModal } from '@/components/ui'; import styles from './styles.module.scss'; import { AnnotationLabelInfo } from './AnnotationLabelInfo'; -import { - AnnotationPhaseType, - AnnotationType, - type GetAnnotationTaskQuery, - useAnnotationTask, - useCurrentCampaign, - useCurrentPhase, - useCurrentUser, -} from '@/api'; +import { AnnotationPhaseType, AnnotationType } from '@/api'; import { useGetAnnotations, useInvalidateAnnotation, @@ -32,24 +24,19 @@ 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'; - -type Spectro = NonNullable -type Task = NonNullable -type CompleteInfo = Pick['results'][number]>, 'detectorConfiguration' | 'annotator'> +import { useLoaderData } from '@tanstack/react-router'; export const AnnotationRow: React.FC<{ annotation: Annotation }> = ({ annotation }) => { - const { phaseType } = useParams({ strict: false }); - const { campaign } = useCurrentCampaign() - const { phase } = useCurrentPhase() + const { campaign } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) + const { phase } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/phase/$phaseType' }) + const { annotations } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/phase/$phaseType/spectrogram/$spectrogramID' }) const focusedAnnotation = useAppSelector(selectAnnotation) const getAnnotations = useGetAnnotations() const validate = useValidateAnnotation() const invalidate = useInvalidateAnnotation() 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) => { @@ -66,9 +53,9 @@ export const AnnotationRow: React.FC<{ annotation: Annotation }> = ({ annotation }) const dispatch = useAppDispatch(); - const completeInfo: CompleteInfo | undefined = useMemo(() => { + const completeInfo = useMemo(() => { if (annotation.annotationPhase == phase?.id) { - return { annotator: user } + return { annotator: user, detectorConfiguration: undefined } } return annotations?.find(a => a.id === annotation.id.toString()) }, [ annotations, annotation, user, phase ]) @@ -87,7 +74,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() @@ -116,19 +103,19 @@ export const AnnotationRow: React.FC<{ annotation: Annotation }> = ({ annotation } {/* Confidence */ } - { campaign?.confidenceSet && } + { campaign.confidenceSet && } {/* Detector | Annotator */ } - { phaseType === AnnotationPhaseType.Verification && ( + { phase.phase === AnnotationPhaseType.Verification && ( completeInfo?.detectorConfiguration ?

{ completeInfo?.detectorConfiguration.detector.name }

: - + -

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

+

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

) } @@ -138,9 +125,9 @@ export const AnnotationRow: React.FC<{ annotation: Annotation }> = ({ annotation {/* Validation */ } - { phaseType === AnnotationPhaseType.Verification && + { phase.phase === AnnotationPhaseType.Verification && - { completeInfo?.annotator?.id !== user?.id ? + { completeInfo?.annotator?.id !== user.id ? = ({ annotation }) => { + const { spectrogram, isEditionAuthorized } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/phase/$phaseType/spectrogram/$spectrogramID' }) + const { labels } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) const dispatch = useAppDispatch(); - const { spectrogram } = useAnnotationTask() const windowWidth = useWindowWidth() const windowHeight = useWindowHeight() - const isEditionAuthorized = useAppSelector(selectTaskIsEditionAuthorized) - const isDrawingEnabled = useAppSelector(selectIsDrawingEnabled) + const isDrawingEnabled = useIsDrawingEnabled() const isSelectingAnnotationFrequency = useAppSelector(selectIsSelectingPositionForAnnotation) const focusedAnnotation = useAppSelector(selectAnnotation) @@ -40,7 +39,6 @@ export const StrongAnnotation: React.FC<{ dispatch(focusAnnotation(annotation)) }, [ annotation, dispatch ]) - const allLabels = useAppSelector(selectAllLabels) const hiddenLabels = useAppSelector(selectHiddenLabels) const [ isMouseHover, setIsMouseHover ] = useState(false); @@ -127,7 +125,7 @@ export const StrongAnnotation: React.FC<{ const isActive = isEditionAuthorized && annotation.id === focusedAnnotation?.id // Style - const colorClass = `ion-color-${ allLabels.indexOf(annotation.update?.label ?? annotation.label) % 10 }` + const colorClass = `ion-color-${ labels.map(l => l.name).indexOf(annotation.update?.label ?? annotation.label) % 10 }` const classes = [ styles.strongAnnotation, colorClass, extendedClassName ] if (!isActive) classes.push(styles.disabled) if (!isDrawingEnabled) classes.push(styles.editDisabled) @@ -173,7 +171,7 @@ export const StrongAnnotation: React.FC<{ }, [ isEditionAuthorized, isDrawingEnabled, isSelectingAnnotationFrequency, isMouseHover, extendedClassName, annotationPosition, - annotation, hiddenLabels, allLabels, focusedAnnotation, spectrogram, + annotation, hiddenLabels, labels, focusedAnnotation, spectrogram, focus, handleMouseDown, ]) } \ No newline at end of file diff --git a/frontend/src/features/Annotator/Annotation/hooks.ts b/frontend/src/features/Annotator/Annotation/hooks.ts index 8f71f37b5..68bcbbca1 100644 --- a/frontend/src/features/Annotator/Annotation/hooks.ts +++ b/frontend/src/features/Annotator/Annotation/hooks.ts @@ -3,10 +3,10 @@ import { useCallback } from 'react'; import { addAnnotation, type Annotation, blur, focusAnnotation, removeAnnotation, updateAnnotation } from './slice'; import { selectAllAnnotations } from './selectors' import { getNewItemID } from '@/service/function'; -import { AnnotationType, useCurrentPhase, useCurrentUser } from '@/api'; +import { AnnotationType } from '@/api'; import { selectDefaultConfidence } from '@/features/Annotator/Confidence'; import { useAlert } from '@/components/ui'; -import { useParams } from '@tanstack/react-router'; +import { useLoaderData, useParams } from '@tanstack/react-router'; type AnnotationEqualsType = Pick @@ -68,17 +68,16 @@ export const useGetAnnotation = () => { export const useAddAnnotation = () => { const getNewID = useGetNewAnnotationID() - const { user } = useCurrentUser(); - const { phase } = useCurrentPhase() + const { user } = useLoaderData({ from: '/_authenticated' }) + const { phase } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/phase/$phaseType' }) const dispatch = useAppDispatch(); return useCallback((annotation: Omit) => { - if (!phase) return; const addedAnnotation = dispatch(addAnnotation({ ...annotation, annotationPhase: phase.id, id: getNewID(), - annotator: user?.id, + annotator: user.id, })).payload as Annotation dispatch(focusAnnotation(addedAnnotation)) }, [ dispatch, getNewID, user, phase ]) @@ -139,8 +138,8 @@ export const useInvalidateAnnotation = () => { export const useUpdateAnnotation = () => { const { phaseType } = useParams({ strict: false }); - const { user } = useCurrentUser(); - const { phase } = useCurrentPhase() + const { user } = useLoaderData({ from: '/_authenticated' }) + const { phase } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/phase/$phaseType' }) const allAnnotations = useAppSelector(selectAllAnnotations); const defaultConfidence = useAppSelector(selectDefaultConfidence); const getNewID = useGetNewAnnotationID() @@ -153,7 +152,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 +160,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 +185,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 +208,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..6cb26b80e 100644 --- a/frontend/src/features/Annotator/Annotation/slice.ts +++ b/frontend/src/features/Annotator/Annotation/slice.ts @@ -5,13 +5,7 @@ import { AnnotationInput, AnnotationType, 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'; export type Comment = Omit & { id: number } @@ -34,18 +28,12 @@ type AnnotationState = { allAnnotations: Annotation[]; id?: number; tempAnnotation?: TempAnnotation; - - _campaignID?: string - _userID?: string } const initialState: AnnotationState = { allAnnotations: [], id: undefined, tempAnnotation: undefined, - - _campaignID: undefined, - _userID: undefined, } export const AnnotatorAnnotationSlice = createSlice({ @@ -80,29 +68,14 @@ export const AnnotatorAnnotationSlice = createSlice({ clearTempAnnotation: (state) => { state.tempAnnotation = undefined }, - }, - 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 } } - }) => { - if (state._campaignID !== action.meta.arg.originalArgs.campaignID) { - state._campaignID = action.meta.arg.originalArgs.campaignID - state.id = initialState.id - } - const annotations = [ - ...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 defaultAnnotation = [ ...state.allAnnotations ].reverse().pop(); - state.id = defaultAnnotation?.id - }) + + initCampaign: (state) => { + state.id = initialState.id + }, + initSpectrogram: (state, action: {payload: {all: Annotation[], default?: Annotation}}) => { + state.allAnnotations = action.payload.all + state.id = action.payload.default?.id + }, }, selectors: { selectAllAnnotations: state => state.allAnnotations, diff --git a/frontend/src/features/Annotator/Annotation/temp.hooks.ts b/frontend/src/features/Annotator/Annotation/temp.hooks.ts index e543d83ff..09be062f4 100644 --- a/frontend/src/features/Annotator/Annotation/temp.hooks.ts +++ b/frontend/src/features/Annotator/Annotation/temp.hooks.ts @@ -3,7 +3,7 @@ import { MouseEvent, PointerEvent, useCallback } from 'react'; import { clearTempAnnotation, setTempAnnotation } from './slice'; import { selectTempAnnotation } from './selectors'; import { useFrequencyScale, useTimeScale } from '@/features/Annotator/Axis'; -import { AnnotationType, useCurrentCampaign } from '@/api'; +import { AnnotationType } from '@/api'; import { MOUSE_DOWN_EVENT, MOUSE_MOVE_EVENT, MOUSE_UP_EVENT, useEvent } from '@/features/UX/Events'; import { useGetFreqTime, useIsHoverCanvas } from '@/features/Annotator/Pointer'; import { formatTime } from '@/service/function'; @@ -11,133 +11,134 @@ import { selectFocusLabel } from '@/features/Annotator/Label'; import { selectDefaultConfidence, selectFocusConfidence } from '@/features/Annotator/Confidence'; import { useToast } from '@/components/ui'; import { useAddAnnotation } from '@/features/Annotator/Annotation'; -import { selectIsDrawingEnabled } from '@/features/Annotator/UX'; import { usePointer } from '@/features/Annotator/Pointer/context'; +import { useLoaderData } from '@tanstack/react-router'; +import { useIsDrawingEnabled } from '@/features/Annotator/UX/hooks'; export const useDrawTempAnnotation = () => { - const tempAnnotation = useAppSelector(selectTempAnnotation) - const timeScale = useTimeScale() - const frequencyScale = useFrequencyScale() + const tempAnnotation = useAppSelector(selectTempAnnotation) + const timeScale = useTimeScale() + const frequencyScale = useFrequencyScale() - return useCallback((context: CanvasRenderingContext2D) => { - if (!tempAnnotation) return; - context.strokeStyle = 'blue'; - context.strokeRect( - timeScale.valueToPosition(Math.min(tempAnnotation.startTime!, tempAnnotation.endTime!)), - frequencyScale.valueToPosition(Math.max(tempAnnotation.startFrequency!, tempAnnotation.endFrequency!)), - Math.floor(timeScale.valuesToPositionRange(tempAnnotation.startTime!, tempAnnotation.endTime!)), - frequencyScale.valuesToPositionRange(tempAnnotation.startFrequency!, tempAnnotation.endFrequency!), - ); - }, [ tempAnnotation, timeScale, frequencyScale ]) + return useCallback((context: CanvasRenderingContext2D) => { + if (!tempAnnotation) return; + context.strokeStyle = 'blue'; + context.strokeRect( + timeScale.valueToPosition(Math.min(tempAnnotation.startTime!, tempAnnotation.endTime!)), + frequencyScale.valueToPosition(Math.max(tempAnnotation.startFrequency!, tempAnnotation.endFrequency!)), + Math.floor(timeScale.valuesToPositionRange(tempAnnotation.startTime!, tempAnnotation.endTime!)), + frequencyScale.valuesToPositionRange(tempAnnotation.startFrequency!, tempAnnotation.endFrequency!), + ); + }, [ tempAnnotation, timeScale, frequencyScale ]) } export const useTempAnnotationsEvents = () => { - const tempAnnotation = useAppSelector(selectTempAnnotation) - const isDrawingEnabled = useAppSelector(selectIsDrawingEnabled); - const timeScale = useTimeScale() - const frequencyScale = useFrequencyScale() - const focusedLabel = useAppSelector(selectFocusLabel) - const defaultConfidence = useAppSelector(selectDefaultConfidence); - const focusedConfidence = useAppSelector(selectFocusConfidence) - const { campaign } = useCurrentCampaign() - const pointer = usePointer() + const tempAnnotation = useAppSelector(selectTempAnnotation) + const isDrawingEnabled = useIsDrawingEnabled(); + const timeScale = useTimeScale() + const frequencyScale = useFrequencyScale() + const focusedLabel = useAppSelector(selectFocusLabel) + const defaultConfidence = useAppSelector(selectDefaultConfidence); + const focusedConfidence = useAppSelector(selectFocusConfidence) + const { campaign } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID' }) + const pointer = usePointer() - const addAnnotation = useAddAnnotation() - const getFreqTime = useGetFreqTime() - const isHoverCanvas = useIsHoverCanvas() - const dispatch = useAppDispatch(); - const toast = useToast() + const addAnnotation = useAddAnnotation() + const getFreqTime = useGetFreqTime() + const isHoverCanvas = useIsHoverCanvas() + const dispatch = useAppDispatch(); + const toast = useToast() - const onStartTempAnnotation = useCallback((e: MouseEvent) => { - if (!isDrawingEnabled) return; - if (!isHoverCanvas(e)) return; - const data = getFreqTime(e); - if (!data) return; + const onStartTempAnnotation = useCallback((e: MouseEvent) => { + if (!isDrawingEnabled) return; + if (!isHoverCanvas(e)) return; + const data = getFreqTime(e); + if (!data) return; - dispatch(setTempAnnotation({ - type: AnnotationType.Box, - startTime: data.time, - endTime: data.time, - startFrequency: data.frequency, - endFrequency: data.frequency, - })) - }, [ isHoverCanvas, getFreqTime, isDrawingEnabled, dispatch ]) - useEvent(MOUSE_DOWN_EVENT, onStartTempAnnotation); - - const onUpdateTempAnnotation = useCallback((e: PointerEvent) => { - const isHover = isHoverCanvas(e) - const data = getFreqTime(e); - if (data) { - if (isHover) pointer.setPosition(data) - if (tempAnnotation) { dispatch(setTempAnnotation({ - ...tempAnnotation, - endTime: data.time, - endFrequency: data.frequency, + type: AnnotationType.Box, + startTime: data.time, + endTime: data.time, + startFrequency: data.frequency, + endFrequency: data.frequency, })) - } - } - if (!isHover || !data) pointer.clearPosition() - }, [ isHoverCanvas, getFreqTime, dispatch, tempAnnotation, pointer ]) - useEvent(MOUSE_MOVE_EVENT, onUpdateTempAnnotation); + }, [ isHoverCanvas, getFreqTime, isDrawingEnabled, dispatch ]) + useEvent(MOUSE_DOWN_EVENT, onStartTempAnnotation); + + const onUpdateTempAnnotation = useCallback((e: PointerEvent) => { + const isHover = isHoverCanvas(e) + const data = getFreqTime(e); + if (data) { + if (isHover) pointer.setPosition(data) + if (tempAnnotation) { + dispatch(setTempAnnotation({ + ...tempAnnotation, + endTime: data.time, + endFrequency: data.frequency, + })) + } + } + if (!isHover || !data) pointer.clearPosition() + }, [ isHoverCanvas, getFreqTime, dispatch, tempAnnotation, pointer ]) + useEvent(MOUSE_MOVE_EVENT, onUpdateTempAnnotation); - const onEndNewAnnotation = useCallback((e: PointerEvent) => { - if (tempAnnotation && focusedLabel) { - const annotation = { ...tempAnnotation } - const data = getFreqTime(e); - if (data) { - annotation.endTime = data.time; - annotation.endFrequency = data.frequency; - } - if (annotation.type !== AnnotationType.Box) return; - const start_time = Math.min(annotation.startTime!, annotation.endTime!); - const end_time = Math.max(annotation.startTime!, annotation.endTime!); - const start_frequency = Math.min(annotation.startFrequency!, annotation.endFrequency!); - const end_frequency = Math.max(annotation.startFrequency!, annotation.endFrequency!); - annotation.startTime = start_time; - annotation.endTime = end_time; - annotation.startFrequency = start_frequency; - annotation.endFrequency = end_frequency; + const onEndNewAnnotation = useCallback((e: PointerEvent) => { + if (tempAnnotation && focusedLabel) { + const annotation = { ...tempAnnotation } + const data = getFreqTime(e); + if (data) { + annotation.endTime = data.time; + annotation.endFrequency = data.frequency; + } + if (annotation.type !== AnnotationType.Box) return; + const start_time = Math.min(annotation.startTime!, annotation.endTime!); + const end_time = Math.max(annotation.startTime!, annotation.endTime!); + const start_frequency = Math.min(annotation.startFrequency!, annotation.endFrequency!); + const end_frequency = Math.max(annotation.startFrequency!, annotation.endFrequency!); + annotation.startTime = start_time; + annotation.endTime = end_time; + annotation.startFrequency = start_frequency; + annotation.endFrequency = end_frequency; - if (!frequencyScale.isRangeContinuouslyOnScale(start_frequency, end_frequency)) { - toast.raiseError({ - message: `Be careful, your annotation overlaps a void in the frequency scale. + if (!frequencyScale.isRangeContinuouslyOnScale(start_frequency, end_frequency)) { + toast.raiseError({ + message: `Be careful, your annotation overlaps a void in the frequency scale. Are you sure your annotation goes from ${ start_frequency.toFixed(0) }Hz to ${ end_frequency.toFixed(0) }Hz?`, - }) - } - if (!timeScale.isRangeContinuouslyOnScale(start_time, end_time)) { - toast.raiseError({ - message: `Be careful, your annotation overlaps a void in the time scale. + }) + } + if (!timeScale.isRangeContinuouslyOnScale(start_time, end_time)) { + toast.raiseError({ + message: `Be careful, your annotation overlaps a void in the time scale. Are you sure your annotation goes from ${ formatTime(start_time) } to ${ formatTime(end_time) }?`, - }) - } - const width = timeScale.valuesToPositionRange(annotation.startTime, annotation.endTime); - const height = frequencyScale.valuesToPositionRange(annotation.startFrequency, annotation.endFrequency); - if (width > 2 && height > 2) { - addAnnotation({ - type: AnnotationType.Box, - startTime: annotation.startTime, - startFrequency: annotation.startFrequency, - endTime: annotation.endTime, - endFrequency: annotation.endFrequency, - label: focusedLabel, - confidence: defaultConfidence ?? focusedConfidence ?? undefined, - }) - } else if (campaign?.allowPointAnnotation) { - addAnnotation({ - type: AnnotationType.Point, - startTime: annotation.startTime, - startFrequency: annotation.endFrequency, - label: focusedLabel, - confidence: defaultConfidence ?? focusedConfidence ?? undefined, - }) - } - } - dispatch(clearTempAnnotation()) - if (!isHoverCanvas(e)) pointer.clearPosition() - }, [ dispatch, pointer, addAnnotation, getFreqTime, isHoverCanvas, toast, timeScale, frequencyScale, campaign, focusedLabel, defaultConfidence, focusedConfidence, tempAnnotation ]) - useEvent(MOUSE_UP_EVENT, onEndNewAnnotation); + }) + } + const width = timeScale.valuesToPositionRange(annotation.startTime, annotation.endTime); + const height = frequencyScale.valuesToPositionRange(annotation.startFrequency, annotation.endFrequency); + if (width > 2 && height > 2) { + addAnnotation({ + type: AnnotationType.Box, + startTime: annotation.startTime, + startFrequency: annotation.startFrequency, + endTime: annotation.endTime, + endFrequency: annotation.endFrequency, + label: focusedLabel, + confidence: defaultConfidence ?? focusedConfidence ?? undefined, + }) + } else if (campaign.allowPointAnnotation) { + addAnnotation({ + type: AnnotationType.Point, + startTime: annotation.startTime, + startFrequency: annotation.endFrequency, + label: focusedLabel, + confidence: defaultConfidence ?? focusedConfidence ?? undefined, + }) + } + } + dispatch(clearTempAnnotation()) + if (!isHoverCanvas(e)) pointer.clearPosition() + }, [ dispatch, pointer, addAnnotation, getFreqTime, isHoverCanvas, toast, timeScale, frequencyScale, campaign, focusedLabel, defaultConfidence, focusedConfidence, tempAnnotation ]) + useEvent(MOUSE_UP_EVENT, onEndNewAnnotation); - return { onStartTempAnnotation } + return { onStartTempAnnotation } } diff --git a/frontend/src/features/Annotator/Axis/Axis.tsx b/frontend/src/features/Annotator/Axis/Axis.tsx index 6b86961d9..c77735bf1 100644 --- a/frontend/src/features/Annotator/Axis/Axis.tsx +++ b/frontend/src/features/Annotator/Axis/Axis.tsx @@ -10,10 +10,10 @@ import { import { useAxis } from '@/components/ui'; import { formatTime, frequencyToString } from '@/service/function'; import { useFrequencyScale, useTimeScale } from './hooks' -import { useAnnotationTask } from '@/api'; +import { useLoaderData } from '@tanstack/react-router'; export const TimeAxis: React.FC = () => { - const { spectrogram } = useAnnotationTask() + const { spectrogram } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/phase/$phaseType/spectrogram/$spectrogramID' }) const timeScale = useTimeScale() const width = useWindowWidth() const { xAxisCanvasRef } = useAnnotatorCanvasContext() diff --git a/frontend/src/features/Annotator/Axis/hooks.ts b/frontend/src/features/Annotator/Axis/hooks.ts index 9b2a04fe3..11f4e3253 100644 --- a/frontend/src/features/Annotator/Axis/hooks.ts +++ b/frontend/src/features/Annotator/Axis/hooks.ts @@ -1,45 +1,44 @@ import { useMemo } from 'react'; import { LinearScaleService, MultiScaleService } from '@/components/ui'; -import { useAnnotationTask } from '@/api'; -import { selectAnalysis } from '@/features/Annotator/Analysis'; import { useWindowHeight, useWindowWidth } from '@/features/Annotator/Canvas'; -import { useAppSelector } from '@/features/App'; +import { useAnnotatorAnalysis } from '@/features/Annotator/Analysis/hooks'; +import { useLoaderData } from '@tanstack/react-router'; export const useTimeScale = () => { - const { spectrogram } = useAnnotationTask() - const width = useWindowWidth() + const { spectrogram } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/phase/$phaseType/spectrogram/$spectrogramID' }) + const width = useWindowWidth() - return useMemo(() => new LinearScaleService( - width, - { - ratio: 1, - minValue: 0, - maxValue: spectrogram?.duration ?? 0, - }, - ), [ spectrogram, width ]) + return useMemo(() => new LinearScaleService( + width, + { + ratio: 1, + minValue: 0, + maxValue: spectrogram?.duration ?? 0, + }, + ), [ spectrogram, width ]) } export const useFrequencyScale = () => { - const analysis = useAppSelector(selectAnalysis) - const height = useWindowHeight() + const analysis = useAnnotatorAnalysis() + const height = useWindowHeight() - return useMemo(() => { - const options = { - pixelOffset: 0, - disableValueFloats: true, - revert: true, - } - if (analysis?.frequencyScaleParts && analysis?.frequencyScaleParts.length) { - return new MultiScaleService( - height, - analysis.frequencyScaleParts?.filter(s => s !== null).map(s => s!) ?? [], - options, - ) - } - return new LinearScaleService(height, { - maxValue: (analysis?.fft.samplingFrequency ?? 0) / 2, - minValue: 0, - ratio: 1, - }, options) - }, [ analysis, height ]); + return useMemo(() => { + const options = { + pixelOffset: 0, + disableValueFloats: true, + revert: true, + } + if (analysis?.frequencyScaleParts && analysis?.frequencyScaleParts.length) { + return new MultiScaleService( + height, + analysis.frequencyScaleParts?.filter(s => s !== null).map(s => s!) ?? [], + options, + ) + } + return new LinearScaleService(height, { + maxValue: (analysis?.fft.samplingFrequency ?? 0) / 2, + minValue: 0, + ratio: 1, + }, options) + }, [ analysis, height ]); } diff --git a/frontend/src/features/Annotator/Canvas/Window.tsx b/frontend/src/features/Annotator/Canvas/Window.tsx index fdc700e2d..c70552964 100644 --- a/frontend/src/features/Annotator/Canvas/Window.tsx +++ b/frontend/src/features/Annotator/Canvas/Window.tsx @@ -11,23 +11,25 @@ import { import { useWindowContainerWidth, useWindowHeight, useWindowWidth, Y_AXIS_WIDTH } from './window.hooks'; import { useGetCoords, useGetFreqTime, useIsHoverCanvas, usePointer } from '@/features/Annotator/Pointer'; import { selectZoom, selectZoomOrigin, useZoomIn, useZoomOut } from '@/features/Annotator/Zoom'; -import { selectCanDraw } from '@/features/Annotator/UX'; import { useAudio } from '@/features/Audio'; import { useAnnotatorCanvasContext } from '@/features/Annotator/Canvas/context'; import { useAppDispatch, useAppSelector } from '@/features/App'; import { setAllFileAsSeen } from '@/features/Annotator/UX/slice'; import { useDrawCanvas } from '@/features/Annotator/Canvas/hooks'; -import { AnnotationType, useAnnotationTask } from '@/api'; +import { AnnotationType } from '@/api'; import { selectBrightness, selectColormap, selectContrast, selectIsColormapReversed, } from '@/features/Annotator/VisualConfiguration'; -import { selectAnalysis } from '@/features/Annotator/Analysis'; import { AcousticFeatures } from '@/features/Annotator/AcousticFeatures'; +import { useAnnotatorAnalysis } from '@/features/Annotator/Analysis/hooks'; +import { useLoaderData } from '@tanstack/react-router'; +import { useCanDraw } from '@/features/Annotator/UX/hooks'; export const AnnotatorCanvasWindow: React.FC = () => { + const { spectrogram } = useLoaderData({ from: '/_authenticated/annotation-campaign/$campaignID/phase/$phaseType/spectrogram/$spectrogramID' }) const width = useWindowWidth() const height = useWindowHeight() const containerWidth = useWindowContainerWidth() @@ -37,7 +39,7 @@ export const AnnotatorCanvasWindow: React.FC = () => { const getCoords = useGetCoords() const zoomIn = useZoomIn() const zoomOut = useZoomOut() - const canDraw = useAppSelector(selectCanDraw) + const canDraw = useCanDraw() const { seek } = useAudio() const allAnnotations = useAppSelector(selectAllAnnotations) const draw = useDrawCanvas() @@ -55,7 +57,7 @@ export const AnnotatorCanvasWindow: React.FC = () => { const left = div.scrollWidth - div.scrollLeft - div.clientWidth; if (left <= 0) dispatch(setAllFileAsSeen()) setScrollLeft(div.scrollLeft) - }, [dispatch]) + }, [ dispatch ]) const onWheel = useCallback((event: WheelEvent) => { // Disable zoom if the user wants horizontal scroll @@ -75,8 +77,7 @@ export const AnnotatorCanvasWindow: React.FC = () => { // Global updates const tempAnnotation = useAppSelector(selectTempAnnotation) - const analysis = useAppSelector(selectAnalysis) - const { spectrogram } = useAnnotationTask() + const analysis = useAnnotatorAnalysis() const brightness = useAppSelector(selectBrightness); const contrast = useAppSelector(selectContrast); const colormap = useAppSelector(selectColormap); @@ -174,10 +175,11 @@ export const AnnotatorCanvasWindow: React.FC = () => { - { allAnnotations.filter(a => a.type !== AnnotationType.Weak).map(annotation => ) } + { allAnnotations.filter(a => a.type !== AnnotationType.Weak).map(annotation => ) }
- +
} 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 -