From f8e28545c32cd6edd0f320153c86263c72085140 Mon Sep 17 00:00:00 2001 From: Limber Mamani <154026979+LimberHope@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:36:02 -0400 Subject: [PATCH] [TM-1536] add delayed job progress alert (#716) * [TM-1536] add delayed job progress alert * [TM-1536] add disabled and styles to check and fix buttons * [TM-1536] add abort signal to delayed job progress alert * [TM-1536] add default value to progress message --- .../Alerts/DelayedJobsProgressAlert.tsx | 80 +++++++++++++++++++ .../ResourceTabs/PolygonReviewTab/index.tsx | 6 ++ .../modules/sites/components/SiteShow.tsx | 19 ++++- src/components/elements/Map-mapbox/Map.tsx | 21 ++++- .../MapControls/CheckPolygonControl.tsx | 57 ++++++++++--- .../ProcessBulkPolygonsControl.tsx | 70 +++++++++++++--- src/generated/apiFetcher.ts | 11 ++- src/store/apiSlice.ts | 47 ++++++++++- 8 files changed, 287 insertions(+), 24 deletions(-) create mode 100644 src/admin/components/Alerts/DelayedJobsProgressAlert.tsx diff --git a/src/admin/components/Alerts/DelayedJobsProgressAlert.tsx b/src/admin/components/Alerts/DelayedJobsProgressAlert.tsx new file mode 100644 index 000000000..1155ca212 --- /dev/null +++ b/src/admin/components/Alerts/DelayedJobsProgressAlert.tsx @@ -0,0 +1,80 @@ +import { Alert, AlertTitle, CircularProgress } from "@mui/material"; +import { FC, useEffect, useState } from "react"; +import { useStore } from "react-redux"; + +import ApiSlice from "@/store/apiSlice"; +import { AppStore } from "@/store/store"; + +type DelayedJobsProgressAlertProps = { + show: boolean; + title?: string; + setIsLoadingDelayedJob?: (value: boolean) => void; +}; + +const DelayedJobsProgressAlert: FC = ({ show, title, setIsLoadingDelayedJob }) => { + const [delayedJobProcessing, setDelayedJobProcessing] = useState(0); + const [delayedJobTotal, setDalayedJobTotal] = useState(0); + const [proccessMessage, setProccessMessage] = useState("Running 0 out of 0 polygons (0%)"); + + const store = useStore(); + useEffect(() => { + let intervalId: any; + if (show) { + intervalId = setInterval(() => { + const { total_content, processed_content, proccess_message } = store.getState().api; + setDalayedJobTotal(total_content); + setDelayedJobProcessing(processed_content); + if (proccess_message != "") { + setProccessMessage(proccess_message); + } + }, 1000); + } + + return () => { + if (intervalId) { + setDelayedJobProcessing(0); + setDalayedJobTotal(0); + setProccessMessage("Running 0 out of 0 polygons (0%)"); + clearInterval(intervalId); + } + }; + }, [show]); + + const abortDelayedJob = () => { + ApiSlice.abortDelayedJob(true); + ApiSlice.addTotalContent(0); + ApiSlice.addProgressContent(0); + ApiSlice.addProgressMessage("Running 0 out of 0 polygons (0%)"); + setDelayedJobProcessing(0); + setDalayedJobTotal(0); + setIsLoadingDelayedJob?.(false); + }; + + if (!show) return null; + + const calculatedProgress = delayedJobTotal! > 0 ? Math.round((delayedJobProcessing! / delayedJobTotal!) * 100) : 0; + + const severity = calculatedProgress >= 75 ? "success" : calculatedProgress >= 50 ? "info" : "warning"; + + return ( +
+ } + action={ + + } + > + {title} + {proccessMessage ?? "Running 0 out of 0 polygons (0%)"} + +
+ ); +}; + +export default DelayedJobsProgressAlert; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx index cd5af5fa4..624580bbc 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx @@ -61,6 +61,9 @@ import SitePolygonStatus from "./components/SitePolygonStatus/SitePolygonStatus" interface IProps extends Omit { type: EntityName; label: string; + setIsLoadingDelayedJob?: (isLoading: boolean) => void; + isLoadingDelayedJob?: boolean; + setAlertTitle?: (value: string) => void; } export interface IPolygonItem { id: string; @@ -656,6 +659,9 @@ const PolygonReviewTab: FC = props => { tooltipType="edit" sitePolygonData={sitePolygonData} modelFilesData={modelFilesData?.data} + setIsLoadingDelayedJob={props.setIsLoadingDelayedJob} + isLoadingDelayedJob={props.isLoadingDelayedJob} + setAlertTitle={props.setAlertTitle} />
diff --git a/src/admin/modules/sites/components/SiteShow.tsx b/src/admin/modules/sites/components/SiteShow.tsx index 216c5e366..7ff86d4f9 100644 --- a/src/admin/modules/sites/components/SiteShow.tsx +++ b/src/admin/modules/sites/components/SiteShow.tsx @@ -1,7 +1,8 @@ -import { FC } from "react"; +import { FC, useState } from "react"; import { Show, TabbedShowLayout } from "react-admin"; import ShowActions from "@/admin/components/Actions/ShowActions"; +import DelayedJobsProgressAlert from "@/admin/components/Alerts/DelayedJobsProgressAlert"; import AuditLogTab from "@/admin/components/ResourceTabs/AuditLogTab/AuditLogTab"; import { AuditLogButtonStates } from "@/admin/components/ResourceTabs/AuditLogTab/constants/enum"; import ChangeRequestsTab from "@/admin/components/ResourceTabs/ChangeRequestsTab/ChangeRequestsTab"; @@ -14,6 +15,9 @@ import { RecordFrameworkProvider } from "@/context/framework.provider"; import { MapAreaProvider } from "@/context/mapArea.provider"; const SiteShow: FC = () => { + const [isLoadingDelayedJob, setIsLoadingDelayedJob] = useState(false); + const [alertTitle, setAlertTitle] = useState(""); + return ( record?.name} />} @@ -25,7 +29,13 @@ const SiteShow: FC = () => { - + @@ -35,6 +45,11 @@ const SiteShow: FC = () => { + ); }; diff --git a/src/components/elements/Map-mapbox/Map.tsx b/src/components/elements/Map-mapbox/Map.tsx index 03ad67a12..2ec1d6580 100644 --- a/src/components/elements/Map-mapbox/Map.tsx +++ b/src/components/elements/Map-mapbox/Map.tsx @@ -122,6 +122,9 @@ interface MapProps extends Omit role?: any; selectedCountry?: string | null; setLoader?: (value: boolean) => void; + setIsLoadingDelayedJob?: (value: boolean) => void; + isLoadingDelayedJob?: boolean; + setAlertTitle?: (value: string) => void; showViewGallery?: boolean; legendPosition?: ControlMapPosition; } @@ -158,6 +161,9 @@ export const MapContainer = ({ centroids, listViewProjects, showImagesButton, + setIsLoadingDelayedJob, + isLoadingDelayedJob, + setAlertTitle, showViewGallery = true, legendPosition, ...props @@ -548,7 +554,12 @@ export const MapContainer = ({ - + @@ -561,7 +572,13 @@ export const MapContainer = ({ - + diff --git a/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx b/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx index 52d69c192..3c824d575 100644 --- a/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx +++ b/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx @@ -22,6 +22,7 @@ import { usePostV2TerrafundValidationSitePolygons } from "@/generated/apiComponents"; import { ClippedPolygonResponse, SitePolygon } from "@/generated/apiSchemas"; +import ApiSlice from "@/store/apiSlice"; import Log from "@/utils/log"; import Button from "../../Button/Button"; @@ -32,6 +33,9 @@ export interface CheckSitePolygonProps { uuid: string; }; polygonCheck: boolean; + setIsLoadingDelayedJob?: (isLoading: boolean) => void; + isLoadingDelayedJob?: boolean; + setAlertTitle?: (value: string) => void; } interface CheckedPolygon { @@ -50,7 +54,7 @@ interface TransformedData { } const CheckPolygonControl = (props: CheckSitePolygonProps) => { - const { siteRecord, polygonCheck } = props; + const { siteRecord, polygonCheck, setIsLoadingDelayedJob, isLoadingDelayedJob, setAlertTitle } = props; const siteUuid = siteRecord?.uuid; const [openCollapse, setOpenCollapse] = useState(false); const [sitePolygonCheckData, setSitePolygonCheckData] = useState([]); @@ -59,7 +63,7 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => { const context = useSitePolygonData(); const sitePolygonData = context?.sitePolygonData; const sitePolygonRefresh = context?.reloadSiteData; - const { showLoader, hideLoader } = useLoading(); + const { hideLoader } = useLoading(); const { setShouldRefetchValidation, setShouldRefetchPolygonData, setSelectedPolygonsInCheckbox } = useMapAreaContext(); const { openModal, closeModal } = useModalContext(); @@ -92,11 +96,29 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => { "success", t("Success! TerraMatch reviewed all polygons") ); + setIsLoadingDelayedJob?.(false); + ApiSlice.addTotalContent(0); + ApiSlice.addProgressContent(0); + ApiSlice.addProgressMessage(""); }, onError: () => { hideLoader(); + setIsLoadingDelayedJob?.(false); setClickedValidation(false); - displayNotification(t("Please try again later."), "error", t("Error! TerraMatch could not review polygons")); + if (ApiSlice.apiDataStore.abort_delayed_job) { + displayNotification( + t("The Check Polygons processing was cancelled."), + "warning", + t("You can try again later.") + ); + + ApiSlice.abortDelayedJob(false); + ApiSlice.addTotalContent(0); + ApiSlice.addProgressContent(0); + ApiSlice.addProgressMessage(""); + } else { + displayNotification(t("Please try again later."), "error", t("Error! TerraMatch could not review polygons")); + } } }); @@ -105,6 +127,7 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => { if (!data.updated_polygons?.length) { openNotification("warning", t("No polygon have been fixed"), t("Please run 'Check Polygons' again.")); hideLoader(); + setIsLoadingDelayedJob?.(false); closeModal(ModalId.FIX_POLYGONS); return; } @@ -118,13 +141,23 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => { .join(", "); openNotification("success", t("Success! The following polygons have been fixed:"), updatedPolygonNames); hideLoader(); + setIsLoadingDelayedJob?.(false); } closeModal(ModalId.FIX_POLYGONS); }, onError: error => { - Log.error("Error clipping polygons:", error); - displayNotification(t("An error occurred while fixing polygons. Please try again."), "error", t("Error")); + if (ApiSlice.apiDataStore.abort_delayed_job) { + displayNotification(t("The Fix Polygons processing was cancelled."), "warning", t("You can try again later.")); + ApiSlice.abortDelayedJob(false); + ApiSlice.addTotalContent(0); + ApiSlice.addProgressContent(0); + ApiSlice.addProgressMessage(""); + } else { + Log.error("Error clipping polygons:", error); + displayNotification(t("An error occurred while fixing polygons. Please try again."), "error", t("Error")); + } hideLoader(); + setIsLoadingDelayedJob?.(false); } }); @@ -167,7 +200,8 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => { const runFixPolygonOverlaps = () => { if (siteUuid) { - showLoader(); + setIsLoadingDelayedJob?.(true); + setAlertTitle?.("Fix Polygons"); clipPolygons({ pathParams: { uuid: siteUuid } }); } else { displayNotification(t("Cannot fix polygons: Site UUID is missing."), "error", t("Error")); @@ -216,7 +250,8 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => { useEffect(() => { if (clickedValidation) { - showLoader(); + setIsLoadingDelayedJob?.(true); + setAlertTitle?.("Check Polygons"); getValidations({ queryParams: { uuid: siteUuid ?? "" } }); } }, [clickedValidation]); @@ -226,20 +261,24 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => {
diff --git a/src/components/elements/Map-mapbox/MapControls/ProcessBulkPolygonsControl.tsx b/src/components/elements/Map-mapbox/MapControls/ProcessBulkPolygonsControl.tsx index 7aa998d04..a5853f7a9 100644 --- a/src/components/elements/Map-mapbox/MapControls/ProcessBulkPolygonsControl.tsx +++ b/src/components/elements/Map-mapbox/MapControls/ProcessBulkPolygonsControl.tsx @@ -16,8 +16,19 @@ import { usePostV2TerrafundValidationPolygons } from "@/generated/apiComponents"; import { SitePolygon } from "@/generated/apiSchemas"; +import ApiSlice from "@/store/apiSlice"; -const ProcessBulkPolygonsControl = ({ entityData }: { entityData: any }) => { +const ProcessBulkPolygonsControl = ({ + entityData, + setIsLoadingDelayedJob, + isLoadingDelayedJob, + setAlertTitle +}: { + entityData: any; + setIsLoadingDelayedJob?: (value: boolean) => void; + isLoadingDelayedJob?: boolean; + setAlertTitle?: (value: string) => void; +}) => { const t = useT(); const { openModal, closeModal } = useModalContext(); const context = useSitePolygonData(); @@ -100,7 +111,8 @@ const ProcessBulkPolygonsControl = ({ entityData }: { entityData: any }) => { className: "px-8 py-3", variant: "primary", onClick: () => { - showLoader(); + setIsLoadingDelayedJob?.(true); + setAlertTitle?.("Fix Polygons"); fixPolygons( { body: { @@ -111,6 +123,10 @@ const ProcessBulkPolygonsControl = ({ entityData }: { entityData: any }) => { onSuccess: response => { const processedNames = response?.processed?.map(item => item.poly_name).join(", "); closeModal(ModalId.FIX_POLYGONS); + setIsLoadingDelayedJob?.(false); + ApiSlice.addTotalContent(0); + ApiSlice.addProgressContent(0); + ApiSlice.addProgressMessage(""); if (processedNames) { openNotification( "success", @@ -121,11 +137,23 @@ const ProcessBulkPolygonsControl = ({ entityData }: { entityData: any }) => { openNotification("warning", t("Warning"), t("No polygons were fixed.")); } refetchData?.(); - hideLoader(); }, onError: () => { hideLoader(); - openNotification("error", t("Error!"), t("Failed to fix polygons")); + setIsLoadingDelayedJob?.(false); + if (ApiSlice.apiDataStore.abort_delayed_job) { + openNotification( + "warning", + t("The Fix Polygons processing was cancelled."), + t("You can try again later.") + ); + ApiSlice.abortDelayedJob(false); + ApiSlice.addTotalContent(0); + ApiSlice.addProgressContent(0); + ApiSlice.addProgressMessage(""); + } else { + openNotification("error", t("Error!"), t("Failed to fix polygons")); + } } } ); @@ -153,10 +181,27 @@ const ProcessBulkPolygonsControl = ({ entityData }: { entityData: any }) => { refetchData?.(); openNotification("success", t("Success!"), t("Polygons checked successfully")); hideLoader(); + setIsLoadingDelayedJob?.(false); + ApiSlice.addTotalContent(0); + ApiSlice.addProgressContent(0); + ApiSlice.addProgressMessage(""); }, onError: () => { hideLoader(); - openNotification("error", t("Error!"), t("Failed to check polygons")); + setIsLoadingDelayedJob?.(false); + if (ApiSlice.apiDataStore.abort_delayed_job) { + openNotification( + "warning", + t("The Check Polygons processing was cancelled."), + t("You can try again later.") + ); + ApiSlice.abortDelayedJob(false); + ApiSlice.addTotalContent(0); + ApiSlice.addProgressContent(0); + ApiSlice.addProgressMessage(""); + } else { + openNotification("error", t("Error!"), t("Failed to check polygons")); + } } } ); @@ -170,7 +215,8 @@ const ProcessBulkPolygonsControl = ({ entityData }: { entityData: any }) => { .filter((_, index) => initialSelection[index]) .map((polygon: SitePolygon) => polygon.poly_id || ""); if (type === "check") { - showLoader(); + setIsLoadingDelayedJob?.(true); + setAlertTitle?.("Check Polygons"); runCheckPolygonsSelected(selectedUUIDs); } else if (type === "fix") { openFormModalHandlerSubmitPolygon(selectedUUIDs); @@ -188,22 +234,28 @@ const ProcessBulkPolygonsControl = ({ entityData }: { entityData: any }) => {
diff --git a/src/generated/apiFetcher.ts b/src/generated/apiFetcher.ts index 182d5cd84..3dcd9484f 100644 --- a/src/generated/apiFetcher.ts +++ b/src/generated/apiFetcher.ts @@ -4,6 +4,7 @@ import FormData from "form-data"; import Log from "@/utils/log"; import { resolveUrl as resolveV3Url } from "./v3/utils"; import { apiBaseUrl } from "@/constants/environment"; +import ApiSlice from "@/store/apiSlice"; const baseUrl = `${apiBaseUrl}/api`; @@ -191,7 +192,15 @@ async function processDelayedJob(signal: AbortSignal | undefined, delayed jobResult.data?.attributes?.status === "pending"; jobResult = await loadJob(signal, delayedJobId) ) { - if (signal?.aborted) throw new Error("Aborted"); + //@ts-ignore + const { total_content, processed_content, proccess_message } = jobResult.data?.attributes; + if (total_content != null) { + ApiSlice.addTotalContent(total_content); + ApiSlice.addProgressContent(processed_content); + ApiSlice.addProgressMessage(proccess_message); + } + + if (signal?.aborted || ApiSlice.apiDataStore.abort_delayed_job) throw new Error("Aborted"); await new Promise(resolve => setTimeout(resolve, JOB_POLL_TIMEOUT)); } diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index ea5c9a13a..7f15b433a 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -81,6 +81,10 @@ export type ApiDataStore = ApiResources & { /** Is snatched and stored by middleware when a users/me request completes. */ meUserId?: string; }; + total_content: number; + processed_content: number; + proccess_message: string; + abort_delayed_job: boolean; }; export const INITIAL_STATE = { @@ -94,7 +98,11 @@ export const INITIAL_STATE = { acc[method] = {}; return acc; }, {}) as ApiPendingStore - } + }, + total_content: 0, + processed_content: 0, + proccess_message: "", + abort_delayed_job: false } as ApiDataStore; type ApiFetchStartingProps = { @@ -184,6 +192,22 @@ export const apiSlice = createSlice({ // so we can safely fake a login into the store when we have an authToken already set in a // cookie on app bootup. state.logins["1"] = { attributes: { token: authToken } }; + }, + + setTotalContent: (state, action: PayloadAction) => { + state.total_content = action.payload; + }, + + setProgressContent: (state, action: PayloadAction) => { + state.processed_content = action.payload; + }, + + setAbortDelayedJob: (state, action: PayloadAction) => { + state.abort_delayed_job = action.payload; + }, + + setProccessMessage: (state, action: PayloadAction) => { + state.proccess_message = action.payload; } }, @@ -211,6 +235,11 @@ export const apiSlice = createSlice({ if (payloadState.meta.meUserId != null) { state.meta.meUserId = payloadState.meta.meUserId; } + + state.total_content = payloadState.total_content ?? state.total_content; + state.processed_content = payloadState.processed_content ?? state.processed_content; + state.proccess_message = payloadState.proccess_message ?? state.proccess_message; + state.abort_delayed_job = payloadState.abort_delayed_job ?? state.abort_delayed_job; }); } }); @@ -261,4 +290,20 @@ export default class ApiSlice { static clearApiCache() { this.redux.dispatch(apiSlice.actions.clearApiCache()); } + + static addTotalContent(total_content: number) { + this.redux.dispatch(apiSlice.actions.setTotalContent(total_content)); + } + + static addProgressContent(processed_content: number) { + this.redux.dispatch(apiSlice.actions.setProgressContent(processed_content)); + } + + static addProgressMessage(proccess_message: string) { + this.redux.dispatch(apiSlice.actions.setProccessMessage(proccess_message)); + } + + static abortDelayedJob(abort_delayed_job: boolean) { + this.redux.dispatch(apiSlice.actions.setAbortDelayedJob(abort_delayed_job)); + } }