compose, testing and async into web-util
This commit is contained in:
parent
5fc8f95a5d
commit
880961034c
@ -31,6 +31,7 @@
|
|||||||
"esbuild": "^0.14.21",
|
"esbuild": "^0.14.21",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"preact": "10.11.3",
|
"preact": "10.11.3",
|
||||||
|
"preact-render-to-string": "^5.2.6",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"tslib": "^2.4.0",
|
"tslib": "^2.4.0",
|
||||||
|
2
packages/web-util/src/components/index.ts
Normal file
2
packages/web-util/src/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
export * as utils from "./utils.js";
|
36
packages/web-util/src/components/utils.ts
Normal file
36
packages/web-util/src/components/utils.ts
Normal file
@ -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();
|
||||||
|
};
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
export { useLang } from "./useLang.js";
|
export { useLang } from "./useLang.js";
|
||||||
export { useLocalStorage, useNotNullLocalStorage } from "./useLocalStorage.js"
|
export { useLocalStorage, useNotNullLocalStorage } from "./useLocalStorage.js"
|
||||||
|
export { useAsyncAsHook, HookError, HookOk, HookResponse, HookResponseWithRetry, HookGenericError, HookOperationalError } from "./useAsyncAsHook.js"
|
91
packages/web-util/src/hooks/useAsyncAsHook.ts
Normal file
91
packages/web-util/src/hooks/useAsyncAsHook.ts
Normal file
@ -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 };
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
export * from "./hooks/index.js";
|
export * from "./hooks/index.js";
|
||||||
export * from "./context/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";
|
export { renderStories, parseGroupImport } from "./stories.js";
|
||||||
|
224
packages/web-util/src/test/index.ts
Normal file
224
packages/web-util/src/test/index.ts
Normal file
@ -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() }
|
@ -701,6 +701,7 @@ importers:
|
|||||||
esbuild: ^0.14.21
|
esbuild: ^0.14.21
|
||||||
express: ^4.18.2
|
express: ^4.18.2
|
||||||
preact: 10.11.3
|
preact: 10.11.3
|
||||||
|
preact-render-to-string: ^5.2.6
|
||||||
prettier: ^2.5.1
|
prettier: ^2.5.1
|
||||||
rimraf: ^3.0.2
|
rimraf: ^3.0.2
|
||||||
tslib: ^2.4.0
|
tslib: ^2.4.0
|
||||||
@ -716,6 +717,7 @@ importers:
|
|||||||
esbuild: 0.14.54
|
esbuild: 0.14.54
|
||||||
express: 4.18.2
|
express: 4.18.2
|
||||||
preact: 10.11.3
|
preact: 10.11.3
|
||||||
|
preact-render-to-string: 5.2.6_preact@10.11.3
|
||||||
prettier: 2.7.1
|
prettier: 2.7.1
|
||||||
rimraf: 3.0.2
|
rimraf: 3.0.2
|
||||||
tslib: 2.4.1
|
tslib: 2.4.1
|
||||||
|
Loading…
Reference in New Issue
Block a user