commit 740866142fed3a3af0a7db535eb85bc49098a4e3 parent 7fb9bb8fa0ebbc05411a0fbbc799ce4e1bf0e4a7 Author: Sebastian <sebasjm@gmail.com> Date: Tue, 18 May 2021 15:13:21 -0300 async button and other things Diffstat:
30 files changed, 354 insertions(+), 175 deletions(-)
diff --git a/packages/frontend/src/components/exception/AsyncButton.tsx b/packages/frontend/src/components/exception/AsyncButton.tsx @@ -0,0 +1,25 @@ +import { ComponentChildren, h } from "preact"; +import { LoadingModal } from "../modal"; +import { useAsync } from "../../hooks/async"; +import { Translate } from "../../i18n"; + +type Props = { + children: ComponentChildren, + disabled: boolean; + onClick?: () => Promise<void>; +}; + +export function AsyncButton({ onClick, disabled, children }: Props) { + const { isSlow, isLoading, request, cancel } = useAsync(onClick); + + if (isSlow) { + return <LoadingModal onCancel={cancel} />; + } + if (isLoading) { + return <button class="button"><Translate>Loading...</Translate></button>; + } + + return <button class="button is-success" onClick={request} disabled={disabled}> + {children} + </button>; +} diff --git a/packages/frontend/src/components/form/FormProvider.tsx b/packages/frontend/src/components/form/FormProvider.tsx @@ -35,8 +35,8 @@ function noop() { } export function FormProvider<T>({ object = {}, errors = {}, name = '', valueHandler, children }: Props<T>): VNode { - const initial = useMemo(() => object, []); - const value = useMemo<FormType<T>>(() => ({ errors, object, initial, valueHandler: valueHandler ? valueHandler : noop, name, toStr: {}, fromStr: {} }), [errors, object, valueHandler]); + const initialObject = useMemo(() => object, []); + const value = useMemo<FormType<T>>(() => ({ errors, object, initialObject, valueHandler: valueHandler ? valueHandler : noop, name, toStr: {}, fromStr: {} }), [errors, object, valueHandler]); return <FormContext.Provider value={value}> <form class="field" onSubmit={(e) => { @@ -50,7 +50,7 @@ export function FormProvider<T>({ object = {}, errors = {}, name = '', valueHand export interface FormType<T> { object: Partial<T>; - initial: Partial<T>; + initialObject: Partial<T>; errors: FormErrors<T>; toStr: FormtoStr<T>; name: string; diff --git a/packages/frontend/src/components/form/Input.tsx b/packages/frontend/src/components/form/Input.tsx @@ -40,7 +40,6 @@ const TextInput = ({ inputType, error, ...rest }: any) => inputType === 'multili export function Input<T>({ name, readonly, placeholder, tooltip, label, expand, help, children, inputType, inputExtra, side, fromStr = defaultFromString, toStr = defaultToString }: Props<keyof T>): VNode { const { error, value, onChange } = useField<T>(name); - return <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label"> diff --git a/packages/frontend/src/components/form/InputSelector.tsx b/packages/frontend/src/components/form/InputSelector.tsx @@ -35,7 +35,6 @@ const defaultFromString = (v: string): any => v as any export function InputSelector<T>({ name, readonly, expand, placeholder, tooltip, label, help, values, fromStr = defaultFromString, toStr = defaultToString }: Props<keyof T>): VNode { const { error, value, onChange } = useField<T>(name); - return <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label"> diff --git a/packages/frontend/src/components/form/useField.tsx b/packages/frontend/src/components/form/useField.tsx @@ -25,7 +25,6 @@ import { useFormContext } from "./FormProvider"; interface Use<V> { error?: string; value: any; - formName: string; initial: any; onChange: (v: V) => void; toStr: (f: V | undefined) => string; @@ -33,7 +32,7 @@ interface Use<V> { } export function useField<T>(name: keyof T): Use<T[typeof name]> { - const { errors, object, initial, name: formName, toStr, fromStr, valueHandler } = useFormContext<T>() + const { errors, object, initialObject, toStr, fromStr, valueHandler } = useFormContext<T>() type P = typeof name type V = T[P] @@ -45,19 +44,21 @@ export function useField<T>(name: keyof T): Use<T[typeof name]> { const defaultToString = ((f?: V):string => String(!f ? '': f)) const defaultFromString = ((v: string):V => v as any) - + const value = readField(object, String(name)) + const initial = readField(initialObject, String(name)) + const isDirty = value !== initial + return { - error: errors[name] as any, - value: readField(object, String(name)), - formName, - initial: initial[name], + error: isDirty ? readField(errors, String(name)) : undefined, + value, + initial, onChange: updateField(name) as any, toStr: toStr[name] ? toStr[name]! : defaultToString, fromStr: fromStr[name] ? fromStr[name]! : defaultFromString, } } /** - * read the field of an object an support accesing it using '.' + * read the field of an object an support accessing it using '.' * * @param object * @param name diff --git a/packages/frontend/src/components/modal/index.tsx b/packages/frontend/src/components/modal/index.tsx @@ -23,6 +23,7 @@ import { ComponentChildren, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { Translate, useTranslator } from "../../i18n"; +import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants"; import { FormProvider } from "../form/FormProvider"; import { Input } from "../form/Input"; @@ -150,3 +151,25 @@ export function UpdateTokenModal({ element, onCancel, onClear, onConfirm, oldTok } +export function LoadingModal({ onCancel }: { onCancel: () => void}): VNode { + const i18n = useTranslator() + return <div class={"modal is-active"}> + <div class="modal-background " onClick={onCancel} /> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"><Translate>Operation is taking to much time</Translate></p> + </header> + <section class="modal-card-body"> + <p><Translate>You can wait a little longer or abort the request to the backend. If the problem persist + contact the administrator.</Translate></p> + <p>{i18n`The operation will be automatically canceled after ${DEFAULT_REQUEST_TIMEOUT} seconds`}</p> + </section> + <footer class="modal-card-foot"> + <div class="buttons is-right" style={{ width: '100%' }}> + <button class="button " onClick={onCancel} ><Translate>Abort</Translate></button> + </div> + </footer> + </div> + <button class="modal-close is-large " aria-label="close" onClick={onCancel} /> + </div> +} diff --git a/packages/frontend/src/components/product/ProductForm.tsx b/packages/frontend/src/components/product/ProductForm.tsx @@ -39,7 +39,7 @@ import { InputWithAddon } from "../form/InputWithAddon"; type Entity = MerchantBackend.Products.ProductDetail & { product_id: string } interface Props { - onSubscribe: (c: () => Entity | undefined) => void; + onSubscribe: (c?: () => Entity | undefined) => void; initial?: Partial<Entity>; alreadyExist?: boolean; } @@ -59,33 +59,35 @@ export function ProductForm({ onSubscribe, initial, alreadyExist, }: Props) { nextRestock: initial.next_restock, } }) - const [errors, setErrors] = useState<FormErrors<Entity>>({}) + let errors : FormErrors<Entity>= {} + + try { + (alreadyExist ? updateSchema : createSchema).validateSync(value, { abortEarly: false }) + } catch (err) { + const yupErrors = err.inner as yup.ValidationError[] + errors = yupErrors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: cur.message }), {}) + } + const hasErrors = Object.keys(errors).some(k => (errors as any)[k] !== undefined) const submit = useCallback((): Entity | undefined => { - try { - (alreadyExist ? updateSchema : createSchema).validateSync(value, { abortEarly: false }) - const stock: Stock = (value as any).stock; - delete (value as any).stock; - - if (!stock) { - value.total_stock = -1 - } else { - value.total_stock = stock.current; - value.total_lost = stock.lost; - value.next_restock = stock.nextRestock instanceof Date ? { t_ms: stock.nextRestock.getTime() } : stock.nextRestock; - value.address = stock.address; - } - return value as MerchantBackend.Products.ProductDetail & { product_id: string } - } catch (err) { - const errors = err.inner as yup.ValidationError[] - const pathMessages = errors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: cur.message }), {}) - setErrors(pathMessages) + const stock: Stock = (value as any).stock; + + if (!stock) { + value.total_stock = -1 + } else { + value.total_stock = stock.current; + value.total_lost = stock.lost; + value.next_restock = stock.nextRestock instanceof Date ? { t_ms: stock.nextRestock.getTime() } : stock.nextRestock; + value.address = stock.address; } + delete (value as any).stock; + + return value as MerchantBackend.Products.ProductDetail & { product_id: string } }, [value]) useEffect(() => { - onSubscribe(submit) - }, [submit]) + onSubscribe(hasErrors ? undefined : submit) + }, [submit, hasErrors]) const backend = useBackendContext(); const i18n = useTranslator() diff --git a/packages/frontend/src/hooks/async.ts b/packages/frontend/src/hooks/async.ts @@ -0,0 +1,76 @@ +/* + This file is part of GNU Taler + (C) 2021 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 { useState } from "preact/hooks"; +import { cancelPendingRequest } from "./backend"; + +export interface Options { + slowTolerance: number, +} + +export interface AsyncOperationApi<T> { + request: (...a: any) => void, + cancel: () => void, + data: T | undefined, + isSlow: boolean, + isLoading: boolean, + error: string | undefined +} + +export function useAsync<T>(fn?: (...args: any) => Promise<T>, { slowTolerance: tooLong }: Options = { slowTolerance: 1000 }): AsyncOperationApi<T> { + const [data, setData] = useState<T | undefined>(undefined); + const [isLoading, setLoading] = useState<boolean>(false); + const [error, setError] = useState<any>(undefined); + const [isSlow, setSlow] = useState(false) + + const request = async (...args: any) => { + if (!fn) return; + setLoading(true); + + const handler = setTimeout(() => { + setSlow(true) + }, tooLong) + + try { + const result = await fn(...args); + setData(result); + } catch (error) { + setError(error); + } + setLoading(false); + setSlow(false) + clearTimeout(handler) + }; + + function cancel() { + cancelPendingRequest() + setLoading(false); + setSlow(false) + } + + return { + request, + cancel, + data, + isSlow, + isLoading, + error + }; +}; diff --git a/packages/frontend/src/hooks/backend.ts b/packages/frontend/src/hooks/backend.ts @@ -24,6 +24,7 @@ import axios, { AxiosError, AxiosResponse } from 'axios' import { MerchantBackend } from '../declaration'; import { useBackendContext } from '../context/backend'; import { useEffect, useState } from 'preact/hooks'; +import { DEFAULT_REQUEST_TIMEOUT } from '../utils/constants'; export function mutateAll(re: RegExp, value?: unknown): Array<Promise<any>> { return cache.keys().filter(key => { @@ -183,18 +184,27 @@ function buildRequestFailed(ex: AxiosError<MerchantBackend.ErrorDetail>, url: st } +const CancelToken = axios.CancelToken; +let source = CancelToken.source(); + +export function cancelPendingRequest() { + source.cancel('canceled by the user') + source = CancelToken.source() +} + export async function request<T>(url: string, options: RequestOptions = {}): Promise<HttpResponseOk<T>> { const headers = options.token ? { Authorization: `Bearer ${options.token}` } : undefined try { - // console.log(options.method || 'get', url, options.data, options.params) const res = await axios({ url, responseType: 'json', headers, + cancelToken: source.token, method: options.method || 'get', data: options.data, - params: options.params + params: options.params, + timeout: DEFAULT_REQUEST_TIMEOUT * 1000, }) return buildRequestOk<T>(res, url, !!options.token) } catch (e) { diff --git a/packages/frontend/src/hooks/index.ts b/packages/frontend/src/hooks/index.ts @@ -59,9 +59,9 @@ export function useBackendInstanceToken(id: string): [string | undefined, StateU return [token, setToken] } -export function useLang(initial?:string): [string, StateUpdater<string>] { +export function useLang(initial?: string): [string, StateUpdater<string>] { const browserLang = typeof window !== "undefined" ? navigator.language || (navigator as any).userLanguage : undefined; - const defaultLang = (browserLang || initial || 'en').substring(0,2) + const defaultLang = (browserLang || initial || 'en').substring(0, 2) return useNotNullLocalStorage('lang-preference', defaultLang) } @@ -107,19 +107,42 @@ export function useNotNullLocalStorage(key: string, initialValue: string): [stri return [storedValue, setValue]; } -// eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => {} - -export function useListener<T>(onCall: (r: T) => void): [() => void, (listener: () => T) => void] { - const [state, setState] = useState({ run: noop }) - - const subscriber = (listener: () => T) => { - setState({ - run: () => onCall(listener()) - }) +/** + * returns subscriber and activator + * subscriber will receive a method (listener) that will be call when the activator runs. + * the result of calling the listener will be sent to @action + * + * @param action from <T> to <R> + * @returns activator and subscriber, undefined activator means that there is not subscriber + */ +export function useListener<T, R = any>(action: (r: T) => Promise<R>): [undefined | (() => Promise<R>), (listener?: () => T) => void] { + const [state, setState] = useState<{ toBeRan?: () => Promise<R> }>({}) + + /** + * subscriber will receive a method that will be call when the activator runs + * + * @param listener function to be run when the activator runs + */ + const subscriber = (listener?: () => T) => { + if (listener) { + setState({ + toBeRan: () => { + const whatWeGetFromTheListener = listener() + return action(whatWeGetFromTheListener) + } + }) + } } - const activator = () => state.run() + /** + * activator will call runner if there is someone subscribed + */ + const activator = state.toBeRan ? async () => { + if (state.toBeRan) { + return state.toBeRan() + } + return Promise.reject() + } : undefined return [activator, subscriber] } diff --git a/packages/frontend/src/hooks/transfer.ts b/packages/frontend/src/hooks/transfer.ts @@ -125,12 +125,10 @@ export function useInstanceTransfers(args?: InstanceTransferFilter, updatePositi setPageAfter(pageAfter + 1) } else { const from = ""+afterData.data.transfers[afterData.data.transfers.length - 1].transfer_serial_id - console.log('load more', from) if (from && updatePosition) updatePosition(from) } }, loadMorePrev: () => { - console.log('load more prev') if (!beforeData) return if (beforeData.data.transfers.length < MAX_RESULT_SIZE) { setPageBefore(pageBefore + 1) diff --git a/packages/frontend/src/i18n/index.tsx b/packages/frontend/src/i18n/index.tsx @@ -70,7 +70,6 @@ function stringifyChildren(children: ComponentChildren): string { return `%${n++}$s`; }); const s = ss.join("").replace(/ +/g, " ").trim(); - // console.log("translation lookup", JSON.stringify(s)); return s; } diff --git a/packages/frontend/src/paths/admin/create/CreatePage.tsx b/packages/frontend/src/paths/admin/create/CreatePage.tsx @@ -22,6 +22,7 @@ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import * as yup from 'yup'; +import { AsyncButton } from "../../../components/exception/AsyncButton"; import { FormErrors, FormProvider } from "../../../components/form/FormProvider"; import { Input } from "../../../components/form/Input"; import { InputCurrency } from "../../../components/form/InputCurrency"; @@ -39,7 +40,7 @@ import { InstanceCreateSchema as schema } from '../../../schemas'; type Entity = MerchantBackend.Instances.InstanceConfigurationMessage & { auth_token?: string } interface Props { - onCreate: (d: Entity) => void; + onCreate: (d: Entity) => Promise<void>; onBack?: () => void; forceId?: string; } @@ -55,22 +56,25 @@ function with_defaults(id?: string): Partial<Entity> { export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { const [value, valueHandler] = useState(with_defaults(forceId)) - const [errors, setErrors] = useState<FormErrors<Entity>>({}) - - const submit = (): void => { - try { - // use conversion instead of this - const newToken = value.auth_token; - value.auth_token = undefined; - value.auth = newToken === null || newToken === undefined ? { method: "external" } : { method: "token", token: `secret-token:${newToken}` }; - // remove above use conversion - schema.validateSync(value, { abortEarly: false }) - onCreate(schema.cast(value) as Entity); - } catch (err) { - const errors = err.inner as yup.ValidationError[] - const pathMessages = errors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: cur.message }), {}) - setErrors(pathMessages) - } + // const [errors, setErrors] = useState<FormErrors<Entity>>({}) + + let errors: FormErrors<Entity> = {} + try { + schema.validateSync(value, { abortEarly: false }) + } catch (err) { + const yupErrors = err.inner as yup.ValidationError[] + errors = yupErrors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: cur.message }), {}) + } + const hasErrors = Object.keys(errors).some(k => (errors as any)[k] !== undefined) + + const submit = (): Promise<void> => { + // use conversion instead of this + const newToken = value.auth_token; + value.auth_token = undefined; + value.auth = newToken === null || newToken === undefined ? { method: "external" } : { method: "token", token: `secret-token:${newToken}` }; + // remove above use conversion + // schema.validateSync(value, { abortEarly: false }) + return onCreate(schema.cast(value) as Entity); } const backend = useBackendContext() const i18n = useTranslator() @@ -112,7 +116,7 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { <div class="buttons is-right mt-5"> {onBack && <button class="button" onClick={onBack} ><Translate>Cancel</Translate></button>} - <button class="button is-success" onClick={submit} ><Translate>Confirm</Translate></button> + <AsyncButton onClick={submit} disabled={hasErrors} ><Translate>Confirm</Translate></AsyncButton> </div> </div> diff --git a/packages/frontend/src/paths/admin/create/index.tsx b/packages/frontend/src/paths/admin/create/index.tsx @@ -51,7 +51,7 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { onBack={onBack} forceId={forceId} onCreate={(d: MerchantBackend.Instances.InstanceConfigurationMessage) => { - createInstance(d).then(() => { + return createInstance(d).then(() => { setCreatedOk(d) }).catch((error) => { setNotif({ diff --git a/packages/frontend/src/paths/instance/orders/create/CreatePage.tsx b/packages/frontend/src/paths/instance/orders/create/CreatePage.tsx @@ -145,8 +145,8 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { }) } - const addNewProduct = (product: MerchantBackend.Product) => { - valueHandler(v => { + const addNewProduct = async (product: MerchantBackend.Product) => { + return valueHandler(v => { const products = [...v.products, product] return ({ ...v, products }) }) @@ -256,7 +256,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { } tooltip={i18n`add products to the order`}> <NonInventoryProductFrom value={editingProduct} onAddProduct={(p) => { setEditingProduct(undefined) - addNewProduct(p) + return addNewProduct(p) }} /> {productList.length > 0 && diff --git a/packages/frontend/src/paths/instance/orders/create/NonInventoryProductForm.tsx b/packages/frontend/src/paths/instance/orders/create/NonInventoryProductForm.tsx @@ -33,7 +33,7 @@ import { useTranslator } from "../../../../i18n"; type Entity = MerchantBackend.Product interface Props { - onAddProduct: (p: Entity) => void; + onAddProduct: (p: Entity) => Promise<void>; value?: Entity; } export function NonInventoryProductFrom({ value, onAddProduct }: Props): VNode { @@ -48,7 +48,7 @@ export function NonInventoryProductFrom({ value, onAddProduct }: Props): VNode { const [submitForm, addFormSubmitter] = useListener<Partial<MerchantBackend.Product> | undefined>((result) => { if (result) { setShowCreateProduct(false) - onAddProduct({ + return onAddProduct({ quantity: result.quantity || 0, taxes: result.taxes || [], description: result.description || '', @@ -57,6 +57,7 @@ export function NonInventoryProductFrom({ value, onAddProduct }: Props): VNode { unit: result.unit || '' }) } + return Promise.reject() }) return <Fragment> diff --git a/packages/frontend/src/paths/instance/orders/details/DetailPage.tsx b/packages/frontend/src/paths/instance/orders/details/DetailPage.tsx @@ -408,7 +408,7 @@ function UnpaidPage({ id, order }: { id: string; order: MerchantBackend.Orders.C </div> } -export function DetailPage({ id, selected, onRefund }: Props): VNode { +export function DetailPage({ id, selected, onRefund, onBack }: Props): VNode { const [showRefund, setShowRefund] = useState<string | undefined>(undefined) const DetailByStatus = function () { @@ -421,15 +421,6 @@ export function DetailPage({ id, selected, onRefund }: Props): VNode { } return <Fragment> - <NotificationCard notification={{ - message: 'DEMO WARNING', - type: 'WARN', - description: <ul> - <li>wired event is faked</li> - <li>fee value is fake, is not being calculated</li> - </ul> - }} /> - {DetailByStatus()} {showRefund && <RefundModal id={id} @@ -439,6 +430,16 @@ export function DetailPage({ id, selected, onRefund }: Props): VNode { setShowRefund(undefined) }} />} + <div class="columns"> + <div class="column" /> + <div class="column is-two-thirds"> + <div class="buttons is-right mt-5"> + <button class="button" onClick={onBack}><Translate>Back</Translate></button> + </div> + </div> + <div class="column" /> + </div> + </Fragment> } diff --git a/packages/frontend/src/paths/instance/orders/list/index.tsx b/packages/frontend/src/paths/instance/orders/list/index.tsx @@ -41,7 +41,7 @@ interface Props { export default function ({ onUnauthorized, onLoadError, onCreate, onSelect, onNotFound }: Props): VNode { - const [filter, setFilter] = useState<InstanceOrderFilter>({ paid: 'yes' }) + const [filter, setFilter] = useState<InstanceOrderFilter>({ }) const [pickDate, setPickDate] = useState(false) const setNewDate = (date: Date) => setFilter(prev => ({ ...prev, date })) @@ -104,10 +104,10 @@ export default function ({ onUnauthorized, onLoadError, onCreate, onSelect, onNo <div class="column"> <div class="tabs"> <ul> + <li class={isAllActive}><a onClick={() => setFilter({})}><Translate>All</Translate></a></li> <li class={isPaidActive}><a onClick={() => setFilter({ paid: 'yes' })}><Translate>Paid</Translate></a></li> <li class={isRefundedActive}><a onClick={() => setFilter({ refunded: 'yes' })}><Translate>Refunded</Translate></a></li> <li class={isNotWiredActive}><a onClick={() => setFilter({ wired: 'no' })}><Translate>Not wired</Translate></a></li> - <li class={isAllActive}><a onClick={() => setFilter({})}><Translate>All</Translate></a></li> </ul> </div> </div> diff --git a/packages/frontend/src/paths/instance/products/create/CreatePage.tsx b/packages/frontend/src/paths/instance/products/create/CreatePage.tsx @@ -20,6 +20,7 @@ */ import { h, VNode } from "preact"; +import { AsyncButton } from "../../../../components/exception/AsyncButton"; import { ProductForm } from "../../../../components/product/ProductForm"; import { MerchantBackend } from "../../../../declaration"; import { useListener } from "../../../../hooks"; @@ -28,7 +29,7 @@ import { Translate } from "../../../../i18n"; type Entity = MerchantBackend.Products.ProductAddDetail & { product_id: string} interface Props { - onCreate: (d: Entity) => void; + onCreate: (d: Entity) => Promise<void>; onBack?: () => void; } @@ -36,7 +37,8 @@ interface Props { export function CreatePage({ onCreate, onBack }: Props): VNode { const [submitForm, addFormSubmitter] = useListener<Entity | undefined>((result) => { - if (result) onCreate(result) + if (result) return onCreate(result) + return Promise.reject() }) return <div> @@ -48,7 +50,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { <div class="buttons is-right mt-5"> {onBack && <button class="button" onClick={onBack} ><Translate>Cancel</Translate></button>} - <button class="button is-success" onClick={submitForm} ><Translate>Confirm</Translate></button> + <AsyncButton onClick={submitForm} disabled={!submitForm}><Translate>Confirm</Translate></AsyncButton> </div> </div> diff --git a/packages/frontend/src/paths/instance/products/create/index.tsx b/packages/frontend/src/paths/instance/products/create/index.tsx @@ -43,7 +43,7 @@ export default function CreateProduct({ onConfirm, onBack }: Props): VNode { <CreatePage onBack={onBack} onCreate={(request: MerchantBackend.Products.ProductAddDetail) => { - createProduct(request).then(() => onConfirm()).catch((error) => { + return createProduct(request).then(() => onConfirm()).catch((error) => { setNotif({ message: i18n`could not create product`, type: "ERROR", diff --git a/packages/frontend/src/paths/instance/products/update/UpdatePage.tsx b/packages/frontend/src/paths/instance/products/update/UpdatePage.tsx @@ -20,22 +20,24 @@ */ import { h, VNode } from "preact"; +import { AsyncButton } from "../../../../components/exception/AsyncButton"; import { ProductForm } from "../../../../components/product/ProductForm"; import { MerchantBackend, WithId } from "../../../../declaration"; import { useListener } from "../../../../hooks"; import { Translate } from "../../../../i18n"; -type Entity = MerchantBackend.Products.ProductDetail & WithId +type Entity = MerchantBackend.Products.ProductDetail & { product_id: string} interface Props { - onUpdate: (d: Entity) => void; + onUpdate: (d: Entity) => Promise<void>; onBack?: () => void; product: Entity; } export function UpdatePage({ product, onUpdate, onBack }: Props): VNode { const [submitForm, addFormSubmitter] = useListener<Entity | undefined>((result) => { - if (result) onUpdate(result) + if (result) return onUpdate(result) + return Promise.resolve() }) return <div> @@ -43,16 +45,11 @@ export function UpdatePage({ product, onUpdate, onBack }: Props): VNode { <div class="columns"> <div class="column" /> <div class="column is-two-thirds"> - <ProductForm initial={product} onSubscribe={(a) => { - addFormSubmitter(() => { - const p = a() - return p as any - }) - }} alreadyExist /> + <ProductForm initial={product} onSubscribe={addFormSubmitter} alreadyExist /> <div class="buttons is-right mt-5"> {onBack && <button class="button" onClick={onBack} ><Translate>Cancel</Translate></button>} - <button class="button is-success" onClick={submitForm} ><Translate>Confirm</Translate></button> + <AsyncButton onClick={submitForm} disabled={!submitForm}><Translate>Confirm</Translate></AsyncButton> </div> </div> diff --git a/packages/frontend/src/paths/instance/products/update/index.tsx b/packages/frontend/src/paths/instance/products/update/index.tsx @@ -54,10 +54,10 @@ export default function UpdateProduct({ pid, onConfirm, onBack, onUnauthorized, return <Fragment> <NotificationCard notification={notif} /> <UpdatePage - product={{ ...result.data, id: pid }} + product={{ ...result.data, product_id: pid }} onBack={onBack} onUpdate={(data) => { - updateProduct(pid, data) + return updateProduct(pid, data) .then(onConfirm) .catch((error) => { setNotif({ diff --git a/packages/frontend/src/paths/instance/reserves/create/CreatePage.tsx b/packages/frontend/src/paths/instance/reserves/create/CreatePage.tsx @@ -24,37 +24,33 @@ import { useState } from "preact/hooks"; import { FormErrors, FormProvider } from "../../../../components/form/FormProvider"; import { Input } from "../../../../components/form/Input"; import { InputCurrency } from "../../../../components/form/InputCurrency"; -import { useConfigContext } from "../../../../context/config"; import { MerchantBackend } from "../../../../declaration"; import { Translate, useTranslator } from "../../../../i18n"; +import { AsyncButton } from "../../../../components/exception/AsyncButton"; type Entity = MerchantBackend.Tips.ReserveCreateRequest interface Props { - onCreate: (d: Entity) => void; + onCreate: (d: Entity) => Promise<void>; onBack?: () => void; } export function CreatePage({ onCreate, onBack }: Props): VNode { - const { currency } = useConfigContext() - - const [reserve, setReserve] = useState<Partial<Entity>>({ - initial_balance: `${currency}:2`, - exchange_url: 'http://exchange.taler:8081/', - wire_method: 'x-taler-bank', - }) + const [reserve, setReserve] = useState<Partial<Entity>>({}) const i18n = useTranslator() const errors: FormErrors<Entity> = { - + initial_balance: !reserve.initial_balance ? 'cannot be empty' : !(parseInt(reserve.initial_balance.split(':')[1], 10) > 0) ? i18n`it should be greater than 0` : undefined, + exchange_url: !reserve.exchange_url ? i18n`cannot be empty` : undefined, + wire_method: !reserve.wire_method ? i18n`cannot be empty` : undefined, } const hasErrors = Object.keys(errors).some(k => (errors as any)[k] !== undefined) const submitForm = () => { - if (hasErrors) return - onCreate(reserve as Entity) + if (hasErrors) return Promise.reject() + return onCreate(reserve as Entity) } return <div> @@ -62,7 +58,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { <div class="columns"> <div class="column" /> <div class="column is-two-thirds"> - <FormProvider<Entity> object={reserve} valueHandler={setReserve}> + <FormProvider<Entity> object={reserve} errors={errors} valueHandler={setReserve}> <InputCurrency<Entity> name="initial_balance" label={i18n`Initial balance`} /> <Input<Entity> name="exchange_url" label={i18n`Exchange`} /> <Input<Entity> name="wire_method" label={i18n`Wire method`} /> @@ -70,11 +66,11 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { <div class="buttons is-right mt-5"> {onBack && <button class="button" onClick={onBack} ><Translate>Cancel</Translate></button>} - <button class="button is-success" onClick={submitForm} ><Translate>Confirm</Translate></button> + <AsyncButton onClick={submitForm} disabled={hasErrors} ><Translate>Confirm</Translate></AsyncButton> </div> </div> <div class="column" /> </div> </section> </div> -} -\ No newline at end of file +} diff --git a/packages/frontend/src/paths/instance/reserves/create/index.tsx b/packages/frontend/src/paths/instance/reserves/create/index.tsx @@ -49,7 +49,7 @@ export default function CreateReserve({ onBack, onConfirm }: Props): VNode { <CreatePage onBack={onBack} onCreate={(request: MerchantBackend.Tips.ReserveCreateRequest) => { - createReserve(request).then((r) => setCreatedOk(r.data)).catch((error) => { + return createReserve(request).then((r) => setCreatedOk(r.data)).catch((error) => { setNotif({ message: i18n`could not create reserve`, type: "ERROR", diff --git a/packages/frontend/src/paths/instance/transfers/create/CreatePage.tsx b/packages/frontend/src/paths/instance/transfers/create/CreatePage.tsx @@ -21,19 +21,23 @@ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../../components/exception/AsyncButton"; import { FormErrors, FormProvider } from "../../../../components/form/FormProvider"; import { Input } from "../../../../components/form/Input"; import { InputCurrency } from "../../../../components/form/InputCurrency"; +import { InputPayto } from "../../../../components/form/InputPayto"; +import { InputSelector } from "../../../../components/form/InputSelector"; import { InputWithAddon } from "../../../../components/form/InputWithAddon"; import { useConfigContext } from "../../../../context/config"; import { MerchantBackend } from "../../../../declaration"; +import { useInstanceDetails } from "../../../../hooks/instance"; import { Translate, useTranslator } from "../../../../i18n"; import { CROCKFORD_BASE32_REGEX, URL_REGEX } from "../../../../utils/constants"; type Entity = MerchantBackend.Transfers.TransferInformation interface Props { - onCreate: (d: Entity) => void; + onCreate: (d: Entity) => Promise<void>; onBack?: () => void; } @@ -41,13 +45,16 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { const i18n = useTranslator() const { currency } = useConfigContext() + const instance = useInstanceDetails() + const accounts = !instance.ok ? [] : instance.data.accounts.map(a => a.payto_uri) + const [state, setState] = useState<Partial<Entity>>({ - wtid: 'DCMGEM7F0DPW930M06C2AVNC6CFXT6HBQ2YVQH7EC8ZQ0W8SS9TG', - payto_uri: 'payto://x-taler-bank/bank.taler:5882/blogger', + wtid: '', + // payto_uri: , exchange_url: 'http://exchange.taler:8081/', - credit_amount: `${currency}:22.80`, + credit_amount: ``, }); - + const errors: FormErrors<Entity> = { wtid: !state.wtid ? i18n`cannot be empty` : (!CROCKFORD_BASE32_REGEX.test(state.wtid) ? i18n`check the id, doest look valid` : @@ -62,8 +69,8 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { const hasErrors = Object.keys(errors).some(k => (errors as any)[k] !== undefined) const submitForm = () => { - if (hasErrors) return - onCreate(state as any) + if (hasErrors) return Promise.reject() + return onCreate(state as any) } return <div> @@ -74,23 +81,22 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { <FormProvider object={state} valueHandler={setState} errors={errors}> <Input<Entity> name="wtid" label={i18n`Transfer ID`} help="" tooltip={i18n`unique identifier of the wire transfer, usually 52 random characters long`} /> - <InputWithAddon<Entity> name="payto_uri" - label={i18n`Account Address`} - addonBefore="payto://" - toStr={(v?: string) => v ? v.substring("payto://".length) : ''} - fromStr={(v: string) => !v ? '' : `payto://${v}`} - tooltip={i18n`account address where the transfer has being received`} - help="x-taler-bank/bank.taler:5882/blogger" /> + <InputSelector name="payto_uri" label={i18n`Address`} + values={accounts} + placeholder={i18n`Select one account`} + tooltip={i18n`filter by account address`} + /> <Input<Entity> name="exchange_url" label={i18n`Exchange URL`} tooltip={i18n`exchange that made the transfer`} help="http://exchange.taler:8081/" /> <InputCurrency<Entity> name="credit_amount" label={i18n`Amount`} tooltip={i18n`how much money transferred into the account`} /> + </FormProvider> <div class="buttons is-right mt-5"> {onBack && <button class="button" onClick={onBack} ><Translate>Cancel</Translate></button>} - <button class="button is-success" disabled={hasErrors} onClick={submitForm} ><Translate>Confirm</Translate></button> + <AsyncButton disabled={hasErrors} onClick={submitForm} ><Translate>Confirm</Translate></AsyncButton> </div> </div> diff --git a/packages/frontend/src/paths/instance/transfers/create/index.tsx b/packages/frontend/src/paths/instance/transfers/create/index.tsx @@ -44,7 +44,7 @@ export default function CreateTransfer({onConfirm, onBack}:Props): VNode { <CreatePage onBack={onBack} onCreate={(request: MerchantBackend.Transfers.TransferInformation) => { - informTransfer(request).then(() => onConfirm()).catch((error) => { + return informTransfer(request).then(() => onConfirm()).catch((error) => { setNotif({ message: i18n`could not inform transfer`, type: "ERROR", diff --git a/packages/frontend/src/paths/instance/transfers/list/Table.tsx b/packages/frontend/src/paths/instance/transfers/list/Table.tsx @@ -111,7 +111,7 @@ function toggleSelected<T>(id: T): (prev: T[]) => T[] { return (prev: T[]): T[] => prev.indexOf(id) == -1 ? [...prev, id] : prev.filter(e => e != id) } -function Table({ instances, onLoadMoreAfter, onLoadMoreBefore, hasMoreAfter, hasMoreBefore }: TableProps): VNode { +function Table({ instances, onLoadMoreAfter, onDelete, onLoadMoreBefore, hasMoreAfter, hasMoreBefore }: TableProps): VNode { const i18n = useTranslator() return ( <div class="table-container"> @@ -126,6 +126,7 @@ function Table({ instances, onLoadMoreAfter, onLoadMoreBefore, hasMoreAfter, has <th><Translate>Confirmed</Translate></th> <th><Translate>Verified</Translate></th> <th><Translate>Executed at</Translate></th> + <th></th> </tr> </thead> <tbody> @@ -138,6 +139,9 @@ function Table({ instances, onLoadMoreAfter, onLoadMoreBefore, hasMoreAfter, has <td>{i.confirmed ? i18n`yes` : i18n`no`}</td> <td>{i.verified ? i18n`yes` : i18n`no`}</td> <td>{i.execution_time ? (i.execution_time.t_ms == 'never' ? i18n`never` : format(i.execution_time.t_ms, 'yyyy/MM/dd HH:mm:ss')) : i18n`unknown`}</td> + <td> + {i.verified === undefined ? <button class="button is-danger is-small" onClick={() => onDelete(i) }>Delete</button> : undefined } + </td> </tr> })} </tbody> diff --git a/packages/frontend/src/paths/instance/transfers/list/index.tsx b/packages/frontend/src/paths/instance/transfers/list/index.tsx @@ -47,6 +47,7 @@ interface Form { export default function ListTransfer({ onUnauthorized, onLoadError, onCreate, onNotFound }: Props): VNode { const [form, setForm] = useState<Form>({ payto_uri: '' }) + const setFilter = (s?: 'yes' | 'no') => setForm({ ...form, verified: s }) const i18n = useTranslator() const [position, setPosition] = useState<string | undefined>(undefined) @@ -54,25 +55,31 @@ export default function ListTransfer({ onUnauthorized, onLoadError, onCreate, on const instance = useInstanceDetails() const accounts = !instance.ok ? [] : instance.data.accounts.map(a => a.payto_uri) + const isVerifiedTransfers = form.verified === 'yes' ? "is-active" : '' + const isNonVerifiedTransfers = form.verified === 'no' ? "is-active" : '' + const isAllTransfers = form.verified === undefined ? 'is-active' : '' + return <section class="section is-main-section"> <div class="columns"> <div class="column" /> <div class="column is-6"> <FormProvider object={form} valueHandler={setForm as any}> - <InputBoolean name="verified" label={i18n`Verified`} threeState - tooltip={i18n`checked will query for verified transfer, unchecked will query for unverified transfer and (-) sign will query for both`} - fromBoolean={(b?: boolean) => b === undefined ? undefined : (b ? 'yes' : 'no')} - toBoolean={(b?: string) => b === undefined ? undefined : (b === 'yes')} - /> - <InputSelector name="payto_uri" label={i18n`Address`} - values={accounts} - placeholder={i18n`Select one account`} + <InputSelector name="payto_uri" label={i18n`Address`} + values={accounts} + placeholder={i18n`Select one account`} tooltip={i18n`filter by account address`} /> </FormProvider> </div> <div class="column" /> </div> + <div class="tabs"> + <ul> + <li class={isAllTransfers}><a onClick={() => setFilter(undefined)}><Translate>All</Translate></a></li> + <li class={isVerifiedTransfers}><a onClick={() => setFilter('yes')}><Translate>Verified</Translate></a></li> + <li class={isNonVerifiedTransfers}><a onClick={() => setFilter('no')}><Translate>Non Verified</Translate></a></li> + </ul> + </div> <View accounts={accounts} form={form} onCreate={onCreate} onLoadError={onLoadError} onNotFound={onNotFound} onUnauthorized={onUnauthorized} diff --git a/packages/frontend/src/paths/instance/update/UpdatePage.tsx b/packages/frontend/src/paths/instance/update/UpdatePage.tsx @@ -22,6 +22,7 @@ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import * as yup from 'yup'; +import { AsyncButton } from "../../../components/exception/AsyncButton"; import { FormProvider, FormErrors } from "../../../components/form/FormProvider"; import { Input } from "../../../components/form/Input"; import { InputCurrency } from "../../../components/form/InputCurrency"; @@ -67,31 +68,34 @@ export function UpdatePage({ onUpdate, selected, onBack }: Props): VNode { const { token } = useInstanceContext() const currentTokenValue = getTokenValuePart(token) const [value, valueHandler] = useState<Partial<Entity>>(convert(selected, currentTokenValue)) - const [errors, setErrors] = useState<FormErrors<Entity>>({}) - - const submit = (): void => { - try { - // use conversion instead of this - const newToken = value.auth_token; - value.auth_token = undefined; - - //if new token was not set or has been set to the actual current token - //it is not needed to send a change - //otherwise, checked where we are setting a new token or removing it - const auth: MerchantBackend.Instances.InstanceAuthConfigurationMessage | undefined = - newToken === undefined || newToken === currentTokenValue ? undefined : (newToken === null ? - { method: "external" } : - { method: "token", token: `secret-token:${newToken}` }); - - // remove above use conversion - schema.validateSync(value, { abortEarly: false }) - onUpdate(schema.cast(value), auth); - onBack() - } catch (err) { - const errors = err.inner as yup.ValidationError[] - const pathMessages = errors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: cur.message }), {}) - setErrors(pathMessages) - } + + let errors: FormErrors<Entity> = {} + try { + schema.validateSync(value, { abortEarly: false }) + } catch (err) { + const yupErrors = err.inner as yup.ValidationError[] + errors = yupErrors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: cur.message }), {}) + } + const hasErrors = Object.keys(errors).some(k => (errors as any)[k] !== undefined) + + const submit = async (): Promise<void> => { + // use conversion instead of this + const newToken = value.auth_token; + value.auth_token = undefined; + + //if new token was not set or has been set to the actual current token + //it is not needed to send a change + //otherwise, checked where we are setting a new token or removing it + const auth: MerchantBackend.Instances.InstanceAuthConfigurationMessage | undefined = + newToken === undefined || newToken === currentTokenValue ? undefined : (newToken === null ? + { method: "external" } : + { method: "token", token: `secret-token:${newToken}` }); + + // remove above use conversion + schema.validateSync(value, { abortEarly: false }) + await onUpdate(schema.cast(value), auth); + await onBack() + return Promise.resolve() } const i18n = useTranslator() @@ -131,7 +135,7 @@ export function UpdatePage({ onUpdate, selected, onBack }: Props): VNode { <div class="buttons is-right mt-4"> <button class="button" onClick={onBack} ><Translate>Cancel</Translate></button> - <button class="button is-success" onClick={submit} ><Translate>Confirm</Translate></button> + <AsyncButton onClick={submit} disabled={hasErrors} ><Translate>Confirm</Translate></AsyncButton> </div> </div> <div class="column" /> diff --git a/packages/frontend/src/utils/constants.ts b/packages/frontend/src/utils/constants.ts @@ -36,4 +36,7 @@ export const URL_REGEX = /^((https?:)(\/\/\/?)([\w]*(?::[\w]*)?@)?([\d\w\.-]+)(? export const PAGE_SIZE = 20 // how bigger can be the result set // after this threshold, load more with move the cursor -export const MAX_RESULT_SIZE = PAGE_SIZE * 2 - 1; -\ No newline at end of file +export const MAX_RESULT_SIZE = PAGE_SIZE * 2 - 1; + +// how much we will wait for all request, in seconds +export const DEFAULT_REQUEST_TIMEOUT = 10; +\ No newline at end of file