new compose feature: sub-states

implemented in withdraw page, WIP
This commit is contained in:
Sebastian 2022-09-20 16:04:51 -03:00
parent a5525eab1e
commit 52ec740c82
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
16 changed files with 446 additions and 421 deletions

View File

@ -48,6 +48,7 @@ export namespace State {
}
export interface Ready extends BaseInfo {
status: "ready";
doSelectExchange: ButtonHandler;
create: ButtonHandler;
subject: TextFieldHandler;
toBeReceived: AmountJson;

View File

@ -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: {

View File

@ -37,6 +37,9 @@ export const Ready = createExample(ReadyView, {
currency: "ARS",
value: 1,
fraction: 0,
},
doSelectExchange: {
},
exchangeUrl: "https://exchange.taler.ar",
subject: {

View File

@ -54,6 +54,7 @@ export function ReadyView({
create,
toBeReceived,
chosenAmount,
doSelectExchange,
}: State.Ready): VNode {
const { i18n } = useTranslationContext();
@ -93,13 +94,13 @@ export function ReadyView({
}}
>
<i18n.Translate>Exchange</i18n.Translate>
{/* <Link>
<Button onClick={doSelectExchange.onClick} variant="text">
<SvgIcon
title="Edit"
dangerouslySetInnerHTML={{ __html: editIcon }}
color="black"
/>
</Link> */}
</Button>
</div>
}
text={<ExchangeDetails exchange={exchangeUrl} />}

View File

@ -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,

View File

@ -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<State> = {
loading: Loading,
"loading-uri": LoadingUriView,
"loading-exchange": LoadingExchangeView,
"loading-error": LoadingUriView,
"no-exchange": LoadingExchangeView,
"loading-info": LoadingInfoView,
"selecting-exchange": ExchangeSelectionPage,
success: SuccessView,
};

View File

@ -14,223 +14,58 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
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 extends object> = S | (() => RecursiveState<S>)
export function useComponentStateFromParams(
{ amount, cancel, onSuccess }: PropsFromParams,
api: typeof wxApi,
): State {
const [ageRestricted, setAgeRestricted] = useState(0);
): RecursiveState<State> {
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<boolean>(false);
const [reviewed, setReviewed] = useState<boolean>(false);
const chosenAmount = uriInfoHook.response.amount;
const exchangeList = uriInfoHook.response.exchanges.exchanges
const [withdrawError, setWithdrawError] = useState<TalerError | undefined>(
undefined,
);
const [doingWithdraw, setDoingWithdraw] = useState<boolean>(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<void> {
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<void> {
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<string, string>,
);
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<State> {
/**
* 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<boolean>(false);
const [reviewed, setReviewed] = useState<boolean>(false);
const [withdrawError, setWithdrawError] = useState<TalerError | undefined>(
undefined,
);
const [doingWithdraw, setDoingWithdraw] = useState<boolean>(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<void> {
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<void>, onSuccess: (txid: string) => Promise<void>, talerWithdrawUri: string | undefined, chosenAmount: AmountJson, exchangeList: ExchangeListItem[], defaultExchange: string | undefined, api: typeof wxApi,): RecursiveState<State> {
//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<void> {
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<string, string>,
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<boolean>(false);
const [reviewed, setReviewed] = useState<boolean>(false);
const [withdrawError, setWithdrawError] = useState<TalerError | undefined>(
undefined,
);
const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false);
const ageRestrictionEnabled = ageRestrictionOptions !== undefined;
if (ageRestrictionEnabled) {
ageRestrictionOptions["0"] = "Not restricted";
}
//TODO: calculate based on exchange info
const ageRestriction = ageRestrictionEnabled
? {
async function doWithdrawAndCheckError(): Promise<void> {
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<void> {
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<string, string>,
);
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,
};
}
}

View File

@ -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,

View File

@ -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<ExchangeWithdrawDetails> =>
({
withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:2",
} as any),
({
withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:2",
} as any),
getExchangeTos: async (): Promise<GetExchangeTosResult> => ({
contentType: "text",
content: "just accept",
@ -246,10 +243,10 @@ describe("Withdraw CTA states", () => {
}),
getExchangeWithdrawalInfo:
async (): Promise<ExchangeWithdrawDetails> =>
({
withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:2",
} as any),
({
withdrawalAmountRaw: "ARS:2",
withdrawalAmountEffective: "ARS:2",
} as any),
getExchangeTos: async (): Promise<GetExchangeTosResult> => ({
contentType: "text",
content: "just accept",

View File

@ -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 (
<LoadingError
title={<i18n.Translate>Could not get exchange</i18n.Translate>}
error={error}
<ErrorMessage
title={<i18n.Translate>Could not get a default exchange, please check configuration</i18n.Translate>}
/>
);
}
@ -106,13 +104,13 @@ export function SuccessView(state: State.Success): VNode {
}}
>
<i18n.Translate>Exchange</i18n.Translate>
{/* <Link>
<Button onClick={state.doSelectExchange.onClick} variant="text">
<SvgIcon
title="Edit"
dangerouslySetInnerHTML={{ __html: editIcon }}
color="black"
/>
</Link> */}
</Button>
</div>
}
text={<ExchangeDetails exchange={state.exchangeUrl} />}

View File

@ -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 <http://www.gnu.org/licenses/>
*/
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<void>;
onCancel: () => Promise<void>;
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<string | undefined>(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
}
}

View File

@ -82,31 +82,38 @@ export function renderNodeOrBrowser(Component: any, args: any): void {
document.body.removeChild(div);
}
}
type RecursiveState<S> = S | (() => RecursiveState<S>)
interface Mounted<T> {
unmount: () => void;
getLastResultOrThrow: () => T;
getLastResultOrThrow: () => Exclude<T, VoidFunction>;
assertNoPendingUpdate: () => void;
waitNextUpdate: (s?: string) => Promise<void>;
}
const isNode = typeof window === "undefined";
export function mountHook<T>(
callback: () => T,
export function mountHook<T extends object>(
callback: () => RecursiveState<T>,
Context?: ({ children }: { children: any }) => VNode,
): Mounted<T> {
// const result: { current: T | null } = {
// current: null
// }
let lastResult: T | Error | null = null;
let lastResult: Exclude<T, VoidFunction> | 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<T, () => void> = componentOrResult as any
lastResult = l;
} catch (e) {
if (e instanceof Error) {
lastResult = e;
@ -157,13 +164,13 @@ export function mountHook<T>(
}
}
function getLastResult(): T | Error | null {
const copy = lastResult;
function getLastResult(): Exclude<T | Error | null, VoidFunction> {
const copy: Exclude<T | Error | null, VoidFunction> = lastResult;
lastResult = null;
return copy;
}
function getLastResultOrThrow(): T {
function getLastResultOrThrow(): Exclude<T, VoidFunction> {
const r = getLastResult();
if (r instanceof Error) throw r;
if (!r) throw Error("there was no last result");

View File

@ -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<any> {
if (r.ok) {
@ -31,8 +31,7 @@ function getJsonIfOk(r: Response): Promise<any> {
}
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<StateType extends { status: string }> = {
[S in StateType as S["status"]]: StateFunc<S>;
};
type RecursiveState<S extends object> = S | (() => RecursiveState<S>)
export function compose<SType extends { status: string }, PType>(
name: string,
hook: (p: PType) => SType,
vs: StateViewMap<SType>,
hook: (p: PType) => RecursiveState<SType>,
viewMap: StateViewMap<SType>,
): (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<SType>;
return c(state);
function withHook(stateHook: () => RecursiveState<SType>): () => 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<SType>;
return createElement(viewComponent, state);
}
TheComponent.name = `${name}`;
return TheComponent;
}
return (p: PType) => {
const h = withHook(() => hook(p))
return h()
};
Component.name = `${name}`;
return Component;
}

View File

@ -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<void>;
onSelection: (exchange: string) => Promise<void>;
}
@ -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<State> = {
loading: Loading,
"loading-uri": LoadingUriView,
"error-loading": ErrorLoadingView,
comparing: ComparingView,
"no-exchanges": NoExchangesView,
ready: ReadyView,

View File

@ -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

View File

@ -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 (