diff options
Diffstat (limited to 'packages/web-util/src')
76 files changed, 5045 insertions, 746 deletions
diff --git a/packages/web-util/src/components/Attention.tsx b/packages/web-util/src/components/Attention.tsx index b85230a1b..4172c0c9b 100644 --- a/packages/web-util/src/components/Attention.tsx +++ b/packages/web-util/src/components/Attention.tsx @@ -1,36 +1,53 @@ -import { TranslatedString, assertUnreachable } from "@gnu-taler/taler-util"; +import { Duration, TranslatedString, assertUnreachable } from "@gnu-taler/taler-util"; import { ComponentChildren, Fragment, VNode, h } from "preact"; interface Props { - type?: "info" | "success" | "warning" | "danger", + type?: "info" | "success" | "warning" | "danger" | "low", onClose?: () => void, title: TranslatedString, children?: ComponentChildren, + timeout?: Duration, } -export function Attention({ type = "info", title, children, onClose }: Props): VNode { +export function Attention({ type = "info", title, children, onClose, timeout = Duration.getForever() }: Props): VNode { + return <div class={`group attention-${type} mt-2 shadow-lg`}> - <div class="rounded-md group-[.attention-info]:bg-blue-50 group-[.attention-warning]:bg-yellow-50 group-[.attention-danger]:bg-red-50 group-[.attention-success]:bg-green-50 p-4 shadow"> + {timeout.d_ms === "forever" ? undefined : <style>{` + .progress { + animation: notificationTimeoutBar ${Math.round(timeout.d_ms / 1000)}s ease-in-out; + animation-fill-mode:both; + } + + @keyframes notificationTimeoutBar { + 0% { width: 0; } + 100% { width: 100%; } + } + `}</style> + } + + <div data-timed={timeout.d_ms !== "forever"} class="rounded-md data-[timed=true]:rounded-b-none group-[.attention-info]:bg-blue-50 group-[.attention-low]:bg-gray-100 group-[.attention-warning]:bg-yellow-50 group-[.attention-danger]:bg-red-50 group-[.attention-success]:bg-green-50 p-4 shadow"> <div class="flex"> <div > - <svg xmlns="http://www.w3.org/2000/svg" stroke="none" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 group-[.attention-info]:text-blue-400 group-[.attention-warning]:text-yellow-400 group-[.attention-danger]:text-red-400 group-[.attention-success]:text-green-400"> - {(() => { - switch (type) { - case "info": - return <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" /> - case "warning": - return <path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" /> - case "danger": - return <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" /> - case "success": - return <path fill-rule="evenodd" d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 016 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 012.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 00.322-1.672V3a.75.75 0 01.75-.75 2.25 2.25 0 012.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 01-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 00-1.423-.23h-.777zM2.331 10.977a11.969 11.969 0 00-.831 4.398 12 12 0 00.52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 01-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227z" /> - default: - assertUnreachable(type) - } - })()} - </svg> + {type === "low" ? undefined : + <svg xmlns="http://www.w3.org/2000/svg" stroke="none" viewBox="0 0 24 24" fill="currentColor" class="w-8 h-8 group-[.attention-info]:text-blue-400 group-[.attention-warning]:text-yellow-400 group-[.attention-danger]:text-red-400 group-[.attention-success]:text-green-400"> + {(() => { + switch (type) { + case "info": + return <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" /> + case "warning": + return <path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" /> + case "danger": + return <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" /> + case "success": + return <path fill-rule="evenodd" d="M7.493 18.75c-.425 0-.82-.236-.975-.632A7.48 7.48 0 016 15.375c0-1.75.599-3.358 1.602-4.634.151-.192.373-.309.6-.397.473-.183.89-.514 1.212-.924a9.042 9.042 0 012.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 00.322-1.672V3a.75.75 0 01.75-.75 2.25 2.25 0 012.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 01-2.649 7.521c-.388.482-.987.729-1.605.729H14.23c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 00-1.423-.23h-.777zM2.331 10.977a11.969 11.969 0 00-.831 4.398 12 12 0 00.52 3.507c.26.85 1.084 1.368 1.973 1.368H4.9c.445 0 .72-.498.523-.898a8.963 8.963 0 01-.924-3.977c0-1.708.476-3.305 1.302-4.666.245-.403-.028-.959-.5-.959H4.25c-.832 0-1.612.453-1.918 1.227z" /> + default: + assertUnreachable(type) + } + })()} + </svg> + } </div> <div class="ml-3 w-full"> - <h3 class="text-sm group-hover:text-white font-bold group-[.attention-info]:text-blue-800 group-[.attention-success]:text-green-800 group-[.attention-warning]:text-yellow-800 group-[.attention-danger]:text-red-800"> + <h3 class="text-sm font-bold group-[.attention-info]:text-blue-800 group-[.attention-success]:text-green-800 group-[.attention-warning]:text-yellow-800 group-[.attention-danger]:text-red-800"> {title} </h3> <div class="mt-2 text-sm group-[.attention-info]:text-blue-700 group-[.attention-warning]:text-yellow-700 group-[.attention-danger]:text-red-700 group-[.attention-success]:text-green-700"> @@ -53,6 +70,11 @@ export function Attention({ type = "info", title, children, onClose }: Props): V } </div> </div> + {timeout.d_ms === "forever" ? undefined : + <div class="meter group-[.attention-info]:bg-blue-50 group-[.attention-low]:bg-gray-100 group-[.attention-warning]:bg-yellow-50 group-[.attention-danger]:bg-red-50 group-[.attention-success]:bg-green-50 h-1 relative overflow-hidden -mt-1"> + <span class="w-full h-full block"><span class="h-full block progress group-[.attention-info]:bg-blue-600 group-[.attention-low]:bg-gray-600 group-[.attention-warning]:bg-yellow-600 group-[.attention-danger]:bg-red-600 group-[.attention-success]:bg-green-600"></span></span> + </div> + } </div> } diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx new file mode 100644 index 000000000..b142114e7 --- /dev/null +++ b/packages/web-util/src/components/Button.tsx @@ -0,0 +1,167 @@ +/* + 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, + OperationAlternative, + 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, useState } from "preact/compat"; +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 { + +export type OnOperationSuccesReturnType<T> = ( + result: T extends OperationOk<any> ? T : never, +) => TranslatedString | void; +export type OnOperationFailReturnType<T> = ( + (d: (T extends OperationFail<any> ? T : never) | (T extends OperationAlternative<any,any> ? T : never)) => TranslatedString) + +export interface ButtonHandler<T extends OperationResult<A, B>, A, B> { + onClick: () => Promise<T | undefined>; + onNotification: (n: NotificationMessage) => void; + onOperationSuccess: OnOperationSuccesReturnType<T>; + onOperationFail?: OnOperationFailReturnType<T>; + onOperationComplete?: () => void; +} + +interface Props<T extends OperationResult<A, B>, A, B> + extends HTMLAttributes<HTMLButtonElement> { + handler: ButtonHandler<T, A, B> | undefined; +} + +/** + * This button accept an async function and report a notification + * on error or success. + * + * When the async function is running the inner text will change into + * a "loading" animation. + * + * @param param0 + * @returns + */ +export function Button<T extends OperationResult<A, B>, A, B>({ + handler, + children, + disabled, + onClick: clickEvent, + ...rest +}: Props<T, A, B>): VNode { + const { i18n } = useTranslationContext(); + const [running, setRunning] = useState(false); + return ( + <button + {...rest} + disabled={disabled || running} + onClick={(e) => { + e.preventDefault(); + if (!handler) { + return; + } + setRunning(true); + handler + .onClick() + .then((resp) => { + if (resp) { + if (resp.type === "ok") { + const result: OperationOk<any> = resp; + // @ts-expect-error this is an operationOk + const msg = handler.onOperationSuccess(result); + if (msg) { + notifyInfo(msg); + } + } + if (resp.type === "fail") { + const d = 'detail' in resp ? resp.detail : undefined + + const title = !handler.onOperationFail ? "Unexpected error." as TranslatedString : handler.onOperationFail(resp as any); + handler.onNotification({ + title, + type: "error", + description: d && d.hint ? d.hint as TranslatedString : undefined, + debug: d, + when: AbsoluteTime.now(), + }); + } + } + if (handler.onOperationComplete) { + handler.onOperationComplete(); + } + setRunning(false); + }) + .catch((error) => { + console.error(error); + + if (error instanceof TalerError) { + 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(), + }); + } + + if (handler.onOperationComplete) { + handler.onOperationComplete(); + } + setRunning(false); + }); + }} + > + {running ? <Wait /> : children} + </button> + ); +} + +function Wait(): VNode { + return ( + <Fragment> + <style> + {` + #l1 { width: 120px; + height: 20px; + -webkit-mask: radial-gradient(circle closest-side, currentColor 90%, #0000) left/20% 100%; + background: linear-gradient(currentColor 0 0) left/0% 100% no-repeat #ddd; + animation: l17 2s infinite steps(6); + } + @keyframes l17 { + 100% {background-size:120% 100%} +`} + </style> + <div id="l1" /> + </Fragment> + ); +} diff --git a/packages/web-util/src/components/CopyButton.tsx b/packages/web-util/src/components/CopyButton.tsx index e76447291..dbb38b474 100644 --- a/packages/web-util/src/components/CopyButton.tsx +++ b/packages/web-util/src/components/CopyButton.tsx @@ -1,4 +1,4 @@ -import { h, VNode } from "preact"; +import { ComponentChildren, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; export function CopyIcon(): VNode { @@ -17,7 +17,7 @@ export function CopiedIcon(): VNode { ) }; -export function CopyButton({ class: clazz, getContent }: { class: string, getContent: () => string }): VNode { +export function CopyButton({ class: clazz, children, getContent }: { children?: ComponentChildren, class: string, getContent: () => string }): VNode { const [copied, setCopied] = useState(false); function copyText(): void { if (!navigator.clipboard && !window.isSecureContext) { @@ -38,14 +38,19 @@ 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 /> + {children} </button> ); } return ( <button class={clazz} disabled> <CopiedIcon /> + {children} </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/ErrorLoadingMerchant.tsx b/packages/web-util/src/components/ErrorLoadingMerchant.tsx new file mode 100644 index 000000000..7089266b9 --- /dev/null +++ b/packages/web-util/src/components/ErrorLoadingMerchant.tsx @@ -0,0 +1,147 @@ +/* +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { TalerError, TalerErrorCode, assertUnreachable } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { Attention } from "./Attention.js"; +import { useTranslationContext } from "../index.browser.js"; + +export function ErrorLoading({ error, showDetail }: { error: TalerError, showDetail?: boolean }): VNode { + const { i18n } = useTranslationContext() + switch (error.errorDetail.code) { + ////////////////// + // 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 + 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.WALLET_HTTP_REQUEST_THROTTLED: { + if (error.hasErrorCode(TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED)) { + const { requestMethod, requestUrl, throttleStats } = error.errorDetail + return <Attention type="danger" title={i18n.str`A lot of request were made to the same server and this action was throttled`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, throttleStats }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: { + if (error.hasErrorCode(TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE)) { + const { requestMethod, requestUrl, httpStatusCode, validationError } = error.errorDetail + return <Attention type="danger" title={i18n.str`The response of the request is malformed.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, validationError }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_NETWORK_ERROR: { + if (error.hasErrorCode(TalerErrorCode.WALLET_NETWORK_ERROR)) { + const { requestMethod, requestUrl } = error.errorDetail + return <Attention type="danger" title={i18n.str`Could not complete the request due to a network problem.`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { + if (error.hasErrorCode(TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR)) { + const { requestMethod, requestUrl, httpStatusCode, errorResponse } = error.errorDetail + return <Attention type="danger" title={i18n.str`Unexpected request error`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify({ requestMethod, requestUrl, httpStatusCode, errorResponse }, undefined, 2)} + </pre> + } + </Attention> + } + assertUnreachable(1 as never) + } + ////////////////// + // Every other error + ////////////////// + // case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { + // return <Attention type="danger" title={i18n.str``}> + // </Attention> + // } + ////////////////// + // Default message for unhandled case + ////////////////// + default: return <Attention type="danger" title={i18n.str`Unexpected error`}> + {error.message} + {showDetail && + <pre class="whitespace-break-spaces "> + {JSON.stringify(error.errorDetail, undefined, 2)} + </pre> + } + </Attention> + } +} + diff --git a/packages/web-util/src/components/GlobalNotificationBanner.tsx b/packages/web-util/src/components/GlobalNotificationBanner.tsx deleted file mode 100644 index c8049acc3..000000000 --- a/packages/web-util/src/components/GlobalNotificationBanner.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Fragment, VNode, h } from "preact" -import { Attention, useNotifications } from "../index.browser.js" - -export function GlobalNotificationsBanner(): VNode { - const notifs = useNotifications() - if (notifs.length === 0) return <Fragment /> - return <div class="fixed z-20 w-full p-4"> { - notifs.map(n => { - switch (n.message.type) { - case "error": - return <Attention type="danger" title={n.message.title} onClose={() => { - n.remove() - }}> - {n.message.description && - <div class="mt-2 text-sm text-red-700"> - {n.message.description} - </div> - } - {/* <MaybeShowDebugInfo info={n.message.debug} /> */} - </Attention> - case "info": - return <Attention type="success" title={n.message.title} onClose={() => { - n.remove(); - }} /> - } - })} - </div> -} diff --git a/packages/web-util/src/components/Header.tsx b/packages/web-util/src/components/Header.tsx index a0587b2ae..29f4a4949 100644 --- a/packages/web-util/src/components/Header.tsx +++ b/packages/web-util/src/components/Header.tsx @@ -1,18 +1,30 @@ 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, iconLinkURL, sites, supportedLangs, onLogout, children }: - { title: string, iconLinkURL: 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 "> <div class="flex px-2 justify-start"> <div class="flex-shrink-0 bg-white rounded-lg"> - <a href={iconLinkURL ?? "#"}> + <a href={iconLinkURL ?? "#"} name="logo"> <img class="h-8 w-auto" src={logo} @@ -30,12 +42,37 @@ export function Header({ title, iconLinkURL, sites, supportedLangs, onLogout, ch {sites.map((site) => { if (site.length !== 2) return; const [name, url] = site - return <a href={url} class="hidden sm:block text-white hover:bg-indigo-500 hover:bg-opacity-75 rounded-md py-2 px-3 text-sm font-medium">{name}</a> + return <a href={url} name={`site header ${name}`} class="hidden sm:block text-white hover:bg-indigo-500 hover:bg-opacity-75 rounded-md py-2 px-3 text-sm font-medium">{name}</a> })} </div> </div> <div class="flex justify-end"> - <button type="button" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 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" + {!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> + <span class="sr-only"><i18n.Translate>Open profile</i18n.Translate></span> + <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="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> + </svg> + </a> + } + <button type="button" name="toggle sidebar" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 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" onClick={(e) => { setOpen(!open) }}> @@ -49,8 +86,9 @@ export function Header({ title, iconLinkURL, sites, supportedLangs, onLogout, ch </div> </header> - {open && - <div class="relative z-10" aria-labelledby="slide-over-title" role="dialog" aria-modal="true" + { + open && + <div class="relative z-10" name="sidebar overlay" aria-labelledby="slide-over-title" role="dialog" aria-modal="true" onClick={() => { setOpen(false) }}> @@ -70,7 +108,7 @@ export function Header({ title, iconLinkURL, sites, supportedLangs, onLogout, ch <i18n.Translate>Menu</i18n.Translate> </h2> <div class="ml-3 flex h-7 items-center"> - <button type="button" class="relative rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" + <button type="button" name="close sidebar" class="relative rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" onClick={(e) => { setOpen(false) }} @@ -93,6 +131,7 @@ export function Header({ title, iconLinkURL, sites, supportedLangs, onLogout, ch {onLogout ? <li> <a href="#" + name="logout" class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold" onClick={() => { onLogout(); @@ -107,26 +146,29 @@ export function Header({ title, iconLinkURL, sites, supportedLangs, onLogout, ch </li> : undefined} <li> - <LangSelector supportedLangs={supportedLangs} /> + <LangSelector /> </li> {/* CHILDREN */} {children} {/* /CHILDREN */} - <li class="block sm:hidden"> - <div class="text-xs font-semibold leading-6 text-gray-400"> - <i18n.Translate>Sites</i18n.Translate> - </div> - <ul role="list" class="space-y-1"> - {sites.map(([name, url]) => { - return <li> - <a href={url} target="_blank" rel="noopener noreferrer" class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"> - <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white text-gray-400 border-gray-200 group-hover:border-indigo-600 group-hover:text-indigo-600">></span> - <span class="truncate">{name}</span> - </a> - </li> - })} - </ul> - </li> + {sites.length > 0 ? + <li class="block sm:hidden"> + <div class="text-xs font-semibold leading-6 text-gray-400"> + <i18n.Translate>Sites</i18n.Translate> + </div> + <ul role="list" class="space-y-1"> + {sites.map(([name, url]) => { + return <li> + <a href={url} name={`site ${name}`} target="_blank" rel="noopener noreferrer" class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"> + <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white text-gray-400 border-gray-200 group-hover:border-indigo-600 group-hover:text-indigo-600">></span> + <span class="truncate">{name}</span> + </a> + </li> + })} + </ul> + </li> + : undefined + } </ul> </nav> </div> @@ -137,5 +179,5 @@ export function Header({ title, iconLinkURL, sites, supportedLangs, onLogout, ch </div> </div> } - </Fragment> + </Fragment > } diff --git a/packages/web-util/src/components/LangSelector.tsx b/packages/web-util/src/components/LangSelector.tsx index a8d910129..8e5a82f75 100644 --- a/packages/web-util/src/components/LangSelector.tsx +++ b/packages/web-util/src/components/LangSelector.tsx @@ -43,9 +43,9 @@ function getLangName(s: keyof LangsNames | string): string { return String(s); } -export function LangSelector({ supportedLangs }: { supportedLangs: string[] }): VNode { +export function LangSelector({ }: {}): VNode { const [updatingLang, setUpdatingLang] = useState(false); - const { lang, changeLanguage } = useTranslationContext(); + const { lang, changeLanguage, completeness, supportedLang } = useTranslationContext(); const [hidden, setHidden] = useState(true); useEffect(() => { @@ -66,8 +66,9 @@ export function LangSelector({ supportedLangs }: { supportedLangs: string[] }): <div> <div class="relative mt-2"> <button type="button" class="relative w-full cursor-default rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" - onClick={() => { - setHidden((h) => !h); + onClick={(e) => { + setHidden(!hidden); + e.stopPropagation() }}> <span class="flex items-center"> <img alt="language" class="h-5 w-5 flex-shrink-0 rounded-full" src={langIcon} /> @@ -82,7 +83,7 @@ export function LangSelector({ supportedLangs }: { supportedLangs: string[] }): {!hidden && <ul class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" tabIndex={-1} role="listbox" aria-labelledby="listbox-label" aria-activedescendant="listbox-option-3"> - {supportedLangs + {Object.keys(supportedLang) .filter((l) => l !== lang) .map((lang) => ( <li class="text-gray-900 hover:bg-indigo-600 hover:text-white cursor-pointer relative select-none py-2 pl-3 pr-9" role="option" @@ -92,7 +93,10 @@ export function LangSelector({ supportedLangs }: { supportedLangs: string[] }): setHidden(true) }} > - <span class="font-normal block truncate">{getLangName(lang)}</span> + <span class="font-normal truncate flex justify-between "> + <span>{getLangName(lang)}</span> + <span>{(completeness as any)[lang]}%</span> + </span> <span class="text-indigo-600 absolute inset-y-0 right-0 flex items-center pr-4"> {/* <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> diff --git a/packages/web-util/src/components/LocalNotificationBanner.tsx b/packages/web-util/src/components/NotificationBanner.tsx index ab46703cb..31d5a5d01 100644 --- a/packages/web-util/src/components/LocalNotificationBanner.tsx +++ b/packages/web-util/src/components/NotificationBanner.tsx @@ -1,23 +1,24 @@ import { h, Fragment, VNode } from "preact"; import { Attention } from "./Attention.js"; import { Notification } from "../index.browser.js"; -// import { useSettings } from "../hooks/settings.js"; -export function LocalNotificationBanner({ notification }: { notification?: Notification }): VNode { +export function LocalNotificationBanner({ notification, showDebug }: { notification?: Notification, showDebug?: boolean }): VNode { if (!notification) return <Fragment /> switch (notification.message.type) { case "error": 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"> {notification.message.description} </div> } - {/* <MaybeShowDebugInfo info={notification.message.debug} /> */} + {showDebug && <pre class="whitespace-break-spaces "> + {notification.message.debug} + </pre>} </Attention> </div> </div> @@ -25,19 +26,8 @@ export function LocalNotificationBanner({ notification }: { notification?: Notif 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> } } - -// function MaybeShowDebugInfo({ info }: { info: any }): VNode { -// const [settings] = useSettings() -// if (settings.showDebugInfo) { -// return <pre class="whitespace-break-spaces "> -// {info} -// </pre> -// } -// return <Fragment /> -// } - diff --git a/packages/web-util/src/components/ToastBanner.tsx b/packages/web-util/src/components/ToastBanner.tsx new file mode 100644 index 000000000..ece26285f --- /dev/null +++ b/packages/web-util/src/components/ToastBanner.tsx @@ -0,0 +1,61 @@ +/* + 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, Notification, useNotifications } from "../index.browser.js" + +/** + * Toasts should be considered when displaying these types of information to the user: + * + * Low attention messages that do not require user action + * Singular status updates + * Confirmations + * Information that does not need to be followed up + * + * Do not use toasts if the information contains the following: + * + * High attention and crtitical information + * Time-sensitive information + * Requires user action or input + * Batch updates + * + * @returns + */ +export function ToastBanner(): VNode { + const notifs = useNotifications() + if (notifs.length === 0) return <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/components/index.ts b/packages/web-util/src/components/index.ts index d94502b48..d7ea41874 100644 --- a/packages/web-util/src/components/index.ts +++ b/packages/web-util/src/components/index.ts @@ -6,6 +6,7 @@ export * from "./LangSelector.js"; export * from "./Loading.js"; export * from "./Header.js"; export * from "./Footer.js"; +export * from "./Button.js"; export * from "./ShowInputErrorLabel.js"; -export * from "./LocalNotificationBanner.js"; -export * from "./GlobalNotificationBanner.js"; +export * from "./NotificationBanner.js"; +export * from "./ToastBanner.js"; diff --git a/packages/web-util/src/components/utils.ts b/packages/web-util/src/components/utils.ts index 34693f7d7..75c3fc0fe 100644 --- a/packages/web-util/src/components/utils.ts +++ b/packages/web-util/src/components/utils.ts @@ -12,6 +12,7 @@ export function compose<SType extends { status: string }, PType>( hook: (p: PType) => RecursiveState<SType>, viewMap: StateViewMap<SType>, ): (p: PType) => VNode { + function withHook(stateHook: () => RecursiveState<SType>): () => VNode { function ComposedComponent(): VNode { const state = stateHook(); @@ -35,6 +36,33 @@ export function compose<SType extends { status: string }, PType>( }; } +export function recursive<PType>( + hook: (p: PType) => RecursiveState<VNode>, +): (p: PType) => VNode { + + function withHook(stateHook: () => RecursiveState<VNode>): () => VNode { + function ComposedComponent(): VNode { + const state = stateHook(); + + if (typeof state === "function") { + const subComponent = withHook(state); + return createElement(subComponent, {}); + } + + return state; + } + + return ComposedComponent; + } + + return (p: PType) => { + const h = withHook(() => hook(p)); + return h(); + }; +} + + + /** * * @param obj VNode diff --git a/packages/web-util/src/context/activity.ts b/packages/web-util/src/context/activity.ts new file mode 100644 index 000000000..d12d1efb6 --- /dev/null +++ b/packages/web-util/src/context/activity.ts @@ -0,0 +1,76 @@ +/* + 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 { ChallengerHttpClient, ObservabilityEvent, TalerAuthenticationHttpClient, TalerBankConversionHttpClient, TalerCoreBankHttpClient, TalerExchangeHttpClient, TalerMerchantInstanceHttpClient, TalerMerchantManagementHttpClient } from "@gnu-taler/taler-util"; + +type Listener<Event> = (e: Event) => void; +type Unsuscriber = () => void; +export type Subscriber<Event> = (fn: Listener<Event>) => Unsuscriber; + +export class ActiviyTracker<Event> { + private observers = new Array<Listener<Event>>(); + constructor() { + this.notify = this.notify.bind(this) + this.subscribe = this.subscribe.bind(this) + } + notify(data: Event): void { + this.observers.forEach((observer) => observer(data)) + } + subscribe(func: Listener<Event>): Unsuscriber { + this.observers.push(func); + return () => { + this.observers.forEach((observer, index) => { + if (observer === func) { + this.observers.splice(index, 1); + } + }); + }; + } +} + +/** + * build http client with cache breaker due to SWR + * @param url + * @returns + */ +export interface APIClient<T, C> { + getRemoteConfig(): Promise<C>; + VERSION: string; + lib: T, + onActivity: Subscriber<ObservabilityEvent>; + cancelRequest(id: string): void; +} + +export interface MerchantLib { + instance: TalerMerchantManagementHttpClient; + authenticate: TalerAuthenticationHttpClient; + subInstanceApi: (instanceId: string) => MerchantLib; +} + +export interface ExchangeLib { + exchange: TalerExchangeHttpClient; +} + +export interface BankLib { + bank: TalerCoreBankHttpClient; + conversion: TalerBankConversionHttpClient; + auth: (user: string) => TalerAuthenticationHttpClient; +} + +export interface ChallengerLib { + challenger: ChallengerHttpClient; +} + diff --git a/packages/web-util/src/context/api.ts b/packages/web-util/src/context/api.ts index b4a82065b..c1eaa37f8 100644 --- a/packages/web-util/src/context/api.ts +++ b/packages/web-util/src/context/api.ts @@ -25,6 +25,9 @@ import { useContext } from "preact/hooks"; import { defaultRequestHandler } from "../utils/request.js"; interface Type { + /** + * @deprecated this show not be used + */ request: typeof defaultRequestHandler; bankCore: TalerCoreBankHttpClient, bankIntegration: TalerBankIntegrationHttpClient, @@ -32,7 +35,7 @@ interface Type { bankRevenue: TalerRevenueHttpClient, } -const Context = createContext<Type>({request: defaultRequestHandler} as any); +const Context = createContext<Type>({ request: defaultRequestHandler } as any); export const useApiContext = (): Type => useContext(Context); export const ApiContextProvider = ({ diff --git a/packages/web-util/src/context/bank-api.ts b/packages/web-util/src/context/bank-api.ts new file mode 100644 index 000000000..3f6a32f4b --- /dev/null +++ b/packages/web-util/src/context/bank-api.ts @@ -0,0 +1,224 @@ +/* + 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 { + CacheEvictor, + LibtoolVersion, + ObservabilityEvent, + ObservableHttpClientLibrary, + TalerAuthenticationHttpClient, + TalerBankConversionCacheEviction, + TalerBankConversionHttpClient, + TalerCoreBankCacheEviction, + TalerCoreBankHttpClient, + TalerCorebankApi, + TalerError, +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + FunctionComponent, + VNode, + createContext, + h, +} from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { APIClient, ActiviyTracker, BankLib, Subscriber } from "./activity.js"; +import { useTranslationContext } from "./translation.js"; +import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type BankContextType = { + url: URL; + config: TalerCorebankApi.Config; + lib: BankLib; + hints: VersionHint[]; + onActivity: Subscriber<ObservabilityEvent>; + cancelRequest: (eventId: string) => void; +}; + +// @ts-expect-error default value to undefined, should it be another thing? +const BankContext = createContext<BankContextType>(undefined); + +export const useBankCoreApiContext = (): BankContextType => + useContext(BankContext); + +enum VersionHint { + NONE, +} + +type Evictors = { + conversion?: CacheEvictor<TalerBankConversionCacheEviction>; + bank?: CacheEvictor<TalerCoreBankCacheEviction>; +}; + +type ConfigResult<T> = + | undefined + | { type: "ok"; config: T; hints: VersionHint[] } + | { type: "incompatible"; result: T; supported: string } + | { type: "error"; error: TalerError }; + +const CONFIG_FAIL_TRY_AGAIN_MS = 5000; + +export const BankApiProvider = ({ + baseUrl, + children, + frameOnError, + evictors = {}, +}: { + baseUrl: URL; + children: ComponentChildren; + evictors?: Evictors; + frameOnError: FunctionComponent<{ children: ComponentChildren }>; +}): VNode => { + const [checked, setChecked] = + useState<ConfigResult<TalerCorebankApi.Config>>(); + const { i18n } = useTranslationContext(); + + const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = + buildBankApiClient(baseUrl, evictors); + + useEffect(() => { + let keepRetrying = true; + async function testConfig(): Promise<void> { + try { + const config = await getRemoteConfig(); + if (LibtoolVersion.compare(VERSION, config.version)) { + setChecked({ type: "ok", config, hints: [] }); + } else { + setChecked({ + type: "incompatible", + result: config, + supported: VERSION, + }); + } + } catch (error) { + if (error instanceof TalerError) { + if (keepRetrying) { + setTimeout(() => { + testConfig(); + }, CONFIG_FAIL_TRY_AGAIN_MS); + } + setChecked({ type: "error", error }); + } else { + setChecked({ type: "error", error: TalerError.fromException(error) }); + } + } + } + testConfig(); + return () => { + // on unload, stop retry + keepRetrying = false; + }; + }, []); + + if (checked === undefined) { + return h(frameOnError, { + children: h("div", {}, "checking compatibility with server..."), + }); + } + if (checked.type === "error") { + return h(frameOnError, { + children: h(ErrorLoading, { error: checked.error, showDetail: true }), + }); + } + if (checked.type === "incompatible") { + return h(frameOnError, { + children: h( + "div", + {}, + i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`, + ), + }); + } + + const value: BankContextType = { + url: baseUrl, + config: checked.config, + onActivity: onActivity, + lib, + cancelRequest, + hints: checked.hints, + }; + return h(BankContext.Provider, { + value, + children, + }); +}; + +function buildBankApiClient( + url: URL, + evictors: Evictors, +): APIClient<BankLib, TalerCorebankApi.Config> { + const httpFetch = new BrowserFetchHttpLib({ + enableThrottling: true, + requireTls: false, + }); + const tracker = new ActiviyTracker<ObservabilityEvent>(); + const httpLib = new ObservableHttpClientLibrary(httpFetch, { + observe(ev) { + tracker.notify(ev); + }, + }); + + const bank = new TalerCoreBankHttpClient(url.href, httpLib, evictors.bank); + const conversion = new TalerBankConversionHttpClient( + bank.getConversionInfoAPI().href, + httpLib, + evictors.conversion, + ); + const auth = (user: string) => + new TalerAuthenticationHttpClient( + bank.getAuthenticationAPI(user).href, + httpLib, + ); + + async function getRemoteConfig(): Promise<TalerCorebankApi.Config> { + const resp = await bank.getConfig(); + if (resp.type === "fail") { + throw TalerError.fromUncheckedDetail(resp.detail); + } + return resp.body; + } + + return { + getRemoteConfig, + VERSION: bank.PROTOCOL_VERSION, + lib: { + bank, + conversion, + auth, + }, + onActivity: tracker.subscribe, + cancelRequest: httpLib.cancelRequest, + }; +} + +export const BankApiProviderTesting = ({ + children, + value, +}: { + value: BankContextType; + children: ComponentChildren; +}): VNode => { + return h(BankContext.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/context/challenger-api.ts b/packages/web-util/src/context/challenger-api.ts new file mode 100644 index 000000000..8748f5f69 --- /dev/null +++ b/packages/web-util/src/context/challenger-api.ts @@ -0,0 +1,213 @@ +/* + 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 { + CacheEvictor, + ChallengerApi, + ChallengerCacheEviction, + ChallengerHttpClient, + LibtoolVersion, + ObservabilityEvent, + ObservableHttpClientLibrary, + TalerError +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + FunctionComponent, + VNode, + createContext, + h, +} from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js"; +import { + APIClient, + ActiviyTracker, + ChallengerLib, + Subscriber +} from "./activity.js"; +import { useTranslationContext } from "./translation.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type ChallengerContextType = { + url: URL; + config: ChallengerApi.ChallengerTermsOfServiceResponse; + lib: ChallengerLib; + hints: VersionHint[]; + onActivity: Subscriber<ObservabilityEvent>; + cancelRequest: (eventId: string) => void; +}; + +// @ts-expect-error default value to undefined, should it be another thing? +const ChallengerContext = createContext<ChallengerContextType>(undefined); + +export const useChallengerApiContext = (): ChallengerContextType => + useContext(ChallengerContext); + +enum VersionHint { + NONE, +} + +type Evictors = { + challenger?: CacheEvictor<ChallengerCacheEviction>; +} + +type ConfigResult<T> = + | undefined + | { type: "ok"; config: T; hints: VersionHint[] } + | { type: "incompatible"; result: T; supported: string } + | { type: "error"; error: TalerError }; + +const CONFIG_FAIL_TRY_AGAIN_MS = 5000; + +export const ChallengerApiProvider = ({ + baseUrl, + children, + frameOnError, + evictors = {}, +}: { + baseUrl: URL; + children: ComponentChildren; + evictors?: Evictors; + frameOnError: FunctionComponent<{ children: ComponentChildren }>; +}): VNode => { + const [checked, setChecked] = + useState<ConfigResult<ChallengerApi.ChallengerTermsOfServiceResponse>>(); + const { i18n } = useTranslationContext(); + + const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = + buildChallengerApiClient(baseUrl, evictors); + + useEffect(() => { + let keepRetrying = true; + async function testConfig(): Promise<void> { + try { + const config = await getRemoteConfig(); + if (LibtoolVersion.compare(VERSION, config.version)) { + setChecked({ type: "ok", config, hints: [] }); + } else { + setChecked({ + type: "incompatible", + result: config, + supported: VERSION, + }); + } + } catch (error) { + if (error instanceof TalerError) { + if (keepRetrying) { + setTimeout(() => { + testConfig(); + }, CONFIG_FAIL_TRY_AGAIN_MS); + } + setChecked({ type: "error", error }); + } else { + setChecked({ type: "error", error: TalerError.fromException(error) }); + } + } + } + testConfig(); + return () => { + // on unload, stop retry + keepRetrying = false; + }; + }, []); + + if (checked === undefined) { + return h(frameOnError, { + children: h("div", {}, "checking compatibility with server..."), + }); + } + if (checked.type === "error") { + return h(frameOnError, { + children: h(ErrorLoading, { error: checked.error, showDetail: true }), + }); + } + if (checked.type === "incompatible") { + return h(frameOnError, { + children: h( + "div", + {}, + i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`, + ), + }); + } + + const value: ChallengerContextType = { + url: baseUrl, + config: checked.config, + onActivity: onActivity, + lib, + cancelRequest, + hints: checked.hints, + }; + return h(ChallengerContext.Provider, { + value, + children, + }); +}; + +function buildChallengerApiClient( + url: URL, + evictors: Evictors, +): APIClient<ChallengerLib, ChallengerApi.ChallengerTermsOfServiceResponse> { + const httpFetch = new BrowserFetchHttpLib({ + enableThrottling: true, + requireTls: false, + }); + const tracker = new ActiviyTracker<ObservabilityEvent>(); + const httpLib = new ObservableHttpClientLibrary(httpFetch, { + observe(ev) { + tracker.notify(ev); + }, + }); + + const challenger = new ChallengerHttpClient(url.href, httpLib, evictors.challenger); + + async function getRemoteConfig(): Promise<ChallengerApi.ChallengerTermsOfServiceResponse> { + const resp = await challenger.getConfig(); + if (resp.type === "fail") { + throw TalerError.fromUncheckedDetail(resp.detail); + } + return resp.body; + } + + return { + getRemoteConfig, + VERSION: challenger.PROTOCOL_VERSION, + lib: { + challenger, + }, + onActivity: tracker.subscribe, + cancelRequest: httpLib.cancelRequest, + }; +} + +export const ChallengerApiProviderTesting = ({ + children, + value, +}: { + value: ChallengerContextType; + children: ComponentChildren; +}): VNode => { + return h(ChallengerContext.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/context/exchange-api.ts b/packages/web-util/src/context/exchange-api.ts new file mode 100644 index 000000000..39f889ba9 --- /dev/null +++ b/packages/web-util/src/context/exchange-api.ts @@ -0,0 +1,217 @@ +/* + 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 { + CacheEvictor, + LibtoolVersion, + ObservabilityEvent, + ObservableHttpClientLibrary, + TalerError, + TalerExchangeApi, + TalerExchangeCacheEviction, + TalerExchangeHttpClient +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + FunctionComponent, + VNode, + createContext, + h, +} from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { BrowserFetchHttpLib, ErrorLoading, useTranslationContext } from "../index.browser.js"; +import { + APIClient, + ActiviyTracker, + ExchangeLib, + Subscriber, +} from "./activity.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type ExchangeContextType = { + url: URL; + config: TalerExchangeApi.ExchangeVersionResponse; + lib: ExchangeLib; + hints: VersionHint[]; + onActivity: Subscriber<ObservabilityEvent>; + cancelRequest: (eventId: string) => void; +}; + +// FIXME: below +// @ts-expect-error default value to undefined, should it be another thing? +const ExchangeContext = createContext<ExchangeContextType>(undefined); + +export const useExchangeApiContext = (): ExchangeContextType => + useContext(ExchangeContext); + +enum VersionHint { + NONE, +} + +type Evictors = { + exchange?: CacheEvictor<TalerExchangeCacheEviction>; +}; + +type ConfigResult<T> = + | undefined + | { type: "ok"; config: T; hints: VersionHint[] } + | ConfigResultFail<T>; + +type ConfigResultFail<T> = + | { type: "incompatible"; result: T; supported: string } + | { type: "error"; error: TalerError }; + +const CONFIG_FAIL_TRY_AGAIN_MS = 5000; + +export const ExchangeApiProvider = ({ + baseUrl, + children, + evictors = {}, + frameOnError, +}: { + baseUrl: URL; + evictors?: Evictors; + children: ComponentChildren; + frameOnError: FunctionComponent<{ children: ComponentChildren }>; +}): VNode => { + const [checked, setChecked] = + useState<ConfigResult<TalerExchangeApi.ExchangeVersionResponse>>(); + const { i18n } = useTranslationContext(); + + const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = + buildExchangeApiClient(baseUrl, evictors); + + useEffect(() => { + let keepRetrying = true; + async function testConfig(): Promise<void> { + try { + const config = await getRemoteConfig(); + if (LibtoolVersion.compare(VERSION, config.version)) { + setChecked({ type: "ok", config, hints: [] }); + } else { + setChecked({ + type: "incompatible", + result: config, + supported: VERSION, + }); + } + } catch (error) { + if (error instanceof TalerError) { + if (keepRetrying) { + setTimeout(() => { + testConfig(); + }, CONFIG_FAIL_TRY_AGAIN_MS); + } + setChecked({ type: "error", error }); + } else { + setChecked({ type: "error", error: TalerError.fromException(error) }); + } + } + } + testConfig(); + return () => { + // on unload, stop retry + keepRetrying = false; + }; + }, []); + + if (checked === undefined) { + return h(frameOnError, { + children: h("div", {}, "checking compatibility with server..."), + }); + } + if (checked.type === "error") { + return h(frameOnError, { + children: h(ErrorLoading, { error: checked.error, showDetail: true }), + }); + } + if (checked.type === "incompatible") { + return h(frameOnError, { + children: h( + "div", + {}, + i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`, + ), + }); + } + + const value: ExchangeContextType = { + url: baseUrl, + config: checked.config, + onActivity: onActivity, + lib, + cancelRequest, + hints: checked.hints, + }; + return h(ExchangeContext.Provider, { + value, + children, + }); +}; + +function buildExchangeApiClient( + url: URL, + evictors: Evictors, +): APIClient<ExchangeLib, TalerExchangeApi.ExchangeVersionResponse> { + const httpFetch = new BrowserFetchHttpLib({ + enableThrottling: true, + requireTls: false, + }); + const tracker = new ActiviyTracker<ObservabilityEvent>(); + + const httpLib = new ObservableHttpClientLibrary(httpFetch, { + observe(ev) { + tracker.notify(ev); + }, + }); + + const ex = new TalerExchangeHttpClient(url.href, httpLib, evictors.exchange); + + async function getRemoteConfig(): Promise<TalerExchangeApi.ExchangeVersionResponse> { + const resp = await ex.getConfig(); + if (resp.type === "fail") { + throw TalerError.fromUncheckedDetail(resp.detail); + } + return resp.body; + } + + return { + getRemoteConfig, + VERSION: ex.PROTOCOL_VERSION, + lib: { + exchange: ex, + }, + onActivity: tracker.subscribe, + cancelRequest: httpLib.cancelRequest, + }; +} + +export const ExchangeApiProviderTesting = ({ + children, + value, +}: { + value: ExchangeContextType; + children: ComponentChildren; +}): VNode => { + return h(ExchangeContext.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/context/index.ts b/packages/web-util/src/context/index.ts index 9ed3ef645..7e30ecd09 100644 --- a/packages/web-util/src/context/index.ts +++ b/packages/web-util/src/context/index.ts @@ -4,4 +4,9 @@ export { TranslationProvider, useTranslationContext } from "./translation.js"; - +export * from "./bank-api.js"; +export * from "./challenger-api.js"; +export * from "./merchant-api.js"; +export * from "./exchange-api.js"; +export * from "./navigation.js"; +export * from "./wallet-integration.js"; diff --git a/packages/web-util/src/context/merchant-api.ts b/packages/web-util/src/context/merchant-api.ts new file mode 100644 index 000000000..03c95d48e --- /dev/null +++ b/packages/web-util/src/context/merchant-api.ts @@ -0,0 +1,228 @@ +/* + 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 { + CacheEvictor, + LibtoolVersion, + ObservabilityEvent, + ObservableHttpClientLibrary, + TalerAuthenticationHttpClient, + TalerError, + TalerMerchantApi, + TalerMerchantInstanceCacheEviction, + TalerMerchantManagementCacheEviction, + TalerMerchantManagementHttpClient, +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + FunctionComponent, + VNode, + createContext, + h, +} from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { BrowserFetchHttpLib } from "../index.browser.js"; +import { + APIClient, + ActiviyTracker, + MerchantLib, + Subscriber, +} from "./activity.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type MerchantContextType = { + url: URL; + config: TalerMerchantApi.VersionResponse; + lib: MerchantLib; + hints: VersionHint[]; + onActivity: Subscriber<ObservabilityEvent>; + cancelRequest: (eventId: string) => void; + changeBackend: (url: URL) => void; +}; + +// FIXME: below +// @ts-expect-error default value to undefined, should it be another thing? +const MerchantContext = createContext<MerchantContextType>(undefined); + +export const useMerchantApiContext = (): MerchantContextType => + useContext(MerchantContext); + +enum VersionHint { + NONE, +} + +type Evictors = { + management?: CacheEvictor< + TalerMerchantManagementCacheEviction | TalerMerchantInstanceCacheEviction + >; +}; + +type ConfigResult<T> = + | undefined + | { type: "ok"; config: T; hints: VersionHint[] } + | ConfigResultFail<T>; + +export type ConfigResultFail<T> = + | { type: "incompatible"; result: T; supported: string } + | { type: "error"; error: TalerError }; + +const CONFIG_FAIL_TRY_AGAIN_MS = 5000; + +export const MerchantApiProvider = ({ + baseUrl, + children, + evictors = {}, + frameOnError, +}: { + baseUrl: URL; + evictors?: Evictors; + children: ComponentChildren; + frameOnError: FunctionComponent<{ + state: ConfigResultFail<TalerMerchantApi.VersionResponse> | undefined; + }>; +}): VNode => { + const [checked, setChecked] = + useState<ConfigResult<TalerMerchantApi.VersionResponse>>(); + + const [merchantEndpoint, changeMerchantEndpoint] = useState(baseUrl); + + const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = + buildMerchantApiClient(merchantEndpoint, evictors); + + useEffect(() => { + let keepRetrying = true; + async function testConfig(): Promise<void> { + try { + const config = await getRemoteConfig(); + if (LibtoolVersion.compare(VERSION, config.version)) { + setChecked({ type: "ok", config, hints: [] }); + } else { + setChecked({ + type: "incompatible", + result: config, + supported: VERSION, + }); + } + } catch (error) { + if (error instanceof TalerError) { + if (keepRetrying) { + setTimeout(() => { + testConfig(); + }, CONFIG_FAIL_TRY_AGAIN_MS); + } + setChecked({ type: "error", error }); + } else { + setChecked({ type: "error", error: TalerError.fromException(error) }); + } + } + } + testConfig(); + return () => { + // on unload, stop retry + keepRetrying = false; + }; + }, []); + + if (!checked || checked.type !== "ok") { + return h(frameOnError, { state: checked }, []); + } + + const value: MerchantContextType = { + url: merchantEndpoint, + config: checked.config, + onActivity: onActivity, + lib, + cancelRequest, + changeBackend: changeMerchantEndpoint, + hints: checked.hints, + }; + return h(MerchantContext.Provider, { + value, + children, + }); +}; + +function buildMerchantApiClient( + url: URL, + evictors: Evictors, +): APIClient<MerchantLib, TalerMerchantApi.VersionResponse> { + const httpFetch = new BrowserFetchHttpLib({ + enableThrottling: true, + requireTls: false, + }); + const tracker = new ActiviyTracker<ObservabilityEvent>(); + + const httpLib = new ObservableHttpClientLibrary(httpFetch, { + observe(ev) { + tracker.notify(ev); + }, + }); + + const instance = new TalerMerchantManagementHttpClient( + url.href, + httpLib, + evictors.management, + ); + const authenticate = new TalerAuthenticationHttpClient( + instance.getAuthenticationAPI().href, + httpLib, + ); + + function getSubInstanceAPI(instanceId: string): MerchantLib { + const api = buildMerchantApiClient( + instance.getSubInstanceAPI(instanceId) as URL, + evictors, + ); + return api.lib; + } + + async function getRemoteConfig(): Promise<TalerMerchantApi.VersionResponse> { + const resp = await instance.getConfig(); + if (resp.type === "fail") { + throw TalerError.fromUncheckedDetail(resp.detail); + } + return resp.body; + } + + return { + getRemoteConfig, + VERSION: instance.PROTOCOL_VERSION, + lib: { + instance, + authenticate, + subInstanceApi: getSubInstanceAPI, + }, + onActivity: tracker.subscribe, + cancelRequest: httpLib.cancelRequest, + }; +} + +export const MerchantApiProviderTesting = ({ + children, + value, +}: { + value: MerchantContextType; + children: ComponentChildren; +}): VNode => { + return h(MerchantContext.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/context/navigation.ts b/packages/web-util/src/context/navigation.ts new file mode 100644 index 000000000..c2f2bbbc1 --- /dev/null +++ b/packages/web-util/src/context/navigation.ts @@ -0,0 +1,114 @@ +/* + 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 { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { + AppLocation, + ObjectOf, + Location, + findMatch, + RouteDefinition, +} from "../utils/route.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type Type = { + path: string; + params: Record<string, string[]>; + navigateTo: (path: AppLocation) => void; + // addNavigationListener: (listener: (path: string, params: Record<string, string>) => void) => (() => void); +}; + +// @ts-expect-error should not be used without provider +const Context = createContext<Type>(undefined); + +export const useNavigationContext = (): Type => useContext(Context); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>( + pagesMap: T, +): Location<T> | undefined { + const pageList = Object.keys(pagesMap as object) as Array<keyof T>; + const { path, params } = useNavigationContext(); + + return findMatch(pagesMap, pageList, path, params); +} + +function getPathAndParamsFromWindow(): { + path: string; + params: Record<string, string[]>; +} { + const path = + typeof window !== "undefined" ? window.location.hash.substring(1) : "/"; + const params: Record<string, string[]> = {}; + if (typeof window !== "undefined") { + for (const [key, value] of new URLSearchParams(window.location.search)) { + if (!params[key]) { + params[key] = []; + } + params[key].push(value); + } + } + return { path, params }; +} + +const { path: initialPath, params: initialParams } = + getPathAndParamsFromWindow(); + +// there is a possibility that if the browser does a redirection +// (which doesn't go through navigatTo function) and that executed +// too early (before addEventListener runs) it won't be taking +// into account +const PopStateEventType = "popstate"; + +export const BrowserHashNavigationProvider = ({ + children, +}: { + children: ComponentChildren; +}): VNode => { + const [{ path, params }, setState] = useState({ + path: initialPath, + params: initialParams, + }); + if (typeof window === "undefined") { + throw Error( + "Can't use BrowserHashNavigationProvider if there is no window object", + ); + } + function navigateTo(path: string): void { + const { params } = getPathAndParamsFromWindow(); + setState({ path, params }); + window.location.href = path; + } + + useEffect(() => { + function eventListener(): void { + setState(getPathAndParamsFromWindow()); + } + window.addEventListener(PopStateEventType, eventListener); + return () => { + window.removeEventListener(PopStateEventType, eventListener); + }; + }, []); + return h(Context.Provider, { + value: { path, params, navigateTo }, + children, + }); +}; diff --git a/packages/web-util/src/context/translation.ts b/packages/web-util/src/context/translation.ts index 53ca87f9d..2725dc7e1 100644 --- a/packages/web-util/src/context/translation.ts +++ b/packages/web-util/src/context/translation.ts @@ -18,6 +18,13 @@ import { i18n, setupI18n } from "@gnu-taler/taler-util"; import { ComponentChildren, createContext, h, VNode } from "preact"; import { useContext, useEffect } from "preact/hooks"; import { useLang } from "../hooks/index.js"; +import { Locale } from "date-fns"; +import { + es as esLocale, + enGB as enLocale, + fr as frLocale, + de as deLocale +} from "date-fns/locale" export type InternationalizationAPI = typeof i18n; @@ -26,6 +33,8 @@ interface Type { supportedLang: { [id in keyof typeof supportedLang]: string }; changeLanguage: (l: string) => void; i18n: InternationalizationAPI; + dateLocale: Locale, + completeness: { [id in keyof typeof supportedLang]: number } } const supportedLang = { @@ -35,16 +44,24 @@ const supportedLang = { de: "Deutsch [de]", sv: "Svenska [sv]", it: "Italiane [it]", - navigator: "Defined by navigator", }; -const initial = { +const initial: Type = { lang: "en", supportedLang, changeLanguage: () => { // do not change anything }, i18n, + dateLocale: enLocale, + completeness: { + de: 0, + en: 0, + es: 0, + fr: 0, + it: 0, + sv: 0, + } }; const Context = createContext<Type>(initial); @@ -53,6 +70,7 @@ interface Props { children: ComponentChildren; forceLang?: string; source: Record<string, any>; + completeness?: Record<string, number>; } // Outmost UI wrapper. @@ -61,8 +79,18 @@ export const TranslationProvider = ({ children, forceLang, source, + completeness: completenessProp }: Props): VNode => { - const { value: lang, update: changeLanguage } = useLang(initial); + const completeness = { + en: 100, + de: !completenessProp || !completenessProp["de"] ? 0 : completenessProp["de"], + es: !completenessProp || !completenessProp["es"] ? 0 : completenessProp["es"], + fr: !completenessProp || !completenessProp["fr"] ? 0 : completenessProp["fr"], + it: !completenessProp || !completenessProp["it"] ? 0 : completenessProp["it"], + sv: !completenessProp || !completenessProp["sv"] ? 0 : completenessProp["sv"], + } + const { value: lang, update: changeLanguage } = useLang(initial, completeness); + useEffect(() => { if (forceLang) { changeLanguage(forceLang); @@ -77,8 +105,13 @@ export const TranslationProvider = ({ setupI18n(lang, source); } + const dateLocale = lang === "es" ? esLocale : + lang === "fr" ? frLocale : + lang === "de" ? deLocale : + enLocale; + return h(Context.Provider, { - value: { lang, changeLanguage, supportedLang, i18n }, + value: { lang, changeLanguage, supportedLang, i18n, dateLocale, completeness }, children, }); }; diff --git a/packages/web-util/src/context/wallet-integration.ts b/packages/web-util/src/context/wallet-integration.ts new file mode 100644 index 000000000..e14988ed1 --- /dev/null +++ b/packages/web-util/src/context/wallet-integration.ts @@ -0,0 +1,83 @@ +/* + 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 { stringifyTalerUri, TalerUri } from "@gnu-taler/taler-util"; +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext } from "preact/hooks"; + +/** + * https://docs.taler.net/design-documents/039-taler-browser-integration.html + * + * @param uri + */ +function createHeadMetaTag(uri: TalerUri, onNotFound?: () => void) { + const meta = document.createElement("meta"); + meta.setAttribute("name", "taler-uri"); + meta.setAttribute("content", stringifyTalerUri(uri)); + + document.head.appendChild(meta); + + let walletFound = false; + window.addEventListener("beforeunload", () => { + walletFound = true; + }); + setTimeout(() => { + if (!walletFound && onNotFound) { + onNotFound(); + } + }, 10); //very short timeout +} +interface Type { + /** + * Tell the active wallet that an action is found + * + * @param uri + * @returns + */ + publishTalerAction: (uri: TalerUri, onNotFound?: () => void) => void; +} + +// @ts-expect-error default value to undefined, should it be another thing? +const Context = createContext<Type>(undefined); + +export const useTalerWalletIntegrationAPI = (): Type => useContext(Context); + +export const TalerWalletIntegrationBrowserProvider = ({ + children, +}: { + children: ComponentChildren; +}): VNode => { + const value: Type = { + publishTalerAction: createHeadMetaTag, + }; + return h(Context.Provider, { + value, + children, + }); +}; + +export const TalerWalletIntegrationTestingProvider = ({ + children, + value, +}: { + children: ComponentChildren; + value: Type; +}): VNode => { + return h(Context.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/forms/Calendar.tsx b/packages/web-util/src/forms/Calendar.tsx new file mode 100644 index 000000000..b08129f56 --- /dev/null +++ b/packages/web-util/src/forms/Calendar.tsx @@ -0,0 +1,186 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util"; +import { + add as dateAdd, + sub as dateSub, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + getMonth, + getYear, + isSameDay, + isSameMonth, + startOfDay, + startOfMonth, + startOfWeek, +} from "date-fns"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useTranslationContext } from "../index.browser.js"; + +export function Calendar({ + value, + onChange, +}: { + value: AbsoluteTime | undefined; + onChange: (v: AbsoluteTime) => void; +}): VNode { + const today = startOfDay(new Date()); + const selected = !value ? today : new Date(AbsoluteTime.toStampMs(value)); + const [showingDate, setShowingDate] = useState(selected); + const month = getMonth(showingDate); + const year = getYear(showingDate); + + const start = startOfWeek(startOfMonth(showingDate)); + const end = endOfWeek(endOfMonth(showingDate)); + const daysInMonth = eachDayOfInterval({ start, end }); + const { i18n } = useTranslationContext(); + const monthNames = [ + i18n.str`January`, + i18n.str`February`, + i18n.str`March`, + i18n.str`April`, + i18n.str`May`, + i18n.str`June`, + i18n.str`July`, + i18n.str`August`, + i18n.str`September`, + i18n.str`October`, + i18n.str`November`, + i18n.str`December`, + ]; + return ( + <div class="text-center p-2"> + <div class="flex items-center text-gray-900"> + <button + type="button" + class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateSub(showingDate, { years: 1 })); + }} + > + <span class="sr-only">{i18n.str`Previous year`}</span> + <svg + class="h-5 w-5" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" + clip-rule="evenodd" + /> + </svg> + </button> + <div class="flex-auto text-sm font-semibold">{year}</div> + <button + type="button" + class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateAdd(showingDate, { years: 1 })); + }} + > + <span class="sr-only">{i18n.str`Next year`}</span> + <svg + class="h-5 w-5" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + clip-rule="evenodd" + /> + </svg> + </button> + </div> + <div class="mt-4 flex items-center text-gray-900"> + <button + type="button" + class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateSub(showingDate, { months: 1 })); + }} + > + <span class="sr-only">{i18n.str`Previous month`}</span> + <svg + class="h-5 w-5" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" + clip-rule="evenodd" + /> + </svg> + </button> + <div class="flex-auto text-sm font-semibold">{monthNames[month]}</div> + <button + type="button" + class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 rounded-sm " + onClick={() => { + setShowingDate(dateAdd(showingDate, { months: 1 })); + }} + > + <span class="sr-only">{i18n.str`Next month`}</span> + <svg + class="h-5 w-5" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" + clip-rule="evenodd" + /> + </svg> + </button> + </div> + <div class="mt-6 grid grid-cols-7 text-xs leading-6 text-gray-500"> + <div>M</div> + <div>T</div> + <div>W</div> + <div>T</div> + <div>F</div> + <div>S</div> + <div>S</div> + </div> + <div class="isolate mt-2"> + <div class="grid grid-cols-7 gap-px rounded-lg bg-gray-200 text-sm shadow ring-1 ring-gray-200"> + {daysInMonth.map((current, idx) => ( + <button + type="button" + key={idx} + data-month={isSameMonth(current, showingDate)} + data-today={isSameDay(current, today)} + data-selected={isSameDay(current, selected)} + onClick={() => { + onChange(AbsoluteTime.fromStampMs(current.getTime())); + }} + class="text-gray-400 hover:bg-gray-700 focus:z-10 py-1.5 + data-[month=false]:bg-gray-100 data-[month=true]:bg-white + data-[today=true]:font-semibold + data-[month=true]:text-gray-900 + data-[today=true]:bg-red-300 data-[today=true]:hover:bg-red-200 + data-[month=true]:hover:bg-gray-200 + data-[selected=true]:!bg-blue-400 data-[selected=true]:hover:!bg-blue-300 " + > + <time + dateTime={format(current, "yyyy-MM-dd")} + class="mx-auto flex h-7 w-7 py-4 px-5 sm:px-8 items-center justify-center rounded-full" + > + {format(current, "dd")} + </time> + </button> + ))} + </div> + {daysInMonth.length < 40 ? <div class="w-7 h-7 m-1.5" /> : undefined} + </div> + </div> + ); +} diff --git a/packages/web-util/src/forms/Caption.tsx b/packages/web-util/src/forms/Caption.tsx index 8facddec3..be4725ffa 100644 --- a/packages/web-util/src/forms/Caption.tsx +++ b/packages/web-util/src/forms/Caption.tsx @@ -1,27 +1,22 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; -import { - LabelWithTooltipMaybeRequired -} from "./InputLine.js"; +import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; +import { Addon } from "./FormProvider.js"; interface Props { label: TranslatedString; tooltip?: TranslatedString; help?: TranslatedString; - before?: VNode; - after?: VNode; + before?: Addon; + after?: Addon; } export function Caption({ before, after, label, tooltip, help }: Props): VNode { return ( <div class="sm:col-span-6 flex"> - {before !== undefined && ( - <span class="pointer-events-none flex items-center pr-2">{before}</span> - )} + {before !== undefined && <RenderAddon addon={before} />} <LabelWithTooltipMaybeRequired label={label} tooltip={tooltip} /> - {after !== undefined && ( - <span class="pointer-events-none flex items-center pl-2">{after}</span> - )} + {after !== undefined && <RenderAddon addon={after} />} {help && ( <p class="mt-2 text-sm text-gray-500" id="email-description"> {help} diff --git a/packages/web-util/src/forms/DefaultForm.tsx b/packages/web-util/src/forms/DefaultForm.tsx index 12babf39a..338460170 100644 --- a/packages/web-util/src/forms/DefaultForm.tsx +++ b/packages/web-util/src/forms/DefaultForm.tsx @@ -1,39 +1,59 @@ +import { Fragment, VNode, h } from "preact"; +import { FormProvider, FormProviderProps, FormState } from "./FormProvider.js"; +import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; +import { TranslatedString } from "@gnu-taler/taler-util"; +// import { FlexibleForm } from "./ui-form.js"; -import { ComponentChildren, Fragment, h } from "preact"; -import { FormProvider, FormState } from "./FormProvider.js"; -import { DoubleColumnForm, RenderAllFieldsByUiConfig } from "./forms.js"; +/** + * Flexible form uses a DoubleColumForm for design + * and may have a dynamic properties defined by + * behavior function. + */ +export interface FlexibleForm_Deprecated<T extends object> { + design: DoubleColumnForm_Deprecated; + behavior?: (form: Partial<T>) => FormState<T>; +} +/** + * Double column form + * + * Form with sections, every sections have a title and may + * have a description. + * Every sections contain a set of fields. + */ +export type DoubleColumnForm_Deprecated = Array<DoubleColumnFormSection_Deprecated | undefined>; -export interface FlexibleForm<T extends object> { - versionId: string; - design: DoubleColumnForm; - behavior: (form: Partial<T>) => FormState<T>; -} +export type DoubleColumnFormSection_Deprecated = { + title: TranslatedString; + description?: TranslatedString; + fields: UIFormField[]; +}; + +/** + * Form Provider implementation that use FlexibleForm + * to defined behavior and fields. + */ export function DefaultForm<T extends object>({ initial, onUpdate, form, onSubmit, children, -}: { - children?: ComponentChildren; - initial: Partial<T>; - onSubmit?: (v: Partial<T>) => void; - form: FlexibleForm<T>; - onUpdate?: (d: Partial<T>) => void; -}) { + readOnly, +}: Omit<FormProviderProps<T>, "computeFormState"> & { form: FlexibleForm_Deprecated<T> }): VNode { return ( <FormProvider - initialValue={initial} + initial={initial} onUpdate={onUpdate} onSubmit={onSubmit} - computeFormState={form.behavior} + readOnly={readOnly} + // computeFormState={form.behavior} > <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> {form.design.map((section, i) => { if (!section) return <Fragment />; return ( - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> + <div key={i} class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> <div class="px-4 sm:px-0"> <h2 class="text-base font-semibold leading-7 text-gray-900"> {section.title} diff --git a/packages/web-util/src/forms/Dialog.tsx b/packages/web-util/src/forms/Dialog.tsx new file mode 100644 index 000000000..7b41fe487 --- /dev/null +++ b/packages/web-util/src/forms/Dialog.tsx @@ -0,0 +1,15 @@ +import { ComponentChildren, VNode, h } from "preact"; + +export function Dialog({ children, onClose }: { onClose?: () => void; children: ComponentChildren }): VNode { + return <div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true" onClick={onClose}> + <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div> + + <div class="fixed inset-0 z-10 w-screen overflow-y-auto"> + <div class="flex min-h-full items-center justify-center p-4 text-center "> + <div class="relative transform overflow-hidden rounded-lg bg-white p-1 text-left shadow-xl transition-all" onClick={(e) => e.stopPropagation()}> + {children} + </div> + </div> + </div> + </div> +} diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx index 3da2a4f07..5e08efb32 100644 --- a/packages/web-util/src/forms/FormProvider.tsx +++ b/packages/web-util/src/forms/FormProvider.tsx @@ -4,82 +4,133 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; import { ComponentChildren, VNode, createContext, h } from "preact"; -import { - MutableRef, - StateUpdater, - useEffect, - useRef, - useState, -} from "preact/hooks"; - -export interface FormType<T> { +import { MutableRef, useState } from "preact/hooks"; + +export interface FormType<T extends object> { value: MutableRef<Partial<T>>; - initialValue?: Partial<T>; - onUpdate?: StateUpdater<T>; - computeFormState?: (v: T) => FormState<T>; + initial?: Partial<T>; + readOnly?: boolean; + onUpdate?: (v: Partial<T>) => void; + computeFormState?: (v: Partial<T>) => FormState<T>; } -//@ts-ignore -export const FormContext = createContext<FormType<any>>({}); +export const FormContext = createContext<FormType<any>| undefined>(undefined); -export type FormState<T> = { +/** + * Map of {[field]:FieldUIOptions} + * for every field of type + * - any native (string, number, etc...) + * - absoluteTime + * - amountJson + * + * except for: + * - object => recurse into + * - array => behavior result and element field + */ +export type FormState<T extends object | undefined> = { [field in keyof T]?: T[field] extends AbsoluteTime - ? Partial<InputFieldState> + ? FieldUIOptions : T[field] extends AmountJson - ? Partial<InputFieldState> - : T[field] extends Array<infer P> - ? Partial<InputArrayFieldState<P>> - : T[field] extends (object | undefined) - ? FormState<T[field]> - : Partial<InputFieldState>; + ? FieldUIOptions + : T[field] extends Array<infer P extends object> + ? InputArrayFieldState<P> + : T[field] extends object | undefined + ? FormState<T[field]> + : FieldUIOptions; }; -export interface InputFieldState { - /* should show the error */ - error?: TranslatedString; - /* should not allow to edit */ - readonly: boolean; - /* should show as disable */ - disabled: boolean; +/** + * Properties that can be defined by design or by computing state + */ +export type FieldUIOptions = { + /* instruction to be shown in the field */ + placeholder?: TranslatedString; + /* long text help to be shown on demand */ + tooltip?: TranslatedString; + /* short text to be shown next to the field*/ + + help?: TranslatedString; + /* should show as disabled and readonly */ + disabled?: boolean; /* should not show */ - hidden: boolean; + hidden?: boolean; + + /* show a mark as required*/ + required?: boolean; +}; + +/** + * properties only to be defined on design time + */ +export interface UIFormProps<T extends object, K extends keyof T> + extends FieldUIOptions { + // property name of the object + name: K; + + // label if the field + label: TranslatedString; + before?: Addon; + after?: Addon; + + // converter to string and back + converter?: StringConverter<T[K]>; + + handler?: UIFieldHandler; } -export interface InputArrayFieldState<T> extends InputFieldState { - elements: FormState<T>[]; +export type UIFieldHandler = { + value: string | undefined; + onChange: (s: string) => void; + state: FieldUIOptions; + error?: TranslatedString; +}; + +export interface IconAddon { + type: "icon"; + icon: VNode; +} +export interface ButtonAddon { + type: "button"; + onClick: () => void; + children: ComponentChildren; +} +export interface TextAddon { + type: "text"; + text: TranslatedString; +} +export type Addon = IconAddon | ButtonAddon | TextAddon; + +export interface StringConverter<T> { + toStringUI: (v?: T) => string; + fromStringUI: (v?: string) => T; +} + +export interface InputArrayFieldState<P extends object> extends FieldUIOptions { + elements?: FormState<P>[]; } -export function FormProvider<T>({ +export type FormProviderProps<T extends object> = Omit<FormType<T>, "value"> & { + onSubmit?: (v: Partial<T>, s: FormState<T> | undefined) => void; + children?: ComponentChildren; +}; + +export function FormProvider<T extends object>({ children, - initialValue, + initial, onUpdate: notify, onSubmit, computeFormState, -}: { - initialValue?: Partial<T>; - onUpdate?: (v: Partial<T>) => void; - onSubmit?: (v: Partial<T>, s: FormState<T> | undefined) => void; - computeFormState?: (v: Partial<T>) => FormState<T>; - children: ComponentChildren; -}): VNode { - // const value = useRef(initialValue ?? {}); - // useEffect(() => { - // return function onUnload() { - // value.current = initialValue ?? {}; - // }; - // }); - // const onUpdate = notify - const [state, setState] = useState<Partial<T>>(initialValue ?? {}); + readOnly, +}: FormProviderProps<T>): VNode { + const [state, setState] = useState<Partial<T>>(initial ?? {}); const value = { current: state }; - // console.log("RENDER", initialValue, value); const onUpdate = (v: typeof state) => { - // console.log("updated"); setState(v); if (notify) notify(v); }; return ( <FormContext.Provider - value={{ initialValue, value, onUpdate, computeFormState }} + value={{ initial, value, onUpdate, computeFormState, readOnly }} > <form onSubmit={(e) => { diff --git a/packages/web-util/src/forms/Group.tsx b/packages/web-util/src/forms/Group.tsx index 0645f6d97..f63fa4a9b 100644 --- a/packages/web-util/src/forms/Group.tsx +++ b/packages/web-util/src/forms/Group.tsx @@ -1,40 +1,43 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; -import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; +import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; +import { RenderAllFieldsByUiConfig, UIFormField, convertUiField } from "./forms.js"; +import { Addon, FormProvider } from "./FormProvider.js"; +import { useField } from "./useField.js"; +import { useTranslationContext } from "../index.browser.js"; +import { getConverterById } from "./converter.js"; interface Props { - before?: TranslatedString; - after?: TranslatedString; - tooltipBefore?: TranslatedString; - tooltipAfter?: TranslatedString; + label: TranslatedString; + tooltip?: TranslatedString; + help?: TranslatedString; + before?: Addon; + after?: Addon; fields: UIFormField[]; } export function Group({ before, after, - tooltipAfter, - tooltipBefore, + label, + tooltip, + help, fields, }: Props): VNode { return ( <div class="sm:col-span-6 p-4 rounded-lg border-r-2 border-2 bg-gray-50"> - <div class="pb-4"> - {before && ( - <LabelWithTooltipMaybeRequired - label={before} - tooltip={tooltipBefore} - /> - )} - </div> + {before !== undefined && <RenderAddon addon={before} />} + <LabelWithTooltipMaybeRequired label={label} tooltip={tooltip} /> + {after !== undefined && <RenderAddon addon={after} />} + {help && ( + <p class="mt-2 text-sm text-gray-500" id="email-description"> + {help} + </p> + )} <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-6"> - <RenderAllFieldsByUiConfig fields={fields} /> - </div> - <div class="pt-4"> - {after && ( - <LabelWithTooltipMaybeRequired label={after} tooltip={tooltipAfter} /> - )} + <RenderAllFieldsByUiConfig + fields={fields} + /> </div> </div> ); diff --git a/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx new file mode 100644 index 000000000..6b792bfee --- /dev/null +++ b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx @@ -0,0 +1,60 @@ +/* + 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, TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Absolute Time", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + today: AbsoluteTime; +} +const initial: TargetObject = { + today: AbsoluteTime.now() +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "absoluteTimeText", + properties: { + label: "label of the field" as TranslatedString, + name: "today", + pattern: "dd/MM/yyyy HH:mm" + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputAbsoluteTime.tsx b/packages/web-util/src/forms/InputAbsoluteTime.tsx new file mode 100644 index 000000000..f5fd4fc50 --- /dev/null +++ b/packages/web-util/src/forms/InputAbsoluteTime.tsx @@ -0,0 +1,94 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util"; +import { format, parse } from "date-fns"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Calendar } from "./Calendar.js"; +import { Dialog } from "./Dialog.js"; +import { UIFormProps } from "./FormProvider.js"; +import { InputLine } from "./InputLine.js"; +import { useField } from "./useField.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; + +export function InputAbsoluteTime<T extends object, K extends keyof T>( + properties: { pattern?: string } & UIFormProps<T, K>, +): VNode { + const pattern = properties.pattern ?? "dd/MM/yyyy"; + const [open, setOpen] = useState(false); + + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(properties.name); + const { value, onChange } = + properties.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(properties.name); + return ( + <Fragment> + <InputLine<T, K> + type="text" + after={{ + type: "button", + onClick: () => { + setOpen(true); + }, + // icon: <CalendarIcon class="h-6 w-6" />, + children: ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" + /> + </svg> + ), + }} + converter={{ + //@ts-ignore + fromStringUI: (v): AbsoluteTime | undefined => { + if (!v) return undefined; + try { + const t_ms = parse(v, pattern, Date.now()).getTime(); + return AbsoluteTime.fromMilliseconds(t_ms); + } catch (e) { + return undefined; + } + }, + //@ts-ignore + toStringUI: (v: AbsoluteTime | undefined) => { + return !v || !v.t_ms + ? undefined + : v.t_ms === "never" + ? "never" + : format(v.t_ms, pattern); + }, + }} + {...properties} + /> + {open && ( + <Dialog onClose={() => setOpen(false)}> + <Calendar + value={(value as AbsoluteTime) ?? AbsoluteTime.now()} + onChange={(v) => { + onChange(v as any); + setOpen(false); + }} + /> + </Dialog> + )} + {/* {open && + <Dialog onClose={() => setOpen(false)} > + <TimePicker value={value as AbsoluteTime ?? AbsoluteTime.now()} + onChange={(v) => { + onChange(v as any) + }} + onConfirm={() => { + setOpen(false) + }} /> + </Dialog>} */} + </Fragment> + ); +} diff --git a/packages/web-util/src/forms/InputAmount.stories.tsx b/packages/web-util/src/forms/InputAmount.stories.tsx new file mode 100644 index 000000000..f05887515 --- /dev/null +++ b/packages/web-util/src/forms/InputAmount.stories.tsx @@ -0,0 +1,59 @@ +/* + 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 { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Amount", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + amount: AmountJson; +} +const initial: TargetObject = { + amount: Amounts.parseOrThrow("USD:10") +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "amount", + properties: { + label: "label of the field" as TranslatedString, + name: "amount", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputAmount.tsx b/packages/web-util/src/forms/InputAmount.tsx index 9be9dd4d0..647d2c823 100644 --- a/packages/web-util/src/forms/InputAmount.tsx +++ b/packages/web-util/src/forms/InputAmount.tsx @@ -1,34 +1,43 @@ import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; -import { InputLine, UIFormProps } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; +import { InputLine } from "./InputLine.js"; import { useField } from "./useField.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; export function InputAmount<T extends object, K extends keyof T>( props: { currency?: string } & UIFormProps<T, K>, ): VNode { - const { value } = useField<T, K>(props.name); + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); const currency = !value || !(value as any).currency ? props.currency : (value as any).currency; return ( <InputLine<T, K> + {...props} type="text" before={{ type: "text", text: currency as TranslatedString, }} - converter={{ - //@ts-ignore - fromStringUI: (v): AmountJson => { - return Amounts.parseOrThrow(`${currency}:${v}`); - }, - //@ts-ignore - toStringUI: (v: AmountJson) => { - return v === undefined ? "" : Amounts.stringifyValue(v); - }, - }} - {...props} + //@ts-ignore + converter={ + props.converter ?? { + fromStringUI: (v): AmountJson => { + return ( + Amounts.parse(`${currency}:${v}`) ?? + Amounts.zeroOfCurrency(currency) + ); + }, + toStringUI: (v: AmountJson) => { + return v === undefined ? "" : Amounts.stringifyValue(v); + }, + } + } /> ); } diff --git a/packages/web-util/src/forms/InputArray.stories.tsx b/packages/web-util/src/forms/InputArray.stories.tsx new file mode 100644 index 000000000..143e73f02 --- /dev/null +++ b/packages/web-util/src/forms/InputArray.stories.tsx @@ -0,0 +1,79 @@ +/* + 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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Array", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + people: { + name: string; + age: number; + }[]; +} +const initial: TargetObject = { + people: [{ + name: "me", + age: 17, + }] +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "array", + properties: { + label: "People" as TranslatedString, + name: "comment", + fields: [{ + type: "text", + properties: { + label: "the name" as TranslatedString, + name: "name", + } + }, { + type: "integer", + properties: { + label: "the age" as TranslatedString, + name: "age", + } + }], + labelField: "name" + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx index 00379bed6..d90028508 100644 --- a/packages/web-util/src/forms/InputArray.tsx +++ b/packages/web-util/src/forms/InputArray.tsx @@ -1,10 +1,10 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { FormProvider, InputArrayFieldState } from "./FormProvider.js"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { useState } from "preact/hooks"; +import { FormProvider, UIFormProps } from "./FormProvider.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; import { useField } from "./useField.js"; -import { TranslatedString } from "@gnu-taler/taler-util"; function Option({ label, @@ -71,6 +71,14 @@ function Option({ ); } +export function noHandlerPropsAndNoContextForField( + field: string | number | symbol, +): never { + throw Error( + `Field ${field.toString()} doesn't have handler and is not in a form provider context.`, + ); +} + export function InputArray<T extends object, K extends keyof T>( props: { fields: UIFormField[]; @@ -78,12 +86,20 @@ export function InputArray<T extends object, K extends keyof T>( } & UIFormProps<T, K>, ): VNode { const { fields, labelField, name, label, required, tooltip } = props; - const { value, onChange, state } = useField<T, K>(name); + // const { value, onChange, state } = useField<T, K>(name); + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + if (!props.handler && !fieldCtx) { + throw Error(""); + } + const { value, onChange, state } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + const list = (value ?? []) as Array<Record<string, string | undefined>>; const [selectedIndex, setSelected] = useState<number | undefined>(undefined); const selected = selectedIndex === undefined ? undefined : list[selectedIndex]; - + return ( <div class="sm:col-span-6"> <LabelWithTooltipMaybeRequired @@ -94,9 +110,11 @@ export function InputArray<T extends object, K extends keyof T>( <div class="-space-y-px rounded-md bg-white "> {list.map((v, idx) => { + const label = getValueDeeper(v, labelField.split(".")) return ( <Option - label={v[labelField] as TranslatedString} + label={label as TranslatedString} + key={idx} isSelected={selectedIndex === idx} isLast={idx === list.length - 1} disabled={selectedIndex !== undefined && selectedIndex !== idx} @@ -107,22 +125,24 @@ export function InputArray<T extends object, K extends keyof T>( /> ); })} - <div class="pt-2"> - <Option - label={"Add..." as TranslatedString} - isSelected={selectedIndex === list.length} - isLast - isFirst - disabled={ - selectedIndex !== undefined && selectedIndex !== list.length - } - onClick={() => { - setSelected( - selectedIndex === list.length ? undefined : list.length, - ); - }} - /> - </div> + {!state.disabled && ( + <div class="pt-2"> + <Option + label={"Add..." as TranslatedString} + isSelected={selectedIndex === list.length} + isLast + isFirst + disabled={ + selectedIndex !== undefined && selectedIndex !== list.length + } + onClick={() => { + setSelected( + selectedIndex === list.length ? undefined : list.length, + ); + }} + /> + </div> + )} </div> {selectedIndex !== undefined && ( /** @@ -130,25 +150,27 @@ export function InputArray<T extends object, K extends keyof T>( * Consider creating an InnerFormProvider since not every feature is expected */ <FormProvider - initialValue={selected} + initial={selected} + readOnly={state.disabled} computeFormState={(v) => { // current state is ignored // the state is defined by the parent form // elements should be present in the state object since this is expected to be an array //@ts-ignore - return state.elements[selectedIndex]; + // return state.elements[selectedIndex]; + return {}; }} onSubmit={(v) => { const newValue = [...list]; newValue.splice(selectedIndex, 1, v); - onChange(newValue as T[K]); + onChange(newValue as any); setSelected(undefined); }} onUpdate={(v) => { const newValue = [...list]; newValue.splice(selectedIndex, 1, v); - onChange(newValue as T[K]); + onChange(newValue as any); }} > <div class="px-4 py-6"> @@ -167,7 +189,7 @@ export function InputArray<T extends object, K extends keyof T>( onClick={() => { const newValue = [...list]; newValue.splice(selectedIndex, 1); - onChange(newValue as T[K]); + onChange(newValue as any); setSelected(undefined); }} class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 " @@ -181,3 +203,24 @@ export function InputArray<T extends object, K extends keyof T>( </div> ); } + + + +export function getValueDeeper( + object: Record<string, any>, + names: string[], +): string { + if (names.length === 0) { + return object as any as string; + } + const [head, ...rest] = names; + if (!head) { + return getValueDeeper(object, rest); + } + if (object === undefined) { + return "" + } + return getValueDeeper(object[head], rest); +} + + diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx new file mode 100644 index 000000000..786dfe5bc --- /dev/null +++ b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx @@ -0,0 +1,69 @@ +/* + 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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Choice Horizontal", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "0" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "choiceHorizontal", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + choices: [{ + label: "first choice" as TranslatedString, + value: "1" + }, { + label: "second choice" as TranslatedString, + value: "2" + }, { + label: "third choice" as TranslatedString, + value: "3" + },], + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.tsx index 5c909b5d7..86d3aa926 100644 --- a/packages/web-util/src/forms/InputChoiceHorizontal.tsx +++ b/packages/web-util/src/forms/InputChoiceHorizontal.tsx @@ -1,27 +1,25 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; -import { Choice } from "./InputChoiceStacked.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; + +export interface ChoiceH<V> { + label: TranslatedString; + value: V; +} export function InputChoiceHorizontal<T extends object, K extends keyof T>( props: { - choices: Choice<T[K]>[]; + choices: ChoiceH<string>[]; } & UIFormProps<T, K>, ): VNode { - const { - choices, - name, - label, - tooltip, - help, - placeholder, - required, - before, - after, - converter, - } = props; - const { value, onChange, state, isDirty } = useField<T, K>(name); + const { choices, label, tooltip, help, required, converter } = props; + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange, state } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); if (state.hidden) { return <Fragment />; } @@ -36,11 +34,12 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>( <fieldset class="mt-2"> <div class="isolate inline-flex rounded-md shadow-sm"> {choices.map((choice, idx) => { + const convertedValue = converter?.fromStringUI(choice.value as any) const isFirst = idx === 0; const isLast = idx === choices.length - 1; let clazz = "relative inline-flex items-center px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:z-10"; - if (choice.value === value) { + if (convertedValue !== undefined && convertedValue === value) { clazz += " text-white bg-indigo-600 hover:bg-indigo-500 ring-2 ring-indigo-600 hover:ring-indigo-500"; } else { @@ -57,16 +56,17 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>( return ( <button type="button" + key={idx} + disabled={state.disabled} + label={choice.label} class={clazz} onClick={(e) => { onChange( - (value === choice.value ? undefined : choice.value) as T[K], + (value === choice.value ? undefined : convertedValue) as any, ); }} > - {(!converter - ? (choice.value as string) - : converter?.toStringUI(choice.value)) ?? ""} + {choice.label} </button> ); })} diff --git a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx new file mode 100644 index 000000000..9a634d05c --- /dev/null +++ b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx @@ -0,0 +1,69 @@ +/* + 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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Choice Stacked", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "choiceStacked", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + choices: [{ + label: "first choice" as TranslatedString, + value: "1" + }, { + label: "second choice" as TranslatedString, + value: "2" + }, { + label: "third choice" as TranslatedString, + value: "3" + },], + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputChoiceStacked.tsx b/packages/web-util/src/forms/InputChoiceStacked.tsx index c37984368..1928f4365 100644 --- a/packages/web-util/src/forms/InputChoiceStacked.tsx +++ b/packages/web-util/src/forms/InputChoiceStacked.tsx @@ -1,9 +1,11 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; -export interface Choice<V> { +export interface ChoiceS<V> { label: TranslatedString; description?: TranslatedString; value: V; @@ -11,7 +13,7 @@ export interface Choice<V> { export function InputChoiceStacked<T extends object, K extends keyof T>( props: { - choices: Choice<T[K]>[]; + choices: ChoiceS<T[K]>[]; } & UIFormProps<T, K>, ): VNode { const { @@ -26,7 +28,12 @@ export function InputChoiceStacked<T extends object, K extends keyof T>( after, converter, } = props; - const { value, onChange, state, isDirty } = useField<T, K>(name); + + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange, state } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + if (state.hidden) { return <Fragment />; } @@ -40,7 +47,7 @@ export function InputChoiceStacked<T extends object, K extends keyof T>( /> <fieldset class="mt-2"> <div class="space-y-4"> - {choices.map((choice) => { + {choices.map((choice, idx) => { // const currentValue = !converter // ? choice.value // : converter.fromStringUI(choice.value) ?? ""; @@ -55,11 +62,12 @@ export function InputChoiceStacked<T extends object, K extends keyof T>( } return ( - <label class={clazz}> + <label key={idx} class={clazz}> <input type="radio" name="server-size" // defaultValue={choice.value} + disabled={state.disabled} value={ (!converter ? (choice.value as string) @@ -69,7 +77,7 @@ export function InputChoiceStacked<T extends object, K extends keyof T>( onChange( (value === choice.value ? undefined - : choice.value) as T[K], + : choice.value) as any, ); }} class="sr-only" diff --git a/packages/web-util/src/forms/InputDate.tsx b/packages/web-util/src/forms/InputDate.tsx deleted file mode 100644 index 1fd81aad9..000000000 --- a/packages/web-util/src/forms/InputDate.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { AbsoluteTime } from "@gnu-taler/taler-util"; -import { InputLine, UIFormProps } from "./InputLine.js"; -import { CalendarIcon } from "@heroicons/react/24/outline"; -import { VNode, h } from "preact"; -import { format, parse } from "date-fns"; - -export function InputDate<T extends object, K extends keyof T>( - props: { pattern?: string } & UIFormProps<T, K>, -): VNode { - const pattern = props.pattern ?? "dd/MM/yyyy"; - return ( - <InputLine<T, K> - type="text" - after={{ - type: "icon", - icon: <CalendarIcon class="h-6 w-6" />, - }} - converter={{ - //@ts-ignore - fromStringUI: (v): AbsoluteTime => { - if (!v) return AbsoluteTime.never(); - const t_ms = parse(v, pattern, Date.now()).getTime(); - return AbsoluteTime.fromMilliseconds(t_ms); - }, - //@ts-ignore - toStringUI: (v: AbsoluteTime) => { - return !v || !v.t_ms - ? "" - : v.t_ms === "never" - ? "never" - : format(v.t_ms, pattern); - }, - }} - {...props} - /> - ); -} diff --git a/packages/web-util/src/forms/InputFile.stories.tsx b/packages/web-util/src/forms/InputFile.stories.tsx new file mode 100644 index 000000000..eff18d071 --- /dev/null +++ b/packages/web-util/src/forms/InputFile.stories.tsx @@ -0,0 +1,64 @@ +/* + 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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input File", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "file", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + required: true, + maxBites: 2 * 1024 * 1024, + accept: ".png", + tooltip: "this is a very long tooltip that explain what the field does without being short" as TranslatedString, + help: "Max size of 2 mega bytes" as TranslatedString, + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputFile.tsx b/packages/web-util/src/forms/InputFile.tsx index 0d89a98a3..cd0a96d1c 100644 --- a/packages/web-util/src/forms/InputFile.tsx +++ b/packages/web-util/src/forms/InputFile.tsx @@ -1,25 +1,43 @@ import { Fragment, VNode, h } from "preact"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; export function InputFile<T extends object, K extends keyof T>( props: { maxBites: number; accept?: string } & UIFormProps<T, K>, ): VNode { const { - name, label, - placeholder, tooltip, required, - help, + help: propsHelp, maxBites, accept, } = props; - const { value, onChange, state } = useField<T, K>(name); + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange, state } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + const help = propsHelp ?? state.help; if (state.hidden) { return <div />; } + + const valueStr = !value ? "" : value.toString(); + const firstColon = valueStr.indexOf(";"); + + const { fileName, dataUri } = valueStr.startsWith("file:") + ? { + fileName: valueStr.substring(5, firstColon), + dataUri: valueStr.substring(firstColon + 1), + } + : { + fileName: "", + dataUri: valueStr, + }; + return ( <div class="col-span-full"> <LabelWithTooltipMaybeRequired @@ -27,7 +45,7 @@ export function InputFile<T extends object, K extends keyof T>( tooltip={tooltip} required={required} /> - {!value || !(value as string).startsWith("data:image/") ? ( + {!dataUri ? ( <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 py-1"> <div class="text-center"> <svg @@ -42,57 +60,77 @@ export function InputFile<T extends object, K extends keyof T>( clip-rule="evenodd" /> </svg> - <div class="my-2 flex text-sm leading-6 text-gray-600"> - <label - for="file-upload" - class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500" - > - <span>Upload a file</span> - <input - id="file-upload" - name="file-upload" - type="file" - class="sr-only" - accept={accept} - onChange={(e) => { - const f: FileList | null = e.currentTarget.files; - if (!f || f.length != 1) { - return onChange(undefined!); - } - if (f[0].size > maxBites) { - return onChange(undefined!); - } - return f[0].arrayBuffer().then((b) => { - const b64 = window.btoa( - new Uint8Array(b).reduce( - (data, byte) => data + String.fromCharCode(byte), - "", - ), - ); - return onChange(`data:${f[0].type};base64,${b64}` as any); - }); - }} - /> - </label> - {/* <p class="pl-1">or drag and drop</p> */} - </div> + {!state.disabled && ( + <div class="my-2 flex text-sm leading-6 text-gray-600"> + <label + for={String(props.name)} + class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500" + > + <span>Upload a file</span> + <input + id={String(props.name)} + type="file" + class="sr-only" + accept={accept} + onChange={(e) => { + const f: FileList | null = e.currentTarget.files; + if (!f || f.length != 1) { + return onChange(undefined!); + } + if (f[0].size > maxBites) { + return onChange(undefined!); + } + const fileName = f[0].name; + return f[0].arrayBuffer().then((b) => { + const b64 = window.btoa( + new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + if (fileName) { + return onChange( + `file:${fileName};data:${f[0].type};base64,${b64}` as any, + ); + } else { + return onChange( + `data:${f[0].type};base64,${b64}` as any, + ); + } + }); + }} + /> + </label> + {/* <p class="pl-1">or drag and drop</p> */} + </div> + )} </div> </div> ) : ( <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative"> - <img - src={value as string} - class=" h-24 w-full object-cover relative" - /> + {(dataUri as string).startsWith("data:image/") ? ( + <img src={dataUri} class=" h-24 w-full object-cover relative" /> + ) : ( + <div /> + )} + {fileName ? ( + <div class="absolute rounded-lg border flex justify-center text-xl items-center text-white "> + {fileName} + </div> + ) : ( + <Fragment /> + )} - <div - class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg border inset-0 z-10 flex justify-center text-xl items-center bg-black text-white cursor-pointer " - onClick={() => { - onChange(undefined!); - }} - > - Clear - </div> + {!state.disabled && ( + <div + class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg border inset-0 z-10 flex justify-center text-xl items-center bg-black text-white cursor-pointer " + onClick={() => { + onChange(undefined!); + }} + > + Clear + </div> + )} </div> )} {help && <p class="text-xs leading-5 text-gray-600 mt-2">{help}</p>} diff --git a/packages/web-util/src/forms/InputInteger.stories.tsx b/packages/web-util/src/forms/InputInteger.stories.tsx new file mode 100644 index 000000000..378736a24 --- /dev/null +++ b/packages/web-util/src/forms/InputInteger.stories.tsx @@ -0,0 +1,55 @@ +/* + 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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Integer", +}; + + +type TargetObject = { + age: number; +} +const initial: TargetObject = { + age: 5, +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "integer", + properties: { + label: "label of the field" as TranslatedString, + name: "age", + tooltip: "just numbers" as TranslatedString, + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputInteger.tsx b/packages/web-util/src/forms/InputInteger.tsx index fb04e3852..a6a02ad43 100644 --- a/packages/web-util/src/forms/InputInteger.tsx +++ b/packages/web-util/src/forms/InputInteger.tsx @@ -1,5 +1,6 @@ import { VNode, h } from "preact"; -import { InputLine, UIFormProps } from "./InputLine.js"; +import { InputLine } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; export function InputInteger<T extends object, K extends keyof T>( props: UIFormProps<T, K>, diff --git a/packages/web-util/src/forms/InputLine.stories.tsx b/packages/web-util/src/forms/InputLine.stories.tsx new file mode 100644 index 000000000..dea5c142a --- /dev/null +++ b/packages/web-util/src/forms/InputLine.stories.tsx @@ -0,0 +1,59 @@ +/* + 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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Line", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "text", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputLine.tsx b/packages/web-util/src/forms/InputLine.tsx index 9448ef5e4..eb3238ef9 100644 --- a/packages/web-util/src/forms/InputLine.tsx +++ b/packages/web-util/src/forms/InputLine.tsx @@ -1,43 +1,9 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { Addon, UIFormProps } from "./FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { useField } from "./useField.js"; -export interface IconAddon { - type: "icon"; - icon: VNode; -} -interface ButtonAddon { - type: "button"; - onClick: () => void; - children: ComponentChildren; -} -interface TextAddon { - type: "text"; - text: TranslatedString; -} -type Addon = IconAddon | ButtonAddon | TextAddon; - -interface StringConverter<T> { - toStringUI: (v?: T) => string; - fromStringUI: (v?: string) => T; -} - -export interface UIFormProps<T extends object, K extends keyof T> { - name: K; - label: TranslatedString; - placeholder?: TranslatedString; - tooltip?: TranslatedString; - help?: TranslatedString; - before?: Addon; - after?: Addon; - required?: boolean; - converter?: StringConverter<T[K]>; -} - -export type FormErrors<T> = { - [P in keyof T]?: string | FormErrors<T[P]>; -}; - //@ts-ignore const TooltipIcon = ( <svg @@ -80,11 +46,11 @@ export function LabelWithTooltipMaybeRequired({ {Label} <span class="relative flex items-center group pl-2"> {TooltipIcon} - <div class="absolute bottom-0 flex flex-col items-center hidden mb-6 group-hover:flex"> - <span class="relative z-10 p-2 text-xs leading-none text-white whitespace-no-wrap bg-black shadow-lg"> + <div class="absolute bottom-0 -ml-10 hidden flex-col items-center mb-6 group-hover:flex w-28"> + <div class="relative z-10 p-2 text-xs leading-none text-white whitespace-no-wrap bg-black shadow-lg"> {tooltip} - </span> - <div class="w-3 h-3 -mt-2 rotate-45 bg-black"></div> + </div> + <div class="w-3 h-3 -mt-2 rotate-45 bg-black"></div> </div> </span> </div> @@ -102,6 +68,37 @@ export function LabelWithTooltipMaybeRequired({ return WithTooltip; } +export function RenderAddon({ disabled, addon }: { disabled?: boolean, addon: Addon }): VNode { + switch (addon.type) { + case "text": { + return ( + <span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> + {addon.text} + </span> + ); + } + case "icon": { + return ( + <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> + {addon.icon} + </div> + ); + } + case "button": { + return ( + <button + type="button" + disabled={disabled} + onClick={addon.onClick} + class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-l-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" + > + {addon.children} + </button> + ); + } + } +} + function InputWrapper<T extends object, K extends keyof T>({ children, label, @@ -110,8 +107,13 @@ function InputWrapper<T extends object, K extends keyof T>({ after, help, error, + disabled, required, -}: { error?: string; children: ComponentChildren } & UIFormProps<T, K>): VNode { +}: { + error?: string; + disabled: boolean; + children: ComponentChildren; +} & UIFormProps<T, K>): VNode { return ( <div class="sm:col-span-6"> <LabelWithTooltipMaybeRequired @@ -120,45 +122,11 @@ function InputWrapper<T extends object, K extends keyof T>({ tooltip={tooltip} /> <div class="relative mt-2 flex rounded-md shadow-sm"> - {before && - (before.type === "text" ? ( - <span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> - {before.text} - </span> - ) : before.type === "icon" ? ( - <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> - {before.icon} - </div> - ) : before.type === "button" ? ( - <button - type="button" - onClick={before.onClick} - class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-l-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" - > - {before.children} - </button> - ) : undefined)} + {before && <RenderAddon disabled={disabled} addon={before} />} {children} - {after && - (after.type === "text" ? ( - <span class="inline-flex items-center rounded-r-md border border-l-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> - {after.text} - </span> - ) : after.type === "icon" ? ( - <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"> - {after.icon} - </div> - ) : after.type === "button" ? ( - <button - type="button" - onClick={after.onClick} - class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" - > - {after.children} - </button> - ) : undefined)} + {after && <RenderAddon disabled={disabled} addon={after} />} </div> {error && ( <p class="mt-2 text-sm text-red-600" id="email-error"> @@ -187,7 +155,22 @@ export function InputLine<T extends object, K extends keyof T>( props: { type: InputType } & UIFormProps<T, K>, ): VNode { const { name, placeholder, before, after, converter, type } = props; - const { value, onChange, state, isDirty } = useField<T, K>(name); + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange, state, error } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + + // const [text, setText] = useState(""); + const fromString: (s: string) => any = + converter?.fromStringUI ?? defaultFromString; + const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; + + // useEffect(() => { + // const newValue = toString(value); + // if (newValue) { + // setText(newValue); + // } + // }, [value]); if (state.hidden) return <div />; @@ -225,7 +208,7 @@ export function InputLine<T extends object, K extends keyof T>( } } } - const showError = isDirty && state.error; + const showError = value !== undefined && error; if (showError) { clazz += " text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500"; @@ -233,15 +216,14 @@ export function InputLine<T extends object, K extends keyof T>( clazz += " text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600"; } - const fromString: (s: string) => any = - converter?.fromStringUI ?? defaultFromString; - const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; if (type === "text-area") { return ( <InputWrapper<T, K> {...props} - error={showError ? state.error : undefined} + help={props.help ?? state.help} + disabled={state.disabled ?? false} + error={showError ? error : undefined} > <textarea rows={4} @@ -262,7 +244,12 @@ export function InputLine<T extends object, K extends keyof T>( } return ( - <InputWrapper<T, K> {...props} error={showError ? state.error : undefined}> + <InputWrapper<T, K> + {...props} + help={props.help ?? state.help} + disabled={state.disabled ?? false} + error={showError ? error : undefined} + > <input name={String(name)} type={type} @@ -271,6 +258,9 @@ export function InputLine<T extends object, K extends keyof T>( }} placeholder={placeholder ? placeholder : undefined} value={toString(value) ?? ""} + // onBlur={() => { + // onChange(fromString(value as any)); + // }} // defaultValue={toString(value)} disabled={state.disabled} aria-invalid={showError} diff --git a/packages/web-util/src/forms/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx new file mode 100644 index 000000000..ab17545f5 --- /dev/null +++ b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx @@ -0,0 +1,90 @@ +/* + 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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Select Multiple", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + pets: string[]; + things: string[]; +} +const initial: TargetObject = { + pets: [], + things: [], +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "selectMultiple", + properties: { + label: "allow diplicates" as TranslatedString, + name: "pets", + placeholder: "search..." as TranslatedString, + choices: [{ + label: "one label" as TranslatedString, + value: "one" + }, { + label: "two label" as TranslatedString, + value: "two" + }, { + label: "five label" as TranslatedString, + value: "five" + }] + }, + }, { + type: "selectMultiple", + properties: { + label: "unique values" as TranslatedString, + name: "things", + unique: true, + placeholder: "search..." as TranslatedString, + choices: [{ + label: "one label" as TranslatedString, + value: "one" + }, { + label: "two label" as TranslatedString, + value: "two" + }, { + label: "five label" as TranslatedString, + value: "five" + }] + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputSelectMultiple.tsx b/packages/web-util/src/forms/InputSelectMultiple.tsx index 8116bdc03..1bcf85061 100644 --- a/packages/web-util/src/forms/InputSelectMultiple.tsx +++ b/packages/web-util/src/forms/InputSelectMultiple.tsx @@ -1,25 +1,32 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { Choice } from "./InputChoiceStacked.js"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { ChoiceS } from "./InputChoiceStacked.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; export function InputSelectMultiple<T extends object, K extends keyof T>( props: { - choices: Choice<T[K]>[]; + choices: ChoiceS<T[K]>[]; unique?: boolean; max?: number; } & UIFormProps<T, K>, ): VNode { - const { name, label, choices, placeholder, tooltip, required, unique, max } = - props; - const { value, onChange } = useField<T, K>(name); + const { converter, label, choices, placeholder, tooltip, required, unique, max } = props; + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange, state } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); const [filter, setFilter] = useState<string | undefined>(undefined); const regex = new RegExp(`.*${filter}.*`, "i"); - const choiceMap = choices.reduce((prev, curr) => { - return { ...prev, [curr.value as string]: curr.label }; - }, {} as Record<string, string>); + const choiceMap = choices.reduce( + (prev, curr) => { + return { ...prev, [curr.value as string]: curr.label }; + }, + {} as Record<string, string>, + ); const list = (value ?? []) as string[]; const filteredChoices = @@ -37,14 +44,18 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( /> {list.map((v, idx) => { return ( - <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 text-xs font-medium text-gray-600"> + <span + key={idx} + class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 text-xs font-medium text-gray-600" + > {choiceMap[v]} <button type="button" + disabled={state.disabled} onClick={() => { const newValue = [...list]; newValue.splice(idx, 1); - onChange(newValue as T[K]); + onChange(newValue as any); setFilter(undefined); }} class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20" @@ -62,90 +73,94 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( ); })} - <div class="relative mt-2"> - <input - id="combobox" - type="text" - value={filter ?? ""} - onChange={(e) => { - setFilter(e.currentTarget.value); - }} - placeholder={placeholder} - class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - role="combobox" - aria-controls="options" - aria-expanded="false" - /> - <button - type="button" - onClick={() => { - setFilter(filter === undefined ? "" : undefined); - }} - class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" - > - <svg - class="h-5 w-5 text-gray-400" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" + {!state.disabled && ( + <div class="relative mt-2"> + <input + id="combobox" + type="text" + value={filter ?? ""} + onChange={(e) => { + setFilter(e.currentTarget.value); + }} + placeholder={placeholder} + class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + role="combobox" + aria-controls="options" + aria-expanded="false" + /> + <button + type="button" + disabled={state.disabled} + onClick={() => { + setFilter(filter === undefined ? "" : undefined); + }} + class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" > - <path - fill-rule="evenodd" - d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" - clip-rule="evenodd" - /> - </svg> - </button> + <svg + class="h-5 w-5 text-gray-400" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" + clip-rule="evenodd" + /> + </svg> + </button> - {filteredChoices !== undefined && ( - <ul - class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" - id="options" - role="listbox" - > - {filteredChoices.map((v, idx) => { - return ( - <li - class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" - id="option-0" - role="option" - onClick={() => { - setFilter(undefined); - if (unique && list.indexOf(v.value as string) !== -1) { - return; - } - if (max !== undefined && list.length >= max) { - return; - } - const newValue = [...list]; - newValue.splice(0, 0, v.value as string); - onChange(newValue as T[K]); - }} + {filteredChoices !== undefined && ( + <ul + class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + id="options" + role="listbox" + > + {filteredChoices.map((v, idx) => { + return ( + <li + key={idx} + class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" + id="option-0" + role="option" + onClick={() => { + setFilter(undefined); + if (unique && list.indexOf(v.value as string) !== -1) { + return; + } + if (max !== undefined && list.length >= max) { + return; + } + const newValue = [...list]; + newValue.splice(0, 0, v.value as string); + onChange(newValue as any); + }} - // tabindex="-1" - > - {/* <!-- Selected: "font-semibold" --> */} - <span class="block truncate">{v.label}</span> + // tabindex="-1" + > + {/* <!-- Selected: "font-semibold" --> */} + <span class="block truncate">{v.label}</span> - {/* <!-- + {/* <!-- Checkmark, only display for selected option. Active: "text-white", Not Active: "text-indigo-600" --> */} - </li> - ); - })} + </li> + ); + })} - {/* <!-- + {/* <!-- Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. Active: "text-white bg-indigo-600", Not Active: "text-gray-900" --> */} - {/* <!-- More items... --> */} - </ul> - )} - </div> + {/* <!-- More items... --> */} + </ul> + )} + </div> + )} </div> ); } diff --git a/packages/web-util/src/forms/InputSelectOne.stories.tsx b/packages/web-util/src/forms/InputSelectOne.stories.tsx new file mode 100644 index 000000000..2ebde3096 --- /dev/null +++ b/packages/web-util/src/forms/InputSelectOne.stories.tsx @@ -0,0 +1,70 @@ +/* + 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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Select One", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + things: string; +} +const initial: TargetObject = { + things: "one" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "selectOne", + properties: { + label: "label of the field" as TranslatedString, + name: "things", + placeholder: "search..." as TranslatedString, + choices: [{ + label: "one label" as TranslatedString, + value: "one" + }, { + label: "two label" as TranslatedString, + value: "two" + }, { + label: "five label" as TranslatedString, + value: "five" + }] + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputSelectOne.tsx b/packages/web-util/src/forms/InputSelectOne.tsx index 7bef1058b..26f887b08 100644 --- a/packages/web-util/src/forms/InputSelectOne.tsx +++ b/packages/web-util/src/forms/InputSelectOne.tsx @@ -1,22 +1,31 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { Choice } from "./InputChoiceStacked.js"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; +import { ChoiceS } from "./InputChoiceStacked.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; export function InputSelectOne<T extends object, K extends keyof T>( props: { - choices: Choice<T[K]>[]; + choices: ChoiceS<T[K]>[]; } & UIFormProps<T, K>, ): VNode { - const { name, label, choices, placeholder, tooltip, required } = props; - const { value, onChange } = useField<T, K>(name); + const { label, choices, placeholder, tooltip, required } = props; + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + const [filter, setFilter] = useState<string | undefined>(undefined); const regex = new RegExp(`.*${filter}.*`, "i"); - const choiceMap = choices.reduce((prev, curr) => { - return { ...prev, [curr.value as string]: curr.label }; - }, {} as Record<string, string>); + const choiceMap = choices.reduce( + (prev, curr) => { + return { ...prev, [curr.value as string]: curr.label }; + }, + {} as Record<string, string>, + ); const filteredChoices = filter === undefined @@ -96,12 +105,13 @@ export function InputSelectOne<T extends object, K extends keyof T>( {filteredChoices.map((v, idx) => { return ( <li + key={idx} class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" id="option-0" role="option" onClick={() => { setFilter(undefined); - onChange(v.value as T[K]); + onChange(v.value as any); }} // tabindex="-1" diff --git a/packages/web-util/src/forms/InputText.stories.tsx b/packages/web-util/src/forms/InputText.stories.tsx new file mode 100644 index 000000000..60b6ca224 --- /dev/null +++ b/packages/web-util/src/forms/InputText.stories.tsx @@ -0,0 +1,59 @@ +/* + 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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Text", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "text", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputText.tsx b/packages/web-util/src/forms/InputText.tsx index 1b37ee6fb..1c0c04225 100644 --- a/packages/web-util/src/forms/InputText.tsx +++ b/packages/web-util/src/forms/InputText.tsx @@ -1,5 +1,6 @@ import { VNode, h } from "preact"; -import { InputLine, UIFormProps } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; +import { InputLine } from "./InputLine.js"; export function InputText<T extends object, K extends keyof T>( props: UIFormProps<T, K>, diff --git a/packages/web-util/src/forms/InputTextArea.stories.tsx b/packages/web-util/src/forms/InputTextArea.stories.tsx new file mode 100644 index 000000000..ab1a695f5 --- /dev/null +++ b/packages/web-util/src/forms/InputTextArea.stories.tsx @@ -0,0 +1,59 @@ +/* + 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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + DefaultForm as TestedComponent, + FlexibleForm_Deprecated, +} from "./DefaultForm.js"; + +export default { + title: "Input Text Area", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "text", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputTextArea.tsx b/packages/web-util/src/forms/InputTextArea.tsx index 45229951e..6b76d8329 100644 --- a/packages/web-util/src/forms/InputTextArea.tsx +++ b/packages/web-util/src/forms/InputTextArea.tsx @@ -1,5 +1,6 @@ import { VNode, h } from "preact"; -import { InputLine, UIFormProps } from "./InputLine.js"; +import { InputLine } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; export function InputTextArea<T extends object, K extends keyof T>( props: UIFormProps<T, K>, diff --git a/packages/web-util/src/forms/InputToggle.stories.tsx b/packages/web-util/src/forms/InputToggle.stories.tsx new file mode 100644 index 000000000..fcc57ffe2 --- /dev/null +++ b/packages/web-util/src/forms/InputToggle.stories.tsx @@ -0,0 +1,59 @@ +/* + 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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + FlexibleForm_Deprecated, + DefaultForm as TestedComponent, +} from "./DefaultForm.js"; + +export default { + title: "Input Toggle", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm_Deprecated<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "toggle", + properties: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputToggle.tsx b/packages/web-util/src/forms/InputToggle.tsx new file mode 100644 index 000000000..58386045c --- /dev/null +++ b/packages/web-util/src/forms/InputToggle.tsx @@ -0,0 +1,56 @@ +import { VNode, h } from "preact"; +import { UIFormProps } from "./FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { useField } from "./useField.js"; + +export function InputToggle<T extends object, K extends keyof T>( + props: UIFormProps<T, K>, +): VNode { + const { + name, + label, + tooltip, + help, + placeholder, + required, + before, + after, + converter, + } = props; + //FIXME: remove deprecated + const fieldCtx = useField<T, K>(props.name); + const { value, onChange } = + props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + + const isOn = !!value; + return ( + <div class="sm:col-span-6"> + <div class="flex items-center justify-between"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + <button + type="button" + data-enabled={isOn} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + onChange(!isOn as any); + }} + > + <span + aria-hidden="true" + data-enabled={isOn} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </div> + ); +} diff --git a/packages/web-util/src/forms/TimePicker.tsx b/packages/web-util/src/forms/TimePicker.tsx new file mode 100644 index 000000000..5e4e7a8fa --- /dev/null +++ b/packages/web-util/src/forms/TimePicker.tsx @@ -0,0 +1,109 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util" +import { getHours, getMinutes, getSeconds, setHours } from "date-fns" +import { Fragment, VNode, h } from "preact" +import { useTranslationContext } from "../index.browser.js" + +export function TimePicker({ value, onChange, onConfirm }: { value: AbsoluteTime | undefined, onChange: (v: AbsoluteTime) => void, onConfirm: () => void }): VNode { + const date = !value ? new Date() : new Date(AbsoluteTime.toStampMs(value)) + const hours = getHours(date) % 12 + const minutes = getMinutes(date) + const seconds = getSeconds(date) + + const { i18n } = useTranslationContext() + + return <Fragment> + <div class="flex flex-col bg-white rounded-t-sm justify-around" > + {/* time selection */} + <div id="" class="bg-[#3b71ca] dark:bg-zinc-700 h-24 rounded-t-lg p-12 flex flex-row items-center justify-center"> + <div class="flex w-full justify-evenly"> + <div class=""> + <span class="relative h-full"> + <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " + style="pointer-events: none;"> + {new String(hours).padStart(2, "0")} + </button> + </span> + <span type="button" class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " >:</span> + <span class="relative h-full"> + <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " > + {new String(minutes).padStart(2, "0")} + </button> + </span> + <span type="button" class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " >:</span> + <span class="relative h-full"> + <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " > + {new String(seconds).padStart(2, "0")} + </button> + </span> + </div> + <div class="flex flex-col justify-center text-[18px] text-[#ffffff8a] "> + <button type="button" class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" > + AM + </button> + <button type="button" class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" > + PM + </button> + </div> + </div> + </div> + {/* clock */} + <div id="" class="mt-2 min-w-[310px] max-w-[325px] min-h-[305px] overflow-x-hidden h-full flex justify-center mx-auto flex-col items-center dark:bg-zinc-500" > + <div class="relative rounded-[100%] w-[260px] h-[260px] cursor-default my-0 mx-auto bg-[#00000012] dark:bg-zinc-600/50 animate-[show-up-clock_350ms_linear]" > + + <span class="top-1/2 left-1/2 w-[6px] h-[6px] -translate-y-1/2 -translate-x-1/2 rounded-[50%] bg-[#3b71ca] absolute" ></span> + <div class="bg-[#3b71ca] bottom-1/2 h-2/5 left-[calc(50%-1px)] rtl:!left-auto origin-[center_bottom_0] rtl:!origin-[50%_50%_0] w-[2px] absolute" style={{ transform: "rotateZ(60deg)", height: "calc(35% + 1px)" }}> + {/* <div class="-top-[21px] -left-[15px] w-[4px] border-[14px] border-solid border-[#3b71ca] h-[4px] box-content rounded-[100%] absolute" style="background-color: rgb(25, 118, 210);"></div> */} + </div> + + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 12).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 114px; bottom: 224px;"> + <span>0</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 1).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 169px; bottom: 209.263px;"> + <span >1</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 2).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" data-selected={true} style="left: 209.263px; bottom: 169px;" > + <span >2</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 3).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 224px; bottom: 114px;"> + <span >3</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 4).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 209.263px; bottom: 59px;"> + <span >4</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 5).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 169px; bottom: 18.7372px;"> + <span >5</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 6).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 114px; bottom: 4px;"> + <span >6</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 7).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 59px; bottom: 18.7372px;"> + <span >7</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 8).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 18.7372px; bottom: 59px;"> + <span >8</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 9).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 4px; bottom: 114px;"> + <span >9</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 10).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 18.7372px; bottom: 169px;"> + <span >10</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 11).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 59px; bottom: 209.263px;"> + <span >11</span> + </span> + </div> + </div> + </div> + <div id="" class="rounded-b-lg flex justify-between items-center w-full h-[56px] px-[12px] bg-white dark:bg-zinc-500"> + <div class="w-full flex justify-end"> + <button + type="submit" + onClick={onConfirm} + class="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" + > + <i18n.Translate>Confirm</i18n.Translate> + </button> + </div> + </div> + </Fragment> +} diff --git a/packages/web-util/src/forms/converter.ts b/packages/web-util/src/forms/converter.ts new file mode 100644 index 000000000..eee891776 --- /dev/null +++ b/packages/web-util/src/forms/converter.ts @@ -0,0 +1,130 @@ +/* + 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, + AmountJson, + Amounts, + TalerExchangeApi, +} from "@gnu-taler/taler-util"; +import { format, parse } from "date-fns"; +import { StringConverter } from "./FormProvider.js"; + +export const amlStateConverter = { + toStringUI: stringifyAmlState, + fromStringUI: parseAmlState, +}; + +function stringifyAmlState(s: TalerExchangeApi.AmlState | undefined): string { + if (s === undefined) return ""; + switch (s) { + case TalerExchangeApi.AmlState.normal: + return "normal"; + case TalerExchangeApi.AmlState.pending: + return "pending"; + case TalerExchangeApi.AmlState.frozen: + return "frozen"; + } +} + +function parseAmlState(s: string | undefined): TalerExchangeApi.AmlState { + switch (s) { + case "normal": + return TalerExchangeApi.AmlState.normal; + case "pending": + return TalerExchangeApi.AmlState.pending; + case "frozen": + return TalerExchangeApi.AmlState.frozen; + default: + throw Error(`unknown AML state: ${s}`); + } +} + +const nullConverter: StringConverter<string> = { + fromStringUI(v: string | undefined): string { + return v ?? ""; + }, + toStringUI(v: unknown): string { + return v as string; + }, +}; + +function amountConverter(config: any): StringConverter<AmountJson> { + const currency = config["currency"]; + if (!currency || typeof currency !== "string") { + throw Error(`amount converter needs a currency`); + } + return { + fromStringUI(v: string | undefined): AmountJson { + // FIXME: requires currency + return ( + Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency) + ); + }, + toStringUI(v: unknown): string { + return v === undefined ? "" : Amounts.stringifyValue(v as AmountJson); + }, + }; +} + +function absTimeConverter(config: any): StringConverter<AbsoluteTime> { + const pattern = config["pattern"]; + if (!pattern || typeof pattern !== "string") { + throw Error(`absTime converter needs a pattern`); + } + return { + fromStringUI(v: string | undefined): AbsoluteTime { + if (v === undefined) { + return AbsoluteTime.never(); + } + try { + const time = parse(v, pattern, new Date()); + return AbsoluteTime.fromMilliseconds(time.getTime()); + } catch (e) { + return AbsoluteTime.never(); + } + }, + toStringUI(v: unknown): string { + if (v === undefined) return ""; + const d = v as AbsoluteTime; + if (d.t_ms === "never") return "never"; + try { + return format(d.t_ms, pattern); + } catch (e) { + return ""; + } + }, + }; +} + +export function getConverterById( + id: string | undefined, + config: unknown, +): StringConverter<unknown> { + if (id === "Taler.AbsoluteTime") { + // @ts-expect-error check this + return absTimeConverter(config); + } + if (id === "Taler.Amount") { + // @ts-expect-error check this + return amountConverter(config); + } + if (id === "TalerExchangeApi.AmlState") { + // @ts-expect-error check this + return amlStateConverter; + } + return nullConverter as StringConverter<unknown>; +} diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts index 2c90a69ed..4c5050830 100644 --- a/packages/web-util/src/forms/forms.ts +++ b/packages/web-util/src/forms/forms.ts @@ -1,29 +1,22 @@ -import { TranslatedString } from "@gnu-taler/taler-util"; -import { InputText } from "./InputText.js"; -import { InputDate } from "./InputDate.js"; -import { InputInteger } from "./InputInteger.js"; import { h as create, Fragment, VNode } from "preact"; -import { InputChoiceStacked } from "./InputChoiceStacked.js"; -import { InputArray } from "./InputArray.js"; -import { InputSelectMultiple } from "./InputSelectMultiple.js"; -import { InputTextArea } from "./InputTextArea.js"; -import { InputFile } from "./InputFile.js"; import { Caption } from "./Caption.js"; import { Group } from "./Group.js"; -import { InputSelectOne } from "./InputSelectOne.js"; -import { FormProvider } from "./FormProvider.js"; -import { InputLine } from "./InputLine.js"; +import { InputAbsoluteTime } from "./InputAbsoluteTime.js"; import { InputAmount } from "./InputAmount.js"; +import { InputArray } from "./InputArray.js"; import { InputChoiceHorizontal } from "./InputChoiceHorizontal.js"; - -export type DoubleColumnForm = Array<DoubleColumnFormSection | undefined>; - -export type DoubleColumnFormSection = { - title: TranslatedString; - description?: TranslatedString; - fields: UIFormField[]; -}; - +import { InputChoiceStacked } from "./InputChoiceStacked.js"; +import { InputFile } from "./InputFile.js"; +import { InputInteger } from "./InputInteger.js"; +import { InputSelectMultiple } from "./InputSelectMultiple.js"; +import { InputSelectOne } from "./InputSelectOne.js"; +import { InputText } from "./InputText.js"; +import { InputTextArea } from "./InputTextArea.js"; +import { InputToggle } from "./InputToggle.js"; +import { Addon, StringConverter, UIFieldHandler } from "./FormProvider.js"; +import { InternationalizationAPI, UIFieldElementDescription } from "../index.browser.js"; +import { assertUnreachable, TranslatedString } from "@gnu-taler/taler-util"; +import {UIFormFieldBaseConfig, UIFormElementConfig} from "./ui-form.js"; /** * Constrain the type with the ui props */ @@ -38,8 +31,9 @@ type FieldType<T extends object = any, K extends keyof T = any> = { textArea: Parameters<typeof InputTextArea<T, K>>[0]; choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0]; choiceHorizontal: Parameters<typeof InputChoiceHorizontal<T, K>>[0]; - date: Parameters<typeof InputDate<T, K>>[0]; + absoluteTimeText: Parameters<typeof InputAbsoluteTime<T, K>>[0]; integer: Parameters<typeof InputInteger<T, K>>[0]; + toggle: Parameters<typeof InputToggle<T, K>>[0]; amount: Parameters<typeof InputAmount<T, K>>[0]; }; @@ -47,19 +41,32 @@ type FieldType<T extends object = any, K extends keyof T = any> = { * List all the form fields so typescript can type-check the form instance */ export type UIFormField = - | { type: "group"; props: FieldType["group"] } - | { type: "caption"; props: FieldType["caption"] } - | { type: "array"; props: FieldType["array"] } - | { type: "file"; props: FieldType["file"] } - | { type: "amount"; props: FieldType["amount"] } - | { type: "selectOne"; props: FieldType["selectOne"] } - | { type: "selectMultiple"; props: FieldType["selectMultiple"] } - | { type: "text"; props: FieldType["text"] } - | { type: "textArea"; props: FieldType["textArea"] } - | { type: "choiceStacked"; props: FieldType["choiceStacked"] } - | { type: "choiceHorizontal"; props: FieldType["choiceHorizontal"] } - | { type: "integer"; props: FieldType["integer"] } - | { type: "date"; props: FieldType["date"] }; + | { type: "group"; properties: FieldType["group"] } + | { type: "caption"; properties: FieldType["caption"] } + | { type: "array"; properties: FieldType["array"] } + | { type: "file"; properties: FieldType["file"] } + | { type: "amount"; properties: FieldType["amount"] } + | { type: "selectOne"; properties: FieldType["selectOne"] } + | { + type: "selectMultiple"; + properties: FieldType["selectMultiple"]; + } + | { type: "text"; properties: FieldType["text"] } + | { type: "textArea"; properties: FieldType["textArea"] } + | { + type: "choiceStacked"; + properties: FieldType["choiceStacked"]; + } + | { + type: "choiceHorizontal"; + properties: FieldType["choiceHorizontal"]; + } + | { type: "integer"; properties: FieldType["integer"] } + | { type: "toggle"; properties: FieldType["toggle"] } + | { + type: "absoluteTimeText"; + properties: FieldType["absoluteTimeText"]; + }; type FieldComponentFunction<key extends keyof FieldType> = ( props: FieldType[key], @@ -82,7 +89,7 @@ const UIFormConfiguration: UIFormFieldMap = { file: InputFile, textArea: InputTextArea, //@ts-ignore - date: InputDate, + absoluteTimeText: InputAbsoluteTime, //@ts-ignore choiceStacked: InputChoiceStacked, //@ts-ignore @@ -93,6 +100,8 @@ const UIFormConfiguration: UIFormFieldMap = { //@ts-ignore selectMultiple: InputSelectMultiple, //@ts-ignore + toggle: InputToggle, + //@ts-ignore amount: InputAmount, }; @@ -108,28 +117,256 @@ export function RenderAllFieldsByUiConfig({ const Component = UIFormConfiguration[ field.type ] as FieldComponentFunction<any>; - return Component(field.props); + return Component(field.properties); }), ); } -type FormSet<T extends object> = { - Provider: typeof FormProvider<T>; - InputLine: <K extends keyof T>() => typeof InputLine<T, K>; - InputChoiceHorizontal: <K extends keyof T>() => typeof InputChoiceHorizontal< - T, - K - >; -}; -export function createNewForm<T extends object>() { - const res: FormSet<T> = { - Provider: FormProvider, - InputLine: () => InputLine, - InputChoiceHorizontal: () => InputChoiceHorizontal, +// type FormSet<T extends object> = { +// Provider: typeof FormProvider<T>; +// InputLine: <K extends keyof T>() => typeof InputLine<T, K>; +// InputChoiceHorizontal: <K extends keyof T>() => typeof InputChoiceHorizontal<T, K>; +// }; + +/** + * Helper function that created a typed object. + * + * @returns + */ +// export function createNewForm<T extends object>() { +// const res: FormSet<T> = { +// Provider: FormProvider, +// InputLine: () => InputLine, +// InputChoiceHorizontal: () => InputChoiceHorizontal, +// }; +// return { +// Provider: res.Provider, +// InputLine: res.InputLine(), +// InputChoiceHorizontal: res.InputChoiceHorizontal(), +// }; +// } + +/** + * convert field configuration to render function + * + * @param i18n_ + * @param fieldConfig + * @param form + * @returns + */ +export function convertUiField( + i18n_: InternationalizationAPI, + fieldConfig: UIFormElementConfig[], + form: object, + getConverterById: GetConverterById, +): UIFormField[] { + return fieldConfig.map((config) => { + // NON input fields + switch (config.type) { + case "caption": { + const resp: UIFormField = { + type: config.type, + properties: converBaseFieldsProps(i18n_, config), + }; + return resp; + } + case "group": { + const resp: UIFormField = { + type: config.type, + properties: { + ...converBaseFieldsProps(i18n_, config), + fields: convertUiField(i18n_, config.fields, form, getConverterById), + }, + }; + return resp; + } + } + // Input Fields + switch (config.type) { + case "array": { + return { + type: "array", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + labelField: config.labelFieldId, + fields: convertUiField(i18n_, config.fields, form, getConverterById), + }, + } as UIFormField; + } + case "absoluteTimeText": { + return { + type: "absoluteTimeText", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + }, + } as UIFormField; + } + case "amount": { + return { + type: "amount", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + currency: config.currency, + }, + } as UIFormField; + } + case "choiceHorizontal": { + return { + type: "choiceHorizontal", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + choices: config.choices, + }, + } as UIFormField; + } + case "choiceStacked": { + return { + type: "choiceStacked", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + choices: config.choices, + + }, + }as UIFormField; + } + case "file":{ + return { + type: "file", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + accept: config.accept, + maxBites: config.maxBytes, + }, + } as UIFormField; + } + case "integer":{ + return { + type: "integer", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + }, + } as UIFormField; + } + case "selectMultiple":{ + return { + type: "selectMultiple", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + choices: config.choices, + }, + } as UIFormField; + } + case "selectOne": { + return { + type: "selectOne", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + choices: config.choices, + }, + } as UIFormField; + } + case "text": { + return { + type: "text", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + }, + } as UIFormField; + } + case "textArea": { + return { + type: "text", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + }, + } as UIFormField; + } + case "toggle": { + return { + type: "toggle", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps(form, config, getConverterById), + }, + } as UIFormField; + } + default: { + assertUnreachable(config); + } + } + }); +} + + + +function getAddonById(_id: string | undefined): Addon { + return undefined!; +} + + +type GetConverterById = ( + id: string | undefined, + config: unknown, +) => StringConverter<unknown>; + + +function converInputFieldsProps( + form: object, + p: UIFormFieldBaseConfig, + getConverterById: GetConverterById, +) { + return { + converter: getConverterById(p.converterId, p), + handler: getValueDeeper2(form, p.id.split(".")), + name: p.name, + required: p.required, + disabled: p.disabled, + help: p.help, + placeholder: p.placeholder, + tooltip: p.tooltip, + label: p.label as TranslatedString, }; +} + +function converBaseFieldsProps( + i18n_: InternationalizationAPI, + p: UIFieldElementDescription, +) { return { - Provider: res.Provider, - InputLine: res.InputLine(), - InputChoiceHorizontal: res.InputChoiceHorizontal(), + after: getAddonById(p.addonAfterId), + before: getAddonById(p.addonBeforeId), + hidden: p.hidden, + name: p.name, + help: i18n_.str`${p.help}`, + label: i18n_.str`${p.label}`, + tooltip: i18n_.str`${p.tooltip}`, }; } + +export function getValueDeeper2( + object: Record<string, any>, + names: string[], +): UIFieldHandler { + if (names.length === 0) return object as UIFieldHandler; + const [head, ...rest] = names; + if (!head) { + return getValueDeeper2(object, rest); + } + if (object === undefined) { + throw Error("handler not found"); + } + return getValueDeeper2(object[head], rest); +} + + diff --git a/packages/web-util/src/forms/index.stories.ts b/packages/web-util/src/forms/index.stories.ts new file mode 100644 index 000000000..55878cb02 --- /dev/null +++ b/packages/web-util/src/forms/index.stories.ts @@ -0,0 +1,13 @@ +export * as a1 from "./InputAmount.stories.js"; +export * as a2 from "./InputArray.stories.js"; +export * as a3 from "./InputChoiceHorizontal.stories.js"; +export * as a4 from "./InputChoiceStacked.stories.js"; +export * as a5 from "./InputAbsoluteTime.stories.js"; +export * as a6 from "./InputFile.stories.js"; +export * as a7 from "./InputInteger.stories.js"; +export * as a8 from "./InputLine.stories.js"; +export * as a9 from "./InputSelectMultiple.stories.js"; +export * as a10 from "./InputSelectOne.stories.js"; +export * as a11 from "./InputText.stories.js"; +export * as a12 from "./InputTextArea.stories.js"; +export * as a13 from "./InputToggle.stories.js"; diff --git a/packages/web-util/src/forms/index.ts b/packages/web-util/src/forms/index.ts index 08bb9ee77..7320c70d0 100644 --- a/packages/web-util/src/forms/index.ts +++ b/packages/web-util/src/forms/index.ts @@ -1,19 +1,25 @@ +export * from "./Calendar.js" export * from "./Caption.js" +export * from "./DefaultForm.js" +export * from "./Dialog.js" export * from "./FormProvider.js" -export * from "./forms.js" export * from "./Group.js" -export * from "./index.js" +export * from "./InputAbsoluteTime.js" export * from "./InputAmount.js" export * from "./InputArray.js" export * from "./InputChoiceHorizontal.js" export * from "./InputChoiceStacked.js" -export * from "./InputDate.js" export * from "./InputFile.js" export * from "./InputInteger.js" export * from "./InputLine.js" export * from "./InputSelectMultiple.js" export * from "./InputSelectOne.js" -export * from "./InputTextArea.js" export * from "./InputText.js" +export * from "./InputTextArea.js" +export * from "./InputToggle.js" +export * from "./TimePicker.js" +export * from "./forms.js" +export * from "./ui-form.js" +export * from "./converter.js" export * from "./useField.js" -export * from "./DefaultForm.js" + diff --git a/packages/web-util/src/forms/ui-form.ts b/packages/web-util/src/forms/ui-form.ts new file mode 100644 index 000000000..012499d6d --- /dev/null +++ b/packages/web-util/src/forms/ui-form.ts @@ -0,0 +1,363 @@ +import { + buildCodecForObject, + buildCodecForUnion, + Codec, + codecForBoolean, + codecForConstString, + codecForLazy, + codecForList, + codecForNumber, + codecForString, + codecForTimestamp, + codecOptional, + Integer, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; + +export type FormConfiguration = DoubleColumnForm; + +export type DoubleColumnForm = { + type: "double-column"; + design: DoubleColumnFormSection[]; + // behavior?: (form: Partial<T>) => FormState<T>; +}; + +export type DoubleColumnFormSection = { + title: string; + description?: string; + fields: UIFormElementConfig[]; +}; + +// export interface BaseForm { +// state: TalerExchangeApi.AmlState; +// threshold: AmountJson; +// } + +export type UIFormElementConfig = + | UIFormElementGroup + | UIFormElementCaption + | UIFormFieldAbsoluteTime + | UIFormFieldAmount + | UIFormFieldArray + | UIFormFieldChoiseHorizontal + | UIFormFieldChoiseStacked + | UIFormFieldFile + | UIFormFieldInteger + | UIFormFieldSelectMultiple + | UIFormFieldSelectOne + | UIFormFieldText + | UIFormFieldTextArea + | UIFormFieldToggle; + +type UIFormFieldAbsoluteTime = { + type: "absoluteTimeText"; + max?: TalerProtocolTimestamp; + min?: TalerProtocolTimestamp; + pattern: string; +} & UIFormFieldBaseConfig; + +type UIFormFieldAmount = { + type: "amount"; + max?: Integer; + min?: Integer; + currency: string; +} & UIFormFieldBaseConfig; + +type UIFormFieldArray = { + type: "array"; + // id of the field shown when the array is collapsed + labelFieldId: UIHandlerId; + fields: UIFormElementConfig[]; +} & UIFormFieldBaseConfig; + +type UIFormElementCaption = { type: "caption" } & UIFieldElementDescription; + +type UIFormElementGroup = { + type: "group"; + fields: UIFormElementConfig[]; +} & UIFieldElementDescription; + +type UIFormFieldChoiseHorizontal = { + type: "choiceHorizontal"; + choices: Array<SelectUiChoice>; +} & UIFormFieldBaseConfig; + +type UIFormFieldChoiseStacked = { + type: "choiceStacked"; + choices: Array<SelectUiChoice>; +} & UIFormFieldBaseConfig; + +type UIFormFieldFile = { + type: "file"; + maxBytes?: Integer; + minBytes?: Integer; + // comma-separated list of one or more file types + // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept#unique_file_type_specifiers + accept?: string; +} & UIFormFieldBaseConfig; + +type UIFormFieldInteger = { + type: "integer"; + max?: Integer; + min?: Integer; +} & UIFormFieldBaseConfig; + +interface SelectUiChoice { + label: string; + description?: string; + value: string; +} + +type UIFormFieldSelectMultiple = { + type: "selectMultiple"; + max?: Integer; + min?: Integer; + unique?: boolean; + choices: Array<SelectUiChoice>; +} & UIFormFieldBaseConfig; + +type UIFormFieldSelectOne = { + type: "selectOne"; + choices: Array<SelectUiChoice>; +} & UIFormFieldBaseConfig; +type UIFormFieldText = { type: "text" } & UIFormFieldBaseConfig; +type UIFormFieldTextArea = { type: "textArea" } & UIFormFieldBaseConfig; +type UIFormFieldToggle = { type: "toggle" } & UIFormFieldBaseConfig; + +export type UIFieldElementDescription = { + /* label if the field, visible for the user */ + label: string; + + /* long text to be shown on user demand */ + tooltip?: string; + + /* short text to be shown close to the field, usually below and dimmer*/ + help?: string; + + /* name of the field, useful for a11y */ + name: string; + + /* if the field should be initially hidden */ + hidden?: boolean; + + /* ui element to show before */ + addonBeforeId?: string; + + /* ui element to show after */ + addonAfterId?: string; +}; + +export type UIFormFieldBaseConfig = UIFieldElementDescription & { + /* example to be shown inside the field */ + placeholder?: string; + + /* show a mark as required */ + required?: boolean; + + /* readonly and dim */ + disabled?: boolean; + + /* conversion id to convert the string into the value type + the id should be known to the ui impl + */ + converterId?: string; + + /* property id of the form */ + id: UIHandlerId; +}; + +declare const __handlerId: unique symbol; +export type UIHandlerId = string & { [__handlerId]: true }; + +// FIXME: validate well formed ui field id +const codecForUiFieldId = codecForString as () => Codec<UIHandlerId>; + +const codecForUIFormFieldBaseDescriptionTemplate = < + T extends UIFieldElementDescription, +>() => + buildCodecForObject<T>() + .property("addonAfterId", codecOptional(codecForString())) + .property("addonBeforeId", codecOptional(codecForString())) + .property("hidden", codecOptional(codecForBoolean())) + .property("help", codecOptional(codecForString())) + .property("label", codecForString()) + .property("name", codecForString()) + .property("tooltip", codecOptional(codecForString())); + +const codecForUIFormFieldBaseConfigTemplate = < + T extends UIFormFieldBaseConfig, +>() => + codecForUIFormFieldBaseDescriptionTemplate<T>() + .property("id", codecForUiFieldId()) + .property("converterId", codecOptional(codecForString())) + .property("disabled", codecOptional(codecForBoolean())) + .property("required", codecOptional(codecForBoolean())) + .property("placeholder", codecOptional(codecForString())); + +const codecForUiFormFieldAbsoluteTime = (): Codec<UIFormFieldAbsoluteTime> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldAbsoluteTime>() + .property("type", codecForConstString("absoluteTimeText")) + .property("pattern", codecForString()) + .property("max", codecOptional(codecForTimestamp)) + .property("min", codecOptional(codecForTimestamp)) + .build("UIFormFieldAbsoluteTime"); + +const codecForUiFormFieldAmount = (): Codec<UIFormFieldAmount> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldAmount>() + .property("type", codecForConstString("amount")) + .property("currency", codecForString()) + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .build("UIFormFieldAmount"); + +const codecForUiFormFieldArray = (): Codec<UIFormFieldArray> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldArray>() + .property("type", codecForConstString("array")) + .property("labelFieldId", codecForUiFieldId()) + .property("tooltip", codecOptional(codecForString())) + // eslint-disable-next-line @typescript-eslint/no-use-before-define + .property("fields", codecForList(codecForUiFormField())) + .build("UIFormFieldArray"); + +const codecForUiFormFieldCaption = (): Codec<UIFormElementCaption> => + codecForUIFormFieldBaseDescriptionTemplate<UIFormElementCaption>() + .property("type", codecForConstString("caption")) + .build("UIFormFieldCaption"); + +const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> => + buildCodecForObject<SelectUiChoice>() + .property("description", codecOptional(codecForString())) + .property("label", codecForString()) + .property("value", codecForString()) + .build("SelectUiChoice"); + +const codecForUiFormFieldChoiceHorizontal = + (): Codec<UIFormFieldChoiseHorizontal> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseHorizontal>() + .property("type", codecForConstString("choiceHorizontal")) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldChoiseHorizontal"); + +const codecForUiFormFieldChoiceStacked = (): Codec<UIFormFieldChoiseStacked> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseStacked>() + .property("type", codecForConstString("choiceStacked")) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldChoiseStacked"); + +const codecForUiFormFieldFile = (): Codec<UIFormFieldFile> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldFile>() + .property("type", codecForConstString("file")) + .property("accept", codecOptional(codecForString())) + .property("maxBytes", codecOptional(codecForNumber())) + .property("minBytes", codecOptional(codecForNumber())) + .build("UIFormFieldFile"); + +const codecForUiFormFieldGroup = (): Codec<UIFormElementGroup> => + codecForUIFormFieldBaseDescriptionTemplate<UIFormElementGroup>() + .property("type", codecForConstString("group")) + // eslint-disable-next-line @typescript-eslint/no-use-before-define + .property("fields", codecForList(codecForUiFormField())) + .build("UiFormFieldGroup"); + +const codecForUiFormFieldInteger = (): Codec<UIFormFieldInteger> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldInteger>() + .property("type", codecForConstString("integer")) + // .property("properties", codecForUIFormFieldBaseConfig()) + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .build("UIFormFieldInteger"); + +const codecForUiFormFieldSelectMultiple = + (): Codec<UIFormFieldSelectMultiple> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectMultiple>() + .property("type", codecForConstString("selectMultiple")) + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .property("unique", codecOptional(codecForBoolean())) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UiFormFieldSelectMultiple"); + +const codecForUiFormFieldSelectOne = (): Codec<UIFormFieldSelectOne> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectOne>() + .property("type", codecForConstString("selectOne")) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldSelectOne"); + +const codecForUiFormFieldText = (): Codec<UIFormFieldText> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldText>() + .property("type", codecForConstString("text")) + .build("UIFormFieldText"); + +const codecForUiFormFieldTextArea = (): Codec<UIFormFieldTextArea> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldTextArea>() + .property("type", codecForConstString("textArea")) + .build("UIFormFieldTextArea"); + +const codecForUiFormFieldToggle = (): Codec<UIFormFieldToggle> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldToggle>() + .property("type", codecForConstString("toggle")) + .build("UIFormFieldToggle"); + +const codecForUiFormField = (): Codec<UIFormElementConfig> => + buildCodecForUnion<UIFormElementConfig>() + .discriminateOn("type") + .alternative("array", codecForLazy(codecForUiFormFieldArray)) + .alternative("group", codecForLazy(codecForUiFormFieldGroup)) + .alternative("absoluteTimeText", codecForUiFormFieldAbsoluteTime()) + .alternative("amount", codecForUiFormFieldAmount()) + .alternative("caption", codecForUiFormFieldCaption()) + .alternative("choiceHorizontal", codecForUiFormFieldChoiceHorizontal()) + .alternative("choiceStacked", codecForUiFormFieldChoiceStacked()) + .alternative("file", codecForUiFormFieldFile()) + .alternative("integer", codecForUiFormFieldInteger()) + .alternative("selectMultiple", codecForUiFormFieldSelectMultiple()) + .alternative("selectOne", codecForUiFormFieldSelectOne()) + .alternative("text", codecForUiFormFieldText()) + .alternative("textArea", codecForUiFormFieldTextArea()) + .alternative("toggle", codecForUiFormFieldToggle()) + .build("UIFormField"); + +const codecForDoubleColumnFormSection = (): Codec<DoubleColumnFormSection> => + buildCodecForObject<DoubleColumnFormSection>() + .property("title", codecForString()) + .property("description", codecOptional(codecForString())) + .property("fields", codecForList(codecForUiFormField())) + .build("DoubleColumnFormSection"); + +const codecForDoubleColumnForm = (): Codec<DoubleColumnForm> => + buildCodecForObject<DoubleColumnForm>() + .property("type", codecForConstString("double-column")) + .property("design", codecForList(codecForDoubleColumnFormSection())) + .build("DoubleColumnForm"); + +const codecForFormConfiguration = (): Codec<FormConfiguration> => + buildCodecForUnion<FormConfiguration>() + .discriminateOn("type") + .alternative("double-column", codecForDoubleColumnForm()) + .build<FormConfiguration>("FormConfiguration"); + +const codecForFormMetadata = (): Codec<FormMetadata> => + buildCodecForObject<FormMetadata>() + .property("label", codecForString()) + .property("id", codecForString()) + .property("version", codecForNumber()) + .property("config", codecForFormConfiguration()) + .build("FormMetadata"); + +export const codecForUIForms = (): Codec<UiForms> => + buildCodecForObject<UiForms>() + .property("forms", codecForList(codecForFormMetadata())) + .build("UiForms"); + +export type FormMetadata = { + label: string; + id: string; + version: number; + config: FormConfiguration; +}; + +export interface UiForms { + // Where libeufin backend is localted + // default: window.origin without "webui/" + forms: Array<FormMetadata>; +} diff --git a/packages/web-util/src/forms/useField.ts b/packages/web-util/src/forms/useField.ts index bf94d2f5d..a250d3100 100644 --- a/packages/web-util/src/forms/useField.ts +++ b/packages/web-util/src/forms/useField.ts @@ -1,44 +1,53 @@ -import { useContext, useState } from "preact/compat"; -import { FormContext, InputFieldState } from "./FormProvider.js"; +import { useContext } from "preact/compat"; +import { FieldUIOptions, FormContext } from "./FormProvider.js"; +import { TranslatedString } from "@gnu-taler/taler-util"; export interface InputFieldHandler<Type> { value: Type; onChange: (s: Type) => void; - state: InputFieldState; - isDirty: boolean; + state: FieldUIOptions; + error?: TranslatedString | undefined; } +/** + * @deprecated removing this so we don't depend on context to create a form + * @param name + * @returns + */ export function useField<T extends object, K extends keyof T>( name: K, -): InputFieldHandler<T[K]> { +): InputFieldHandler<T[K]> | undefined { + const ctx = useContext(FormContext); + if (!ctx) { + //no context, can't be used + return undefined; + } const { - initialValue, value: formValue, computeFormState, onUpdate: notifyUpdate, - } = useContext(FormContext); + readOnly: readOnlyForm, + } = ctx type P = typeof name; type V = T[P]; const formState = computeFormState ? computeFormState(formValue.current) : {}; const fieldValue = readField(formValue.current, String(name)) as V; - // console.log("USE FIELD", String(name), formValue.current, fieldValue); - const [currentValue, setCurrentValue] = useState<any | undefined>(fieldValue); + const fieldState = - readField<Partial<InputFieldState>>(formState, String(name)) ?? {}; + readField<Partial<FieldUIOptions>>(formState, String(name)) ?? {}; //compute default state const state = { - disabled: fieldState.disabled ?? false, - readonly: fieldState.readonly ?? false, + disabled: readOnlyForm ? true : (fieldState.disabled ?? false), hidden: fieldState.hidden ?? false, - error: fieldState.error, + help: fieldState.help, elements: "elements" in fieldState ? fieldState.elements ?? [] : [], }; function onChange(value: V): void { - setCurrentValue(value); + // setCurrentValue(value); formValue.current = setValueDeeper( formValue.current, String(name).split("."), @@ -52,7 +61,6 @@ export function useField<T extends object, K extends keyof T>( return { value: fieldValue, onChange, - isDirty: currentValue !== undefined, state, }; } @@ -67,18 +75,8 @@ export function useField<T extends object, K extends keyof T>( function readField<T>( object: any, name: string, - debug?: boolean, ): T | undefined { return name.split(".").reduce((prev, current) => { - if (debug) { - console.log( - "READ", - name, - prev, - current, - prev ? prev[current] : undefined, - ); - } return prev ? prev[current] : undefined; }, object); } diff --git a/packages/web-util/src/hooks/index.ts b/packages/web-util/src/hooks/index.ts index f6c74ff22..ba1b6e222 100644 --- a/packages/web-util/src/hooks/index.ts +++ b/packages/web-util/src/hooks/index.ts @@ -1,5 +1,5 @@ export { useLang } from "./useLang.js"; -export { useLocalStorage, buildStorageKey } from "./useLocalStorage.js"; +export { useLocalStorage, buildStorageKey, StorageKey, StorageState } from "./useLocalStorage.js"; export { useMemoryStorage } from "./useMemoryStorage.js"; export * from "./useNotifications.js"; export { diff --git a/packages/web-util/src/hooks/useLang.ts b/packages/web-util/src/hooks/useLang.ts index 448cd8aba..5b1be0309 100644 --- a/packages/web-util/src/hooks/useLang.ts +++ b/packages/web-util/src/hooks/useLang.ts @@ -20,16 +20,42 @@ import { useLocalStorage, } from "./useLocalStorage.js"; -function getBrowserLang(): string | undefined { +const MIN_LANG_COVERAGE_THRESHOLD = 90; +/** + * choose the best from the browser config based on the completeness + * on the translation + */ +function getBrowserLang(completeness: Record<string, number>): string | undefined { if (typeof window === "undefined") return undefined; - if (window.navigator.languages) return window.navigator.languages[0]; - if (window.navigator.language) return window.navigator.language; + + if (window.navigator.language) { + if (completeness[window.navigator.language] >= MIN_LANG_COVERAGE_THRESHOLD) { + return window.navigator.language + } + } + if (window.navigator.languages) { + const match = Object.entries(completeness).filter(([code, value]) => { + if (value < MIN_LANG_COVERAGE_THRESHOLD) return false; //do not consider langs below 90% + return window.navigator.languages.findIndex(l => l.startsWith(code)) !== -1 + }).map(([code, value]) => ({ code, value })) + + if (match.length > 0) { + let max = match[0] + match.forEach(v => { + if (v.value > max.value) { + max = v + } + }) + return max.code + } + }; + return undefined; } const langPreferenceKey = buildStorageKey("lang-preference"); -export function useLang(initial?: string): Required<StorageState> { - const defaultValue = (getBrowserLang() || initial || "en").substring(0, 2); +export function useLang(initial: string | undefined, completeness: Record<string, number>): Required<StorageState> { + const defaultValue = (getBrowserLang(completeness) || initial || "en").substring(0, 2); return useLocalStorage(langPreferenceKey, defaultValue); } diff --git a/packages/web-util/src/hooks/useLocalStorage.ts b/packages/web-util/src/hooks/useLocalStorage.ts index b460144a6..abd80bacc 100644 --- a/packages/web-util/src/hooks/useLocalStorage.ts +++ b/packages/web-util/src/hooks/useLocalStorage.ts @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Codec, codecForString } from "@gnu-taler/taler-util"; +import { AbsoluteTime, Codec, codecForString } from "@gnu-taler/taler-util"; import { useEffect, useState } from "preact/hooks"; import { ObservableMap, @@ -61,16 +61,28 @@ const supportLocalStorage = typeof window !== "undefined"; const supportBrowserStorage = typeof chrome !== "undefined" && typeof chrome.storage !== "undefined"; +/** + * Build setting storage + */ const storage: ObservableMap<string, string> = (function buildStorage() { if (supportBrowserStorage) { - return browserStorageMap(memoryMap<string>()); + //browser storage is like local storage but + //with app sync. + //Works for almost every browser + if (supportLocalStorage) { + return browserStorageMap(localStorageMap()); + } else { + // service worker doesn't have local storage + return browserStorageMap(memoryMap<string>()); + } } else if (supportLocalStorage) { + // fallback if browser is too old return localStorageMap(); } else { + // new need to save settings somewhere return memoryMap<string>(); } })(); - //with initial value export function useLocalStorage<Type = string>( key: StorageKey<Type>, @@ -85,26 +97,14 @@ export function useLocalStorage<Type = string>( key: StorageKey<Type>, defaultValue?: Type, ): StorageState<Type> { - function convert(updated: string | undefined): Type | undefined { - if (updated === undefined) return defaultValue; //optional - try { - return key.codec.decode(JSON.parse(updated)); - } catch (e) { - //decode error - return defaultValue; - } - } - const [storedValue, setStoredValue] = useState<Type | undefined>( - (): Type | undefined => { - const prev = storage.get(key.id); - return convert(prev); - }, - ); + const current = convert(storage.get(key.id), key, defaultValue); + + const [_, setStoredValue] = useState(AbsoluteTime.now().t_ms); useEffect(() => { return storage.onUpdate(key.id, () => { - const newValue = storage.get(key.id); - setStoredValue(convert(newValue)); + // const newValue = storage.get(key.id); + setStoredValue(AbsoluteTime.now().t_ms); }); }, [key.id]); @@ -120,10 +120,20 @@ export function useLocalStorage<Type = string>( }; return { - value: storedValue, + value: current, update: setValue, reset: () => { setValue(defaultValue); }, }; } + +function convert<Type>(updated: string | undefined, key: StorageKey<Type>, defaultValue?: Type): Type | undefined { + if (updated === undefined) return defaultValue; //optional + try { + return key.codec.decode(JSON.parse(updated)); + } catch (e) { + //decode error + return defaultValue; + } +} diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts index ca67c5b9b..103b88c86 100644 --- a/packages/web-util/src/hooks/useNotifications.ts +++ b/packages/web-util/src/hooks/useNotifications.ts @@ -1,27 +1,68 @@ -import { TalerError, TalerErrorCode, TranslatedString } from "@gnu-taler/taler-util"; +import { + AbsoluteTime, + Duration, + OperationAlternative, + OperationFail, + OperationOk, + OperationResult, + TalerError, + TalerErrorCode, + TranslatedString, +} from "@gnu-taler/taler-util"; import { useEffect, useState } from "preact/hooks"; -import { memoryMap, useTranslationContext } from "../index.browser.js"; +import { ButtonHandler, OnOperationFailReturnType, OnOperationSuccesReturnType } from "../components/Button.js"; +import { + InternationalizationAPI, + memoryMap, + useTranslationContext, +} from "../index.browser.js"; 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>>(); 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( @@ -34,48 +75,49 @@ export function notifyError( title, description, debug, + when: AbsoluteTime.now(), }); } -export function notifyException( - title: TranslatedString, - ex: Error, -) { +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; - 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: () => { - const mem = storage.get(NOTIFICATION_KEY) ?? new Map(); - const newState = new Map(mem); - newState.delete(hash(message)); - storage.set(NOTIFICATION_KEY, newState); + acknowledge: () => { + message.ack = true; + updateInStorage(message); }, }; }); @@ -106,48 +148,137 @@ function hash(msg: NotificationMessage): string { return hashCode(str); } -export function useLocalNotification(): [Notification | undefined, (n: NotificationMessage) => void, (cb: () => Promise<void>) => Promise<void>] { - const {i18n} = useTranslationContext(); +function errorMap<T extends OperationFail<unknown>>( + resp: T, + map: (d: T["case"]) => TranslatedString, +): void { + notify({ + type: "error", + title: map(resp.case), + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + when: AbsoluteTime.now(), + }); +} + +export type ErrorNotificationHandler = ( + cb: (notify: typeof errorMap) => Promise<void>, +) => Promise<void>; + +/** + * @deprecated use useLocalNotificationHandler + * + * @returns + */ +export function useLocalNotification(): [ + Notification | undefined, + (n: NotificationMessage) => void, + ErrorNotificationHandler, +] { + const { i18n } = useTranslationContext(); const [value, setter] = useState<NotificationMessage>(); - const notif = !value ? undefined : { - message: value, - remove: () => { - setter(undefined); - }, - } + const notif = !value + ? undefined + : { + message: value, + acknowledge: () => { + setter(undefined); + }, + }; - async function errorHandling(cb: () => Promise<void>) { + async function errorHandling(cb: (notify: typeof errorMap) => Promise<void>) { try { - return await cb() + 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`, (error instanceof Error ? error.message - : JSON.stringify(error)) as TranslatedString - ) + : JSON.stringify(error)) as TranslatedString, + ); } - } } - return [notif, setter, errorHandling] + return [notif, setter, errorHandling]; } -type Translator = ReturnType<typeof useTranslationContext>["i18n"] +type HandlerMaker = <T extends OperationResult<A, B>, A, B>( + onClick: () => Promise<T | undefined>, + onOperationSuccess: OnOperationSuccesReturnType<T>, + onOperationFail?: OnOperationFailReturnType<T>, + onOperationComplete?: () => void, +) => ButtonHandler<T, A, B>; + +export function useLocalNotificationHandler(): [ + Notification | undefined, + HandlerMaker, + (n: NotificationMessage) => void, +] { + const [value, setter] = useState<NotificationMessage>(); + const notif = !value + ? undefined + : { + message: value, + acknowledge: () => { + setter(undefined); + }, + }; + + function makeHandler<T extends OperationResult<A, B>, A, B>( + onClick: () => Promise<T | undefined>, + onOperationSuccess:OnOperationSuccesReturnType<T>, + onOperationFail?: OnOperationFailReturnType<T>, + onOperationComplete?: () => void, + ): ButtonHandler<T, A, B> { + return { + onClick, + onNotification: setter, + onOperationFail, + onOperationSuccess, + onOperationComplete, + }; + } -function buildRequestErrorMessage( i18n: Translator, cause: TalerError): ErrorNotification { + return [notif, makeHandler, setter]; +} + +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; } @@ -157,6 +288,7 @@ function buildRequestErrorMessage( i18n: Translator, cause: TalerError): ErrorNo title: i18n.str`Request throttled`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -166,6 +298,7 @@ function buildRequestErrorMessage( i18n: Translator, cause: TalerError): ErrorNo title: i18n.str`Malformed response`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -175,6 +308,7 @@ function buildRequestErrorMessage( i18n: Translator, cause: TalerError): ErrorNo title: i18n.str`Network error`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -184,6 +318,7 @@ function buildRequestErrorMessage( i18n: Translator, cause: TalerError): ErrorNo title: i18n.str`Unexpected request error`, description: cause.message as TranslatedString, debug: JSON.stringify(cause.errorDetail, undefined, 2), + when: AbsoluteTime.now(), }; break; } @@ -193,6 +328,7 @@ function buildRequestErrorMessage( i18n: Translator, cause: TalerError): ErrorNo 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/index.browser.ts b/packages/web-util/src/index.browser.ts index 82c399bfd..2f3b57b8d 100644 --- a/packages/web-util/src/index.browser.ts +++ b/packages/web-util/src/index.browser.ts @@ -3,6 +3,7 @@ export * from "./utils/request.js"; export * from "./utils/http-impl.browser.js"; export * from "./utils/http-impl.sw.js"; export * from "./utils/observable.js"; +export * from "./utils/route.js"; export * from "./context/index.js"; export * from "./components/index.js"; export * from "./forms/index.js"; diff --git a/packages/web-util/src/index.build.ts b/packages/web-util/src/index.build.ts index e2851dc3a..c0c5fc179 100644 --- a/packages/web-util/src/index.build.ts +++ b/packages/web-util/src/index.build.ts @@ -121,6 +121,51 @@ const sassPlugin: esbuild.Plugin = { }, }; + +/** + * Problem: + * No loader is configured for ".node" files: ../../node_modules/.pnpm/fsevents@2.3.3/node_modules/fsevents/fsevents.node + * + * Reference: + * https://github.com/evanw/esbuild/issues/1051#issuecomment-806325487 + */ +const nativeNodeModulesPlugin: esbuild.Plugin = { + name: 'native-node-modules', + setup(build) { + // If a ".node" file is imported within a module in the "file" namespace, resolve + // it to an absolute path and put it into the "node-file" virtual namespace. + build.onResolve({ filter: /\.node$/, namespace: 'file' }, args => ({ + path: require.resolve(args.path, { paths: [args.resolveDir] }), + namespace: 'node-file', + })) + + // Files in the "node-file" virtual namespace call "require()" on the + // path from esbuild of the ".node" file in the output directory. + build.onLoad({ filter: /.*/, namespace: 'node-file' }, args => ({ + contents: ` + import path from ${JSON.stringify(args.path)} + try { module.exports = require(path) } + catch {} + `, + })) + + // If a ".node" file is imported within a module in the "node-file" namespace, put + // it in the "file" namespace where esbuild's default loading behavior will handle + // it. It is already an absolute path since we resolved it to one above. + build.onResolve({ filter: /\.node$/, namespace: 'node-file' }, args => ({ + path: args.path, + namespace: 'file', + })) + + // Tell esbuild's default loading behavior to use the "file" loader for + // these ".node" files. + let opts = build.initialOptions + opts.loader = opts.loader || {} + opts.loader['.node'] = 'file' + }, +} + + const postCssPlugin: esbuild.Plugin = { name: "custom-build-postcss", setup(build) { @@ -173,7 +218,7 @@ const defaultEsBuildConfig: esbuild.BuildOptions = { ".woff2": "file", ".eot": "file", }, - target: ["es6"], + target: ["es2020"], format: "esm", platform: "browser", jsxFactory: "h", diff --git a/packages/web-util/src/serve.ts b/packages/web-util/src/serve.ts index 3d2744bb9..1daea15bf 100644 --- a/packages/web-util/src/serve.ts +++ b/packages/web-util/src/serve.ts @@ -29,6 +29,7 @@ export async function serve(opts: { folder: string; port: number; source?: string; + tls?: boolean; examplesLocationJs?: string; examplesLocationCss?: string; onSourceUpdate?: () => Promise<void>; @@ -39,9 +40,14 @@ export async function serve(opts: { const httpServer = http.createServer(app); const httpPort = opts.port; - const httpsServer = https.createServer(httpServerOptions, app); - const httpsPort = opts.port + 1; - const servers = [httpServer, httpsServer]; + let httpsServer: typeof httpServer | undefined; + let httpsPort: number | undefined; + const servers = [httpServer]; + if (opts.tls) { + httpsServer = https.createServer(httpServerOptions, app); + httpsPort = opts.port + 1; + servers.push(httpsServer) + } logger.info(`Dev server. Endpoints:`); logger.info(` ${PATHS.APP}: where root application can be tested`); @@ -120,6 +126,8 @@ export async function serve(opts: { logger.info(`Serving ${opts.folder} on ${httpPort}: plain HTTP`); httpServer.listen(httpPort); - logger.info(`Serving ${opts.folder} on ${httpsPort}: HTTP + TLS`); - httpsServer.listen(httpsPort); + if (httpsServer !== undefined) { + logger.info(`Serving ${opts.folder} on ${httpsPort}: HTTP + TLS`); + httpsServer.listen(httpsPort); + } } diff --git a/packages/web-util/src/tests/mock.ts b/packages/web-util/src/tests/mock.ts index f4eb0e7aa..d09e8b4a6 100644 --- a/packages/web-util/src/tests/mock.ts +++ b/packages/web-util/src/tests/mock.ts @@ -15,7 +15,6 @@ */ import { Logger } from "@gnu-taler/taler-util"; -import { deprecate } from "util"; type HttpMethod = | "get" @@ -39,6 +38,9 @@ type HttpMethod = | "unlink" | "UNLINK"; +/** + * @deprecated do not use it, it will be removed + */ export type Query<Req, Res> = { method: HttpMethod; url: string; @@ -69,6 +71,9 @@ type MockedResponse = { expectedQuery?: ExpectationValues; }; +/** + * @deprecated do not use it, it will be removed + */ export abstract class MockEnvironment { expectations: Array<ExpectationValues> = []; queriesMade: Array<ExpectationValues> = []; diff --git a/packages/web-util/src/tests/swr.ts b/packages/web-util/src/tests/swr.ts index 903cd48d8..d5f4341f3 100644 --- a/packages/web-util/src/tests/swr.ts +++ b/packages/web-util/src/tests/swr.ts @@ -28,6 +28,7 @@ const logger = new Logger("tests/swr.ts"); * * buildTestingContext() will return a testing context * + * @deprecated do not use it, it will be removed */ export class SwrMockEnvironment extends MockEnvironment { constructor(debug = false) { diff --git a/packages/web-util/src/utils/http-impl.browser.ts b/packages/web-util/src/utils/http-impl.browser.ts index 974a7d1b8..1e5496071 100644 --- a/packages/web-util/src/utils/http-impl.browser.ts +++ b/packages/web-util/src/utils/http-impl.browser.ts @@ -33,6 +33,7 @@ import { getDefaultHeaders, encodeBody, DEFAULT_REQUEST_TIMEOUT_MS, + HttpLibArgs, } from "@gnu-taler/taler-util/http"; const logger = new Logger("browserHttpLib"); @@ -40,10 +41,18 @@ const logger = new Logger("browserHttpLib"); /** * An implementation of the [[HttpRequestLibrary]] using the * browser's XMLHttpRequest. + * + * @deprecated use BrowserFetchHttpLib */ -export class BrowserHttpLib implements HttpRequestLibrary { +export class BrowserHttpLibDepreacted implements HttpRequestLibrary { private throttle = new RequestThrottler(); private throttlingEnabled = true; + private requireTls = false; + + constructor(args?: HttpLibArgs) { + this.throttlingEnabled = args?.enableThrottling ?? true; + this.requireTls = args?.requireTls ?? false; + } fetch( requestUrl: string, @@ -55,8 +64,8 @@ export class BrowserHttpLib implements HttpRequestLibrary { const requestTimeout = options?.timeout ?? Duration.fromMilliseconds(DEFAULT_REQUEST_TIMEOUT_MS); + const parsedUrl = new URL(requestUrl); if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) { - const parsedUrl = new URL(requestUrl); throw TalerError.fromDetail( TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, { @@ -67,16 +76,29 @@ export class BrowserHttpLib implements HttpRequestLibrary { `request to origin ${parsedUrl.origin} was throttled`, ); } + if (this.requireTls && parsedUrl.protocol !== "https:") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NETWORK_ERROR, + { + requestMethod: requestMethod, + requestUrl: requestUrl, + }, + `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`, + ); + } let myBody: ArrayBuffer | undefined = requestMethod === "POST" || requestMethod === "PUT" || requestMethod === "PATCH" ? encodeBody(requestBody) : undefined; - const requestHeadersMap = { - ...getDefaultHeaders(requestMethod), - ...requestHeader, - }; + const requestHeadersMap = getDefaultHeaders(requestMethod); + if (requestHeader) { + Object.entries(requestHeader).forEach(([key, value]) => { + if (value === undefined) return; + requestHeadersMap[key] = value + }) + } return new Promise<HttpResponse>((resolve, reject) => { const myRequest = new XMLHttpRequest(); diff --git a/packages/web-util/src/utils/http-impl.sw.ts b/packages/web-util/src/utils/http-impl.sw.ts index 3120309f4..9c820bb4b 100644 --- a/packages/web-util/src/utils/http-impl.sw.ts +++ b/packages/web-util/src/utils/http-impl.sw.ts @@ -18,15 +18,16 @@ * Imports. */ import { + Duration, RequestThrottler, - TalerErrorCode, TalerError, - Duration, + TalerErrorCode } from "@gnu-taler/taler-util"; import { DEFAULT_REQUEST_TIMEOUT_MS, Headers, + HttpLibArgs, HttpRequestLibrary, HttpRequestOptions, HttpResponse, @@ -38,9 +39,15 @@ import { * An implementation of the [[HttpRequestLibrary]] using the * browser's XMLHttpRequest. */ -export class ServiceWorkerHttpLib implements HttpRequestLibrary { +export class BrowserFetchHttpLib implements HttpRequestLibrary { private throttle = new RequestThrottler(); private throttlingEnabled = true; + private requireTls = false; + + public constructor(args?: HttpLibArgs) { + this.throttlingEnabled = args?.enableThrottling ?? true; + this.requireTls = args?.requireTls ?? false; + } async fetch( requestUrl: string, @@ -51,9 +58,11 @@ export class ServiceWorkerHttpLib implements HttpRequestLibrary { const requestHeader = options?.headers; const requestTimeout = options?.timeout ?? Duration.fromMilliseconds(DEFAULT_REQUEST_TIMEOUT_MS); + const requestCancel = options?.cancellationToken; + const requestRedirect = options?.redirect; + const parsedUrl = new URL(requestUrl); if (this.throttlingEnabled && this.throttle.applyThrottle(requestUrl)) { - const parsedUrl = new URL(requestUrl); throw TalerError.fromDetail( TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED, { @@ -64,22 +73,42 @@ export class ServiceWorkerHttpLib implements HttpRequestLibrary { `request to origin ${parsedUrl.origin} was throttled`, ); } + if (this.requireTls && parsedUrl.protocol !== "https:") { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_NETWORK_ERROR, + { + requestMethod: requestMethod, + requestUrl: requestUrl, + }, + `request to ${parsedUrl.origin} is not possible with protocol ${parsedUrl.protocol}`, + ); + } - let myBody: ArrayBuffer | undefined = - requestMethod === "POST" ? encodeBody(requestBody) : undefined; - - const requestHeadersMap = { - ...getDefaultHeaders(requestMethod), - ...requestHeader, - }; + const myBody: ArrayBuffer | undefined = + requestMethod === "POST" || requestMethod === "PUT" || requestMethod === "PATCH" + ? encodeBody(requestBody) + : undefined; + + const requestHeadersMap = getDefaultHeaders(requestMethod); + if (requestHeader) { + Object.entries(requestHeader).forEach(([key, value]) => { + if (value === undefined) return; + requestHeadersMap[key] = value + }) + } const controller = new AbortController(); - let timeoutId: any | undefined; + let timeoutId: ReturnType<typeof setTimeout> | undefined; if (requestTimeout.d_ms !== "forever") { timeoutId = setTimeout(() => { - controller.abort(TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT); + controller.abort(TalerErrorCode.GENERIC_TIMEOUT); }, requestTimeout.d_ms); } + if (requestCancel) { + requestCancel.onCancelled(() => { + controller.abort(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR) + }); + } try { const response = await fetch(requestUrl, { @@ -87,6 +116,7 @@ export class ServiceWorkerHttpLib implements HttpRequestLibrary { body: myBody, method: requestMethod, signal: controller.signal, + redirect: requestRedirect }); if (timeoutId) { @@ -109,42 +139,19 @@ export class ServiceWorkerHttpLib 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; } } - get(url: string, opt?: HttpRequestOptions): Promise<HttpResponse> { - return this.fetch(url, { - method: "GET", - ...opt, - }); - } - - postJson( - url: string, - body: any, - opt?: HttpRequestOptions, - ): Promise<HttpResponse> { - return this.fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - ...opt, - }); - } - - stop(): void { - // Nothing to do - } } function makeTextHandler( @@ -190,7 +197,7 @@ function makeJsonHandler( requestMethod, httpStatusCode: response.status, }, - message, + message, ); } } diff --git a/packages/web-util/src/utils/observable.ts b/packages/web-util/src/utils/observable.ts index 01e655eaa..16a33ae72 100644 --- a/packages/web-util/src/utils/observable.ts +++ b/packages/web-util/src/utils/observable.ts @@ -118,6 +118,7 @@ export function localStorageMap(): ObservableMap<string, string> { const total = localStorage.length; return { next() { + if (index === total) return { done: true, value: undefined }; const key = localStorage.key(index); if (key === null) { //we are going from 0 until last, this should not happen @@ -128,7 +129,6 @@ export function localStorageMap(): ObservableMap<string, string> { //the key exist, this should not happen throw Error("value cant be null"); } - if (index == total) return { done: true, value: [key, item] }; index = index + 1; return { done: false, value: [key, item] }; }, @@ -165,12 +165,12 @@ export function localStorageMap(): ObservableMap<string, string> { const total = localStorage.length; return { next() { + if (index === total) return { done: true, value: undefined }; const key = localStorage.key(index); if (key === null) { //we are going from 0 until last, this should not happen throw Error("key cant be null"); } - if (index == total) return { done: true, value: key }; index = index + 1; return { done: false, value: key }; }, @@ -185,6 +185,7 @@ export function localStorageMap(): ObservableMap<string, string> { const total = localStorage.length; return { next() { + if (index === total) return { done: true, value: undefined }; const key = localStorage.key(index); if (key === null) { //we are going from 0 until last, this should not happen @@ -195,7 +196,6 @@ export function localStorageMap(): ObservableMap<string, string> { //the key exist, this should not happen throw Error("value cant be null"); } - if (index == total) return { done: true, value: item }; index = index + 1; return { done: false, value: item }; }, @@ -247,11 +247,11 @@ function onBrowserStorageUpdate(cb: (changes: Changes) => void): void { export function browserStorageMap( backend: ObservableMap<string, string>, ): ObservableMap<string, string> { - getAllContent().then((content) => { + getAllContent().then(content => { Object.entries(content ?? {}).forEach(([k, v]) => { backend.set(k, v as string); }); - }); + }) backend.onAnyUpdate(async () => { const result: Record<string, string> = {}; diff --git a/packages/web-util/src/utils/request.ts b/packages/web-util/src/utils/request.ts index f8a892d99..23d3af468 100644 --- a/packages/web-util/src/utils/request.ts +++ b/packages/web-util/src/utils/request.ts @@ -17,6 +17,9 @@ import { HttpStatusCode } from "@gnu-taler/taler-util"; import { base64encode } from "./base64.js"; +/** + * @deprecated do not use it, it will be removed + */ export enum ErrorType { CLIENT, SERVER, @@ -32,6 +35,7 @@ export enum ErrorType { * @param baseUrl URL where the service is located * @param endpoint endpoint of the service to be called * @param options auth, method and params + * @deprecated do not use it, it will be removed * @returns */ export async function defaultRequestHandler<T>( @@ -41,7 +45,7 @@ export async function defaultRequestHandler<T>( ): Promise<HttpResponseOk<T>> { const requestHeaders: Record<string, string> = {}; if (options.token) { - requestHeaders.Authorization = `Bearer ${options.token}`; + requestHeaders.Authorization = `Bearer secret-token:${options.token}`; } else if (options.basicAuth) { requestHeaders.Authorization = `Basic ${base64encode( `${options.basicAuth.username}:${options.basicAuth.password}`, @@ -189,16 +193,25 @@ export async function defaultRequestHandler<T>( } } +/** + * @deprecated do not use it, it will be removed + */ export type HttpResponse<T, ErrorDetail> = | HttpResponseOk<T> | HttpResponseLoading<T> | HttpError<ErrorDetail>; +/** + * @deprecated do not use it, it will be removed + */ export type HttpResponsePaginated<T, ErrorDetail> = | HttpResponseOkPaginated<T> | HttpResponseLoading<T> | HttpError<ErrorDetail>; +/** + * @deprecated do not use it, it will be removed + */ export interface RequestInfo { url: string; hasToken: boolean; @@ -215,6 +228,9 @@ interface HttpResponseLoading<T> { data?: T; } +/** + * @deprecated do not use it, it will be removed + */ export interface HttpResponseOk<T> { ok: true; loading?: false; @@ -225,8 +241,14 @@ export interface HttpResponseOk<T> { info?: RequestInfo; } +/** + * @deprecated do not use it, it will be removed + */ export type HttpResponseOkPaginated<T> = HttpResponseOk<T> & WithPagination; +/** + * @deprecated do not use it, it will be removed + */ export interface WithPagination { loadMore: () => void; loadMorePrev: () => void; @@ -234,6 +256,9 @@ export interface WithPagination { isReachingStart?: boolean; } +/** + * @deprecated do not use it, it will be removed + */ export type HttpError<ErrorDetail> = | HttpRequestTimeoutError | HttpResponseClientError<ErrorDetail> @@ -241,6 +266,9 @@ export type HttpError<ErrorDetail> = | HttpResponseUnreadableError | HttpResponseUnexpectedError; +/** + * @deprecated do not use it, it will be removed + */ export interface HttpResponseServerError<ErrorDetail> { ok?: false; loading?: false; @@ -292,6 +320,9 @@ interface HttpResponseUnreadableError { body: string; message: string; } +/** + * @deprecated do not use it, it will be removed + */ export class RequestError<ErrorDetail> extends Error { /** * @deprecated use cause @@ -307,6 +338,9 @@ export class RequestError<ErrorDetail> extends Error { type Methods = "GET" | "POST" | "PATCH" | "DELETE" | "PUT"; +/** + * @deprecated do not use it, it will be removed + */ export interface RequestOptions { method?: Methods; token?: string; @@ -323,6 +357,9 @@ export interface RequestOptions { talerAmlOfficerSignature?: string; } +/** + * @deprecated do not use it, it will be removed + */ async function buildRequestOk<T>( response: Response, url: string, @@ -345,6 +382,9 @@ async function buildRequestOk<T>( }; } +/** + * @deprecated do not use it, it will be removed + */ export function buildRequestFailed<ErrorDetail>( url: string, dataTxt: string, @@ -424,6 +464,9 @@ export function buildRequestFailed<ErrorDetail>( } } +/** + * @deprecated do not use it, it will be removed + */ function validateURL(baseUrl: string, endpoint: string): URL | undefined { try { return new URL(`${baseUrl}${endpoint}`) diff --git a/packages/web-util/src/utils/route.ts b/packages/web-util/src/utils/route.ts new file mode 100644 index 000000000..494a61efa --- /dev/null +++ b/packages/web-util/src/utils/route.ts @@ -0,0 +1,126 @@ +/* + 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/> + */ + +declare const __location: unique symbol; +/** + * special string that defined a location in the application + * + * this help to prevent wrong path + */ +export type AppLocation = string & { + [__location]: true; +}; + +export type EmptyObject = Record<string, never>; + +export function urlPattern< + T extends Record<string, string | undefined> = EmptyObject, +>(pattern: RegExp, reverse: (p: T) => string): RouteDefinition<T> { + const url = reverse as (p: T) => AppLocation; + return { + pattern: new RegExp(pattern), + url, + }; +} + +/** + * defines a location in the app + * + * pattern: how a string will trigger this location + * url(): how a state serialize to a location + */ + +export type ObjectOf<T> = Record<string, T> | EmptyObject; + +export type RouteDefinition< + T extends ObjectOf<string | undefined> = EmptyObject, +> = { + pattern: RegExp; + url: (p: T) => AppLocation; +}; + +const nullRountDef = { + pattern: new RegExp(/.*/), + url: () => "" as AppLocation, +}; +export function buildNullRoutDefinition< + T extends ObjectOf<string>, +>(): RouteDefinition<T> { + return nullRountDef; +} + +/** + * Search path in the pageList + * get the values from the path found + * add params from searchParams + * + * @param path + * @param params + */ +export function findMatch<T extends ObjectOf<RouteDefinition>>( + pagesMap: T, + pageList: Array<keyof T>, + path: string, + params: Record<string, string[]>, +): Location<T> | undefined { + for (let idx = 0; idx < pageList.length; idx++) { + const name = pageList[idx]; + const found = pagesMap[name].pattern.exec(path); + if (found !== null) { + const values = {} as Record<string, unknown>; + + if (found.groups !== undefined) { + Object.entries(found.groups).forEach(([key, value]) => { + values[key] = value; + }); + } + + // @ts-expect-error values is a map string which is equivalent to the RouteParamsType + return { name, parent: pagesMap, values, params }; + } + } + return undefined; +} + +/** + * get the type of the params of a location + * + */ +type RouteParamsType< + RouteType, + Key extends keyof RouteType, +> = RouteType[Key] extends RouteDefinition<infer ParamType> ? ParamType : never; + +/** + * Helps to create a map of a type with the key + */ +type MapKeyValue<Type> = { + [Key in keyof Type]: Key extends string + ? { + parent: Type; + name: Key; + values: RouteParamsType<Type, Key>; + params: Record<string, string[]>; + } + : never; +}; + +/** + * create a enumeration of value of a mapped type + */ +type EnumerationOf<T> = T[keyof T]; + +export type Location<T> = EnumerationOf<MapKeyValue<T>>; |