Skip to content

Commit 732a289

Browse files
authored
4699 - dataset api - p3 (#4745)
1 parent 8a18eb6 commit 732a289

File tree

7 files changed

+125
-38
lines changed

7 files changed

+125
-38
lines changed

ui/src/features/dataset/gallery/delete-media-item/alert-dialog-content.component.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import { AlertDialog, Flex, Text } from '@geti/ui';
66
import { useEventListener } from '../../../../hooks/event-listener/event-listener.hook';
77

88
type AlertDialogContentProps = {
9-
itemId: string;
9+
itemsIds: string[];
1010
onPrimaryAction: () => void;
1111
};
1212

13-
export const AlertDialogContent = ({ itemId, onPrimaryAction }: AlertDialogContentProps) => {
13+
export const AlertDialogContent = ({ itemsIds, onPrimaryAction }: AlertDialogContentProps) => {
1414
useEventListener('keydown', (event) => {
1515
if (event.key === 'Enter') {
1616
event.preventDefault();
@@ -30,7 +30,9 @@ export const AlertDialogContent = ({ itemId, onPrimaryAction }: AlertDialogConte
3030
<Text>Are you sure you want to delete the next items?</Text>
3131

3232
<Flex direction={'column'} marginTop={'size-100'}>
33-
<Text> - {itemId}</Text>
33+
{itemsIds.map((itemId) => (
34+
<Text key={itemId}>- {itemId}</Text>
35+
))}
3436
</Flex>
3537
</AlertDialog>
3638
);

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

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ vi.mock('react-router', () => ({
1818
describe('DeleteMediaItem', () => {
1919
it('deletes a media item and shows a success toast', async () => {
2020
const itemId = '123';
21+
const mockedOnDeleted = vitest.fn();
22+
2123
server.use(
2224
http.delete('/api/projects/{project_id}/dataset/items/{dataset_item_id}', () => {
2325
return HttpResponse.json(null, { status: 204 });
@@ -26,7 +28,7 @@ describe('DeleteMediaItem', () => {
2628

2729
render(
2830
<TestProviders>
29-
<DeleteMediaItem itemId={itemId} />
31+
<DeleteMediaItem itemsIds={[itemId]} onDeleted={mockedOnDeleted} />
3032
</TestProviders>
3133
);
3234

@@ -36,23 +38,30 @@ describe('DeleteMediaItem', () => {
3638
userEvent.click(screen.getByRole('button', { name: /confirm/i }));
3739
await waitForElementToBeRemoved(() => screen.queryByRole('button', { name: /confirm/i }));
3840

39-
expect(screen.getByText(`Item "${itemId}" was deleted successfully`)).toBeVisible();
41+
expect(screen.getByText(`1 item(s) deleted successfully`)).toBeVisible();
42+
expect(mockedOnDeleted).toHaveBeenCalledWith([itemId]);
4043
});
4144

4245
it('shows an error toast when deleting a media item fails', async () => {
43-
const itemId = '123';
46+
const itemToFail = '321';
47+
const itemToDelete = '123';
4448
const errorMessage = 'test error message';
49+
const mockedOnDeleted = vitest.fn();
50+
4551
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 });
52+
http.delete('/api/projects/{project_id}/dataset/items/{dataset_item_id}', ({ params }) => {
53+
const { dataset_item_id } = params;
54+
return dataset_item_id === itemToDelete
55+
? HttpResponse.json(null, { status: 204 })
56+
: // eslint-disable-next-line @typescript-eslint/ban-ts-comment
57+
// @ts-expect-error
58+
HttpResponse.json({ detail: errorMessage }, { status: 500 });
5059
})
5160
);
5261

5362
render(
5463
<TestProviders>
55-
<DeleteMediaItem itemId={itemId} />
64+
<DeleteMediaItem itemsIds={[itemToFail, itemToDelete]} onDeleted={mockedOnDeleted} />
5665
</TestProviders>
5766
);
5867

@@ -62,6 +71,8 @@ describe('DeleteMediaItem', () => {
6271
userEvent.click(screen.getByRole('button', { name: /confirm/i }));
6372
await waitForElementToBeRemoved(() => screen.queryByRole('button', { name: /confirm/i }));
6473

65-
expect(screen.getByText(`Failed to delete item: ${errorMessage}`)).toBeVisible();
74+
expect(screen.getByText(`1 item(s) deleted successfully`)).toBeVisible();
75+
expect(screen.getByText(`Failed to delete, ${errorMessage}`)).toBeVisible();
76+
expect(mockedOnDeleted).toHaveBeenCalledWith([itemToDelete]);
6677
});
6778
});

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

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
// Copyright (C) 2025 Intel Corporation
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { useRef } from 'react';
5-
6-
import { ActionButton, DialogContainer, removeToast, toast } from '@geti/ui';
4+
import { ActionButton, DialogContainer, toast } from '@geti/ui';
75
import { Delete } from '@geti/ui/icons';
86
import { useOverlayTriggerState } from '@react-stately/overlays';
7+
import { isFunction } from 'lodash-es';
98

109
import { $api } from '../../../../api/client';
1110
import { useProjectIdentifier } from '../../../../hooks/use-project-identifier.hook';
@@ -14,36 +13,49 @@ import { AlertDialogContent } from './alert-dialog-content.component';
1413
import classes from './delete-media-item.module.scss';
1514

1615
type DeleteMediaItemProps = {
17-
itemId: string;
16+
itemsIds: string[];
17+
onDeleted?: (deletedIds: string[]) => void;
1818
};
1919

20-
export const DeleteMediaItem = ({ itemId }: DeleteMediaItemProps) => {
20+
const isFulfilled = (response: PromiseSettledResult<{ itemId: string }>) => response.status === 'fulfilled';
21+
22+
export const DeleteMediaItem = ({ itemsIds = [], onDeleted }: DeleteMediaItemProps) => {
2123
const project_id = useProjectIdentifier();
22-
const processingToastId = useRef<null | string | number>(null);
2324
const alertDialogState = useOverlayTriggerState({});
2425

2526
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-
}
27+
onError: (error, { params: { path } }) => {
28+
const { dataset_item_id: itemId } = path;
29+
30+
toast({
31+
id: itemId,
32+
type: 'error',
33+
message: `Failed to delete, ${error?.detail}`,
34+
});
3635
},
3736
});
3837

39-
const handleRemoveItems = () => {
38+
const handleRemoveItems = async () => {
4039
alertDialogState.close();
4140

42-
removeMutation.mutate({ params: { path: { project_id, dataset_item_id: itemId } } });
41+
toast({ id: 'deleting-notification', type: 'info', message: `Deleting items...` });
42+
43+
const deleteItemPromises = itemsIds.map(async (dataset_item_id) => {
44+
await removeMutation.mutateAsync({ params: { path: { project_id, dataset_item_id } } });
45+
46+
return { itemId: dataset_item_id };
47+
});
48+
49+
const responses = await Promise.allSettled(deleteItemPromises);
50+
const deletedIds = responses.filter(isFulfilled).map(({ value }) => value.itemId);
51+
52+
isFunction(onDeleted) && onDeleted(deletedIds);
4353

44-
processingToastId.current = toast({
45-
type: 'info',
46-
message: `Deleting ${itemId} item(s)...`,
54+
toast({
55+
id: 'deleting-notification',
56+
type: 'success',
57+
message: `${deletedIds.length} item(s) deleted successfully`,
58+
duration: 3000,
4759
});
4860
};
4961

@@ -60,7 +72,9 @@ export const DeleteMediaItem = ({ itemId }: DeleteMediaItemProps) => {
6072
</ActionButton>
6173

6274
<DialogContainer onDismiss={alertDialogState.close}>
63-
{alertDialogState.isOpen && <AlertDialogContent itemId={itemId} onPrimaryAction={handleRemoveItems} />}
75+
{alertDialogState.isOpen && (
76+
<AlertDialogContent itemsIds={itemsIds} onPrimaryAction={handleRemoveItems} />
77+
)}
6478
</DialogContainer>
6579
</>
6680
);

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const Gallery = ({ items, hasNextPage, isFetchingNextPage, fetchNextPage
4646
const project_id = useProjectIdentifier();
4747

4848
const [selectedMediaItem, setSelectedMediaItem] = useState<null | DatasetItem>(null);
49-
const { selectedKeys, mediaState, setSelectedKeys } = useSelectedData();
49+
const { selectedKeys, mediaState, setSelectedKeys, toggleSelectedKeys } = useSelectedData();
5050

5151
const isSetSelectedKeys = selectedKeys instanceof Set;
5252

@@ -94,7 +94,9 @@ export const Gallery = ({ items, hasNextPage, isFetchingNextPage, fetchNextPage
9494
isChecked={isSetSelectedKeys && selectedKeys.has(String(item.id))}
9595
/>
9696
)}
97-
topRightElement={() => <DeleteMediaItem itemId={String(item.id)} />}
97+
topRightElement={() => (
98+
<DeleteMediaItem itemsIds={[String(item.id)]} onDeleted={toggleSelectedKeys} />
99+
)}
98100
bottomRightElement={() => (
99101
<AnnotationStateIcon state={mediaState.get(String(item.id))} />
100102
)}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import { Button, Divider, Flex, Heading, Text } from '@geti/ui';
66
import { useSelectedData } from '../../../routes/dataset/provider';
77
import { DatasetItem } from '../../annotator/types';
88
import { CheckboxInput } from '../checkbox-input';
9+
import { DeleteMediaItem } from '../gallery/delete-media-item/delete-media-item.component';
910
import { toggleMultipleSelection, updateSelectedKeysTo } from './util';
1011

1112
type ToolbarProps = {
1213
items: DatasetItem[];
1314
};
1415

1516
export const Toolbar = ({ items }: ToolbarProps) => {
16-
const { selectedKeys, setSelectedKeys, setMediaState } = useSelectedData();
17+
const { selectedKeys, setSelectedKeys, setMediaState, toggleSelectedKeys } = useSelectedData();
1718
const totalSelectedElements = selectedKeys instanceof Set ? selectedKeys.size : 0;
1819
const hasSelectedElements = totalSelectedElements > 0;
1920

@@ -57,6 +58,11 @@ export const Toolbar = ({ items }: ToolbarProps) => {
5758

5859
{hasSelectedElements && (
5960
<>
61+
<DeleteMediaItem
62+
itemsIds={Array.from(selectedKeys) as string[]}
63+
onDeleted={toggleSelectedKeys}
64+
/>
65+
6066
<Button variant={'accent'} onPress={handleAccept}>
6167
Accept
6268
</Button>
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 { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
5+
6+
import { SelectedDataProvider, useSelectedData } from './provider';
7+
8+
describe('SelectedDataProvider', () => {
9+
it('toggles a selected key and displays it', async () => {
10+
const testKey = 'test-key';
11+
const App = () => {
12+
const { selectedKeys, toggleSelectedKeys } = useSelectedData();
13+
14+
return (
15+
<div>
16+
<p>{selectedKeys === 'all' ? 'all' : [...selectedKeys.values()].join(', ')}</p>
17+
<button onClick={() => toggleSelectedKeys([testKey])}>toggle key</button>
18+
</div>
19+
);
20+
};
21+
22+
render(
23+
<SelectedDataProvider>
24+
<App />
25+
</SelectedDataProvider>
26+
);
27+
28+
expect(screen.queryByText(testKey)).not.toBeInTheDocument();
29+
30+
screen.getByRole('button', { name: /toggle key/i }).click();
31+
expect(await screen.findByText(testKey)).toBeVisible();
32+
33+
screen.getByRole('button', { name: /toggle key/i }).click();
34+
await waitForElementToBeRemoved(() => screen.queryByText(testKey));
35+
expect(screen.queryByText(testKey)).not.toBeInTheDocument();
36+
});
37+
});

ui/src/routes/dataset/provider.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type SelectedDataState = null | {
1414

1515
mediaState: MediaState;
1616
setMediaState: Dispatch<SetStateAction<MediaState>>;
17+
toggleSelectedKeys: (key: string[]) => void;
1718
};
1819

1920
export const SelectedDataContext = createContext<SelectedDataState>(null);
@@ -22,8 +23,22 @@ export const SelectedDataProvider = ({ children }: { children: ReactNode }) => {
2223
const [mediaState, setMediaState] = useState<MediaState>(new Map());
2324
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set());
2425

26+
const toggleSelectedKeys = (keys: string[]) => {
27+
setSelectedKeys((prevSelectedKeys) => {
28+
const updatedSelectedKeys = new Set(prevSelectedKeys);
29+
30+
keys.forEach((key) => {
31+
updatedSelectedKeys.has(key) ? updatedSelectedKeys.delete(key) : updatedSelectedKeys.add(key);
32+
});
33+
34+
return updatedSelectedKeys;
35+
});
36+
};
37+
2538
return (
26-
<SelectedDataContext.Provider value={{ selectedKeys, setSelectedKeys, mediaState, setMediaState }}>
39+
<SelectedDataContext.Provider
40+
value={{ selectedKeys, setSelectedKeys, mediaState, setMediaState, toggleSelectedKeys }}
41+
>
2742
{children}
2843
</SelectedDataContext.Provider>
2944
);

0 commit comments

Comments
 (0)