diff --git a/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx b/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx index e3b66c10643..51d2bdc4f8c 100644 --- a/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx +++ b/application/ui/src/features/dataset/media-preview/secondary-toolbar/secondary-toolbar.component.tsx @@ -22,6 +22,7 @@ import { AnnotatorModes } from './annotator-modes/annotator-modes-toggle.compone import { PredictionInferenceDevices } from './annotator-modes/prediction-inference-devices.component'; import { PredictionModelSelector } from './annotator-modes/prediction-model-selector.component'; import { PredictionButtons } from './annotator-modes/predictions-buttons.component'; +import { useIsSubmitDisabled } from './use-is-submit-disabled.hook'; import { getNextItem } from './util'; import classes from './secondary-toolbar.module.scss'; @@ -95,7 +96,7 @@ export const SecondaryToolbar = ({ const { selectableModels } = usePredictionSetup(); const isPlaying = videoPlayerContext?.videoControls?.isPlaying ?? false; - const { canSubmit, isSaving, submitAnnotations, submitPredictions, initialAnnotations, initialPredictions } = + const { isSaving, submitAnnotations, submitPredictions, initialAnnotations, initialPredictions } = useAnnotationActions(); const handleSubmit = async () => { @@ -121,8 +122,11 @@ export const SecondaryToolbar = ({ const isPredictionMode = mode === 'prediction'; const isAnnotationMode = mode === 'annotation'; - // If annotations are not changed but subset has changed we want to allow user to submit - const isSubmitDisabled = (!canSubmit && !hasSubsetChanged) || isSaving || isLoadingPredictions; + const isSubmitDisabled = useIsSubmitDisabled({ + mode, + hasSubsetChanged, + isLoadingPredictions, + }); useHotkeys( HOTKEYS.submit, diff --git a/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.test.tsx b/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.test.tsx new file mode 100644 index 00000000000..e5dfca322ba --- /dev/null +++ b/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.test.tsx @@ -0,0 +1,119 @@ +// Copyright (C) 2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { type ReactNode } from 'react'; + +import { waitFor } from '@testing-library/react'; +import { getMockedShape } from 'mocks/mock-annotation'; +import { getMockedLabel } from 'mocks/mock-labels'; +import { getMockedMediaImage } from 'mocks/mock-media'; +import { getMockedProject } from 'mocks/mock-project'; +import { HttpResponse } from 'msw'; + +import { http } from '../../../../api/utils'; +import type { AnnotationDTO, Label } from '../../../../constants/shared-types'; +import { server } from '../../../../msw-node-setup'; +import { AnnotationActionsProvider } from '../../../../shared/annotator/annotation-actions-provider.component'; +import type { AnnotatorMode } from '../../../../shared/annotator/annotator-mode'; +import { renderHook } from '../../../../test-utils/render'; +import { useIsSubmitDisabled } from './use-is-submit-disabled.hook'; + +type RenderIsSubmitDisabledParams = { + mode?: AnnotatorMode; + hasSubsetChanged?: boolean; + isLoadingPredictions?: boolean; + initialAnnotationsDTO?: AnnotationDTO[]; + initialPredictionsDTO?: AnnotationDTO[]; + labels?: Label[]; +}; + +const renderIsSubmitDisabled = ({ + mode = 'annotation', + hasSubsetChanged = false, + isLoadingPredictions = false, + initialAnnotationsDTO = [], + initialPredictionsDTO = [], + labels = [], +}: RenderIsSubmitDisabledParams) => { + server.use( + http.get('/api/projects/{project_id}', () => + HttpResponse.json(getMockedProject({ task: { task_type: 'detection', exclusive_labels: false, labels } })) + ) + ); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return renderHook(() => useIsSubmitDisabled({ mode, hasSubsetChanged, isLoadingPredictions }), { wrapper }); +}; + +describe('useIsSubmitDisabled', () => { + const label1 = getMockedLabel({ id: 'label-1', name: 'Cat', color: '#FF0000' }); + + it('annotation mode: disabled when annotations and subset are both unchanged', async () => { + const annotationsDTO: AnnotationDTO[] = [ + { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null }, + ]; + + const { result } = renderIsSubmitDisabled({ + mode: 'annotation', + labels: [label1], + initialAnnotationsDTO: annotationsDTO, + hasSubsetChanged: false, + }); + + await waitFor(() => expect(result.current).toBe(true)); + }); + + it('annotation mode: enabled when annotations are unchanged but subset changed', async () => { + const annotationsDTO: AnnotationDTO[] = [ + { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null }, + ]; + + const { result } = renderIsSubmitDisabled({ + mode: 'annotation', + labels: [label1], + initialAnnotationsDTO: annotationsDTO, + hasSubsetChanged: true, + }); + + await waitFor(() => expect(result.current).toBe(false)); + }); + + it('prediction mode: enabled when a prediction is present', async () => { + const predictionsDTO: AnnotationDTO[] = [ + { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.9] }, + ]; + + const { result } = renderIsSubmitDisabled({ + mode: 'prediction', + labels: [label1], + initialPredictionsDTO: predictionsDTO, + }); + + await waitFor(() => expect(result.current).toBe(false)); + }); + + it('prediction mode: disabled while loading predictions, even with a prediction present', async () => { + const predictionsDTO: AnnotationDTO[] = [ + { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.9] }, + ]; + + const { result } = renderIsSubmitDisabled({ + mode: 'prediction', + labels: [label1], + initialPredictionsDTO: predictionsDTO, + isLoadingPredictions: true, + }); + + await waitFor(() => expect(result.current).toBe(true)); + }); +}); diff --git a/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.ts b/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.ts new file mode 100644 index 00000000000..37beed2f3a9 --- /dev/null +++ b/application/ui/src/features/dataset/media-preview/secondary-toolbar/use-is-submit-disabled.hook.ts @@ -0,0 +1,24 @@ +// Copyright (C) 2025-2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { useAnnotationActions } from '../../../../shared/annotator/annotation-actions-provider.component'; +import type { AnnotatorMode } from '../../../../shared/annotator/annotator-mode'; + +type UseIsSubmitDisabledParams = { + mode: AnnotatorMode; + hasSubsetChanged: boolean; + isLoadingPredictions: boolean; +}; + +export const useIsSubmitDisabled = ({ + mode, + hasSubsetChanged, + isLoadingPredictions, +}: UseIsSubmitDisabledParams): boolean => { + const { canSubmit, hasInvalidAnnotation, isSaving } = useAnnotationActions(); + + const isContentSubmittable = + mode === 'prediction' ? canSubmit : !hasInvalidAnnotation && (canSubmit || hasSubsetChanged); + + return !isContentSubmittable || isSaving || isLoadingPredictions; +}; diff --git a/application/ui/src/shared/annotator/annotation-actions-provider.test.tsx b/application/ui/src/shared/annotator/annotation-actions-provider.test.tsx index 46f5c6330ac..e1d5d76de87 100644 --- a/application/ui/src/shared/annotator/annotation-actions-provider.test.tsx +++ b/application/ui/src/shared/annotator/annotation-actions-provider.test.tsx @@ -196,3 +196,54 @@ describe('Label normalization', () => { }); }); }); + +describe('canSubmit on initial load', () => { + it('is false when initial annotations are unchanged, including a null confidences field from the server', async () => { + const label1 = getMockedLabel({ id: 'label-1', name: 'Cat', color: '#FF0000' }); + + // This is the exact shape the backend returns for a human annotation with no confidence + // scores: `confidences` is present and `null` (see DatasetItemAnnotation), never omitted. + const annotationsDTO: AnnotationDTO[] = [ + { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null }, + ]; + + const { result } = renderAnnotationActions({ + mode: 'annotation', + labels: [label1], + initialAnnotationsDTO: annotationsDTO, + }); + + await waitFor(() => expect(result.current).not.toBeNull()); + + await waitFor(() => { + expect(result.current.annotations).toHaveLength(1); + expect(result.current.canSubmit).toBe(false); + }); + }); + + it('is true once an unchanged annotation set is actually edited', async () => { + const label1 = getMockedLabel({ id: 'label-1', name: 'Cat', color: '#FF0000' }); + const label2 = getMockedLabel({ id: 'label-2', name: 'Dog', color: '#00FF00' }); + + const annotationsDTO: AnnotationDTO[] = [ + { labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null }, + ]; + + const { result } = renderAnnotationActions({ + mode: 'annotation', + labels: [label1, label2], + initialAnnotationsDTO: annotationsDTO, + }); + + await waitFor(() => expect(result.current.canSubmit).toBe(false)); + + act(() => { + result.current.addAnnotations([getMockedShape({ type: 'rectangle' })], [{ id: label2.id }]); + }); + + await waitFor(() => { + expect(result.current.annotations).toHaveLength(2); + expect(result.current.canSubmit).toBe(true); + }); + }); +}); diff --git a/application/ui/src/shared/annotator/annotation-mappers.test.ts b/application/ui/src/shared/annotator/annotation-mappers.test.ts new file mode 100644 index 00000000000..651bb2faa39 --- /dev/null +++ b/application/ui/src/shared/annotator/annotation-mappers.test.ts @@ -0,0 +1,111 @@ +// Copyright (C) 2025-2026 Intel Corporation +// SPDX-License-Identifier: Apache-2.0 + +import { getMockedShape } from 'mocks/mock-annotation'; +import { getMockedAnnotationLabelRef } from 'mocks/mock-labels'; + +import type { AnnotationDTO } from '../../constants/shared-types'; +import { mapLocalAnnotationsToServer, mapServerAnnotationsToLocal } from './annotation-mappers'; + +describe('mapServerAnnotationsToLocal', () => { + it('maps a label with no confidence score to a ref without a probability', () => { + const dto: AnnotationDTO[] = [ + { labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: null }, + ]; + + const [annotation] = mapServerAnnotationsToLocal(dto); + + expect(annotation.labels).toEqual([{ id: 'label-1' }]); + expect(annotation.labels[0]).not.toHaveProperty('probability'); + }); + + it('maps a label with a confidence score to a ref with a matching probability', () => { + const dto: AnnotationDTO[] = [ + { labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.87] }, + ]; + + const [annotation] = mapServerAnnotationsToLocal(dto); + + expect(annotation.labels).toEqual([{ id: 'label-1', probability: 0.87 }]); + }); +}); + +describe('mapLocalAnnotationsToServer', () => { + it('maps confidences to null when no label has a probability', () => { + const annotation = { + id: 'annotation-1', + shape: getMockedShape({ type: 'rectangle' }), + labels: [getMockedAnnotationLabelRef({ id: 'label-1' })], + }; + + const [result] = mapLocalAnnotationsToServer([annotation]); + + expect(result).toHaveProperty('confidences', null); + }); + + it('maps confidences to an array of probabilities, in label order, when present', () => { + const annotation = { + id: 'annotation-1', + shape: getMockedShape({ type: 'rectangle' }), + labels: [ + getMockedAnnotationLabelRef({ id: 'label-1', probability: 0.6 }), + getMockedAnnotationLabelRef({ id: 'label-2', probability: 0.4 }), + ], + }; + + const [result] = mapLocalAnnotationsToServer([annotation]); + + expect(result.confidences).toEqual([0.6, 0.4]); + }); + + it('strips labels that are not in the provided valid id set', () => { + const annotation = { + id: 'annotation-1', + shape: getMockedShape({ type: 'rectangle' }), + labels: [ + getMockedAnnotationLabelRef({ id: 'label-1' }), + getMockedAnnotationLabelRef({ id: 'deleted-label' }), + ], + }; + + const [result] = mapLocalAnnotationsToServer([annotation], new Set(['label-1'])); + + expect(result.labels).toEqual([{ id: 'label-1' }]); + }); + + it('keeps all labels when no valid id set is provided', () => { + const annotation = { + id: 'annotation-1', + shape: getMockedShape({ type: 'rectangle' }), + labels: [getMockedAnnotationLabelRef({ id: 'label-1' })], + }; + + const [result] = mapLocalAnnotationsToServer([annotation]); + + expect(result.labels).toEqual([{ id: 'label-1' }]); + }); +}); + +describe('round trip: mapLocalAnnotationsToServer(mapServerAnnotationsToLocal(dto))', () => { + it('is deep-equal to a plain (non-prediction) server annotation, confidences included', () => { + // This is the exact shape the backend returns for a human annotation with no confidence + // scores: `confidences` is present and `null`, never omitted. + const dto: AnnotationDTO[] = [ + { labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: null }, + ]; + + const roundTripped = mapLocalAnnotationsToServer(mapServerAnnotationsToLocal(dto)); + + expect(roundTripped).toEqual(dto); + }); + + it('is deep-equal to a prediction-style server annotation, confidences included', () => { + const dto: AnnotationDTO[] = [ + { labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.95] }, + ]; + + const roundTripped = mapLocalAnnotationsToServer(mapServerAnnotationsToLocal(dto)); + + expect(roundTripped).toEqual(dto); + }); +}); diff --git a/application/ui/src/shared/annotator/annotation-mappers.ts b/application/ui/src/shared/annotator/annotation-mappers.ts index 9cb2d3ab655..40cf1596c72 100644 --- a/application/ui/src/shared/annotator/annotation-mappers.ts +++ b/application/ui/src/shared/annotator/annotation-mappers.ts @@ -37,14 +37,16 @@ export const mapLocalAnnotationsToServer = ( const hasProbabilities = filteredLabels.some((ref) => ref.probability !== undefined); + const confidences = hasProbabilities + ? filteredLabels + .filter((labelRef): labelRef is Required => labelRef.probability !== undefined) + .map((labelRef) => labelRef.probability) + : null; + return { labels: filteredLabels.map(({ id }) => ({ id })), shape: annotation.shape, - ...(hasProbabilities && { - confidences: filteredLabels - .filter((labelRef): labelRef is Required => labelRef.probability !== undefined) - .map((labelRef) => labelRef.probability), - }), + confidences, }; }); };