From 880961034c81e85e191c6c4b845d96506bbd4ea7 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 12 Dec 2022 10:57:14 -0300 Subject: compose, testing and async into web-util --- packages/web-util/package.json | 1 + packages/web-util/src/components/index.ts | 2 + packages/web-util/src/components/utils.ts | 36 +++++ packages/web-util/src/hooks/index.ts | 3 +- packages/web-util/src/hooks/useAsyncAsHook.ts | 91 +++++++++++ packages/web-util/src/index.browser.ts | 2 + packages/web-util/src/test/index.ts | 224 ++++++++++++++++++++++++++ 7 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 packages/web-util/src/components/index.ts create mode 100644 packages/web-util/src/components/utils.ts create mode 100644 packages/web-util/src/hooks/useAsyncAsHook.ts create mode 100644 packages/web-util/src/test/index.ts (limited to 'packages/web-util') diff --git a/packages/web-util/package.json b/packages/web-util/package.json index a4d1c116b..1add56d87 100644 --- a/packages/web-util/package.json +++ b/packages/web-util/package.json @@ -31,6 +31,7 @@ "esbuild": "^0.14.21", "express": "^4.18.2", "preact": "10.11.3", + "preact-render-to-string": "^5.2.6", "prettier": "^2.5.1", "rimraf": "^3.0.2", "tslib": "^2.4.0", diff --git a/packages/web-util/src/components/index.ts b/packages/web-util/src/components/index.ts new file mode 100644 index 000000000..dc7c86d7d --- /dev/null +++ b/packages/web-util/src/components/index.ts @@ -0,0 +1,2 @@ + +export * as utils from "./utils.js"; diff --git a/packages/web-util/src/components/utils.ts b/packages/web-util/src/components/utils.ts new file mode 100644 index 000000000..71824e14f --- /dev/null +++ b/packages/web-util/src/components/utils.ts @@ -0,0 +1,36 @@ +import { createElement, VNode } from "preact"; + +export type StateFunc = (p: S) => VNode; + +export type StateViewMap = { + [S in StateType as S["status"]]: StateFunc; +}; + +export type RecursiveState = S | (() => RecursiveState); + +export function compose( + hook: (p: PType) => RecursiveState, + viewMap: StateViewMap, +): (p: PType) => VNode { + function withHook(stateHook: () => RecursiveState): () => VNode { + function ComposedComponent(): VNode { + const state = stateHook(); + + if (typeof state === "function") { + const subComponent = withHook(state); + return createElement(subComponent, {}); + } + + const statusName = state.status as unknown as SType["status"]; + const viewComponent = viewMap[statusName] as unknown as StateFunc; + return createElement(viewComponent, state); + } + + return ComposedComponent; + } + + return (p: PType) => { + const h = withHook(() => hook(p)); + return h(); + }; +} diff --git a/packages/web-util/src/hooks/index.ts b/packages/web-util/src/hooks/index.ts index f18d61b9c..9ac56c4ac 100644 --- a/packages/web-util/src/hooks/index.ts +++ b/packages/web-util/src/hooks/index.ts @@ -1,3 +1,4 @@ export { useLang } from "./useLang.js"; -export { useLocalStorage, useNotNullLocalStorage } from "./useLocalStorage.js" \ No newline at end of file +export { useLocalStorage, useNotNullLocalStorage } from "./useLocalStorage.js" +export { useAsyncAsHook, HookError, HookOk, HookResponse, HookResponseWithRetry, HookGenericError, HookOperationalError } from "./useAsyncAsHook.js" \ No newline at end of file diff --git a/packages/web-util/src/hooks/useAsyncAsHook.ts b/packages/web-util/src/hooks/useAsyncAsHook.ts new file mode 100644 index 000000000..48d29aa45 --- /dev/null +++ b/packages/web-util/src/hooks/useAsyncAsHook.ts @@ -0,0 +1,91 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ +import { TalerErrorDetail } from "@gnu-taler/taler-util"; +// import { TalerError } from "@gnu-taler/taler-wallet-core"; +import { useEffect, useMemo, useState } from "preact/hooks"; + +export interface HookOk { + hasError: false; + response: T; +} + +export type HookError = HookGenericError | HookOperationalError; + +export interface HookGenericError { + hasError: true; + operational: false; + message: string; +} + +export interface HookOperationalError { + hasError: true; + operational: true; + details: TalerErrorDetail; +} + +interface WithRetry { + retry: () => void; +} + +export type HookResponse = HookOk | HookError | undefined; +export type HookResponseWithRetry = + | ((HookOk | HookError) & WithRetry) + | undefined; + +export function useAsyncAsHook( + fn: () => Promise, + deps?: any[], +): HookResponseWithRetry { + const [result, setHookResponse] = useState>(undefined); + + const args = useMemo( + () => ({ + fn, + // eslint-disable-next-line react-hooks/exhaustive-deps + }), + deps || [], + ); + + async function doAsync(): Promise { + try { + const response = await args.fn(); + if (response === false) return; + setHookResponse({ hasError: false, response }); + } catch (e) { + // if (e instanceof TalerError) { + // setHookResponse({ + // hasError: true, + // operational: true, + // details: e.errorDetail, + // }); + // } else + if (e instanceof Error) { + setHookResponse({ + hasError: true, + operational: false, + message: e.message, + }); + } + } + } + + useEffect(() => { + doAsync(); + }, [args]); + + if (!result) return undefined; + return { ...result, retry: doAsync }; +} diff --git a/packages/web-util/src/index.browser.ts b/packages/web-util/src/index.browser.ts index 2197d1b24..734a2f426 100644 --- a/packages/web-util/src/index.browser.ts +++ b/packages/web-util/src/index.browser.ts @@ -1,3 +1,5 @@ export * from "./hooks/index.js"; export * from "./context/index.js"; +export * from "./components/index.js"; +export * as test from "./test/index.js"; export { renderStories, parseGroupImport } from "./stories.js"; diff --git a/packages/web-util/src/test/index.ts b/packages/web-util/src/test/index.ts new file mode 100644 index 000000000..623115e79 --- /dev/null +++ b/packages/web-util/src/test/index.ts @@ -0,0 +1,224 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import { NotificationType } from "@gnu-taler/taler-util"; +// import { +// WalletCoreApiClient, +// WalletCoreOpKeys, +// WalletCoreRequestType, +// WalletCoreResponseType, +// } from "@gnu-taler/taler-wallet-core"; +import { + ComponentChildren, + Fragment, + FunctionalComponent, + h as create, + options, + render as renderIntoDom, + VNode, +} from "preact"; +import { render as renderToString } from "preact-render-to-string"; +// import { BackgroundApiClient, wxApi } from "./wxApi.js"; + +// When doing tests we want the requestAnimationFrame to be as fast as possible. +// without this option the RAF will timeout after 100ms making the tests slower +options.requestAnimationFrame = (fn: () => void) => { + return fn(); +}; + +export function createExample( + Component: FunctionalComponent, + props: Partial | (() => Partial), +): ComponentChildren { + const evaluatedProps = typeof props === "function" ? props() : props; + const Render = (args: any): VNode => create(Component, args); + + return { + component: Render, + props: evaluatedProps + }; +} + +export function createExampleWithCustomContext( + Component: FunctionalComponent, + props: Partial | (() => Partial), + ContextProvider: FunctionalComponent, + contextProps: Partial, +): ComponentChildren { + /** + * FIXME: + * This may not be useful since the example can be created with context + * already + */ + const evaluatedProps = typeof props === "function" ? props() : props; + const Render = (args: any): VNode => create(Component, args); + const WithContext = (args: any): VNode => + create(ContextProvider, { + ...contextProps, + children: [Render(args)], + } as any); + + return { + component: WithContext, + props: evaluatedProps + }; +} + +const isNode = typeof window === "undefined"; + +/** + * To be used on automated unit test. + * So test will run under node or browser + * @param Component + * @param args + */ +export function renderNodeOrBrowser(Component: any, args: any): void { + const vdom = create(Component, args); + if (isNode) { + renderToString(vdom); + } else { + const div = document.createElement("div"); + document.body.appendChild(div); + renderIntoDom(vdom, div); + renderIntoDom(null, div); + document.body.removeChild(div); + } +} +type RecursiveState = S | (() => RecursiveState); + +interface Mounted { + unmount: () => void; + pullLastResultOrThrow: () => Exclude; + assertNoPendingUpdate: () => void; + // waitNextUpdate: (s?: string) => Promise; + waitForStateUpdate: () => Promise; +} + +/** + * Main test API, mount the hook and return testing API + * @param callback + * @param Context + * @returns + */ +export function mountHook( + callback: () => RecursiveState, + Context?: ({ children }: { children: any }) => VNode, +): Mounted { + let lastResult: Exclude | Error | null = null; + + const listener: Array<() => void> = []; + + // component that's going to hold the hook + function Component(): VNode { + try { + let componentOrResult = callback(); + while (typeof componentOrResult === "function") { + componentOrResult = componentOrResult(); + } + //typecheck fails here + const l: Exclude void> = componentOrResult as any; + lastResult = l; + } catch (e) { + if (e instanceof Error) { + lastResult = e; + } else { + lastResult = new Error(`mounting the hook throw an exception: ${e}`); + } + } + + // notify to everyone waiting for an update and clean the queue + listener.splice(0, listener.length).forEach((cb) => cb()); + return create(Fragment, {}); + } + + // create the vdom with context if required + const vdom = !Context + ? create(Component, {}) + : create(Context, { children: [create(Component, {})] }); + + const customElement = {} as Element; + const parentElement = isNode ? customElement : document.createElement("div"); + if (!isNode) { + document.body.appendChild(parentElement); + } + + renderIntoDom(vdom, parentElement); + + // clean up callback + function unmount(): void { + if (!isNode) { + document.body.removeChild(parentElement); + } + } + + function pullLastResult(): Exclude { + const copy: Exclude = lastResult; + lastResult = null; + return copy; + } + + function pullLastResultOrThrow(): Exclude { + const r = pullLastResult(); + if (r instanceof Error) throw r; + if (!r) throw Error("there was no last result"); + return r; + } + + async function assertNoPendingUpdate(): Promise { + await new Promise((res, rej) => { + const tid = setTimeout(() => { + res(undefined); + }, 10); + + listener.push(() => { + clearTimeout(tid); + rej( + Error(`Expecting no pending result but the hook got updated. + If the update was not intended you need to check the hook dependencies + (or dependencies of the internal state) but otherwise make + sure to consume the result before ending the test.`), + ); + }); + }); + + const r = pullLastResult(); + if (r) + throw Error(`There are still pending results. + This may happen because the hook did a new update but the test didn't consume the result using pullLastResult`); + } + async function waitForStateUpdate(): Promise { + return await new Promise((res, rej) => { + const tid = setTimeout(() => { + res(false); + }, 10); + + listener.push(() => { + clearTimeout(tid); + res(true); + }); + }); + } + + return { + unmount, + pullLastResultOrThrow, + waitForStateUpdate, + assertNoPendingUpdate, + }; +} + +export const nullFunction = (): void => { null } +export const nullAsyncFunction = (): Promise => { return Promise.resolve() } \ No newline at end of file -- cgit v1.2.3