From 52ec740c825d4e94fd59ef0a5cd8e8b73f4dfc06 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 20 Sep 2022 16:04:51 -0300 Subject: [PATCH] new compose feature: sub-states implemented in withdraw page, WIP --- .../src/cta/InvoiceCreate/index.ts | 1 + .../src/cta/InvoiceCreate/state.ts | 3 + .../src/cta/InvoiceCreate/stories.tsx | 3 + .../src/cta/InvoiceCreate/views.tsx | 5 +- .../src/cta/Payment/state.ts | 1 + .../src/cta/Withdraw/index.ts | 18 +- .../src/cta/Withdraw/state.ts | 559 +++++++----------- .../src/cta/Withdraw/stories.tsx | 12 + .../src/cta/Withdraw/test.ts | 29 +- .../src/cta/Withdraw/views.tsx | 14 +- .../src/hooks/useSelectedExchange.ts | 125 ++++ .../src/test-utils.ts | 23 +- .../src/utils/index.ts | 49 +- .../src/wallet/ExchangeSelection/index.ts | 10 +- .../src/wallet/ExchangeSelection/state.ts | 13 +- .../src/wallet/ExchangeSelection/views.tsx | 2 +- 16 files changed, 446 insertions(+), 421 deletions(-) create mode 100644 packages/taler-wallet-webextension/src/hooks/useSelectedExchange.ts diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts index 8beac2cb2..2bee51669 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts +++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/index.ts @@ -48,6 +48,7 @@ export namespace State { } export interface Ready extends BaseInfo { status: "ready"; + doSelectExchange: ButtonHandler; create: ButtonHandler; subject: TextFieldHandler; toBeReceived: AmountJson; diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts index 6b4f54504..9b67b4414 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts +++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/state.ts @@ -84,6 +84,9 @@ export function useComponentState( value: subject, onInput: async (e) => setSubject(e), }, + doSelectExchange: { + //FIX + }, invalid: !subject || Amounts.isZero(amount), exchangeUrl: selected.exchangeBaseUrl, create: { diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/stories.tsx b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/stories.tsx index b5a0a52e2..306d1b199 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/stories.tsx @@ -37,6 +37,9 @@ export const Ready = createExample(ReadyView, { currency: "ARS", value: 1, fraction: 0, + }, + doSelectExchange: { + }, exchangeUrl: "https://exchange.taler.ar", subject: { diff --git a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx index 209fb31e5..603392b60 100644 --- a/packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/InvoiceCreate/views.tsx @@ -54,6 +54,7 @@ export function ReadyView({ create, toBeReceived, chosenAmount, + doSelectExchange, }: State.Ready): VNode { const { i18n } = useTranslationContext(); @@ -93,13 +94,13 @@ export function ReadyView({ }} > Exchange - {/* + } text={} diff --git a/packages/taler-wallet-webextension/src/cta/Payment/state.ts b/packages/taler-wallet-webextension/src/cta/Payment/state.ts index e8690be39..8d388aa60 100644 --- a/packages/taler-wallet-webextension/src/cta/Payment/state.ts +++ b/packages/taler-wallet-webextension/src/cta/Payment/state.ts @@ -128,6 +128,7 @@ export function useComponentState( }); } const res = await api.confirmPay(payStatus.proposalId, undefined); + // handle confirm pay if (res.type !== ConfirmPayResultType.Done) { throw TalerError.fromUncheckedDetail({ code: TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts index 2d9aaf828..d38c27a2f 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts @@ -25,12 +25,17 @@ import { useComponentStateFromParams, useComponentStateFromURI, } from "./state.js"; +import { + State as SelectExchangeState +} from "../../hooks/useSelectedExchange.js"; + import { LoadingExchangeView, LoadingInfoView, LoadingUriView, SuccessView, } from "./views.js"; +import { ExchangeSelectionPage } from "../../wallet/ExchangeSelection/index.js"; export interface PropsFromURI { talerWithdrawUri: string | undefined; @@ -49,6 +54,7 @@ export type State = | State.LoadingUriError | State.LoadingExchangeError | State.LoadingInfoError + | SelectExchangeState.Selecting | State.Success; export namespace State { @@ -57,12 +63,12 @@ export namespace State { error: undefined; } export interface LoadingUriError { - status: "loading-uri"; + status: "loading-error"; error: HookError; } export interface LoadingExchangeError { - status: "loading-exchange"; - error: HookError; + status: "no-exchange"; + error: undefined, } export interface LoadingInfoError { status: "loading-info"; @@ -80,6 +86,7 @@ export namespace State { toBeReceived: AmountJson; doWithdrawal: ButtonHandler; + doSelectExchange: ButtonHandler; tosProps?: TermsOfServiceSectionProps; mustAcceptFirst: boolean; @@ -92,9 +99,10 @@ export namespace State { const viewMapping: StateViewMap = { loading: Loading, - "loading-uri": LoadingUriView, - "loading-exchange": LoadingExchangeView, + "loading-error": LoadingUriView, + "no-exchange": LoadingExchangeView, "loading-info": LoadingInfoView, + "selecting-exchange": ExchangeSelectionPage, success: SuccessView, }; diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts index 1256bf469..2e68d056e 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts @@ -14,223 +14,58 @@ GNU Taler; see the file COPYING. If not, see */ -import { Amounts, parsePaytoUri } from "@gnu-taler/taler-util"; +/* eslint-disable react-hooks/rules-of-hooks */ +import { AmountJson, Amounts, ExchangeListItem, parsePaytoUri } from "@gnu-taler/taler-util"; import { TalerError } from "@gnu-taler/taler-wallet-core"; import { useState } from "preact/hooks"; +import { Amount } from "../../components/Amount.js"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import { useSelectedExchange } from "../../hooks/useSelectedExchange.js"; import { buildTermsOfServiceState } from "../../utils/index.js"; import * as wxApi from "../../wxApi.js"; import { PropsFromURI, PropsFromParams, State } from "./index.js"; +type RecursiveState = S | (() => RecursiveState) + export function useComponentStateFromParams( { amount, cancel, onSuccess }: PropsFromParams, api: typeof wxApi, -): State { - const [ageRestricted, setAgeRestricted] = useState(0); +): RecursiveState { + const uriInfoHook = useAsyncAsHook(async () => { + const exchanges = await api.listExchanges(); + return { amount: Amounts.parseOrThrow(amount), exchanges }; + }); - const exchangeHook = useAsyncAsHook(api.listExchanges); + console.log("uri info", uriInfoHook) - const exchangeHookDep = - !exchangeHook || exchangeHook.hasError || !exchangeHook.response - ? undefined - : exchangeHook.response; - - const chosenAmount = Amounts.parseOrThrow(amount); - - // get the first exchange with the currency as the default one - const exchange = exchangeHookDep - ? exchangeHookDep.exchanges.find( - (e) => e.currency === chosenAmount.currency, - ) - : undefined; - /** - * For the exchange selected, bring the status of the terms of service - */ - const terms = useAsyncAsHook(async () => { - if (!exchange) return undefined; - - const exchangeTos = await api.getExchangeTos(exchange.exchangeBaseUrl, [ - "text/xml", - ]); - - const state = buildTermsOfServiceState(exchangeTos); - - return { state }; - }, [exchangeHookDep]); - - /** - * With the exchange and amount, ask the wallet the information - * about the withdrawal - */ - const amountHook = useAsyncAsHook(async () => { - if (!exchange) return undefined; - - const info = await api.getExchangeWithdrawalInfo({ - exchangeBaseUrl: exchange.exchangeBaseUrl, - amount: chosenAmount, - tosAcceptedFormat: ["text/xml"], - ageRestricted, - }); - - const withdrawAmount = { - raw: Amounts.parseOrThrow(info.withdrawalAmountRaw), - effective: Amounts.parseOrThrow(info.withdrawalAmountEffective), - }; + if (!uriInfoHook) return { status: "loading", error: undefined }; + if (uriInfoHook.hasError) { return { - amount: withdrawAmount, - ageRestrictionOptions: info.ageRestrictionOptions, + status: "loading-error", + error: uriInfoHook, }; - }, [exchangeHookDep]); + } - const [reviewing, setReviewing] = useState(false); - const [reviewed, setReviewed] = useState(false); + const chosenAmount = uriInfoHook.response.amount; + const exchangeList = uriInfoHook.response.exchanges.exchanges - const [withdrawError, setWithdrawError] = useState( - undefined, - ); - const [doingWithdraw, setDoingWithdraw] = useState(false); - - if (!exchangeHook) return { status: "loading", error: undefined }; - if (exchangeHook.hasError) { + async function doManualWithdraw(exchange: string, ageRestricted: number | undefined): Promise<{ transactionId: string, confirmTransferUrl: string | undefined }> { + const res = await api.acceptManualWithdrawal(exchange, Amounts.stringify(chosenAmount), ageRestricted); return { - status: "loading-uri", - error: exchangeHook, + confirmTransferUrl: undefined, + transactionId: res.transactionId }; } - if (!exchange) { - return { - status: "loading-exchange", - error: { - hasError: true, - operational: false, - message: "ERROR_NO-DEFAULT-EXCHANGE", - }, - }; - } + return () => exchangeSelectionState(doManualWithdraw, cancel, onSuccess, undefined, chosenAmount, exchangeList, undefined, api) - async function doWithdrawAndCheckError(): Promise { - if (!exchange) return; - - try { - setDoingWithdraw(true); - - const response = await wxApi.acceptManualWithdrawal( - exchange.exchangeBaseUrl, - Amounts.stringify(amount), - ); - - onSuccess(response.transactionId); - } catch (e) { - if (e instanceof TalerError) { - setWithdrawError(e); - } - } - setDoingWithdraw(false); - } - - if (!amountHook) { - return { status: "loading", error: undefined }; - } - if (amountHook.hasError) { - return { - status: "loading-info", - error: amountHook, - }; - } - if (!amountHook.response) { - return { status: "loading", error: undefined }; - } - - const withdrawalFee = Amounts.sub( - amountHook.response.amount.raw, - amountHook.response.amount.effective, - ).amount; - const toBeReceived = amountHook.response.amount.effective; - - const { state: termsState } = (!terms - ? undefined - : terms.hasError - ? undefined - : terms.response) || { state: undefined }; - - async function onAccept(accepted: boolean): Promise { - if (!termsState || !exchange) return; - - try { - await api.setExchangeTosAccepted( - exchange.exchangeBaseUrl, - accepted ? termsState.version : undefined, - ); - setReviewed(accepted); - } catch (e) { - if (e instanceof Error) { - //FIXME: uncomment this and display error - // setErrorAccepting(e.message); - } - } - } - - const mustAcceptFirst = - termsState !== undefined && - (termsState.status === "changed" || termsState.status === "new"); - - const ageRestrictionOptions = - amountHook.response.ageRestrictionOptions?.reduce( - (p, c) => ({ ...p, [c]: `under ${c}` }), - {} as Record, - ); - - const ageRestrictionEnabled = ageRestrictionOptions !== undefined; - if (ageRestrictionEnabled) { - ageRestrictionOptions["0"] = "Not restricted"; - } - - //TODO: calculate based on exchange info - const ageRestriction = ageRestrictionEnabled - ? { - list: ageRestrictionOptions, - value: String(ageRestricted), - onChange: async (v: string) => setAgeRestricted(parseInt(v, 10)), - } - : undefined; - - return { - status: "success", - error: undefined, - exchangeUrl: exchange.exchangeBaseUrl, - toBeReceived, - withdrawalFee, - chosenAmount, - ageRestriction, - doWithdrawal: { - onClick: - doingWithdraw || (mustAcceptFirst && !reviewed) - ? undefined - : doWithdrawAndCheckError, - error: withdrawError, - }, - tosProps: !termsState - ? undefined - : { - onAccept, - onReview: setReviewing, - reviewed: reviewed, - reviewing: reviewing, - terms: termsState, - }, - mustAcceptFirst, - cancel, - }; } export function useComponentStateFromURI( { talerWithdrawUri, cancel, onSuccess }: PropsFromURI, api: typeof wxApi, -): State { - const [ageRestricted, setAgeRestricted] = useState(0); - +): RecursiveState { /** * Ask the wallet about the withdraw URI */ @@ -240,207 +75,219 @@ export function useComponentStateFromURI( const uriInfo = await api.getWithdrawalDetailsForUri({ talerWithdrawUri, }); + const exchanges = await api.listExchanges(); const { amount, defaultExchangeBaseUrl } = uriInfo; - return { amount, thisExchange: defaultExchangeBaseUrl }; + return { talerWithdrawUri, amount: Amounts.parseOrThrow(amount), thisExchange: defaultExchangeBaseUrl, exchanges }; }); - /** - * Get the amount and select one exchange - */ - const uriHookDep = - !uriInfoHook || uriInfoHook.hasError || !uriInfoHook.response - ? undefined - : uriInfoHook.response; - - /** - * For the exchange selected, bring the status of the terms of service - */ - const terms = useAsyncAsHook(async () => { - if (!uriHookDep?.thisExchange) return false; - - const exchangeTos = await api.getExchangeTos(uriHookDep.thisExchange, [ - "text/xml", - ]); - - const state = buildTermsOfServiceState(exchangeTos); - - return { state }; - }, [uriHookDep]); - - /** - * With the exchange and amount, ask the wallet the information - * about the withdrawal - */ - const amountHook = useAsyncAsHook(async () => { - if (!uriHookDep?.thisExchange) return false; - - const info = await api.getExchangeWithdrawalInfo({ - exchangeBaseUrl: uriHookDep?.thisExchange, - amount: Amounts.parseOrThrow(uriHookDep.amount), - tosAcceptedFormat: ["text/xml"], - ageRestricted, - }); - - const withdrawAmount = { - raw: Amounts.parseOrThrow(info.withdrawalAmountRaw), - effective: Amounts.parseOrThrow(info.withdrawalAmountEffective), - }; - - return { - amount: withdrawAmount, - ageRestrictionOptions: info.ageRestrictionOptions, - }; - }, [uriHookDep]); - - const [reviewing, setReviewing] = useState(false); - const [reviewed, setReviewed] = useState(false); - - const [withdrawError, setWithdrawError] = useState( - undefined, - ); - const [doingWithdraw, setDoingWithdraw] = useState(false); - + console.log("uri info", uriInfoHook) if (!uriInfoHook) return { status: "loading", error: undefined }; + if (uriInfoHook.hasError) { return { - status: "loading-uri", + status: "loading-error", error: uriInfoHook, }; } - const { amount, thisExchange } = uriInfoHook.response; + const uri = uriInfoHook.response.talerWithdrawUri; + const chosenAmount = uriInfoHook.response.amount; + const defaultExchange = uriInfoHook.response.thisExchange; + const exchangeList = uriInfoHook.response.exchanges.exchanges - const chosenAmount = Amounts.parseOrThrow(amount); - - if (!thisExchange) { + async function doManagedWithdraw(exchange: string, ageRestricted: number | undefined): Promise<{ transactionId: string, confirmTransferUrl: string | undefined }> { + const res = await api.acceptWithdrawal(uri, exchange, ageRestricted,); return { - status: "loading-exchange", - error: { - hasError: true, - operational: false, - message: "ERROR_NO-DEFAULT-EXCHANGE", - }, + confirmTransferUrl: res.confirmTransferUrl, + transactionId: res.transactionId }; } - // const selectedExchange = thisExchange; + return () => exchangeSelectionState(doManagedWithdraw, cancel, onSuccess, uri, chosenAmount, exchangeList, defaultExchange, api) - async function doWithdrawAndCheckError(): Promise { - if (!thisExchange) return; +} - try { - setDoingWithdraw(true); - if (!talerWithdrawUri) return; - const res = await api.acceptWithdrawal( - talerWithdrawUri, - thisExchange, - !ageRestricted ? undefined : ageRestricted, - ); - if (res.confirmTransferUrl) { - document.location.href = res.confirmTransferUrl; - } else { - onSuccess(res.transactionId); - } - } catch (e) { - if (e instanceof TalerError) { - setWithdrawError(e); - } - } - setDoingWithdraw(false); - } +type ManualOrManagedWithdrawFunction = (exchange: string, ageRestricted: number | undefined) => Promise<{ transactionId: string, confirmTransferUrl: string | undefined }> - if (!amountHook) { - return { status: "loading", error: undefined }; - } - if (amountHook.hasError) { +function exchangeSelectionState(doWithdraw: ManualOrManagedWithdrawFunction, cancel: () => Promise, onSuccess: (txid: string) => Promise, talerWithdrawUri: string | undefined, chosenAmount: AmountJson, exchangeList: ExchangeListItem[], defaultExchange: string | undefined, api: typeof wxApi,): RecursiveState { + + //FIXME: use substates here + const selectedExchange = useSelectedExchange({ currency: chosenAmount.currency, defaultExchange, list: exchangeList }) + + if (selectedExchange.status === 'no-exchange') { return { - status: "loading-info", - error: amountHook, - }; - } - if (!amountHook.response) { - return { status: "loading", error: undefined }; - } - - const withdrawalFee = Amounts.sub( - amountHook.response.amount.raw, - amountHook.response.amount.effective, - ).amount; - const toBeReceived = amountHook.response.amount.effective; - - const { state: termsState } = (!terms - ? undefined - : terms.hasError - ? undefined - : terms.response) || { state: undefined }; - - async function onAccept(accepted: boolean): Promise { - if (!termsState || !thisExchange) 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); - } + status: "no-exchange", + error: undefined, } } - const mustAcceptFirst = - termsState !== undefined && - (termsState.status === "changed" || termsState.status === "new"); + if (selectedExchange.status === 'selecting-exchange') { + return selectedExchange + } + console.log("exchange selected", selectedExchange.selected) - const ageRestrictionOptions = - amountHook.response.ageRestrictionOptions?.reduce( - (p, c) => ({ ...p, [c]: `under ${c}` }), - {} as Record, + return () => { + + const [ageRestricted, setAgeRestricted] = useState(0); + const currentExchange = selectedExchange.selected + /** + * For the exchange selected, bring the status of the terms of service + */ + const terms = useAsyncAsHook(async () => { + const exchangeTos = await api.getExchangeTos(currentExchange.exchangeBaseUrl, [ + "text/xml", + ]); + + const state = buildTermsOfServiceState(exchangeTos); + + return { state }; + }, []); + console.log("terms", terms) + /** + * With the exchange and amount, ask the wallet the information + * about the withdrawal + */ + const amountHook = useAsyncAsHook(async () => { + + const info = await api.getExchangeWithdrawalInfo({ + exchangeBaseUrl: currentExchange.exchangeBaseUrl, + amount: chosenAmount, + tosAcceptedFormat: ["text/xml"], + ageRestricted, + }); + + const withdrawAmount = { + raw: Amounts.parseOrThrow(info.withdrawalAmountRaw), + effective: Amounts.parseOrThrow(info.withdrawalAmountEffective), + }; + + return { + amount: withdrawAmount, + ageRestrictionOptions: info.ageRestrictionOptions, + }; + }, []); + + const [reviewing, setReviewing] = useState(false); + const [reviewed, setReviewed] = useState(false); + + const [withdrawError, setWithdrawError] = useState( + undefined, ); + const [doingWithdraw, setDoingWithdraw] = useState(false); - const ageRestrictionEnabled = ageRestrictionOptions !== undefined; - if (ageRestrictionEnabled) { - ageRestrictionOptions["0"] = "Not restricted"; - } - //TODO: calculate based on exchange info - const ageRestriction = ageRestrictionEnabled - ? { + async function doWithdrawAndCheckError(): Promise { + + try { + setDoingWithdraw(true); + const res = await doWithdraw(currentExchange.exchangeBaseUrl, !ageRestricted ? undefined : ageRestricted) + if (res.confirmTransferUrl) { + document.location.href = res.confirmTransferUrl; + } else { + onSuccess(res.transactionId); + } + } catch (e) { + if (e instanceof TalerError) { + setWithdrawError(e); + } + } + setDoingWithdraw(false); + } + + if (!amountHook) { + return { status: "loading", error: undefined }; + } + if (amountHook.hasError) { + return { + status: "loading-info", + error: amountHook, + }; + } + if (!amountHook.response) { + return { status: "loading", error: undefined }; + } + + const withdrawalFee = Amounts.sub( + amountHook.response.amount.raw, + amountHook.response.amount.effective, + ).amount; + const toBeReceived = amountHook.response.amount.effective; + + 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( + currentExchange.exchangeBaseUrl, + accepted ? termsState.version : undefined, + ); + setReviewed(accepted); + } catch (e) { + if (e instanceof Error) { + //FIXME: uncomment this and display error + // setErrorAccepting(e.message); + } + } + } + + const mustAcceptFirst = + termsState !== undefined && + (termsState.status === "changed" || termsState.status === "new"); + + const ageRestrictionOptions = + amountHook.response.ageRestrictionOptions?.reduce( + (p, c) => ({ ...p, [c]: `under ${c}` }), + {} as Record, + ); + + const ageRestrictionEnabled = ageRestrictionOptions !== undefined; + if (ageRestrictionEnabled) { + ageRestrictionOptions["0"] = "Not restricted"; + } + + //TODO: calculate based on exchange info + const ageRestriction = ageRestrictionEnabled + ? { list: ageRestrictionOptions, value: String(ageRestricted), onChange: async (v: string) => setAgeRestricted(parseInt(v, 10)), } - : undefined; + : undefined; - return { - status: "success", - error: undefined, - exchangeUrl: thisExchange, - toBeReceived, - withdrawalFee, - chosenAmount, - talerWithdrawUri, - ageRestriction, - doWithdrawal: { - onClick: - doingWithdraw || (mustAcceptFirst && !reviewed) - ? undefined - : doWithdrawAndCheckError, - error: withdrawError, - }, - tosProps: !termsState - ? undefined - : { + return { + status: "success", + error: undefined, + doSelectExchange: selectedExchange.doSelect, + exchangeUrl: currentExchange.exchangeBaseUrl, + toBeReceived, + withdrawalFee, + chosenAmount, + talerWithdrawUri, + ageRestriction, + doWithdrawal: { + onClick: + doingWithdraw || (mustAcceptFirst && !reviewed) + ? undefined + : doWithdrawAndCheckError, + error: withdrawError, + }, + tosProps: !termsState + ? undefined + : { onAccept, onReview: setReviewing, reviewed: reviewed, reviewing: reviewing, terms: termsState, }, - mustAcceptFirst, - cancel, - }; + mustAcceptFirst, + cancel, + }; + } } diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx index 2be4437cc..a3daeb5e9 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx @@ -76,6 +76,8 @@ export const TermsOfServiceNotYetLoaded = createExample(SuccessView, { fraction: 10000000, value: 1, }, + doSelectExchange: { + }, toBeReceived: { currency: "USD", fraction: 0, @@ -104,6 +106,8 @@ export const WithSomeFee = createExample(SuccessView, { fraction: 0, value: 1, }, + doSelectExchange: { + }, tosProps: normalTosState, }); @@ -123,6 +127,8 @@ export const WithoutFee = createExample(SuccessView, { fraction: 0, value: 0, }, + doSelectExchange: { + }, toBeReceived: { currency: "USD", fraction: 0, @@ -147,6 +153,8 @@ export const EditExchangeUntouched = createExample(SuccessView, { fraction: 0, value: 0, }, + doSelectExchange: { + }, toBeReceived: { currency: "USD", fraction: 0, @@ -171,6 +179,8 @@ export const EditExchangeModified = createExample(SuccessView, { fraction: 0, value: 0, }, + doSelectExchange: { + }, toBeReceived: { currency: "USD", fraction: 0, @@ -188,6 +198,8 @@ export const WithAgeRestriction = createExample(SuccessView, { value: 2, fraction: 10000000, }, + doSelectExchange: { + }, doWithdrawal: nullHandler, exchangeUrl: "https://exchange.demo.taler.net", mustAcceptFirst: false, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts index f614c1c8c..5c62671fe 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts @@ -29,6 +29,7 @@ import { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core"; import { expect } from "chai"; import { mountHook } from "../../test-utils.js"; import { useComponentStateFromURI } from "./state.js"; +import * as wxApi from "../../wxApi.js"; const exchanges: ExchangeFullDetails[] = [ { @@ -92,7 +93,7 @@ describe("Withdraw CTA states", () => { { const { status, error } = getLastResultOrThrow(); - if (status != "loading-uri") expect.fail(); + if (status != "loading-error") expect.fail(); if (!error) expect.fail(); if (!error.hasError) expect.fail(); if (error.operational) expect.fail(); @@ -127,7 +128,7 @@ describe("Withdraw CTA states", () => { { const { status } = getLastResultOrThrow(); - expect(status).equals("loading"); + expect(status).equals("loading", "1"); } await waitNextUpdate(); @@ -135,13 +136,9 @@ describe("Withdraw CTA states", () => { { const { status, error } = getLastResultOrThrow(); - expect(status).equals("loading-exchange"); + expect(status).equals("no-exchange", "3"); - expect(error).deep.equals({ - hasError: true, - operational: false, - message: "ERROR_NO-DEFAULT-EXCHANGE", - }); + expect(error).undefined; } await assertNoPendingUpdate(); @@ -169,10 +166,10 @@ describe("Withdraw CTA states", () => { }), getExchangeWithdrawalInfo: async (): Promise => - ({ - withdrawalAmountRaw: "ARS:2", - withdrawalAmountEffective: "ARS:2", - } as any), + ({ + withdrawalAmountRaw: "ARS:2", + withdrawalAmountEffective: "ARS:2", + } as any), getExchangeTos: async (): Promise => ({ contentType: "text", content: "just accept", @@ -246,10 +243,10 @@ describe("Withdraw CTA states", () => { }), getExchangeWithdrawalInfo: async (): Promise => - ({ - withdrawalAmountRaw: "ARS:2", - withdrawalAmountEffective: "ARS:2", - } as any), + ({ + withdrawalAmountRaw: "ARS:2", + withdrawalAmountEffective: "ARS:2", + } as any), getExchangeTos: async (): Promise => ({ contentType: "text", content: "just accept", diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx index 60157d289..82d6090e5 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx @@ -38,6 +38,7 @@ import editIcon from "../../svg/edit_24px.svg"; import { Amount } from "../../components/Amount.js"; import { QR } from "../../components/QR.js"; import { useState } from "preact/hooks"; +import { ErrorMessage } from "../../components/ErrorMessage.js"; export function LoadingUriView({ error }: State.LoadingUriError): VNode { const { i18n } = useTranslationContext(); @@ -52,15 +53,12 @@ export function LoadingUriView({ error }: State.LoadingUriError): VNode { ); } -export function LoadingExchangeView({ - error, -}: State.LoadingExchangeError): VNode { +export function LoadingExchangeView(p: State.LoadingExchangeError): VNode { const { i18n } = useTranslationContext(); return ( - Could not get exchange} - error={error} + Could not get a default exchange, please check configuration} /> ); } @@ -106,13 +104,13 @@ export function SuccessView(state: State.Success): VNode { }} > Exchange - {/* + } text={} diff --git a/packages/taler-wallet-webextension/src/hooks/useSelectedExchange.ts b/packages/taler-wallet-webextension/src/hooks/useSelectedExchange.ts new file mode 100644 index 000000000..d9085153e --- /dev/null +++ b/packages/taler-wallet-webextension/src/hooks/useSelectedExchange.ts @@ -0,0 +1,125 @@ +/* + 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 + */ + +import { ExchangeListItem } from "@gnu-taler/taler-util"; +import { useState } from "preact/hooks"; +import { ButtonHandler } from "../mui/handlers.js"; + +type State = State.Ready | State.NoExchange | State.Selecting; + +export namespace State { + export interface NoExchange { + status: "no-exchange" + error: undefined; + } + export interface Ready { + status: "ready", + doSelect: ButtonHandler, + selected: ExchangeListItem; + } + export interface Selecting { + status: "selecting-exchange", + error: undefined, + onSelection: (url: string) => Promise; + onCancel: () => Promise; + list: ExchangeListItem[], + currency: string; + currentExchange: string; + } +} + +interface Props { + currency: string; + //there is a preference for the default at the initial state + defaultExchange?: string, + //list of exchanges + list: ExchangeListItem[], +} + + + +export function useSelectedExchange({ currency, defaultExchange, list }: Props): State { + const [isSelecting, setIsSelecting] = useState(false); + const [selectedExchange, setSelectedExchange] = useState(undefined); + + if (!list.length) { + return { + status: "no-exchange", + error: undefined, + } + } + + const firstByCurrency = list.find((e) => e.currency === currency) + if (!firstByCurrency) { + // there should be at least one exchange for this currency + return { + status: "no-exchange", + error: undefined, + } + } + + + if (isSelecting) { + const currentExchange = selectedExchange ?? defaultExchange ?? firstByCurrency.exchangeBaseUrl; + return { + status: "selecting-exchange", + error: undefined, + list, + currency, + currentExchange: currentExchange, + onSelection: async (exchangeBaseUrl: string) => { + setIsSelecting(false); + setSelectedExchange(exchangeBaseUrl) + }, + onCancel: async () => { + setIsSelecting(false); + } + } + } + + { + const found = !selectedExchange ? undefined : list.find( + (e) => e.exchangeBaseUrl === selectedExchange, + ) + if (found) return { + status: "ready", + doSelect: { + onClick: async () => setIsSelecting(true) + }, + selected: found + }; + } + { + const found = !defaultExchange ? undefined : list.find( + (e) => e.exchangeBaseUrl === defaultExchange, + ) + if (found) return { + status: "ready", + doSelect: { + onClick: async () => setIsSelecting(true) + }, + selected: found + }; + } + + return { + status: "ready", + doSelect: { + onClick: async () => setIsSelecting(true) + }, + selected: firstByCurrency + } +} diff --git a/packages/taler-wallet-webextension/src/test-utils.ts b/packages/taler-wallet-webextension/src/test-utils.ts index eebfa3612..7e9c5670e 100644 --- a/packages/taler-wallet-webextension/src/test-utils.ts +++ b/packages/taler-wallet-webextension/src/test-utils.ts @@ -82,31 +82,38 @@ export function renderNodeOrBrowser(Component: any, args: any): void { document.body.removeChild(div); } } +type RecursiveState = S | (() => RecursiveState) interface Mounted { unmount: () => void; - getLastResultOrThrow: () => T; + getLastResultOrThrow: () => Exclude; assertNoPendingUpdate: () => void; waitNextUpdate: (s?: string) => Promise; } const isNode = typeof window === "undefined"; -export function mountHook( - callback: () => T, +export function mountHook( + callback: () => RecursiveState, Context?: ({ children }: { children: any }) => VNode, ): Mounted { // const result: { current: T | null } = { // current: null // } - let lastResult: T | Error | null = null; + let lastResult: Exclude | Error | null = null; const listener: Array<() => void> = []; // component that's going to hold the hook function Component(): VNode { try { - lastResult = callback(); + let componentOrResult = callback() + while (typeof componentOrResult === "function") { + componentOrResult = componentOrResult(); + } + //typecheck fails here + const l: Exclude void> = componentOrResult as any + lastResult = l; } catch (e) { if (e instanceof Error) { lastResult = e; @@ -157,13 +164,13 @@ export function mountHook( } } - function getLastResult(): T | Error | null { - const copy = lastResult; + function getLastResult(): Exclude { + const copy: Exclude = lastResult; lastResult = null; return copy; } - function getLastResultOrThrow(): T { + function getLastResultOrThrow(): Exclude { const r = getLastResult(); if (r instanceof Error) throw r; if (!r) throw Error("there was no last result"); diff --git a/packages/taler-wallet-webextension/src/utils/index.ts b/packages/taler-wallet-webextension/src/utils/index.ts index 8fe1f2a44..3535910cf 100644 --- a/packages/taler-wallet-webextension/src/utils/index.ts +++ b/packages/taler-wallet-webextension/src/utils/index.ts @@ -19,7 +19,7 @@ import { Amounts, GetExchangeTosResult, } from "@gnu-taler/taler-util"; -import { VNode } from "preact"; +import { VNode, createElement } from "preact"; function getJsonIfOk(r: Response): Promise { if (r.ok) { @@ -31,8 +31,7 @@ function getJsonIfOk(r: Response): Promise { } throw new Error( - `Try another server: (${r.status}) ${ - r.statusText || "internal server error" + `Try another server: (${r.status}) ${r.statusText || "internal server error" }`, ); } @@ -103,10 +102,10 @@ export function buildTermsOfServiceStatus( return !content ? "notfound" : !acceptedVersion - ? "new" - : acceptedVersion !== currentVersion - ? "changed" - : "accepted"; + ? "new" + : acceptedVersion !== currentVersion + ? "changed" + : "accepted"; } function parseTermsOfServiceContent( @@ -198,17 +197,35 @@ export type StateViewMap = { [S in StateType as S["status"]]: StateFunc; }; +type RecursiveState = S | (() => RecursiveState) + export function compose( name: string, - hook: (p: PType) => SType, - vs: StateViewMap, + hook: (p: PType) => RecursiveState, + viewMap: StateViewMap, ): (p: PType) => VNode { - const Component = (p: PType): VNode => { - const state = hook(p); - const s = state.status as unknown as SType["status"]; - const c = vs[s] as unknown as StateFunc; - return c(state); + + function withHook(stateHook: () => RecursiveState): () => VNode { + + function TheComponent(): VNode { + const state = stateHook(); + + if (typeof state === "function") { + const subComponent = withHook(state) + return createElement(subComponent, {}); + } + + const statusName = state.status as unknown as SType["status"]; + const viewComponent = viewMap[statusName] as unknown as StateFunc; + return createElement(viewComponent, state); + } + TheComponent.name = `${name}`; + + return TheComponent; + } + + return (p: PType) => { + const h = withHook(() => hook(p)) + return h() }; - Component.name = `${name}`; - return Component; } diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts index 3b2708eff..2834028c6 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/index.ts @@ -20,6 +20,7 @@ import { AbsoluteTime, ExchangeFullDetails, OperationMap, + ExchangeListItem, } from "@gnu-taler/taler-util"; import { Loading } from "../../components/Loading.js"; import { HookError } from "../../hooks/useAsyncAsHook.js"; @@ -29,13 +30,14 @@ import * as wxApi from "../../wxApi.js"; import { useComponentState } from "./state.js"; import { ComparingView, - LoadingUriView, + ErrorLoadingView, NoExchangesView, ReadyView, } from "./views.js"; export interface Props { - currency?: string; + list: ExchangeListItem[], + currentExchange: string, onCancel: () => Promise; onSelection: (exchange: string) => Promise; } @@ -54,7 +56,7 @@ export namespace State { } export interface LoadingUriError { - status: "loading-uri"; + status: "error-loading"; error: HookError; } @@ -85,7 +87,7 @@ export namespace State { const viewMapping: StateViewMap = { loading: Loading, - "loading-uri": LoadingUriView, + "error-loading": ErrorLoadingView, comparing: ComparingView, "no-exchanges": NoExchangesView, ready: ReadyView, diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts index 8c0c21486..db6138f8e 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts @@ -22,14 +22,17 @@ import * as wxApi from "../../wxApi.js"; import { Props, State } from "./index.js"; export function useComponentState( - { onCancel, onSelection, currency }: Props, + { onCancel, onSelection, list: exchanges, currentExchange }: Props, api: typeof wxApi, ): State { - const initialValue = 0; + const initialValue = exchanges.findIndex(e => e.exchangeBaseUrl === currentExchange); + if (initialValue === -1) { + throw Error(`wrong usage of ExchangeSelection component, currentExchange '${currentExchange}' is not in the list of exchanges`) + } const [value, setValue] = useState(String(initialValue)); const hook = useAsyncAsHook(async () => { - const { exchanges } = await api.listExchanges(); + // const { exchanges } = await api.listExchanges(); const selectedIdx = parseInt(value, 10); const selectedExchange = @@ -54,12 +57,12 @@ export function useComponentState( } if (hook.hasError) { return { - status: "loading-uri", + status: "error-loading", error: hook, }; } - const { exchanges, selected, original } = hook.response; + const { selected, original } = hook.response; if (!selected) { //!selected <=> exchanges.length === 0 diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx index 4cd90700f..dd85dff46 100644 --- a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/views.tsx @@ -101,7 +101,7 @@ const Container = styled.div` } `; -export function LoadingUriView({ error }: State.LoadingUriError): VNode { +export function ErrorLoadingView({ error }: State.LoadingUriError): VNode { const { i18n } = useTranslationContext(); return (