diff --git a/src/components/common/EnableDisable.tsx b/src/components/common/EnableDisable.tsx index 2ceeb7b..bd1017d 100644 --- a/src/components/common/EnableDisable.tsx +++ b/src/components/common/EnableDisable.tsx @@ -15,7 +15,10 @@ import { WithNamespaces, withNamespaces } from "react-i18next"; import NavButton from "./NavButton"; import NavDropdown from "./NavDropdown"; import { StatusContext } from "./context/StatusContext"; -import { CancelablePromise, makeCancelable } from "../../util"; +import { + CancelablePromise, + makeCancelable +} from "../../util/CancelablePromise"; import api from "../../util/api"; import { Button, diff --git a/src/components/common/WithAPIData.tsx b/src/components/common/WithAPIData.tsx index 3e72e0a..2a165b3 100644 --- a/src/components/common/WithAPIData.tsx +++ b/src/components/common/WithAPIData.tsx @@ -9,7 +9,11 @@ * Please see LICENSE file for your rights under this license. */ import { Component, ReactNode } from "react"; -import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; import { Err, Ok, Result } from "../../util/result"; export interface WithAPIDataProps { diff --git a/src/components/list/ListPage.tsx b/src/components/list/ListPage.tsx index 512b838..336beca 100644 --- a/src/components/list/ListPage.tsx +++ b/src/components/list/ListPage.tsx @@ -13,7 +13,11 @@ import { WithNamespaces, withNamespaces } from "react-i18next"; import DomainInput from "./DomainInput"; import Alert, { AlertType } from "../common/Alert"; import DomainList from "./DomainList"; -import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; export interface ListPageProps extends WithNamespaces { title: string; diff --git a/src/components/log/QueryLog.tsx b/src/components/log/QueryLog.tsx index a2b9421..b746ca1 100644 --- a/src/components/log/QueryLog.tsx +++ b/src/components/log/QueryLog.tsx @@ -20,18 +20,18 @@ import i18next from "i18next"; import { WithNamespaces, withNamespaces } from "react-i18next"; import debounce from "lodash.debounce"; import moment from "moment"; -import { - CancelablePromise, - ignoreCancel, - makeCancelable, - padNumber -} from "../../util"; +import { padNumber } from "../../util"; import api from "../../util/api"; import { dateRanges } from "../../util/dateRanges"; import { TranslatedTimeRangeSelector } from "../dashboard/TimeRangeSelector"; import { TimeRange } from "../common/context/TimeRangeContext"; import "react-table/react-table.css"; import "bootstrap-daterangepicker/daterangepicker.css"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; export interface QueryLogState { history: Array; diff --git a/src/components/settings/DHCPInfo.tsx b/src/components/settings/DHCPInfo.tsx index 85aeece..5d00a17 100644 --- a/src/components/settings/DHCPInfo.tsx +++ b/src/components/settings/DHCPInfo.tsx @@ -10,7 +10,11 @@ import React, { ChangeEvent, Component, FormEvent } from "react"; import { WithNamespaces, withNamespaces } from "react-i18next"; -import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; import api from "../../util/api"; import { Button, diff --git a/src/components/settings/DNSInfo.tsx b/src/components/settings/DNSInfo.tsx index f2a4d15..0d32967 100644 --- a/src/components/settings/DNSInfo.tsx +++ b/src/components/settings/DNSInfo.tsx @@ -10,7 +10,11 @@ import React, { Component, FormEvent } from "react"; import { WithNamespaces, withNamespaces } from "react-i18next"; -import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; import api from "../../util/api"; import DnsList from "./DnsList"; import { Button, Col, Form, FormGroup } from "reactstrap"; diff --git a/src/components/settings/PreferenceSettings.tsx b/src/components/settings/PreferenceSettings.tsx index 22ec7c3..3fbc6c8 100644 --- a/src/components/settings/PreferenceSettings.tsx +++ b/src/components/settings/PreferenceSettings.tsx @@ -10,7 +10,11 @@ import React, { ChangeEvent, Component, FormEvent } from "react"; import { WithNamespaces, withNamespaces } from "react-i18next"; -import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; import api from "../../util/api"; import Alert, { AlertType } from "../common/Alert"; import { Button, Col, Form, FormGroup, Input, Label } from "reactstrap"; diff --git a/src/util/CancelablePromise.tsx b/src/util/CancelablePromise.tsx new file mode 100644 index 0000000..1fa2d12 --- /dev/null +++ b/src/util/CancelablePromise.tsx @@ -0,0 +1,95 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * Wrap promises to make them cancelable + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +/** + * A promise which can be canceled + */ +export interface CancelablePromise { + promise: Promise; + cancel: () => void; +} + +/** + * The options given to {@link makeCancelable} + */ +export interface CancelableOptions { + /** + * The function to call to repeat the promise + */ + repeat: () => void; + + /** + * The amount of time to wait until repeating + */ + interval: number; +} + +/** + * Make a promise cancelable and repeatable + * + * @param promise the promise + * @param options the interval repeat options + * @returns a cancelable promise + */ +export function makeCancelable( + promise: Promise, + options?: CancelableOptions +): CancelablePromise { + let hasCanceled = false; + let repeatId: NodeJS.Timeout | null = null; + + const handle = ( + resolve: (value: T) => void, + reject: (error: any) => void, + val: T, + isError: boolean + ) => { + if (hasCanceled) { + reject({ isCanceled: true }); + return; + } + + if (isError) { + reject(val); + } else { + resolve(val); + } + + if (options) { + repeatId = setTimeout(options.repeat, options.interval); + } + }; + + const wrappedPromise: Promise = new Promise((resolve, reject) => { + promise.then( + val => handle(resolve, reject, val, false), + error => handle(resolve, reject, error, true) + ); + }); + + return { + promise: wrappedPromise, + cancel() { + if (repeatId !== null) { + clearTimeout(repeatId); + } + hasCanceled = true; + } + }; +} + +/** + * Ignore canceled promises (pass into a promise's catch function) + * + * @param err the error from catching the promise + */ +export const ignoreCancel = (err: any) => { + if (!err.isCanceled) throw err; +}; diff --git a/src/util/__tests__/CancelablePromise.test.tsx b/src/util/__tests__/CancelablePromise.test.tsx new file mode 100644 index 0000000..77ef425 --- /dev/null +++ b/src/util/__tests__/CancelablePromise.test.tsx @@ -0,0 +1,102 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * Tests for canceling promises + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +import { makeCancelable, ignoreCancel } from "../CancelablePromise"; + +describe("makeCancelable", () => { + const testValue = "test"; + const testError = "testError"; + const promise = Promise.resolve(testValue); + const promiseErr = Promise.reject(testError); + + it("passes through promises in the default case", async () => { + const cancelablePromise = makeCancelable(promise); + + await expect(cancelablePromise.promise).resolves.toEqual(testValue); + }); + + it("passes through errors in the default case", async () => { + const cancelablePromise = makeCancelable(promiseErr); + + await expect(cancelablePromise.promise).rejects.toEqual(testError); + }); + + it("rejects with cancel error if canceled", async () => { + const cancelablePromise = makeCancelable(promise); + + cancelablePromise.cancel(); + + await expect(cancelablePromise.promise).rejects.toEqual({ + isCanceled: true + }); + }); + + it("calls the repeat function after resolving", async () => { + jest.useFakeTimers(); + + const mockFunction = jest.fn(); + const interval = 1000; + const cancelablePromise = makeCancelable(promise, { + interval, + repeat: mockFunction + }); + + await expect(cancelablePromise.promise).resolves; + + expect(setTimeout).toHaveBeenCalledWith(mockFunction, interval); + }); + + it("calls the repeat function after rejecting", async () => { + jest.useFakeTimers(); + + const mockFunction = jest.fn(); + const interval = 1000; + const cancelablePromise = makeCancelable(promiseErr, { + interval, + repeat: mockFunction + }); + + await expect(cancelablePromise.promise).rejects.toEqual(testError); + + expect(setTimeout).toHaveBeenCalledWith(mockFunction, interval); + }); + + it("clears the timeout if canceled after resolving", async () => { + jest.useFakeTimers(); + + const mockFunction = jest.fn(); + const interval = 1000; + const cancelablePromise = makeCancelable(promise, { + interval, + repeat: mockFunction + }); + + await cancelablePromise.promise; + + cancelablePromise.cancel(); + + expect(clearTimeout).toHaveBeenCalled(); + }); +}); + +describe("ignoreCancel", () => { + it("passes through non-canceled errors", async () => { + const testError = "test"; + const promise = Promise.reject(testError); + + await expect(promise.catch(ignoreCancel)).rejects.toEqual(testError); + }); + + it("does not pass through canceled errors", async () => { + const canceledPromise = Promise.reject({ isCanceled: true }); + + await expect(canceledPromise.catch(ignoreCancel)).resolves; + }); +}); diff --git a/src/util/index.tsx b/src/util/index.tsx index b16364d..d339154 100644 --- a/src/util/index.tsx +++ b/src/util/index.tsx @@ -29,77 +29,3 @@ export const padNumber = (num: number) => { export const getIntervalForRange = (range: TimeRange): number => { return Math.ceil((range.until.unix() - range.from.unix()) / 144); }; - -export interface CancelablePromise { - promise: Promise; - cancel: () => void; -} - -export interface CancelableOptions { - /** - * The function to call to repeat the promise - */ - repeat: () => void; - - /** - * The amount of time to wait until repeating - */ - interval: number; -} - -/** - * Make a promise cancelable and repeatable - * - * @param promise the promise - * @param options the interval repeat options - * @returns {{promise: Promise, cancel(): void}} a handle on the cancelable - * promise - */ -export function makeCancelable( - promise: Promise, - options?: CancelableOptions -): CancelablePromise { - let hasCanceled = false; - let repeatId: NodeJS.Timeout | null = null; - - const handle = ( - resolve: (value: any) => void, - reject: (error: any) => void, - val: T, - isError: boolean - ) => { - if (hasCanceled) reject({ isCanceled: true }); - else { - if (isError) reject(val); - else resolve(val); - - if (options) repeatId = setTimeout(options.repeat, options.interval); - } - }; - - const wrappedPromise: Promise = new Promise((resolve, reject) => { - promise.then( - val => handle(resolve, reject, val, false), - error => handle(resolve, reject, error, true) - ); - }); - - return { - promise: wrappedPromise, - cancel() { - if (repeatId !== null) { - clearTimeout(repeatId); - } - hasCanceled = true; - } - }; -} - -/** - * Ignore canceled promises (pass into a promise's catch function) - * - * @param err the error from catching the promise - */ -export const ignoreCancel = (err: any) => { - if (!err.isCanceled) throw err; -};