From ccb50c636054819f5af8778cc3ebe5258b1c2e87 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 11 Apr 2022 11:36:32 -0300 Subject: new test api to test hooks rendering iteration, testing state of withdraw page --- .../taler-wallet-webextension/src/cta/Withdraw.tsx | 626 ++++++++++++--------- 1 file changed, 371 insertions(+), 255 deletions(-) (limited to 'packages/taler-wallet-webextension/src/cta/Withdraw.tsx') 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 { useTranslationContext } from "../context/translation.js"; +import { HookError, useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { buildTermsOfServiceState } from "../utils/index.js"; import { - amountToString, - buildTermsOfServiceState, - TermsState, -} from "../utils/index.js"; + ButtonHandler, + SelectFieldHandler, +} from "../wallet/CreateManualWithdraw.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 { + 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(); - const [withdrawError, setWithdrawError] = useState( +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 [confirmDisabled, setConfirmDisabled] = useState(false); - const needsReview = terms.status === "changed" || terms.status === "new"; + 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; - const [switchingExchange, setSwitchingExchange] = useState(false); - const [nextExchange, setNextExchange] = useState( + 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 exchanges = knownExchanges - .filter((e) => e.currency === amount.currency) - .reduce( - (prev, ex) => ({ ...prev, [ex.exchangeBaseUrl]: ex.exchangeBaseUrl }), - {}, - ); + const [showExchangeSelection, setShowExchangeSelection] = useState(false); + const [nextExchange, setNextExchange] = useState(); + + if (!uriInfoHook || uriInfoHook.hasError) { + return { + status: "loading-uri", + hook: uriInfoHook, + }; + } + + 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,6 +239,107 @@ export function View({ } } + const exchanges = thisCurrencyExchanges.reduce( + (prev, ex) => ({ ...prev, [ex.exchangeBaseUrl]: ex.exchangeBaseUrl }), + {}, + ); + + if (!info || info.hasError) { + return { + status: "loading-info", + hook: info, + }; + } + if (!info.response) { + return { + status: "loading-info", + hook: undefined, + }; + } + + 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; + + try { + await api.setExchangeTosAccepted( + thisExchange, + accepted ? termsState.version : undefined, + ); + setReviewed(accepted); + } catch (e) { + if (e instanceof Error) { + //FIXME: uncomment this and display error + // 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"), + }; +} + +export function View({ state }: { state: Success }): VNode { + const { i18n } = useTranslationContext(); return ( @@ -123,267 +347,159 @@ export function View({ Digital cash withdrawal - {withdrawError && ( + {state.doWithdrawal.error && ( Could not finish the withdrawal operation } - error={withdrawError.errorDetail} + error={state.doWithdrawal.error.errorDetail} /> )}
Total to withdraw} - text={amountToString(Amounts.sub(amount, withdrawalFee).amount)} + text={} kind="positive" /> - {Amounts.isNonZero(withdrawalFee) && ( + {Amounts.isNonZero(state.withdrawalFee) && ( Chosen amount} - text={amountToString(amount)} + text={} kind="neutral" /> Exchange fee} - text={amountToString(withdrawalFee)} + text={} 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 - )} - -
- ) : ( + 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} + /> +
setSwitchingExchange(true)} + style={{ fontSize: "small" }} + onClick={state.confirmEditExchange.onClick} > - Edit exchange + {state.exchange.isDirty ? ( + Confirm exchange selection + ) : ( + Cancel exchange selection + )} - ))} -
- -
- {(terms.status === "accepted" || (needsReview && reviewed)) && ( - - Confirm withdrawal - - )} - {terms.status === "notfound" && ( - + ) : ( + - Withdraw anyway - + 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 WithdrawPageWithParsedURI({ - uri, - uriInfo, -}: { - uri: string; - uriInfo: WithdrawUriInfoResponse; -}): VNode { +export function WithdrawPage({ talerWithdrawUri }: Props): VNode { const { i18n } = useTranslationContext(); - const [customExchange, setCustomExchange] = useState( - undefined, - ); - - 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 state = useComponentState(talerWithdrawUri, wxApi); - const tosState = buildTermsOfServiceState(tos); - - const info = await wxApi.getExchangeWithdrawalInfo({ - exchangeBaseUrl: exchange, - amount: withdrawAmount, - tosAcceptedFormat: ["text/xml"], - }); - return { tos: tosState, info }; - }); + if (!talerWithdrawUri) { + return ( + + missing withdraw uri + + ); + } - if (!detailsHook) { + if (!state) { return ; } - if (detailsHook.hasError) { + + console.log(state); + if (state.status === "loading-uri") { + if (!state.hook) return ; + return ( Could not load the withdrawal details + Could not get the info from the URI } - error={detailsHook} + error={state.hook} /> ); } + if (state.status === "loading-exchange") { + if (!state.hook) return ; - const details = detailsHook.response; - - const onAccept = async (accepted: boolean): Promise => { - if (!exchange) return; - try { - await wxApi.setExchangeTosAccepted( - exchange, - accepted ? details.tos.version : undefined, - ); - setReviewed(accepted); - } catch (e) { - if (e instanceof Error) { - //FIXME: uncomment this and display error - // setErrorAccepting(e.message); - } - } - }; - - 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; - - return ( - - ); -} -export function WithdrawPage({ talerWithdrawUri }: Props): VNode { - const { i18n } = useTranslationContext(); - const uriInfoHook = useAsyncAsHook(() => - !talerWithdrawUri - ? Promise.reject(undefined) - : wxApi.getWithdrawalDetailsForUri({ talerWithdrawUri }), - ); - - if (!talerWithdrawUri) { return ( - - missing withdraw uri - + Could not get exchange} + error={state.hook} + /> ); } - if (!uriInfoHook) { - return ; - } - if (uriInfoHook.hasError) { + if (state.status === "loading-info") { + if (!state.hook) return ; return ( Could not get the info from the URI + Could not get info of withdrawal } - error={uriInfoHook} + error={state.hook} /> ); } - return ( - - ); + return ; } -- cgit v1.2.3