diff --git a/src/containers/BackgroundTasks/BackgroundTasksProvider.test.tsx b/src/containers/BackgroundTasks/BackgroundTasksProvider.test.tsx new file mode 100644 index 000000000..72c57cbae --- /dev/null +++ b/src/containers/BackgroundTasks/BackgroundTasksProvider.test.tsx @@ -0,0 +1,109 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import { + backgroundTasksRefreshTime, + useBackgroundTasks +} from "./BackgroundTasksProvider"; +import { Task, TaskData } from "./types"; + +jest.useFakeTimers(); + +describe("Background task provider", () => { + it("can queue a task", () => { + const onCompleted = jest.fn(); + const onError = jest.fn(); + + const { result } = renderHook(useBackgroundTasks); + + const taskId = result.current.queue(Task.CUSTOM, { + handle: () => true, + onCompleted, + onError + }); + expect(taskId).toBe(1); + expect(onCompleted).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + + jest.runOnlyPendingTimers(); + + expect(onCompleted).toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + it("can handle task error", () => { + const onCompleted = jest.fn(); + const onError = jest.fn(); + + const { result } = renderHook(useBackgroundTasks); + + result.current.queue(Task.CUSTOM, { + handle: () => { + throw new Error("dummy error"); + }, + onCompleted, + onError + }); + + jest.runOnlyPendingTimers(); + + expect(onCompleted).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalled(); + }); + + it("can cancel task", () => { + const onCompleted = jest.fn(); + + const { result } = renderHook(useBackgroundTasks); + + const taskId = result.current.queue(Task.CUSTOM, { + handle: () => true, + onCompleted + }); + + jest.advanceTimersByTime(backgroundTasksRefreshTime - 1000); + result.current.cancel(taskId); + + jest.runOnlyPendingTimers(); + + expect(onCompleted).not.toHaveBeenCalled(); + }); + + it("can queue multiple tasks", () => { + const responses = [ + { + finished: false + }, + { + finished: false + } + ]; + + const tasks: TaskData[] = responses.map(response => ({ + handle: () => response.finished, + onCompleted: jest.fn() + })); + + const { result } = renderHook(useBackgroundTasks); + + tasks.forEach(task => result.current.queue(Task.CUSTOM, task)); + + jest.runOnlyPendingTimers(); + + expect(tasks[0].onCompleted).not.toHaveBeenCalled(); + expect(tasks[1].onCompleted).not.toHaveBeenCalled(); + + responses[0].finished = true; + + jest.runOnlyPendingTimers(); + + expect(tasks[0].onCompleted).toHaveBeenCalled(); + expect(tasks[1].onCompleted).not.toHaveBeenCalled(); + + responses[1].finished = true; + + jest.runOnlyPendingTimers(); + + expect(tasks[1].onCompleted).toHaveBeenCalled(); + expect(tasks[1].onCompleted).toHaveBeenCalled(); + }); +}); diff --git a/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx b/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx new file mode 100644 index 000000000..7a1b34007 --- /dev/null +++ b/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx @@ -0,0 +1,58 @@ +import React from "react"; + +import BackgroundTasksContext from "./context"; +import { handleTask, queueCustom } from "./tasks"; +import { QueuedTask, Task, TaskData } from "./types"; + +export const backgroundTasksRefreshTime = 15 * 1000; + +export function useBackgroundTasks() { + const idCounter = React.useRef(0); + const tasks = React.useRef([]); + + React.useEffect(() => { + const intervalId = setInterval(() => { + tasks.current = tasks.current.filter(task => !handleTask(task)); + }, backgroundTasksRefreshTime); + + return () => clearInterval(intervalId); + }); + + function cancel(id: number) { + tasks.current = tasks.current.filter(task => task.id !== id); + } + + function queue(type: Task, data?: TaskData) { + idCounter.current += 1; + + switch (type) { + case Task.CUSTOM: + queueCustom(idCounter.current, tasks, data); + } + + return idCounter.current; + } + + return { + cancel, + queue + }; +} + +const BackgroundTasksProvider: React.FC = ({ children }) => { + const { cancel, queue } = useBackgroundTasks(); + + return ( + + {children} + + ); +}; + +BackgroundTasksProvider.displayName = "BackgroundTasksProvider"; +export default BackgroundTasksProvider; diff --git a/src/containers/BackgroundTasks/context.ts b/src/containers/BackgroundTasks/context.ts new file mode 100644 index 000000000..985da8963 --- /dev/null +++ b/src/containers/BackgroundTasks/context.ts @@ -0,0 +1,7 @@ +import { createContext } from "React"; + +import { BackgroundTasksContextType } from "./types"; + +const BackgroundTasksContext = createContext(null); + +export default BackgroundTasksContext; diff --git a/src/containers/BackgroundTasks/index.ts b/src/containers/BackgroundTasks/index.ts new file mode 100644 index 000000000..3525dcf82 --- /dev/null +++ b/src/containers/BackgroundTasks/index.ts @@ -0,0 +1,2 @@ +export * from "./BackgroundTasksProvider"; +export { default } from "./BackgroundTasksProvider"; diff --git a/src/containers/BackgroundTasks/tasks.ts b/src/containers/BackgroundTasks/tasks.ts new file mode 100644 index 000000000..61a488e94 --- /dev/null +++ b/src/containers/BackgroundTasks/tasks.ts @@ -0,0 +1,42 @@ +import { QueuedTask, TaskData } from "./types"; + +export function handleTask(task: QueuedTask) { + let ok: boolean; + try { + ok = task.handle(); + } catch (error) { + task.onError(error); + } + + if (ok) { + task.onCompleted(); + } + + return ok; +} + +export function handleError(error: Error) { + throw error; +} + +export function queueCustom( + id: number, + tasks: React.MutableRefObject, + data: TaskData +) { + (["handle", "onCompleted"] as Array) + .filter(field => !data[field]) + .forEach(field => { + throw new Error(`${field} is required when creating custom task`); + }); + + tasks.current = [ + ...tasks.current, + { + handle: data.handle, + id, + onCompleted: data.onCompleted, + onError: data.onError || handleError + } + ]; +} diff --git a/src/containers/BackgroundTasks/types.ts b/src/containers/BackgroundTasks/types.ts new file mode 100644 index 000000000..719a6df2c --- /dev/null +++ b/src/containers/BackgroundTasks/types.ts @@ -0,0 +1,21 @@ +export enum Task { + CUSTOM +} + +export interface QueuedTask { + id: number; + handle: () => boolean; + onCompleted: () => void; + onError: (error: Error) => void; +} + +export interface TaskData { + handle?: () => boolean; + onCompleted?: () => void; + onError?: () => void; +} + +export interface BackgroundTasksContextType { + cancel: (id: number) => void; + queue: (type: Task, data?: TaskData) => void; +}