diff options
author | Sebastian <sebasjm@gmail.com> | 2024-03-11 14:57:03 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2024-03-11 14:57:49 -0300 |
commit | 6e02a3852590f39cdd414a1caf89506bcd9dc83a (patch) | |
tree | 3fddf6ae92eaf3abed6a702531f2d3536e023449 | |
parent | 37f46f4d6b821d163c3e4db5c374b1120212ac74 (diff) |
obs and cancel request, plus lint
-rw-r--r-- | packages/web-util/src/components/Button.tsx | 36 | ||||
-rw-r--r-- | packages/web-util/src/components/CopyButton.tsx | 5 | ||||
-rw-r--r-- | packages/web-util/src/components/ErrorLoading.tsx | 28 | ||||
-rw-r--r-- | packages/web-util/src/components/Header.tsx | 39 | ||||
-rw-r--r-- | packages/web-util/src/components/NotificationBanner.tsx | 4 | ||||
-rw-r--r-- | packages/web-util/src/components/ToastBanner.tsx | 59 | ||||
-rw-r--r-- | packages/web-util/src/hooks/useNotifications.ts | 68 | ||||
-rw-r--r-- | packages/web-util/src/utils/http-impl.sw.ts | 5 |
8 files changed, 191 insertions, 53 deletions
diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx index 26b778eec..ea0ea2f38 100644 --- a/packages/web-util/src/components/Button.tsx +++ b/packages/web-util/src/components/Button.tsx @@ -1,8 +1,24 @@ -import { OperationFail, OperationOk, OperationResult, TalerError, TranslatedString } from "@gnu-taler/taler-util"; +/* + This file is part of GNU Taler + (C) 2022-2024 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 { AbsoluteTime, OperationFail, OperationOk, OperationResult, TalerError, TranslatedString } from "@gnu-taler/taler-util"; // import { NotificationMessage, notifyInfo } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { HTMLAttributes, useEffect, useState, useTransition } from "preact/compat"; -import { NotificationMessage, buildRequestErrorMessage, notifyInfo, useTranslationContext } from "../index.browser.js"; +import { NotificationMessage, buildUnifiedRequestErrorMessage, notifyInfo, useTranslationContext } from "../index.browser.js"; // import { useBankCoreApiContext } from "../context/config.js"; // function errorMap<T extends OperationFail<unknown>>(resp: T, map: (d: T["case"]) => TranslatedString): void { @@ -10,7 +26,7 @@ import { NotificationMessage, buildRequestErrorMessage, notifyInfo, useTranslati export interface ButtonHandler<T extends OperationResult<A, B>, A, B> { onClick: () => Promise<T | undefined>, onNotification: (n: NotificationMessage) => void; - onOperationSuccess: ((result:T extends OperationOk<any> ? T :never) => void) | ((result:T extends OperationOk<any> ? T :never) => TranslatedString | undefined), + onOperationSuccess: ((result: T extends OperationOk<any> ? T : never) => void) | ((result: T extends OperationOk<any> ? T : never) => TranslatedString | undefined), onOperationFail: (d: T extends OperationFail<any> ? T : never) => TranslatedString; onOperationComplete?: () => void; } @@ -33,10 +49,10 @@ export function Button<T extends OperationResult<A, B>, A, B>({ handler, children, disabled, - onClick:clickEvent, + onClick: clickEvent, ...rest }: Props<T, A, B>): VNode { - const {i18n} = useTranslationContext(); + const { i18n } = useTranslationContext(); const [running, setRunning] = useState(false) return <button {...rest} disabled={disabled || running} onClick={(e) => { e.preventDefault(); @@ -62,6 +78,7 @@ export function Button<T extends OperationResult<A, B>, A, B>({ type: "error", description: error.detail.hint as TranslatedString, debug: error.detail, + when: AbsoluteTime.now(), }) } } @@ -71,17 +88,18 @@ export function Button<T extends OperationResult<A, B>, A, B>({ setRunning(false) }).catch(error => { console.error(error) - + if (error instanceof TalerError) { - handler.onNotification(buildRequestErrorMessage(i18n, error)) + handler.onNotification(buildUnifiedRequestErrorMessage(i18n, error)) } else { const description = (error instanceof Error ? error.message : String(error)) as TranslatedString - + handler.onNotification({ title: i18n.str`Operation failed`, type: "error", description, + when: AbsoluteTime.now(), }) } @@ -95,7 +113,7 @@ export function Button<T extends OperationResult<A, B>, A, B>({ </button> } -function Wait():VNode { +function Wait(): VNode { return <Fragment> <style>{` #l1 { width: 120px; diff --git a/packages/web-util/src/components/CopyButton.tsx b/packages/web-util/src/components/CopyButton.tsx index e76447291..fd7f8b3b4 100644 --- a/packages/web-util/src/components/CopyButton.tsx +++ b/packages/web-util/src/components/CopyButton.tsx @@ -38,7 +38,10 @@ export function CopyButton({ class: clazz, getContent }: { class: string, getCon if (!copied) { return ( - <button class={clazz} onClick={copyText} > + <button class={clazz} onClick={e => { + e.preventDefault() + copyText() + }} > <CopyIcon /> </button> ); diff --git a/packages/web-util/src/components/ErrorLoading.tsx b/packages/web-util/src/components/ErrorLoading.tsx index 02f2a3282..7089266b9 100644 --- a/packages/web-util/src/components/ErrorLoading.tsx +++ b/packages/web-util/src/components/ErrorLoading.tsx @@ -26,6 +26,34 @@ export function ErrorLoading({ error, showDetail }: { error: TalerError, showDet ////////////////// // Every error that can be produce in a Http Request ////////////////// + case TalerErrorCode.GENERIC_TIMEOUT: { + if (error.hasErrorCode(TalerErrorCode.GENERIC_TIMEOUT)) { + const { requestMethod, requestUrl, timeoutMs } = error.errorDetail + return <Attention type="danger" title={i18n.str`The request reached a timeout, check your connection.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR: { + if (error.hasErrorCode(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR)) { + const { requestMethod, requestUrl, timeoutMs } = error.errorDetail + return <Attention type="danger" title={i18n.str`The request was cancelled.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, timeoutMs }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: { if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT)) { const { requestMethod, requestUrl, timeoutMs } = error.errorDetail diff --git a/packages/web-util/src/components/Header.tsx b/packages/web-util/src/components/Header.tsx index fc7716320..29f4a4949 100644 --- a/packages/web-util/src/components/Header.tsx +++ b/packages/web-util/src/components/Header.tsx @@ -1,12 +1,24 @@ import { useState } from "preact/hooks"; -import { LangSelector, useTranslationContext } from "../index.browser.js"; +import { LangSelector, useNotifications, useTranslationContext } from "../index.browser.js"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import logo from "../assets/logo-2021.svg"; -export function Header({ title, profileURL, iconLinkURL, sites, onLogout, children }: - { title: string, iconLinkURL: string, profileURL?: string, children?: ComponentChildren, onLogout: (() => void) | undefined, sites: Array<Array<string>>, supportedLangs: string[] }): VNode { +interface Props { + title: string; + iconLinkURL: string; + profileURL?: string; + notificationURL?: string; + children?: ComponentChildren; + onLogout: (() => void) | undefined; + sites: Array<Array<string>>; + supportedLangs: string[] +} + +export function Header({ title, profileURL, notificationURL, iconLinkURL, sites, onLogout, children }: Props): VNode { const { i18n } = useTranslationContext(); const [open, setOpen] = useState(false) + const ns = useNotifications(); + return <Fragment> <header class="bg-indigo-600 w-full mx-auto px-2 border-b border-opacity-25 border-indigo-400"> <div class="flex flex-row h-16 items-center "> @@ -35,6 +47,22 @@ export function Header({ title, profileURL, iconLinkURL, sites, onLogout, childr </div> </div> <div class="flex justify-end"> + {!notificationURL ? undefined : + <a href={notificationURL} name="notifications" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 mr-2 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" aria-controls="mobile-menu" aria-expanded="false"> + <span class="absolute -inset-0.5"></span> + <span class="sr-only"><i18n.Translate>Show notifications</i18n.Translate></span> + {ns.length > 0 ? + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-10 h-10"> + <path d="M5.85 3.5a.75.75 0 0 0-1.117-1 9.719 9.719 0 0 0-2.348 4.876.75.75 0 0 0 1.479.248A8.219 8.219 0 0 1 5.85 3.5ZM19.267 2.5a.75.75 0 1 0-1.118 1 8.22 8.22 0 0 1 1.987 4.124.75.75 0 0 0 1.48-.248A9.72 9.72 0 0 0 19.266 2.5Z" /> + <path fill-rule="evenodd" d="M12 2.25A6.75 6.75 0 0 0 5.25 9v.75a8.217 8.217 0 0 1-2.119 5.52.75.75 0 0 0 .298 1.206c1.544.57 3.16.99 4.831 1.243a3.75 3.75 0 1 0 7.48 0 24.583 24.583 0 0 0 4.83-1.244.75.75 0 0 0 .298-1.205 8.217 8.217 0 0 1-2.118-5.52V9A6.75 6.75 0 0 0 12 2.25ZM9.75 18c0-.034 0-.067.002-.1a25.05 25.05 0 0 0 4.496 0l.002.1a2.25 2.25 0 1 1-4.5 0Z" clip-rule="evenodd" /> + </svg> + : + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-10 h-10"> + <path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" /> + </svg> + } + </a> + } {!profileURL ? undefined : <a href={profileURL} name="profile" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 mr-2 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" aria-controls="mobile-menu" aria-expanded="false"> <span class="absolute -inset-0.5"></span> @@ -58,7 +86,8 @@ export function Header({ title, profileURL, iconLinkURL, sites, onLogout, childr </div> </header> - {open && + { + open && <div class="relative z-10" name="sidebar overlay" aria-labelledby="slide-over-title" role="dialog" aria-modal="true" onClick={() => { setOpen(false) @@ -150,5 +179,5 @@ export function Header({ title, profileURL, iconLinkURL, sites, onLogout, childr </div> </div> } - </Fragment> + </Fragment > } diff --git a/packages/web-util/src/components/NotificationBanner.tsx b/packages/web-util/src/components/NotificationBanner.tsx index 62733ab3c..31d5a5d01 100644 --- a/packages/web-util/src/components/NotificationBanner.tsx +++ b/packages/web-util/src/components/NotificationBanner.tsx @@ -9,7 +9,7 @@ export function LocalNotificationBanner({ notification, showDebug }: { notificat return <div class="relative"> <div class="fixed top-0 left-0 right-0 z-20 w-full p-4"> <Attention type="danger" title={notification.message.title} onClose={() => { - notification.remove() + notification.acknowledge() }}> {notification.message.description && <div class="mt-2 text-sm text-red-700"> @@ -26,7 +26,7 @@ export function LocalNotificationBanner({ notification, showDebug }: { notificat return <div class="relative"> <div class="fixed top-0 left-0 right-0 z-20 w-full p-4"> <Attention type="success" title={notification.message.title} onClose={() => { - notification.remove(); + notification.acknowledge(); }} /></div></div> } } diff --git a/packages/web-util/src/components/ToastBanner.tsx b/packages/web-util/src/components/ToastBanner.tsx index 2424b17ff..ece26285f 100644 --- a/packages/web-util/src/components/ToastBanner.tsx +++ b/packages/web-util/src/components/ToastBanner.tsx @@ -1,5 +1,20 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { Fragment, VNode, h } from "preact" -import { Attention, GLOBAL_NOTIFICATION_TIMEOUT as GLOBAL_TOAST_TIMEOUT, useNotifications } from "../index.browser.js" +import { Attention, GLOBAL_NOTIFICATION_TIMEOUT as GLOBAL_TOAST_TIMEOUT, Notification, useNotifications } from "../index.browser.js" /** * Toasts should be considered when displaying these types of information to the user: @@ -21,24 +36,26 @@ import { Attention, GLOBAL_NOTIFICATION_TIMEOUT as GLOBAL_TOAST_TIMEOUT, useNoti export function ToastBanner(): VNode { const notifs = useNotifications() if (notifs.length === 0) return <Fragment /> - return <Fragment> { - notifs.map(n => { - switch (n.message.type) { - case "error": - return <Attention type="danger" title={n.message.title} onClose={() => { - n.remove() - }} timeout={GLOBAL_TOAST_TIMEOUT}> - {n.message.description && - <div class="mt-2 text-sm text-red-700"> - {n.message.description} - </div> - } - </Attention> - case "info": - return <Attention type="success" title={n.message.title} onClose={() => { - n.remove(); - }} timeout={GLOBAL_TOAST_TIMEOUT} /> - } - })} - </Fragment> + const show = notifs.filter(e => !e.message.ack && !e.message.timeout) + if (show.length === 0) return <Fragment /> + return <AttentionByType msg={show[0]} /> +} + +function AttentionByType({ msg }: { msg: Notification }) { + switch (msg.message.type) { + case "error": + return <Attention type="danger" title={msg.message.title} onClose={() => { + msg.acknowledge() + }} timeout={GLOBAL_TOAST_TIMEOUT}> + {msg.message.description && + <div class="mt-2 text-sm text-red-700"> + {msg.message.description} + </div> + } + </Attention> + case "info": + return <Attention type="success" title={msg.message.title} onClose={() => { + msg.acknowledge(); + }} timeout={GLOBAL_TOAST_TIMEOUT} /> + } } diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts index 000abbc94..99f4f2699 100644 --- a/packages/web-util/src/hooks/useNotifications.ts +++ b/packages/web-util/src/hooks/useNotifications.ts @@ -1,4 +1,5 @@ import { + AbsoluteTime, Duration, OperationFail, OperationOk, @@ -20,12 +21,18 @@ export type NotificationMessage = ErrorNotification | InfoNotification; export interface ErrorNotification { type: "error"; title: TranslatedString; + ack?: boolean; + timeout?: boolean; description?: TranslatedString; debug?: any; + when: AbsoluteTime; } export interface InfoNotification { type: "info"; title: TranslatedString; + ack?: boolean; + timeout?: boolean; + when: AbsoluteTime; } const storage = memoryMap<Map<string, NotificationMessage>>(); @@ -35,11 +42,11 @@ export const GLOBAL_NOTIFICATION_TIMEOUT = Duration.fromSpec({ seconds: 5, }); -function removeFromStorage(n: NotificationMessage) { +function updateInStorage(n: NotificationMessage) { const h = hash(n); const mem = storage.get(NOTIFICATION_KEY) ?? new Map(); const newState = new Map(mem); - newState.delete(h); + newState.set(h, n); storage.set(NOTIFICATION_KEY, newState); } @@ -50,7 +57,8 @@ export function notify(notif: NotificationMessage): void { if (GLOBAL_NOTIFICATION_TIMEOUT.d_ms !== "forever") { setTimeout(() => { - removeFromStorage(notif); + notif.timeout = true; + updateInStorage(notif); }, GLOBAL_NOTIFICATION_TIMEOUT.d_ms); } @@ -66,6 +74,7 @@ export function notifyError( title, description, debug, + when: AbsoluteTime.now(), }); } export function notifyException(title: TranslatedString, ex: Error) { @@ -74,34 +83,40 @@ export function notifyException(title: TranslatedString, ex: Error) { 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; - remove: () => void; + acknowledge: () => void; }; export function useNotifications(): Notification[] { - const [value, setter] = useState<Map<string, NotificationMessage>>(new Map()); + const [, setLastUpdate] = useState<number>(); + const value = storage.get(NOTIFICATION_KEY) ?? new Map(); + useEffect(() => { return storage.onUpdate(NOTIFICATION_KEY, () => { - const mem = storage.get(NOTIFICATION_KEY) ?? new Map(); - setter(structuredClone(mem)); + setLastUpdate(Date.now()) + // const mem = storage.get(NOTIFICATION_KEY) ?? new Map(); + // setter(structuredClone(mem)); }); }); return Array.from(value.values()).map((message, idx) => { return { message, - remove: () => { - removeFromStorage(message); + acknowledge: () => { + message.ack = true; + updateInStorage(message); }, }; }); @@ -141,6 +156,7 @@ function errorMap<T extends OperationFail<unknown>>( title: map(resp.case), description: resp.detail.hint as TranslatedString, debug: resp.detail, + when: AbsoluteTime.now(), }); } @@ -165,7 +181,7 @@ export function useLocalNotification(): [ ? undefined : { message: value, - remove: () => { + acknowledge: () => { setter(undefined); }, }; @@ -175,7 +191,7 @@ export function useLocalNotification(): [ return await cb(errorMap); } catch (error: unknown) { if (error instanceof TalerError) { - notify(buildRequestErrorMessage(i18n, error)); + notify(buildUnifiedRequestErrorMessage(i18n, error)); } else { notifyError( i18n.str`Operation failed, please report`, @@ -212,7 +228,7 @@ export function useLocalNotificationHandler(): [ ? undefined : { message: value, - remove: () => { + acknowledge: () => { setter(undefined); }, }; @@ -241,18 +257,39 @@ export function useLocalNotificationHandler(): [ return [notif, makeHandler, setter]; } -export function buildRequestErrorMessage( +export function buildUnifiedRequestErrorMessage( i18n: InternationalizationAPI, cause: TalerError, ): ErrorNotification { let result: ErrorNotification; switch (cause.errorDetail.code) { + case TalerErrorCode.GENERIC_TIMEOUT: { + result = { + type: "error", + title: i18n.str`Request timeout`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } + case TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR: { + result = { + type: "error", + title: i18n.str`Request cancelled`, + description: cause.message as TranslatedString, + debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), + }; + break; + } case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: { result = { type: "error", title: i18n.str`Request timeout`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -262,6 +299,7 @@ export function buildRequestErrorMessage( title: i18n.str`Request throttled`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -271,6 +309,7 @@ export function buildRequestErrorMessage( title: i18n.str`Malformed response`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -280,6 +319,7 @@ export function buildRequestErrorMessage( title: i18n.str`Network error`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -289,6 +329,7 @@ export function buildRequestErrorMessage( title: i18n.str`Unexpected request error`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -298,6 +339,7 @@ export function buildRequestErrorMessage( title: i18n.str`Unexpected error`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } diff --git a/packages/web-util/src/utils/http-impl.sw.ts b/packages/web-util/src/utils/http-impl.sw.ts index 316b75dfd..4d7f3a8a1 100644 --- a/packages/web-util/src/utils/http-impl.sw.ts +++ b/packages/web-util/src/utils/http-impl.sw.ts @@ -22,6 +22,7 @@ import { TalerErrorCode, TalerError, Duration, + CancellationToken, } from "@gnu-taler/taler-util"; import { @@ -137,13 +138,13 @@ export class BrowserFetchHttpLib implements HttpRequestLibrary { } catch (e) { if (controller.signal) { throw TalerError.fromDetail( - TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT, + controller.signal.reason, { requestUrl, requestMethod, timeoutMs: requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms }, - `request to ${requestUrl} timed out`, + `HTTP request failed.`, ); } throw e; |