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:
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: {},
},