diff options
Diffstat (limited to 'packages/web-util/src')
40 files changed, 1586 insertions, 1108 deletions
diff --git a/packages/web-util/src/components/Time.tsx b/packages/web-util/src/components/Time.tsx new file mode 100644 index 000000000..9057b34f0 --- /dev/null +++ b/packages/web-util/src/components/Time.tsx @@ -0,0 +1,80 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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 { AbsoluteTime, Duration } from "@gnu-taler/taler-util"; +import { + formatISO, + format, + formatDuration, + intervalToDuration, +} from "date-fns"; +import { Fragment, h, VNode } from "preact"; +import { useTranslationContext } from "../index.browser.js"; + +/** + * + * @param timestamp time to be formatted + * @param relative duration threshold, if the difference is lower + * the timestamp will be formatted as relative time from "now" + * + * @returns + */ +export function Time({ + timestamp, + relative, + format: formatString, +}: { + timestamp: AbsoluteTime | undefined; + relative?: Duration; + format: string; +}): VNode { + const { i18n, dateLocale } = useTranslationContext(); + if (!timestamp) return <Fragment />; + + if (timestamp.t_ms === "never") { + return <time>{i18n.str`never`}</time>; + } + + const now = AbsoluteTime.now(); + const diff = AbsoluteTime.difference(now, timestamp); + if (relative && now.t_ms !== "never" && Duration.cmp(diff, relative) === -1) { + const d = intervalToDuration({ + start: now.t_ms, + end: timestamp.t_ms, + }); + d.seconds = 0; + const duration = formatDuration(d, { locale: dateLocale }); + const isFuture = AbsoluteTime.cmp(now, timestamp) < 0; + if (isFuture) { + return ( + <time dateTime={formatISO(timestamp.t_ms)}> + <i18n.Translate>in {duration}</i18n.Translate> + </time> + ); + } else { + return ( + <time dateTime={formatISO(timestamp.t_ms)}> + <i18n.Translate>{duration} ago</i18n.Translate> + </time> + ); + } + } + return ( + <time dateTime={formatISO(timestamp.t_ms)}> + {format(timestamp.t_ms, formatString, { locale: dateLocale })} + </time> + ); +} diff --git a/packages/web-util/src/components/index.ts b/packages/web-util/src/components/index.ts index d7ea41874..63231f8a2 100644 --- a/packages/web-util/src/components/index.ts +++ b/packages/web-util/src/components/index.ts @@ -10,3 +10,4 @@ export * from "./Button.js"; export * from "./ShowInputErrorLabel.js"; export * from "./NotificationBanner.js"; export * from "./ToastBanner.js"; +export * from "./Time.js"; diff --git a/packages/web-util/src/context/api.ts b/packages/web-util/src/context/api.ts index c1eaa37f8..89561e239 100644 --- a/packages/web-util/src/context/api.ts +++ b/packages/web-util/src/context/api.ts @@ -19,7 +19,12 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { TalerBankIntegrationHttpClient, TalerCoreBankHttpClient, TalerRevenueHttpClient, TalerWireGatewayHttpClient } from "@gnu-taler/taler-util"; +import { + TalerBankIntegrationHttpClient, + TalerCoreBankHttpClient, + TalerRevenueHttpClient, + TalerWireGatewayHttpClient, +} from "@gnu-taler/taler-util"; import { ComponentChildren, createContext, h, VNode } from "preact"; import { useContext } from "preact/hooks"; import { defaultRequestHandler } from "../utils/request.js"; @@ -29,10 +34,10 @@ interface Type { * @deprecated this show not be used */ request: typeof defaultRequestHandler; - bankCore: TalerCoreBankHttpClient, - bankIntegration: TalerBankIntegrationHttpClient, - bankWire: TalerWireGatewayHttpClient, - bankRevenue: TalerRevenueHttpClient, + bankCore: TalerCoreBankHttpClient; + bankIntegration: TalerBankIntegrationHttpClient; + bankWire: TalerWireGatewayHttpClient; + bankRevenue: TalerRevenueHttpClient; } const Context = createContext<Type>({ request: defaultRequestHandler } as any); diff --git a/packages/web-util/src/context/bank-api.ts b/packages/web-util/src/context/bank-api.ts index 3f6a32f4b..e610b49e0 100644 --- a/packages/web-util/src/context/bank-api.ts +++ b/packages/web-util/src/context/bank-api.ts @@ -16,6 +16,7 @@ import { CacheEvictor, + TalerCorebankConfigResponse, LibtoolVersion, ObservabilityEvent, ObservableHttpClientLibrary, @@ -24,7 +25,6 @@ import { TalerBankConversionHttpClient, TalerCoreBankCacheEviction, TalerCoreBankHttpClient, - TalerCorebankApi, TalerError, } from "@gnu-taler/taler-util"; import { @@ -35,9 +35,9 @@ import { h, } from "preact"; import { useContext, useEffect, useState } from "preact/hooks"; +import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js"; import { APIClient, ActiviyTracker, BankLib, Subscriber } from "./activity.js"; import { useTranslationContext } from "./translation.js"; -import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js"; /** * @@ -46,7 +46,7 @@ import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js"; export type BankContextType = { url: URL; - config: TalerCorebankApi.Config; + config: TalerCorebankConfigResponse; lib: BankLib; hints: VersionHint[]; onActivity: Subscriber<ObservabilityEvent>; @@ -88,7 +88,7 @@ export const BankApiProvider = ({ frameOnError: FunctionComponent<{ children: ComponentChildren }>; }): VNode => { const [checked, setChecked] = - useState<ConfigResult<TalerCorebankApi.Config>>(); + useState<ConfigResult<TalerCorebankConfigResponse>>(); const { i18n } = useTranslationContext(); const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = @@ -165,7 +165,7 @@ export const BankApiProvider = ({ function buildBankApiClient( url: URL, evictors: Evictors, -): APIClient<BankLib, TalerCorebankApi.Config> { +): APIClient<BankLib, TalerCorebankConfigResponse> { const httpFetch = new BrowserFetchHttpLib({ enableThrottling: true, requireTls: false, @@ -189,10 +189,14 @@ function buildBankApiClient( httpLib, ); - async function getRemoteConfig(): Promise<TalerCorebankApi.Config> { + async function getRemoteConfig(): Promise<TalerCorebankConfigResponse> { const resp = await bank.getConfig(); if (resp.type === "fail") { - throw TalerError.fromUncheckedDetail(resp.detail); + if (resp.detail) { + throw TalerError.fromUncheckedDetail(resp.detail); + } else { + throw TalerError.fromException(new Error("failed to get bank remote config")) + } } return resp.body; } diff --git a/packages/web-util/src/context/challenger-api.ts b/packages/web-util/src/context/challenger-api.ts index 8748f5f69..e2a6e05c3 100644 --- a/packages/web-util/src/context/challenger-api.ts +++ b/packages/web-util/src/context/challenger-api.ts @@ -183,7 +183,11 @@ function buildChallengerApiClient( async function getRemoteConfig(): Promise<ChallengerApi.ChallengerTermsOfServiceResponse> { const resp = await challenger.getConfig(); if (resp.type === "fail") { - throw TalerError.fromUncheckedDetail(resp.detail); + if (resp.detail) { + throw TalerError.fromUncheckedDetail(resp.detail); + } else { + throw TalerError.fromException(new Error("failed to get challenger remote config")) + } } return resp.body; } diff --git a/packages/web-util/src/context/exchange-api.ts b/packages/web-util/src/context/exchange-api.ts index 39f889ba9..967b042f9 100644 --- a/packages/web-util/src/context/exchange-api.ts +++ b/packages/web-util/src/context/exchange-api.ts @@ -187,7 +187,11 @@ function buildExchangeApiClient( async function getRemoteConfig(): Promise<TalerExchangeApi.ExchangeVersionResponse> { const resp = await ex.getConfig(); if (resp.type === "fail") { - throw TalerError.fromUncheckedDetail(resp.detail); + if (resp.detail) { + throw TalerError.fromUncheckedDetail(resp.detail); + } else { + throw TalerError.fromException(new Error("failed to get exchange remote config")) + } } return resp.body; } diff --git a/packages/web-util/src/context/merchant-api.ts b/packages/web-util/src/context/merchant-api.ts index 03c95d48e..8d929ae12 100644 --- a/packages/web-util/src/context/merchant-api.ts +++ b/packages/web-util/src/context/merchant-api.ts @@ -49,7 +49,7 @@ import { export type MerchantContextType = { url: URL; - config: TalerMerchantApi.VersionResponse; + config: TalerMerchantApi.TalerMerchantConfigResponse; lib: MerchantLib; hints: VersionHint[]; onActivity: Subscriber<ObservabilityEvent>; @@ -95,11 +95,13 @@ export const MerchantApiProvider = ({ evictors?: Evictors; children: ComponentChildren; frameOnError: FunctionComponent<{ - state: ConfigResultFail<TalerMerchantApi.VersionResponse> | undefined; + state: + | ConfigResultFail<TalerMerchantApi.TalerMerchantConfigResponse> + | undefined; }>; }): VNode => { const [checked, setChecked] = - useState<ConfigResult<TalerMerchantApi.VersionResponse>>(); + useState<ConfigResult<TalerMerchantApi.TalerMerchantConfigResponse>>(); const [merchantEndpoint, changeMerchantEndpoint] = useState(baseUrl); @@ -162,7 +164,7 @@ export const MerchantApiProvider = ({ function buildMerchantApiClient( url: URL, evictors: Evictors, -): APIClient<MerchantLib, TalerMerchantApi.VersionResponse> { +): APIClient<MerchantLib, TalerMerchantApi.TalerMerchantConfigResponse> { const httpFetch = new BrowserFetchHttpLib({ enableThrottling: true, requireTls: false, @@ -193,10 +195,14 @@ function buildMerchantApiClient( return api.lib; } - async function getRemoteConfig(): Promise<TalerMerchantApi.VersionResponse> { + async function getRemoteConfig(): Promise<TalerMerchantApi.TalerMerchantConfigResponse> { const resp = await instance.getConfig(); if (resp.type === "fail") { - throw TalerError.fromUncheckedDetail(resp.detail); + if (resp.detail) { + throw TalerError.fromUncheckedDetail(resp.detail); + } else { + throw TalerError.fromException(new Error("failed to get merchant remote config")) + } } return resp.body; } diff --git a/packages/web-util/src/context/navigation.ts b/packages/web-util/src/context/navigation.ts index c2f2bbbc1..bd756318b 100644 --- a/packages/web-util/src/context/navigation.ts +++ b/packages/web-util/src/context/navigation.ts @@ -22,6 +22,7 @@ import { Location, findMatch, RouteDefinition, + LocationNotFound, } from "../utils/route.js"; /** @@ -44,7 +45,7 @@ export const useNavigationContext = (): Type => useContext(Context); // eslint-disable-next-line @typescript-eslint/no-explicit-any export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>( pagesMap: T, -): Location<T> | undefined { +): Location<T> | LocationNotFound<T> { const pageList = Object.keys(pagesMap as object) as Array<keyof T>; const { path, params } = useNavigationContext(); diff --git a/packages/web-util/src/forms/Caption.tsx b/packages/web-util/src/forms/Caption.tsx index be4725ffa..0a4de5a4c 100644 --- a/packages/web-util/src/forms/Caption.tsx +++ b/packages/web-util/src/forms/Caption.tsx @@ -13,7 +13,7 @@ interface Props { export function Caption({ before, after, label, tooltip, help }: Props): VNode { return ( - <div class="sm:col-span-6 flex"> + <div class="sm:col-span-6"> {before !== undefined && <RenderAddon addon={before} />} <LabelWithTooltipMaybeRequired label={label} tooltip={tooltip} /> {after !== undefined && <RenderAddon addon={after} />} diff --git a/packages/web-util/src/forms/DefaultForm.tsx b/packages/web-util/src/forms/DefaultForm.tsx index 338460170..239577e24 100644 --- a/packages/web-util/src/forms/DefaultForm.tsx +++ b/packages/web-util/src/forms/DefaultForm.tsx @@ -1,12 +1,21 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; +import { + UIFormElementConfig, + getConverterById, + useTranslationContext, +} from "../index.browser.js"; import { FormProvider, FormProviderProps, FormState } from "./FormProvider.js"; -import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; -import { TranslatedString } from "@gnu-taler/taler-util"; +import { + RenderAllFieldsByUiConfig, + UIFormField, + convertUiField, +} from "./forms.js"; // import { FlexibleForm } from "./ui-form.js"; /** * Flexible form uses a DoubleColumForm for design - * and may have a dynamic properties defined by + * and may have a dynamic properties defined by * behavior function. */ export interface FlexibleForm_Deprecated<T extends object> { @@ -16,17 +25,19 @@ export interface FlexibleForm_Deprecated<T extends object> { /** * Double column form - * + * * Form with sections, every sections have a title and may * have a description. * Every sections contain a set of fields. */ -export type DoubleColumnForm_Deprecated = Array<DoubleColumnFormSection_Deprecated | undefined>; +export type DoubleColumnForm_Deprecated = Array< + DoubleColumnFormSection_Deprecated | undefined +>; export type DoubleColumnFormSection_Deprecated = { title: TranslatedString; description?: TranslatedString; - fields: UIFormField[]; + fields: UIFormElementConfig[]; }; /** @@ -40,20 +51,25 @@ export function DefaultForm<T extends object>({ onSubmit, children, readOnly, -}: Omit<FormProviderProps<T>, "computeFormState"> & { form: FlexibleForm_Deprecated<T> }): VNode { +}: Omit<FormProviderProps<T>, "computeFormState"> & { + form: FlexibleForm_Deprecated<T>; +}): VNode { + const { i18n } = useTranslationContext(); return ( <FormProvider initial={initial} onUpdate={onUpdate} onSubmit={onSubmit} readOnly={readOnly} - // computeFormState={form.behavior} > <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> {form.design.map((section, i) => { if (!section) return <Fragment />; return ( - <div key={i} class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> + <div + key={i} + class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3" + > <div class="px-4 sm:px-0"> <h2 class="text-base font-semibold leading-7 text-gray-900"> {section.title} @@ -69,7 +85,12 @@ export function DefaultForm<T extends object>({ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> <RenderAllFieldsByUiConfig key={i} - fields={section.fields} + fields={convertUiField( + i18n, + section.fields, + form, + getConverterById, + )} /> </div> </div> diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx index 5e08efb32..fe886030a 100644 --- a/packages/web-util/src/forms/FormProvider.tsx +++ b/packages/web-util/src/forms/FormProvider.tsx @@ -14,7 +14,7 @@ export interface FormType<T extends object> { computeFormState?: (v: Partial<T>) => FormState<T>; } -export const FormContext = createContext<FormType<any>| undefined>(undefined); +export const FormContext = createContext<FormType<any> | undefined>(undefined); /** * Map of {[field]:FieldUIOptions} diff --git a/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx index 6b792bfee..858349a00 100644 --- a/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx +++ b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx @@ -20,11 +20,12 @@ */ import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Absolute Time", @@ -38,23 +39,28 @@ export namespace Simplest { type TargetObject = { today: AbsoluteTime; -} +}; const initial: TargetObject = { - today: AbsoluteTime.now() -} + today: AbsoluteTime.now(), +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "absoluteTimeText", - properties: { - label: "label of the field" as TranslatedString, - name: "today", - pattern: "dd/MM/yyyy HH:mm" - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "absoluteTimeText", + label: "label of the field" as TranslatedString, + id: "today" as UIHandlerId, + pattern: "dd/MM/yyyy HH:mm", + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputAmount.stories.tsx b/packages/web-util/src/forms/InputAmount.stories.tsx index f05887515..4351a9655 100644 --- a/packages/web-util/src/forms/InputAmount.stories.tsx +++ b/packages/web-util/src/forms/InputAmount.stories.tsx @@ -20,11 +20,12 @@ */ import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Amount", @@ -38,22 +39,28 @@ export namespace Simplest { type TargetObject = { amount: AmountJson; -} +}; const initial: TargetObject = { - amount: Amounts.parseOrThrow("USD:10") -} + amount: Amounts.parseOrThrow("USD:10"), +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "amount", - properties: { - label: "label of the field" as TranslatedString, - name: "amount", - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "amount", + label: "label of the field" as TranslatedString, + id: "amount" as UIHandlerId, + currency: "ARS", + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputArray.stories.tsx b/packages/web-util/src/forms/InputArray.stories.tsx index 143e73f02..fc6889189 100644 --- a/packages/web-util/src/forms/InputArray.stories.tsx +++ b/packages/web-util/src/forms/InputArray.stories.tsx @@ -20,11 +20,12 @@ */ import { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Array", @@ -41,39 +42,47 @@ type TargetObject = { name: string; age: number; }[]; -} +}; const initial: TargetObject = { - people: [{ - name: "me", - age: 17, - }] -} + people: [ + { + name: "me", + age: 17, + }, + ], +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "array", - properties: { - label: "People" as TranslatedString, - name: "comment", - fields: [{ - type: "text", - properties: { - label: "the name" as TranslatedString, - name: "name", - } - }, { - type: "integer", - properties: { - label: "the age" as TranslatedString, - name: "age", - } - }], - labelField: "name" - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + description: "to test how arrays are used" as TranslatedString, + fields: [ + { + type: "array", + label: "People" as TranslatedString, + fields: [ { + id: "name" as UIHandlerId, + type: "text", + required: true, + label: "Name" as TranslatedString, + }, + { + id: "age" as UIHandlerId, + type: "integer", + required: true, + label: "Age" as TranslatedString, + }, + ], + id: "name" as UIHandlerId, + labelFieldId: "name" as UIHandlerId, + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx index d90028508..6b14a65b7 100644 --- a/packages/web-util/src/forms/InputArray.tsx +++ b/packages/web-util/src/forms/InputArray.tsx @@ -96,10 +96,12 @@ export function InputArray<T extends object, K extends keyof T>( props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); const list = (value ?? []) as Array<Record<string, string | undefined>>; - const [selectedIndex, setSelected] = useState<number | undefined>(undefined); + const [selectedIndex, setSelectedIndex] = useState<number | undefined>( + undefined, + ); const selected = selectedIndex === undefined ? undefined : list[selectedIndex]; - + return ( <div class="sm:col-span-6"> <LabelWithTooltipMaybeRequired @@ -108,104 +110,112 @@ export function InputArray<T extends object, K extends keyof T>( tooltip={tooltip} /> - <div class="-space-y-px rounded-md bg-white "> - {list.map((v, idx) => { - const label = getValueDeeper(v, labelField.split(".")) - return ( - <Option - label={label as TranslatedString} - key={idx} - isSelected={selectedIndex === idx} - isLast={idx === list.length - 1} - disabled={selectedIndex !== undefined && selectedIndex !== idx} - isFirst={idx === 0} + <div class="overflow-hidden ring-1 ring-gray-900/5 rounded-xl p-4"> + <div class="-space-y-px rounded-md bg-white "> + {list.map((v, idx) => { + const label = + getValueDeeper(v, labelField.split(".")) ?? "<<incomplete>>"; + return ( + <Option + label={label as TranslatedString} + key={idx} + isSelected={selectedIndex === idx} + isLast={idx === list.length - 1} + disabled={selectedIndex !== undefined && selectedIndex !== idx} + isFirst={idx === 0} + onClick={() => { + setSelectedIndex(selectedIndex === idx ? undefined : idx); + }} + /> + ); + })} + {!state.disabled && ( + <div class="pt-2"> + <Option + label={"Add new..." as TranslatedString} + isSelected={selectedIndex === list.length} + isLast + isFirst + disabled={ + selectedIndex !== undefined && selectedIndex !== list.length + } + onClick={() => { + setSelectedIndex( + selectedIndex === list.length ? undefined : list.length, + ); + }} + /> + </div> + )} + </div> + {selectedIndex !== undefined && ( + /** + * This form provider act as a substate of the parent form + * Consider creating an InnerFormProvider since not every feature is expected + */ + <FormProvider + initial={selected} + readOnly={state.disabled} + computeFormState={(v) => { + // current state is ignored + // the state is defined by the parent form + + // elements should be present in the state object since this is expected to be an array + //@ts-ignore + // return state.elements[selectedIndex]; + return {}; + }} + onSubmit={(v) => { + const newValue = [...list]; + newValue.splice(selectedIndex, 1, v); + onChange(newValue as any); + setSelectedIndex(undefined); + }} + onUpdate={(v) => { + const newValue = [...list]; + newValue.splice(selectedIndex, 1, v); + onChange(newValue as any); + }} + > + <div class="px-4 py-6"> + <div class="grid grid-cols-1 gap-y-8 "> + <RenderAllFieldsByUiConfig fields={fields} /> + </div> + </div> + </FormProvider> + )} + {selectedIndex !== undefined && ( + <div class="flex items-center justify-end gap-x-6"> + <button + type="button" onClick={() => { - setSelected(selectedIndex === idx ? undefined : idx); + setSelectedIndex(undefined); }} - /> - ); - })} - {!state.disabled && ( - <div class="pt-2"> - <Option - label={"Add..." as TranslatedString} - isSelected={selectedIndex === list.length} - isLast - isFirst - disabled={ - selectedIndex !== undefined && selectedIndex !== list.length - } + class="block px-3 py-2 text-sm font-semibold leading-6 text-gray-900" + > + Close + </button> + + <button + type="button" + disabled={selected !== undefined} onClick={() => { - setSelected( - selectedIndex === list.length ? undefined : list.length, - ); + const newValue = [...list]; + newValue.splice(selectedIndex, 1); + onChange(newValue as any); + setSelectedIndex(undefined); }} - /> + class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 " + > + Remove + </button> </div> )} </div> - {selectedIndex !== undefined && ( - /** - * This form provider act as a substate of the parent form - * Consider creating an InnerFormProvider since not every feature is expected - */ - <FormProvider - initial={selected} - readOnly={state.disabled} - computeFormState={(v) => { - // current state is ignored - // the state is defined by the parent form - - // elements should be present in the state object since this is expected to be an array - //@ts-ignore - // return state.elements[selectedIndex]; - return {}; - }} - onSubmit={(v) => { - const newValue = [...list]; - newValue.splice(selectedIndex, 1, v); - onChange(newValue as any); - setSelected(undefined); - }} - onUpdate={(v) => { - const newValue = [...list]; - newValue.splice(selectedIndex, 1, v); - onChange(newValue as any); - }} - > - <div class="px-4 py-6"> - <div class="grid grid-cols-1 gap-y-8 "> - <RenderAllFieldsByUiConfig fields={fields} /> - </div> - </div> - </FormProvider> - )} - {selectedIndex !== undefined && ( - <div class="flex items-center pt-3"> - <div class="flex-auto"> - {selected !== undefined && ( - <button - type="button" - onClick={() => { - const newValue = [...list]; - newValue.splice(selectedIndex, 1); - onChange(newValue as any); - setSelected(undefined); - }} - class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 " - > - Remove - </button> - )} - </div> - </div> - )} </div> ); } - - export function getValueDeeper( object: Record<string, any>, names: string[], @@ -218,9 +228,7 @@ export function getValueDeeper( return getValueDeeper(object, rest); } if (object === undefined) { - return "" + return ""; } return getValueDeeper(object[head], rest); } - - diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx index 786dfe5bc..a00bcd6a1 100644 --- a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx +++ b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx @@ -20,11 +20,12 @@ */ import { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Choice Horizontal", @@ -38,32 +39,41 @@ export namespace Simplest { type TargetObject = { comment: string; -} +}; const initial: TargetObject = { - comment: "0" -} + comment: "0", +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "choiceHorizontal", - properties: { - label: "label of the field" as TranslatedString, - name: "comment", - choices: [{ - label: "first choice" as TranslatedString, - value: "1" - }, { - label: "second choice" as TranslatedString, - value: "2" - }, { - label: "third choice" as TranslatedString, - value: "3" - },], - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "choiceHorizontal", + label: "label of the field" as TranslatedString, + id: "comment" as UIHandlerId, + choices: [ + { + label: "first choice" as TranslatedString, + value: "1", + }, + { + label: "second choice" as TranslatedString, + value: "2", + }, + { + label: "third choice" as TranslatedString, + value: "3", + }, + ], + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx index 9a634d05c..6e6a1a126 100644 --- a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx +++ b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx @@ -20,11 +20,12 @@ */ import { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Choice Stacked", @@ -38,32 +39,41 @@ export namespace Simplest { type TargetObject = { comment: string; -} +}; const initial: TargetObject = { - comment: "some initial comment" -} + comment: "some initial comment", +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "choiceStacked", - properties: { - label: "label of the field" as TranslatedString, - name: "comment", - choices: [{ - label: "first choice" as TranslatedString, - value: "1" - }, { - label: "second choice" as TranslatedString, - value: "2" - }, { - label: "third choice" as TranslatedString, - value: "3" - },], - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "choiceStacked", + label: "label of the field" as TranslatedString, + id: "comment" as UIHandlerId, + choices: [ + { + label: "first choice" as TranslatedString, + value: "1", + }, + { + label: "second choice" as TranslatedString, + value: "2", + }, + { + label: "third choice" as TranslatedString, + value: "3", + }, + ], + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputFile.stories.tsx b/packages/web-util/src/forms/InputFile.stories.tsx index eff18d071..75b1fb918 100644 --- a/packages/web-util/src/forms/InputFile.stories.tsx +++ b/packages/web-util/src/forms/InputFile.stories.tsx @@ -20,11 +20,12 @@ */ import { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input File", @@ -38,27 +39,32 @@ export namespace Simplest { type TargetObject = { comment: string; -} +}; const initial: TargetObject = { - comment: "some initial comment" -} + comment: "some initial comment", +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "file", - properties: { - label: "label of the field" as TranslatedString, - name: "comment", - required: true, - maxBites: 2 * 1024 * 1024, - accept: ".png", - tooltip: "this is a very long tooltip that explain what the field does without being short" as TranslatedString, - help: "Max size of 2 mega bytes" as TranslatedString, - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "file", + label: "label of the field" as TranslatedString, + required: true, + id: "comment" as UIHandlerId, + accept: ".png", + tooltip: + "this is a very long tooltip that explain what the field does without being short" as TranslatedString, + help: "Max size of 2 mega bytes" as TranslatedString, + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputInteger.stories.tsx b/packages/web-util/src/forms/InputInteger.stories.tsx index 378736a24..76d9e8668 100644 --- a/packages/web-util/src/forms/InputInteger.stories.tsx +++ b/packages/web-util/src/forms/InputInteger.stories.tsx @@ -20,36 +20,41 @@ */ import { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Integer", }; - type TargetObject = { age: number; -} +}; const initial: TargetObject = { age: 5, -} +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "integer", - properties: { - label: "label of the field" as TranslatedString, - name: "age", - tooltip: "just numbers" as TranslatedString, - }, - }] - }] -} - -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "integer", + label: "label of the field" as TranslatedString, + id: "comment" as UIHandlerId, + tooltip: "just numbers" as TranslatedString, + }, + ], + }, + ], +}; + +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputLine.stories.tsx b/packages/web-util/src/forms/InputLine.stories.tsx index dea5c142a..e5209f4d4 100644 --- a/packages/web-util/src/forms/InputLine.stories.tsx +++ b/packages/web-util/src/forms/InputLine.stories.tsx @@ -20,11 +20,12 @@ */ import { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Line", @@ -38,22 +39,27 @@ export namespace Simplest { type TargetObject = { comment: string; -} +}; const initial: TargetObject = { - comment: "some initial comment" -} + comment: "some initial comment", +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "text", - properties: { - label: "label of the field" as TranslatedString, - name: "comment", - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "text", + label: "label of the field" as TranslatedString, + id: "comment" as UIHandlerId, + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputLine.tsx b/packages/web-util/src/forms/InputLine.tsx index eb3238ef9..4c0176195 100644 --- a/packages/web-util/src/forms/InputLine.tsx +++ b/packages/web-util/src/forms/InputLine.tsx @@ -59,16 +59,22 @@ export function LabelWithTooltipMaybeRequired({ ); if (required) { return ( - <div class="flex justify-between"> + <div class="flex justify-between w-fit"> {WithTooltip} - <span class="text-sm leading-6 text-red-600">*</span> + <span class="text-sm leading-6 text-red-600 pl-2">*</span> </div> ); } return WithTooltip; } -export function RenderAddon({ disabled, addon }: { disabled?: boolean, addon: Addon }): VNode { +export function RenderAddon({ + disabled, + addon, +}: { + disabled?: boolean; + addon: Addon; +}): VNode { switch (addon.type) { case "text": { return ( @@ -115,7 +121,7 @@ function InputWrapper<T extends object, K extends keyof T>({ children: ComponentChildren; } & UIFormProps<T, K>): VNode { return ( - <div class="sm:col-span-6"> + <div class="sm:col-span-6 "> <LabelWithTooltipMaybeRequired label={label} required={required} @@ -154,7 +160,7 @@ type InputType = "text" | "text-area" | "password" | "email" | "number"; export function InputLine<T extends object, K extends keyof T>( props: { type: InputType } & UIFormProps<T, K>, ): VNode { - const { name, placeholder, before, after, converter, type } = props; + const { name, placeholder, before, after, converter, type, disabled } = props; //FIXME: remove deprecated const fieldCtx = useField<T, K>(props.name); const { value, onChange, state, error } = @@ -222,7 +228,7 @@ export function InputLine<T extends object, K extends keyof T>( <InputWrapper<T, K> {...props} help={props.help ?? state.help} - disabled={state.disabled ?? false} + disabled={disabled ?? false} error={showError ? error : undefined} > <textarea @@ -234,7 +240,7 @@ export function InputLine<T extends object, K extends keyof T>( placeholder={placeholder ? placeholder : undefined} value={toString(value) ?? ""} // defaultValue={toString(value)} - disabled={state.disabled} + disabled={disabled ?? false} aria-invalid={showError} // aria-describedby="email-error" class={clazz} @@ -247,7 +253,7 @@ export function InputLine<T extends object, K extends keyof T>( <InputWrapper<T, K> {...props} help={props.help ?? state.help} - disabled={state.disabled ?? false} + disabled={disabled ?? false} error={showError ? error : undefined} > <input @@ -262,7 +268,7 @@ export function InputLine<T extends object, K extends keyof T>( // onChange(fromString(value as any)); // }} // defaultValue={toString(value)} - disabled={state.disabled} + disabled={disabled ?? false} aria-invalid={showError} // aria-describedby="email-error" class={clazz} diff --git a/packages/web-util/src/forms/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx index ab17545f5..9cb997490 100644 --- a/packages/web-util/src/forms/InputSelectMultiple.stories.tsx +++ b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx @@ -20,11 +20,12 @@ */ import { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Select Multiple", @@ -39,52 +40,64 @@ export namespace Simplest { type TargetObject = { pets: string[]; things: string[]; -} +}; const initial: TargetObject = { pets: [], things: [], -} +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "selectMultiple", - properties: { - label: "allow diplicates" as TranslatedString, - name: "pets", - placeholder: "search..." as TranslatedString, - choices: [{ - label: "one label" as TranslatedString, - value: "one" - }, { - label: "two label" as TranslatedString, - value: "two" - }, { - label: "five label" as TranslatedString, - value: "five" - }] - }, - }, { - type: "selectMultiple", - properties: { - label: "unique values" as TranslatedString, - name: "things", - unique: true, - placeholder: "search..." as TranslatedString, - choices: [{ - label: "one label" as TranslatedString, - value: "one" - }, { - label: "two label" as TranslatedString, - value: "two" - }, { - label: "five label" as TranslatedString, - value: "five" - }] - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "selectMultiple", + label: "allow diplicates" as TranslatedString, + id: "pets" as UIHandlerId, + placeholder: "search..." as TranslatedString, + choices: [ + { + label: "one label" as TranslatedString, + value: "one", + }, + { + label: "two label" as TranslatedString, + value: "two", + }, + { + label: "five label" as TranslatedString, + value: "five", + }, + ], + }, + { + type: "selectMultiple", + label: "unique values" as TranslatedString, + id: "things" as UIHandlerId, + unique: true, + placeholder: "search..." as TranslatedString, + choices: [ + { + label: "one label" as TranslatedString, + value: "one", + }, + { + label: "two label" as TranslatedString, + value: "two", + }, + { + label: "five label" as TranslatedString, + value: "five", + }, + ], + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputSelectOne.stories.tsx b/packages/web-util/src/forms/InputSelectOne.stories.tsx index 2ebde3096..25b96f0c0 100644 --- a/packages/web-util/src/forms/InputSelectOne.stories.tsx +++ b/packages/web-util/src/forms/InputSelectOne.stories.tsx @@ -20,11 +20,12 @@ */ import { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Select One", @@ -38,33 +39,42 @@ export namespace Simplest { type TargetObject = { things: string; -} +}; const initial: TargetObject = { - things: "one" -} + things: "one", +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "selectOne", - properties: { - label: "label of the field" as TranslatedString, - name: "things", - placeholder: "search..." as TranslatedString, - choices: [{ - label: "one label" as TranslatedString, - value: "one" - }, { - label: "two label" as TranslatedString, - value: "two" - }, { - label: "five label" as TranslatedString, - value: "five" - }] - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "selectOne", + label: "label of the field" as TranslatedString, + id: "things" as UIHandlerId, + placeholder: "search..." as TranslatedString, + choices: [ + { + label: "one label" as TranslatedString, + value: "one", + }, + { + label: "two label" as TranslatedString, + value: "two", + }, + { + label: "five label" as TranslatedString, + value: "five", + }, + ], + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputText.stories.tsx b/packages/web-util/src/forms/InputText.stories.tsx index 60b6ca224..6d0db938b 100644 --- a/packages/web-util/src/forms/InputText.stories.tsx +++ b/packages/web-util/src/forms/InputText.stories.tsx @@ -20,11 +20,12 @@ */ import { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Text", @@ -38,22 +39,27 @@ export namespace Simplest { type TargetObject = { comment: string; -} +}; const initial: TargetObject = { - comment: "some initial comment" -} + comment: "some initial comment", +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "text", - properties: { - label: "label of the field" as TranslatedString, - name: "comment", - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "text", + label: "label of the field" as TranslatedString, + id: "comment" as UIHandlerId, + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputTextArea.stories.tsx b/packages/web-util/src/forms/InputTextArea.stories.tsx index ab1a695f5..a3b135c36 100644 --- a/packages/web-util/src/forms/InputTextArea.stories.tsx +++ b/packages/web-util/src/forms/InputTextArea.stories.tsx @@ -20,11 +20,12 @@ */ import { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { DefaultForm as TestedComponent, FlexibleForm_Deprecated, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Text Area", @@ -38,22 +39,27 @@ export namespace Simplest { type TargetObject = { comment: string; -} +}; const initial: TargetObject = { - comment: "some initial comment" -} + comment: "some initial comment", +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "text", - properties: { - label: "label of the field" as TranslatedString, - name: "comment", - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "text", + label: "label of the field" as TranslatedString, + id: "comment" as UIHandlerId, + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/InputToggle.stories.tsx b/packages/web-util/src/forms/InputToggle.stories.tsx index fcc57ffe2..5b5794308 100644 --- a/packages/web-util/src/forms/InputToggle.stories.tsx +++ b/packages/web-util/src/forms/InputToggle.stories.tsx @@ -20,11 +20,12 @@ */ import { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "@gnu-taler/web-util/testing"; +import * as tests from "../tests/hook.js"; import { FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; +import { UIHandlerId } from "./ui-form.js"; export default { title: "Input Toggle", @@ -38,22 +39,27 @@ export namespace Simplest { type TargetObject = { comment: string; -} +}; const initial: TargetObject = { - comment: "some initial comment" -} + comment: "some initial comment", +}; const form: FlexibleForm_Deprecated<TargetObject> = { - design: [{ - title: "this is a simple form" as TranslatedString, - fields: [{ - type: "toggle", - properties: { - label: "label of the field" as TranslatedString, - name: "comment", - }, - }] - }] -} + design: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "toggle", + label: "label of the field" as TranslatedString, + id: "comment" as UIHandlerId, + }, + ], + }, + ], +}; -export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + form, +}); diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts index 4c5050830..2c789b9a3 100644 --- a/packages/web-util/src/forms/forms.ts +++ b/packages/web-util/src/forms/forms.ts @@ -14,9 +14,12 @@ import { InputText } from "./InputText.js"; import { InputTextArea } from "./InputTextArea.js"; import { InputToggle } from "./InputToggle.js"; import { Addon, StringConverter, UIFieldHandler } from "./FormProvider.js"; -import { InternationalizationAPI, UIFieldElementDescription } from "../index.browser.js"; +import { + InternationalizationAPI, + UIFieldElementDescription, +} from "../index.browser.js"; import { assertUnreachable, TranslatedString } from "@gnu-taler/taler-util"; -import {UIFormFieldBaseConfig, UIFormElementConfig} from "./ui-form.js"; +import { UIFormFieldBaseConfig, UIFormElementConfig } from "./ui-form.js"; /** * Constrain the type with the ui props */ @@ -148,11 +151,11 @@ export function RenderAllFieldsByUiConfig({ /** * convert field configuration to render function - * - * @param i18n_ - * @param fieldConfig - * @param form - * @returns + * + * @param i18n_ + * @param fieldConfig + * @param form + * @returns */ export function convertUiField( i18n_: InternationalizationAPI, @@ -175,7 +178,12 @@ export function convertUiField( type: config.type, properties: { ...converBaseFieldsProps(i18n_, config), - fields: convertUiField(i18n_, config.fields, form, getConverterById), + fields: convertUiField( + i18n_, + config.fields, + form, + getConverterById, + ), }, }; return resp; @@ -190,7 +198,12 @@ export function convertUiField( ...converBaseFieldsProps(i18n_, config), ...converInputFieldsProps(form, config, getConverterById), labelField: config.labelFieldId, - fields: convertUiField(i18n_, config.fields, form, getConverterById), + fields: convertUiField( + i18n_, + config.fields, + form, + getConverterById, + ), }, } as UIFormField; } @@ -208,8 +221,8 @@ export function convertUiField( type: "amount", properties: { ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - currency: config.currency, + ...converInputFieldsProps(form, config, getConverterById), + currency: config.currency, }, } as UIFormField; } @@ -230,11 +243,10 @@ export function convertUiField( ...converBaseFieldsProps(i18n_, config), ...converInputFieldsProps(form, config, getConverterById), choices: config.choices, - }, - }as UIFormField; + } as UIFormField; } - case "file":{ + case "file": { return { type: "file", properties: { @@ -245,7 +257,7 @@ export function convertUiField( }, } as UIFormField; } - case "integer":{ + case "integer": { return { type: "integer", properties: { @@ -254,7 +266,7 @@ export function convertUiField( }, } as UIFormField; } - case "selectMultiple":{ + case "selectMultiple": { return { type: "selectMultiple", properties: { @@ -285,7 +297,7 @@ export function convertUiField( } case "textArea": { return { - type: "text", + type: "textArea", properties: { ...converBaseFieldsProps(i18n_, config), ...converInputFieldsProps(form, config, getConverterById), @@ -308,30 +320,27 @@ export function convertUiField( }); } - - function getAddonById(_id: string | undefined): Addon { return undefined!; } - type GetConverterById = ( id: string | undefined, config: unknown, ) => StringConverter<unknown>; - function converInputFieldsProps( form: object, p: UIFormFieldBaseConfig, getConverterById: GetConverterById, ) { + const names = p.id.split("."); return { converter: getConverterById(p.converterId, p), - handler: getValueDeeper2(form, p.id.split(".")), - name: p.name, + handler: getValueDeeper2(form, names), required: p.required, disabled: p.disabled, + name: names[names.length - 1], help: p.help, placeholder: p.placeholder, tooltip: p.tooltip, @@ -347,7 +356,6 @@ function converBaseFieldsProps( after: getAddonById(p.addonAfterId), before: getAddonById(p.addonBeforeId), hidden: p.hidden, - name: p.name, help: i18n_.str`${p.help}`, label: i18n_.str`${p.label}`, tooltip: i18n_.str`${p.tooltip}`, @@ -368,5 +376,3 @@ export function getValueDeeper2( } return getValueDeeper2(object[head], rest); } - - diff --git a/packages/web-util/src/forms/ui-form.ts b/packages/web-util/src/forms/ui-form.ts index 012499d6d..f26e08f3b 100644 --- a/packages/web-util/src/forms/ui-form.ts +++ b/packages/web-util/src/forms/ui-form.ts @@ -12,7 +12,9 @@ import { codecOptional, Integer, TalerProtocolTimestamp, + TranslatedString, } from "@gnu-taler/taler-util"; +import { InternationalizationAPI } from "../index.browser.js"; export type FormConfiguration = DoubleColumnForm; @@ -134,9 +136,6 @@ export type UIFieldElementDescription = { /* short text to be shown close to the field, usually below and dimmer*/ help?: string; - /* name of the field, useful for a11y */ - name: string; - /* if the field should be initially hidden */ hidden?: boolean; @@ -162,6 +161,11 @@ export type UIFormFieldBaseConfig = UIFieldElementDescription & { */ converterId?: string; + /* return an error message if the value is not valid, returns un undefined + if there is no error + */ + validator?: (value: string) => TranslatedString | undefined; + /* property id of the form */ id: UIHandlerId; }; @@ -181,7 +185,6 @@ const codecForUIFormFieldBaseDescriptionTemplate = < .property("hidden", codecOptional(codecForBoolean())) .property("help", codecOptional(codecForString())) .property("label", codecForString()) - .property("name", codecForString()) .property("tooltip", codecOptional(codecForString())); const codecForUIFormFieldBaseConfigTemplate = < diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts index 103b88c86..929c54a58 100644 --- a/packages/web-util/src/hooks/useNotifications.ts +++ b/packages/web-util/src/hooks/useNotifications.ts @@ -155,7 +155,7 @@ function errorMap<T extends OperationFail<unknown>>( notify({ type: "error", title: map(resp.case), - description: resp.detail.hint as TranslatedString, + description: (resp.detail?.hint as TranslatedString) ?? "", debug: resp.detail, when: AbsoluteTime.now(), }); diff --git a/packages/web-util/src/index.browser.ts b/packages/web-util/src/index.browser.ts index 2f3b57b8d..7d413a17a 100644 --- a/packages/web-util/src/index.browser.ts +++ b/packages/web-util/src/index.browser.ts @@ -7,4 +7,5 @@ export * from "./utils/route.js"; export * from "./context/index.js"; export * from "./components/index.js"; export * from "./forms/index.js"; -export { renderStories, parseGroupImport } from "./stories.js"; +export { encodeCrockForURI, decodeCrockFromURI } from "./utils/base64.js"; +export { renderStories, parseGroupImport } from "./stories-utils.js"; diff --git a/packages/web-util/src/index.build.ts b/packages/web-util/src/index.build.ts index 2260ecb9a..9095db892 100644 --- a/packages/web-util/src/index.build.ts +++ b/packages/web-util/src/index.build.ts @@ -292,6 +292,7 @@ export function computeConfig(params: BuildParams): esbuild.BuildOptions { entryPoints: params.source.js, publicPath: params.public, outdir: params.destination, + treeShaking: true, minify: false, //params.type === "production", sourcemap: true, //params.type !== "production", define: { @@ -311,11 +312,30 @@ export async function build(config: BuildParams) { return res; } -const LIVE_RELOAD_SCRIPT = - "./node_modules/@gnu-taler/web-util/lib/live-reload.mjs"; +const LIVE_RELOAD_SCRIPT = "./node_modules/@gnu-taler/web-util/lib/live-reload.mjs"; +const LIVE_RELOAD_SCRIPT_LOCALLY = "./lib/live-reload.mjs"; /** * Do startup for development environment + * + * To be used from web-utils project + */ +export function initializeDevOnWebUtils( + config: BuildParams, +): () => Promise<esbuild.BuildResult> { + function buildDevelopment() { + const result = computeConfig(config); + result.inject = [LIVE_RELOAD_SCRIPT_LOCALLY]; + return esbuild.build(result); + } + return buildDevelopment; +} + + +/** + * Do startup for development environment + * + * To be used when web-utils is a library */ export function initializeDev( config: BuildParams, diff --git a/packages/web-util/src/index.html b/packages/web-util/src/index.html new file mode 100644 index 000000000..a51fe776a --- /dev/null +++ b/packages/web-util/src/index.html @@ -0,0 +1,41 @@ +<!-- + This file is part of GNU Taler + (C) 2021--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/> + + @author Sebastian Javier Marchano +--> +<!doctype html> +<html lang="en" class="h-full bg-gray-100"> + <head> + <meta http-equiv="content-type" content="text/html; charset=utf-8" /> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <meta name="taler-support" content="uri,api" /> + <meta name="mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + <link + rel="icon" + href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + /> + <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> + <title>Web Util</title> + <!-- Entry point for the bank SPA. --> + <script type="module" src="index.js"></script> + <link rel="stylesheet" href="index.css" /> + </head> + + <body class="h-full"> + <div id="app"></div> + </body> +</html> diff --git a/packages/web-util/src/live-reload.ts b/packages/web-util/src/live-reload.ts index cd3a7540d..c89d09383 100644 --- a/packages/web-util/src/live-reload.ts +++ b/packages/web-util/src/live-reload.ts @@ -1,10 +1,12 @@ /* eslint-disable no-undef */ function setupLiveReload(): void { - const stopWs = localStorage.getItem("stop-ws") + const stopWs = localStorage.getItem("stop-ws"); if (!!stopWs) return; const protocol = window.location.protocol === "http:" ? "ws:" : "wss:"; - const ws = new WebSocket(`${protocol}//${window.location.hostname}:${window.location.port}/ws`); + const ws = new WebSocket( + `${protocol}//${window.location.hostname}:${window.location.port}/ws`, + ); ws.addEventListener("message", (message) => { try { @@ -60,18 +62,22 @@ setupLiveReload(); function showReloadOverlay(): void { const d = document.createElement("div"); d.id = "overlay"; - d.style.position = "absolute"; - d.style.width = "100%"; - d.style.height = "100%"; + d.style.position = "fixed"; + d.style.left = "0px"; + d.style.top = "0px"; + d.style.width = "100vw"; + d.style.height = "100vh"; + d.style.display = "flex"; + d.style.alignItems = "center"; + d.style.justifyContent = "center"; + d.style.fontFamily = `system-ui, -apple-system, BlinkMacSystemFont, Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif`; d.style.color = "white"; d.style.backgroundColor = "rgba(0,0,0,0.5)"; - d.style.display = "flex"; d.style.zIndex = String(Number.MAX_SAFE_INTEGER); - d.style.justifyContent = "center"; const h = document.createElement("h1"); h.id = "overlay-text"; h.style.margin = "auto"; - h.innerHTML = "reloading..."; + h.innerHTML = "Reloading..."; d.appendChild(h); if (document.body.firstChild) { document.body.insertBefore(d, document.body.firstChild); diff --git a/packages/web-util/src/serve.ts b/packages/web-util/src/serve.ts index 1daea15bf..5d2b770c9 100644 --- a/packages/web-util/src/serve.ts +++ b/packages/web-util/src/serve.ts @@ -6,15 +6,15 @@ import http from "http"; import { parse } from "url"; import WebSocket from "ws"; -import locahostCrt from "./keys/localhost.crt"; -import locahostKey from "./keys/localhost.key"; +// import locahostCrt from "./keys/localhost.crt"; +// import locahostKey from "./keys/localhost.key"; import storiesHtml from "./stories.html"; import path from "path"; const httpServerOptions = { - key: locahostKey, - cert: locahostCrt, + // key: locahostKey, + // cert: locahostCrt, }; const logger = new Logger("serve.ts"); @@ -22,6 +22,7 @@ const logger = new Logger("serve.ts"); const PATHS = { WS: "/ws", EXAMPLE: "/examples", + ROOT: "/", APP: "/app", }; @@ -46,7 +47,7 @@ export async function serve(opts: { if (opts.tls) { httpsServer = https.createServer(httpServerOptions, app); httpsPort = opts.port + 1; - servers.push(httpsServer) + servers.push(httpsServer); } logger.info(`Dev server. Endpoints:`); @@ -124,6 +125,27 @@ export async function serve(opts: { ); }); + app.get(PATHS.ROOT, function (req: any, res: any) { + res.set("Content-Type", "text/html"); + res.send(`<hmtl> + <head><title>Development Server</title></head> + <body> + it will connect to this server using websocket and reload automatically when the code changes + <h1>Endpoints</h1> + <dl> + <dt><a href="./app">app</a></dt> + <dd>Where you can find the application. Reloads on update.</dd> + + <dt><a href="./examples">ui examples</a></dt> + <dd>Where you can browse static UI examples. Reloads on update.</dd> + + <dt><a href="./ws">websocket</a></dt> + <dd>Announce when the code changes</dd> + </dl> + </body> + </html>`); + }); + logger.info(`Serving ${opts.folder} on ${httpPort}: plain HTTP`); httpServer.listen(httpPort); if (httpsServer !== undefined) { diff --git a/packages/web-util/src/stories-utils.tsx b/packages/web-util/src/stories-utils.tsx new file mode 100644 index 000000000..d9c2406eb --- /dev/null +++ b/packages/web-util/src/stories-utils.tsx @@ -0,0 +1,578 @@ +/* + 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { setupI18n } from "@gnu-taler/taler-util"; +import { + ComponentChild, + ComponentChildren, + Fragment, + FunctionalComponent, + FunctionComponent, + h, + JSX, + render, + VNode, +} from "preact"; +import { useEffect, useErrorBoundary, useState } from "preact/hooks"; +import { ExampleItemSetup } from "./tests/hook.js"; + +const Page: FunctionalComponent = ({ children }): VNode => { + return ( + <div + style={{ + fontFamily: "Arial, Helvetica, sans-serif", + width: "100%", + display: "flex", + flexDirection: "row", + }} + > + {children} + </div> + ); +}; + +const SideBar: FunctionalComponent<{ width: number }> = ({ + width, + children, +}): VNode => { + return ( + <div + style={{ + minWidth: width, + height: "calc(100vh - 20px)", + overflowX: "hidden", + overflowY: "visible", + scrollBehavior: "smooth", + }} + > + {children} + </div> + ); +}; + +const ResizeHandleDiv: FunctionalComponent< + JSX.HTMLAttributes<HTMLDivElement> +> = ({ children, ...props }): VNode => { + return ( + <div + {...props} + style={{ + width: 10, + backgroundColor: "#ddd", + cursor: "ew-resize", + }} + > + {children} + </div> + ); +}; + +const Content: FunctionalComponent = ({ children }): VNode => { + return ( + <div + style={{ + width: "100%", + padding: 20, + }} + > + {children} + </div> + ); +}; + +function findByGroupComponentName( + allExamples: Group[], + group: string, + component: string, + name: string, +): ExampleItem | undefined { + const gl = allExamples.filter((e) => e.title === group); + if (gl.length === 0) { + return undefined; + } + const cl = gl[0].list.filter((l) => l.name === component); + if (cl.length === 0) { + return undefined; + } + const el = cl[0].examples.filter((c) => c.name === name); + if (el.length === 0) { + return undefined; + } + return el[0]; +} + +function getContentForExample( + item: ExampleItem | undefined, + allExamples: Group[], +): FunctionalComponent { + if (!item) + return function SelectExampleMessage() { + return <div>select example from the list on the left</div>; + }; + const example = findByGroupComponentName( + allExamples, + item.group, + item.component, + item.name, + ); + if (!example) { + return function ExampleNotFoundMessage() { + return <div>example not found</div>; + }; + } + return () => example.render.component(example.render.props); +} + +function ExampleList({ + name, + list, + selected, + onSelectStory, +}: { + name: string; + list: { + name: string; + examples: ExampleItem[]; + }[]; + selected: ExampleItem | undefined; + onSelectStory: (i: ExampleItem, id: string) => void; +}): VNode { + const [isOpen, setOpen] = useState(selected && selected.group === name); + return ( + <ol style={{ padding: 4, margin: 0 }}> + <div + style={{ backgroundColor: "lightcoral", cursor: "pointer" }} + onClick={() => setOpen(!isOpen)} + > + {name} + </div> + <div style={{ display: isOpen ? undefined : "none" }}> + {list.map((k) => ( + <li key={k.name}> + <dl style={{ margin: 0 }}> + <dt>{k.name}</dt> + {k.examples.map((r, i) => { + const e = encodeURIComponent; + const eId = `${e(r.group)}-${e(r.component)}-${e(r.name)}`; + const isSelected = + selected && + selected.component === r.component && + selected.group === r.group && + selected.name === r.name; + return ( + <dd + id={eId} + key={r.name} + style={{ + backgroundColor: isSelected + ? "green" + : i % 2 + ? "lightgray" + : "lightblue", + marginLeft: "1em", + padding: 4, + cursor: "pointer", + borderRadius: 4, + marginBottom: 4, + }} + > + <a + href={`#${eId}`} + style={{ color: "black" }} + onClick={(e) => { + e.preventDefault(); + location.hash = `#${eId}`; + onSelectStory(r, eId); + history.pushState({}, "", `#${eId}`); + }} + > + {r.name} + </a> + </dd> + ); + })} + </dl> + </li> + ))} + </div> + </ol> + ); +} + +/** + * Prevents the UI from redirecting and inform the dev + * where the <a /> should have redirected + * @returns + */ +function PreventLinkNavigation({ + children, +}: { + children: ComponentChildren; +}): VNode { + return ( + <div + onClick={(e) => { + let t: any = e.target; + do { + if (t.localName === "a" && t.getAttribute("href")) { + alert(`should navigate to: ${t.attributes.href.value}`); + e.stopImmediatePropagation(); + e.stopPropagation(); + e.preventDefault(); + return false; + } + } while ((t = t.parentNode)); + return true; + }} + > + {children} + </div> + ); +} + +function ErrorReport({ + children, + selected, +}: { + children: ComponentChild; + selected: ExampleItem | undefined; +}): VNode { + const [error, resetError] = useErrorBoundary(); + //if there is an error, reset when unloading this component + useEffect(() => (error ? resetError : undefined)); + if (error) { + return ( + <div> + <p>Error was thrown trying to render</p> + {selected && ( + <ul> + <li> + <b>group</b>: {selected.group} + </li> + <li> + <b>component</b>: {selected.component} + </li> + <li> + <b>example</b>: {selected.name} + </li> + <li> + <b>args</b>:{" "} + <pre>{JSON.stringify(selected.render.props, undefined, 2)}</pre> + </li> + </ul> + )} + <p>{error.message}</p> + <pre>{error.stack}</pre> + </div> + ); + } + return <Fragment>{children}</Fragment>; +} + +function getSelectionFromLocationHash( + hash: string, + allExamples: Group[], +): ExampleItem | undefined { + if (!hash) return undefined; + const parts = hash.substring(1).split("-"); + if (parts.length < 3) return undefined; + return findByGroupComponentName( + allExamples, + decodeURIComponent(parts[0]), + decodeURIComponent(parts[1]), + decodeURIComponent(parts[2]), + ); +} + +function parseExampleImport( + group: string, + componentName: string, + im: MaybeComponent, +): ComponentItem { + const examples: ExampleItem[] = Object.entries(im) + .filter(([k]) => k !== "default") + .map(([exampleName, exampleValue]): ExampleItem => { + if (!exampleValue) { + throw Error( + `example "${exampleName}" from component "${componentName}" in group "${group}" is undefined`, + ); + } + + if (typeof exampleValue === "function") { + return { + group, + component: componentName, + name: exampleName, + render: { + component: exampleValue as FunctionComponent, + props: {}, + contextProps: {}, + }, + }; + } + const v: any = exampleValue; + if ( + "component" in v && + typeof v.component === "function" && + "props" in v + ) { + return { + group, + component: componentName, + name: exampleName, + render: v, + }; + } + throw Error( + `example "${exampleName}" from component "${componentName}" in group "${group}" doesn't follow one of the two ways of example`, + ); + }); + return { + name: componentName, + examples, + }; +} + +export function parseGroupImport( + groups: Record<string, ComponentOrFolder>, +): Group[] { + return Object.entries(groups).map(([groupName, value]) => { + return { + title: groupName, + list: Object.entries(value).flatMap(([key, value]) => + folder(groupName, value), + ), + }; + }); +} + +export interface Group { + title: string; + list: ComponentItem[]; +} + +export interface ComponentItem<Props extends object = {}> { + name: string; + examples: ExampleItem<Props>[]; +} + +export interface ExampleItem<Props extends object = {}> { + group: string; + component: string; + name: string; + render: ExampleItemSetup<Props>; +} + +type ComponentOrFolder = MaybeComponent | MaybeFolder; +interface MaybeFolder { + default?: { title: string }; + // [exampleName: string]: FunctionalComponent; +} +interface MaybeComponent { + // default?: undefined; + [exampleName: string]: undefined | object; +} + +function folder(groupName: string, value: ComponentOrFolder): ComponentItem[] { + let title: string | undefined = undefined; + try { + title = + typeof value === "object" && + typeof value.default === "object" && + value.default !== undefined && + "title" in value.default && + typeof value.default.title === "string" + ? value.default.title + : undefined; + } catch (e) { + throw Error( + `Could not defined if it is component or folder ${groupName}: ${JSON.stringify( + value, + undefined, + 2, + )}`, + ); + } + if (title) { + const c = parseExampleImport(groupName, title, value as MaybeComponent); + return [c]; + } + return Object.entries(value).flatMap(([subkey, value]) => + folder(groupName, value), + ); +} + +interface Props { + getWrapperForGroup: (name: string) => FunctionComponent; + examplesInGroups: Group[]; + langs: Record<string, object>; +} + +function Application({ + langs, + examplesInGroups, + getWrapperForGroup, +}: Props): VNode { + const url = new URL(window.location.href); + const initialSelection = getSelectionFromLocationHash( + url.hash, + examplesInGroups, + ); + + const currentLang = url.searchParams.get("lang") || "en"; + + if (!langs["en"]) { + langs["en"] = {}; + } + setupI18n(currentLang, langs); + + const [selected, updateSelected] = useState<ExampleItem | undefined>( + initialSelection, + ); + const [sidebarWidth, setSidebarWidth] = useState(200); + useEffect(() => { + if (url.hash) { + const hash = url.hash.substring(1); + const found = document.getElementById(hash); + if (found) { + setTimeout(() => { + found.scrollIntoView({ + block: "center", + }); + }, 50); + } + } + }, []); + + const GroupWrapper = getWrapperForGroup(selected?.group || "default"); + const ExampleContent = getContentForExample(selected, examplesInGroups); + + //style={{ "--with-size": `${sidebarWidth}px` }} + return ( + <Page> + {/* <LiveReload /> */} + <SideBar width={sidebarWidth}> + <div> + Language: + <select + value={currentLang} + onChange={(e) => { + const url = new URL(window.location.href); + url.searchParams.set("lang", e.currentTarget.value); + window.location.href = url.href; + }} + > + {Object.keys(langs).map((l) => ( + <option key={l}>{l}</option> + ))} + </select> + </div> + {examplesInGroups.map((group) => ( + <ExampleList + key={group.title} + name={group.title} + list={group.list} + selected={selected} + onSelectStory={(item, htmlId) => { + document.getElementById(htmlId)?.scrollIntoView({ + block: "center", + }); + updateSelected(item); + }} + /> + ))} + <hr /> + </SideBar> + {/* <ResizeHandle + onUpdate={(x) => { + setSidebarWidth((s) => s + x); + }} + /> */} + <Content> + <ErrorReport selected={selected}> + <PreventLinkNavigation> + <GroupWrapper> + <ExampleContent /> + </GroupWrapper> + </PreventLinkNavigation> + </ErrorReport> + </Content> + </Page> + ); +} + +export interface Options { + id?: string; + strings?: any; + getWrapperForGroup?: (name: string) => FunctionComponent; +} + +export function renderStories( + groups: Record<string, ComponentOrFolder>, + options: Options = {}, +): void { + const examples = parseGroupImport(groups); + + try { + const cid = options.id ?? "container"; + const container = document.getElementById(cid); + if (!container) { + throw Error( + `container with id ${cid} not found, can't mount page contents`, + ); + } + render( + <Application + examplesInGroups={examples} + getWrapperForGroup={options.getWrapperForGroup ?? (() => Fragment)} + langs={options.strings ?? { en: {} }} + />, + container, + ); + } catch (e) { + console.error("got error", e); + if (e instanceof Error) { + document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`; + } + } +} + +function ResizeHandle({ onUpdate }: { onUpdate: (x: number) => void }): VNode { + const [start, setStart] = useState<number | undefined>(undefined); + return ( + <ResizeHandleDiv + onMouseDown={(e: any) => { + setStart(e.pageX); + console.log("active", e.pageX); + return false; + }} + onMouseMove={(e: any) => { + if (start !== undefined) { + onUpdate(e.pageX - start); + } + return false; + }} + onMouseUp={() => { + setStart(undefined); + return false; + }} + /> + ); +} diff --git a/packages/web-util/src/stories.tsx b/packages/web-util/src/stories.tsx index d9c2406eb..00e1fb7f9 100644 --- a/packages/web-util/src/stories.tsx +++ b/packages/web-util/src/stories.tsx @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2022 Taler Systems S.A. + (C) 2022-2024 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 @@ -18,561 +18,24 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { setupI18n } from "@gnu-taler/taler-util"; -import { - ComponentChild, - ComponentChildren, - Fragment, - FunctionalComponent, - FunctionComponent, - h, - JSX, - render, - VNode, -} from "preact"; -import { useEffect, useErrorBoundary, useState } from "preact/hooks"; -import { ExampleItemSetup } from "./tests/hook.js"; +// import { strings } from "./i18n/strings.js"; -const Page: FunctionalComponent = ({ children }): VNode => { - return ( - <div - style={{ - fontFamily: "Arial, Helvetica, sans-serif", - width: "100%", - display: "flex", - flexDirection: "row", - }} - > - {children} - </div> - ); -}; - -const SideBar: FunctionalComponent<{ width: number }> = ({ - width, - children, -}): VNode => { - return ( - <div - style={{ - minWidth: width, - height: "calc(100vh - 20px)", - overflowX: "hidden", - overflowY: "visible", - scrollBehavior: "smooth", - }} - > - {children} - </div> - ); -}; - -const ResizeHandleDiv: FunctionalComponent< - JSX.HTMLAttributes<HTMLDivElement> -> = ({ children, ...props }): VNode => { - return ( - <div - {...props} - style={{ - width: 10, - backgroundColor: "#ddd", - cursor: "ew-resize", - }} - > - {children} - </div> - ); -}; - -const Content: FunctionalComponent = ({ children }): VNode => { - return ( - <div - style={{ - width: "100%", - padding: 20, - }} - > - {children} - </div> - ); -}; - -function findByGroupComponentName( - allExamples: Group[], - group: string, - component: string, - name: string, -): ExampleItem | undefined { - const gl = allExamples.filter((e) => e.title === group); - if (gl.length === 0) { - return undefined; - } - const cl = gl[0].list.filter((l) => l.name === component); - if (cl.length === 0) { - return undefined; - } - const el = cl[0].examples.filter((c) => c.name === name); - if (el.length === 0) { - return undefined; - } - return el[0]; -} - -function getContentForExample( - item: ExampleItem | undefined, - allExamples: Group[], -): FunctionalComponent { - if (!item) - return function SelectExampleMessage() { - return <div>select example from the list on the left</div>; - }; - const example = findByGroupComponentName( - allExamples, - item.group, - item.component, - item.name, - ); - if (!example) { - return function ExampleNotFoundMessage() { - return <div>example not found</div>; - }; - } - return () => example.render.component(example.render.props); -} - -function ExampleList({ - name, - list, - selected, - onSelectStory, -}: { - name: string; - list: { - name: string; - examples: ExampleItem[]; - }[]; - selected: ExampleItem | undefined; - onSelectStory: (i: ExampleItem, id: string) => void; -}): VNode { - const [isOpen, setOpen] = useState(selected && selected.group === name); - return ( - <ol style={{ padding: 4, margin: 0 }}> - <div - style={{ backgroundColor: "lightcoral", cursor: "pointer" }} - onClick={() => setOpen(!isOpen)} - > - {name} - </div> - <div style={{ display: isOpen ? undefined : "none" }}> - {list.map((k) => ( - <li key={k.name}> - <dl style={{ margin: 0 }}> - <dt>{k.name}</dt> - {k.examples.map((r, i) => { - const e = encodeURIComponent; - const eId = `${e(r.group)}-${e(r.component)}-${e(r.name)}`; - const isSelected = - selected && - selected.component === r.component && - selected.group === r.group && - selected.name === r.name; - return ( - <dd - id={eId} - key={r.name} - style={{ - backgroundColor: isSelected - ? "green" - : i % 2 - ? "lightgray" - : "lightblue", - marginLeft: "1em", - padding: 4, - cursor: "pointer", - borderRadius: 4, - marginBottom: 4, - }} - > - <a - href={`#${eId}`} - style={{ color: "black" }} - onClick={(e) => { - e.preventDefault(); - location.hash = `#${eId}`; - onSelectStory(r, eId); - history.pushState({}, "", `#${eId}`); - }} - > - {r.name} - </a> - </dd> - ); - })} - </dl> - </li> - ))} - </div> - </ol> - ); -} - -/** - * Prevents the UI from redirecting and inform the dev - * where the <a /> should have redirected - * @returns - */ -function PreventLinkNavigation({ - children, -}: { - children: ComponentChildren; -}): VNode { - return ( - <div - onClick={(e) => { - let t: any = e.target; - do { - if (t.localName === "a" && t.getAttribute("href")) { - alert(`should navigate to: ${t.attributes.href.value}`); - e.stopImmediatePropagation(); - e.stopPropagation(); - e.preventDefault(); - return false; - } - } while ((t = t.parentNode)); - return true; - }} - > - {children} - </div> - ); -} - -function ErrorReport({ - children, - selected, -}: { - children: ComponentChild; - selected: ExampleItem | undefined; -}): VNode { - const [error, resetError] = useErrorBoundary(); - //if there is an error, reset when unloading this component - useEffect(() => (error ? resetError : undefined)); - if (error) { - return ( - <div> - <p>Error was thrown trying to render</p> - {selected && ( - <ul> - <li> - <b>group</b>: {selected.group} - </li> - <li> - <b>component</b>: {selected.component} - </li> - <li> - <b>example</b>: {selected.name} - </li> - <li> - <b>args</b>:{" "} - <pre>{JSON.stringify(selected.render.props, undefined, 2)}</pre> - </li> - </ul> - )} - <p>{error.message}</p> - <pre>{error.stack}</pre> - </div> - ); - } - return <Fragment>{children}</Fragment>; -} - -function getSelectionFromLocationHash( - hash: string, - allExamples: Group[], -): ExampleItem | undefined { - if (!hash) return undefined; - const parts = hash.substring(1).split("-"); - if (parts.length < 3) return undefined; - return findByGroupComponentName( - allExamples, - decodeURIComponent(parts[0]), - decodeURIComponent(parts[1]), - decodeURIComponent(parts[2]), - ); -} - -function parseExampleImport( - group: string, - componentName: string, - im: MaybeComponent, -): ComponentItem { - const examples: ExampleItem[] = Object.entries(im) - .filter(([k]) => k !== "default") - .map(([exampleName, exampleValue]): ExampleItem => { - if (!exampleValue) { - throw Error( - `example "${exampleName}" from component "${componentName}" in group "${group}" is undefined`, - ); - } - - if (typeof exampleValue === "function") { - return { - group, - component: componentName, - name: exampleName, - render: { - component: exampleValue as FunctionComponent, - props: {}, - contextProps: {}, - }, - }; - } - const v: any = exampleValue; - if ( - "component" in v && - typeof v.component === "function" && - "props" in v - ) { - return { - group, - component: componentName, - name: exampleName, - render: v, - }; - } - throw Error( - `example "${exampleName}" from component "${componentName}" in group "${group}" doesn't follow one of the two ways of example`, - ); - }); - return { - name: componentName, - examples, - }; -} +import * as forms from "./forms/index.stories.js"; +import { renderStories } from "./stories-utils.js"; -export function parseGroupImport( - groups: Record<string, ComponentOrFolder>, -): Group[] { - return Object.entries(groups).map(([groupName, value]) => { - return { - title: groupName, - list: Object.entries(value).flatMap(([key, value]) => - folder(groupName, value), - ), - }; - }); -} - -export interface Group { - title: string; - list: ComponentItem[]; -} - -export interface ComponentItem<Props extends object = {}> { - name: string; - examples: ExampleItem<Props>[]; -} - -export interface ExampleItem<Props extends object = {}> { - group: string; - component: string; - name: string; - render: ExampleItemSetup<Props>; -} - -type ComponentOrFolder = MaybeComponent | MaybeFolder; -interface MaybeFolder { - default?: { title: string }; - // [exampleName: string]: FunctionalComponent; -} -interface MaybeComponent { - // default?: undefined; - [exampleName: string]: undefined | object; -} - -function folder(groupName: string, value: ComponentOrFolder): ComponentItem[] { - let title: string | undefined = undefined; - try { - title = - typeof value === "object" && - typeof value.default === "object" && - value.default !== undefined && - "title" in value.default && - typeof value.default.title === "string" - ? value.default.title - : undefined; - } catch (e) { - throw Error( - `Could not defined if it is component or folder ${groupName}: ${JSON.stringify( - value, - undefined, - 2, - )}`, - ); - } - if (title) { - const c = parseExampleImport(groupName, title, value as MaybeComponent); - return [c]; - } - return Object.entries(value).flatMap(([subkey, value]) => - folder(groupName, value), - ); -} - -interface Props { - getWrapperForGroup: (name: string) => FunctionComponent; - examplesInGroups: Group[]; - langs: Record<string, object>; -} - -function Application({ - langs, - examplesInGroups, - getWrapperForGroup, -}: Props): VNode { - const url = new URL(window.location.href); - const initialSelection = getSelectionFromLocationHash( - url.hash, - examplesInGroups, - ); - - const currentLang = url.searchParams.get("lang") || "en"; - - if (!langs["en"]) { - langs["en"] = {}; - } - setupI18n(currentLang, langs); - - const [selected, updateSelected] = useState<ExampleItem | undefined>( - initialSelection, - ); - const [sidebarWidth, setSidebarWidth] = useState(200); - useEffect(() => { - if (url.hash) { - const hash = url.hash.substring(1); - const found = document.getElementById(hash); - if (found) { - setTimeout(() => { - found.scrollIntoView({ - block: "center", - }); - }, 50); - } - } - }, []); +const TALER_SCREEN_ID = 101; - const GroupWrapper = getWrapperForGroup(selected?.group || "default"); - const ExampleContent = getContentForExample(selected, examplesInGroups); - - //style={{ "--with-size": `${sidebarWidth}px` }} - return ( - <Page> - {/* <LiveReload /> */} - <SideBar width={sidebarWidth}> - <div> - Language: - <select - value={currentLang} - onChange={(e) => { - const url = new URL(window.location.href); - url.searchParams.set("lang", e.currentTarget.value); - window.location.href = url.href; - }} - > - {Object.keys(langs).map((l) => ( - <option key={l}>{l}</option> - ))} - </select> - </div> - {examplesInGroups.map((group) => ( - <ExampleList - key={group.title} - name={group.title} - list={group.list} - selected={selected} - onSelectStory={(item, htmlId) => { - document.getElementById(htmlId)?.scrollIntoView({ - block: "center", - }); - updateSelected(item); - }} - /> - ))} - <hr /> - </SideBar> - {/* <ResizeHandle - onUpdate={(x) => { - setSidebarWidth((s) => s + x); - }} - /> */} - <Content> - <ErrorReport selected={selected}> - <PreventLinkNavigation> - <GroupWrapper> - <ExampleContent /> - </GroupWrapper> - </PreventLinkNavigation> - </ErrorReport> - </Content> - </Page> +function main(): void { + renderStories( + { forms }, + { + strings: {}, + }, ); } -export interface Options { - id?: string; - strings?: any; - getWrapperForGroup?: (name: string) => FunctionComponent; -} - -export function renderStories( - groups: Record<string, ComponentOrFolder>, - options: Options = {}, -): void { - const examples = parseGroupImport(groups); - - try { - const cid = options.id ?? "container"; - const container = document.getElementById(cid); - if (!container) { - throw Error( - `container with id ${cid} not found, can't mount page contents`, - ); - } - render( - <Application - examplesInGroups={examples} - getWrapperForGroup={options.getWrapperForGroup ?? (() => Fragment)} - langs={options.strings ?? { en: {} }} - />, - container, - ); - } catch (e) { - console.error("got error", e); - if (e instanceof Error) { - document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`; - } - } -} - -function ResizeHandle({ onUpdate }: { onUpdate: (x: number) => void }): VNode { - const [start, setStart] = useState<number | undefined>(undefined); - return ( - <ResizeHandleDiv - onMouseDown={(e: any) => { - setStart(e.pageX); - console.log("active", e.pageX); - return false; - }} - onMouseMove={(e: any) => { - if (start !== undefined) { - onUpdate(e.pageX - start); - } - return false; - }} - onMouseUp={() => { - setStart(undefined); - return false; - }} - /> - ); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", main); +} else { + main(); } diff --git a/packages/web-util/src/utils/base64.ts b/packages/web-util/src/utils/base64.ts index 0e075880f..e51591df6 100644 --- a/packages/web-util/src/utils/base64.ts +++ b/packages/web-util/src/utils/base64.ts @@ -14,13 +14,25 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { decodeCrock, encodeCrock } from "@gnu-taler/taler-util"; + +const utf8Encoder = new TextEncoder(); +const utf8Decoder = new TextDecoder("utf-8", { ignoreBOM: true }); + +export function encodeCrockForURI(string: string): string { + return encodeCrock(utf8Encoder.encode(string)); +} + +export function decodeCrockFromURI(enc: string): string { + return utf8Decoder.decode(decodeCrock(enc)); +} export function base64encode(str: string): string { - return base64EncArr(strToUTF8Arr(str)) + return base64EncArr(strToUTF8Arr(str)); } export function base64decode(str: string): string { - return UTF8ArrToStr(base64DecToArr(str)) + return UTF8ArrToStr(base64DecToArr(str)); } // from https://developer.mozilla.org/en-US/docs/Glossary/Base64 @@ -103,7 +115,7 @@ function base64EncArr(aBytes: Uint8Array): string { uint6ToB64((nUint24 >>> 18) & 63), uint6ToB64((nUint24 >>> 12) & 63), uint6ToB64((nUint24 >>> 6) & 63), - uint6ToB64(nUint24 & 63) + uint6ToB64(nUint24 & 63), ); nUint24 = 0; } @@ -114,8 +126,13 @@ function base64EncArr(aBytes: Uint8Array): string { ); } -/* UTF-8 array to JS string and vice versa */ - +/** + * UTF-8 array to JS string and vice versa + * + * @param aBytes + * @deprecated use textEncoder + * @returns + */ function UTF8ArrToStr(aBytes: Uint8Array): string { let sView = ""; let nPart; @@ -125,40 +142,46 @@ function UTF8ArrToStr(aBytes: Uint8Array): string { sView += String.fromCodePoint( nPart > 251 && nPart < 254 && nIdx + 5 < nLen /* six bytes */ ? /* (nPart - 252 << 30) may be not so safe in ECMAScript! So…: */ - (nPart - 252) * 1073741824 + - ((aBytes[++nIdx] - 128) << 24) + - ((aBytes[++nIdx] - 128) << 18) + - ((aBytes[++nIdx] - 128) << 12) + - ((aBytes[++nIdx] - 128) << 6) + - aBytes[++nIdx] - - 128 + (nPart - 252) * 1073741824 + + ((aBytes[++nIdx] - 128) << 24) + + ((aBytes[++nIdx] - 128) << 18) + + ((aBytes[++nIdx] - 128) << 12) + + ((aBytes[++nIdx] - 128) << 6) + + aBytes[++nIdx] - + 128 : nPart > 247 && nPart < 252 && nIdx + 4 < nLen /* five bytes */ ? ((nPart - 248) << 24) + - ((aBytes[++nIdx] - 128) << 18) + - ((aBytes[++nIdx] - 128) << 12) + - ((aBytes[++nIdx] - 128) << 6) + - aBytes[++nIdx] - - 128 - : nPart > 239 && nPart < 248 && nIdx + 3 < nLen /* four bytes */ - ? ((nPart - 240) << 18) + + ((aBytes[++nIdx] - 128) << 18) + ((aBytes[++nIdx] - 128) << 12) + ((aBytes[++nIdx] - 128) << 6) + aBytes[++nIdx] - 128 - : nPart > 223 && nPart < 240 && nIdx + 2 < nLen /* three bytes */ - ? ((nPart - 224) << 12) + + : nPart > 239 && nPart < 248 && nIdx + 3 < nLen /* four bytes */ + ? ((nPart - 240) << 18) + + ((aBytes[++nIdx] - 128) << 12) + ((aBytes[++nIdx] - 128) << 6) + aBytes[++nIdx] - 128 + : nPart > 223 && nPart < 240 && nIdx + 2 < nLen /* three bytes */ + ? ((nPart - 224) << 12) + + ((aBytes[++nIdx] - 128) << 6) + + aBytes[++nIdx] - + 128 : nPart > 191 && nPart < 224 && nIdx + 1 < nLen /* two bytes */ ? ((nPart - 192) << 6) + aBytes[++nIdx] - 128 : /* nPart < 127 ? */ /* one byte */ - nPart + nPart, ); } return sView; } +/** + * + * @param sDOMStr + * @deprecated use textEncoder + * @returns + */ function strToUTF8Arr(sDOMStr: string): Uint8Array { let nChr; const nStrLen = sDOMStr.length; @@ -168,7 +191,9 @@ function strToUTF8Arr(sDOMStr: string): Uint8Array { for (let nMapIdx = 0; nMapIdx < nStrLen; nMapIdx++) { nChr = sDOMStr.codePointAt(nMapIdx); if (nChr === undefined) { - throw Error(`No char at ${nMapIdx} on string with length: ${sDOMStr.length}`) + throw Error( + `No char at ${nMapIdx} on string with length: ${sDOMStr.length}`, + ); } if (nChr >= 0x10000) { @@ -197,7 +222,9 @@ function strToUTF8Arr(sDOMStr: string): Uint8Array { while (nIdx < nArrLen) { nChr = sDOMStr.codePointAt(nChrIdx); if (nChr === undefined) { - throw Error(`No char at ${nChrIdx} on string with length: ${sDOMStr.length}`) + throw Error( + `No char at ${nChrIdx} on string with length: ${sDOMStr.length}`, + ); } if (nChr < 128) { /* one byte */ diff --git a/packages/web-util/src/utils/http-impl.sw.ts b/packages/web-util/src/utils/http-impl.sw.ts index 9c820bb4b..2f7f24fd6 100644 --- a/packages/web-util/src/utils/http-impl.sw.ts +++ b/packages/web-util/src/utils/http-impl.sw.ts @@ -21,7 +21,7 @@ import { Duration, RequestThrottler, TalerError, - TalerErrorCode + TalerErrorCode, } from "@gnu-taler/taler-util"; import { @@ -85,7 +85,9 @@ export class BrowserFetchHttpLib implements HttpRequestLibrary { } const myBody: ArrayBuffer | undefined = - requestMethod === "POST" || requestMethod === "PUT" || requestMethod === "PATCH" + requestMethod === "POST" || + requestMethod === "PUT" || + requestMethod === "PATCH" ? encodeBody(requestBody) : undefined; @@ -93,8 +95,19 @@ export class BrowserFetchHttpLib implements HttpRequestLibrary { if (requestHeader) { Object.entries(requestHeader).forEach(([key, value]) => { if (value === undefined) return; - requestHeadersMap[key] = value - }) + requestHeadersMap[key] = value; + }); + } + + /** + * default header assume everything is json + * in case of formData the content-type will be + * auto generated + */ + if (requestBody instanceof FormData) { + delete requestHeadersMap["Content-Type"] + } else if (requestBody instanceof URLSearchParams) { + requestHeadersMap["Content-Type"] = "application/x-www-form-urlencoded" } const controller = new AbortController(); @@ -106,7 +119,7 @@ export class BrowserFetchHttpLib implements HttpRequestLibrary { } if (requestCancel) { requestCancel.onCancelled(() => { - controller.abort(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR) + controller.abort(TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR); }); } @@ -116,7 +129,7 @@ export class BrowserFetchHttpLib implements HttpRequestLibrary { body: myBody, method: requestMethod, signal: controller.signal, - redirect: requestRedirect + redirect: requestRedirect, }); if (timeoutId) { @@ -127,13 +140,15 @@ export class BrowserFetchHttpLib implements HttpRequestLibrary { response.headers.forEach((value, key) => { headerMap.set(key, value); }); + const text = makeTextHandler(response, requestUrl, requestMethod); + const json = makeJsonHandler(response, requestUrl, requestMethod, text); return { headers: headerMap, status: response.status, requestMethod, requestUrl, - json: makeJsonHandler(response, requestUrl, requestMethod), - text: makeTextHandler(response, requestUrl, requestMethod), + json, + text, bytes: async () => (await response.blob()).arrayBuffer(), }; } catch (e) { @@ -143,7 +158,8 @@ export class BrowserFetchHttpLib implements HttpRequestLibrary { { requestUrl, requestMethod, - timeoutMs: requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms + timeoutMs: + requestTimeout.d_ms === "forever" ? 0 : requestTimeout.d_ms, }, `HTTP request failed.`, ); @@ -151,7 +167,6 @@ export class BrowserFetchHttpLib implements HttpRequestLibrary { throw e; } } - } function makeTextHandler( @@ -159,20 +174,29 @@ function makeTextHandler( requestUrl: string, requestMethod: string, ) { - return async function getTextFromResponse(): Promise<any> { - let respText; - try { - respText = await response.text(); - } catch (e) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl, - requestMethod, - httpStatusCode: response.status, - }, - "Invalid text from HTTP response", - ); + let firstTime = true; + let respText: string; + let error: TalerError | undefined; + return async function getTextFromResponse(): Promise<string> { + if (firstTime) { + firstTime = false; + try { + respText = await response.text(); + } catch (e) { + error = TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl, + requestMethod, + httpStatusCode: response.status, + validationError: e instanceof Error ? e.message : String(e), + }, + "Invalid text from HTTP response", + ); + } + } + if (error !== undefined) { + throw error; } return respText; }; @@ -182,35 +206,70 @@ function makeJsonHandler( response: Response, requestUrl: string, requestMethod: string, + readTextHandler: () => Promise<string>, ) { - let responseJson: unknown = undefined; + let firstTime = true; + let responseJson: string | undefined = undefined; + let error: TalerError | undefined; return async function getJsonFromResponse(): Promise<any> { - if (responseJson === undefined) { + if (firstTime) { + let responseText: string; try { - responseJson = await response.json(); + responseText = await readTextHandler(); } catch (e) { - const message = e instanceof Error ? `Invalid JSON from HTTP response: ${e.message}` : "Invalid JSON from HTTP response" - throw TalerError.fromDetail( + const message = + e instanceof Error + ? `Couldn't read HTTP response: ${e.message}` + : "Couldn't read HTTP response"; + error = TalerError.fromDetail( TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, { requestUrl, requestMethod, httpStatusCode: response.status, + validationError: e instanceof Error ? e.message : String(e), }, message, ); } + if (!error) { + try { + // @ts-expect-error no error then text is initialized + responseJson = JSON.parse(responseText); + } catch (e) { + const message = + e instanceof Error + ? `Invalid JSON from HTTP response: ${e.message}` + : "Invalid JSON from HTTP response"; + error = TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl, + requestMethod, + // @ts-expect-error no error then text is initialized + response: responseText, + httpStatusCode: response.status, + validationError: e instanceof Error ? e.message : String(e), + }, + message, + ); + } + if (responseJson === null || typeof responseJson !== "object") { + error = TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl, + requestMethod, + response: JSON.stringify(responseJson), + httpStatusCode: response.status, + }, + "Invalid JSON from HTTP response: null or not object", + ); + } + } } - if (responseJson === null || typeof responseJson !== "object") { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl, - requestMethod, - httpStatusCode: response.status, - }, - "Invalid JSON from HTTP response: null or not object", - ); + if (error !== undefined) { + throw error; } return responseJson; }; diff --git a/packages/web-util/src/utils/request.ts b/packages/web-util/src/utils/request.ts index 944e65945..0c11c8c8a 100644 --- a/packages/web-util/src/utils/request.ts +++ b/packages/web-util/src/utils/request.ts @@ -28,8 +28,6 @@ export enum ErrorType { UNEXPECTED, } - - /** * * @param baseUrl URL where the service is located @@ -53,7 +51,9 @@ export async function defaultRequestHandler<T>( } requestHeaders["Content-Type"] = - !options.contentType || options.contentType === "json" ? "application/json" : "text/plain"; + !options.contentType || options.contentType === "json" + ? "application/json" + : "text/plain"; if (options.talerAmlOfficerSignature) { requestHeaders["Taler-AML-Officer-Signature"] = @@ -83,7 +83,7 @@ export async function defaultRequestHandler<T>( loading: false, message: `invalid URL: "${baseUrl}${endpoint}"`, }; - throw new RequestError(error) + throw new RequestError(error); } Object.entries(requestParams).forEach(([key, value]) => { @@ -114,7 +114,7 @@ export async function defaultRequestHandler<T>( loading: false, message: `unsupported request body type: "${typeof requestBody}"`, }; - throw new RequestError(error) + throw new RequestError(error); } } @@ -159,7 +159,7 @@ export async function defaultRequestHandler<T>( type: ErrorType.UNEXPECTED, exception: ex, loading: false, - message: (ex instanceof Error ? ex.message : ""), + message: ex instanceof Error ? ex.message : "", }; throw new RequestError(error); } @@ -470,9 +470,8 @@ export function buildRequestFailed<ErrorDetail>( */ function validateURL(baseUrl: string, endpoint: string): URL | undefined { try { - return new URL(`${baseUrl}${endpoint}`) + return new URL(`${baseUrl}${endpoint}`); } catch (ex) { - return undefined + return undefined; } - -}
\ No newline at end of file +} diff --git a/packages/web-util/src/utils/route.ts b/packages/web-util/src/utils/route.ts index 494a61efa..fbbbfebd1 100644 --- a/packages/web-util/src/utils/route.ts +++ b/packages/web-util/src/utils/route.ts @@ -25,7 +25,13 @@ export type AppLocation = string & { }; export type EmptyObject = Record<string, never>; - +/** + * FIXME: receive parameters + * maybe return URL for reverse function instead of string + * @param pattern + * @param reverse + * @returns + */ export function urlPattern< T extends Record<string, string | undefined> = EmptyObject, >(pattern: RegExp, reverse: (p: T) => string): RouteDefinition<T> { @@ -75,7 +81,7 @@ export function findMatch<T extends ObjectOf<RouteDefinition>>( pageList: Array<keyof T>, path: string, params: Record<string, string[]>, -): Location<T> | undefined { +): Location<T> | LocationNotFound<T> { for (let idx = 0; idx < pageList.length; idx++) { const name = pageList[idx]; const found = pagesMap[name].pattern.exec(path); @@ -92,7 +98,8 @@ export function findMatch<T extends ObjectOf<RouteDefinition>>( return { name, parent: pagesMap, values, params }; } } - return undefined; + // @ts-expect-error values is a map string which is equivalent to the RouteParamsType + return { name: undefined, parent: pagesMap, values: {}, params }; } /** @@ -109,13 +116,13 @@ type RouteParamsType< */ type MapKeyValue<Type> = { [Key in keyof Type]: Key extends string - ? { - parent: Type; - name: Key; - values: RouteParamsType<Type, Key>; - params: Record<string, string[]>; - } - : never; + ? { + parent: Type; + name: Key; + values: RouteParamsType<Type, Key>; + params: Record<string, string[]>; + } + : never; }; /** @@ -124,3 +131,9 @@ type MapKeyValue<Type> = { type EnumerationOf<T> = T[keyof T]; export type Location<T> = EnumerationOf<MapKeyValue<T>>; +export type LocationNotFound<Type> = { + parent: Type; + name: undefined; + values: RouteParamsType<Type, keyof Type>; + params: Record<string, string[]>; +}; |