Repository: open-edge-platform/geti
Pull request #191: fix: Don't hide quick annotation when app is no longer hovered
Author: login: dwesolow
Labels: name: UI
name: ready for review
Created: 2025-05-12T10:40:28Z | Merged: 2025-05-13T06:26:40Z

## 📝 Description

I believe that it's regression after updating the spectrum package that we did some time ago (we replaced `useHover` from `react-aria` with our own implementation.
The issue was that the quick annotation dialog was removed from the tree when geti app was no longer hovered.
I thought about two solutions:
- move the selected media action state to the parent and don't hide children when there is media item selected. In this approach we don't need to move state and hooks to the parent like `useVideoMediaItemQuery`, `videoMediaItemForDialog`. **(I decided to go with this approach).**
- move all the necessary state together with `VideoPlayerDialog` to the parent and pass every necessary parameter to the children.

<!--  
If the PR addresses a specific GitHub issue, include one of the following lines to enable auto-closing:
Fixes #<issue_number>
Closes #<issue_number>

If referencing an internal ticket (e.g. JIRA), include the ticket number instead:
JIRA: <project-key>-<ticket-number>

If there’s no related issue or ticket, you can skip this section.
-->

## ✨ Type of Change

Select the type of change your PR introduces:

- [X] 🐞 **Bug fix** – Non-breaking change which fixes an issue
- [ ] 🚀 **New feature** – Non-breaking change which adds functionality
- [ ] 🔨 **Refactor** – Non-breaking change which refactors the code base
- [ ] 💥 **Breaking change** – Changes that break existing functionality
- [ ] 📚 **Documentation update**
- [ ] 🔒 **Security update**
- [ ] 🧪 **Tests**

## 🧪 Testing Scenarios

Describe how the changes were tested and how reviewers can test them too:

- [X] ✅ Tested manually
- [ ] 🤖 Run automated end-to-end tests


## ✅ Checklist

Before submitting the PR, ensure the following:

- [X] 🔍 PR title is clear and descriptive
- [ ] 📝 For internal contributors: If applicable, include the JIRA ticket number (e.g., ITEP-123456) in the PR **title**. Do **not** include full URLs
- [ ] 💬 I have commented my code, especially in hard-to-understand areas
- [ ] 📄 I have made corresponding changes to the documentation
- [ ] ✅ I have added tests that prove my fix is effective or my feature works



Diff:
diff --git a/web_ui/src/pages/project-details/components/project-media/grid-media-item.component.tsx b/web_ui/src/pages/project-details/components/project-media/grid-media-item.component.tsx
index 781e4465ea..75791093b4 100644
--- a/web_ui/src/pages/project-details/components/project-media/grid-media-item.component.tsx
+++ b/web_ui/src/pages/project-details/components/project-media/grid-media-item.component.tsx
@@ -1,7 +1,7 @@
 // Copyright (C) 2022-2025 Intel Corporation
 // LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
 
-import { useRef, useState } from 'react';
+import { Key, useRef, useState } from 'react';
 
 import { Flex, Tooltip, TooltipTrigger } from '@adobe/react-spectrum';
 import { View } from '@react-spectrum/view';
@@ -38,6 +38,7 @@ export const GridMediaItem = ({
     shouldShowAnnotationIndicator,
 }: GridMediaItemProps): JSX.Element => {
     const triggerRef = useRef(null);
+    const [selectedMediaItemAction, setSelectedMediaItemAction] = useState<Key | undefined>(undefined);
 
     const [isHovered, setIsHovered] = useState(false);
 
@@ -61,7 +62,7 @@ export const GridMediaItem = ({
             onPointerOver={handlePointerOver}
             onPointerOut={handlePointerOut}
         >
-            {(isHovered || isSelected) && (
+            {(isHovered || isSelected || selectedMediaItemAction !== undefined) && (
                 <Flex
                     wrap
                     ref={triggerRef}
@@ -103,7 +104,11 @@ export const GridMediaItem = ({
                         height={'size-500'}
                         zIndex={10}
                     >
-                        <MediaItemActions mediaItem={mediaItem} />
+                        <MediaItemActions
+                            mediaItem={mediaItem}
+                            onSelectedMediaItemActionChange={setSelectedMediaItemAction}
+                            selectedMediaItemAction={selectedMediaItemAction}
+                        />
                     </View>
                 </Flex>
             )}
diff --git a/web_ui/src/pages/project-details/components/project-media/media-item-actions/media-item-actions.component.tsx b/web_ui/src/pages/project-details/components/project-media/media-item-actions/media-item-actions.component.tsx
index f29a711d68..f6a41c22fd 100644
--- a/web_ui/src/pages/project-details/components/project-media/media-item-actions/media-item-actions.component.tsx
+++ b/web_ui/src/pages/project-details/components/project-media/media-item-actions/media-item-actions.component.tsx
@@ -1,7 +1,7 @@
 // Copyright (C) 2022-2025 Intel Corporation
 // LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
 
-import { FC, Key, useState } from 'react';
+import { FC, Key } from 'react';
 
 import { DialogContainer, Flex, Text, Tooltip, TooltipTrigger } from '@adobe/react-spectrum';
 
@@ -55,10 +55,15 @@ const QuickAnnotationButton: FC<QuickAnnotationButtonProps> = ({ onClick }) => {
 
 interface MediaItemActionsProps {
     mediaItem: MediaItem;
+    selectedMediaItemAction: Key | undefined;
+    onSelectedMediaItemActionChange: (action: Key | undefined) => void;
 }
 
-export const MediaItemActions: FC<MediaItemActionsProps> = ({ mediaItem }) => {
-    const [selectedMediaItemAction, setSelectedMediaItemAction] = useState<Key | undefined>(undefined);
+export const MediaItemActions: FC<MediaItemActionsProps> = ({
+    mediaItem,
+    selectedMediaItemAction,
+    onSelectedMediaItemActionChange,
+}) => {
     const { FEATURE_FLAG_CLASSIFICATION_RANGES } = useFeatureFlags();
     const { isSingleDomainProject } = useProject();
 
@@ -77,9 +82,9 @@ export const MediaItemActions: FC<MediaItemActionsProps> = ({ mediaItem }) => {
 
     if (shouldShowQuickAnnotation) {
         const handleOpenQuickAnnotation = () =>
-            setSelectedMediaItemAction(MediaItemMenuActions.QUICK_ANNOTATION.toLocaleLowerCase());
+            onSelectedMediaItemActionChange(MediaItemMenuActions.QUICK_ANNOTATION.toLocaleLowerCase());
 
-        const handleCloseDialog = () => setSelectedMediaItemAction(undefined);
+        const handleCloseDialog = () => onSelectedMediaItemActionChange(undefined);
 
         return (
             <Flex alignItems={'center'}>
@@ -89,7 +94,7 @@ export const MediaItemActions: FC<MediaItemActionsProps> = ({ mediaItem }) => {
                     showQuickAnnotation
                     isAnomalyVideo={isAnomalyVideo}
                     selectedMediaItemAction={selectedMediaItemAction}
-                    onChangeSelectedMediaItemAction={setSelectedMediaItemAction}
+                    onChangeSelectedMediaItemAction={onSelectedMediaItemActionChange}
                 />
                 <DialogContainer onDismiss={handleCloseDialog}>
                     {selectedMediaItemAction === MediaItemMenuActions.QUICK_ANNOTATION.toLowerCase() &&
@@ -111,7 +116,7 @@ export const MediaItemActions: FC<MediaItemActionsProps> = ({ mediaItem }) => {
             showQuickAnnotation={false}
             isAnomalyVideo={isAnomalyVideo}
             selectedMediaItemAction={selectedMediaItemAction}
-            onChangeSelectedMediaItemAction={setSelectedMediaItemAction}
+            onChangeSelectedMediaItemAction={onSelectedMediaItemActionChange}
         />
     );
 };
diff --git a/web_ui/src/pages/project-details/components/project-media/media-item-actions/media-item-actions.test.tsx b/web_ui/src/pages/project-details/components/project-media/media-item-actions/media-item-actions.test.tsx
index 580853acf8..3995aeab98 100644
--- a/web_ui/src/pages/project-details/components/project-media/media-item-actions/media-item-actions.test.tsx
+++ b/web_ui/src/pages/project-details/components/project-media/media-item-actions/media-item-actions.test.tsx
@@ -1,6 +1,8 @@
 // Copyright (C) 2022-2025 Intel Corporation
 // LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
 
+import { Key, useState } from 'react';
+
 import { fireEvent, screen, waitFor } from '@testing-library/react';
 
 import { MediaItem } from '../../../../../core/media/media.interface';
@@ -36,6 +38,18 @@ jest.mock('../../../../media/providers/media-provider.component', () => ({
 const mockVideo: MediaItem = getMockedVideoMediaItem({});
 
 describe('MediaItemActions', () => {
+    const App = ({ mediaItem }: { mediaItem: MediaItem }) => {
+        const [selectedMediaItemAction, setSelectedMediaItemAction] = useState<Key | undefined>(undefined);
+
+        return (
+            <MediaItemActions
+                mediaItem={mediaItem}
+                selectedMediaItemAction={selectedMediaItemAction}
+                onSelectedMediaItemActionChange={setSelectedMediaItemAction}
+            />
+        );
+    };
+
     const renderApp = async ({
         mediaItem,
         projectService = createInMemoryProjectService(),
@@ -47,7 +61,7 @@ describe('MediaItemActions', () => {
     }) => {
         return projectRender(
             <MediaProvider>
-                <MediaItemActions mediaItem={mediaItem} />
+                <App mediaItem={mediaItem} />
             </MediaProvider>,
             { services: { projectService }, featureFlags: { FEATURE_FLAG_CLASSIFICATION_RANGES } }
         );
diff --git a/web_ui/src/pages/project-details/components/project-media/media-item-details.component.tsx b/web_ui/src/pages/project-details/components/project-media/media-item-details.component.tsx
index 7db2890f0c..c599ca51b1 100644
--- a/web_ui/src/pages/project-details/components/project-media/media-item-details.component.tsx
+++ b/web_ui/src/pages/project-details/components/project-media/media-item-details.component.tsx
@@ -1,7 +1,7 @@
 // Copyright (C) 2022-2025 Intel Corporation
 // LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
 
-import { useRef } from 'react';
+import { Key, useRef, useState } from 'react';
 
 import { Divider, Flex, Grid, minmax, Tooltip, TooltipTrigger } from '@adobe/react-spectrum';
 import dayjs from 'dayjs';
@@ -51,6 +51,7 @@ export const MediaItemDetails = ({
     const { organizationId } = useOrganizationIdentifier();
     const { useGetUserQuery } = useUsers();
     const uploaderQuery = useGetUserQuery(organizationId, uploaderId);
+    const [selectedMediaItemAction, setSelectedMediaItemAction] = useState<Key | undefined>(undefined);
 
     const preventSingleClick = useRef<boolean>(false);
     const { pressProps } = usePress({
@@ -186,7 +187,11 @@ export const MediaItemDetails = ({
                         <Tooltip>{`${UPLOADER_TOOLTIP}: ${getFullNameFromUser(uploaderQuery.data)}`}</Tooltip>
                     </TooltipTrigger>
 
-                    <MediaItemActions mediaItem={mediaItem} />
+                    <MediaItemActions
+                        mediaItem={mediaItem}
+                        selectedMediaItemAction={selectedMediaItemAction}
+                        onSelectedMediaItemActionChange={setSelectedMediaItemAction}
+                    />
                 </Grid>
             </div>
             <Divider size={'S'} width={'100%'} />


Reviews:
user: login: MarkRedeman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T10:56:25Z
user: login: MarkRedeman
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T10:57:16Z
user: login: dwesolow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T11:11:10Z
user: login: dwesolow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T11:12:32Z
user: login: jpggvilaca
body: media-item-actions can be slightly refactored, but i think even though this fixes the issue, the real fix would be to move the dialogcontainer to the grid item. The item should contain the dialog, not the menu. Then we dont need any state checks because the item is always present. And makes more sense to me, structurally. Thoughts?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T11:30:22Z
user: login: jpggvilaca
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T13:50:31Z
user: login: MarkRedeman
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T15:27:40Z

Review comments:
diff_hunk: @@ -61,7 +62,7 @@ export const GridMediaItem = ({
             onPointerOver={handlePointerOver}
             onPointerOut={handlePointerOut}
         >
-            {(isHovered || isSelected) && (
+            {(isHovered || isSelected || selectedMediaItemAction !== undefined) && (
path: web_ui/src/pages/project-details/components/project-media/grid-media-item.component.tsx
user: login: MarkRedeman
body: Asking to clarify, so in the dialog it triggers something that makes the `MediaItemActions` no longer hovered, which then unmounts `MediaItemActions` and therefore reset its state (plus the dialog)?
created_at: 2025-05-12T10:56:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,6 +38,7 @@ export const GridMediaItem = ({
     shouldShowAnnotationIndicator,
 }: GridMediaItemProps): JSX.Element => {
     const triggerRef = useRef(null);
+    const [selectedMediaItemAction, setSelectedMediaItemAction] = useState<Key | undefined>(undefined);
path: web_ui/src/pages/project-details/components/project-media/grid-media-item.component.tsx
user: login: MarkRedeman
body: Could we be more explicit about `Key`? Now that this state is no longer in the action component, it is a bit more difficult to understand what `Key` is expected to be.
Iirc key could indicate here that the user wants to quick annotate or delete the media item?
created_at: 2025-05-12T10:57:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,6 +38,7 @@ export const GridMediaItem = ({
     shouldShowAnnotationIndicator,
 }: GridMediaItemProps): JSX.Element => {
     const triggerRef = useRef(null);
+    const [selectedMediaItemAction, setSelectedMediaItemAction] = useState<Key | undefined>(undefined);
path: web_ui/src/pages/project-details/components/project-media/grid-media-item.component.tsx
user: login: dwesolow
body: I wanted to change it but it would require to change more files which relies on `Key`. For now I decided to use `Key` but we should think about refactoring the components that use our wrapper for `MenuTrigger` (it's annoying that we need to to `key.toLocaleLowercase()` etc.
created_at: 2025-05-12T11:11:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,7 +62,7 @@ export const GridMediaItem = ({
             onPointerOver={handlePointerOver}
             onPointerOut={handlePointerOut}
         >
-            {(isHovered || isSelected) && (
+            {(isHovered || isSelected || selectedMediaItemAction !== undefined) && (
path: web_ui/src/pages/project-details/components/project-media/grid-media-item.component.tsx
user: login: dwesolow
body: No, when u play only with the dialog it's fine, but when u want to open a new browser tab or check email (whatever) then this part is unmounted because is no longer hovered).
created_at: 2025-05-12T11:12:32Z
author_association: CONTRIBUTOR

PR comments:
user: login: dwesolow
created_at: 2025-05-12T11:41:10Z
body: > media-item-actions can be slightly refactored, but i think even though this fixes the issue, the real fix would be to move the dialogcontainer to the grid item. The item should contain the dialog, not the menu. Then we dont need any state checks because the item is always present. And makes more sense to me, structurally. Thoughts?

I don't get it, we dialog is connected with the media actions, they live together and we need to know which action was selected to decided whether we should show the dialog or now. Could u show a pseudo code with the change u mean?
author_association: CONTRIBUTOR
user: login: jpggvilaca
created_at: 2025-05-12T13:50:12Z
body: > > media-item-actions can be slightly refactored, but i think even though this fixes the issue, the real fix would be to move the dialogcontainer to the grid item. The item should contain the dialog, not the menu. Then we dont need any state checks because the item is always present. And makes more sense to me, structurally. Thoughts?
> 
> I don't get it, we dialog is connected with the media actions, they live together and we need to know which action was selected to decided whether we should show the dialog or now. Could u show a pseudo code with the change u mean?

discussed offline. my approach lacked a full overview so this fix for now has a lower cost/benefit
author_association: CONTRIBUTOR