From ee40a5e25c44ef478ee13426549e548d2610a215 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 27 Feb 2024 01:18:23 -0300 Subject: conversion UI --- packages/demobank-ui/src/context/config.ts | 28 +- packages/demobank-ui/src/hooks/circuit.ts | 61 +- .../demobank-ui/src/pages/ConversionConfig.tsx | 982 ++++++++++++++++----- .../src/pages/PaytoWireTransferForm.tsx | 180 ++-- .../src/pages/business/CreateCashout.tsx | 1 + packages/web-util/src/components/utils.ts | 28 + 6 files changed, 954 insertions(+), 326 deletions(-) (limited to 'packages') diff --git a/packages/demobank-ui/src/context/config.ts b/packages/demobank-ui/src/context/config.ts index 1cabab51c..529108275 100644 --- a/packages/demobank-ui/src/context/config.ts +++ b/packages/demobank-ui/src/context/config.ts @@ -16,7 +16,13 @@ import { AccessToken, + AmountJson, + HttpStatusCode, LibtoolVersion, + OperationFail, + OperationOk, + TalerBankConversionApi, + TalerBankConversionHttpClient, TalerCorebankApi, TalerCoreBankHttpClient, TalerError, @@ -44,6 +50,7 @@ import { import { revalidateBusinessAccounts, revalidateCashouts, + revalidateConversionInfo, } from "../hooks/circuit.js"; /** @@ -89,7 +96,7 @@ export const BankCoreApiProvider = ({ const [checked, setChecked] = useState(); const { i18n } = useTranslationContext(); const url = new URL(baseUrl); - const api = new CacheAwareApi(url.href, new BrowserHttpLib()); + const api = new CacheAwareTalerCoreBankHttpClient(url.href, new BrowserHttpLib()); useEffect(() => { api .getConfig() @@ -149,8 +156,20 @@ export const BankCoreApiProvider = ({ children, }); }; +class CacheAwareTalerBankConversionHttpClient extends TalerBankConversionHttpClient { + constructor(baseUrl: string, httpClient?: HttpRequestLibrary) { + super(baseUrl, httpClient); + } + async updateConversionRate(auth: AccessToken, body: TalerBankConversionApi.ConversionRate) { + const resp = await super.updateConversionRate(auth, body); + if (resp.type === "ok") { + await revalidateConversionInfo(); + } + return resp + } +} -export class CacheAwareApi extends TalerCoreBankHttpClient { +class CacheAwareTalerCoreBankHttpClient extends TalerCoreBankHttpClient { constructor(baseUrl: string, httpClient?: HttpRequestLibrary) { super(baseUrl, httpClient); } @@ -223,6 +242,11 @@ export class CacheAwareApi extends TalerCoreBankHttpClient { } return resp; } + + getConversionInfoAPI(): TalerBankConversionHttpClient { + const api = super.getConversionInfoAPI(); + return new CacheAwareTalerBankConversionHttpClient(api.baseUrl, this.httpLib) + } } export const BankCoreApiProviderTesting = ({ diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts index 7d8884797..2c0a58a5e 100644 --- a/packages/demobank-ui/src/hooks/circuit.ts +++ b/packages/demobank-ui/src/hooks/circuit.ts @@ -47,7 +47,7 @@ type EstimatorFunction = ( fee: AmountJson, ) => Promise; -type CashoutEstimators = { +type ConversionEstimators = { estimateByCredit: EstimatorFunction; estimateByDebit: EstimatorFunction; }; @@ -84,7 +84,53 @@ export function useConversionInfo() { return undefined; } -export function useEstimator(): CashoutEstimators { +export function useCashinEstimator(): ConversionEstimators { + const { api } = useBankCoreApiContext(); + return { + estimateByCredit: async (fiatAmount, fee) => { + const resp = await api.getConversionInfoAPI().getCashinRate({ + credit: fiatAmount, + }); + if (resp.type === "fail") { + // can't happen + // not-supported: it should not be able to call this function + // wrong-calculation: we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); + const beforeFee = Amounts.sub(credit, fee).amount; + + return { + debit, + beforeFee, + credit, + }; + }, + estimateByDebit: async (regionalAmount, fee) => { + const resp = await api.getConversionInfoAPI().getCashinRate({ + debit: regionalAmount, + }); + if (resp.type === "fail") { + // can't happen + // not-supported: it should not be able to call this function + // wrong-calculation: we are using just one parameter + throw TalerError.fromDetail(resp.detail.code, {}, resp.detail.hint); + } + const credit = Amounts.parseOrThrow(resp.body.amount_credit); + const debit = Amounts.parseOrThrow(resp.body.amount_debit); + const beforeFee = Amounts.add(credit, fee).amount; + + return { + debit, + beforeFee, + credit, + }; + }, + }; +} + +export function useCashoutEstimator(): ConversionEstimators { const { api } = useBankCoreApiContext(); return { estimateByCredit: async (fiatAmount, fee) => { @@ -130,6 +176,13 @@ export function useEstimator(): CashoutEstimators { }; } +/** + * @deprecated use useCashoutEstimator + */ +export function useEstimator(): ConversionEstimators { + return useCashoutEstimator() +} + export function revalidateBusinessAccounts() { return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getAccounts", undefined, { revalidate: true }); } @@ -147,7 +200,7 @@ export function useBusinessAccounts() { token, {}, { - limit: PAGE_SIZE+1, + limit: PAGE_SIZE + 1, offset: String(offset), order: "asc", }, @@ -174,7 +227,7 @@ export function useBusinessAccounts() { const isFirstPage = !offset; const result = data && data.type == "ok" ? structuredClone(data.body.accounts) : [] - if (result.length == PAGE_SIZE+1) { + if (result.length == PAGE_SIZE + 1) { result.pop() } const pagination = { diff --git a/packages/demobank-ui/src/pages/ConversionConfig.tsx b/packages/demobank-ui/src/pages/ConversionConfig.tsx index 73a6ab3ee..efe2d1756 100644 --- a/packages/demobank-ui/src/pages/ConversionConfig.tsx +++ b/packages/demobank-ui/src/pages/ConversionConfig.tsx @@ -15,29 +15,32 @@ */ import { - AmountString, + AmountJson, Amounts, HttpStatusCode, - OperationOk, - OperationResult, TalerBankConversionApi, + TalerError, TranslatedString, assertUnreachable } from "@gnu-taler/taler-util"; import { + Attention, + InternationalizationAPI, LocalNotificationBanner, ShowInputErrorLabel, useLocalNotification, - useTranslationContext + useTranslationContext, + utils } from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; import { useBankCoreApiContext } from "../context/config.js"; import { useBackendState } from "../hooks/backend.js"; +import { TransferCalculation, useCashinEstimator, useCashoutEstimator, useConversionInfo } from "../hooks/circuit.js"; import { RouteDefinition } from "../route.js"; -import { ProfileNavigation } from "./ProfileNavigation.js"; -import { useState } from "preact/hooks"; import { undefinedIfEmpty } from "../utils.js"; -import { InputAmount } from "./PaytoWireTransferForm.js"; +import { InputAmount, RenderAmount } from "./PaytoWireTransferForm.js"; +import { ProfileNavigation } from "./ProfileNavigation.js"; interface Props { routeMyAccountDetails: RouteDefinition; @@ -49,285 +52,806 @@ interface Props { onUpdateSuccess: () => void; } -type FormType = { - [k in keyof T]: string | undefined; +type UIField = { + value: string | undefined; + onUpdate: (s: string) => void; + error: TranslatedString | undefined; +} + +type FormHandler = { + [k in keyof T]?: + T[k] extends string ? UIField : + T[k] extends AmountJson ? UIField : + FormHandler; } -type ErrorsType = { - [k in keyof T]?: TranslatedString; +type FormValues = { + [k in keyof T]: + T[k] extends string ? (string | undefined) : + T[k] extends AmountJson ? (string | undefined) : + FormValues; } +type RecursivePartial = { + [k in keyof T]?: + T[k] extends string ? (string) : + T[k] extends AmountJson ? (AmountJson) : + RecursivePartial; +} -type FormHandler = { - [k in keyof T]?: { - value: string | undefined; - onUpdate: (s: string) => void; - error: TranslatedString | undefined; - } +type FormErrors = { + [k in keyof T]?: + T[k] extends string ? (TranslatedString) : + T[k] extends AmountJson ? (TranslatedString) : + FormErrors; } -function useFormState(defaultValue: FormType, validate: (f: FormType) => ErrorsType): FormHandler { - const [form, updateForm] = useState>(defaultValue) - - const errors = undefinedIfEmpty>(validate(form)) - - const p = (Object.keys(form) as Array) - console.log("FORM", p) - const handler = p.reduce((prev, fieldName) => { - console.log("fie;d", fieldName) - const currentValue = form[fieldName] - const currentError = errors !== undefined ? errors[fieldName] : undefined - prev[fieldName] = { + +type FormStatus = { + status: "ok", + result: T, + errors: undefined, +} | { + status: "fail", + result: RecursivePartial, + errors: FormErrors, +} +type FormType = { amount: AmountJson, conv: TalerBankConversionApi.ConversionRate } + +function constructFormHandler(form: FormValues, updateForm: (d: FormValues) => void, errors: FormErrors | undefined): FormHandler { + const keys = (Object.keys(form) as Array) + + const handler = keys.reduce((prev, fieldName) => { + const currentValue: any = form[fieldName]; + const currentError: any = errors ? errors[fieldName] : undefined; + function updater(newValue: any) { + updateForm({ ...form, [fieldName]: newValue }) + } + if (typeof currentValue === "object") { + const group = constructFormHandler(currentValue, updater, currentError) + // @ts-expect-error asdasd + prev[fieldName] = group + return prev; + } + const field: UIField = { error: currentError, value: currentValue, - onUpdate: (newValue) => { - updateForm({ ...form, [fieldName]: newValue }) - } + onUpdate: updater } + // @ts-expect-error asdasd + prev[fieldName] = field return prev }, {} as FormHandler) - return handler + return handler; } -/** - * Show histories of public accounts. - */ -export function ConversionConfig({ +function useFormState(defaultValue: FormValues, check: (f: FormValues) => FormStatus): [FormHandler, FormStatus] { + const [form, updateForm] = useState>(defaultValue) + + const status = check(form) + const handler = constructFormHandler(form, updateForm, status.errors) + + return [handler, status] +} + +function useComponentState({ + onUpdateSuccess, + routeCancel, + routeConversionConfig, routeMyAccountCashout, routeMyAccountDelete, routeMyAccountDetails, routeMyAccountPassword, - routeConversionConfig, - routeCancel, - onUpdateSuccess, -}: Props): VNode { - const { i18n } = useTranslationContext(); +}: Props): utils.RecursiveState { + + const result = useConversionInfo() + const info = result && !(result instanceof TalerError) && result.type === "ok" ? + result.body : undefined; const { state: credentials } = useBackendState(); const creds = credentials.status !== "loggedIn" || !credentials.isUserAdministrator ? undefined : credentials; - const { api, config } = useBankCoreApiContext(); - const [notification, notify, handleError] = useLocalNotification(); + if (!info) { + return
waiting...
+ } if (!creds) { return
only admin can setup conversion
; } - const form = useFormState({ - cashin_min_amount: undefined, - cashin_tiny_amount: undefined, - cashin_fee: undefined, - cashin_ratio: undefined, - cashin_rounding_mode: undefined, - cashout_min_amount: undefined, - cashout_tiny_amount: undefined, - cashout_fee: undefined, - cashout_ratio: undefined, - cashout_rounding_mode: undefined, - }, (state) => { - return ({ - cashin_min_amount: !state.cashin_min_amount ? i18n.str`required` : - !Amounts.parse(`${config.currency}:${state.cashin_min_amount}`) ? i18n.str`invalid` : - undefined, + return () => { + const { i18n } = useTranslationContext(); - }) - }) - - - async function doUpdate() { - if (!creds) return - await handleError(async () => { - const resp = await api - .getConversionInfoAPI() - .updateConversionRate(creds.token, { - - } as any) - if (resp.type === "ok") { - onUpdateSuccess() - } else { - switch (resp.case) { - case HttpStatusCode.Unauthorized: { - return notify({ - type: "error", - title: i18n.str`Wrong credentials`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); - } - case HttpStatusCode.NotImplemented: { - return notify({ - type: "error", - title: i18n.str`Conversion is disabled`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }); + const { api, config } = useBankCoreApiContext(); + + const [notification, notify, handleError] = useLocalNotification(); + + const initalState: FormValues = { + amount: "100", + conv: { + cashin_min_amount: info.conversion_rate.cashin_min_amount.split(":")[1], + cashin_tiny_amount: info.conversion_rate.cashin_tiny_amount.split(":")[1], + cashin_fee: info.conversion_rate.cashin_fee.split(":")[1], + cashin_ratio: info.conversion_rate.cashin_ratio, + cashin_rounding_mode: info.conversion_rate.cashin_rounding_mode, + cashout_min_amount: info.conversion_rate.cashout_min_amount.split(":")[1], + cashout_tiny_amount: info.conversion_rate.cashout_tiny_amount.split(":")[1], + cashout_fee: info.conversion_rate.cashout_fee.split(":")[1], + cashout_ratio: info.conversion_rate.cashout_ratio, + cashout_rounding_mode: info.conversion_rate.cashout_rounding_mode, + } + } + + const [form, status] = useFormState( + initalState, + checkConversionForm(i18n, info.regional_currency, info.fiat_currency) + ) + + const { + estimateByDebit: calculateCashoutFromDebit, + } = useCashoutEstimator(); + + const { + estimateByDebit: calculateCashinFromDebit, + } = useCashinEstimator(); + + const [calc, setCalc] = useState<{ cashin: TransferCalculation, cashout: TransferCalculation }>() + + useEffect(() => { + async function doAsync() { + await handleError(async () => { + if (!info) return; + if (!form.amount?.value || form.amount.error) return; + const in_amount = Amounts.parseOrThrow(`${info.fiat_currency}:${form.amount.value}`) + const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee) + const cashin = await calculateCashinFromDebit(in_amount, in_fee); + + + // const out_amount = Amounts.parseOrThrow(`${info.regional_currency}:${form.amount.value}`) + const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee) + const cashout = await calculateCashoutFromDebit(cashin.credit, out_fee); + + setCalc({ cashin, cashout }); + }); + } + doAsync(); + }, [form.amount?.value, form.conv?.cashin_fee?.value, form.conv?.cashout_fee?.value]); + + const [section, setSection] = useState<"detail" | "cashout" | "cashin">("detail") + + async function doUpdate() { + if (!creds) return + await handleError(async () => { + if (status.status === "fail") return; + const resp = await api + .getConversionInfoAPI() + .updateConversionRate(creds.token, status.result.conv) + if (resp.type === "ok") { + setSection("detail") + } else { + switch (resp.case) { + case HttpStatusCode.Unauthorized: { + return notify({ + type: "error", + title: i18n.str`Wrong credentials`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + case HttpStatusCode.NotImplemented: { + return notify({ + type: "error", + title: i18n.str`Conversion is disabled`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + default: + assertUnreachable(resp); } - default: - assertUnreachable(resp); } - } - }); - } + }); + } + + const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio) + const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio) + const both_high = in_ratio > 1 && out_ratio > 1; + const both_low = in_ratio < 1 && out_ratio < 1; - return ( -
- + return ( +
+ -
+
-
-

- Conversion -

-
+
+

+ Conversion +

+
+ -
{ - e.preventDefault(); - }} - > -
-
-
- - + { + setSection("cashout") + }} /> - + + + Config cashout + + + + +
+ + + + Config cashin + + + +
+
-
-
-
+ { + e.preventDefault(); + }} + > + {section == "cashin" && +
+
+
+ + + +

+ Only cashout operation above this threshold will be allowed +

+
+
+
+ +
+
+
+ + + +

+ Smallest difference between two amounts +

+
+
+
+ +
+
+
+ + + +

+ Operation fee +

+
+
+
+ +
- - + for="password" + > + {i18n.str`Ratio`} + +
+ { + form.conv?.cashin_ratio?.onUpdate(e.currentTarget.value); + }} + autocomplete="off" + /> + +

- Smallest difference between two amounts + + Conversion ratio between currencies +

-
-
-
-
-
+ } + + + + {section == "cashout" && +
+
+
+ + + +

+ Only cashout operation above this threshold will be allowed +

+
+
+
+ +
+
+
+ + + +

+ Smallest difference between two amounts +

+
+
+
+ +
+
+
+ + + +

+ Operation fee +

+
+
+
+ +
- - + for="password" + > + {i18n.str`Ratio`} + +
+ { + form.conv?.cashout_ratio?.onUpdate(e.currentTarget.value); + }} + autocomplete="off" + /> + +

- Operation fee + + Conversion ratio between currencies +

-
-
+ } + + + + + {section == "detail" && +
+
+
+ Cashin ratio +
+
+ {info.conversion_rate.cashin_ratio} +
+
+
+ +
+
+
+ Cashout ratio +
+
+ {info.conversion_rate.cashout_ratio} +
+
+
+ + {both_low || both_high ?
+ + + One of the ratios should be higher or equal than 1 an the other should be lower or equal than 1. + + +
: undefined} + +
+
+
+ + + +

+ Use it to test how the conversion will affect the amount. +

+
+
+
+ + {!calc ? undefined : ( +
+
+
+
+
+ Sending to this bank +
+
+ +
+
+ + {Amounts.isZero(calc.cashin.beforeFee) ? undefined : ( +
+
+ + Converted + +
+
+ +
+
+ )} +
+
+ Cashin after fee +
+
+ +
+
+
+
+ +
+
+
+
+ Sending from this bank +
+
+ +
+
-
- -
- { - form.cashout_ratio?.onUpdate(e.currentTarget.value); - }} - autocomplete="off" - /> - + {Amounts.isZero(calc.cashout.beforeFee) ? undefined : ( +
+
+ + Converted + +
+
+ +
+
+ )} +
+
+ Cashout after fee +
+
+ +
+
+
+
+ + {calc && status.status === "ok" && Amounts.cmp(status.result.amount, calc.cashout.credit) < 0 ?
+ + + This configuration allows users to cash out more of what has been cashed in. + + +
: undefined} +
+ )} +
} + + +
+ + Cancel + + {section == "cashin" || section == "cashout" ? + + :
}
-

- - Your current password, for security - -

-
-
- - Cancel - - -
- + + +
-
- ); + ); + + } +} + +/** + * Show histories of public accounts. + */ +export const ConversionConfig = utils.recursive(useComponentState); + +function checkConversionForm(i18n: InternationalizationAPI, regional: string, fiat: string) { + return function check(state: FormValues): FormStatus { + + const cashin_min_amount = Amounts.parse(`${fiat}:${state.conv.cashin_min_amount}`) + const cashin_tiny_amount = Amounts.parse(`${regional}:${state.conv.cashin_tiny_amount}`) + const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`) + + const cashout_min_amount = Amounts.parse(`${regional}:${state.conv.cashout_min_amount}`) + const cashout_tiny_amount = Amounts.parse(`${fiat}:${state.conv.cashout_tiny_amount}`) + const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`) + + const am = Amounts.parse(`${fiat}:${state.amount}`) + + const cashin_ratio = Number.parseFloat(state.conv.cashin_ratio ?? "") + const cashout_ratio = Number.parseFloat(state.conv.cashout_ratio ?? "") + + const errors = undefinedIfEmpty>({ + conv: undefinedIfEmpty>({ + cashin_min_amount: !state.conv.cashin_min_amount ? i18n.str`required` : + !cashin_min_amount ? i18n.str`invalid` : + undefined, + cashin_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : + !cashin_tiny_amount ? i18n.str`invalid` : + undefined, + cashin_fee: !state.conv.cashin_fee ? i18n.str`required` : + !cashin_fee ? i18n.str`invalid` : + undefined, + + cashout_min_amount: !state.conv.cashout_min_amount ? i18n.str`required` : + !cashout_min_amount ? i18n.str`invalid` : + undefined, + cashout_tiny_amount: !state.conv.cashin_tiny_amount ? i18n.str`required` : + !cashout_tiny_amount ? i18n.str`invalid` : + undefined, + cashout_fee: !state.conv.cashin_fee ? i18n.str`required` : + !cashout_fee ? i18n.str`invalid` : + undefined, + + cashin_rounding_mode: !state.conv.cashin_rounding_mode ? i18n.str`required` : undefined, + cashout_rounding_mode: !state.conv.cashout_rounding_mode ? i18n.str`required` : undefined, + + cashin_ratio: !state.conv.cashin_ratio ? i18n.str`required` : Number.isNaN(cashin_ratio) ? i18n.str`invalid` : undefined, + cashout_ratio: !state.conv.cashout_ratio ? i18n.str`required` : Number.isNaN(cashout_ratio) ? i18n.str`invalid` : undefined, + }), + + amount: !state.amount ? i18n.str`required` : + !am ? i18n.str`invalid` : + undefined, + }) + + const result: RecursivePartial = { + amount: am, + conv: { + cashin_fee: !errors?.conv?.cashin_fee ? Amounts.stringify(cashin_fee!) : undefined, + cashin_min_amount: !errors?.conv?.cashin_min_amount ? Amounts.stringify(cashin_min_amount!) : undefined, + cashin_ratio: !errors?.conv?.cashin_ratio ? String(cashin_ratio!) : undefined, + cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode ? (state.conv.cashin_rounding_mode!) : undefined, + cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount ? Amounts.stringify(cashin_tiny_amount!) : undefined, + cashout_fee: !errors?.conv?.cashout_fee ? Amounts.stringify(cashout_fee!) : undefined, + cashout_min_amount: !errors?.conv?.cashout_min_amount ? Amounts.stringify(cashout_min_amount!) : undefined, + cashout_ratio: !errors?.conv?.cashout_ratio ? String(cashout_ratio!) : undefined, + cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode ? (state.conv.cashout_rounding_mode!) : undefined, + cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount ? Amounts.stringify(cashout_tiny_amount!) : undefined, + } + + } + return errors === undefined ? + { status: "ok", result: result as FormType, errors } : + { status: "fail", result, errors } + } } diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx index 00b6767ac..177bf3c20 100644 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -238,12 +238,69 @@ export function PaytoWireTransferForm({ */}

{title}

-
-
+
+ + + {sendingToFixedAccount ? undefined : ( - - {sendingToFixedAccount ? undefined : ( - - )} -
+ )}
diff --git a/packages/demobank-ui/src/pages/business/CreateCashout.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx index 1b51e3222..7adacb775 100644 --- a/packages/demobank-ui/src/pages/business/CreateCashout.tsx +++ b/packages/demobank-ui/src/pages/business/CreateCashout.tsx @@ -329,6 +329,7 @@ export function CreateCashout({ const cashoutAccountName = !cashoutAccount ? undefined : cashoutAccount.targetPath; + return (
diff --git a/packages/web-util/src/components/utils.ts b/packages/web-util/src/components/utils.ts index 34693f7d7..75c3fc0fe 100644 --- a/packages/web-util/src/components/utils.ts +++ b/packages/web-util/src/components/utils.ts @@ -12,6 +12,7 @@ export function compose( hook: (p: PType) => RecursiveState, viewMap: StateViewMap, ): (p: PType) => VNode { + function withHook(stateHook: () => RecursiveState): () => VNode { function ComposedComponent(): VNode { const state = stateHook(); @@ -35,6 +36,33 @@ export function compose( }; } +export function recursive( + hook: (p: PType) => RecursiveState, +): (p: PType) => VNode { + + function withHook(stateHook: () => RecursiveState): () => VNode { + function ComposedComponent(): VNode { + const state = stateHook(); + + if (typeof state === "function") { + const subComponent = withHook(state); + return createElement(subComponent, {}); + } + + return state; + } + + return ComposedComponent; + } + + return (p: PType) => { + const h = withHook(() => hook(p)); + return h(); + }; +} + + + /** * * @param obj VNode -- cgit v1.2.3