diff --git a/lightly_studio_view/src/lib/components/AnnotationDetails/AnnotationDetailsPanel/AnnotationDetailsPanel.svelte b/lightly_studio_view/src/lib/components/AnnotationDetails/AnnotationDetailsPanel/AnnotationDetailsPanel.svelte index 96502ee42a..ffef3a6e5c 100644 --- a/lightly_studio_view/src/lib/components/AnnotationDetails/AnnotationDetailsPanel/AnnotationDetailsPanel.svelte +++ b/lightly_studio_view/src/lib/components/AnnotationDetails/AnnotationDetailsPanel/AnnotationDetailsPanel.svelte @@ -32,7 +32,7 @@ const handleDeleteAnnotation = async () => { try { - await deleteAnnotation(annotation.sample_id); + await deleteAnnotation(annotation.sample_id, annotation.annotation_type); toast.success('Annotation deleted successfully'); diff --git a/lightly_studio_view/src/lib/components/Header/Header.svelte b/lightly_studio_view/src/lib/components/Header/Header.svelte index aee66a3622..7c2de166f0 100644 --- a/lightly_studio_view/src/lib/components/Header/Header.svelte +++ b/lightly_studio_view/src/lib/components/Header/Header.svelte @@ -15,6 +15,7 @@ import UserAvatar from '$lib/components/UserAvatar/UserAvatar.svelte'; import useAuth from '$lib/hooks/useAuth/useAuth'; import { hasMinimumRole } from '$lib/hooks/useAuth/hasMinimumRole'; + import { usePostHog } from '$lib/hooks'; let { collection }: { collection: CollectionView } = $props(); @@ -33,6 +34,31 @@ const { setIsEditingMode, isEditingMode, reversibleActions, executeReversibleAction } = page.data.globalStorage; + const { trackEvent } = usePostHog(); + + type TriggeredBy = 'click' | 'keyboard_shortcut'; + + const setEditMode = (active: boolean, triggeredBy: TriggeredBy) => { + if (active) { + trackEvent('edit_mode_started', { + collection_id: collection.collection_id, + triggered_by: triggeredBy + }); + } + setIsEditingMode(active); + }; + + const executeUndoAction = async (triggeredBy: TriggeredBy) => { + const latestAction = $reversibleActions[0]; + if (latestAction) { + trackEvent('edit_undo', { + collection_id: collection.collection_id, + triggered_by: triggeredBy + }); + await executeReversibleAction(latestAction.id); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { if (isInputElement(event.target)) { return; @@ -42,16 +68,9 @@ return; } if (event.key === get(settingsStore).key_toggle_edit_mode) { - setIsEditingMode(!$isEditingMode); + setEditMode(!$isEditingMode, 'keyboard_shortcut'); } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { - executeUndoAction(); - } - }; - - const executeUndoAction = async () => { - const latestAction = $reversibleActions[0]; - if (latestAction) { - await executeReversibleAction(latestAction.id); + void executeUndoAction('keyboard_shortcut'); } }; @@ -86,7 +105,7 @@ @@ -135,7 +139,7 @@ ? 'bg-primary/20 text-primary' : 'text-muted-foreground hover:bg-muted'} " - onclick={() => setBrushMode('eraser')} + onclick={() => activateBrushMode('eraser')} > diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsAnnotationSegment/SampleDetailsAnnotationSegment.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsAnnotationSegment/SampleDetailsAnnotationSegment.svelte index 41fe88e631..d504e244a7 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsAnnotationSegment/SampleDetailsAnnotationSegment.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsAnnotationSegment/SampleDetailsAnnotationSegment.svelte @@ -172,7 +172,7 @@ refetch }); - await deleteAnnotation(annotationId); + await deleteAnnotation(annotationId, annotation.annotation_type); toast.success('Annotation deleted successfully'); refetch(); if (annotationLabelContext.annotationId === annotationId) { @@ -230,7 +230,11 @@ }} canHighlight={annotationLabelContext.lastCreatedAnnotationId === annotation.sample_id} onClickSelectList={() => { - setAnnotationId(annotation.sample_id); + selectAnnotation({ + annotationId: annotation.sample_id, + annotations, + collectionId + }); }} /> {/snippet} diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsClassificationSegment/SampleDetailsClassificationSegment.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsClassificationSegment/SampleDetailsClassificationSegment.svelte index 94cc039398..bea5b6f50f 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsClassificationSegment/SampleDetailsClassificationSegment.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsClassificationSegment/SampleDetailsClassificationSegment.svelte @@ -107,7 +107,7 @@ refetch }); - await deleteAnnotation(annotationId); + await deleteAnnotation(annotationId, annotation.annotation_type); toast.success('Classification deleted successfully'); refetch(); } catch (error) { diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsPanel.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsPanel.svelte index 4261d74492..8e5bbd20ed 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsPanel.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsPanel.svelte @@ -175,7 +175,11 @@ isOnAnnotationDetailsView ) return; - selectAnnotation({ annotationId, annotations: sample.annotations ?? [], collectionId }); + selectAnnotation({ + annotationId, + annotations: sample.annotations ?? [], + collectionId + }); }; let annotationsToShow = $derived(sample?.annotations ? getAnnotations(sample.annotations) : []); diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsToolbar/SampleDetailsToolbar.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsToolbar/SampleDetailsToolbar.svelte index 7fdb9cd5da..3d0514e6cd 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsToolbar/SampleDetailsToolbar.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleDetailsToolbar/SampleDetailsToolbar.svelte @@ -32,11 +32,11 @@ onClickCursor(); } else if (key === $settingsStore.key_toolbar_bounding_box) { e.preventDefault(); - onClickBoundingBox(); + activateBoundingBox(); } else if (key === $settingsStore.key_toolbar_segmentation_mask) { if (!showSegmentationTool) return; e.preventDefault(); - onClickBrush(); + activateBrush(); } else if (key === $settingsStore.key_toolbar_drag) { e.preventDefault(); onClickDrag(); @@ -109,7 +109,7 @@ } }); - const onClickBoundingBox = () => { + const activateBoundingBox = () => { if (annotationLabelContext.isOnAnnotationDetailsView) return; setStatus('bounding-box'); @@ -118,6 +118,8 @@ setLastCreatedAnnotationId(null); }; + const onClickBoundingBox = () => activateBoundingBox(); + const onClickCursor = () => { setStatus('cursor'); }; @@ -126,7 +128,7 @@ setStatus('drag'); }; - const onClickBrush = () => { + const activateBrush = () => { if (!showSegmentationTool) return; setStatus('brush'); @@ -134,6 +136,8 @@ if (!annotationLabelContext.isOnAnnotationDetailsView) setAnnotationId(null); setLastCreatedAnnotationId(null); }; + + const onClickBrush = () => activateBrush();
diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleEraserRect/SampleEraserRect.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleEraserRect/SampleEraserRect.svelte index 79d0d4460a..391ee6462f 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleEraserRect/SampleEraserRect.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleEraserRect/SampleEraserRect.svelte @@ -187,7 +187,7 @@ refetch }); - await deleteAnnotation(annotation!.sample_id); + await deleteAnnotation(annotation!.sample_id, annotation!.annotation_type); toast.success('Annotation deleted successfully'); if (annotationLabelContext.isOnAnnotationDetailsView) return gotoNextAnnotation(); diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleObjectDetectionRect/SampleObjectDetectionRect.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleObjectDetectionRect/SampleObjectDetectionRect.svelte index f58d2b03b7..1ad3f7fefe 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleObjectDetectionRect/SampleObjectDetectionRect.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleObjectDetectionRect/SampleObjectDetectionRect.svelte @@ -21,6 +21,7 @@ import SelectClassDialog from '$lib/components/SelectClassDialog/SelectClassDialog.svelte'; import { getBoundingBox } from '$lib/components/SampleAnnotation/utils'; import type { PendingChange } from '../pendingChange'; + import { usePostHog } from '$lib/hooks'; type D3Event = D3DragEvent; @@ -52,6 +53,8 @@ let temporaryBbox = $state(null); let shouldDisableInteraction = $state(false); + let drawStartFired = false; + const { trackEvent } = usePostHog(); const labels = useAnnotationLabels(() => ({ collectionId })); const { @@ -84,6 +87,7 @@ const cancelDrag = () => { setIsDrawing(false); temporaryBbox = null; + drawStartFired = false; }; const datasetId = $derived(page.params.dataset_id!); @@ -105,6 +109,14 @@ // Remove focus from any selected annotation. setAnnotationId(null); setIsDrawing(true); + if (!drawStartFired) { + trackEvent('annotation_draw_started', { + collection_id: collectionId, + tool: 'bounding-box', + parent_sample_type: page.params.collection_type + }); + drawStartFired = true; + } // Get mouse position relative to the SVG element const svgRect = interactionRect!.getBoundingClientRect(); const clientX = event.sourceEvent.clientX; @@ -156,6 +168,7 @@ cancelDrag(); startPoint = null; + drawStartFired = false; }); rectSelection.call(dragBehavior); diff --git a/lightly_studio_view/src/lib/components/SampleDetails/SampleSegmentationMaskRect/SampleSegmentationMaskRect.svelte b/lightly_studio_view/src/lib/components/SampleDetails/SampleSegmentationMaskRect/SampleSegmentationMaskRect.svelte index 708ab8128f..bdef528a75 100644 --- a/lightly_studio_view/src/lib/components/SampleDetails/SampleSegmentationMaskRect/SampleSegmentationMaskRect.svelte +++ b/lightly_studio_view/src/lib/components/SampleDetails/SampleSegmentationMaskRect/SampleSegmentationMaskRect.svelte @@ -19,6 +19,7 @@ useDeleteAnnotation } from '$lib/hooks'; import { page } from '$app/state'; + import { usePostHog } from '$lib/hooks'; import type { PendingChange } from '../pendingChange'; import SampleAnnotationRect from '../SampleAnnotationRect/SampleAnnotationRect.svelte'; import SelectClassDialog from '$lib/components/SelectClassDialog/SelectClassDialog.svelte'; @@ -59,6 +60,9 @@ setAnnotationId } = useAnnotationLabelContext(); + const { trackEvent } = usePostHog(); + let drawStartFired = false; + const { deleteAnnotation } = useDeleteAnnotation({ collectionId }); const labels = useAnnotationLabels(() => ({ collectionId })); @@ -213,6 +217,7 @@ const handleStrokeComplete = (e: PointerEvent) => { releasePointerCapture(e); + drawStartFired = false; resetPreviewState({ clearDrawing: false }); const targetAnnotation = resolveSelectedAnnotation(); @@ -252,6 +257,7 @@ const handleStrokeCancel = (e: PointerEvent) => { releasePointerCapture(e); + drawStartFired = false; resetPreviewState(); }; @@ -347,6 +353,14 @@ return; } + if (!drawStartFired) { + trackEvent('annotation_draw_started', { + collection_id: collectionId, + tool: 'brush', + parent_sample_type: page.params.collection_type + }); + drawStartFired = true; + } setIsDrawing(true); lastBrushPoint = point; isPreviewVisible = false; diff --git a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte index b4608e3138..a438f879e8 100644 --- a/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte +++ b/lightly_studio_view/src/lib/components/VideoDetails/VideoDetails.svelte @@ -122,7 +122,7 @@ async function handleEventDelete(event: VideoEvent) { try { - await deleteAnnotation(event.id); + await deleteAnnotation(event.id, AnnotationType.CLASSIFICATION); onVideoUpdate(); toast.success('Event deleted'); } catch (error) { diff --git a/lightly_studio_view/src/lib/hooks/index.ts b/lightly_studio_view/src/lib/hooks/index.ts index abd2250cb5..c511f6e6b2 100644 --- a/lightly_studio_view/src/lib/hooks/index.ts +++ b/lightly_studio_view/src/lib/hooks/index.ts @@ -42,8 +42,8 @@ export { useColorPicker } from '$lib/hooks/useColorPicker/useColorPicker.svelte' export { useSubmitCombinationSelection } from '$lib/hooks/useSubmitCombinationSelection/useSubmitCombinationSelection'; export { useOperatorsDialog } from '$lib/hooks/useOperatorsDialog/useOperatorsDialog'; export { useDeleteAnnotation } from '$lib/hooks/useDeleteAnnotation/useDeleteAnnotation'; -export { useSettings } from '$lib/hooks/useSettings'; export { usePostHog } from '$lib/hooks/usePostHog'; +export { useSettings } from '$lib/hooks/useSettings'; export { useTrackSampleInspected } from '$lib/hooks/useTrackSampleInspected'; export { useAnnotationClassVisibility } from '$lib/hooks/useAnnotationClassVisibility/useAnnotationClassVisibility'; export { diff --git a/lightly_studio_view/src/lib/hooks/useAnnotationSelection/useAnnotationSelection.ts b/lightly_studio_view/src/lib/hooks/useAnnotationSelection/useAnnotationSelection.ts index ae40161d50..ee10de868d 100644 --- a/lightly_studio_view/src/lib/hooks/useAnnotationSelection/useAnnotationSelection.ts +++ b/lightly_studio_view/src/lib/hooks/useAnnotationSelection/useAnnotationSelection.ts @@ -43,6 +43,7 @@ export function useAnnotationSelection() { ); setLastCreatedAnnotationId(null); + setAnnotationId(context.annotationId === annotationId ? null : annotationId); } diff --git a/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts b/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts index dcfdc2b82a..d8f2d10b70 100644 --- a/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts +++ b/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.test.ts @@ -12,6 +12,15 @@ vi.mock('@tanstack/svelte-query', async (importOriginal) => { return { ...actual, createMutation: vi.fn(), useQueryClient: vi.fn() }; }); +const { trackEvent } = vi.hoisted(() => ({ trackEvent: vi.fn() })); +vi.mock('$lib/hooks/usePostHog', () => ({ + usePostHog: () => ({ trackEvent }) +})); + +vi.mock('$app/state', () => ({ + page: { params: { collection_type: 'images' } } +})); + describe('useCreateAnnotation', () => { const invalidateQueries = vi.fn(); @@ -25,7 +34,11 @@ describe('useCreateAnnotation', () => { it('invalidates the annotation counts and the source list after a successful create', async () => { vi.mocked(createMutation).mockReturnValue({ mutate: (_vars: unknown, opts: { onSuccess: (data: unknown) => void }) => { - opts.onSuccess({ sample_id: 'created-annotation' }); + opts.onSuccess({ + sample_id: 'created-annotation', + annotation_type: 'object_detection', + annotation_label: { annotation_label_name: 'car' } + }); } } as unknown as ReturnType); @@ -43,4 +56,30 @@ describe('useCreateAnnotation', () => { queryKey: readAnnotationCollectionsQueryKey({ path: { collection_id: 'col-1' } }) }); }); + + it('fires annotation_created with correct properties on success', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: (data: unknown) => void }) => { + opts.onSuccess({ + sample_id: 'created-annotation', + annotation_type: 'object_detection', + annotation_label: { annotation_label_name: 'car' } + }); + } + } as unknown as ReturnType); + + const { createAnnotation } = useCreateAnnotation({ collectionId: 'col-1' }); + await createAnnotation({ + parent_sample_id: 's1', + annotation_type: 'object_detection', + annotation_label_id: 'l1' + } as AnnotationCreateInput); + + expect(trackEvent).toHaveBeenCalledWith('annotation_created', { + collection_id: 'col-1', + annotation_type: 'object_detection', + parent_sample_type: 'images', + label_name: 'car' + }); + }); }); diff --git a/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.ts b/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.ts index e82e613fe2..d1d73855bb 100644 --- a/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.ts +++ b/lightly_studio_view/src/lib/hooks/useCreateAnnotation/useCreateAnnotation.ts @@ -8,10 +8,13 @@ import { } from '$lib/api/lightly_studio_local/@tanstack/svelte-query.gen'; import { createMutation, useQueryClient } from '@tanstack/svelte-query'; import { useImageAnnotationCountsQueryKey } from '$lib/hooks/useImageAnnotationCounts/useImageAnnotationCounts'; +import { usePostHog } from '$lib/hooks'; +import { page } from '$app/state'; export const useCreateAnnotation = ({ collectionId }: { collectionId: string }) => { const mutation = createMutation(() => createAnnotationMutation()); const client = useQueryClient(); + const { trackEvent } = usePostHog(); const refetch = () => { client.invalidateQueries({ @@ -36,6 +39,12 @@ export const useCreateAnnotation = ({ collectionId }: { collectionId: string }) { onSuccess: (data) => { refetch(); + trackEvent('annotation_created', { + collection_id: collectionId, + annotation_type: data.annotation_type, + parent_sample_type: page.params.collection_type, + label_name: data.annotation_label.annotation_label_name + }); resolve(data); }, onError: (error) => { diff --git a/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.test.ts b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.test.ts new file mode 100644 index 0000000000..8c9307576f --- /dev/null +++ b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMutation, useQueryClient } from '@tanstack/svelte-query'; +import { useDeleteAnnotation } from './useDeleteAnnotation'; + +vi.mock('@tanstack/svelte-query', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createMutation: vi.fn(), useQueryClient: vi.fn() }; +}); + +const trackEvent = vi.fn(); +vi.mock('$lib/hooks/usePostHog', () => ({ + usePostHog: () => ({ trackEvent }) +})); + +describe('useDeleteAnnotation', () => { + const invalidateQueries = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useQueryClient).mockReturnValue({ + invalidateQueries + } as unknown as ReturnType); + }); + + it('fires annotation_deleted with collection_id and annotation_type on success', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess(); + } + } as unknown as ReturnType); + + const { deleteAnnotation } = useDeleteAnnotation({ collectionId: 'col-1' }); + await deleteAnnotation('ann-1', 'classification'); + + expect(trackEvent).toHaveBeenCalledWith('annotation_deleted', { + collection_id: 'col-1', + annotation_type: 'classification' + }); + }); + + it('includes the provided annotation_type in the event', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess(); + } + } as unknown as ReturnType); + + const { deleteAnnotation } = useDeleteAnnotation({ collectionId: 'col-1' }); + await deleteAnnotation('ann-1', 'object_detection'); + + expect(trackEvent).toHaveBeenCalledWith('annotation_deleted', { + collection_id: 'col-1', + annotation_type: 'object_detection' + }); + }); +}); diff --git a/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts index 4c9fdd357c..f1fde2146d 100644 --- a/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts +++ b/lightly_studio_view/src/lib/hooks/useDeleteAnnotation/useDeleteAnnotation.ts @@ -1,11 +1,13 @@ import { deleteAnnotationMutation } from '$lib/api/lightly_studio_local/@tanstack/svelte-query.gen'; import { createMutation, useQueryClient } from '@tanstack/svelte-query'; import { useImageAnnotationCountsQueryKey } from '$lib/hooks/useImageAnnotationCounts/useImageAnnotationCounts'; +import { usePostHog } from '$lib/hooks'; export const useDeleteAnnotation = ({ collectionId }: { collectionId: string }) => { const mutation = createMutation(() => deleteAnnotationMutation()); const client = useQueryClient(); + const { trackEvent } = usePostHog(); const refetch = () => { client.invalidateQueries({ @@ -13,7 +15,7 @@ export const useDeleteAnnotation = ({ collectionId }: { collectionId: string }) }); }; - const deleteAnnotation = (annotationId: string) => + const deleteAnnotation = (annotationId: string, annotationType: string) => new Promise((resolve, reject) => { mutation.mutate( { @@ -25,6 +27,10 @@ export const useDeleteAnnotation = ({ collectionId }: { collectionId: string }) { onSuccess: () => { refetch(); + trackEvent('annotation_deleted', { + collection_id: collectionId, + annotation_type: annotationType + }); resolve(); }, onError: (error) => { diff --git a/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.test.ts b/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.test.ts index b5c01cb91e..bdec020f11 100644 --- a/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.test.ts +++ b/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.test.ts @@ -105,7 +105,8 @@ describe('useSegmentationMaskBrush', () => { vi.mocked(applySegmentationMaskConstraints).mockResolvedValue([]); createAnnotation.mockResolvedValue({ - sample_id: 'new-annotation-id' + sample_id: 'new-annotation-id', + annotation_type: 'segmentation_mask' }); createLabel.mockResolvedValue({ @@ -502,7 +503,7 @@ describe('useSegmentationMaskBrush', () => { const actions = get(reversibleActions); await executeReversibleAction(actions[0].id); - expect(deleteAnnotation).toHaveBeenCalledWith('new-annotation-id'); + expect(deleteAnnotation).toHaveBeenCalledWith('new-annotation-id', 'segmentation_mask'); expect(get(reversibleActions)).toHaveLength(0); expect(annotationLabelContext.annotationId).toBeNull(); }); diff --git a/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.ts b/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.ts index ecbdd9723e..134bee866d 100644 --- a/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.ts +++ b/lightly_studio_view/src/lib/hooks/useSegmentationMaskBrush.ts @@ -34,7 +34,7 @@ export function useSegmentationMaskBrush({ refetch: () => void; /** Must be a stable reference (not recreated on re-renders) to ensure undo closures * call the live mutation rather than a disposed one. */ - deleteAnnotation: (annotationId: string) => Promise; + deleteAnnotation: (annotationId: string, annotationType: string) => Promise; onAnnotationCreated?: () => void; /** Called when no label is currently selected. Should show a class-picker and resolve with * the chosen class, or null if the user cancelled. */ diff --git a/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.test.ts b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.test.ts new file mode 100644 index 0000000000..86e6102c1f --- /dev/null +++ b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createMutation, useQueryClient } from '@tanstack/svelte-query'; +import { useUpdateAnnotationsMutation } from './useUpdateAnnotationsMutation'; + +vi.mock('@tanstack/svelte-query', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createMutation: vi.fn(), useQueryClient: vi.fn() }; +}); + +const { trackEvent } = vi.hoisted(() => ({ trackEvent: vi.fn() })); +vi.mock('$lib/hooks/usePostHog', () => ({ + usePostHog: () => ({ trackEvent }) +})); + +describe('useUpdateAnnotationsMutation', () => { + const invalidateQueries = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useQueryClient).mockReturnValue({ + invalidateQueries + } as unknown as ReturnType); + }); + + it('fires annotation_label_updated when a single update with label_name succeeds', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess(); + } + } as unknown as ReturnType); + + const { updateAnnotations } = useUpdateAnnotationsMutation({ collectionId: 'col-1' }); + await updateAnnotations([ + { annotation_id: 'ann-1', collection_id: 'col-1', label_name: 'dog' } + ]); + + expect(trackEvent).toHaveBeenCalledWith('annotation_label_updated', { + collection_id: 'col-1', + annotation_id: 'ann-1', + label_name: 'dog' + }); + }); + + it('fires annotations_bulk_labeled for multiple updates with label_names', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess(); + } + } as unknown as ReturnType); + + const { updateAnnotations } = useUpdateAnnotationsMutation({ collectionId: 'col-1' }); + await updateAnnotations([ + { annotation_id: 'ann-1', collection_id: 'col-1', label_name: 'dog' }, + { annotation_id: 'ann-2', collection_id: 'col-1', label_name: 'cat' } + ]); + + expect(trackEvent).toHaveBeenCalledWith('annotations_bulk_labeled', { + collection_id: 'col-1', + annotation_ids: ['ann-1', 'ann-2'], + annotation_count: 2 + }); + }); + + it('fires annotation_label_updated with label_name undefined when a single update has no label_name', async () => { + vi.mocked(createMutation).mockReturnValue({ + mutate: (_vars: unknown, opts: { onSuccess: () => void }) => { + opts.onSuccess(); + } + } as unknown as ReturnType); + + const { updateAnnotations } = useUpdateAnnotationsMutation({ collectionId: 'col-1' }); + await updateAnnotations([ + { + annotation_id: 'ann-1', + collection_id: 'col-1', + bounding_box: { x: 0, y: 0, width: 10, height: 10 } + } + ]); + + expect(trackEvent).toHaveBeenCalledWith('annotation_label_updated', { + collection_id: 'col-1', + annotation_id: 'ann-1', + label_name: undefined + }); + }); +}); diff --git a/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts index cd21a82a39..2761ac4281 100644 --- a/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts +++ b/lightly_studio_view/src/lib/hooks/useUpdateAnnotationsMutation/useUpdateAnnotationsMutation.ts @@ -2,11 +2,13 @@ import { type AnnotationUpdateInput } from '$lib/api/lightly_studio_local'; import { updateAnnotationsMutation } from '$lib/api/lightly_studio_local/@tanstack/svelte-query.gen'; import { createMutation, useQueryClient } from '@tanstack/svelte-query'; import { useImageAnnotationCountsQueryKey } from '$lib/hooks/useImageAnnotationCounts/useImageAnnotationCounts'; +import { usePostHog } from '$lib/hooks'; export const useUpdateAnnotationsMutation = ({ collectionId }: { collectionId: string }) => { const mutation = createMutation(() => updateAnnotationsMutation()); const client = useQueryClient(); + const { trackEvent } = usePostHog(); const refetch = () => { client.invalidateQueries({ @@ -26,6 +28,20 @@ export const useUpdateAnnotationsMutation = ({ collectionId }: { collectionId: s { onSuccess: () => { refetch(); + const labelInputs = inputs.filter((input) => input.label_name != null); + if (inputs.length === 1) { + trackEvent('annotation_label_updated', { + collection_id: collectionId, + annotation_id: inputs[0].annotation_id, + label_name: labelInputs[0]?.label_name + }); + } else if (inputs.length > 1) { + trackEvent('annotations_bulk_labeled', { + collection_id: collectionId, + annotation_ids: inputs.map((input) => input.annotation_id), + annotation_count: inputs.length + }); + } resolve(); }, onError: (error) => { diff --git a/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.test.ts b/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.test.ts index e661a9652a..74de76cece 100644 --- a/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.test.ts +++ b/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.test.ts @@ -49,7 +49,7 @@ describe('addAnnotationCreateToUndoStack', () => { const action = addReversibleAction.mock.calls[0][0] as ReversibleAction; await action.execute(); - expect(deleteAnnotation).toHaveBeenCalledWith('annotation-123'); + expect(deleteAnnotation).toHaveBeenCalledWith('annotation-123', 'object_detection'); expect(refetch).toHaveBeenCalled(); }); diff --git a/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.ts b/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.ts index 6a7c6b3343..f835dbfb00 100644 --- a/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.ts +++ b/lightly_studio_view/src/lib/services/addAnnotationCreateToUndoStack.ts @@ -13,7 +13,7 @@ export const addAnnotationCreateToUndoStack = ({ }: { annotation: AnnotationView; addReversibleAction: (action: ReversibleAction) => void; - deleteAnnotation: (annotationId: string) => Promise; + deleteAnnotation: (annotationId: string, annotationType: string) => Promise; refetch: () => void; onUndo?: ReversibleActionCallback; /** Called after deletion succeeds, before refetch. Use to clean up UI state @@ -21,7 +21,7 @@ export const addAnnotationCreateToUndoStack = ({ onDelete?: ReversibleActionCallback; }) => { const execute = async () => { - await deleteAnnotation(annotation.sample_id); + await deleteAnnotation(annotation.sample_id, annotation.annotation_type); await onUndo?.(); await onDelete?.(); refetch();