diff --git a/src/api/buildQuery.js b/src/api/buildQuery.js index 809afa9f..8c7f48f4 100644 --- a/src/api/buildQuery.js +++ b/src/api/buildQuery.js @@ -767,6 +767,17 @@ const queries = { variables: { input: input }, }), + deleteCameraConfig: (input) => ({ + template: ` + mutation deleteCameraConfig($input: DeleteCameraInput!) { + deleteCameraConfig(input: $input) { + _id + } + } + `, + variables: { input: input }, + }), + createUpload: (input) => ({ template: ` mutation CreateUpload($input: CreateUploadInput!) { diff --git a/src/components/ErrorToast.jsx b/src/components/ErrorToast.jsx index b450a27c..7a3448f0 100644 --- a/src/components/ErrorToast.jsx +++ b/src/components/ErrorToast.jsx @@ -23,7 +23,7 @@ import { selectManageLabelsErrors, dismissManageLabelsError, selectProjectTagErrors, - dismissProjectTagErrors + dismissProjectTagErrors, } from '../features/projects/projectsSlice'; import { selectWirelessCamerasErrors, @@ -46,6 +46,8 @@ import { dismissDeploymentsError, selectCameraSerialNumberErrors, dismissCameraSerialNumberError, + selectDeleteCameraErrors, + dismissDeleteCameraError, selectDeleteImagesErrors, dismissDeleteImagesError, } from '../features/tasks/tasksSlice'; @@ -81,6 +83,7 @@ const ErrorToast = () => { const manageLabelsErrors = useSelector(selectManageLabelsErrors); const uploadErrors = useSelector(selectUploadErrors); const cameraSerialNumberErrors = useSelector(selectCameraSerialNumberErrors); + const deleteCameraErrors = useSelector(selectDeleteCameraErrors); const projectTagErrors = useSelector(selectProjectTagErrors); const deleteImagesErrors = useSelector(selectDeleteImagesErrors); @@ -109,6 +112,7 @@ const ErrorToast = () => { 'Error Updating Camera Serial Number', 'cameraSerialNumber', ), + enrichErrors(deleteCameraErrors, 'Error Deleting Camera', 'deleteCamera'), enrichErrors(deleteImagesErrors, 'Error Deleting Images', 'deleteImages'), ]; @@ -175,6 +179,7 @@ const dismissErrorActions = { manageLabels: (i) => dismissManageLabelsError(i), upload: (i) => dismissUploadError(i), cameraSerialNumber: (i) => dismissCameraSerialNumberError(i), + deleteCamera: (i) => dismissDeleteCameraError(i), deleteImagesError: (i) => dismissDeleteImagesError(i), }; diff --git a/src/components/HydratedModal.jsx b/src/components/HydratedModal.jsx index 900a9c8c..8d9d9c06 100644 --- a/src/components/HydratedModal.jsx +++ b/src/components/HydratedModal.jsx @@ -32,7 +32,10 @@ import { setSelectedCamera, } from '../features/projects/projectsSlice'; import { clearUsers } from '../features/projects/usersSlice.js'; -import { ManageLabelsAndTagsModal, ManageLabelsAndTagsModalTitle } from '../features/projects/ManageTagsAndLabelsModal.jsx'; +import { + ManageLabelsAndTagsModal, + ManageLabelsAndTagsModalTitle, +} from '../features/projects/ManageTagsAndLabelsModal.jsx'; // Modal populated with content const HydratedModal = () => { @@ -55,7 +58,7 @@ const HydratedModal = () => { cameraSerialNumberLoading.isLoading || deleteImagesLoading.isLoading; - const [manageTagsAndLabelsTab, setManageTagsAndLabelsTab] = useState("labels"); + const [manageTagsAndLabelsTab, setManageTagsAndLabelsTab] = useState('labels'); const modalContentMap = { 'stats-modal': { @@ -121,14 +124,19 @@ const HydratedModal = () => { }, }, 'manage-tags-and-labels-form': { - title: , + title: ( + + ), size: 'md', - content: , + content: , callBackOnClose: () => { - setManageTagsAndLabelsTab("labels"); + setManageTagsAndLabelsTab('labels'); return true; }, - } + }, }; const handleModalToggle = (content) => { diff --git a/src/config.js b/src/config.js index bf680155..058c5929 100644 --- a/src/config.js +++ b/src/config.js @@ -17,7 +17,7 @@ export const API_URL = API_URLS[stage]; export const IMAGES_URL = IMAGES_URLS[stage]; export const IMAGE_QUERY_LIMITS = [10, 50, 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_ID_LIMIT = 3000; // 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']; diff --git a/src/features/cameras/CameraList.jsx b/src/features/cameras/CameraList.jsx index 0b2687c9..7805d599 100644 --- a/src/features/cameras/CameraList.jsx +++ b/src/features/cameras/CameraList.jsx @@ -16,7 +16,11 @@ import { TooltipTrigger, } from '../../components/Tooltip.jsx'; import { selectUserCurrentRoles } from '../auth/authSlice'; -import { unregisterCamera, registerCamera } from './wirelessCamerasSlice'; +import { + unregisterCamera, + registerCamera, + setDeleteCameraAlertStatus, +} from './wirelessCamerasSlice'; import { setModalContent, setSelectedCamera } from '../projects/projectsSlice.js'; import IconButton from '../../components/IconButton'; import { @@ -36,6 +40,7 @@ import { WRITE_CAMERA_SERIAL_NUMBER_ROLES, WRITE_DEPLOYMENTS_ROLES, } from '../auth/roles'; +import DeleteCameraAlert from './DeleteCameraAlert.jsx'; const StyledCameraList = styled('div', { border: '1px solid $border', @@ -176,6 +181,11 @@ const CameraList = ({ cameras, handleSaveDepClick, handleDeleteDepClick }) => { dispatch(setSelectedCamera(cameraId)); }; + const handleDeleteCameraClick = ({ cameraId }) => { + dispatch(setDeleteCameraAlertStatus({ isOpen: true })); + dispatch(setSelectedCamera(cameraId)); + }; + const [tooltipOpen, setTooltipOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(null); @@ -271,6 +281,19 @@ const CameraList = ({ cameras, handleSaveDepClick, handleDeleteDepClick }) => { Re-register camera )} + {hasRole(userRoles, WRITE_CAMERA_REGISTRATION_ROLES) && ( + { + e.stopPropagation; + handleDeleteCameraClick({ + cameraId: cam._id, + }); + }} + > + Delete camera + + )} @@ -352,6 +375,7 @@ const CameraList = ({ cameras, handleSaveDepClick, handleDeleteDepClick }) => { Deployment + )} diff --git a/src/features/cameras/DeleteCameraAlert.jsx b/src/features/cameras/DeleteCameraAlert.jsx new file mode 100644 index 00000000..ec487a65 --- /dev/null +++ b/src/features/cameras/DeleteCameraAlert.jsx @@ -0,0 +1,146 @@ +import React, { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { red } from '@radix-ui/colors'; +import { styled } from '../../theme/stitches.config.js'; +import Button from '../../components/Button.jsx'; +import { ButtonRow } from '../../components/Form.jsx'; +import { + clearDeleteCameraTask, + deleteCamera, + fetchTask, + selectDeleteCameraLoading, +} from '../tasks/tasksSlice.js'; +import { SimpleSpinner, SpinnerOverlay } from '../../components/Spinner.jsx'; +import { selectSelectedCamera, setSelectedCamera } from '../projects/projectsSlice.js'; +import { + fetchCameraImageCount, + selectCameraImageCount, + selectCameraImageCountLoading, + clearCameraImageCount, + selectDeleteCameraAlertStatus, + setDeleteCameraAlertStatus, +} from './wirelessCamerasSlice.js'; +import { ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT } from '../../config.js'; +import { + Alert, + AlertPortal, + AlertOverlay, + AlertContent, + AlertTitle, +} from '../../components/AlertDialog.jsx'; +import { DeleteImagesProgressBar } from '../images/DeleteImagesProgressBar.jsx'; + +const BoldText = styled('span', { + fontWeight: '$5', +}); + +const DeleteCameraAlert = () => { + const deleteCameraLoading = useSelector(selectDeleteCameraLoading); + const selectedCamera = useSelector(selectSelectedCamera); + const imageCount = useSelector(selectCameraImageCount); + const imageCountLoading = useSelector(selectCameraImageCountLoading); + const isAlertOpen = useSelector(selectDeleteCameraAlertStatus); + const dispatch = useDispatch(); + + useEffect(() => { + if (imageCount === null && selectedCamera !== null && !imageCountLoading) { + dispatch(fetchCameraImageCount({ cameraId: selectedCamera })); + } + }, [imageCount, selectedCamera, dispatch]); + + const handleDeleteCameraSubmit = () => { + dispatch(deleteCamera({ cameraId: selectedCamera })); + }; + + const handleCancelDelete = () => { + dispatch(setDeleteCameraAlertStatus({ isOpen: false })); + dispatch(setSelectedCamera(null)); + dispatch(clearDeleteCameraTask()); + dispatch(clearCameraImageCount()); + }; + + // handle polling for task completion + useEffect(() => { + const deleteCameraPending = deleteCameraLoading.isLoading && deleteCameraLoading.taskId; + if (deleteCameraPending) { + dispatch(fetchTask(deleteCameraLoading.taskId)); + } + }, [deleteCameraLoading, dispatch]); + + return ( + + + + + {(deleteCameraLoading.isLoading || imageCountLoading) && ( + + + + + )} + Delete Camera + {/*TODO: Add a link to the documentation for more information on how to delete images.*/} + {imageCount > ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT ? ( + <> + Due to the large number of images associated with this camera, we are unable to delete + Camera {selectedCamera} at this time. Please ensure that the + number of images associated with this camera do not exceed{' '} + {ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT} before trying again. We apologize for the + inconvenience. + + ) : ( + <> +

+ Are you sure you'd like to delete Camera {selectedCamera}?{' '} + {imageCount === 0 && 'This will remove the Camera and the Deployments '} + {imageCount > 0 && ( + <> + This will remove the Camera, its Deployments, and{' '} + {imageCount > 1 ? 'all' : 'the'}{' '} + + {imageCount} image{imageCount > 1 && 's'} + {' '} + + )} + associated with it from the Project. +

+

+ This action cannot be undone. +

+ + + + + + )} +
+
+
+ ); +}; + +export default DeleteCameraAlert; diff --git a/src/features/cameras/wirelessCamerasSlice.jsx b/src/features/cameras/wirelessCamerasSlice.js similarity index 61% rename from src/features/cameras/wirelessCamerasSlice.jsx rename to src/features/cameras/wirelessCamerasSlice.js index 32c6a80a..2cb8dc26 100644 --- a/src/features/cameras/wirelessCamerasSlice.jsx +++ b/src/features/cameras/wirelessCamerasSlice.js @@ -5,19 +5,24 @@ import { setSelectedProjAndView } from '../projects/projectsSlice'; const initialState = { wirelessCameras: [], + cameraImageCount: { + isLoading: false, + currentCameraId: null, + count: null, + }, loadingState: { isLoading: false, - operation: null, /* 'fetching', 'updating', 'deleting' */ + operation: null /* 'fetching', 'updating', 'deleting' */, errors: null, noneFound: false, }, + isDeleteCameraAlertOpen: false, }; export const wirelessCamerasSlice = createSlice({ name: 'wirelessCameras', initialState, reducers: { - getWirelessCamerasStart: (state) => { state.loadingState.isLoading = true; state.loadingState.operation = 'fetching'; @@ -35,7 +40,7 @@ export const wirelessCamerasSlice = createSlice({ isLoading: false, operation: null, errors: null, - noneFound: (payload.length === 0), + noneFound: payload.length === 0, }; }, @@ -58,13 +63,13 @@ export const wirelessCamerasSlice = createSlice({ isLoading: false, operation: null, errors: null, - noneFound: (payload.wirelessCameras.length === 0), + noneFound: payload.wirelessCameras.length === 0, }; // TODO: make the cameras update update more surgical? - // i.e. ONLY return the new/updated Camera source record and merge it - // into existing cameras (like we do with Views), and only return the - // new cameraConfig & merge that with Project.cameras array. - // Advantages: don't have to do getCameras() on backend before returning, + // i.e. ONLY return the new/updated Camera source record and merge it + // into existing cameras (like we do with Views), and only return the + // new cameraConfig & merge that with Project.cameras array. + // Advantages: don't have to do getCameras() on backend before returning, // less data in payload }, @@ -87,7 +92,7 @@ export const wirelessCamerasSlice = createSlice({ isLoading: false, operation: null, errors: null, - noneFound: (payload.wirelessCameras.length === 0), + noneFound: payload.wirelessCameras.length === 0, }; }, @@ -95,28 +100,54 @@ export const wirelessCamerasSlice = createSlice({ const index = payload; state.loadingState.errors.splice(index, 1); }, - + + /* Fetch image count for specific camera, so as to not clash with imageCount in imagesSlice */ + + cameraImageCountStart: (state, { payload }) => { + state.cameraImageCount.isLoading = true; + state.cameraImageCount.currentCameraId = payload.cameraId; + state.cameraImageCount.count = null; + }, + + cameraImageCountSuccess(state, { payload }) { + state.cameraImageCount.isLoading = false; + state.cameraImageCount.count = payload.imagesCount.count; + }, + + clearCameraImageCount(state) { + state.cameraImageCount.isLoading = false; + state.cameraImageCount.currentCameraId = null; + state.cameraImageCount.count = null; + }, + + cameraImageCountError(state) { + state.cameraImageCount.isLoading = false; + state.cameraImageCount.currentCameraId = null; + state.cameraImageCount.count = null; + }, + + setDeleteCameraAlertStatus: (state, { payload }) => { + state.isDeleteCameraAlertOpen = payload.isOpen; + }, }, extraReducers: (builder) => { - builder - .addCase(setSelectedProjAndView, (state, { payload }) => { - if (payload.newProjSelected) { - state.wirelessCameras = []; - state.loadingState = { - isLoading: false, - operation: null, - errors: null, - noneFound: null, - }; - } - }) + builder.addCase(setSelectedProjAndView, (state, { payload }) => { + if (payload.newProjSelected) { + state.wirelessCameras = []; + state.loadingState = { + isLoading: false, + operation: null, + errors: null, + noneFound: null, + }; + } + }); }, }); // export actions from slice export const { - getWirelessCamerasStart, getWirelessCamerasFailure, getWirelessCamerasSuccess, @@ -131,6 +162,12 @@ export const { dismissWirelessCamerasError, + cameraImageCountStart, + cameraImageCountSuccess, + clearCameraImageCount, + cameraImageCountError, + + setDeleteCameraAlertStatus, } = wirelessCamerasSlice.actions; // fetchWirelessCameras thunk @@ -169,9 +206,8 @@ export const registerCamera = (payload) => { request: 'registerCamera', input: payload, }); - dispatch(registerCameraSuccess(res.registerCamera)) + dispatch(registerCameraSuccess(res.registerCamera)); } - } catch (err) { console.log(`error(s) attempting to register camera: `, err); dispatch(registerCameraFailure(err)); @@ -179,7 +215,7 @@ export const registerCamera = (payload) => { }; }; -// unregisger camera thunk +// unregister camera thunk export const unregisterCamera = (payload) => async (dispatch, getState) => { try { dispatch(unregisterCameraStart()); @@ -192,7 +228,7 @@ export const unregisterCamera = (payload) => async (dispatch, getState) => { const res = await call({ projId: selectedProj._id, request: 'unregisterCamera', - input: payload + input: payload, }); dispatch(unregisterCameraSuccess(res.unregisterCamera)); } @@ -201,10 +237,37 @@ export const unregisterCamera = (payload) => async (dispatch, getState) => { } }; +// fetchCameraImageCount thunk +export const fetchCameraImageCount = (payload) => async (dispatch, getState) => { + try { + dispatch(cameraImageCountStart(payload)); + 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) { + const res = await call({ + projId: selectedProj._id, + request: 'getImagesCount', + input: { filters: { cameras: [payload.cameraId] } }, + }); + dispatch(cameraImageCountSuccess(res)); + } + } catch (err) { + console.log(`error(s) attempting to fetch image count for camera ${payload.cameraId}: `, err); + dispatch(cameraImageCountError(err)); + } +}; // Selectors -export const selectWirelessCameras = state => state.wirelessCameras.wirelessCameras; -export const selectWirelessCamerasLoading = state => state.wirelessCameras.loadingState; -export const selectWirelessCamerasErrors = state => state.wirelessCameras.loadingState.errors; +export const selectWirelessCameras = (state) => state.wirelessCameras.wirelessCameras; +export const selectWirelessCamerasLoading = (state) => state.wirelessCameras.loadingState; +export const selectWirelessCamerasErrors = (state) => state.wirelessCameras.loadingState.errors; +export const selectCameraImageCount = (state) => state.wirelessCameras.cameraImageCount.count; +export const selectCameraImageCountLoading = (state) => + state.wirelessCameras.cameraImageCount.isLoading; +export const selectDeleteCameraAlertStatus = (state) => + state.wirelessCameras.isDeleteCameraAlertOpen; export default wirelessCamerasSlice.reducer; diff --git a/src/features/images/DeleteImagesAlert.jsx b/src/features/images/DeleteImagesAlert.jsx index 2a3d3b91..e69fda23 100644 --- a/src/features/images/DeleteImagesAlert.jsx +++ b/src/features/images/DeleteImagesAlert.jsx @@ -1,6 +1,11 @@ -import React, { useState, useEffect } from 'react'; -import { styled } from '../../theme/stitches.config'; +import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { red } from '@radix-ui/colors'; +import { + SYNC_IMAGE_DELETE_LIMIT, + ASYNC_IMAGE_DELETE_BY_ID_LIMIT, + ASYNC_IMAGE_DELETE_BY_FILTER_LIMIT, +} from '../../config.js'; import { deleteImages, selectImagesCountLoading, @@ -19,43 +24,14 @@ import { 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'; + clearDeleteImagesTask, + deleteImagesTask, + fetchTask, + selectDeleteImagesLoading, +} from '../tasks/tasksSlice.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)', -}); +import { DeleteImagesProgressBar } from './DeleteImagesProgressBar.jsx'; const DeleteImagesAlert = () => { const dispatch = useDispatch(); @@ -76,9 +52,6 @@ const DeleteImagesAlert = () => { } }, [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 @@ -91,33 +64,11 @@ const DeleteImagesAlert = () => { 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 })); + dispatch(clearDeleteImagesTask()); }; const deleteByIdLimitExceeded = @@ -130,8 +81,14 @@ const DeleteImagesAlert = () => { (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 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 = (

@@ -195,17 +152,11 @@ const DeleteImagesAlert = () => { {isSpinnerActive && ( - - - - - + )} {title} diff --git a/src/features/images/DeleteImagesProgressBar.jsx b/src/features/images/DeleteImagesProgressBar.jsx new file mode 100644 index 00000000..fb7ea937 --- /dev/null +++ b/src/features/images/DeleteImagesProgressBar.jsx @@ -0,0 +1,75 @@ +import React, { useState, useEffect } from 'react'; +import * as Progress from '@radix-ui/react-progress'; +import { green } from '@radix-ui/colors'; +import { styled } from '../../theme/stitches.config.js'; + +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)', +}); + +// show progress bar if deleting more than 3000 images (approx wait time will be > 10 seconds) +const PROGRESS_BAR_IMAGE_COUNT_BREAKPOINT = 3000; + +export const DeleteImagesProgressBar = ({ imageCount }) => { + const [estimatedTotalTime, setEstimatedTotalTime] = useState( + imageCount > PROGRESS_BAR_IMAGE_COUNT_BREAKPOINT + ? imageCount * 0.0055 // estimated deletion time per image in seconds + : null, + ); // in seconds + const [elapsedTime, setElapsedTime] = useState( + imageCount > PROGRESS_BAR_IMAGE_COUNT_BREAKPOINT ? 0 : null, + ); + + 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]); + + return ( + + + + + + ); +}; diff --git a/src/features/images/imagesSlice.js b/src/features/images/imagesSlice.js index ea10446c..316fe88f 100644 --- a/src/features/images/imagesSlice.js +++ b/src/features/images/imagesSlice.js @@ -189,8 +189,7 @@ export const imagesSlice = createSlice({ if (openStatus) { state.deleteImagesAlertState.deleteImagesAlertOpen = openStatus; state.deleteImagesAlertState.deleteImagesAlertByFilter = deleteImagesByFilter; - } - else { + } else { state.deleteImagesAlertState.deleteImagesAlertOpen = openStatus; state.deleteImagesAlertState.deleteImagesAlertByFilter = null; } @@ -350,10 +349,10 @@ 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)); + } finally { dispatch(setDeleteImagesAlertStatus({ openStatus: false })); } }; diff --git a/src/features/tasks/tasksSlice.js b/src/features/tasks/tasksSlice.js index 9762d7a7..fa1060d8 100644 --- a/src/features/tasks/tasksSlice.js +++ b/src/features/tasks/tasksSlice.js @@ -10,7 +10,15 @@ import { } from '../projects/projectsSlice'; import { toggleOpenLoupe } from '../loupe/loupeSlice'; import { setFocus, setSelectedImageIndices } from '../review/reviewSlice.js'; -import { fetchImages, fetchImagesCount, setDeleteImagesAlertStatus } from '../images/imagesSlice.js'; +import { + fetchImages, + fetchImagesCount, + setDeleteImagesAlertStatus, +} from '../images/imagesSlice.js'; +import { + setDeleteCameraAlertStatus, + clearCameraImageCount, +} from '../cameras/wirelessCamerasSlice.js'; const initialState = { loadingStates: { @@ -44,10 +52,15 @@ const initialState = { isLoading: false, errors: null, }, + deleteCamera: { + taskId: null, + isLoading: false, + errors: null, + }, deleteImages: { taskId: null, isLoading: false, - errors: null + errors: null, }, }, imagesStats: null, @@ -262,6 +275,42 @@ export const tasksSlice = createSlice({ state.loadingStates.cameraSerialNumber.errors.splice(index, 1); }, + // delete camera + + updateDeleteCameraStart: (state) => { + let ls = state.loadingStates.deleteCamera; + ls.taskId = null; + ls.isLoading = true; + ls.errors = null; + }, + + updateDeleteCameraUpdate: (state, { payload }) => { + state.loadingStates.deleteCamera.taskId = payload.taskId; + }, + + updateDeleteCameraSuccess: (state) => { + let ls = state.loadingStates.deleteCamera; + ls.taskId = null; + ls.isLoading = false; + ls.errors = null; + }, + + updateDeleteCameraFailure: (state, { payload }) => { + let ls = state.loadingStates.deleteCamera; + ls.isLoading = false; + ls.errors = [payload.task.output.error]; + }, + + clearDeleteCameraTask: (state) => { + state.loadingStates.deleteCamera = initialState.loadingStates.deleteCamera; + }, + + dismissDeleteCameraError: (state, { payload }) => { + const index = payload; + state.loadingStates.deleteCamera.taskId = null; + state.loadingStates.deleteCamera.errors.splice(index, 1); + }, + // delete images deleteImagesStart: (state) => { @@ -297,7 +346,6 @@ export const tasksSlice = createSlice({ state.loadingStates.deleteImages.taskId = null; state.loadingStates.deleteImages.errors.splice(index, 1); }, - }, }); @@ -339,6 +387,13 @@ export const { clearCameraSerialNumberTask, dismissCameraSerialNumberError, + updateDeleteCameraStart, + updateDeleteCameraUpdate, + updateDeleteCameraSuccess, + updateDeleteCameraFailure, + clearDeleteCameraTask, + dismissDeleteCameraError, + deleteImagesStart, deleteImagesUpdate, deleteImagesSuccess, @@ -410,6 +465,19 @@ export const fetchTask = (taskId) => { }, FAIL: (res) => dispatch(updateCameraSerialNumberFailure(res)), }, + DeleteCamera: { + COMPLETE: () => { + dispatch(updateDeleteCameraSuccess()); + dispatch(toggleOpenLoupe(false)); + dispatch(setModalOpen(false)); + dispatch(setModalContent(null)); + dispatch(setSelectedCamera(null)); + dispatch(setDeleteCameraAlertStatus({ isOpen: false })); + dispatch(clearCameraImageCount()); + dispatch(fetchProjects({ _ids: [selectedProj._id] })); + }, + FAIL: (res) => dispatch(updateDeleteCameraFailure(res)), + }, DeleteImages: { COMPLETE: (res) => { const filters = getState().filters.activeFilters; @@ -425,7 +493,7 @@ export const fetchTask = (taskId) => { dispatch(fetchImages(filters)); dispatch(fetchImagesCount(filters)); }, - FAIL: (res) => dispatch(deleteImagesFailure(res)) + FAIL: (res) => dispatch(deleteImagesFailure(res)), }, DeleteImagesByFilter: { COMPLETE: (res) => { @@ -442,8 +510,8 @@ export const fetchTask = (taskId) => { dispatch(fetchImagesCount(res.task.output.filters)); }, - FAIL: (res) => dispatch(deleteImagesFailure(res)) - } + FAIL: (res) => dispatch(deleteImagesFailure(res)), + }, }; if (res.task.type.includes('Deployment')) { @@ -590,6 +658,29 @@ export const updateCameraSerialNumber = (payload) => { }; }; +export const deleteCamera = (payload) => { + return async (dispatch, getState) => { + try { + dispatch(updateDeleteCameraStart()); + 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) { + const res = await call({ + projId: selectedProj._id, + request: 'deleteCameraConfig', + input: payload, + }); + console.log('deleteCamera - res: ', res); + dispatch(updateDeleteCameraUpdate({ taskId: res.deleteCameraConfig._id })); + } + } catch (err) { + dispatch(updateDeleteCameraFailure(err)); + } + }; +}; + // delete images thunk export const deleteImagesTask = ({ imageIds = [], filters = null }) => { /** @@ -621,14 +712,12 @@ export const deleteImagesTask = ({ imageIds = [], filters = null }) => { dispatch(deleteImagesUpdate({ taskId: res.deleteImagesTask._id })); } } - } - catch (err) { + } 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; @@ -646,6 +735,8 @@ export const selectCameraSerialNumberLoading = (state) => state.tasks.loadingStates.cameraSerialNumber; export const selectCameraSerialNumberErrors = (state) => state.tasks.loadingStates.cameraSerialNumber.errors; +export const selectDeleteCameraLoading = (state) => state.tasks.loadingStates.deleteCamera; +export const selectDeleteCameraErrors = (state) => state.tasks.loadingStates.deleteCamera.errors; export const selectDeleteImagesLoading = (state) => state.tasks.loadingStates.deleteImages; export const selectDeleteImagesErrors = (state) => state.tasks.loadingStates.deleteImages.errors;