diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx index eb18251fd..2191205c2 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx @@ -19,349 +19,203 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { amountFractionalBase, ExchangeListItem } from "@gnu-taler/taler-util"; import { createExample } from "../test-utils.js"; -import { termsHtml, termsPdf, termsPlain, termsXml } from "./termsExample.js"; +import { TermsState } from "../utils/index.js"; import { View as TestedComponent } from "./Withdraw.js"; -function parseFromString(s: string): Document { - if (typeof window === "undefined") { - return {} as Document; - } - return new window.DOMParser().parseFromString(s, "text/xml"); -} - export default { title: "cta/withdraw", component: TestedComponent, }; -const exchangeList: ExchangeListItem[] = [ - { - currency: "USD", - exchangeBaseUrl: "exchange.demo.taler.net", - tos: { - currentVersion: "1", - acceptedVersion: "1", - content: "terms of service content", - contentType: "text/plain", - }, - paytoUris: ["asd"], - }, - { - currency: "USD", - exchangeBaseUrl: "exchange.test.taler.net", - tos: { - currentVersion: "1", - acceptedVersion: "1", - content: "terms of service content", - contentType: "text/plain", - }, - paytoUris: ["asd"], - }, -]; +const exchangeList = { + "exchange.demo.taler.net": "http://exchange.demo.taler.net (USD)", + "exchange.test.taler.net": "http://exchange.test.taler.net (KUDOS)", +}; -export const NewTerms = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 1, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { +const nullHandler = { + onClick: async (): Promise => { null; }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), - }, - status: "new", - version: "", - }, -}); +}; -export const TermsReviewingPLAIN = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "plain", - content: termsPlain, - }, - status: "new", - version: "", - }, - reviewing: true, -}); - -export const TermsReviewingHTML = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "html", - href: new URL(`data:text/html;base64,${toBase64(termsHtml)}`), - }, - version: "", - status: "new", - }, - reviewing: true, -}); - -function toBase64(str: string): string { - return btoa( - encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { - return String.fromCharCode(parseInt(p1, 16)); - }), - ); -} - -export const TermsReviewingPDF = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "pdf", - location: new URL(`data:text/html;base64,${toBase64(termsPdf)}`), - }, - status: "new", - version: "", - }, - reviewing: true, -}); - -export const TermsReviewingXML = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), - }, - status: "new", - version: "", - }, - reviewing: true, -}); - -export const NewTermsAccepted = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), - }, - status: "new", - version: "", - }, - reviewed: true, -}); - -export const TermsShowAgainXML = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), - }, - version: "", - status: "new", - }, - reviewed: true, - reviewing: true, -}); - -export const TermsChanged = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), - }, - version: "", - status: "changed", - }, -}); - -export const TermsNotFound = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: undefined, - status: "notfound", - version: "", - }, -}); - -export const TermsAlreadyAccepted = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: amountFractionalBase * 0.5, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, +const normalTosState = { terms: { status: "accepted", - content: undefined, version: "", + } as TermsState, + onAccept: () => null, + onReview: () => null, + reviewed: false, + reviewing: false, +}; + +export const TermsOfServiceNotYetLoaded = createExample(TestedComponent, { + state: { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: () => null, + }, + showExchangeSelection: false, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 10000000, + value: 1, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 1, + }, + }, +}); + +export const WithSomeFee = createExample(TestedComponent, { + state: { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: () => null, + }, + showExchangeSelection: false, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 10000000, + value: 1, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 1, + }, + tosProps: normalTosState, }, }); export const WithoutFee = createExample(TestedComponent, { - knownExchanges: exchangeList, - exchangeBaseUrl: "exchange.demo.taler.net", - withdrawalFee: { - currency: "USD", - fraction: 0, - value: 0, - }, - amount: { - currency: "USD", - value: 2, - fraction: 10000000, - }, - - onSwitchExchange: async () => { - null; - }, - terms: { - content: { - type: "xml", - document: parseFromString(termsXml), + state: { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, }, - status: "accepted", - version: "", + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: () => null, + }, + showExchangeSelection: false, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 0, + value: 0, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 2, + }, + tosProps: normalTosState, + }, +}); + +export const EditExchangeUntouched = createExample(TestedComponent, { + state: { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + value: "exchange.demo.taler.net", + onChange: () => null, + }, + showExchangeSelection: true, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 0, + value: 0, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 2, + }, + tosProps: normalTosState, + }, +}); + +export const EditExchangeModified = createExample(TestedComponent, { + state: { + hook: undefined, + status: "success", + cancelEditExchange: nullHandler, + confirmEditExchange: nullHandler, + chosenAmount: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + doWithdrawal: nullHandler, + editExchange: nullHandler, + exchange: { + list: exchangeList, + isDirty: true, + value: "exchange.test.taler.net", + onChange: () => null, + }, + showExchangeSelection: true, + mustAcceptFirst: false, + withdrawalFee: { + currency: "USD", + fraction: 0, + value: 0, + }, + toBeReceived: { + currency: "USD", + fraction: 0, + value: 2, + }, + tosProps: normalTosState, }, }); diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts new file mode 100644 index 000000000..5a28c4cf5 --- /dev/null +++ b/packages/taler-wallet-webextension/src/cta/Withdraw.test.ts @@ -0,0 +1,122 @@ +/* + This file is part of GNU Taler + (C) 2021 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 + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ExchangeListItem } from "@gnu-taler/taler-util"; +import { expect } from "chai"; +import { mountHook } from "../test-utils.js"; +import { useComponentState } from "./Withdraw.js"; + +const exchanges: ExchangeListItem[] = [{ + currency: 'ARS', + exchangeBaseUrl: 'http://exchange.demo.taler.net', + paytoUris: [], + tos: { + acceptedVersion: '', + } +}] + +describe("Withdraw CTA states", () => { + it("should tell the user that the URI is missing", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState(undefined, { + listExchanges: async () => ({ exchanges }), + getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ + amount: 'ARS:2', + possibleExchanges: exchanges, + }) + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading-uri') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading-uri') + expect(hook).deep.equals({ "hasError": true, "operational": false, "message": "ERROR_NO-URI-FOR-WITHDRAWAL" }); + } + await waitNextUpdate() + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading-uri') + expect(hook).deep.equals({ "hasError": true, "operational": false, "message": "ERROR_NO-URI-FOR-WITHDRAWAL" }); + } + + await assertNoPendingUpdate() + }); + + it("should tell the user that there is not known exchange", async () => { + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => + useComponentState('taler-withdraw://', { + listExchanges: async () => ({ exchanges }), + getWithdrawalDetailsForUri: async ({ talerWithdrawUri }: any) => ({ + amount: 'EUR:2', + possibleExchanges: [], + }) + } as any), + ); + + { + const { status, hook } = getLastResultOrThrow() + expect(status).equals('loading-uri') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading-exchange') + expect(hook).undefined; + } + + await waitNextUpdate() + + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading-exchange') + + expect(hook).deep.equals({ "hasError": true, "operational": false, "message": "ERROR_NO-DEFAULT-EXCHANGE" }); + } + + await waitNextUpdate() + + { + const { status, hook } = getLastResultOrThrow() + + expect(status).equals('loading-exchange') + + expect(hook).deep.equals({ "hasError": true, "operational": false, "message": "ERROR_NO-DEFAULT-EXCHANGE" }); + } + + await assertNoPendingUpdate() + }); + +}); \ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx index 676c65d2d..9739e1a47 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx @@ -21,17 +21,14 @@ * @author sebasjm */ -import { - AmountJson, - Amounts, - ExchangeListItem, - WithdrawUriInfoResponse, -} from "@gnu-taler/taler-util"; +import { AmountJson, Amounts } from "@gnu-taler/taler-util"; +import { TalerError } from "@gnu-taler/taler-wallet-core"; import { Fragment, h, VNode } from "preact"; -import { useCallback, useMemo, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; +import { Amount } from "../components/Amount.js"; +import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js"; import { Loading } from "../components/Loading.js"; import { LoadingError } from "../components/LoadingError.js"; -import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js"; import { LogoHeader } from "../components/LogoHeader.js"; import { Part } from "../components/Part.js"; import { SelectList } from "../components/SelectList.js"; @@ -42,72 +39,198 @@ import { SubTitle, WalletAction, } from "../components/styled/index.js"; -import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; -import { - amountToString, - buildTermsOfServiceState, - TermsState, -} from "../utils/index.js"; -import * as wxApi from "../wxApi.js"; -import { TermsOfServiceSection } from "./TermsOfServiceSection.js"; import { useTranslationContext } from "../context/translation.js"; -import { TalerError } from "@gnu-taler/taler-wallet-core"; +import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { buildTermsOfServiceState } from "../utils/index.js"; +import { + ButtonHandler, + SelectFieldHandler, +} from "../wallet/CreateManualWithdraw.js"; +import * as wxApi from "../wxApi.js"; +import { + Props as TermsOfServiceSectionProps, + TermsOfServiceSection, +} from "./TermsOfServiceSection.js"; interface Props { talerWithdrawUri?: string; } -export interface ViewProps { - withdrawalFee: AmountJson; - exchangeBaseUrl?: string; - amount: AmountJson; - onSwitchExchange: (ex: string) => void; - onWithdraw: () => Promise; - onReview: (b: boolean) => void; - onAccept: (b: boolean) => void; - reviewing: boolean; - reviewed: boolean; - terms: TermsState; - knownExchanges: ExchangeListItem[]; +type State = LoadingUri | LoadingExchange | LoadingInfoError | Success; + +interface LoadingUri { + status: "loading-uri"; + hook: HookError | undefined; +} +interface LoadingExchange { + status: "loading-exchange"; + hook: HookError | undefined; +} +interface LoadingInfoError { + status: "loading-info"; + hook: HookError | undefined; } -export function View({ - withdrawalFee, - exchangeBaseUrl, - knownExchanges, - amount, - onWithdraw, - onSwitchExchange, - terms, - reviewing, - onReview, - onAccept, - reviewed, -}: ViewProps): VNode { - const { i18n } = useTranslationContext(); +type Success = { + status: "success"; + hook: undefined; + + exchange: SelectFieldHandler; + + editExchange: ButtonHandler; + cancelEditExchange: ButtonHandler; + confirmEditExchange: ButtonHandler; + + showExchangeSelection: boolean; + chosenAmount: AmountJson; + withdrawalFee: AmountJson; + toBeReceived: AmountJson; + + doWithdrawal: ButtonHandler; + tosProps?: TermsOfServiceSectionProps; + mustAcceptFirst: boolean; +}; + +export function useComponentState( + talerWithdrawUri: string | undefined, + api: typeof wxApi, +): State { + const [customExchange, setCustomExchange] = useState( + undefined, + ); + + const uriInfoHook = useAsyncAsHook(async () => { + if (!talerWithdrawUri) throw Error("ERROR_NO-URI-FOR-WITHDRAWAL"); + + const uriInfo = await api.getWithdrawalDetailsForUri({ + talerWithdrawUri, + }); + const { exchanges: knownExchanges } = await api.listExchanges(); + + return { uriInfo, knownExchanges }; + }); + + const exchangeAndAmount = useAsyncAsHook( + async () => { + if (!uriInfoHook || uriInfoHook.hasError || !uriInfoHook.response) return; + const { uriInfo, knownExchanges } = uriInfoHook.response; + + const amount = Amounts.parseOrThrow(uriInfo.amount); + + const thisCurrencyExchanges = knownExchanges.filter( + (ex) => ex.currency === amount.currency, + ); + + const thisExchange: string | undefined = + customExchange ?? + uriInfo.defaultExchangeBaseUrl ?? + (thisCurrencyExchanges[0] + ? thisCurrencyExchanges[0].exchangeBaseUrl + : undefined); + + if (!thisExchange) throw Error("ERROR_NO-DEFAULT-EXCHANGE"); + + return { amount, thisExchange, thisCurrencyExchanges }; + }, + [], + [!uriInfoHook || uriInfoHook.hasError ? undefined : uriInfoHook], + ); + + const terms = useAsyncAsHook( + async () => { + if ( + !exchangeAndAmount || + exchangeAndAmount.hasError || + !exchangeAndAmount.response + ) + return; + const { thisExchange } = exchangeAndAmount.response; + const exchangeTos = await api.getExchangeTos(thisExchange, ["text/xml"]); + + const state = buildTermsOfServiceState(exchangeTos); + + return { state }; + }, + [], + [ + !exchangeAndAmount || exchangeAndAmount.hasError + ? undefined + : exchangeAndAmount, + ], + ); + + const info = useAsyncAsHook( + async () => { + if ( + !exchangeAndAmount || + exchangeAndAmount.hasError || + !exchangeAndAmount.response + ) + return; + const { thisExchange, amount } = exchangeAndAmount.response; + + const info = await api.getExchangeWithdrawalInfo({ + exchangeBaseUrl: thisExchange, + amount, + tosAcceptedFormat: ["text/xml"], + }); + + const withdrawalFee = Amounts.sub( + Amounts.parseOrThrow(info.withdrawalAmountRaw), + Amounts.parseOrThrow(info.withdrawalAmountEffective), + ).amount; + + return { info, withdrawalFee }; + }, + [], + [ + !exchangeAndAmount || exchangeAndAmount.hasError + ? undefined + : exchangeAndAmount, + ], + ); + + const [reviewing, setReviewing] = useState(false); + const [reviewed, setReviewed] = useState(false); + const [withdrawError, setWithdrawError] = useState( undefined, ); const [confirmDisabled, setConfirmDisabled] = useState(false); - const needsReview = terms.status === "changed" || terms.status === "new"; + const [showExchangeSelection, setShowExchangeSelection] = useState(false); + const [nextExchange, setNextExchange] = useState(); - const [switchingExchange, setSwitchingExchange] = useState(false); - const [nextExchange, setNextExchange] = useState( - undefined, - ); + if (!uriInfoHook || uriInfoHook.hasError) { + return { + status: "loading-uri", + hook: uriInfoHook, + }; + } - const exchanges = knownExchanges - .filter((e) => e.currency === amount.currency) - .reduce( - (prev, ex) => ({ ...prev, [ex.exchangeBaseUrl]: ex.exchangeBaseUrl }), - {}, - ); + if (!exchangeAndAmount || exchangeAndAmount.hasError) { + return { + status: "loading-exchange", + hook: exchangeAndAmount, + }; + } + if (!exchangeAndAmount.response) { + return { + status: "loading-exchange", + hook: undefined, + }; + } + const { thisExchange, thisCurrencyExchanges, amount } = + exchangeAndAmount.response; async function doWithdrawAndCheckError(): Promise { try { setConfirmDisabled(true); - await onWithdraw(); + if (!talerWithdrawUri) return; + const res = await api.acceptWithdrawal(talerWithdrawUri, thisExchange); + if (res.confirmTransferUrl) { + document.location.href = res.confirmTransferUrl; + } } catch (e) { if (e instanceof TalerError) { setWithdrawError(e); @@ -116,202 +239,64 @@ export function View({ } } - return ( - - - - Digital cash withdrawal - - - {withdrawError && ( - - Could not finish the withdrawal operation - - } - error={withdrawError.errorDetail} - /> - )} - -
- Total to withdraw} - text={amountToString(Amounts.sub(amount, withdrawalFee).amount)} - kind="positive" - /> - {Amounts.isNonZero(withdrawalFee) && ( - - Chosen amount} - text={amountToString(amount)} - kind="neutral" - /> - Exchange fee} - text={amountToString(withdrawalFee)} - kind="negative" - /> - - )} - {exchangeBaseUrl && ( - Exchange} - text={exchangeBaseUrl} - kind="neutral" - big - /> - )} - {!reviewing && - (switchingExchange ? ( - -
- Known exchanges} - list={exchanges} - value={nextExchange} - name="switchingExchange" - onChange={setNextExchange} - /> -
- { - if (nextExchange !== undefined) { - onSwitchExchange(nextExchange); - } - setSwitchingExchange(false); - }} - > - {nextExchange === undefined ? ( - Cancel exchange selection - ) : ( - Confirm exchange selection - )} - -
- ) : ( - setSwitchingExchange(true)} - > - Edit exchange - - ))} -
- -
- {(terms.status === "accepted" || (needsReview && reviewed)) && ( - - Confirm withdrawal - - )} - {terms.status === "notfound" && ( - - Withdraw anyway - - )} -
-
- ); -} - -export function WithdrawPageWithParsedURI({ - uri, - uriInfo, -}: { - uri: string; - uriInfo: WithdrawUriInfoResponse; -}): VNode { - const { i18n } = useTranslationContext(); - const [customExchange, setCustomExchange] = useState( - undefined, + const exchanges = thisCurrencyExchanges.reduce( + (prev, ex) => ({ ...prev, [ex.exchangeBaseUrl]: ex.exchangeBaseUrl }), + {}, ); - const [reviewing, setReviewing] = useState(false); - const [reviewed, setReviewed] = useState(false); - - const knownExchangesHook = useAsyncAsHook(wxApi.listExchanges); - - const knownExchanges = useMemo( - () => - !knownExchangesHook || knownExchangesHook.hasError - ? [] - : knownExchangesHook.response.exchanges, - [knownExchangesHook], - ); - const withdrawAmount = useMemo( - () => Amounts.parseOrThrow(uriInfo.amount), - [uriInfo.amount], - ); - const thisCurrencyExchanges = useMemo( - () => - knownExchanges.filter((ex) => ex.currency === withdrawAmount.currency), - [knownExchanges, withdrawAmount.currency], - ); - - const exchange: string | undefined = useMemo( - () => - customExchange ?? - uriInfo.defaultExchangeBaseUrl ?? - (thisCurrencyExchanges[0] - ? thisCurrencyExchanges[0].exchangeBaseUrl - : undefined), - [customExchange, thisCurrencyExchanges, uriInfo.defaultExchangeBaseUrl], - ); - - const detailsHook = useAsyncAsHook(async () => { - if (!exchange) throw Error("no default exchange"); - const tos = await wxApi.getExchangeTos(exchange, ["text/xml"]); - - const tosState = buildTermsOfServiceState(tos); - - const info = await wxApi.getExchangeWithdrawalInfo({ - exchangeBaseUrl: exchange, - amount: withdrawAmount, - tosAcceptedFormat: ["text/xml"], - }); - return { tos: tosState, info }; - }); - - if (!detailsHook) { - return ; + if (!info || info.hasError) { + return { + status: "loading-info", + hook: info, + }; } - if (detailsHook.hasError) { - return ( - Could not load the withdrawal details - } - error={detailsHook} - /> - ); + if (!info.response) { + return { + status: "loading-info", + hook: undefined, + }; } - const details = detailsHook.response; + const exchangeHandler: SelectFieldHandler = { + onChange: setNextExchange, + value: nextExchange || thisExchange, + list: exchanges, + isDirty: nextExchange !== thisExchange, + }; + + const editExchange: ButtonHandler = { + onClick: async () => { + setShowExchangeSelection(true); + }, + }; + const cancelEditExchange: ButtonHandler = { + onClick: async () => { + setShowExchangeSelection(false); + }, + }; + const confirmEditExchange: ButtonHandler = { + onClick: async () => { + setCustomExchange(exchangeHandler.value); + setShowExchangeSelection(false); + }, + }; + + const { withdrawalFee } = info.response; + const toBeReceived = Amounts.sub(amount, withdrawalFee).amount; + + const { state: termsState } = (!terms + ? undefined + : terms.hasError + ? undefined + : terms.response) || { state: undefined }; + + async function onAccept(accepted: boolean): Promise { + if (!termsState) return; - const onAccept = async (accepted: boolean): Promise => { - if (!exchange) return; try { - await wxApi.setExchangeTosAccepted( - exchange, - accepted ? details.tos.version : undefined, + await api.setExchangeTosAccepted( + thisExchange, + accepted ? termsState.version : undefined, ); setReviewed(accepted); } catch (e) { @@ -320,44 +305,154 @@ export function WithdrawPageWithParsedURI({ // setErrorAccepting(e.message); } } + } + + return { + status: "success", + hook: undefined, + exchange: exchangeHandler, + editExchange, + cancelEditExchange, + confirmEditExchange, + showExchangeSelection, + toBeReceived, + withdrawalFee, + chosenAmount: amount, + doWithdrawal: { + onClick: doWithdrawAndCheckError, + error: withdrawError, + disabled: confirmDisabled, + }, + tosProps: !termsState + ? undefined + : { + onAccept, + onReview: setReviewing, + reviewed: reviewed, + reviewing: reviewing, + terms: termsState, + }, + mustAcceptFirst: + termsState !== undefined && + (termsState.status === "changed" || termsState.status === "new"), }; +} - const onWithdraw = async (): Promise => { - if (!exchange) return; - const res = await wxApi.acceptWithdrawal(uri, exchange); - if (res.confirmTransferUrl) { - document.location.href = res.confirmTransferUrl; - } - }; - - const withdrawalFee = Amounts.sub( - Amounts.parseOrThrow(details.info.withdrawalAmountRaw), - Amounts.parseOrThrow(details.info.withdrawalAmountEffective), - ).amount; - +export function View({ state }: { state: Success }): VNode { + const { i18n } = useTranslationContext(); return ( - + + + + Digital cash withdrawal + + + {state.doWithdrawal.error && ( + + Could not finish the withdrawal operation + + } + error={state.doWithdrawal.error.errorDetail} + /> + )} + +
+ Total to withdraw} + text={} + kind="positive" + /> + {Amounts.isNonZero(state.withdrawalFee) && ( + + Chosen amount} + text={} + kind="neutral" + /> + Exchange fee} + text={} + kind="negative" + /> + + )} + Exchange} + text={state.exchange.value} + kind="neutral" + big + /> + {state.showExchangeSelection ? ( + +
+ Known exchanges} + list={state.exchange.list} + value={state.exchange.value} + name="switchingExchange" + onChange={state.exchange.onChange} + /> +
+ + {state.exchange.isDirty ? ( + Confirm exchange selection + ) : ( + Cancel exchange selection + )} + +
+ ) : ( + + Edit exchange + + )} +
+ {state.tosProps && } + {state.tosProps ? ( +
+ {(state.tosProps.terms.status === "accepted" || + (state.mustAcceptFirst && state.tosProps.reviewed)) && ( + + Confirm withdrawal + + )} + {state.tosProps.terms.status === "notfound" && ( + + Withdraw anyway + + )} +
+ ) : ( +
+ Loading terms of service... +
+ )} +
); } + export function WithdrawPage({ talerWithdrawUri }: Props): VNode { const { i18n } = useTranslationContext(); - const uriInfoHook = useAsyncAsHook(() => - !talerWithdrawUri - ? Promise.reject(undefined) - : wxApi.getWithdrawalDetailsForUri({ talerWithdrawUri }), - ); + + const state = useComponentState(talerWithdrawUri, wxApi); if (!talerWithdrawUri) { return ( @@ -366,24 +461,45 @@ export function WithdrawPage({ talerWithdrawUri }: Props): VNode { ); } - if (!uriInfoHook) { + + if (!state) { return ; } - if (uriInfoHook.hasError) { + + console.log(state); + if (state.status === "loading-uri") { + if (!state.hook) return ; + return ( Could not get the info from the URI } - error={uriInfoHook} + error={state.hook} + /> + ); + } + if (state.status === "loading-exchange") { + if (!state.hook) return ; + + return ( + Could not get exchange} + error={state.hook} + /> + ); + } + if (state.status === "loading-info") { + if (!state.hook) return ; + return ( + Could not get info of withdrawal + } + error={state.hook} /> ); } - return ( - - ); + return ; } diff --git a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts index b2d71874f..51123d154 100644 --- a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts +++ b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts @@ -17,10 +17,10 @@ import { NotificationType, TalerErrorDetail } from "@gnu-taler/taler-util"; import { TalerError } from "@gnu-taler/taler-wallet-core"; -import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; +import { useEffect, useMemo, useState } from "preact/hooks"; import * as wxApi from "../wxApi.js"; -interface HookOk { +export interface HookOk { hasError: false; response: T; } diff --git a/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.test.ts b/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.test.ts index 25513f57b..4893d43ff 100644 --- a/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.test.ts +++ b/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.test.ts @@ -32,30 +32,30 @@ describe('useTalerActionURL hook', () => { }) } - const { result, waitNextUpdate } = mountHook(useTalerActionURL, ctx) + const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(useTalerActionURL, ctx) { - const [url] = result.current! + const [url] = getLastResultOrThrow() expect(url).undefined; } + await waitNextUpdate("waiting for useEffect") { - const [url] = result.current! + const [url, setDismissed] = getLastResultOrThrow() expect(url).equals("asd"); + setDismissed(true) } - const [, setDismissed] = result.current! - setDismissed(true) - await waitNextUpdate("after dismiss") { - const [url] = result.current! + const [url] = getLastResultOrThrow() if (url !== undefined) throw Error('invalid') expect(url).undefined; } + await assertNoPendingUpdate() }) }) \ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/mui/TextField.stories.tsx b/packages/taler-wallet-webextension/src/mui/TextField.stories.tsx index d0ee3b2f6..c0e5d0639 100644 --- a/packages/taler-wallet-webextension/src/mui/TextField.stories.tsx +++ b/packages/taler-wallet-webextension/src/mui/TextField.stories.tsx @@ -103,12 +103,12 @@ export const Multiline = (): VNode => { const [value, onChange] = useState(""); return ( - {/* */} + /> { multiline maxRows={4} /> - {/* */} + /> + + ); +}; + +export const Select = (): VNode => { + const [value, onChange] = useState(""); + return ( + + + + ); }; diff --git a/packages/taler-wallet-webextension/src/mui/input/InputBase.tsx b/packages/taler-wallet-webextension/src/mui/input/InputBase.tsx index 8992aa690..180370a0c 100644 --- a/packages/taler-wallet-webextension/src/mui/input/InputBase.tsx +++ b/packages/taler-wallet-webextension/src/mui/input/InputBase.tsx @@ -304,9 +304,9 @@ function getStyleValue( function debounce(func: any, wait = 166): any { let timeout: any; - function debounced(...args) { + function debounced(...args: any[]): void { const later = () => { - func.apply(this, args); + func.apply({}, args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); @@ -452,7 +452,7 @@ export function TextareaAutoSize({ renders.current = 0; }, [value]); - const handleChange = (event) => { + const handleChange = (event: any): void => { renders.current = 0; if (!isControlled) { diff --git a/packages/taler-wallet-webextension/src/test-utils.ts b/packages/taler-wallet-webextension/src/test-utils.ts index 39ffbda08..f10e49ac4 100644 --- a/packages/taler-wallet-webextension/src/test-utils.ts +++ b/packages/taler-wallet-webextension/src/test-utils.ts @@ -64,23 +64,27 @@ export function renderNodeOrBrowser(Component: any, args: any): void { interface Mounted { unmount: () => void; - result: { current: T | null }; + getLastResult: () => T | null; + getLastResultOrThrow: () => T; + assertNoPendingUpdate: () => void; waitNextUpdate: (s?: string) => Promise; } const isNode = typeof window === "undefined" export function mountHook(callback: () => T, Context?: ({ children }: { children: any }) => VNode): Mounted { - const result: { current: T | null } = { - current: null - } + // const result: { current: T | null } = { + // current: null + // } + let lastResult: T | null = null; + const listener: Array<() => void> = [] // component that's going to hold the hook function Component(): VNode { const hookResult = callback() // save the hook result - result.current = hookResult + lastResult = hookResult // notify to everyone waiting for an update and clean the queue listener.splice(0, listener.length).forEach(cb => cb()) return create(Fragment, {}) @@ -119,7 +123,34 @@ export function mountHook(callback: () => T, Context?: ({ children }: { child } } + function getLastResult(): T | null { + const copy = lastResult + lastResult = null + return copy; + } + + function getLastResultOrThrow(): T { + const r = getLastResult() + if (!r) throw Error('there was no last result') + return r; + } + + async function assertNoPendingUpdate(): Promise { + await new Promise((res, rej) => { + const tid = setTimeout(() => { + res(undefined) + }, 10) + + listener.push(() => { + clearTimeout(tid) + rej(Error(`Expecting no pending result but the hook get updated. Check the dependencies of the hooks.`)) + }) + }) + + const r = getLastResult() + if (r) throw Error('There are still pending results. This may happen because the hook did a new update but the test didn\'t get the result using getLastResult'); + } return { - unmount, result, waitNextUpdate + unmount, getLastResult, getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } } diff --git a/packages/taler-wallet-webextension/src/utils/index.ts b/packages/taler-wallet-webextension/src/utils/index.ts index b652f2754..9181ee5b6 100644 --- a/packages/taler-wallet-webextension/src/utils/index.ts +++ b/packages/taler-wallet-webextension/src/utils/index.ts @@ -156,34 +156,27 @@ type TermsDocument = | TermsDocumentJson | TermsDocumentPdf; -interface TermsDocumentXml { +export interface TermsDocumentXml { type: "xml"; document: Document; } -interface TermsDocumentHtml { +export interface TermsDocumentHtml { type: "html"; href: URL; } -interface TermsDocumentPlain { +export interface TermsDocumentPlain { type: "plain"; content: string; } -interface TermsDocumentJson { +export interface TermsDocumentJson { type: "json"; data: any; } -interface TermsDocumentPdf { +export interface TermsDocumentPdf { type: "pdf"; location: URL; } - -export function amountToString(text: AmountJson): string { - const aj = Amounts.jsonifyAmount(text); - const amount = Amounts.stringifyValue(aj); - return `${amount} ${aj.currency}`; -} - diff --git a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.test.ts b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.test.ts index e6e699ce3..f2bb4a7d2 100644 --- a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.test.ts +++ b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.test.ts @@ -36,174 +36,182 @@ const exchangeListEmpty = { describe("CreateManualWithdraw states", () => { it("should set noExchangeFound when exchange list is empty", () => { - const { result } = mountHook(() => + const { getLastResultOrThrow } = mountHook(() => useComponentState(exchangeListEmpty, undefined, undefined), ); - if (!result.current) { - expect.fail("hook didn't render"); - } + const { noExchangeFound } = getLastResultOrThrow() - expect(result.current.noExchangeFound).equal(true) + expect(noExchangeFound).equal(true) }); it("should set noExchangeFound when exchange list doesn't include selected currency", () => { - const { result } = mountHook(() => + const { getLastResultOrThrow } = mountHook(() => useComponentState(exchangeListWithARSandUSD, undefined, "COL"), ); - if (!result.current) { - expect.fail("hook didn't render"); - } + const { noExchangeFound } = getLastResultOrThrow() - expect(result.current.noExchangeFound).equal(true) + expect(noExchangeFound).equal(true) }); it("should select the first exchange from the list", () => { - const { result } = mountHook(() => + const { getLastResultOrThrow } = mountHook(() => useComponentState(exchangeListWithARSandUSD, undefined, undefined), ); - if (!result.current) { - expect.fail("hook didn't render"); - } + const { exchange } = getLastResultOrThrow() - expect(result.current.exchange.value).equal("url1") + expect(exchange.value).equal("url1") }); it("should select the first exchange with the selected currency", () => { - const { result } = mountHook(() => + const { getLastResultOrThrow } = mountHook(() => useComponentState(exchangeListWithARSandUSD, undefined, "ARS"), ); - if (!result.current) { - expect.fail("hook didn't render"); - } + const { exchange } = getLastResultOrThrow() - expect(result.current.exchange.value).equal("url2") + expect(exchange.value).equal("url2") }); it("should change the exchange when currency change", async () => { - const { result, waitNextUpdate } = mountHook(() => + const { getLastResultOrThrow, waitNextUpdate } = mountHook(() => useComponentState(exchangeListWithARSandUSD, undefined, "ARS"), ); - if (!result.current) { - expect.fail("hook didn't render"); + + { + const { exchange, currency } = getLastResultOrThrow() + + expect(exchange.value).equal("url2") + + currency.onChange("USD") } - expect(result.current.exchange.value).equal("url2") - - result.current.currency.onChange("USD") - await waitNextUpdate() - expect(result.current.exchange.value).equal("url1") + { + const { exchange } = getLastResultOrThrow() + expect(exchange.value).equal("url1") + } }); it("should change the currency when exchange change", async () => { - const { result, waitNextUpdate } = mountHook(() => + const { getLastResultOrThrow, waitNextUpdate } = mountHook(() => useComponentState(exchangeListWithARSandUSD, undefined, "ARS"), ); - if (!result.current) { - expect.fail("hook didn't render"); + { + const { exchange, currency } = getLastResultOrThrow() + + expect(exchange.value).equal("url2") + expect(currency.value).equal("ARS") + + exchange.onChange("url1") } - expect(result.current.exchange.value).equal("url2") - expect(result.current.currency.value).equal("ARS") - - result.current.exchange.onChange("url1") - await waitNextUpdate() - expect(result.current.exchange.value).equal("url1") - expect(result.current.currency.value).equal("USD") + { + const { exchange, currency } = getLastResultOrThrow() + + expect(exchange.value).equal("url1") + expect(currency.value).equal("USD") + } }); it("should update parsed amount when amount change", async () => { - const { result, waitNextUpdate } = mountHook(() => + const { getLastResultOrThrow, waitNextUpdate } = mountHook(() => useComponentState(exchangeListWithARSandUSD, undefined, "ARS"), ); - if (!result.current) { - expect.fail("hook didn't render"); + { + const { amount, parsedAmount } = getLastResultOrThrow() + + expect(parsedAmount).equal(undefined) + + amount.onInput("12") } - expect(result.current.parsedAmount).equal(undefined) - - result.current.amount.onInput("12") - await waitNextUpdate() - expect(result.current.parsedAmount).deep.equals({ - value: 12, fraction: 0, currency: "ARS" - }) + { + const { parsedAmount } = getLastResultOrThrow() + + expect(parsedAmount).deep.equals({ + value: 12, fraction: 0, currency: "ARS" + }) + } }); it("should have an amount field", async () => { - const { result, waitNextUpdate } = mountHook(() => + const { getLastResultOrThrow, waitNextUpdate } = mountHook(() => useComponentState(exchangeListWithARSandUSD, undefined, "ARS"), ); - if (!result.current) { - expect.fail("hook didn't render"); - } - - await defaultTestForInputText(waitNextUpdate, () => result.current!.amount) + await defaultTestForInputText(waitNextUpdate, () => getLastResultOrThrow().amount) }) it("should have an exchange selector ", async () => { - const { result, waitNextUpdate } = mountHook(() => + const { getLastResultOrThrow, waitNextUpdate } = mountHook(() => useComponentState(exchangeListWithARSandUSD, undefined, "ARS"), ); - if (!result.current) { - expect.fail("hook didn't render"); - } - - await defaultTestForInputSelect(waitNextUpdate, () => result.current!.exchange) + await defaultTestForInputSelect(waitNextUpdate, () => getLastResultOrThrow().exchange) }) it("should have a currency selector ", async () => { - const { result, waitNextUpdate } = mountHook(() => + const { getLastResultOrThrow, waitNextUpdate } = mountHook(() => useComponentState(exchangeListWithARSandUSD, undefined, "ARS"), ); - if (!result.current) { - expect.fail("hook didn't render"); - } - - await defaultTestForInputSelect(waitNextUpdate, () => result.current!.currency) + await defaultTestForInputSelect(waitNextUpdate, () => getLastResultOrThrow().currency) }) }); async function defaultTestForInputText(awaiter: () => Promise, getField: () => TextFieldHandler): Promise { - const initialValue = getField().value; - const otherValue = `${initialValue} something else` - getField().onInput(otherValue) + let nextValue = '' + { + const field = getField() + const initialValue = field.value; + nextValue = `${initialValue} something else` + field.onInput(nextValue) + } await awaiter() - expect(getField().value).equal(otherValue) + { + const field = getField() + expect(field.value).equal(nextValue) + } } async function defaultTestForInputSelect(awaiter: () => Promise, getField: () => SelectFieldHandler): Promise { - const initialValue = getField().value; - const keys = Object.keys(getField().list) - const nextIdx = keys.indexOf(initialValue) + 1 - if (keys.length < nextIdx) { - throw new Error('no enough values') + let nextValue = '' + + { + const field = getField(); + const initialValue = field.value; + const keys = Object.keys(field.list) + const nextIdx = keys.indexOf(initialValue) + 1 + if (keys.length < nextIdx) { + throw new Error('no enough values') + } + nextValue = keys[nextIdx] + field.onChange(nextValue) } - const nextValue = keys[nextIdx] - getField().onChange(nextValue) await awaiter() - expect(getField().value).equal(nextValue) + { + const field = getField(); + + expect(field.value).equal(nextValue) + } } diff --git a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx index 215aa4378..b9d398915 100644 --- a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx +++ b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx @@ -21,6 +21,7 @@ */ import { AmountJson, Amounts } from "@gnu-taler/taler-util"; +import { TalerError } from "@gnu-taler/taler-wallet-core"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { ErrorMessage } from "../components/ErrorMessage.js"; @@ -60,10 +61,17 @@ export interface TextFieldHandler { error?: string; } +export interface ButtonHandler { + onClick: () => Promise; + disabled?: boolean; + error?: TalerError; +} + export interface SelectFieldHandler { onChange: (value: string) => void; error?: string; value: string; + isDirty?: boolean; list: Record; } @@ -139,17 +147,6 @@ export function useComponentState( }; } -export interface InputHandler { - value: string; - onInput: (s: string) => void; -} - -export interface SelectInputHandler { - list: Record; - value: string; - onChange: (s: string) => void; -} - export function CreateManualWithdraw({ initialAmount, exchangeUrlWithCurrency, diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage.test.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage.test.ts index 69831cd33..ac4e0ea93 100644 --- a/packages/taler-wallet-webextension/src/wallet/DepositPage.test.ts +++ b/packages/taler-wallet-webextension/src/wallet/DepositPage.test.ts @@ -39,26 +39,26 @@ const someBalance = [{ describe("DepositPage states", () => { it("should have status 'no-balance' when balance is empty", () => { - const { result } = mountHook(() => + const { getLastResultOrThrow } = mountHook(() => useComponentState(currency, [], [], feeCalculator), ); - if (!result.current) { - expect.fail("hook didn't render"); + { + const { status } = getLastResultOrThrow() + expect(status).equal("no-balance") } - expect(result.current.status).equal("no-balance") }); it("should have status 'no-accounts' when balance is not empty and accounts is empty", () => { - const { result } = mountHook(() => + const { getLastResultOrThrow } = mountHook(() => useComponentState(currency, [], someBalance, feeCalculator), ); - if (!result.current) { - expect.fail("hook didn't render"); + { + const { status } = getLastResultOrThrow() + expect(status).equal("no-accounts") } - expect(result.current.status).equal("no-accounts") }); }); \ No newline at end of file