compose, testing and async into web-util
This commit is contained in:
parent
5fc8f95a5d
commit
880961034c
@ -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",
|
||||
|
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 { 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 "./context/index.js";
|
||||
export * from "./components/index.js";
|
||||
export * as test from "./test/index.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
|
||||
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
|
||||
@ -716,6 +717,7 @@ importers:
|
||||
esbuild: 0.14.54
|
||||
express: 4.18.2
|
||||
preact: 10.11.3
|
||||
preact-render-to-string: 5.2.6_preact@10.11.3
|
||||
prettier: 2.7.1
|
||||
rimraf: 3.0.2
|
||||
tslib: 2.4.1
|
||||
|
Loading…
Reference in New Issue
Block a user