Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding Bulk Image Deletion Option #250

Merged
merged 19 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/api/buildQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,38 @@ const queries = {
};
},

deleteImagesTask: (input) => {
return {
template: `
mutation DeleteImagesTask($input: DeleteImagesInput!) {
deleteImagesTask(input: $input) {
_id
}
}
`,
variables: {
input,
},
};
},

deleteImagesByFilterTask: ({ filters }) => {
return {
template: `
mutation DeleteImagesByFilterTask($input: DeleteImagesByFilterTaskInput!) {
deleteImagesByFilterTask(input: $input) {
_id
}
}
`,
variables: {
input: {
filters: filters
},
},
};
},

getImages: ({ filters, pageInfo, page }) => ({
template: `
query GetImages($input: QueryImagesInput!) {
Expand Down
19 changes: 17 additions & 2 deletions src/components/HydratedModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import moment from 'moment-timezone';
import { Modal } from './Modal.jsx';
import ImagesStatsModal from '../features/images/ImagesStatsModal.jsx';
import ExportModal from '../features/images/ExportModal.jsx';
import DeleteImagesByFilterModal from '../features/images/DeleteImagesByFilterModal.jsx';
import CameraAdminModal from '../features/cameras/CameraAdminModal.jsx';
import AutomationRulesForm from '../features/projects/AutomationRulesForm.jsx';
import SaveViewForm from '../features/projects/SaveViewForm.jsx';
Expand All @@ -23,6 +24,9 @@ import {
selectDeploymentsLoading,
selectCameraSerialNumberLoading,
clearCameraSerialNumberTask,
selectDeleteImagesLoading,
selectDeleteImagesByFilterLoading,
clearDeleteImagesByFilterTask,
} from '../features/tasks/tasksSlice.js';
import {
selectModalOpen,
Expand All @@ -45,12 +49,16 @@ const HydratedModal = () => {
const errorsExportLoading = useSelector(selectErrorsExportLoading);
const deploymentsLoading = useSelector(selectDeploymentsLoading);
const cameraSerialNumberLoading = useSelector(selectCameraSerialNumberLoading);
const deleteImagesLoading = useSelector(selectDeleteImagesLoading);
const deleteImagesByFilterLoading = useSelector(selectDeleteImagesByFilterLoading);
const asyncTaskLoading =
statsLoading.isLoading ||
annotationsExportLoading.isLoading ||
errorsExportLoading.isLoading ||
deploymentsLoading.isLoading ||
cameraSerialNumberLoading.isLoading;
cameraSerialNumberLoading.isLoading ||
deleteImagesLoading.isLoading ||
deleteImagesByFilterLoading.isLoading;

const modalContentMap = {
'stats-modal': {
Expand Down Expand Up @@ -121,12 +129,19 @@ const HydratedModal = () => {
dispatch(clearCameraSerialNumberTask());
},
},
'delete-images-by-filter': {
title: 'Delete Filtered Images',
size: 'md',
content: <DeleteImagesByFilterModal />,
callBackOnClose: () => {
dispatch(clearDeleteImagesByFilterTask());
},
},
};

const handleModalToggle = (content) => {
// If async tasks are loading, don't allow modal to close
if (asyncTaskLoading) return;

dispatch(setModalOpen(!modalOpen));
if (modalOpen) {
// modal is being closed, so clean up
Expand Down
50 changes: 45 additions & 5 deletions src/features/filters/FiltersPanelFooter.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { styled, keyframes } from '../../theme/stitches.config.js';
import { selectUserCurrentRoles } from '../auth/authSlice.js';
import { hasRole, READ_STATS_ROLES, EXPORT_DATA_ROLES } from '../auth/roles.js';
import { hasRole, READ_STATS_ROLES, EXPORT_DATA_ROLES, WRITE_IMAGES_ROLES } from '../auth/roles.js';
import { useDispatch, useSelector } from 'react-redux';
import { selectImagesCount, selectImagesCountLoading } from '../images/imagesSlice.js';
import {
Expand All @@ -12,9 +12,14 @@ import {
fetchProjects,
} from '../projects/projectsSlice.js';
import { toggleOpenLoupe } from '../loupe/loupeSlice.js';
import { InfoCircledIcon, DownloadIcon, SymbolIcon } from '@radix-ui/react-icons';
import { InfoCircledIcon, DownloadIcon, SymbolIcon, TrashIcon } from '@radix-ui/react-icons';
import IconButton from '../../components/IconButton.jsx';
import { Tooltip, TooltipContent, TooltipArrow, TooltipTrigger } from '../../components/Tooltip.jsx';
import {
Tooltip,
TooltipContent,
TooltipArrow,
TooltipTrigger,
} from '../../components/Tooltip.jsx';

const RefreshButton = styled('div', {
height: '100%',
Expand All @@ -40,6 +45,14 @@ const ExportCSVButton = styled('div', {
padding: '0 $1',
});

const DeleteImagesButton = styled('div', {
height: '100%',
borderLeft: '1px solid $border',
display: 'flex',
alignItems: 'center',
padding: '0 $1',
});

const ImagesCount = styled('div', {
flexGrow: 1,
display: 'flex',
Expand Down Expand Up @@ -115,7 +128,11 @@ const FiltersPanelFooter = () => {
<Tooltip>
<TooltipTrigger asChild>
<InfoButton>
<IconButton variant="ghost" size="large" onClick={() => handleModalToggle('stats-modal')}>
<IconButton
variant="ghost"
size="large"
onClick={() => handleModalToggle('stats-modal')}
>
<InfoCircledIcon />
</IconButton>
</InfoButton>
Expand All @@ -130,7 +147,11 @@ const FiltersPanelFooter = () => {
<Tooltip>
<TooltipTrigger asChild>
<ExportCSVButton>
<IconButton variant="ghost" size="large" onClick={() => handleModalToggle('export-modal')}>
<IconButton
variant="ghost"
size="large"
onClick={() => handleModalToggle('export-modal')}
>
<DownloadIcon />
</IconButton>
</ExportCSVButton>
Expand All @@ -141,6 +162,25 @@ const FiltersPanelFooter = () => {
</TooltipContent>
</Tooltip>
)}
{hasRole(userRoles, WRITE_IMAGES_ROLES) && (
jue-henry marked this conversation as resolved.
Show resolved Hide resolved
<Tooltip>
<TooltipTrigger asChild>
<DeleteImagesButton>
<IconButton
variant="ghost"
size="large"
onClick={() => handleModalToggle('delete-images-by-filter')}
>
<TrashIcon />
</IconButton>
</DeleteImagesButton>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={5}>
Delete all images shown
<TooltipArrow />
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<RefreshButton>
Expand Down
101 changes: 101 additions & 0 deletions src/features/images/DeleteImagesByFilterModal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
deleteImagesByFilterTask,
fetchTask,
selectDeleteImagesByFilterLoading,
} from '../tasks/tasksSlice.js';
import { selectActiveFilters } from '../filters/filtersSlice.js';
import { SimpleSpinner, SpinnerOverlay } from '../../components/Spinner.jsx';
import { ButtonRow, HelperText } from '../../components/Form.jsx';
import Button from '../../components/Button.jsx';
import NoneFoundAlert from '../../components/NoneFoundAlert.jsx';
import { deleteImages, selectImagesCount, selectImagesCountLoading } from './imagesSlice.js';
import { setModalOpen, setModalContent } from '../projects/projectsSlice.js';
import { IMAGE_QUERY_LIMITS } from '../../config';
import { selectWorkingImages } from '../review/reviewSlice.js';

const DeleteImagesByFilterModal = () => {
jue-henry marked this conversation as resolved.
Show resolved Hide resolved
const filters = useSelector(selectActiveFilters);
const deleteImagesByFilterLoading = useSelector(selectDeleteImagesByFilterLoading);
const imageCountIsLoading = useSelector(selectImagesCountLoading);
const workingImages = useSelector(selectWorkingImages);

const dispatch = useDispatch();

const imageCount = useSelector(selectImagesCount);

useEffect(() => {
if (deleteImagesByFilterLoading.isLoading && deleteImagesByFilterLoading.taskId) {
dispatch(fetchTask(deleteImagesByFilterLoading.taskId));
}
}, [deleteImagesByFilterLoading, dispatch]);

const handleDeleteImagesButtonClick = async () => {
const { isLoading, errors } = deleteImagesByFilterLoading;
const noErrors = !errors || errors.length === 0;
if (!isLoading && noErrors) {
if (imageCount <= IMAGE_QUERY_LIMITS[2]) {
dispatch(deleteImages(workingImages.map((i) => i._id)));
jue-henry marked this conversation as resolved.
Show resolved Hide resolved
} else {
dispatch(deleteImagesByFilterTask({ filters }));
}
}
};

const [isSpinnerActive, setSpinner] = useState(
(deleteImagesByFilterLoading.isLoading && deleteImagesByFilterLoading.taskId) ||
imageCountIsLoading.isLoading,
);

useEffect(() => {
setSpinner(
(deleteImagesByFilterLoading.isLoading && deleteImagesByFilterLoading.taskId) ||
imageCountIsLoading.isLoading,
);
}, [deleteImagesByFilterLoading, imageCountIsLoading.isLoading]);

const handleCancelDelete = () => {
dispatch(setModalOpen(false));
dispatch(setModalContent(null));
};

return (
<div>
{isSpinnerActive && (
<SpinnerOverlay>
<SimpleSpinner />
</SpinnerOverlay>
)}
{imageCount === 0 ? (
<NoneFoundAlert>
We couldn&apos;t find any images that matched this set of filters.
</NoneFoundAlert>
) : (
<>
<HelperText>
<p>
Do you wish to delete all images that match the current filters? This action will
delete {imageCount} {imageCount > 1 ? 'images' : 'image'} and cannot be undone.
</p>
</HelperText>
<ButtonRow>
<Button size="large" css={{ border: 'none' }} onClick={handleCancelDelete}>
Cancel
</Button>
<Button
type="submit"
size="large"
disabled={deleteImagesByFilterLoading.isLoading && deleteImagesByFilterLoading.taskId}
onClick={handleDeleteImagesButtonClick}
>
Delete Images
</Button>
</ButtonRow>
</>
)}
</div>
);
};

export default DeleteImagesByFilterModal;
32 changes: 28 additions & 4 deletions src/features/loupe/DeleteImagesAlert.jsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { deleteImages, setDeleteImagesAlertOpen, selectDeleteImagesAlertOpen } from '../images/imagesSlice.js';
import {
deleteImages,
setDeleteImagesAlertOpen,
selectDeleteImagesAlertOpen,
} from '../images/imagesSlice.js';
import { selectSelectedImages } from '../review/reviewSlice.js';
import { Alert, AlertPortal, AlertOverlay, AlertContent, AlertTitle } from '../../components/AlertDialog.jsx';
import {
Alert,
AlertPortal,
AlertOverlay,
AlertContent,
AlertTitle,
} from '../../components/AlertDialog.jsx';
import Button from '../../components/Button.jsx';
import { red } from '@radix-ui/colors';
import { deleteImagesTask, fetchTask, selectDeleteImagesLoading } from '../tasks/tasksSlice.js';
import { IMAGE_QUERY_LIMITS } from '../../config.js';

const DeleteImagesAlert = () => {
const dispatch = useDispatch();
const open = useSelector(selectDeleteImagesAlertOpen);
const selectedImages = useSelector(selectSelectedImages);
const selectedImageIds = selectedImages.map((img) => img._id);

const deleteImagesLoading = useSelector(selectDeleteImagesLoading);

useEffect(() => {
if (deleteImagesLoading.isLoading && deleteImagesLoading.taskId) {
dispatch(fetchTask(deleteImagesLoading.taskId));
}
}, [deleteImagesLoading, dispatch]);

const handleConfirmDelete = () => {
dispatch(deleteImages(selectedImageIds));
if (selectedImages.length <= IMAGE_QUERY_LIMITS[2]) {
dispatch(deleteImages(selectedImageIds));
jue-henry marked this conversation as resolved.
Show resolved Hide resolved
} else {
dispatch(deleteImagesTask({ imageIds: selectedImageIds }));
}
};

const handleCancelDelete = () => {
Expand Down
4 changes: 4 additions & 0 deletions src/features/review/reviewSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { call } from '../../api';
import { findImage, findObject, findLabel, isImageReviewed } from '../../app/utils';
import { toggleOpenLoupe } from '../loupe/loupeSlice';
import { getImagesSuccess, clearImages, deleteImagesSuccess } from '../images/imagesSlice';
import { deleteImagesSuccess as deleteImageTaskSuccess } from '../tasks/tasksSlice';
jue-henry marked this conversation as resolved.
Show resolved Hide resolved

const initialState = {
workingImages: [],
Expand Down Expand Up @@ -207,6 +208,9 @@ export const reviewSlice = createSlice({
})
.addCase(deleteImagesSuccess, (state, { payload }) => {
state.workingImages = state.workingImages.filter(({ _id }) => !payload.includes(_id));
})
.addCase(deleteImageTaskSuccess, (state, { payload }) => {
state.workingImages = state.workingImages.filter(({ _id }) => !payload.includes(_id));
});
nathanielrindlaub marked this conversation as resolved.
Show resolved Hide resolved
},
});
Expand Down
Loading
Loading