diff --git a/src/videoag/authentication/ViewPermissions.tsx b/src/videoag/authentication/ViewPermissions.tsx index e572693864ab9a639847598ad0bc92e312c7ed9f..dca98f621506c00b55056a500e179ee612414b95 100644 --- a/src/videoag/authentication/ViewPermissions.tsx +++ b/src/videoag/authentication/ViewPermissions.tsx @@ -9,7 +9,7 @@ import { showError, showErrorToast } from "@/videoag/error/ErrorDisplay"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; import { useReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; import { StylizedText } from "@/videoag/miscellaneous/StylizedText"; -import { useDebounceUpdateData } from "@/videoag/miscellaneous/PromiseHelpers"; +import { useDebouncedCall } from "@/videoag/miscellaneous/PromiseHelpers"; export function AuthenticationMethodIcons({ authentication_methods, @@ -163,8 +163,6 @@ function PasswordAuthComponent({ course, lecture }: { course: course; lecture: l ); } -class DummyObject {} - function OAuthComponent({ type, course, @@ -193,54 +191,51 @@ function OAuthComponent({ }; }, []); - // useDebounceUpdateData is not quite intended for this, but works perfectly in ensuring we only do one fetch at a time - const [fetchStatusDeferred, fetchStatusNow] = useDebounceUpdateData<DummyObject, void>( - (dummy) => - api - .getAuthenticationStatus({ lecture_id: lecture.id }) - .then((res) => { - if (res.is_lecture_authenticated) { - setIsOAuthRunning(false); - setWaitingForFinish(false); - authStatus.addAuthenticatedMethod(type); - authStatus.setCurrentlyAuthenticatedLectureId(lecture.id); - } else if (res.in_progress_authentication !== type) { + const [fetchStatusDeferred, fetchStatusNow] = useDebouncedCall<void>(1000, () => + api + .getAuthenticationStatus({ lecture_id: lecture.id }) + .then((res) => { + if (res.is_lecture_authenticated) { + setIsOAuthRunning(false); + setWaitingForFinish(false); + authStatus.addAuthenticatedMethod(type); + authStatus.setCurrentlyAuthenticatedLectureId(lecture.id); + } else if (res.in_progress_authentication !== type) { + setIsOAuthRunning(false); + setWaitingForFinish(false); + setOauthInfo( + <span className="text-warning"> + {language.get("ui.video_player.login_oauth.finished_no_access")} + </span>, + ); + } else { + if (remainingFinishAttempts.current > 0) { + remainingFinishAttempts.current--; + fetchStatusDeferred(); + } else if (popupClosed.current) { setIsOAuthRunning(false); setWaitingForFinish(false); setOauthInfo( <span className="text-warning"> - {language.get("ui.video_player.login_oauth.finished_no_access")} + {language.get( + "ui.video_player.login_oauth.unfinished_popup_closed", + )} </span>, ); - } else { - if (remainingFinishAttempts.current > 0) { - remainingFinishAttempts.current--; - fetchStatusDeferred(new DummyObject()); - } else if (popupClosed.current) { - setIsOAuthRunning(false); - setWaitingForFinish(false); - setOauthInfo( - <span className="text-warning"> - {language.get( - "ui.video_player.login_oauth.unfinished_popup_closed", - )} - </span>, - ); - } else if (remainingFinishAttempts.current <= 0) { - console.log("Stopping automatic authentication status fetching"); - } + } else if (remainingFinishAttempts.current <= 0) { + console.log("Stopping automatic authentication status fetching"); } - }) - .catch((e) => { - showError(e, language.get("ui.video_player.login_oauth.error_finish")); - remainingFinishAttempts.current = 0; - if (popupClosed.current) { - setIsOAuthRunning(false); - setWaitingForFinish(false); - setOauthInfo(undefined); - } - }), - 1000, + } + }) + .catch((e) => { + showError(e, language.get("ui.video_player.login_oauth.error_finish")); + remainingFinishAttempts.current = 0; + if (popupClosed.current) { + setIsOAuthRunning(false); + setWaitingForFinish(false); + setOauthInfo(undefined); + } + }), ); const startFinishListener = (popup: any) => { @@ -248,13 +243,13 @@ function OAuthComponent({ setOauthInfo(undefined); popupClosed.current = false; remainingFinishAttempts.current = 120; - fetchStatusDeferred(new DummyObject()); + fetchStatusDeferred(); let popInterval = setInterval(() => { if (popup.closed) { remainingFinishAttempts.current = 1; popupClosed.current = true; - fetchStatusNow(new DummyObject()); + fetchStatusNow(); clearInterval(popInterval); } }, 100); diff --git a/src/videoag/job/Job.tsx b/src/videoag/job/Job.tsx index 880651c11ded7bf5d4c72a99a7b9df9944b8c869..fb9f08ae102cd440fea815db9201a8ace0017634 100644 --- a/src/videoag/job/Job.tsx +++ b/src/videoag/job/Job.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useRef } from "react"; -import type { int, job } from "@/videoag/api/types"; +import type { int, job, job_state } from "@/videoag/api/types"; import { useApi } from "@/videoag/api/ApiProvider"; import { datetimeToString } from "@/videoag/miscellaneous/Formatting"; import { Spinner } from "@/videoag/miscellaneous/Util"; +import { useDebouncedCall } from "@/videoag/miscellaneous/PromiseHelpers"; export function JobStatusCard({ jobId }: { jobId: int }) { const api = useApi(); @@ -13,10 +14,16 @@ export function JobStatusCard({ jobId }: { jobId: int }) { const [errorMsg, setErrorMsg] = useState<string | undefined>(undefined); const doAutomaticReload = useRef<boolean>(false); - const reloadStatus = () => { - if (!doAutomaticReload.current) return; + const canJobStatusChange = (state: job_state) => + ["ready", "spawning", "running"].includes(state); + + const [reloadStatusDeferred, reloadStatusNow] = useDebouncedCall(2000, () => { + if (!doAutomaticReload.current) { + return Promise.resolve(); + } setIsLoading(true); - api.getJobStatus({ job_ids: [jobId] }) + return api + .getJobStatus({ job_ids: [jobId] }) .then((res) => { const updateData = res.jobs[jobId.toString()]; setJob((old) => ({ @@ -24,11 +31,8 @@ export function JobStatusCard({ jobId }: { jobId: int }) { ...updateData, })); setErrorMsg(undefined); - if ( - doAutomaticReload.current && - ["ready", "spawning", "running"].includes(updateData.state) - ) - setTimeout(reloadStatus, 2000); + if (doAutomaticReload.current && canJobStatusChange(updateData.state)) + reloadStatusDeferred(); }) .catch((e) => { setErrorMsg("Unable to update status: " + e.toString()); @@ -36,7 +40,7 @@ export function JobStatusCard({ jobId }: { jobId: int }) { .finally(() => { setIsLoading(false); }); - }; + }); useEffect(() => { setIsLoading(true); @@ -44,10 +48,13 @@ export function JobStatusCard({ jobId }: { jobId: int }) { setErrorMsg(undefined); api.getJob(jobId) .then((res) => { - setJob(res.job_context[jobId.toString()]); + const newJob = res.job_context[jobId.toString()]; + setJob(newJob); setErrorMsg(undefined); doAutomaticReload.current = true; - setTimeout(reloadStatus, 2000); + if (canJobStatusChange(newJob.state)) { + reloadStatusDeferred(); + } }) .catch((e) => { setErrorMsg("Unable to load job: " + e.toString()); diff --git a/src/videoag/miscellaneous/PromiseHelpers.tsx b/src/videoag/miscellaneous/PromiseHelpers.tsx index 5c568b5f5d41ce63eceab54b1a4ef457239d58a0..8d62cddcf7858febd2ae6e2f50d23174a90cc621 100644 --- a/src/videoag/miscellaneous/PromiseHelpers.tsx +++ b/src/videoag/miscellaneous/PromiseHelpers.tsx @@ -103,37 +103,70 @@ export function useDebounceWithArgument<V, R>( ]; } -export function useDebounceUpdateData<D, R>( - updater: (newData: D) => Promise<R>, +// Returns a function which calls the provided function while ensuring only one call is running at a time +export function useMutexCall<R>(call: () => Promise<R>): () => Promise<R> { + const [deferred, now] = useDebouncedProcessing(0, (data) => call(), true); + return () => now(undefined); +} + +// A function which processes data while ensuring only one processor is running at a time +export function useMutexProcessing<D, R>( + processor: (newData: D) => Promise<R>, + reprocessEqualInput: boolean = false, +): (newData: D) => Promise<R> { + const [deferred, now] = useDebouncedProcessing(0, processor, reprocessEqualInput); + return now; +} + +// A function which calls the given function with an optional delay while ensuring only one call is running at a time +export function useDebouncedCall<R>( delay: number, + call: () => Promise<R>, +): [() => void, () => Promise<R>] { + const [deferred, now] = useDebouncedProcessing(delay, (data) => call(), true); + return [() => deferred(undefined), () => now(undefined)]; +} + +class NoDataMarker {} + +// A function which processes data with an optional delay while ensuring only one processor is running at a time +// Usually intended for API calls to update/fetch data +export function useDebouncedProcessing<D, R>( + delay: number, + processor: (newData: D) => Promise<R>, + reprocessEqualInput: boolean = false, ): [(newData: D) => void, (newData: D) => Promise<R>] { const timeoutIdRef = useRef<NodeJS.Timeout>(undefined); const processingPromiseRef = useRef<Promise<R> | undefined>(undefined); - const unprocessedDataRef = useRef<D | undefined>(undefined); + const unprocessedDataRef = useRef<D | NoDataMarker>(new NoDataMarker()); - function updateData(): Promise<R> | undefined { + function doProcessing(): Promise<R> | undefined { if (processingPromiseRef.current !== undefined) { return processingPromiseRef.current; } - if (unprocessedDataRef.current === undefined) { + if (unprocessedDataRef.current instanceof NoDataMarker) { return undefined; } const data = unprocessedDataRef.current; - const onUpdateFinished = (arg: any) => { + const onProcessingFinished = (arg: any) => { processingPromiseRef.current = undefined; - if (unprocessedDataRef.current !== undefined) { - if (unprocessedDataRef.current === data) { - unprocessedDataRef.current = undefined; + if (!(unprocessedDataRef.current instanceof NoDataMarker)) { + if (!reprocessEqualInput && unprocessedDataRef.current === data) { + unprocessedDataRef.current = new NoDataMarker(); } else if (timeoutIdRef.current === undefined) { // Only run immediately when no timeout is set. A call to deferred should still be deferred even if - // an update was running during the call. - updateData(); + // a processor was running during the call. + doProcessing(); } } return arg; }; - unprocessedDataRef.current = undefined; - const promise = updater(data).then(onUpdateFinished, onUpdateFinished); + unprocessedDataRef.current = new NoDataMarker(); + const processorPromise = processor(data); + if (!processorPromise) { + throw new Error(`Processor '${processor}' did not return a promise`); + } + const promise = processorPromise.then(onProcessingFinished, onProcessingFinished); processingPromiseRef.current = promise; return promise; } @@ -145,7 +178,7 @@ export function useDebounceUpdateData<D, R>( const timeoutRef = setTimeout(() => { if (timeoutIdRef.current === timeoutRef) timeoutIdRef.current = undefined; - updateData(); + doProcessing(); }, delay); timeoutIdRef.current = timeoutRef; }, @@ -153,7 +186,7 @@ export function useDebounceUpdateData<D, R>( clearTimeout(timeoutIdRef.current); timeoutIdRef.current = undefined; unprocessedDataRef.current = newData; - return updateData()!; + return doProcessing()!; }, ]; } diff --git a/src/videoag/object_management/OMConfigComponent.tsx b/src/videoag/object_management/OMConfigComponent.tsx index f242926acb15f414d8235a1575ad9cefc7328b8d..02ff6b7b359829ecf09f45f422ff8df7fedb0254 100644 --- a/src/videoag/object_management/OMConfigComponent.tsx +++ b/src/videoag/object_management/OMConfigComponent.tsx @@ -14,7 +14,7 @@ import { ApiError } from "@/videoag/api/ApiError"; import { showError, showErrorToast } from "@/videoag/error/ErrorDisplay"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; import { useReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; -import { useDebounceUpdateData } from "@/videoag/miscellaneous/PromiseHelpers"; +import { useDebouncedProcessing } from "@/videoag/miscellaneous/PromiseHelpers"; import { datetimeToString, semesterToHuman } from "@/videoag/miscellaneous/Formatting"; import { StylizedText } from "@/videoag/miscellaneous/StylizedText"; import { deepEquals, TooltipButton } from "@/videoag/miscellaneous/Util"; @@ -196,7 +196,8 @@ export function EmbeddedOMFieldComponent({ }); }, [api, isEditing, object_type, object_id, field_id]); - const [deferredSendValueUpdate, nowSendValueUpdate] = useDebounceUpdateData<any, void>( + const [deferredSendValueUpdate, nowSendValueUpdate] = useDebouncedProcessing<any, void>( + isInstantEdit ? 10 : 1000 * 15, async (newValue: any) => { if (field === undefined) return; if (deepEquals(newValue, serverSideFieldValueRef.current)) return; @@ -219,7 +220,6 @@ export function EmbeddedOMFieldComponent({ showError(error, "Unable to update field"); }); }, - isInstantEdit ? 10 : 1000 * 15, ); const disableEditing = () => {