Skip to content

Commit 359ff66

Browse files
authored
4699 - dataset api - p2 (#4740)
1 parent 0a96f08 commit 359ff66

File tree

10 files changed

+280
-3
lines changed

10 files changed

+280
-3
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { AlertDialog, Flex, Text } from '@geti/ui';
5+
6+
import { useEventListener } from '../../../../hooks/event-listener/event-listener.hook';
7+
8+
type AlertDialogContentProps = {
9+
itemId: string;
10+
onPrimaryAction: () => void;
11+
};
12+
13+
export const AlertDialogContent = ({ itemId, onPrimaryAction }: AlertDialogContentProps) => {
14+
useEventListener('keydown', (event) => {
15+
if (event.key === 'Enter') {
16+
event.preventDefault();
17+
onPrimaryAction();
18+
}
19+
});
20+
21+
return (
22+
<AlertDialog
23+
maxHeight={'size-6000'}
24+
title='Delete Items'
25+
variant='confirmation'
26+
primaryActionLabel='Confirm'
27+
secondaryActionLabel='Close'
28+
onPrimaryAction={onPrimaryAction}
29+
>
30+
<Text>Are you sure you want to delete the next items?</Text>
31+
32+
<Flex direction={'column'} marginTop={'size-100'}>
33+
<Text> - {itemId}</Text>
34+
</Flex>
35+
</AlertDialog>
36+
);
37+
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
5+
import { userEvent } from '@testing-library/user-event';
6+
import { HttpResponse } from 'msw';
7+
import { vi } from 'vitest';
8+
9+
import { http } from '../../../../api/utils';
10+
import { server } from '../../../../msw-node-setup';
11+
import { TestProviders } from '../../../../providers';
12+
import { DeleteMediaItem } from './delete-media-item.component';
13+
14+
vi.mock('react-router', () => ({
15+
useParams: vi.fn(() => ({ projectId: '123' })),
16+
}));
17+
18+
describe('DeleteMediaItem', () => {
19+
it('deletes a media item and shows a success toast', async () => {
20+
const itemId = '123';
21+
server.use(
22+
http.delete('/api/projects/{project_id}/dataset/items/{dataset_item_id}', () => {
23+
return HttpResponse.json(null, { status: 204 });
24+
})
25+
);
26+
27+
render(
28+
<TestProviders>
29+
<DeleteMediaItem itemId={itemId} />
30+
</TestProviders>
31+
);
32+
33+
userEvent.click(screen.getByLabelText(/delete media item/i));
34+
await screen.findByText(/Are you sure you want to delete the next items?/i);
35+
36+
userEvent.click(screen.getByRole('button', { name: /confirm/i }));
37+
await waitForElementToBeRemoved(() => screen.queryByRole('button', { name: /confirm/i }));
38+
39+
expect(screen.getByText(`Item "${itemId}" was deleted successfully`)).toBeVisible();
40+
});
41+
42+
it('shows an error toast when deleting a media item fails', async () => {
43+
const itemId = '123';
44+
const errorMessage = 'test error message';
45+
server.use(
46+
http.delete('/api/projects/{project_id}/dataset/items/{dataset_item_id}', () => {
47+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
48+
// @ts-expect-error
49+
return HttpResponse.json({ detail: errorMessage }, { status: 500 });
50+
})
51+
);
52+
53+
render(
54+
<TestProviders>
55+
<DeleteMediaItem itemId={itemId} />
56+
</TestProviders>
57+
);
58+
59+
userEvent.click(screen.getByLabelText(/delete media item/i));
60+
await screen.findByText(/Are you sure you want to delete the next items?/i);
61+
62+
userEvent.click(screen.getByRole('button', { name: /confirm/i }));
63+
await waitForElementToBeRemoved(() => screen.queryByRole('button', { name: /confirm/i }));
64+
65+
expect(screen.getByText(`Failed to delete item: ${errorMessage}`)).toBeVisible();
66+
});
67+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useRef } from 'react';
5+
6+
import { ActionButton, DialogContainer, removeToast, toast } from '@geti/ui';
7+
import { Delete } from '@geti/ui/icons';
8+
import { useOverlayTriggerState } from '@react-stately/overlays';
9+
10+
import { $api } from '../../../../api/client';
11+
import { useProjectIdentifier } from '../../../../hooks/use-project-identifier.hook';
12+
import { AlertDialogContent } from './alert-dialog-content.component';
13+
14+
import classes from './delete-media-item.module.scss';
15+
16+
type DeleteMediaItemProps = {
17+
itemId: string;
18+
};
19+
20+
export const DeleteMediaItem = ({ itemId }: DeleteMediaItemProps) => {
21+
const project_id = useProjectIdentifier();
22+
const processingToastId = useRef<null | string | number>(null);
23+
const alertDialogState = useOverlayTriggerState({});
24+
25+
const removeMutation = $api.useMutation('delete', `/api/projects/{project_id}/dataset/items/{dataset_item_id}`, {
26+
onSuccess: () => {
27+
toast({ type: 'success', message: `Item "${itemId}" was deleted successfully`, duration: 3000 });
28+
},
29+
onError: (error) => {
30+
toast({ type: 'error', message: `Failed to delete item: ${error?.detail}` });
31+
},
32+
onSettled: () => {
33+
if (processingToastId.current) {
34+
removeToast(processingToastId.current);
35+
}
36+
},
37+
});
38+
39+
const handleRemoveItems = () => {
40+
alertDialogState.close();
41+
42+
removeMutation.mutate({ params: { path: { project_id, dataset_item_id: itemId } } });
43+
44+
processingToastId.current = toast({
45+
type: 'info',
46+
message: `Deleting ${itemId} item(s)...`,
47+
});
48+
};
49+
50+
return (
51+
<>
52+
<ActionButton
53+
isQuiet
54+
aria-label='delete media item'
55+
isDisabled={removeMutation.isPending}
56+
UNSAFE_className={classes.deleteButton}
57+
onPress={alertDialogState.open}
58+
>
59+
<Delete />
60+
</ActionButton>
61+
62+
<DialogContainer onDismiss={alertDialogState.close}>
63+
{alertDialogState.isOpen && <AlertDialogContent itemId={itemId} onPrimaryAction={handleRemoveItems} />}
64+
</DialogContainer>
65+
</>
66+
);
67+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
.deleteButton {
5+
fill: var(--spectrum-global-color-gray-700);
6+
}
7+
8+
div:has(> .deleteButton) {
9+
padding: var(--spectrum-global-dimension-size-10);
10+
}

ui/src/features/dataset/gallery/gallery.component.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { DatasetItem } from '../../annotator/types';
2121
import { CheckboxInput } from '../checkbox-input';
2222
import { MediaPreview } from '../media-preview/media-preview.component';
2323
import { AnnotationStateIcon } from './annotation-state-icon.component';
24+
import { DeleteMediaItem } from './delete-media-item/delete-media-item.component';
2425
import { MediaItem } from './media-item.component';
2526
import { MediaThumbnail } from './media-thumbnail.component';
2627
import { getThumbnailUrl } from './utils';
@@ -86,14 +87,17 @@ export const Gallery = ({ items, hasNextPage, isFetchingNextPage, fetchNextPage
8687
onDoubleClick={() => setSelectedMediaItem(item)}
8788
/>
8889
)}
89-
topRightElement={() => <AnnotationStateIcon state={mediaState.get(String(item.id))} />}
9090
topLeftElement={() => (
9191
<CheckboxInput
9292
isReadOnly
9393
name={`select-${item.id}`}
9494
isChecked={isSetSelectedKeys && selectedKeys.has(String(item.id))}
9595
/>
9696
)}
97+
topRightElement={() => <DeleteMediaItem itemId={String(item.id)} />}
98+
bottomRightElement={() => (
99+
<AnnotationStateIcon state={mediaState.get(String(item.id))} />
100+
)}
97101
/>
98102
</ListBoxItem>
99103
))}

ui/src/features/dataset/gallery/media-item.component.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,17 @@ interface MediaItemProps {
1313
contentElement: () => ReactNode;
1414
topLeftElement?: () => ReactNode;
1515
topRightElement?: () => ReactNode;
16+
bottomLeftElement?: () => ReactNode;
17+
bottomRightElement?: () => ReactNode;
1618
}
1719

18-
export const MediaItem = ({ contentElement, topLeftElement, topRightElement }: MediaItemProps) => {
20+
export const MediaItem = ({
21+
contentElement,
22+
topLeftElement,
23+
topRightElement,
24+
bottomLeftElement,
25+
bottomRightElement,
26+
}: MediaItemProps) => {
1927
return (
2028
<View width={'100%'}>
2129
{contentElement()}
@@ -31,6 +39,18 @@ export const MediaItem = ({ contentElement, topLeftElement, topRightElement }: M
3139
{topRightElement()}
3240
</View>
3341
)}
42+
43+
{isFunction(bottomLeftElement) && (
44+
<View UNSAFE_className={clsx(classes.bottomLeftElement, classes.floatingContainer)}>
45+
{bottomLeftElement()}
46+
</View>
47+
)}
48+
49+
{isFunction(bottomRightElement) && (
50+
<View UNSAFE_className={clsx(classes.bottomRightElement, classes.floatingContainer)}>
51+
{bottomRightElement()}
52+
</View>
53+
)}
3454
</View>
3555
);
3656
};

ui/src/features/dataset/gallery/media-item.module.scss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@
2121
right: var(--spectrum-global-dimension-size-50);
2222
}
2323

24+
.bottomLeftElement {
25+
bottom: var(--spectrum-global-dimension-size-50);
26+
left: var(--spectrum-global-dimension-size-50);
27+
}
28+
.bottomRightElement {
29+
bottom: var(--spectrum-global-dimension-size-50);
30+
right: var(--spectrum-global-dimension-size-50);
31+
}
32+
2433
[data-hovered],
2534
[data-selected],
2635
[data-accepted='true'],
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (C) 2025 Intel Corporation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { RefObject, useEffect, useLayoutEffect, useRef } from 'react';
5+
6+
//Todo import from geti-classic when available
7+
function determineTargetElement<ElementType extends Element = Element>(
8+
element?: RefObject<ElementType | null> | ElementType | null
9+
): ElementType | (Window & typeof globalThis) | null {
10+
if (element === undefined) {
11+
return window;
12+
}
13+
14+
if (element === null) {
15+
return null;
16+
}
17+
18+
if ('current' in element) {
19+
return element.current;
20+
}
21+
22+
return element;
23+
}
24+
25+
type EventType = GlobalEventHandlersEventMap & WindowEventHandlersEventMap & DocumentEventMap;
26+
27+
export function useEventListener<
28+
EventName extends keyof EventType,
29+
Handler extends (event: EventType[EventName]) => void,
30+
ElementType extends Element = Element,
31+
>(eventName: EventName, handler: Handler, element?: RefObject<ElementType | null> | ElementType | null): void {
32+
const savedHandler = useRef<Handler>(handler);
33+
34+
useLayoutEffect(() => {
35+
savedHandler.current = handler;
36+
}, [handler]);
37+
38+
useEffect(() => {
39+
const controller = new AbortController();
40+
const targetElement = determineTargetElement(element);
41+
42+
if (targetElement === null) {
43+
return;
44+
}
45+
46+
targetElement.addEventListener(
47+
eventName,
48+
(event) => {
49+
if (savedHandler.current !== undefined) {
50+
savedHandler.current(event as EventType[EventName]);
51+
}
52+
},
53+
{
54+
signal: controller.signal,
55+
}
56+
);
57+
58+
return () => {
59+
controller.abort();
60+
};
61+
}, [eventName, element]);
62+
}

ui/src/providers.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const TestProviders = ({ children, routerProps }: { children: ReactNode;
4141
<QueryClientProvider client={queryClient}>
4242
<ThemeProvider>
4343
<Router {...routerProps}>{children}</Router>
44+
<Toast />
4445
</ThemeProvider>
4546
</QueryClientProvider>
4647
);

ui/src/routes/dataset/dataset.component.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const Dataset = () => {
1515
height={'100%'}
1616
gridArea={'content'}
1717
direction={'column'}
18-
UNSAFE_style={{ padding: dimensionValue('size-350'), paddingBottom: 0, boxSizing: 'border-box' }}
18+
UNSAFE_style={{ padding: dimensionValue('size-350'), paddingBottom: 0 }}
1919
>
2020
<Toolbar items={items} />
2121

0 commit comments

Comments
 (0)