diff --git a/CHANGELOG.md b/CHANGELOG.md index 0982e9674..7598ac2d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ All notable, unreleased changes to this project will be documented in this file. - Update product stock management to newest design - #515 by @dominik-zeglen - Handle untracked products - #523 by @dominik-zeglen - Display correct error if there were no graphql errors - #525 by @dominik-zeglen +- Add background task manager - #574 by @dominik-zeglen ## 2.0.0 diff --git a/src/containers/BackgroundTasks/BackgroundTasksProvider.test.tsx b/src/containers/BackgroundTasks/BackgroundTasksProvider.test.tsx new file mode 100644 index 000000000..51e20fb1f --- /dev/null +++ b/src/containers/BackgroundTasks/BackgroundTasksProvider.test.tsx @@ -0,0 +1,139 @@ +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", done => { + const handle = jest.fn, []>( + () => new Promise(resolve => resolve(true)) + ); + const onCompleted = jest.fn(); + const onError = jest.fn(); + + const { result } = renderHook(useBackgroundTasks); + + const taskId = result.current.queue(Task.CUSTOM, { + handle, + onCompleted, + onError + }); + expect(taskId).toBe(1); + expect(handle).not.toHaveBeenCalled(); + expect(onCompleted).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + + jest.runOnlyPendingTimers(); + + setImmediate(() => { + expect(handle).toHaveBeenCalled(); + expect(onCompleted).toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + + done(); + }); + }); + + it("can handle task error", done => { + const handle = jest.fn, []>( + () => + new Promise(() => { + throw new Error("dummy error"); + }) + ); + const onCompleted = jest.fn(); + const onError = jest.fn(); + + const { result } = renderHook(useBackgroundTasks); + + result.current.queue(Task.CUSTOM, { + handle, + onCompleted, + onError + }); + + jest.runOnlyPendingTimers(); + + setImmediate(() => { + expect(handle).toHaveBeenCalled(); + expect(onCompleted).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalled(); + + done(); + }); + }); + + it("can cancel task", done => { + const onCompleted = jest.fn(); + + const { result } = renderHook(useBackgroundTasks); + + const taskId = result.current.queue(Task.CUSTOM, { + handle: () => new Promise(resolve => resolve(true)), + onCompleted + }); + + // Cancel task before executing it + jest.advanceTimersByTime(backgroundTasksRefreshTime * 0.9); + result.current.cancel(taskId); + + jest.runOnlyPendingTimers(); + + setImmediate(() => { + expect(onCompleted).not.toHaveBeenCalled(); + + done(); + }); + }); + + it("can queue multiple tasks", done => { + const responses: Array> = [ + new Promise(resolve => + setTimeout(() => resolve(true), backgroundTasksRefreshTime * 1.4) + ), + new Promise(resolve => + setTimeout(() => resolve(true), backgroundTasksRefreshTime * 2.1) + ) + ]; + + const tasks: TaskData[] = responses.map(response => ({ + handle: () => response, + onCompleted: jest.fn() + })); + + const { result } = renderHook(useBackgroundTasks); + + tasks.forEach(task => result.current.queue(Task.CUSTOM, task)); + + // Set time to backgroundTasksRefreshTime + jest.advanceTimersByTime(backgroundTasksRefreshTime + 100); + + setImmediate(() => { + expect(tasks[0].onCompleted).not.toHaveBeenCalled(); + expect(tasks[1].onCompleted).not.toHaveBeenCalled(); + + // Set time to backgroundTasksRefreshTime * 2 + jest.advanceTimersByTime(backgroundTasksRefreshTime); + + setImmediate(() => { + expect(tasks[0].onCompleted).toHaveBeenCalled(); + expect(tasks[1].onCompleted).not.toHaveBeenCalled(); + + // Set time to backgroundTasksRefreshTime * 3 + jest.advanceTimersByTime(backgroundTasksRefreshTime); + + setImmediate(() => { + expect(tasks[0].onCompleted).toHaveBeenCalled(); + expect(tasks[1].onCompleted).toHaveBeenCalled(); + + done(); + }); + }); + }); + }); +}); diff --git a/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx b/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx new file mode 100644 index 000000000..7199956d9 --- /dev/null +++ b/src/containers/BackgroundTasks/BackgroundTasksProvider.tsx @@ -0,0 +1,85 @@ +import React from "react"; + +import BackgroundTasksContext from "./context"; +import { handleTask, queueCustom } from "./tasks"; +import { QueuedTask, Task, TaskData, TaskStatus } from "./types"; + +export const backgroundTasksRefreshTime = 15 * 1000; + +export function useBackgroundTasks() { + const idCounter = React.useRef(0); + const tasks = React.useRef([]); + + React.useEffect(() => { + const intervalId = setInterval(() => { + const queue = async () => { + tasks.current = tasks.current.filter( + task => task.status !== TaskStatus.ENDED + ); + try { + await Promise.all( + tasks.current.map(async task => { + let hasFinished: boolean; + + try { + hasFinished = await handleTask(task); + } catch (error) { + throw error; + } + if (hasFinished) { + const taskIndex = tasks.current.findIndex( + t => t.id === task.id + ); + tasks.current[taskIndex].status = TaskStatus.ENDED; + } + }) + ); + } catch (error) { + throw error; + } + }; + + queue(); + }, 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..13f49e3ba --- /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..5fea73b44 --- /dev/null +++ b/src/containers/BackgroundTasks/tasks.ts @@ -0,0 +1,42 @@ +import { QueuedTask, TaskData, TaskStatus } from "./types"; + +export async function handleTask(task: QueuedTask): Promise { + let ok = false; + try { + ok = await task.handle(); + if (ok) { + task.onCompleted(); + } + } catch (error) { + task.onError(error); + } + + 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, + status: TaskStatus.PENDING + } + ]; +} diff --git a/src/containers/BackgroundTasks/types.ts b/src/containers/BackgroundTasks/types.ts new file mode 100644 index 000000000..a4e328e0e --- /dev/null +++ b/src/containers/BackgroundTasks/types.ts @@ -0,0 +1,26 @@ +export enum Task { + CUSTOM +} +export enum TaskStatus { + PENDING, + ENDED +} + +export interface QueuedTask { + id: number; + handle: () => Promise; + status: TaskStatus; + onCompleted: () => void; + onError: (error: Error) => void; +} + +export interface TaskData { + handle?: () => Promise; + onCompleted?: () => void; + onError?: () => void; +} + +export interface BackgroundTasksContextType { + cancel: (id: number) => void; + queue: (type: Task, data?: TaskData) => void; +} diff --git a/src/hooks/useBackgroundTask.ts b/src/hooks/useBackgroundTask.ts new file mode 100644 index 000000000..9ec45f163 --- /dev/null +++ b/src/hooks/useBackgroundTask.ts @@ -0,0 +1,8 @@ +import BackgroundTasksContext from "@saleor/containers/BackgroundTasks/context"; +import { useContext } from "react"; + +function useBackgroundTask() { + return useContext(BackgroundTasksContext); +} + +export default useBackgroundTask; diff --git a/src/index.tsx b/src/index.tsx index 45625ac19..2c30b3571 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -35,6 +35,7 @@ import { WindowTitle } from "./components/WindowTitle"; import { API_URI, APP_MOUNT_URI, GTM_ID } from "./config"; import ConfigurationSection, { createConfigurationMenu } from "./configuration"; import AppStateProvider from "./containers/AppState"; +import BackgroundTasksProvider from "./containers/BackgroundTasks"; import { CustomerSection } from "./customers"; import DiscountSection from "./discounts"; import HomePage from "./home"; @@ -135,11 +136,13 @@ const App: React.FC = () => { - - - - - + + + + + + +