aboutsummaryrefslogtreecommitdiff
path: root/packages/web-util
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2022-12-12 10:57:14 -0300
committerSebastian <sebasjm@gmail.com>2022-12-12 10:57:14 -0300
commit880961034c81e85e191c6c4b845d96506bbd4ea7 (patch)
treee4dcf91f4ea8f7f26760098304f6c9db267803b6 /packages/web-util
parent5fc8f95a5d4ce8dea03b2dbec7eb5c37e7ff3f15 (diff)
downloadwallet-core-880961034c81e85e191c6c4b845d96506bbd4ea7.tar.gz
wallet-core-880961034c81e85e191c6c4b845d96506bbd4ea7.tar.bz2
wallet-core-880961034c81e85e191c6c4b845d96506bbd4ea7.zip
compose, testing and async into web-util
Diffstat (limited to 'packages/web-util')
-rw-r--r--packages/web-util/package.json1
-rw-r--r--packages/web-util/src/components/index.ts2
-rw-r--r--packages/web-util/src/components/utils.ts36
-rw-r--r--packages/web-util/src/hooks/index.ts3
-rw-r--r--packages/web-util/src/hooks/useAsyncAsHook.ts91
-rw-r--r--packages/web-util/src/index.browser.ts2
-rw-r--r--packages/web-util/src/test/index.ts224
7 files changed, 358 insertions, 1 deletions
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<S> = (p: S) => VNode;
+
+export type StateViewMap<StateType extends { status: string }> = {
+ [S in StateType as S["status"]]: StateFunc<S>;
+};
+
+export type RecursiveState<S extends object> = S | (() => RecursiveState<S>);
+
+export function compose<SType extends { status: string }, PType>(
+ hook: (p: PType) => RecursiveState<SType>,
+ viewMap: StateViewMap<SType>,
+): (p: PType) => VNode {
+ function withHook(stateHook: () => RecursiveState<SType>): () => 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<SType>;
+ 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 <http://www.gnu.org/licenses/>
+ */
+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<T> {
+ 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<T> = HookOk<T> | HookError | undefined;
+export type HookResponseWithRetry<T> =
+ | ((HookOk<T> | HookError) & WithRetry)
+ | undefined;
+
+export function useAsyncAsHook<T>(
+ fn: () => Promise<T | false>,
+ deps?: any[],
+): HookResponseWithRetry<T> {
+ const [result, setHookResponse] = useState<HookResponse<T>>(undefined);
+
+ const args = useMemo(
+ () => ({
+ fn,
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }),
+ deps || [],
+ );
+
+ async function doAsync(): Promise<void> {
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+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<Props>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props> | (() => Partial<Props>),
+): ComponentChildren {
+ const evaluatedProps = typeof props === "function" ? props() : props;
+ const Render = (args: any): VNode => create(Component, args);
+
+ return {
+ component: Render,
+ props: evaluatedProps
+ };
+}
+
+export function createExampleWithCustomContext<Props, ContextProps>(
+ Component: FunctionalComponent<Props>,
+ props: Partial<Props> | (() => Partial<Props>),
+ ContextProvider: FunctionalComponent<ContextProps>,
+ contextProps: Partial<ContextProps>,
+): 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> = S | (() => RecursiveState<S>);
+
+interface Mounted<T> {
+ unmount: () => void;
+ pullLastResultOrThrow: () => Exclude<T, VoidFunction>;
+ assertNoPendingUpdate: () => void;
+ // waitNextUpdate: (s?: string) => Promise<void>;
+ waitForStateUpdate: () => Promise<boolean>;
+}
+
+/**
+ * Main test API, mount the hook and return testing API
+ * @param callback
+ * @param Context
+ * @returns
+ */
+export function mountHook<T extends object>(
+ callback: () => RecursiveState<T>,
+ Context?: ({ children }: { children: any }) => VNode,
+): Mounted<T> {
+ let lastResult: Exclude<T, VoidFunction> | 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<T, () => 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<T | Error | null, VoidFunction> {
+ const copy: Exclude<T | Error | null, VoidFunction> = lastResult;
+ lastResult = null;
+ return copy;
+ }
+
+ function pullLastResultOrThrow(): Exclude<T, VoidFunction> {
+ const r = pullLastResult();
+ if (r instanceof Error) throw r;
+ if (!r) throw Error("there was no last result");
+ return r;
+ }
+
+ async function assertNoPendingUpdate(): Promise<void> {
+ 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<boolean> {
+ 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<void> => { return Promise.resolve() } \ No newline at end of file