Merge pull request #574 from mirumee/add/background-tasks

Add background task manager
This commit is contained in:
Dominik Żegleń 2020-06-26 14:05:44 +02:00 committed by GitHub
commit e7a0495556
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 318 additions and 5 deletions

View file

@ -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

View file

@ -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<Promise<boolean>, []>(
() => 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<Promise<boolean>, []>(
() =>
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<Promise<boolean>> = [
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();
});
});
});
});
});

View file

@ -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<QueuedTask[]>([]);
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 (
<BackgroundTasksContext.Provider
value={{
cancel,
queue
}}
>
{children}
</BackgroundTasksContext.Provider>
);
};
BackgroundTasksProvider.displayName = "BackgroundTasksProvider";
export default BackgroundTasksProvider;

View file

@ -0,0 +1,7 @@
import { createContext } from "react";
import { BackgroundTasksContextType } from "./types";
const BackgroundTasksContext = createContext<BackgroundTasksContextType>(null);
export default BackgroundTasksContext;

View file

@ -0,0 +1,2 @@
export * from "./BackgroundTasksProvider";
export { default } from "./BackgroundTasksProvider";

View file

@ -0,0 +1,42 @@
import { QueuedTask, TaskData, TaskStatus } from "./types";
export async function handleTask(task: QueuedTask): Promise<boolean> {
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<QueuedTask[]>,
data: TaskData
) {
(["handle", "onCompleted"] as Array<keyof TaskData>)
.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
}
];
}

View file

@ -0,0 +1,26 @@
export enum Task {
CUSTOM
}
export enum TaskStatus {
PENDING,
ENDED
}
export interface QueuedTask {
id: number;
handle: () => Promise<boolean>;
status: TaskStatus;
onCompleted: () => void;
onError: (error: Error) => void;
}
export interface TaskData {
handle?: () => Promise<boolean>;
onCompleted?: () => void;
onError?: () => void;
}
export interface BackgroundTasksContextType {
cancel: (id: number) => void;
queue: (type: Task, data?: TaskData) => void;
}

View file

@ -0,0 +1,8 @@
import BackgroundTasksContext from "@saleor/containers/BackgroundTasks/context";
import { useContext } from "react";
function useBackgroundTask() {
return useContext(BackgroundTasksContext);
}
export default useBackgroundTask;

View file

@ -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 = () => {
<DateProvider>
<LocaleProvider>
<MessageManager>
<AppStateProvider>
<ShopProvider>
<Routes />
</ShopProvider>
</AppStateProvider>
<BackgroundTasksProvider>
<AppStateProvider>
<ShopProvider>
<Routes />
</ShopProvider>
</AppStateProvider>
</BackgroundTasksProvider>
</MessageManager>
</LocaleProvider>
</DateProvider>