aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--src/components/hooks/backend.ts38
-rw-r--r--src/components/notifications/Notifications.stories.tsx49
-rw-r--r--src/components/notifications/index.tsx31
-rw-r--r--src/declaration.d.ts8
-rw-r--r--src/hooks/notifications.ts19
-rw-r--r--src/routes/index.tsx7
-rw-r--r--src/routes/instances/CreateModal.tsx2
-rw-r--r--src/routes/instances/Table.tsx95
-rw-r--r--src/routes/instances/UpdateModal.stories.tsx2
-rw-r--r--src/routes/instances/UpdateModal.tsx7
-rw-r--r--src/routes/instances/View.stories.tsx6
-rw-r--r--src/routes/instances/View.tsx26
-rw-r--r--src/routes/instances/index.tsx34
-rw-r--r--src/scss/main.scss17
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