taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit ccd755e39d730e3973429e4fd0418461cc0b4b34
parent 242b965f3baee5bf22fde034db390edec6d53333
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Thu, 28 May 2026 08:08:48 -0300

notifcations NG with user stories for testing

Diffstat:
Apackages/web-util/src/hooks/index.stories.ts | 2++
Apackages/web-util/src/hooks/useNotifications.stories.tsx | 468+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/hooks/useNotifications.ts | 383+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Apackages/web-util/src/hooks/useNotificationsDeprecated.stories.tsx | 353+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/stories.tsx | 3++-
5 files changed, 1110 insertions(+), 99 deletions(-)

diff --git a/packages/web-util/src/hooks/index.stories.ts b/packages/web-util/src/hooks/index.stories.ts @@ -0,0 +1,2 @@ +export * as a1 from "./useNotifications.stories.js"; +export * as a2 from "./useNotificationsDeprecated.stories.js"; diff --git a/packages/web-util/src/hooks/useNotifications.stories.tsx b/packages/web-util/src/hooks/useNotifications.stories.tsx @@ -0,0 +1,468 @@ +/* + 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { + AbsoluteTime, + opEmptySuccess, + opFixedSuccess, + opKnownFailure, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { Attention, Button, delayMs } from "../index.browser.js"; +import * as tests from "../tests/hook.js"; +import { + newSafeHandlerBuilder, + Notification, + useNotificationHandler, +} from "./useNotifications.js"; + +export default { + title: "Use Notifications NG", +}; + +export const autoTriggered = tests.createExample(() => { + const safe = newSafeHandlerBuilder({}); + const [count, setCount] = useState(0); + function inc() { + setCount((n) => n + 1); + } + const action = safe(async (ct) => { + await delayMs(1500, ct); + inc(); + return opEmptySuccess(); + }, []); + useEffect(() => { + const id = setInterval(() => { + action.call(); + }, 3_000); + return () => { + clearInterval(id); + }; + }); + return ( + <div> + <div> + This button is going to be cliked programatically every 3 seconds, + unless is already working. While the action is active it can't be + trigered concurrently. Cancel should prevent the action to complete. + </div> + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + <Button + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action} + > + button + </Button> + <button + class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" + onClick={action.call} + > + call directly + </button> + <button + class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" + onClick={action.cancel} + > + cancel + </button> + </div> + </div> + ); +}, {}); + +export const withCancel = tests.createExample(() => { + const safe = newSafeHandlerBuilder({}); + const [count, setCount] = useState(0); + function inc() { + setCount((n) => n + 1); + } + const action = safe(async (ct) => { + await delayMs(5_000, ct); + inc(); + return opEmptySuccess(); + }, []); + return ( + <div> + <div>This button will take 5 secs to complete but can be cancelled</div> + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + <button + class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" + onClick={action.cancel} + > + cancel + </button> + <Button + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action} + > + button + </Button> + </div> + </div> + ); +}, {}); + +export const sharedButton = tests.createExample(() => { + const safe = newSafeHandlerBuilder({}); + const [count, setCount] = useState(0); + function inc(d: number) { + setCount((n) => n + d); + } + + const action = safe(async (ct, size: number) => { + await delayMs(1500, ct); + inc(size); + return opEmptySuccess(); + }); + + return ( + <div> + <div>These buttons tiggers the same action but with different args. </div> + <p>It should block both buttons while working.</p> + <p>The "cancel" button it should cancel any of the button.</p> + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + <Button + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action.withArgs(1)} + > + button +1 + </Button> + <Button + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action.withArgs(2)} + > + button +2 + </Button> + <button + class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" + onClick={action.cancel} + > + cancel + </button> + </div> + </div> + ); +}, {}); + +export const conditionally = tests.createExample(() => { + const { notification, showSuccess, clearOnStart } = useNotificationHandler(); + const safe = newSafeHandlerBuilder({}); + const [count, setCount] = useState(0); + const [name, setName] = useState(""); + function inc() { + setCount((n) => n + 1); + } + const action = safe( + async (ct, s: string) => { + await delayMs(500); + inc(); + return opFixedSuccess(s); + }, + name.length > 4 ? [name] : undefined, + ); + action.addListener(clearOnStart); + action.onSuccess = showSuccess( + (d) => `the name ${d} is a good name` as TranslatedString, + ); + + return ( + <div> + <div> + This actions is disabled until the conditions on the forms are met. It + should show succeed on click + </div> + <input + class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 " + onChange={(e) => { + setName(e.currentTarget.value); + }} + placeholder={"The name should be greater than 4 characters"} + /> + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + <Button + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action} + > + button + </Button> + </div> + <ShowMessage n={notification} /> + </div> + ); +}, {}); + +export const messages = tests.createExample(() => { + const { notification, showError, showSuccess, clearOnStart } = + useNotificationHandler(); + const safe = newSafeHandlerBuilder({}); + const [name, setName] = useState(""); + const [count, setCount] = useState(0); + function inc() { + setCount((n) => n + 1); + return count + 1; + } + const action = safe( + async function (ct, name: string) { + await delayMs(500); + if (!name.length) return opKnownFailure("no-name"); + if (name.length <= 4) return opKnownFailure("short-name"); + const id = inc(); + + return opFixedSuccess(id); + }, + [name], + ); + action.addListener(clearOnStart); + action.onFail = showError( + "the operation failed" as TranslatedString, + (fail) => { + switch (fail.case) { + case "no-name": + return "please enter a name" as TranslatedString; + case "short-name": + return "the name is not long enough" as TranslatedString; + } + }, + ); + action.onSuccess = showSuccess((s) => { + return `person updated, operation id: ${s}` as TranslatedString; + }); + return ( + <div> + <div>Fail if the input is invalid, shows success otherwise</div> + <input + class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 " + onChange={(e) => { + setName(e.currentTarget.value); + }} + placeholder={"The name should be greater than 4 characters"} + /> + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + <Button + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action} + > + button + </Button> + </div> + <ShowMessage n={notification} /> + </div> + ); +}, {}); + +export const confirm = tests.createExample(() => { + const safe = newSafeHandlerBuilder({}); + const [name, setName] = useState(""); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const { notification, showError, showSuccess, clearOnStart } = + useNotificationHandler(); + const [count, setCount] = useState(0); + function inc() { + setCount((n) => n + 1); + return count + 1; + } + const action = safe( + async function (ct, name: string, confirm?: boolean) { + await delayMs(500); + if (!name.length) return opKnownFailure("no-name"); + if (name.length <= 4) return opKnownFailure("short-name"); + if (!confirm) { + return opKnownFailure("confirm"); + } + const id = inc(); + + return opFixedSuccess(name + id); + }, + [name], + ); + + action.addListener(clearOnStart); + action.onFail = showError( + "the operation failed" as TranslatedString, + (fail) => { + switch (fail.case) { + case "no-name": + return "please enter a name" as TranslatedString; + case "short-name": + return "the name is not long enough" as TranslatedString; + case "confirm": + setShowConfirmDialog(true); + return undefined; + } + }, + ); + + action.onSuccess = showSuccess((s) => { + setShowConfirmDialog(false); + return `person updated, operation id: ${s}` as TranslatedString; + }); + + const confirm = action.lambda( + (prev) => (!prev ? undefined : [prev[0], true]), + [], + ); + + return ( + <div> + <div>It will ask for a confirmaton if didn't fail and before succed</div> + <p>When the value is correct it will ask for confirmation</p> + <p>After confirmation the operation should succeed</p> + <p>On fail it should show error dialog</p> + + <input + class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 " + onChange={(e) => { + setName(e.currentTarget.value); + }} + placeholder={"The name should be greater than 4 characters"} + /> + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + {!showConfirmDialog ? ( + <Button + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action} + > + agree + </Button> + ) : ( + <Fragment> + <button + class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" + onClick={() => setShowConfirmDialog(false)} + > + cancel + </button> + + <Button + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600" + onClick={confirm} + > + confirm + </Button> + </Fragment> + )} + </div> + <ShowMessage n={notification} /> + </div> + ); +}, {}); + +export const unhandledError = tests.createExample(() => { + const { notification, displayError, displayInfo, clearOnStart } = + useNotificationHandler(); + const safeHandler = newSafeHandlerBuilder({ + onError: (error) => { + displayError(`unpexpected error` as TranslatedString, error); + }, + onFail: (error) => { + displayError(`the operation failed` as TranslatedString, error); + }, + onSuccess: (body) => { + displayInfo( + `operation succeeded: ${JSON.stringify(body)}` as TranslatedString, + ); + }, + }); + const [count, setCount] = useState(0); + function inc() { + setCount((n) => n + 1); + return count + 1; + } + const action = safeHandler(async function (ct, name: string) { + await delayMs(1500, ct); + if (!name.length) throw Error("missing name"); + if (name.length <= 4) return opKnownFailure("short-name"); + const id = inc(); + + return opFixedSuccess(name + id); + }); + + action.addListener(clearOnStart); + const ok = action.withArgs("taler"); + const fail = action.withArgs("qwe"); + const error = action.withArgs(""); + + return ( + <div> + <div> + This buttons doesnt have set a particular handler on success and on + error but it should work anyway. + <p>"ok" button should show a info notification when clicked.</p> + <p>"cancel" should not show any error</p> + <p>"fail" should show a fail operation based on a validation</p> + <p>"error" should show a unexpected error</p> + </div> + + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + <Button + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={ok} + > + ok + </Button> + <button + class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" + onClick={ok.cancel} + > + cancel + </button> + + <Button + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={fail} + > + fail + </Button> + <Button + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={error} + > + error + </Button> + </div> + <ShowMessage n={notification} /> + </div> + ); +}, {}); + +function ShowMessage({ n }: { n?: Notification }): VNode { + if (!n) return <Fragment />; + return ( + <Attention + type={n.message.type} + title={n.message.title} + onClose={() => { + n.acknowledge(); + }} + > + {n.message.type === "error" ? n.message.description : undefined} + </Attention> + ); +} diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts @@ -1,7 +1,7 @@ import { AbsoluteTime, assertUnreachable, - Duration, + CancellationToken, OperationAlternative, OperationFail, OperationOk, @@ -10,10 +10,9 @@ import { TalerErrorCode, TranslatedString, } from "@gnu-taler/taler-util"; -import { useEffect, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; import { InternationalizationAPI, - memoryMap, useTranslationContext, } from "../index.browser.js"; @@ -37,93 +36,11 @@ export interface InfoNotification { when: AbsoluteTime; } -const storage = memoryMap<Map<string, NotificationMessage>>(); -const NOTIFICATION_KEY = "notification"; - -export const GLOBAL_NOTIFICATION_TIMEOUT = Duration.fromSpec({ - seconds: 5, -}); - -function updateInStorage(n: NotificationMessage) { - const h = hash(n); - const mem = storage.get(NOTIFICATION_KEY) ?? new Map(); - const newState = new Map(mem); - newState.set(h, n); - storage.set(NOTIFICATION_KEY, newState); -} - -export function notify(notif: NotificationMessage): void { - const currentState: Map<string, NotificationMessage> = - storage.get(NOTIFICATION_KEY) ?? new Map(); - const newState = currentState.set(hash(notif), notif); - - if (GLOBAL_NOTIFICATION_TIMEOUT.d_ms !== "forever") { - setTimeout(() => { - notif.timeout = true; - updateInStorage(notif); - }, GLOBAL_NOTIFICATION_TIMEOUT.d_ms); - } - - storage.set(NOTIFICATION_KEY, newState); -} -export function notifyError( - title: TranslatedString, - description: TranslatedString | undefined, - debug?: any, -) { - notify({ - type: "error" as const, - title, - description: description ? [description] : undefined, - debug, - when: AbsoluteTime.now(), - }); -} -export function notifyException(title: TranslatedString, ex: Error) { - notify({ - type: "error" as const, - title, - description: [ex.message as TranslatedString], - debug: ex.stack, - when: AbsoluteTime.now(), - }); -} -export function notifyInfo(title: TranslatedString) { - notify({ - type: "info" as const, - title, - when: AbsoluteTime.now(), - }); -} - export type Notification = { message: NotificationMessage; acknowledge: () => void; }; -export function useNotifications(): Notification[] { - const [, setLastUpdate] = useState<number>(); - const value = storage.get(NOTIFICATION_KEY) ?? new Map(); - - useEffect(() => { - return storage.onUpdate(NOTIFICATION_KEY, () => { - setLastUpdate(Date.now()); - // const mem = storage.get(NOTIFICATION_KEY) ?? new Map(); - // setter(structuredClone(mem)); - }); - }); - - return Array.from(value.values()).map((message, idx) => { - return { - message, - acknowledge: () => { - message.ack = true; - updateInStorage(message); - }, - }; - }); -} - function hashCode(str: string): string { if (str.length === 0) return "0"; let hash = 0; @@ -149,6 +66,82 @@ function hash(msg: NotificationMessage): string { return hashCode(str); } +export function useNotificationHandler() { + const [notification, setNotification] = useState<Notification>(); + function displayError( + title: TranslatedString, + debug: Error | TalerError | unknown | undefined, + ...description: TranslatedString[] + ) { + setNotification({ + message: { + title, + type: "error", + when: AbsoluteTime.now(), + description, + debug, + }, + acknowledge: () => setNotification(undefined), + }); + } + function displayInfo(title: TranslatedString) { + setNotification({ + message: { + title, + type: "info", + when: AbsoluteTime.now(), + }, + acknowledge: () => setNotification(undefined), + }); + } + + function showError<T extends FunctionThatReturnsVoid<T>>( + title: TranslatedString, + h: ReplaceReturnType<T, TranslatedString | TranslatedString[] | undefined>, + ): T { + return ((...args: Parameters<T>): void => { + const n = h(...args); + if (n === undefined) return; + if (Array.isArray(n)) { + displayError(title, args, ...n); + } else { + displayError(title, args, n); + } + }) as T; + } + + function showSuccess<T extends FunctionThatReturnsVoid<T>>( + h: ReplaceReturnType<T, TranslatedString | undefined>, + ): T { + return ((...args: Parameters<T>): void => { + const n = h(...args); + if (n === undefined) return; + displayInfo(n); + }) as T; + } + + function clearOnStart(e: "start" | "finish") { + if (e === "start") { + notification?.acknowledge(); + } + } + + return { + notification, + showError, + showSuccess, + displayError, + displayInfo, + clearOnStart, + }; +} + +export type ReplaceReturnType<T, TNewReturn> = T extends (...a: any) => any + ? (...a: Parameters<T>) => TNewReturn + : never; +export type Params<T> = T extends (...a: infer P) => any ? P : never; +export type FunctionThatReturnsVoid<T> = (...a: Params<T>) => void; + /** * A function that may fail and return a message to be shown * as a notification @@ -198,8 +191,15 @@ export function useLocalNotificationBetter(): [ a: Args | undefined, doAction: (...args: Args) => Promise<R>, ): SafeHandlerTemplate<Args, R> { + const s = CancellationToken.create(); + let running = false; const thiz: SafeHandlerTemplate<Args, R> = { args: a, + cancel: () => { + if (running) { + s.cancel(); + } + }, withArgs: (...newArgs) => { const r = buildSafeHandler(newArgs, doAction); r.onSuccess = thiz.onSuccess; @@ -241,10 +241,14 @@ export function useLocalNotificationBetter(): [ return r as any as SH; }, call: async (): Promise<void> => { - if (!thiz.args) return; + if (!thiz.args || running) return; + running = true; try { - thiz.onStart(); - const resp = await doAction(...thiz.args); + thiz.onStart.forEach((listener) => { + listener(); + }); + + const resp = await s.token.racePromise(doAction(...thiz.args)); switch (resp.type) { case "ok": { const msg = thiz.onSuccess(resp.body, ...thiz.args); @@ -265,6 +269,9 @@ export function useLocalNotificationBetter(): [ } } } catch (error: unknown) { + if (error instanceof CancellationToken.CancellationError) { + return; + } // This functions should not throw, this is a problem. logBugForDevelopers(error); onUnexpected( @@ -273,12 +280,26 @@ export function useLocalNotificationBetter(): [ save, )(error, thiz.args); return; + } finally { + thiz.onComplete.forEach((listener) => { + listener(); + }); + running = false; } }, onFail: (fail, ...rest) => i18n.str`Unhandled failure trying to ${opName}. Code ${fail.case}`, onSuccess: () => undefined, - onStart: () => undefined, + onStart: [], + onComplete: [], + addCompleteListener: (h) => { + thiz.onComplete.push(h); + return thiz; + }, + addStartListener: (h) => { + thiz.onStart.push(h); + return thiz; + }, }; return thiz; } @@ -288,7 +309,163 @@ export function useLocalNotificationBetter(): [ return [notif, safeFunctionHandler]; } -export function logBugForDevelopers(error: unknown) { +export interface SafeHandler<Args extends any[], OpType> { + readonly args: Args | undefined; + readonly listeners: ((e: "start" | "finish") => void)[]; + + call(): Promise<void>; + cancel(): void; + + /** + * creates another handler with new arguments + */ + withArgs(...args: Args): SafeHandler<Args, Error>; + + /** + * derive another handler but convert the arguments before calling + */ + lambda<OtherArgs extends any[]>( + convert: ( + prevArgs: Args | undefined, + nextArgs: OtherArgs, + ) => Args | undefined, + init?: OtherArgs, + ): SafeHandler<OtherArgs, OpType>; + + onSuccess: OnOperationSuccesReturnType_NG<OpType, Args>; + onFail: OnOperationFailReturnType_NG<OpType, Args>; + + addListener: ( + h: (event: "start" | "finish") => void, + ) => SafeHandler<Args, Error>; +} + +interface InnerSafeHandler<Args extends any[], R> extends SafeHandler<Args, R> { + cts: { + source: CancellationToken.Source; + running: boolean; + }; +} + +function noop(): undefined {} + +export function newSafeHandlerBuilder< + A extends any[], + B extends OperationResult<any, any>, +>( + opts: { + onSuccess?: (result: unknown, ...args: any[]) => void; + onFail?: (fail: unknown, ...args: any[]) => void; + onError?: (e: unknown, ...args: any[]) => void; + listeners?: (SafeHandler<unknown[],unknown>["listeners"]); + } = {}, +) { + return function newSafeHandler<Args extends A, R extends B>( + doAction: (t: CancellationToken, ...args: Args) => Promise<R>, + a?: Args, + ): SafeHandler<Args, R> { + const thiz: InnerSafeHandler<Args, R> = { + args: a, + cts: { + source: CancellationToken.create(), + running: false, + }, + cancel: () => { + thiz.cts.source.cancel(); + thiz.cts.source = CancellationToken.create(); + }, + withArgs: (...newArgs) => { + const r = newSafeHandler(doAction, newArgs); + // @ts-expect-error + r.listeners = thiz.listeners; + // @ts-expect-error + r.cts = thiz.cts; + r.cancel = thiz.cancel; + r.onSuccess = thiz.onSuccess; + r.onFail = thiz.onFail; + return r; + }, + lambda: (converter, init) => { + type D = Parameters<typeof converter>[1]; + type SH = SafeHandler<D, R>; + + const r = newSafeHandler( + doAction, + init !== undefined ? converter(thiz.args, init) : undefined, + ); + // @ts-expect-error + r.withArgs = (...args: D) => { + const d = converter(thiz.args, args); + if (d === undefined) return thiz; + const e = thiz.withArgs(...d); + // @ts-expect-error + e.listeners = r.listeners; + // @ts-expect-error + e.cts = r.cts; + e.cancel = r.cancel; + e.onSuccess = r.onSuccess; + e.onFail = r.onFail; + return e; + }; + // @ts-expect-error + r.listeners = thiz.listeners; + // @ts-expect-error + r.cts = thiz.cts; + r.cancel = thiz.cancel; + r.onSuccess = thiz.onSuccess; + r.onFail = thiz.onFail; + return r as any as SH; + }, + call: async (): Promise<void> => { + if (!thiz.args || thiz.cts.running) return; + thiz.cts.running = true; + try { + thiz.listeners.forEach((listener) => { + listener("start"); + }); + const resp = await thiz.cts.source.token.racePromise( + doAction(thiz.cts.source.token, ...thiz.args), + ); + switch (resp.type) { + case "ok": { + thiz.onSuccess(resp.body, ...thiz.args); + return; + } + case "fail": { + thiz.onFail(resp as any, ...thiz.args); + return; + } + default: { + assertUnreachable(resp); + } + } + } catch (error: unknown) { + if (error instanceof CancellationToken.CancellationError) { + return; + } + logBugForDevelopers(error); + (opts.onError ?? noop)(error, ...thiz.args); + return; + } finally { + thiz.listeners.forEach((listener) => { + listener("finish"); + }); + thiz.cts.running = false; + } + }, + onFail: opts.onFail ?? noop, + onSuccess: opts.onSuccess ?? noop, + listeners: opts.listeners ?? [], + addListener: (h) => { + thiz.listeners.push(h); + return thiz; + }, + }; + return thiz; + }; +} + +function logBugForDevelopers(error: unknown) { console.error( `Internal error, this is mostly a bug in the application. Please report: `, error, @@ -312,7 +489,7 @@ function notUndefined<T>(t: T | undefined): t is T { return !!t; } -function translateTalerError( +export function translateTalerError( cause: TalerError, i18n: InternationalizationAPI, ): TranslatedString[] { @@ -459,11 +636,6 @@ function sanitizeFunctionArguments(args: any[]): string { .join(", "); } -interface AppEvents<Errors, Args> { - "on-success": { userId: string; timestamp: number }; - "on-fail": undefined; // Or void if no payload -} - /** * A function converted into a safe handler. * @@ -471,10 +643,13 @@ interface AppEvents<Errors, Args> { */ export interface SafeHandlerTemplate<Args extends any[], Errors> { readonly args: Args | undefined; + readonly onStart: (() => void)[]; + readonly onComplete: (() => void)[]; /** * call the action with the arguments */ call(): Promise<void>; + cancel(): void; /** * creates another handler for the same actions but different arguments * @param e @@ -491,7 +666,8 @@ export interface SafeHandlerTemplate<Args extends any[], Errors> { onSuccess: OnOperationSuccesReturnType<Errors, Args>; onFail: OnOperationFailReturnType<Errors, Args>; - onStart: () => void; + addStartListener: (h: () => void) => SafeHandlerTemplate<Args, Error>; + addCompleteListener: (h: () => void) => SafeHandlerTemplate<Args, Error>; } function successWithTitle(title: TranslatedString): NotificationMessage { @@ -517,7 +693,6 @@ function failWithTitle( detail: fail.detail, case: fail.case, when: AbsoluteTime.now(), - // args: sanitizeFunctionArguments(args), }, when: AbsoluteTime.now(), }; @@ -536,3 +711,15 @@ export type OnOperationFailReturnType<T, K extends any[]> = ( ) => TranslatedString | undefined; export type OnOperationUnexpectedFailReturnType = (e: unknown) => void; + +export type OnOperationSuccesReturnType_NG<T, K extends any[]> = ( + result: T extends OperationOk<infer B> ? B : never, + ...args: K +) => void; + +export type OnOperationFailReturnType_NG<T, K extends any[]> = ( + d: + | (T extends OperationFail<any> ? T : never) + | (T extends OperationAlternative<any, any> ? T : never), + ...args: K +) => void; diff --git a/packages/web-util/src/hooks/useNotificationsDeprecated.stories.tsx b/packages/web-util/src/hooks/useNotificationsDeprecated.stories.tsx @@ -0,0 +1,353 @@ +/* + 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { + opEmptySuccess, + opFixedSuccess, + opKnownFailure, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { + Attention, + ButtonBetter, + delayMs, + ToastBanner, +} from "../index.browser.js"; +import * as tests from "../tests/hook.js"; +import { + Notification, + useLocalNotificationBetter, +} from "./useNotifications.js"; + +export default { + title: "UseNotificationsDeprecated", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +export const autoTriggered = tests.createExample(() => { + const [notif, safe] = useLocalNotificationBetter(); + const [count, setCount] = useState(0); + function inc() { + setCount((n) => n + 1); + } + const action = safe( + "label" as TranslatedString, + async () => { + await delayMs(1500); + inc(); + return opEmptySuccess(); + }, + [], + ); + useEffect(() => { + const id = setTimeout(() => { + action.call(); + }, 3_000); + return () => { + clearTimeout(id); + }; + }); + return ( + <div> + <div> + This button is going to be cliked programatically every 3 seconds, + unless is already working. While the action is active it can't be + trigered agian until completes. + </div> + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + <ButtonBetter + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action} + > + button + </ButtonBetter> + <button + class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" + onClick={() => action.call()} + > + call directly + </button> + </div> + </div> + ); +}, {}); + +export const withCancel = tests.createExample(() => { + const [notif, safe] = useLocalNotificationBetter(); + const [count, setCount] = useState(0); + function inc() { + setCount((n) => n + 1); + } + const action = safe( + "label" as TranslatedString, + async () => { + await delayMs(5_000); + inc(); + return opEmptySuccess(); + }, + [], + ); + return ( + <div> + <div>This button will take 5 secs to complete but can be cancelled</div> + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + <button + class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" + onClick={action.cancel} + > + cancel + </button> + <ButtonBetter + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action} + > + button + </ButtonBetter> + </div> + </div> + ); +}, {}); + +export const sharedButton = tests.createExample(() => { + const [notif, safe] = useLocalNotificationBetter(); + const [count, setCount] = useState(0); + function inc(d: number) { + setCount((n) => n + d); + } + const action = safe("label" as TranslatedString, async (size: number) => { + await delayMs(1_000); + inc(size); + return opEmptySuccess(); + }); + return ( + <div> + <div>These buttons tiggers the same action but with different args</div> + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + <ButtonBetter + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action.withArgs(1)} + > + button +1 + </ButtonBetter> + <ButtonBetter + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action.withArgs(2)} + > + button +2 + </ButtonBetter> + </div> + </div> + ); +}, {}); + +export const conditionally = tests.createExample(() => { + const [notif, safe] = useLocalNotificationBetter(); + const [count, setCount] = useState(0); + const [name, setName] = useState(""); + function inc() { + setCount((n) => n + 1); + } + const action = safe( + "label" as TranslatedString, + async (s: string) => { + await delayMs(500); + inc(); + return opFixedSuccess(s); + }, + name.length > 4 ? [name] : undefined, + ); + action.onSuccess = (d) => `the name ${d} is a good name` as TranslatedString; + return ( + <div> + <div>Only with the field is valid</div> + <ShowMessage n={notif} /> + <input + class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 " + onChange={(e) => { + setName(e.currentTarget.value); + }} + placeholder={"The name should be greater than 4 characters"} + /> + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + <ButtonBetter + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action} + > + button + </ButtonBetter> + </div> + </div> + ); +}, {}); + +export const messages = tests.createExample(() => { + const [notif, safe] = useLocalNotificationBetter(); + const [name, setName] = useState(""); + const [count, setCount] = useState(0); + function inc() { + setCount((n) => n + 1); + return count + 1; + } + const action = safe( + "label" as TranslatedString, + async function (name: string) { + await delayMs(500); + if (!name.length) return opKnownFailure("no-name"); + if (name.length < 4) return opKnownFailure("short-name"); + const id = inc(); + + return opFixedSuccess(id); + }, + [name], + ); + action.onFail = (fail) => { + switch (fail.case) { + case "no-name": + return "please enter a name" as TranslatedString; + case "short-name": + return "the name is not long enough" as TranslatedString; + } + }; + action.onSuccess = (s) => { + return `person updated, operation id: ${s}` as TranslatedString; + }; + return ( + <div> + <div>Fail if the input is invalid, shows success otherwise</div> + <ToastBanner /> + <input + class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 " + onChange={(e) => { + setName(e.currentTarget.value); + }} + placeholder={"The name should be greater than 4 characters"} + /> + <div>click {count} times</div> + <div class="grid gap-2 w-40"> + <ButtonBetter + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action} + > + button + </ButtonBetter> + </div> + </div> + ); +}, {}); + +function ShowMessage({ n }: { n?: Notification }): VNode { + if (!n) return <Fragment />; + return ( + <Attention + type={n.message.type} + title={n.message.title} + onClose={() => { + n.acknowledge(); + }} + > + {n.message.type === "error" ? n.message.description : undefined} + </Attention> + ); +} + +export const confirm = tests.createExample(() => { + const [notif, safe] = useLocalNotificationBetter(); + const [name, setName] = useState(""); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [count, setCount] = useState(0); + function inc() { + setCount((n) => n + 1); + return count + 1; + } + const action = safe( + "label" as TranslatedString, + async function (name: string, confirm?: boolean) { + await delayMs(500); + if (!name.length) return opKnownFailure("no-name"); + if (name.length < 4) return opKnownFailure("short-name"); + if (!confirm) { + return opKnownFailure("confirm"); + } + const id = inc(); + + return opFixedSuccess(name + id); + }, + [name], + ); + action.onFail = (fail) => { + switch (fail.case) { + case "no-name": + return "please enter a name" as TranslatedString; + case "short-name": + return "the name is not long enough" as TranslatedString; + case "confirm": + setShowConfirmDialog(true); + return undefined; + } + }; + action.onSuccess = (s) => { + setShowConfirmDialog(false); + return `person updated, operation id: ${s}` as TranslatedString; + }; + const confirm = action.lambda(() => [action.args![0], true], []); + + return ( + <div> + <div>It will ask for a confirmaton if didn't fail and before succed</div> + <ShowMessage n={notif} /> + + <input + class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 " + onChange={(e) => { + setName(e.currentTarget.value); + }} + placeholder={"The name should be greater than 4 characters"} + /> + <div>click {count} times</div> + {!showConfirmDialog ? ( + <ButtonBetter + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={action} + > + agree + </ButtonBetter> + ) : ( + <ButtonBetter + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600" + onClick={confirm} + > + confirm + </ButtonBetter> + )} + </div> + ); +}, {}); diff --git a/packages/web-util/src/stories.tsx b/packages/web-util/src/stories.tsx @@ -23,11 +23,12 @@ * Imports. */ import * as forms from "./forms/index.stories.js"; +import * as hooks from "./hooks/index.stories.js"; import { renderStories } from "./stories-utils.js"; function main(): void { renderStories( - { forms }, + { forms, hooks }, { strings: {}, },