diff --git a/src/api/buildQuery.js b/src/api/buildQuery.js index 1c01ef2c..9741d1d6 100644 --- a/src/api/buildQuery.js +++ b/src/api/buildQuery.js @@ -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!) { diff --git a/src/components/ErrorToast.jsx b/src/components/ErrorToast.jsx index f1983c79..a0853853 100644 --- a/src/components/ErrorToast.jsx +++ b/src/components/ErrorToast.jsx @@ -42,6 +42,8 @@ import { dismissDeploymentsError, selectCameraSerialNumberErrors, dismissCameraSerialNumberError, + selectDeleteImagesErrors, + dismissDeleteImagesError, } from '../features/tasks/tasksSlice'; import { selectRedriveBatchErrors, @@ -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'), @@ -98,6 +101,7 @@ const ErrorToast = () => { 'Error Updating Camera Serial Number', 'cameraSerialNumber', ), + enrichErrors(deleteImagesErrors, 'Error Deleting Images', 'deleteImages'), ]; const errors = enrichedErrors.reduce( @@ -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) { diff --git a/src/components/HydratedModal.jsx b/src/components/HydratedModal.jsx index b2389b09..13efd114 100644 --- a/src/components/HydratedModal.jsx +++ b/src/components/HydratedModal.jsx @@ -23,6 +23,7 @@ import { selectDeploymentsLoading, selectCameraSerialNumberLoading, clearCameraSerialNumberTask, + selectDeleteImagesLoading, } from '../features/tasks/tasksSlice.js'; import { selectModalOpen, @@ -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': { @@ -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 diff --git a/src/config.js b/src/config.js index 2b1f8982..700db5a8 100644 --- a/src/config.js +++ b/src/config.js @@ -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']; diff --git a/src/features/auth/roles.js b/src/features/auth/roles.js index 0ecaa74b..1b3b8420 100644 --- a/src/features/auth/roles.js +++ b/src/features/auth/roles.js @@ -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]; diff --git a/src/features/filters/FiltersPanelFooter.jsx b/src/features/filters/FiltersPanelFooter.jsx index d9acdff9..b1643736 100644 --- a/src/features/filters/FiltersPanelFooter.jsx +++ b/src/features/filters/FiltersPanelFooter.jsx @@ -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 { @@ -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%', @@ -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', @@ -115,7 +121,11 @@ const FiltersPanelFooter = () => { - handleModalToggle('stats-modal')}> + handleModalToggle('stats-modal')} + > @@ -126,21 +136,6 @@ const FiltersPanelFooter = () => { )} - {hasRole(userRoles, EXPORT_DATA_ROLES) && ( - - - - handleModalToggle('export-modal')}> - - - - - - Export data - - - - )} @@ -154,6 +149,9 @@ const FiltersPanelFooter = () => { + + + ); }; diff --git a/src/features/filters/FiltersPanelFooterDropdown.jsx b/src/features/filters/FiltersPanelFooterDropdown.jsx new file mode 100644 index 00000000..469e0b8b --- /dev/null +++ b/src/features/filters/FiltersPanelFooterDropdown.jsx @@ -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 { setDeleteImagesAlertStatus } 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(setDeleteImagesAlertStatus({ openStatus: true, deleteImagesByFilter: true })); + }; + + return ( + + + + + + {hasRole(userRoles, EXPORT_DATA_ROLES) && ( + props.handleModalToggle('export-modal')}> + Export filtered data + + )} + {hasRole(userRoles, DELETE_IMAGES_ROLES) && ( + + Delete filtered images + + )} + + + + ); +}; + +export default FiltersPanelFooterDropdown; diff --git a/src/features/images/ImagesTableRow.jsx b/src/features/images/ImagesTableRow.jsx index 495213b1..2ac5e002 100644 --- a/src/features/images/ImagesTableRow.jsx +++ b/src/features/images/ImagesTableRow.jsx @@ -3,8 +3,13 @@ import { useSelector, useDispatch } from 'react-redux'; 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 { setDeleteImagesAlertStatus } from './imagesSlice.js'; +import { + toggleOpenLoupe, + selectIsAddingLabel, + addLabelStart, + addLabelEnd, +} from '../loupe/loupeSlice.js'; import { setFocus, setSelectedImageIndices, @@ -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', { @@ -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 @@ -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, @@ -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); @@ -257,7 +281,7 @@ const ImagesTableRow = ({ row, index, focusIndex, style, selectedImageIndices }) }; const handleDeleteImagesMenuItemClick = () => { - dispatch(setDeleteImagesAlertOpen(true)); + dispatch(setDeleteImagesAlertStatus({ openStatus: true, deleteImagesByFilter: false })); }; return ( @@ -265,7 +289,11 @@ const ImagesTableRow = ({ row, index, focusIndex, style, selectedImageIndices }) {' '} {/* modal={false} is fix for pointer-events:none bug: https://github.com/radix-ui/primitives/issues/2416#issuecomment-1738294359 */} - handleRowClick(e, row.id)} selected={selected}> + handleRowClick(e, row.id)} + selected={selected} + > {row.cells.map((cell) => ( { state.loadingStates.images.isLoading = true; state.loadingStates.images.operation = 'deleting'; state.loadingStates.images.error = null; - state.deleteImagesAlertOpen = false; }, deleteImagesSuccess: (state) => { @@ -179,8 +185,15 @@ export const imagesSlice = createSlice({ state.loadingStates.images.errors = payload; }, - setDeleteImagesAlertOpen: (state, { payload }) => { - state.deleteImagesAlertOpen = payload; + setDeleteImagesAlertStatus: (state, { payload: { openStatus, deleteImagesByFilter } }) => { + if (openStatus) { + state.deleteImagesAlertState.deleteImagesAlertOpen = openStatus; + state.deleteImagesAlertState.deleteImagesAlertByFilter = deleteImagesByFilter; + } + else { + state.deleteImagesAlertState.deleteImagesAlertOpen = openStatus; + state.deleteImagesAlertState.deleteImagesAlertByFilter = null; + } }, }, }); @@ -205,7 +218,7 @@ export const { deleteImagesStart, deleteImagesSuccess, deleteImagesError, - setDeleteImagesAlertOpen, + setDeleteImagesAlertStatus, } = imagesSlice.actions; // fetchImages thunk @@ -337,9 +350,11 @@ export const deleteImages = (imageIds) => async (dispatch, getState) => { ); dispatch(setSelectedImageIndices([])); dispatch(deleteImagesSuccess(imageIds)); + dispatch(setDeleteImagesAlertStatus({ openStatus: false })); } catch (err) { console.log(`error attempting to delete image: `, err); dispatch(deleteImagesError(err)); + dispatch(setDeleteImagesAlertStatus({ openStatus: false })); } }; @@ -356,7 +371,7 @@ export const selectVisibleRows = (state) => state.images.visibleRows; export const selectPreFocusImage = (state) => state.images.preFocusImage; export const selectImageContextLoading = (state) => state.images.loadingStates.imageContext; export const selectImageContextErrors = (state) => state.images.loadingStates.imageContext.errors; -export const selectDeleteImagesAlertOpen = (state) => state.images.deleteImagesAlertOpen; +export const selectDeleteImagesAlertState = (state) => state.images.deleteImagesAlertState; // TODO: find a different place for this? export const selectRouterLocation = (state) => state.router.location; diff --git a/src/features/loupe/BoundingBox.jsx b/src/features/loupe/BoundingBox.jsx index 14a81d47..20653ea1 100644 --- a/src/features/loupe/BoundingBox.jsx +++ b/src/features/loupe/BoundingBox.jsx @@ -242,7 +242,6 @@ const BoundingBox = ({ imgId, imgDims, object, objectIndex, focusIndex, setTempO // focus to shift to the react-select category selector component. // see https://github.com/radix-ui/primitives/issues/1446 focusRef.current = catSelectorRef.current; - console.log('focusRef.current: ', focusRef.current); const newIndex = { image: focusIndex.image, object: objectIndex, label: null }; dispatch(setFocus({ index: newIndex, type: 'manual' })); dispatch(addLabelStart('from-object')); @@ -294,25 +293,27 @@ const BoundingBox = ({ imgId, imgDims, object, objectIndex, focusIndex, setTempO background: displayLabel?.color + '0D', }} > - {label && ( - 30 ? 'top' : 'bottom'} - horizontalPos={imgDims.width - left - width < 75 ? 'right' : 'left'} - ref={catSelectorRef} - isAuthorized={isAuthorized} - username={username} - /> - )} - + <> + {label && ( + 30 ? 'top' : 'bottom'} + horizontalPos={imgDims.width - left - width < 75 ? 'right' : 'left'} + ref={catSelectorRef} + isAuthorized={isAuthorized} + username={username} + /> + )} + + diff --git a/src/features/loupe/DeleteImagesAlert.jsx b/src/features/loupe/DeleteImagesAlert.jsx index 73202a2e..1edbf998 100644 --- a/src/features/loupe/DeleteImagesAlert.jsx +++ b/src/features/loupe/DeleteImagesAlert.jsx @@ -1,33 +1,82 @@ -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, + selectImagesCount, + selectImagesCountLoading, + selectDeleteImagesAlertState, + setDeleteImagesAlertStatus, + selectImagesLoading, +} from '../images/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 { + 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_DELETE_LIMIT } from '../../config.js'; +import { SimpleSpinner, SpinnerOverlay } from '../../components/Spinner.jsx'; const DeleteImagesAlert = () => { const dispatch = useDispatch(); - const open = useSelector(selectDeleteImagesAlertOpen); + 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 handleConfirmDelete = () => { - dispatch(deleteImages(selectedImageIds)); + if (alertState.deleteImagesAlertByFilter) { + dispatch(deleteImagesTask({ imageIds: [], filters: filters })); + } else { + if (selectedImages.length > IMAGE_DELETE_LIMIT) { + dispatch(deleteImagesTask({ imageIds: selectedImageIds, filters: null })); + } else { + dispatch(deleteImages(selectedImageIds)); + } + } }; const handleCancelDelete = () => { - dispatch(setDeleteImagesAlertOpen(false)); + dispatch(setDeleteImagesAlertStatus({ openStatus: false })); }; + const isSpinnerActive = + (deleteImagesTaskLoading.isLoading && deleteImagesTaskLoading.taskId) || + (alertState.deleteImagesAlertByFilter && imageCountIsLoading.isLoading) || + imagesLoading.isLoading; + + const filterText = `Are you sure you'd like to delete ${imageCount === 1 ? 'this image' : `these ${imageCount && imageCount.toLocaleString()} images`}?`; + const selectionText = `Are you sure you'd like to delete ${selectedImages.length === 1 ? 'this image' : `these ${selectedImages && selectedImages.length.toLocaleString()} images`}?`; return ( - + + {isSpinnerActive && ( + + + + )} - Are you sure you'd like to delete{' '} - {selectedImages.length > 1 ? `these ${selectedImages.length} images` : `this image`}? + {alertState.deleteImagesAlertByFilter ? filterText : selectionText}

This action can not be undone.

diff --git a/src/features/loupe/FullSizeImage.jsx b/src/features/loupe/FullSizeImage.jsx index 045c7bfb..6fe361cf 100644 --- a/src/features/loupe/FullSizeImage.jsx +++ b/src/features/loupe/FullSizeImage.jsx @@ -100,7 +100,11 @@ const FullSizeImage = ({ workingImages, image, focusIndex }) => { ); })} {isDrawingBbox && ( - + )} {/*{!imgLoaded && diff --git a/src/features/loupe/LoupeDropdown.jsx b/src/features/loupe/LoupeDropdown.jsx index c7ca0b8f..9265fcec 100644 --- a/src/features/loupe/LoupeDropdown.jsx +++ b/src/features/loupe/LoupeDropdown.jsx @@ -11,7 +11,7 @@ import { import IconButton from '../../components/IconButton.jsx'; import { DotsHorizontalIcon } from '@radix-ui/react-icons'; import DeleteImagesAlert from './DeleteImagesAlert.jsx'; -import { setDeleteImagesAlertOpen } from '../images/imagesSlice'; +import { setDeleteImagesAlertStatus } from '../images/imagesSlice'; const StyledDropdownMenuTrigger = styled(DropdownMenuTrigger, { position: 'absolute', @@ -23,7 +23,7 @@ const LoupeDropdown = ({ image }) => { const dispatch = useDispatch(); const handleDeleteImageItemClick = () => { - dispatch(setDeleteImagesAlertOpen(true)); + dispatch(setDeleteImagesAlertStatus({ openStatus: true, deleteImagesByFilter: false })); }; return ( diff --git a/src/features/review/reviewSlice.js b/src/features/review/reviewSlice.js index 5b95ca62..c8837514 100644 --- a/src/features/review/reviewSlice.js +++ b/src/features/review/reviewSlice.js @@ -207,7 +207,7 @@ export const reviewSlice = createSlice({ }) .addCase(deleteImagesSuccess, (state, { payload }) => { state.workingImages = state.workingImages.filter(({ _id }) => !payload.includes(_id)); - }); + }) }, }); diff --git a/src/features/tasks/tasksSlice.js b/src/features/tasks/tasksSlice.js index 211cca19..9762d7a7 100644 --- a/src/features/tasks/tasksSlice.js +++ b/src/features/tasks/tasksSlice.js @@ -9,6 +9,8 @@ import { setSelectedCamera, } from '../projects/projectsSlice'; import { toggleOpenLoupe } from '../loupe/loupeSlice'; +import { setFocus, setSelectedImageIndices } from '../review/reviewSlice.js'; +import { fetchImages, fetchImagesCount, setDeleteImagesAlertStatus } from '../images/imagesSlice.js'; const initialState = { loadingStates: { @@ -42,6 +44,11 @@ const initialState = { isLoading: false, errors: null, }, + deleteImages: { + taskId: null, + isLoading: false, + errors: null + }, }, imagesStats: null, annotationsExport: null, @@ -254,6 +261,43 @@ export const tasksSlice = createSlice({ state.loadingStates.cameraSerialNumber.taskId = null; state.loadingStates.cameraSerialNumber.errors.splice(index, 1); }, + + // delete images + + deleteImagesStart: (state) => { + let ls = state.loadingStates.deleteImages; + ls.taskId = null; + ls.isLoading = true; + ls.errors = null; + }, + + deleteImagesUpdate: (state, { payload }) => { + state.loadingStates.deleteImages.taskId = payload.taskId; + }, + + deleteImagesSuccess: (state) => { + let ls = state.loadingStates.deleteImages; + ls.taskId = null; + ls.isLoading = false; + ls.errors = null; + }, + + deleteImagesFailure: (state, { payload }) => { + let ls = state.loadingStates.deleteImages; + ls.isLoading = false; + ls.errors = [payload.task.output.error]; + }, + + clearDeleteImagesTask: (state) => { + state.loadingStates.deleteImages = initialState.loadingStates.deleteImages; + }, + + dismissDeleteImagesError: (state, { payload }) => { + const index = payload; + state.loadingStates.deleteImages.taskId = null; + state.loadingStates.deleteImages.errors.splice(index, 1); + }, + }, }); @@ -294,6 +338,13 @@ export const { updateCameraSerialNumberFailure, clearCameraSerialNumberTask, dismissCameraSerialNumberError, + + deleteImagesStart, + deleteImagesUpdate, + deleteImagesSuccess, + deleteImagesFailure, + clearDeleteImagesTask, + dismissDeleteImagesError, } = tasksSlice.actions; // fetchTask thunk @@ -305,7 +356,7 @@ export const fetchTask = (taskId) => { const projects = getState().projects.projects; const selectedProj = projects.find((proj) => proj.selected); - if (token && selectedProj) { + if (token && selectedProj && taskId) { const res = await call({ projId: selectedProj._id, request: 'getTask', @@ -359,6 +410,40 @@ export const fetchTask = (taskId) => { }, FAIL: (res) => dispatch(updateCameraSerialNumberFailure(res)), }, + DeleteImages: { + COMPLETE: (res) => { + const filters = getState().filters.activeFilters; + dispatch( + setFocus({ + index: { image: null, object: null, label: null }, + type: 'auto', + }), + ); + dispatch(setSelectedImageIndices([])); + dispatch(deleteImagesSuccess(res.task.output.imageIds)); + dispatch(setDeleteImagesAlertStatus({ openStatus: false })); + dispatch(fetchImages(filters)); + dispatch(fetchImagesCount(filters)); + }, + FAIL: (res) => dispatch(deleteImagesFailure(res)) + }, + DeleteImagesByFilter: { + COMPLETE: (res) => { + dispatch( + setFocus({ + index: { image: null, object: null, label: null }, + type: 'auto', + }), + ); + dispatch(setSelectedImageIndices([])); + dispatch(deleteImagesSuccess([])); + dispatch(setDeleteImagesAlertStatus({ openStatus: false })); + dispatch(fetchImages(res.task.output.filters)); + dispatch(fetchImagesCount(res.task.output.filters)); + }, + + FAIL: (res) => dispatch(deleteImagesFailure(res)) + } }; if (res.task.type.includes('Deployment')) { @@ -505,6 +590,45 @@ export const updateCameraSerialNumber = (payload) => { }; }; +// delete images thunk +export const deleteImagesTask = ({ imageIds = [], filters = null }) => { + /** + * Deletes images by either imageIds or by filters, one argument has to be populated and filters take precedence + * @param {Array} imageIds - array of image ids to delete + * @param {Object} filters - filters to delete images by + */ + return async (dispatch, getState) => { + try { + dispatch(deleteImagesStart()); + const currentUser = await Auth.currentAuthenticatedUser(); + const token = currentUser.getSignInUserSession().getIdToken().getJwtToken(); + const projects = getState().projects.projects; + const selectedProj = projects.find((proj) => proj.selected); + if (token && selectedProj) { + if (filters !== null) { + const res = await call({ + projId: selectedProj._id, + request: 'deleteImagesByFilterTask', + input: { filters }, + }); + dispatch(deleteImagesUpdate({ taskId: res.deleteImagesByFilterTask._id })); + } else { + const res = await call({ + projId: selectedProj._id, + request: 'deleteImagesTask', + input: { imageIds }, + }); + dispatch(deleteImagesUpdate({ taskId: res.deleteImagesTask._id })); + } + } + } + catch (err) { + dispatch(deleteImagesFailure(err)); + } + }; +}; + + export const selectImagesStats = (state) => state.tasks.imagesStats; export const selectStatsLoading = (state) => state.tasks.loadingStates.stats; export const selectStatsErrors = (state) => state.tasks.loadingStates.stats.errors; @@ -522,5 +646,7 @@ export const selectCameraSerialNumberLoading = (state) => state.tasks.loadingStates.cameraSerialNumber; export const selectCameraSerialNumberErrors = (state) => state.tasks.loadingStates.cameraSerialNumber.errors; +export const selectDeleteImagesLoading = (state) => state.tasks.loadingStates.deleteImages; +export const selectDeleteImagesErrors = (state) => state.tasks.loadingStates.deleteImages.errors; export default tasksSlice.reducer;