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 15 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
5 changes: 5 additions & 0 deletions src/components/ErrorToast.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
dismissDeploymentsError,
selectCameraSerialNumberErrors,
dismissCameraSerialNumberError,
selectDeleteImagesErrors,
dismissDeleteImagesError,
} from '../features/tasks/tasksSlice';
import {
selectRedriveBatchErrors,
Expand Down Expand Up @@ -74,6 +76,7 @@ const ErrorToast = () => {
const manageLabelsErrors = useSelector(selectManageLabelsErrors);
const uploadErrors = useSelector(selectUploadErrors);
const cameraSerialNumberErrors = useSelector(selectCameraSerialNumberErrors);
const deleteImagesErrors = useSelector(selectDeleteImagesErrors);

const enrichedErrors = [
enrichErrors(labelsErrors, 'Label Error', 'labels'),
Expand All @@ -98,6 +101,7 @@ const ErrorToast = () => {
'Error Updating Camera Serial Number',
'cameraSerialNumber',
),
enrichErrors(deleteImagesErrors, 'Error Deleting Images', 'deleteImages'),
];

const errors = enrichedErrors.reduce(
Expand Down Expand Up @@ -161,6 +165,7 @@ const dismissErrorActions = {
manageLabels: (i) => dismissManageLabelsError(i),
upload: (i) => dismissUploadError(i),
cameraSerialNumber: (i) => dismissCameraSerialNumberError(i),
deleteImagesError: (i) => dismissDeleteImagesError(i),
};

function enrichErrors(errors, title, entity) {
Expand Down
6 changes: 4 additions & 2 deletions src/components/HydratedModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
selectDeploymentsLoading,
selectCameraSerialNumberLoading,
clearCameraSerialNumberTask,
selectDeleteImagesLoading,
} from '../features/tasks/tasksSlice.js';
import {
selectModalOpen,
Expand All @@ -45,12 +46,14 @@ const HydratedModal = () => {
const errorsExportLoading = useSelector(selectErrorsExportLoading);
const deploymentsLoading = useSelector(selectDeploymentsLoading);
const cameraSerialNumberLoading = useSelector(selectCameraSerialNumberLoading);
const deleteImagesLoading = useSelector(selectDeleteImagesLoading);
const asyncTaskLoading =
statsLoading.isLoading ||
annotationsExportLoading.isLoading ||
errorsExportLoading.isLoading ||
deploymentsLoading.isLoading ||
cameraSerialNumberLoading.isLoading;
cameraSerialNumberLoading.isLoading ||
deleteImagesLoading.isLoading;

const modalContentMap = {
'stats-modal': {
Expand Down Expand Up @@ -126,7 +129,6 @@ const HydratedModal = () => {
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
2 changes: 2 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const stage = import.meta.env.VITE_STAGE || process.env.NODE_ENV;
export const API_URL = API_URLS[stage];
export const IMAGES_URL = IMAGES_URLS[stage];
export const IMAGE_QUERY_LIMITS = [10, 50, 100];
export const IMAGE_DELETE_LIMIT = 100;


export const SUPPORTED_WIRELESS_CAMS = ['BuckEyeCam', 'RidgeTec', 'CUDDEBACK', 'RECONYX'];

Expand Down
2 changes: 1 addition & 1 deletion src/features/auth/roles.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const WRITE_AUTOMATION_RULES_ROLES = [MANAGER];
export const WRITE_CAMERA_REGISTRATION_ROLES = [MANAGER];
export const WRITE_CAMERA_SERIAL_NUMBER_ROLES = [MANAGER];
export const QUERY_WITH_CUSTOM_FILTER = [MANAGER];
export const DELETE_IMAGES = [MANAGER, MEMBER];
export const DELETE_IMAGES_ROLES = [MANAGER];
export const MANAGE_USERS_ROLES = [MANAGER];
export const WRITE_PROJECT_ROLES = [MANAGER];
export const READ_COMMENT_ROLES = [MANAGER, MEMBER];
Expand Down
38 changes: 18 additions & 20 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 } from '../auth/roles.js';
import { useDispatch, useSelector } from 'react-redux';
import { selectImagesCount, selectImagesCountLoading } from '../images/imagesSlice.js';
import {
Expand All @@ -12,9 +12,15 @@ import {
fetchProjects,
} from '../projects/projectsSlice.js';
import { toggleOpenLoupe } from '../loupe/loupeSlice.js';
import { InfoCircledIcon, DownloadIcon, SymbolIcon } from '@radix-ui/react-icons';
import { InfoCircledIcon, SymbolIcon } 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';
import FiltersPanelFooterDropdown from './FiltersPanelFooterDropdown.jsx';

const RefreshButton = styled('div', {
height: '100%',
Expand All @@ -32,7 +38,7 @@ const InfoButton = styled('div', {
padding: '0 $1',
});

const ExportCSVButton = styled('div', {
const DropDownButton = styled('div', {
height: '100%',
borderLeft: '1px solid $border',
display: 'flex',
Expand Down Expand Up @@ -115,7 +121,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 @@ -126,21 +136,6 @@ const FiltersPanelFooter = () => {
</TooltipContent>
</Tooltip>
)}
{hasRole(userRoles, EXPORT_DATA_ROLES) && (
<Tooltip>
<TooltipTrigger asChild>
<ExportCSVButton>
<IconButton variant="ghost" size="large" onClick={() => handleModalToggle('export-modal')}>
<DownloadIcon />
</IconButton>
</ExportCSVButton>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={5}>
Export data
<TooltipArrow />
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<RefreshButton>
Expand All @@ -154,6 +149,9 @@ const FiltersPanelFooter = () => {
<TooltipArrow />
</TooltipContent>
</Tooltip>
<DropDownButton>
<FiltersPanelFooterDropdown handleModalToggle={handleModalToggle} />
</DropDownButton>
</StyledFiltersPanelFooter>
);
};
Expand Down
57 changes: 57 additions & 0 deletions src/features/filters/FiltersPanelFooterDropdown.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { styled } from '../../theme/stitches.config';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuArrow,
} from '../../components/Dropdown.jsx';
import { selectUserCurrentRoles } from '../auth/authSlice.js';
import { hasRole, DELETE_IMAGES_ROLES, EXPORT_DATA_ROLES } from '../auth/roles.js';
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
import { setDeleteImagesAlertOpen } from '../images/imagesSlice';

const StyledDropdownMenuTrigger = styled(DropdownMenuTrigger, {
height: '100%',
width: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
backgroundColor: 'transparent',
padding: '0',
});

const FiltersPanelFooterDropdown = (props) => {
const dispatch = useDispatch();
const userRoles = useSelector(selectUserCurrentRoles);

const handleDeleteImageItemClick = () => {
dispatch(setDeleteImagesAlertOpen(true));
};

return (
<DropdownMenu>
<StyledDropdownMenuTrigger size="large">
<DotsHorizontalIcon />
</StyledDropdownMenuTrigger>
<DropdownMenuContent sideOffset={5}>
{hasRole(userRoles, EXPORT_DATA_ROLES) && (
<DropdownMenuItem onClick={() => props.handleModalToggle('export-modal')}>
Export filtered data
</DropdownMenuItem>
)}
{hasRole(userRoles, DELETE_IMAGES_ROLES) && (
<DropdownMenuItem onClick={handleDeleteImageItemClick}>
Delete filtered images
</DropdownMenuItem>
)}
<DropdownMenuArrow offset={12} />
</DropdownMenuContent>
</DropdownMenu>
);
};

export default FiltersPanelFooterDropdown;
46 changes: 37 additions & 9 deletions src/features/images/ImagesTableRow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { styled } from '../../theme/stitches.config.js';
import { selectUserUsername, selectUserCurrentRoles } from '../auth/authSlice.js';
import { hasRole, WRITE_OBJECTS_ROLES } from '../auth/roles.js';
import { setDeleteImagesAlertOpen } from './imagesSlice.js';
import { toggleOpenLoupe, selectIsAddingLabel, addLabelStart, addLabelEnd } from '../loupe/loupeSlice.js';
import {
toggleOpenLoupe,
selectIsAddingLabel,
addLabelStart,
addLabelEnd,
} from '../loupe/loupeSlice.js';
import {
setFocus,
setSelectedImageIndices,
Expand All @@ -23,7 +28,14 @@ import {
ContextMenuSeparator,
} from '../../components/ContextMenu.jsx';
import CategorySelector from '../../components/CategorySelector.jsx';
import { CheckIcon, Cross2Icon, LockOpen1Icon, Pencil1Icon, ValueNoneIcon, TrashIcon } from '@radix-ui/react-icons';
import {
CheckIcon,
Cross2Icon,
LockOpen1Icon,
Pencil1Icon,
ValueNoneIcon,
TrashIcon,
} from '@radix-ui/react-icons';

// TODO: redundant component (exists in ImagesTable)
const TableRow = styled('div', {
Expand Down Expand Up @@ -168,12 +180,18 @@ const ImagesTableRow = ({ row, index, focusIndex, style, selectedImageIndices })
// NOTE: if this evaluation seems to cause performance issues
// we can always not disable the buttons and perform these checks
// in the handleMenuItemClick functions
const allObjectsLocked = selectedImages.every((img) => img.objects && img.objects.every((obj) => obj.locked));
const allObjectsUnlocked = selectedImages.every((img) => img.objects && img.objects.every((obj) => !obj.locked));
const allObjectsLocked = selectedImages.every(
(img) => img.objects && img.objects.every((obj) => obj.locked),
);
const allObjectsUnlocked = selectedImages.every(
(img) => img.objects && img.objects.every((obj) => !obj.locked),
);
const hasRenderedObjects = selectedImages.some(
(img) =>
img.objects &&
img.objects.some((obj) => obj.labels.some((lbl) => lbl.validation === null || lbl.validation.validated)),
img.objects.some((obj) =>
obj.labels.some((lbl) => lbl.validation === null || lbl.validation.validated),
),
);

// validate all labels
Expand All @@ -185,7 +203,9 @@ const ImagesTableRow = ({ row, index, focusIndex, style, selectedImageIndices })
const unlockedObjects = image.objects.filter((obj) => !obj.locked);
for (const object of unlockedObjects) {
// find first non-invalidated label in array
const label = object.labels.find((lbl) => lbl.validation === null || lbl.validation.validated);
const label = object.labels.find(
(lbl) => lbl.validation === null || lbl.validation.validated,
);
labelsToValidate.push({
imgId: image._id,
objId: object._id,
Expand Down Expand Up @@ -213,7 +233,11 @@ const ImagesTableRow = ({ row, index, focusIndex, style, selectedImageIndices })
let objects = [];
for (const image of selectedImages) {
const objectsToUnlock = image.objects
.filter((obj) => obj.locked && obj.labels.some((lbl) => lbl.validation === null || lbl.validation.validated))
.filter(
(obj) =>
obj.locked &&
obj.labels.some((lbl) => lbl.validation === null || lbl.validation.validated),
)
.map((obj) => ({ imgId: image._id, objId: obj._id }));

objects = objects.concat(objectsToUnlock);
Expand Down Expand Up @@ -257,15 +281,19 @@ const ImagesTableRow = ({ row, index, focusIndex, style, selectedImageIndices })
};

const handleDeleteImagesMenuItemClick = () => {
dispatch(setDeleteImagesAlertOpen(true));
dispatch(setDeleteImagesAlertOpen(false));
};

return (
<ContextMenu modal={false}>
{' '}
{/* modal={false} is fix for pointer-events:none bug: https://github.com/radix-ui/primitives/issues/2416#issuecomment-1738294359 */}
<ContextMenuTrigger disabled={!selected || !isAuthorized}>
<TableRow {...row.getRowProps({ style })} onClick={(e) => handleRowClick(e, row.id)} selected={selected}>
<TableRow
{...row.getRowProps({ style })}
onClick={(e) => handleRowClick(e, row.id)}
selected={selected}
>
{row.cells.map((cell) => (
<DataCell
{...cell.getCellProps()}
Expand Down
Loading
Loading