commit 6aba8b53ce84c6b723a5c8c31179fd23d132d7c7 parent eecd261cef52c4ccebf5eb49f2326aa86e12f0f8 Author: Sebastian <sebasjm@gmail.com> Date: Fri, 19 Feb 2021 17:00:31 -0300 better readme, updated makefile with more commands, add config context, switch to nodejs 12 Diffstat:
15 files changed, 266 insertions(+), 185 deletions(-)
diff --git a/README.md b/README.md @@ -4,38 +4,53 @@ Merchant Admin Frontend is a Single Page Application that connect with a running ## System requirements -- Node version: 14.15.0 -- Yarn version: 1.21.1 +- Node: v12.18.4 +- pnpm: 5.17.2 +- make +- python>=3.8 -## Environment +## Compiling from source -This applications use some configuration from the environment, like the location of the Merchant Backend. -Be sure to copy the file `template.env` into `.env` and override with your custom values. -## Installation +Check the requirements and run `./bootstrap` and `./configure` -First run the command `yarn install` to get all the dependencies. -The running `yarn dev` should set the server up and running. -Use the browser to navigate into `http://localhost:8080` +```shell +./bootstrap +./configure +``` + +Then run `make` to install all the nodejs dependencies + +## Running develop + +To run a development server run + +```shell +make dev +``` + +This should start a watch process that will reload the server every time that a file is saved. + +## Building for deploy ## CLI Commands -* `yarn install`: Installs dependencies +* `make compile`: Installs dependencies and compile with typescript -* `yarn dev`: Run a development, HMR server. The application will automatically refresh +* `make dev`: Run a development, HMR server. The application will automatically refresh every time a file is edited -* `yarn build`: Production-ready build into the `./build` directory. Print bundle size +* `make build`: Production-ready build into the `./build` directory. Print bundle size information and compare its with previous build using the `size-plugin.json` file. -* `yarn serve`: Serves the content into `./build` directory, usefull to test the result. +* `make serve`: build and serves the content, usefull to test the result. -* `yarn lint`: Pass TypeScript files using ESLint +* `make lint`: Pass TypeScript files using ESLint -* `yarn test`: Run Jest and Enzyme with +* `make check`: Run Jest and Enzyme with [`enzyme-adapter-preact-pure`](https://github.com/preactjs/enzyme-adapter-preact-pure) for your tests -* `yarn storybook`: Run visual components explorer. Usefull for components design and development +* `make dev-views`: Run visual components explorer. Usefull for components design and development without the need of setting up the whole system. diff --git a/build-system/Makefile b/build-system/Makefile @@ -15,13 +15,13 @@ compile: pnpm i -r pnpm run compile -.PHONY: dist -dist: - $(git-archive-all) --include ./configure taler-wallet-$(shell git describe --tags).tar.gz +# .PHONY: dist +# dist: +# $(git-archive-all) --include ./configure taler-wallet-$(shell git describe --tags).tar.gz -.PHONY: publish -publish: compile - pnpm publish -r --no-git-checks +# .PHONY: publish +# publish: compile +# pnpm publish -r --no-git-checks # make documentation from docstrings .PHONY: typedoc @@ -32,6 +32,10 @@ typedoc: clean: pnpm run clean +.PHONY: build +build: + pnpm run build + .PHONY: submodules-update submodules-update: git submodule update --recursive --remote @@ -40,6 +44,18 @@ submodules-update: check: compile pnpm run check +.PHONY: dev +dev: compile + pnpm run --filter merchant-backoffice dev + +.PHONY: serve +serve: build + pnpm run --filter merchant-backoffice serve + +.PHONY: dev-view +dev-view: compile + pnpm run --filter merchant-backoffice storybook + .PHONY: lint lint: pnpm run lint diff --git a/package.json b/package.json @@ -3,6 +3,8 @@ "scripts": { "clean": "pnpm run --filter '{packages}' clean", "compile": "pnpm run --filter '{packages}' compile", + "build": "pnpm run --filter '{packages}' build", + "serve": "pnpm run --filter '{packages}' serve", "lint": "pnpm run --filter '{packages}' lint", "typedoc": "pnpm run --filter '{packages}' typedoc", "pretty": "pnpm run --filter '{packages}' pretty", diff --git a/packages/frontend/.storybook/preview.js b/packages/frontend/.storybook/preview.js @@ -1,7 +1,13 @@ import "../src/scss/main.scss" import { IntlProvider } from 'preact-i18n'; import { h } from "preact"; -import lang from '../src/i18n' +import { translations } from '../src/i18n' +import { ConfigContext } from '../src/context/backend' + +const mockConfig = { + backendURL: 'http://demo.taler.net', + currency: 'KUDOS' +} export const parameters = { controls: { expanded: true }, @@ -25,8 +31,9 @@ export const globalTypes = { export const decorators = [ (Story, { globals }) => { - return <IntlProvider definition={lang[globals.locale]} mark> + return <IntlProvider definition={translations[globals.locale]} mark> <Story /> </IntlProvider> }, + (Story) => <ConfigContext.Provider value={mockConfig}> <Story /> </ConfigContext.Provider> ]; diff --git a/packages/frontend/src/components/auth/index.tsx b/packages/frontend/src/components/auth/index.tsx @@ -21,7 +21,7 @@ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { useBackend } from "../hooks"; +import { useBackend } from "../../hooks"; interface Props { onConfirm?: () => void; diff --git a/packages/frontend/src/components/hooks/backend.ts b/packages/frontend/src/components/hooks/backend.ts @@ -1,111 +0,0 @@ -/* - 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 useSWR, { mutate } from 'swr'; -import axios from 'axios' -import { MerchantBackend } from '../../declaration'; - -type HttpResponse<T> = HttpResponseOk<T> | HttpResponseError<T>; - -interface HttpResponseOk<T> { - data: T; -} -interface HttpResponseError<T> { - data: undefined; - needsAuth: boolean; - error: Error; -} - - -type Methods = 'get' | 'post' | 'patch' | 'delete' | 'put'; - -async function request(url: string, method?: Methods, data?: any): Promise<any> { - const backend = localStorage.getItem('backend-url') - const token = localStorage.getItem('backend-token') - const headers = token ? { Authorization: `${token}` } : undefined - - try { - const res = await axios({ - method: method || 'get', - url: `${backend}${url}`, - responseType: 'json', - headers, - data - }) - return res.data - } catch (e) { - const info = e.response?.data - const status = e.response?.status - throw { info, status, error:e, backend, hasToken: !!token } - } - -} - -async function fetcher(url: string): Promise<any> { - return request(url, 'get') -} - -interface WithCreate<T> { - create: (data: T) => Promise<void>; -} -interface WithUpdate<T> { - update: (id: string, data: T) => Promise<void>; -} -interface WithDelete { - delete: (id: string) => Promise<void>; -} - -export function useBackendInstances(): HttpResponse<MerchantBackend.Instances.InstancesResponse> & WithCreate<MerchantBackend.Instances.InstanceConfigurationMessage> { - const { data, error } = useSWR<MerchantBackend.Instances.InstancesResponse>('/private/instances', fetcher) - - const create = async (instance: MerchantBackend.Instances.InstanceConfigurationMessage): Promise<void> => { - await request('/private/instances', 'post', instance) - - mutate('/private/instances') - } - - return { data, needsAuth: error?.status === 401, error, create } -} - -export function useBackendInstance(id: string | null): HttpResponse<MerchantBackend.Instances.QueryInstancesResponse> & WithUpdate<MerchantBackend.Instances.InstanceReconfigurationMessage> & WithDelete { - const { data, error } = useSWR<MerchantBackend.Instances.QueryInstancesResponse>(id ? `/private/instances/${id}` : null, fetcher) - - const update = async (updateId: string, instance: MerchantBackend.Instances.InstanceReconfigurationMessage): Promise<void> => { - await request(`/private/instances/${updateId}`, 'patch', instance) - - mutate('/private/instances', null) - mutate(`/private/instances/${updateId}`, null) - }; - const _delete = async (deleteId: string): Promise<void> => { - await request(`/private/instances/${deleteId}`, 'delete') - - mutate('/private/instances', null) - mutate(`/private/instances/${deleteId}`, null) - } - - return { data, needsAuth: error?.status === 401, error, update, delete: _delete } -} - -export function useBackendConfig(): HttpResponse<MerchantBackend.VersionResponse> { - const { data, error } = useSWR<MerchantBackend.VersionResponse>(`/config`, fetcher) - - return { data, needsAuth: error?.status === 401, error } -} diff --git a/packages/frontend/src/components/yup/YupField.tsx b/packages/frontend/src/components/yup/YupField.tsx @@ -21,9 +21,7 @@ import { h, VNode } from "preact"; import { Text, useText } from "preact-i18n"; -import { useState } from "preact/hooks"; -import { useBackendConfig } from "../hooks/backend"; -import { useBackend } from "../hooks"; +import { StateUpdater, useContext, useState } from "preact/hooks"; import { intervalToDuration, formatDuration } from 'date-fns' function readableDuration(duration: number): string { @@ -33,7 +31,7 @@ function readableDuration(duration: number): string { // customFormatDuration({ start: 0, end: 10800 * 1000}) // 3 hours // customFormatDuration({ start: 0, end: 108000 * 1000}) // 1 day 6 hours -interface Props { +interface PropsInputInternal { name: string; value: string; readonly?: boolean; @@ -41,7 +39,7 @@ interface Props { onChange: any; } -interface Props2 { +interface PropsObject { name: string; info: any; value: any; @@ -49,8 +47,17 @@ interface Props2 { onChange: any; } +interface Props { + name: string; + field: string; + errors: any; + object: any; + valueHandler: StateUpdater<any>; + info: any; +} +import { ConfigContext } from '../../context/backend'; -export function YupField(name: string, field: string, errors: any, object: any, valueHandler: any, info: any): VNode { +export function YupField({ name, field, errors, object, valueHandler, info }: Props): VNode { const updateField = (f: string) => (v: string): void => valueHandler((prev: any) => ({ ...prev, [f]: v })) const values = { name, errors, @@ -58,9 +65,7 @@ export function YupField(name: string, field: string, errors: any, object: any, value: object && object[field], onChange: updateField(field) } - const [backend] = useBackend() - const { data } = useBackendConfig() - const currency = data?.currency || '' + const config = useContext(ConfigContext) switch (info.meta?.type) { case 'group': return <YupObjectInput name={name} @@ -69,8 +74,13 @@ export function YupField(name: string, field: string, errors: any, object: any, onChange={(updater: any): void => valueHandler((prev: any) => ({ ...prev, [field]: updater(prev[field]) }))} /> case 'array': return <YupInputArray {...values} />; - case 'amount': return <YupInputWithAddon {...values} addon={currency} onChange={(v: string): void => values.onChange(`${currency}:${v}`)} value={values.value?.split(':')[1]} />; - case 'url': return <YupInputWithAddon {...values} addon={`${backend.url}/private/instances/`} />; + case 'amount': { + if (config.currency) { + return <YupInputWithAddon {...values} addon={config.currency} onChange={(v: string): void => values.onChange(`${config.currency}:${v}`)} value={values.value?.split(':')[1]} /> + } + return <YupInput {...values} />; + } + case 'url': return <YupInputWithAddon {...values} addon={`${config.backendURL}/private/instances/`} />; case 'secured': return <YupInputSecured {...values} />; case 'duration': return <YupInputWithAddon addon={readableDuration(values.value?.d_ms)} atTheEnd {...values} value={`${values.value?.d_ms / 1000 || ''}`} onChange={(v: string): void => values.onChange({ d_ms: (parseInt(v, 10) * 1000) || undefined } as any)} />; default: return <YupInput {...values} />; @@ -78,7 +88,7 @@ export function YupField(name: string, field: string, errors: any, object: any, } } -function YupObjectInput({ name, info, value, errors, onChange }: Props2): VNode { +function YupObjectInput({ name, info, value, errors, onChange }: PropsObject): VNode { const [active, setActive] = useState(false) return <div class="card"> <header class="card-header"> @@ -95,13 +105,16 @@ function YupObjectInput({ name, info, value, errors, onChange }: Props2): VNode </header> <div class={active ? "card-content" : "is-hidden"}> <div class="content"> - {Object.keys(info.fields).map(f => YupField(`${name}.${f}`, f, errors, value, onChange, info.fields[f]))} + {Object.keys(info.fields).map(f => <YupField name={`${name}.${f}`} + field={f} errors={errors} object={value} + valueHandler={onChange} info={info.fields[f]} + />)} </div> </div> </div> } -function YupInput({ name, readonly, value, errors, onChange }: Props): VNode { +function YupInput({ name, readonly, value, errors, onChange }: PropsInputInternal): VNode { const dict = useText({ placeholder: `fields.instance.${name}.placeholder`, tooltip: `fields.instance.${name}.tooltip`, @@ -133,7 +146,7 @@ function YupInput({ name, readonly, value, errors, onChange }: Props): VNode { </div> } -function YupInputArray({ name, readonly, value, errors, onChange }: Props): VNode { +function YupInputArray({ name, readonly, value, errors, onChange }: PropsInputInternal): VNode { const dict = useText({ placeholder: `fields.instance.${name}.placeholder`, tooltip: `fields.instance.${name}.tooltip`, @@ -184,7 +197,7 @@ function YupInputArray({ name, readonly, value, errors, onChange }: Props): VNod </div> } -function YupInputWithAddon({ name, readonly, value, errors, onChange, addon, atTheEnd }: Props & { addon: string; atTheEnd?: boolean }): VNode { +function YupInputWithAddon({ name, readonly, value, errors, onChange, addon, atTheEnd }: PropsInputInternal & { addon: string; atTheEnd?: boolean }): VNode { const dict = useText({ placeholder: `fields.instance.${name}.placeholder`, tooltip: `fields.instance.${name}.tooltip`, @@ -222,7 +235,7 @@ function YupInputWithAddon({ name, readonly, value, errors, onChange, addon, atT </div> } -function YupInputSecured({ name, readonly, value, errors, onChange }: Props): VNode { +function YupInputSecured({ name, readonly, value, errors, onChange }: PropsInputInternal): VNode { const dict = useText({ placeholder: `fields.instance.${name}.placeholder`, tooltip: `fields.instance.${name}.tooltip`, diff --git a/packages/frontend/src/context/backend.ts b/packages/frontend/src/context/backend.ts @@ -0,0 +1,11 @@ +import { createContext } from 'preact' + +interface GlobalContext { + backendURL: string; + currency?: string; +} + +export const ConfigContext = createContext<GlobalContext>({ + backendURL: '', + currency: '', +}) diff --git a/packages/frontend/src/hooks/backend.ts b/packages/frontend/src/hooks/backend.ts @@ -0,0 +1,111 @@ +/* + 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 useSWR, { mutate } from 'swr'; +import axios from 'axios' +import { MerchantBackend } from '../declaration'; + +type HttpResponse<T> = HttpResponseOk<T> | HttpResponseError<T>; + +interface HttpResponseOk<T> { + data: T; +} +interface HttpResponseError<T> { + data: undefined; + needsAuth: boolean; + error: Error; +} + + +type Methods = 'get' | 'post' | 'patch' | 'delete' | 'put'; + +async function request(url: string, method?: Methods, data?: any): Promise<any> { + const backend = localStorage.getItem('backend-url') + const token = localStorage.getItem('backend-token') + const headers = token ? { Authorization: `${token}` } : undefined + + try { + const res = await axios({ + method: method || 'get', + url: `${backend}${url}`, + responseType: 'json', + headers, + data + }) + return res.data + } catch (e) { + const info = e.response?.data + const status = e.response?.status + throw { info, status, error:e, backend, hasToken: !!token } + } + +} + +async function fetcher(url: string): Promise<any> { + return request(url, 'get') +} + +interface WithCreate<T> { + create: (data: T) => Promise<void>; +} +interface WithUpdate<T> { + update: (id: string, data: T) => Promise<void>; +} +interface WithDelete { + delete: (id: string) => Promise<void>; +} + +export function useBackendInstances(): HttpResponse<MerchantBackend.Instances.InstancesResponse> & WithCreate<MerchantBackend.Instances.InstanceConfigurationMessage> { + const { data, error } = useSWR<MerchantBackend.Instances.InstancesResponse>('/private/instances', fetcher) + + const create = async (instance: MerchantBackend.Instances.InstanceConfigurationMessage): Promise<void> => { + await request('/private/instances', 'post', instance) + + mutate('/private/instances') + } + + return { data, needsAuth: error?.status === 401, error, create } +} + +export function useBackendInstance(id: string | null): HttpResponse<MerchantBackend.Instances.QueryInstancesResponse> & WithUpdate<MerchantBackend.Instances.InstanceReconfigurationMessage> & WithDelete { + const { data, error } = useSWR<MerchantBackend.Instances.QueryInstancesResponse>(id ? `/private/instances/${id}` : null, fetcher) + + const update = async (updateId: string, instance: MerchantBackend.Instances.InstanceReconfigurationMessage): Promise<void> => { + await request(`/private/instances/${updateId}`, 'patch', instance) + + mutate('/private/instances', null) + mutate(`/private/instances/${updateId}`, null) + }; + const _delete = async (deleteId: string): Promise<void> => { + await request(`/private/instances/${deleteId}`, 'delete') + + mutate('/private/instances', null) + mutate(`/private/instances/${deleteId}`, null) + } + + return { data, needsAuth: error?.status === 401, error, update, delete: _delete } +} + +export function useBackendConfig(): HttpResponse<MerchantBackend.VersionResponse> { + const { data, error } = useSWR<MerchantBackend.VersionResponse>(`/config`, fetcher) + + return { data, needsAuth: error?.status === 401, error } +} diff --git a/packages/frontend/src/components/hooks/index.ts b/packages/frontend/src/hooks/index.ts diff --git a/packages/frontend/src/index.tsx b/packages/frontend/src/index.tsx @@ -14,10 +14,10 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** - * - * @author Sebastian Javier Marchano (sebasjm) - */ +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ import "./scss/main.scss" @@ -29,28 +29,36 @@ import { Footer } from './components/footer'; import { Sidebar } from './components/sidebar'; import { NavigationBar } from './components/navbar'; import { Notifications } from './components/notifications'; -import { useNotifications } from './hooks/notifications'; import { translations } from './i18n'; -import { useLang } from './components/hooks'; +import { useBackend, useLang } from './hooks'; import NotFoundPage from './routes/notfound'; import Instances from './routes/instances'; +import { useNotifications } from "./hooks/notifications"; +import { ConfigContext } from './context/backend'; +import { useBackendConfig } from "./hooks/backend"; export default function App(): VNode { const { notifications, pushNotification } = useNotifications() const [lang, setLang] = useLang() + const [{url: backendURL}] = useBackend(); + const { data } = useBackendConfig(); + return ( - <IntlProvider definition={(translations as any)[lang] || translations.en}> - <div id="app"> - <NavigationBar lang={lang} setLang={setLang} /> - <Sidebar /> - <Notifications notifications={notifications} /> - <Router> - <Route path="/" component={Instances} pushNotification={pushNotification} /> - <Route default component={NotFoundPage} /> - </Router> - <Footer /> - </div> - </IntlProvider > + <ConfigContext.Provider value={{backendURL, currency: data && data.currency}}> + + <IntlProvider definition={(translations as any)[lang] || translations.en}> + <div id="app"> + <NavigationBar lang={lang} setLang={setLang} /> + <Sidebar /> + <Notifications notifications={notifications} /> + <Router> + <Route path="/" component={Instances} pushNotification={pushNotification} /> + <Route default component={NotFoundPage} /> + </Router> + <Footer /> + </div> + </IntlProvider > + </ConfigContext.Provider> ); } \ No newline at end of file diff --git a/packages/frontend/src/routes/instances/Create.stories.tsx b/packages/frontend/src/routes/instances/Create.stories.tsx @@ -14,23 +14,25 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** - * - * @author Sebastian Javier Marchano (sebasjm) - */ +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ import { h, VNode } from 'preact'; -import {CreatePage} from './CreatePage' +import { CreatePage } from './CreatePage' export default { - title: 'Instances/CreateModal', + title: 'Instances/Create', component: CreatePage, argTypes: { - element: { control: 'object' }, - onCancel: { action: 'onCancel' }, - onConfirm: { action: 'onConfirm' }, + onCreate: { action: 'onCreate' }, + goBack: { action: 'goBack' }, } }; export const Example = (a: any): VNode => <CreatePage {...a} />; +Example.args = { + isLoading: false +} diff --git a/packages/frontend/src/routes/instances/CreatePage.tsx b/packages/frontend/src/routes/instances/CreatePage.tsx @@ -60,7 +60,6 @@ export function CreatePage({ onCreate, isLoading, goBack }: Props): VNode { setErrors(pathMessages) } } - return <div> <section class="section is-title-bar"> @@ -97,7 +96,11 @@ export function CreatePage({ onCreate, isLoading, goBack }: Props): VNode { <div class="columns"> <div class="column" /> <div class="column is-two-thirds"> - {Object.keys(schema.fields).map(f => YupField(f, f, errors, value, valueHandler, schema.fields[f].describe()))} + {Object.keys(schema.fields) + .map(f => <YupField name={f} + field={f} errors={errors} object={value} + valueHandler={valueHandler} info={schema.fields[f].describe()} + />)} <div class="buttons is-right"> <button class="button" onClick={goBack} ><Text id="cancel" /></button> <button class="button is-success" onClick={submit} ><Text id="confirm" /></button> diff --git a/packages/frontend/src/routes/instances/UpdatePage.tsx b/packages/frontend/src/routes/instances/UpdatePage.tsx @@ -101,8 +101,12 @@ export function UpdatePage({ onUpdate, isLoading, selected, goBack }: Props): VN <div class="columns"> <div class="column" /> <div class="column is-two-thirds"> - {Object.keys(schema.fields).map(f => YupField(f, f, errors, value, valueHandler, schema.fields[f].describe()))} - <div class="buttons is-right"> + {Object.keys(schema.fields) + .map(f => <YupField name={f} + field={f} errors={errors} object={value} + valueHandler={valueHandler} info={schema.fields[f].describe()} + />)} + <div class="buttons is-right"> <button class="button" onClick={goBack} ><Text id="cancel" /></button> <button class="button is-success" onClick={submit} ><Text id="confirm" /></button> </div> diff --git a/packages/frontend/src/routes/instances/index.tsx b/packages/frontend/src/routes/instances/index.tsx @@ -22,7 +22,7 @@ import { h, VNode } from 'preact'; import { View } from './View'; import { LoginPage } from '../../components/auth'; -import { useBackendInstance, useBackendInstances } from '../../components/hooks/backend'; +import { useBackendInstance, useBackendInstances } from '../../hooks/backend'; import { useEffect, useState } from 'preact/hooks'; import { Notification } from '../../declaration'; import { CreatePage } from './CreatePage';