Skip to content

Commit

Permalink
Merge pull request #260 from tnc-ca-geo/feature/227-bulk_image_deleti…
Browse files Browse the repository at this point in the history
…on-limits

#227 enforce bulk image deletion limits
  • Loading branch information
nathanielrindlaub authored Dec 10, 2024
2 parents ec7c2f6 + b77a36a commit 7220aa6
Show file tree
Hide file tree
Showing 7 changed files with 288 additions and 124 deletions.
5 changes: 3 additions & 2 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ 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 SYNC_IMAGE_DELETE_LIMIT = 300; // when deleting w/o using task handler
export const ASYNC_IMAGE_DELETE_BY_ID_LIMIT = 4000; // when deleting using task handler (by _id). Constrained by POST request size limits
export const ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT = 200000; // when deleting using task handler (by filter). Constrained by task Lambda timeout

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

Expand Down
4 changes: 2 additions & 2 deletions src/features/filters/FiltersPanelFooterDropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ const FiltersPanelFooterDropdown = (props) => {
<DropdownMenuContent sideOffset={5}>
{hasRole(userRoles, EXPORT_DATA_ROLES) && (
<DropdownMenuItem onClick={() => props.handleModalToggle('export-modal')}>
Export filtered data
Export currently filtered data
</DropdownMenuItem>
)}
{hasRole(userRoles, DELETE_IMAGES_ROLES) && (
<DropdownMenuItem onClick={handleDeleteImageItemClick}>
Delete filtered images
Delete all currently filtered images
</DropdownMenuItem>
)}
<DropdownMenuArrow offset={12} />
Expand Down
237 changes: 237 additions & 0 deletions src/features/images/DeleteImagesAlert.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import React, { useState, useEffect } from 'react';
import { styled } from '../../theme/stitches.config';
import { useDispatch, useSelector } from 'react-redux';
import {
deleteImages,
selectImagesCountLoading,
selectDeleteImagesAlertState,
setDeleteImagesAlertStatus,
selectImagesLoading,
selectImagesCount,
} from './imagesSlice.js';
import { selectActiveFilters } from '../filters/filtersSlice.js';
import { selectSelectedImages } from '../review/reviewSlice.js';
import {
Alert,
AlertPortal,
AlertOverlay,
AlertContent,
AlertTitle,
} from '../../components/AlertDialog.jsx';
import Button from '../../components/Button.jsx';
import { red, green } from '@radix-ui/colors';
import { deleteImagesTask, fetchTask, selectDeleteImagesLoading } from '../tasks/tasksSlice.js';
import {
SYNC_IMAGE_DELETE_LIMIT,
ASYNC_IMAGE_DELETE_BY_ID_LIMIT,
ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT,
} from '../../config.js';
import { SimpleSpinner, SpinnerOverlay } from '../../components/Spinner.jsx';
import * as Progress from '@radix-ui/react-progress';

const ProgressBar = styled('div', {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'absolute',
bottom: 0,
width: '100%',
});

const ProgressRoot = styled(Progress.Root, {
overflow: 'hidden',
background: '$backgroundDark',
// borderRadius: '99999px',
width: '100%',
height: '8px',

/* Fix overflow clipping in Safari */
/* https://gist.github.com/domske/b66047671c780a238b51c51ffde8d3a0 */
transform: 'translateZ(0)',
});

const ProgressIndicator = styled(Progress.Indicator, {
backgroundColor: green.green9, //sky.sky4, //'$blue600',
width: '100%',
height: '100%',
transition: 'transform 660ms cubic-bezier(0.65, 0, 0.35, 1)',
});

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

const imagesLoading = useSelector(selectImagesLoading);
const deleteImagesTaskLoading = useSelector(selectDeleteImagesLoading);

const filters = useSelector(selectActiveFilters);
const imageCountIsLoading = useSelector(selectImagesCountLoading);
const imageCount = useSelector(selectImagesCount);

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

const [estimatedTotalTime, setEstimatedTotalTime] = useState(null); // in seconds
const [elapsedTime, setElapsedTime] = useState(null);

const handleConfirmDelete = () => {
if (alertState.deleteImagesAlertByFilter) {
// if deleting by filter, always delete using task handler
dispatch(deleteImagesTask({ imageIds: [], filters: filters }));
} else {
// if deleting by selection of IDs, use task handler if over limit
if (selectedImages.length > SYNC_IMAGE_DELETE_LIMIT) {
dispatch(deleteImagesTask({ imageIds: selectedImageIds, filters: null }));
} else {
dispatch(deleteImages(selectedImageIds));
}
}
if (selectedImages.length > 3000 || imageCount > 3000) {
// show progress bar if deleting more than 3000 images (approx wait time will be > 10 seconds)
const count = !alertState.deleteImagesAlertByFilter ? selectedImages.length : imageCount;
setEstimatedTotalTime(count * 0.0055); // estimated deletion time per image in seconds
setElapsedTime(0);
}
};

useEffect(() => {
if (estimatedTotalTime) {
const interval = setInterval(() => {
setElapsedTime((prevElapsedTime) => {
if (prevElapsedTime >= estimatedTotalTime) {
clearInterval(interval);
setEstimatedTotalTime(null);
setElapsedTime(null);
return estimatedTotalTime;
}
return prevElapsedTime + 1;
});
}, 1000);
return () => clearInterval(interval);
}
}, [estimatedTotalTime, elapsedTime]);

const handleCancelDelete = () => {
dispatch(setDeleteImagesAlertStatus({ openStatus: false }));
};

const deleteByIdLimitExceeded =
!alertState.deleteImagesAlertByFilter && selectedImages.length > ASYNC_IMAGE_DELETE_BY_ID_LIMIT;
const byFilterLimitExceeded =
alertState.deleteImagesAlertByFilter && imageCount > ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT;

const isSpinnerActive =
(deleteImagesTaskLoading.isLoading && deleteImagesTaskLoading.taskId) ||
(alertState.deleteImagesAlertByFilter && imageCountIsLoading.isLoading) ||
imagesLoading.isLoading;

const filterTitle = `Are you sure you'd like to delete ${imageCount === 1 ? 'this image' : `these ${imageCount && imageCount.toLocaleString()} images`}?`;
const selectionTitle = `Are you sure you'd like to delete ${selectedImages.length === 1 ? 'this image' : `these ${selectedImages && selectedImages.length.toLocaleString()} images`}?`;
const filterText = (
<div>
<p>
This will delete all images that match the currently applied filters. This action can not be
undone.
</p>
</div>
);
const selectionText = (
<div>
<p>This will delete all currently selected images. This action can not be undone.</p>
</div>
);

const deleteByIdLimitAlertTitle = 'Delete Limit Exceeded';
const deleteByIdLimitAlertText = (
<div>
<p>
You have selected {selectedImages.length.toLocaleString()} images, which is more than the{' '}
{ASYNC_IMAGE_DELETE_BY_ID_LIMIT.toLocaleString()} image limit Animl supports when deleting
individually-selected images.
</p>
{/*TODO: Add a link to the documentation for more information on how to delete images.*/}
<p>
Please select fewer images, or use the delete-by-filter option, which can accommodate
deleting up to {ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT.toLocaleString()} images at a time.
</p>
</div>
);

const deleteByFilterLimitAlertTitle = 'Delete Limit Exceeded';
const deleteByFilterLimitAlertText = (
<div>
<p>
There are {imageCount?.toLocaleString()} images that match the currently selected filters,
which is more than the {ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT.toLocaleString()} image limit
Animl supports when deleting images by filter.
</p>
<p>
To delete all of the currently matching images, you may need to apply additional filters to
stay within the limit and perform multiple separate deletion requests.
</p>
</div>
);

let title = alertState.deleteImagesAlertByFilter ? filterTitle : selectionTitle;
let text = alertState.deleteImagesAlertByFilter ? filterText : selectionText;
if (deleteByIdLimitExceeded) {
title = deleteByIdLimitAlertTitle;
text = deleteByIdLimitAlertText;
} else if (byFilterLimitExceeded) {
title = deleteByFilterLimitAlertTitle;
text = deleteByFilterLimitAlertText;
}

return (
<Alert open={alertState.deleteImagesAlertOpen}>
<AlertPortal>
<AlertOverlay />
<AlertContent>
{isSpinnerActive && (
<SpinnerOverlay>
<SimpleSpinner />
<ProgressBar
css={{ opacity: estimatedTotalTime !== null && elapsedTime !== null ? 1 : 0 }}
>
<ProgressRoot>
<ProgressIndicator
css={{
transform: `translateX(-${100 - (elapsedTime / estimatedTotalTime) * 100}%)`,
}}
/>
</ProgressRoot>
</ProgressBar>
</SpinnerOverlay>
)}
<AlertTitle>{title}</AlertTitle>
{text}
<div style={{ display: 'flex', gap: 25, justifyContent: 'flex-end' }}>
<Button size="small" css={{ border: 'none' }} onClick={handleCancelDelete}>
Cancel
</Button>
<Button
size="small"
disabled={deleteByIdLimitExceeded || byFilterLimitExceeded}
css={{
backgroundColor: red.red4,
color: red.red11,
border: 'none',
'&:hover': { color: red.red11, backgroundColor: red.red5 },
}}
onClick={handleConfirmDelete}
>
Yes, delete
</Button>
</div>
</AlertContent>
</AlertPortal>
</Alert>
);
};

export default DeleteImagesAlert;
53 changes: 41 additions & 12 deletions src/features/images/ImagesTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,23 @@ import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import AutoSizer from 'react-virtualized-auto-sizer';
import ImagesTableRow from './ImagesTableRow.jsx';
import { sortChanged, selectImagesLoading, selectPaginatedField, selectSortAscending } from './imagesSlice';
import { selectFocusIndex, selectFocusChangeType, selectSelectedImageIndices } from '../review/reviewSlice';
import {
sortChanged,
selectImagesLoading,
selectPaginatedField,
selectSortAscending,
} from './imagesSlice';
import {
selectFocusIndex,
selectFocusChangeType,
selectSelectedImageIndices,
} from '../review/reviewSlice';
import { selectLoupeOpen } from '../loupe/loupeSlice';
import { Image } from '../../components/Image';
import LabelPills from './LabelPills';
import { SimpleSpinner, SpinnerOverlay } from '../../components/Spinner';
import { selectProjectsLoading } from '../projects/projectsSlice';
import DeleteImagesAlert from '../loupe/DeleteImagesAlert.jsx';
import DeleteImagesAlert from './DeleteImagesAlert.jsx';
import { columnConfig, columnsToHideMap, defaultColumnDims, tableBreakpoints } from './config';

// TODO: make table horizontally scrollable on smaller screens
Expand Down Expand Up @@ -171,7 +180,9 @@ const StyledReviewIcon = styled('div', {
});

const ReviewedIcon = ({ reviewed }) => (
<StyledReviewIcon reviewed={reviewed}>{reviewed ? <CheckIcon /> : <Cross2Icon />}</StyledReviewIcon>
<StyledReviewIcon reviewed={reviewed}>
{reviewed ? <CheckIcon /> : <Cross2Icon />}
</StyledReviewIcon>
);

const ImagesTable = ({ workingImages, hasNext, loadNextPage }) => {
Expand Down Expand Up @@ -319,17 +330,29 @@ const ImagesTable = ({ workingImages, hasNext, loadNextPage }) => {
<SimpleSpinner />
</SpinnerOverlay>
)}
{imagesLoading.noneFound && <NoneFoundAlert>Rats! We couldn&apos;t find any matching images</NoneFoundAlert>}
{imagesLoading.noneFound && (
<NoneFoundAlert>Rats! We couldn&apos;t find any matching images</NoneFoundAlert>
)}
{workingImages.length > 0 && (
<Table {...getTableProps()}>
<div style={{ height: headerHeight, width: `calc(100% - ${scrollBarSize.width}px)` }}>
{headerGroups.map((headerGroup) => (
<TableRow {...headerGroup.getHeaderGroupProps()} key={headerGroup.getHeaderGroupProps().key}>
<TableRow
{...headerGroup.getHeaderGroupProps()}
key={headerGroup.getHeaderGroupProps().key}
>
{headerGroup.headers.map((column) => (
<HeaderCell {...column.getHeaderProps(column.getSortByToggleProps())} key={column.id}>
<TableHeader issorted={column.isSorted.toString()} cansort={column.canSort.toString()}>
<HeaderCell
{...column.getHeaderProps(column.getSortByToggleProps())}
key={column.id}
>
<TableHeader
issorted={column.isSorted.toString()}
cansort={column.canSort.toString()}
>
{column.render('Header')}
{column.canSort && (column.isSortedDesc ? <TriangleDownIcon /> : <TriangleUpIcon />)}
{column.canSort &&
(column.isSortedDesc ? <TriangleDownIcon /> : <TriangleUpIcon />)}
</TableHeader>
</HeaderCell>
))}
Expand All @@ -352,11 +375,17 @@ function makeRows(workingImages, focusIndex, selectedImageIndices) {

// label pills
const labelPills = (
<LabelPills objects={workingImages[imageIndex].objects} imageIndex={imageIndex} focusIndex={focusIndex} />
<LabelPills
objects={workingImages[imageIndex].objects}
imageIndex={imageIndex}
focusIndex={focusIndex}
/>
);

// date created
const dtOriginal = DateTime.fromISO(img.dateTimeOriginal).toLocaleString(DateTime.DATETIME_SHORT_WITH_SECONDS);
const dtOriginal = DateTime.fromISO(img.dateTimeOriginal).toLocaleString(
DateTime.DATETIME_SHORT_WITH_SECONDS,
);

// date added
const dtAdded = DateTime.fromISO(img.dateAdded).toLocaleString(DateTime.DATE_SHORT);
Expand All @@ -370,7 +399,7 @@ function makeRows(workingImages, focusIndex, selectedImageIndices) {
dtOriginal,
dtAdded,
reviewedIcon,
...img
...img,
};
});
}
Expand Down
Loading

0 comments on commit 7220aa6

Please sign in to comment.