diff options
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | src/components/hooks/backend.ts | 38 | ||||
-rw-r--r-- | src/components/notifications/Notifications.stories.tsx | 49 | ||||
-rw-r--r-- | src/components/notifications/index.tsx | 31 | ||||
-rw-r--r-- | src/declaration.d.ts | 8 | ||||
-rw-r--r-- | src/hooks/notifications.ts | 19 | ||||
-rw-r--r-- | src/routes/index.tsx | 7 | ||||
-rw-r--r-- | src/routes/instances/CreateModal.tsx | 2 | ||||
-rw-r--r-- | src/routes/instances/Table.tsx | 95 | ||||
-rw-r--r-- | src/routes/instances/UpdateModal.stories.tsx | 2 | ||||
-rw-r--r-- | src/routes/instances/UpdateModal.tsx | 7 | ||||
-rw-r--r-- | src/routes/instances/View.stories.tsx | 6 | ||||
-rw-r--r-- | src/routes/instances/View.tsx | 26 | ||||
-rw-r--r-- | src/routes/instances/index.tsx | 34 | ||||
-rw-r--r-- | src/scss/main.scss | 17 |
15 files changed, 240 insertions, 103 deletions
diff --git a/package.json b/package.json index b5cbbeb..0e3c39c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "preact build --no-sw --no-esm", "serve": "sirv build --port 8080 --cors --single --no-sw --no-esm", "dev": "preact watch --no-sw --no-esm", - "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", + "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}' --fix", "test": "jest ./tests", "storybook": "start-storybook -p 6006" }, diff --git a/src/components/hooks/backend.ts b/src/components/hooks/backend.ts index 4a1eebe..2f3fb27 100644 --- a/src/components/hooks/backend.ts +++ b/src/components/hooks/backend.ts @@ -13,10 +13,6 @@ interface HttpResponseError<T> { error: Error; } -class AuthError extends Error { - public readonly isAuth = true -} - const BACKEND = process.env.BACKEND_ENDPOINT const TOKEN_KEY = 'backend-token' @@ -26,20 +22,22 @@ async function request(url: string, method?: Methods, data?: object): Promise<an const token = localStorage.getItem(TOKEN_KEY) const headers = token ? { Authorization: `Bearer secret-token:${token}` } : undefined - const res = await axios({ - method: method || 'get', - url: `${BACKEND}/private${url}`, - responseType: 'json', - headers, - data - }) - if (res.status == 200 || res.status == 204) return res.data - if (res.status == 401) throw new AuthError() - - const error = new Error('An error occurred while fetching the data.') - const info = res.data - const status = res.status - throw { info, status, ...error } + try { + const res = await axios({ + method: method || 'get', + url: `${BACKEND}/private${url}`, + responseType: 'json', + headers, + data + }) + return res.data + } catch (e) { + const error = new Error('An error occurred while fetching the data.') + const info = e.response.data + const status = e.response.status + throw { info, status, ...error } + } + } async function fetcher(url: string): Promise<any> { @@ -74,7 +72,7 @@ export function useBackendInstances(): HttpResponse<MerchantBackend.Instances.In globalMutate('/instances') } - return { data, needsAuth: error instanceof AuthError, error, create } + return { data, needsAuth: error?.status === 401, error, create } } export function useBackendInstance(id: string | null): HttpResponse<MerchantBackend.Instances.QueryInstancesResponse> & WithUpdate<MerchantBackend.Instances.InstanceReconfigurationMessage> & WithDelete { @@ -93,5 +91,5 @@ export function useBackendInstance(id: string | null): HttpResponse<MerchantBack globalMutate(`/instances/${deleteId}`, null) } - return { data, needsAuth: error instanceof AuthError, error, update, delete: _delete } + return { data, needsAuth: error?.status === 401, error, update, delete: _delete } } diff --git a/src/components/notifications/Notifications.stories.tsx b/src/components/notifications/Notifications.stories.tsx new file mode 100644 index 0000000..242432a --- /dev/null +++ b/src/components/notifications/Notifications.stories.tsx @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { h } from 'preact'; +import Notification from './index' + + +export default { + title: 'Components/Notification', + component: Notification, +}; + +export const NotificationInfo = () => { + return <div> + <Notification notifications={[{ + title: 'Title', + description: 'this is a message', + type: 'INFO' + }]} /> + </div> +}; + +export const NotificationWarn = () => { + return <div> + <Notification notifications={[{ + title: 'Title', + description: 'this is a message', + type: 'WARN' + }]} /> + </div> +}; + +export const NotificationError = () => { + return <div> + <Notification notifications={[{ + title: 'Title', + description: 'this is a message', + type: 'ERROR' + }]} /> + </div> +}; + +export const NotificationSuccess = () => { + return <div> + <Notification notifications={[{ + title: 'Title', + description: 'this is a message', + type: 'SUCCESS' + }]} /> + </div> +}; diff --git a/src/components/notifications/index.tsx b/src/components/notifications/index.tsx new file mode 100644 index 0000000..906502f --- /dev/null +++ b/src/components/notifications/index.tsx @@ -0,0 +1,31 @@ +import { h, VNode } from "preact"; +import { useEffect } from "preact/hooks"; +import { MessageType, Notification } from "../../declaration"; + +interface Props { + notifications: Notification[]; +} + +function messageStyle(type: MessageType): string { + switch (type) { + case "INFO": return "message is-info"; + case "WARN": return "message is-warning"; + case "ERROR": return "message is-danger"; + case "SUCCESS": return "message is-success"; + default: return "message" + } +} + +export default function Notifications({ notifications }: Props): VNode { + return <div class="toast"> + {notifications.map(n => <article class={messageStyle(n.type)}> + <div class="message-header"> + <p>{n.title}</p> + <button class="delete" aria-label="delete" /> + </div> + <div class="message-body"> + {n.description} + </div> + </article>)} + </div> +}
\ No newline at end of file diff --git a/src/declaration.d.ts b/src/declaration.d.ts index 7b83773..ef46320 100644 --- a/src/declaration.d.ts +++ b/src/declaration.d.ts @@ -9,6 +9,14 @@ declare module "*.scss" { export default mapping; } +interface Notification { + title: string; + description: string; + type: MessageType; + } + + type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS' + type EddsaPublicKey = string; type RelativeTime = Duration; interface Timestamp { diff --git a/src/hooks/notifications.ts b/src/hooks/notifications.ts new file mode 100644 index 0000000..0dc361f --- /dev/null +++ b/src/hooks/notifications.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; +import { Notification } from '../declaration'; + +interface Result { + notifications: Notification[]; + pushNotification: (n: Notification) => void; +} + +export function useNotifications(): Result { + const [notifications, setNotifications] = useState<(Notification & {since: Date})[]>([]) + const pushNotification = (n: Notification): void => { + const entry = {...n, since: new Date() } + setNotifications(ns => [...ns, entry]) + setTimeout(()=>{ + setNotifications(ns => ns.filter(x => x.since !== entry.since)) + }, 2000) + } + return {notifications, pushNotification} +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 1543a1a..fe190ae 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -8,6 +8,8 @@ import Sidebar from '../components/sidebar'; import NavigationBar from '../components/navbar'; import { useEffect } from 'preact/hooks'; import InstanceDetail from './instanceDetail'; +import Notifications from '../components/notifications'; +import { useNotifications } from '../hooks/notifications'; function Redirector({ to }: { path: string; to: string }): null { useEffect(() => { @@ -16,13 +18,16 @@ function Redirector({ to }: { path: string; to: string }): null { return null; } + export default function PageRouter(): VNode { + const {notifications, pushNotification} = useNotifications() return ( <div id="app"> <NavigationBar /> <Sidebar /> + <Notifications notifications={notifications} /> <Router> - <Route path="/" component={Instances} /> + <Route path="/" component={Instances} pushNotification={pushNotification} /> <Route path="/i/:instance" component={InstanceDetail} /> <NotFoundPage default /> </Router> diff --git a/src/routes/instances/CreateModal.tsx b/src/routes/instances/CreateModal.tsx index 518af6d..e1b53da 100644 --- a/src/routes/instances/CreateModal.tsx +++ b/src/routes/instances/CreateModal.tsx @@ -73,7 +73,7 @@ export default function CreateModal({ active, onCancel, onConfirm }: Props): VNo <div class="field-body"> <div class="field"> <p class="control is-expanded has-icons-left"> - <input class="input" type="text" placeholder={info?.meta?.placeholder} readonly={info?.meta?.readonly} name={f} value={value[f]} onChange={e => valueHandler(prev => ({ ...prev, [f]: e.currentTarget.value }))} /> + <input class="input" type="text" placeholder={info?.meta?.placeholder} readonly={info?.meta?.readonly} name={f} value={value[f]} onChange={e => valueHandler((prev: any) => ({ ...prev, [f]: e.currentTarget.value }))} /> {info?.meta?.help} </p> {errors[f] ? <p class="help is-danger">{errors[f]}</p> : null} diff --git a/src/routes/instances/Table.tsx b/src/routes/instances/Table.tsx index 7db153b..e6d1474 100644 --- a/src/routes/instances/Table.tsx +++ b/src/routes/instances/Table.tsx @@ -1,5 +1,5 @@ import { h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useState, StateUpdater } from "preact/hooks"; import { MerchantBackend, WidthId as WithId } from "../../declaration"; import DeleteModal from './DeleteModal' import UpdateModal from './UpdateModal' @@ -14,7 +14,7 @@ interface Props { selected: MerchantBackend.Instances.QueryInstancesResponse & WithId | undefined; } -function toggleSelected<T>(id: T) { +function toggleSelected<T>(id: T): (prev: T[]) => T[] { return (prev: T[]): T[] => prev.indexOf(id) == -1 ? [...prev, id] : prev.filter(e => e != id) } @@ -40,50 +40,59 @@ const EmptyTable = () => <div class="content has-text-grey has-text-centered"> <p>No instance configured yet, setup one pressing the + button </p> </div> -const Table = ({ rowSelection, rowSelectionHandler, instances, onSelect, toBeDeletedHandler }) => <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> - <thead> - <tr> - <th class="is-checkbox-cell"> - <label class="b-checkbox checkbox"> - <input type="checkbox" checked={rowSelection.length === instances.length} onClick={e => rowSelectionHandler(rowSelection.length === instances.length ? [] : instances.map(i => i.id))} /> - <span class="check" /> - </label> - </th> - <th>id</th> - <th>name</th> - <th>public key</th> - <th>payments</th> - <th /> - </tr> - </thead> - <tbody> - {instances.map(i => { - return <tr> - <td class="is-checkbox-cell"> +interface TableProps { + rowSelection: string[]; + instances: MerchantBackend.Instances.Instance[]; + onSelect: (id: string | null) => void; + rowSelectionHandler: StateUpdater<string[]>; + toBeDeletedHandler: StateUpdater<MerchantBackend.Instances.Instance | null>; +} + +const Table = ({ rowSelection, rowSelectionHandler, instances, onSelect, toBeDeletedHandler }: TableProps): VNode => ( + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th class="is-checkbox-cell"> <label class="b-checkbox checkbox"> - <input type="checkbox" checked={rowSelection.indexOf(i.id) != -1} onClick={e => rowSelectionHandler(toggleSelected(i.id))} /> + <input type="checkbox" checked={rowSelection.length === instances.length} onClick={e => rowSelectionHandler(rowSelection.length === instances.length ? [] : instances.map(i => i.id))} /> <span class="check" /> </label> - </td> - <td >{i.id}</td> - <td >{i.name}</td> - <td >{i.merchant_pub}</td> - <td >{i.payment_targets}</td> - <td class="is-actions-cell"> - <div class="buttons is-right"> - <button class="button is-small is-primary" type="button" onClick={e => onSelect(i.id)}> - <span class="icon"><i class="mdi mdi-eye" /></span> - </button> - <button class="button is-small is-danger jb-modal" type="button" onClick={e => toBeDeletedHandler(i)}> - <span class="icon"><i class="mdi mdi-trash-can" /></span> - </button> - </div> - </td> + </th> + <th>id</th> + <th>name</th> + <th>public key</th> + <th>payments</th> + <th /> </tr> - })} + </thead> + <tbody> + {instances.map(i => { + return <tr> + <td class="is-checkbox-cell"> + <label class="b-checkbox checkbox"> + <input type="checkbox" checked={rowSelection.indexOf(i.id) != -1} onClick={e => rowSelectionHandler(toggleSelected(i.id))} /> + <span class="check" /> + </label> + </td> + <td >{i.id}</td> + <td >{i.name}</td> + <td >{i.merchant_pub}</td> + <td >{i.payment_targets}</td> + <td class="is-actions-cell"> + <div class="buttons is-right"> + <button class="button is-small is-primary" type="button" onClick={e => onSelect(i.id)}> + <span class="icon"><i class="mdi mdi-eye" /></span> + </button> + <button class="button is-small is-danger jb-modal" type="button" onClick={e => toBeDeletedHandler(i)}> + <span class="icon"><i class="mdi mdi-trash-can" /></span> + </button> + </div> + </td> + </tr> + })} - </tbody> -</table> + </tbody> + </table>) export default function CardTable({ instances, onCreate, onDelete, onSelect, onUpdate, selected }: Props): VNode { @@ -121,8 +130,8 @@ export default function CardTable({ instances, onCreate, onDelete, onSelect, onU <div class="card-content"> <div class="b-table has-pagination"> <div class="table-wrapper has-mobile-cards"> - {instances.length > 0 ? - <Table instances={instances} onSelect={onSelect} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} toBeDeletedHandler={toBeDeletedHandler} /> : + {instances.length > 0 ? + <Table instances={instances} onSelect={onSelect} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} toBeDeletedHandler={toBeDeletedHandler} /> : <EmptyTable /> } </div> diff --git a/src/routes/instances/UpdateModal.stories.tsx b/src/routes/instances/UpdateModal.stories.tsx index db39d61..1ae5bca 100644 --- a/src/routes/instances/UpdateModal.stories.tsx +++ b/src/routes/instances/UpdateModal.stories.tsx @@ -13,7 +13,7 @@ export default { } }; -export const WithDefaultInstance = (a) => <UpdateModal {...a} />; +export const WithDefaultInstance = (a: any) => <UpdateModal {...a} />; WithDefaultInstance.args = { element: { id: 'default', diff --git a/src/routes/instances/UpdateModal.tsx b/src/routes/instances/UpdateModal.tsx index f3f3bb8..d788a91 100644 --- a/src/routes/instances/UpdateModal.tsx +++ b/src/routes/instances/UpdateModal.tsx @@ -33,7 +33,7 @@ interface KeyValue { } export default function UpdateModal({ element, onCancel, onConfirm }: Props): VNode { - const copy = !element ? {} : Object.keys(schema.fields).reduce((prev,cur) => ({...prev, [cur]: (element as any)[cur] }), {}) + const copy: any = !element ? {} : Object.keys(schema.fields).reduce((prev,cur) => ({...prev, [cur]: (element as any)[cur] }), {}) const [value, valueHandler] = useState(copy) const [errors, setErrors] = useState<KeyValue>({}) @@ -63,7 +63,10 @@ export default function UpdateModal({ element, onCancel, onConfirm }: Props): VN <div class="field-body"> <div class="field"> <p class="control is-expanded has-icons-left"> - <input class="input" type="text" placeholder={info?.meta?.placeholder} readonly={info?.meta?.readonly} name={f} value={value[f]} onChange={e => valueHandler(prev => ({ ...prev, [f]: e.currentTarget.value }))} /> + <input class="input" type="text" + placeholder={info?.meta?.placeholder} readonly={info?.meta?.readonly} + name={f} value={value[f]} + onChange={e => valueHandler((prev: any) => ({ ...prev, [f]: e.currentTarget.value }))} /> {info?.meta?.help} </p> {errors[f] ? <p class="help is-danger">{errors[f]}</p> : null} diff --git a/src/routes/instances/View.stories.tsx b/src/routes/instances/View.stories.tsx index 208de17..5804152 100644 --- a/src/routes/instances/View.stories.tsx +++ b/src/routes/instances/View.stories.tsx @@ -13,12 +13,12 @@ export default { }, }; -export const Empty = (a) => <View {...a} />; +export const Empty = (a: any) => <View {...a} />; Empty.args = { instances: [] } -export const WithDefaultInstance = (a) => <View {...a} />; +export const WithDefaultInstance = (a: any) => <View {...a} />; WithDefaultInstance.args = { instances: [{ id: 'default', @@ -28,7 +28,7 @@ WithDefaultInstance.args = { }] } -export const WithTwoInstance = (a) => <View {...a} />; +export const WithTwoInstance = (a: any) => <View {...a} />; WithTwoInstance.args = { instances: [{ id: 'first', diff --git a/src/routes/instances/View.tsx b/src/routes/instances/View.tsx index 6d357af..95af162 100644 --- a/src/routes/instances/View.tsx +++ b/src/routes/instances/View.tsx @@ -9,31 +9,11 @@ interface Props { onDelete: (id: string) => void; onSelect: (id: string | null) => void; selected: MerchantBackend.Instances.QueryInstancesResponse & WidthId | undefined; + isLoading: boolean; } -export default function View({ instances, onCreate, onDelete, onSelect, onUpdate, selected }: Props): VNode { +export default function View({ instances, isLoading, onCreate, onDelete, onSelect, onUpdate, selected }: Props): VNode { return <div id="app"> - <div class="toast"> - <article class="message"> - <div class="message-header"> - <p>Normal message</p> - <button class="delete" aria-label="delete" /> - </div> - <div class="message-body"> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - </div> - </article> - <article class="message is-danger"> - <div class="message-header"> - <p>Normal message</p> - <button class="delete" aria-label="delete" /> - </div> - <div class="message-body"> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - </div> - </article> - </div> - <section class="section is-title-bar"> <div class="level"> @@ -47,7 +27,7 @@ export default function View({ instances, onCreate, onDelete, onSelect, onUpdate </div> </div> </section> - <section class="hero is-hero-bar"> + <section class={ isLoading ? "hero is-hero-bar" : "hero is-hero-bar is-loading" }> <div class="hero-body"> <div class="level"> <div class="level-left"> diff --git a/src/routes/instances/index.tsx b/src/routes/instances/index.tsx index b83eee0..feb98b6 100644 --- a/src/routes/instances/index.tsx +++ b/src/routes/instances/index.tsx @@ -2,21 +2,39 @@ import { h, VNode } from 'preact'; import View from './View'; import LoginPage from '../../components/auth/LoginPage'; import { updateToken, useBackendInstance, useBackendInstances } from '../../components/hooks/backend'; -import { useState } from 'preact/hooks'; +import { useEffect, useState } from 'preact/hooks'; +import { Notification } from '../../declaration'; +interface Props { + pushNotification: (n: Notification) => void; +} -export default function Instances(): VNode { - const list = useBackendInstances() - const [selectedId, select] = useState<string|null>(null) +export default function Instances({ pushNotification }: Props): VNode { + const list = useBackendInstances() + const [selectedId, select] = useState<string | null>(null) const details = useBackendInstance(selectedId) - if (!list.data || (selectedId != null && !details.data)) { + + const requiresToken = (!list.data && list.needsAuth) || (selectedId != null && !details.data && details.needsAuth) + const isLoadingTheList = (!list.data && !list.error) + const isLoadingTheDetails = (!details.data && !details.error) + + useEffect(() => { + if (requiresToken) pushNotification({ + title: `unauthorized access`, + description: 'backend has denied access', + type: 'ERROR' + }) + }, [requiresToken]) + + if (requiresToken) { return <LoginPage onLogIn={updateToken} /> } - return <View instances={list.data.instances} - onCreate={list.create} onUpdate={details.update} + return <View instances={list.data?.instances || []} + isLoading={isLoadingTheList || isLoadingTheDetails} + onCreate={list.create} onUpdate={details.update} onDelete={details.delete} onSelect={select} - selected={ !details.data || !selectedId ? undefined : {...details.data, id:selectedId} } + selected={!details.data || !selectedId ? undefined : { ...details.data, id: selectedId }} />; } diff --git a/src/scss/main.scss b/src/scss/main.scss index 8f6ed75..5146c41 100644 --- a/src/scss/main.scss +++ b/src/scss/main.scss @@ -43,3 +43,20 @@ white-space:pre-wrap; opacity:80%; } + +div { + &.is-loading { + position: relative; + pointer-events: none; + opacity: 0.5; + &:after { + // @include loader; + position: absolute; + top: calc(50% - 2.5em); + left: calc(50% - 2.5em); + width: 5em; + height: 5em; + border-width: 0.25em; + } + } +}
\ No newline at end of file |