taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 20c3d4ef149268887107ddcc2b20a84db363dee6
parent 4bf113279530ae1fe2a0f748717e7aff320681ea
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu, 18 May 2023 12:48:01 -0300

add routing

Diffstat:
Mpackages/exchange-backoffice-ui/package.json | 2--
Mpackages/exchange-backoffice-ui/src/App.tsx | 2+-
Apackages/exchange-backoffice-ui/src/Dashboard.tsx | 575+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpackages/exchange-backoffice-ui/src/Dashborad.tsx | 638-------------------------------------------------------------------------------
Apackages/exchange-backoffice-ui/src/pages.ts | 25+++++++++++++++++++++++++
Apackages/exchange-backoffice-ui/src/pages/Home.tsx | 5+++++
Apackages/exchange-backoffice-ui/src/pages/Settings.tsx | 5+++++
Apackages/exchange-backoffice-ui/src/pages/ShowForm.tsx | 20++++++++++++++++++++
Apackages/exchange-backoffice-ui/src/pages/Welcome.tsx | 9+++++++++
Apackages/exchange-backoffice-ui/src/route.ts | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/exchange-backoffice-ui/tsconfig.json | 2+-
11 files changed, 808 insertions(+), 642 deletions(-)

diff --git a/packages/exchange-backoffice-ui/package.json b/packages/exchange-backoffice-ui/package.json @@ -27,8 +27,6 @@ "history": "4.10.1", "jed": "1.1.1", "preact": "10.11.3", - "preact-router": "3.2.1", - "qrcode-generator": "^1.4.4", "swr": "2.0.3" }, "eslintConfig": { diff --git a/packages/exchange-backoffice-ui/src/App.tsx b/packages/exchange-backoffice-ui/src/App.tsx @@ -1,6 +1,6 @@ import { TranslationProvider } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; -import { Dashboard } from "./Dashborad.js"; +import { Dashboard } from "./Dashboard.js"; import "./scss/main.css"; export function App(): VNode { diff --git a/packages/exchange-backoffice-ui/src/Dashboard.tsx b/packages/exchange-backoffice-ui/src/Dashboard.tsx @@ -0,0 +1,575 @@ +import { Dialog, Menu, Transition } from "@headlessui/react"; +import { + ChevronDownIcon, + MagnifyingGlassIcon, +} from "@heroicons/react/20/solid"; +import { + Bars3Icon, + BellIcon, + Cog6ToothIcon, + DocumentDuplicateIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { ForwardedRef, forwardRef } from "preact/compat"; +import { useRef, useState } from "preact/hooks"; +import { v1 as form_902_11e_v1 } from "./forms/902_11e.js"; +import { v1 as form_902_12e_v1 } from "./forms/902_12e.js"; +import { v1 as form_902_13e_v1 } from "./forms/902_13e.js"; +import { v1 as form_902_15e_v1 } from "./forms/902_15e.js"; +import { v1 as form_902_1e_v1 } from "./forms/902_1e.js"; +import { v1 as form_902_4e_v1 } from "./forms/902_4e.js"; +import { v1 as form_902_5e_v1 } from "./forms/902_5e.js"; +import { v1 as form_902_9e_v1 } from "./forms/902_9e.js"; +import { Pages } from "./pages.js"; +import { Router, useCurrentLocation } from "./route.js"; + +/** + * references between forms + * + * 902.1e + * --> 902.11 (operational legal entity or partnership) + * --> 902.12 (a foundation) + * --> 902.13 (a trust) + * --> 902.15 (life insurance policy) + * --> 902.9 (all other cases) + * --> 902.5 (cash transaction with no customer profile) + * --> 902.4 (risk profile) + * + * 902.11 + * --> 902.9 (beneficial owner in fiduciary holding assets) + * + * 902.12 + * + * 902.13 + * + * 902.15 + * + * 902.9 + * + * 902.5 + * + * 902.4 + */ + +export const allForms = [ + { + name: "Identification form (902.1e)", + icon: DocumentDuplicateIcon, + impl: form_902_1e_v1, + }, + { + name: "Operational legal entity or partnership (902.11e)", + icon: DocumentDuplicateIcon, + impl: form_902_11e_v1, + }, + { + name: "Foundations (902.12e)", + icon: DocumentDuplicateIcon, + impl: form_902_12e_v1, + }, + { + name: "Declaration for trusts (902.13e)", + icon: DocumentDuplicateIcon, + impl: form_902_13e_v1, + }, + { + name: "Information on life insurance policies (902.15e)", + icon: DocumentDuplicateIcon, + impl: form_902_15e_v1, + }, + { + name: "Declaration of beneficial owner (902.9e)", + icon: DocumentDuplicateIcon, + impl: form_902_9e_v1, + }, + { + name: "Customer profile (902.5e)", + icon: DocumentDuplicateIcon, + impl: form_902_5e_v1, + }, + { + name: "Risk profile (902.4e)", + icon: DocumentDuplicateIcon, + impl: form_902_4e_v1, + }, +]; +const teams = [ + { id: 1, name: "Heroicons", href: "#", initial: "H", current: false }, + { id: 2, name: "Tailwind Labs", href: "#", initial: "T", current: false }, + { id: 3, name: "Workcation", href: "#", initial: "W", current: false }, +]; +const userNavigation = [ + { name: "Your profile", href: "#" }, + { name: "Sign out", href: "#" }, +]; + +function classNames(...classes: string[]) { + return classes.filter(Boolean).join(" "); +} + +/** + * mapping route to view + * not found (error page) + * nested, index element, relative routes + * link interception + * form POST interception, call action + * fromData => Object.fromEntries + * segments in the URL + * navigationState: idle, submitting, loading + * form GET interception: does a navigateTo + * form GET Sync: + * 1.- back after submit: useEffect to sync URL to form + * 2.- refresh after submit: input default value + * useSubmit for form submission onChange, history replace + * + * post form without redirect + * + * + * @param param0 + * @returns + */ + +const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; +const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; + +const versionText = VERSION + ? GIT_HASH + ? `Version ${VERSION} (${GIT_HASH.substring(0, 8)})` + : VERSION + : ""; + +/** + * TO BE FIXED: + * + * 1.- when the form change to other form and both form share the same structure + * the same input component may be rendered in the same place, + * since input are uncontrolled the are not re-rendered and since they are + * uncontrolled it will keep the value of the previous form. + * One solutions could be to remove the form when unloading and when the new + * form load it will start without previous vdom, preventing the cache + * to create this behavior. + * Other solutions could be using IDs in the fields that are constructed + * with the ID of the form, so two fields of different form will need to re-render + * cleaning up the state of the previous form. + * + * 2.- currently the design prop and the behavior prop of the flexible form + * are two side of the same coin. From the design point of view, it is important + * to design the form in a list-of-field manner and there may be additional + * content that is not directly mapped to the form structure (object) + * So maybe we want to change the current shape so the computation of the state + * of the form is in a field level, but this computation required the field value and + * the whole form values and state (since one field may be disabled/hidden) because + * of the value of other field. + * + * 3.- given the previous requirement, maybe the name of the field of the form could be + * a function (P: F -> V) where F is the form (or parent object) and V is the type of the + * property. That will help with the typing of the forms props + * + * 4.- tooltip are not placed correctly: the arrow should point the question mark + * and the text area should be bigger + * + * 5.- date field should have the calendar icon clickable so the user can select date without + * writing text with the correct format + */ + +export function Dashboard({ + children, +}: { + children?: ComponentChildren; +}): VNode { + const [sidebarOpen, setSidebarOpen] = useState(false); + + const logRef = useRef<HTMLPreElement>(null); + function showFormOnSidebar(v: any) { + if (!logRef.current) return; + logRef.current.innerHTML = JSON.stringify(v, undefined, 1); + } + + const Nav = forwardRef(NavigationBar); + return ( + <Fragment> + <Nav ref={logRef} isOpen={sidebarOpen} setOpen={setSidebarOpen} /> + <div class="lg:pl-72"> + <TopBar + onOpenSidebar={() => { + setSidebarOpen(true); + }} + /> + <main class="py-10 px-4 sm:px-6 lg:px-8"> + <div class="mx-auto max-w-3xl"> + <Router + pageList={pageList} + onNotFound={() => { + return <div>not found</div>; + }} + /> + </div> + </main> + + <Footer /> + </div> + </Fragment> + ); +} + +const pageList = Object.values(Pages); + +function NavigationBar( + { isOpen, setOpen }: { isOpen: boolean; setOpen: (v: boolean) => void }, + logRef: ForwardedRef<HTMLPreElement>, +) { + const currentLocation = useCurrentLocation(pageList); + return ( + <Fragment> + <Transition.Root show={isOpen} as={Fragment}> + <Dialog + as="div" + /* @ts-ignore */ + class="relative z-50 lg:hidden" + onClose={setOpen} + > + <Transition.Child + as={Fragment} + enter="transition-opacity ease-linear duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="transition-opacity ease-linear duration-300" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <div class="fixed inset-0 bg-gray-900/80" /> + </Transition.Child> + + <div class="fixed inset-0 flex"> + <Transition.Child + as={Fragment} + enter="transition ease-in-out duration-300 transform" + enterFrom="-translate-x-full" + enterTo="translate-x-0" + leave="transition ease-in-out duration-300 transform" + leaveFrom="translate-x-0" + leaveTo="-translate-x-full" + > + <Dialog.Panel class="relative mr-16 flex w-full max-w-xs flex-1"> + <Transition.Child + as={Fragment} + enter="ease-in-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in-out duration-300" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > + <div class="absolute left-full top-0 flex w-16 justify-center pt-5"> + <button + type="button" + class="-m-2.5 p-2.5" + onClick={() => setOpen(false)} + > + <span class="sr-only">Close sidebar</span> + <XMarkIcon + class="h-6 w-6 text-white" + aria-hidden="true" + /> + </button> + </div> + </Transition.Child> + <div class="flex grow flex-col gap-y-5 overflow-y-auto bg-indigo-600 px-6 pb-4"> + <div class="flex h-16 shrink-0 items-center"> + <img + class="h-8 w-auto" + src="https://tailwindui.com/img/logos/mark.svg?color=white" + alt="Your Company" + /> + </div> + <nav class="flex flex-1 flex-col"> + <ul role="list" class="flex flex-1 flex-col gap-y-7"> + <li> + <ul role="list" class="-mx-2 space-y-1"> + {allForms.map((item, idx) => { + const url = Pages.form.url({ number: String(idx) }); + return ( + <li key={item.name}> + <a + href={url} + class={classNames( + url === currentLocation?.path + ? "bg-indigo-700 text-white" + : "text-indigo-200 hover:text-white hover:bg-indigo-700", + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", + )} + > + <item.icon + class={classNames( + url === currentLocation?.path + ? "text-white" + : "text-indigo-200 group-hover:text-white", + "h-6 w-6 shrink-0", + )} + aria-hidden="true" + /> + {item.name} + </a> + </li> + ); + })} + </ul> + </li> + {/* <li> + <div class="text-xs font-semibold leading-6 text-indigo-200"> + Your teams + </div> + <ul role="list" class="-mx-2 mt-2 space-y-1"> + {teams.map((team) => ( + <li key={team.name}> + <a + href={team.href} + class={classNames( + team.current + ? "bg-indigo-700 text-white" + : "text-indigo-200 hover:text-white hover:bg-indigo-700", + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", + )} + > + <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-indigo-400 bg-indigo-500 text-[0.625rem] font-medium text-white"> + {team.initial} + </span> + <span class="truncate">{team.name}</span> + </a> + </li> + ))} + </ul> + </li> */} + <li class="mt-auto"> + <a + href={Pages.settings.url} + class="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-indigo-200 hover:bg-indigo-700 hover:text-white" + > + <Cog6ToothIcon + class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white" + aria-hidden="true" + /> + Settings + </a> + </li> + </ul> + </nav> + </div> + </Dialog.Panel> + </Transition.Child> + </div> + </Dialog> + </Transition.Root> + + <div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col"> + <div class="flex grow flex-col gap-y-5 overflow-y-auto bg-indigo-600 px-6 pb-4"> + <div class="flex h-16 shrink-0 items-center"> + <img + class="h-8 w-auto" + src="https://tailwindui.com/img/logos/mark.svg?color=white" + alt="Your Company" + /> + </div> + <nav class="flex flex-1 flex-col"> + <ul role="list" class="flex flex-1 flex-col gap-y-7"> + <li> + <ul role="list" class="-mx-2 space-y-1"> + {allForms.map((item, idx) => { + const url = Pages.form.url({ number: String(idx) }); + return ( + <li key={item.name}> + <a + href={url} + class={classNames( + url === currentLocation?.path + ? "bg-indigo-700 text-white" + : "text-indigo-200 hover:text-white hover:bg-indigo-700", + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", + )} + > + <item.icon + class={classNames( + url === currentLocation?.path + ? "text-white" + : "text-indigo-200 group-hover:text-white", + "h-6 w-6 shrink-0", + )} + aria-hidden="true" + /> + {item.name} + </a> + </li> + ); + })} + </ul> + </li> + {/* <li> + <div class="text-xs font-semibold leading-6 text-indigo-200"> + Your teams + </div> + <ul role="list" class="-mx-2 mt-2 space-y-1"> + {teams.map((team) => ( + <li key={team.name}> + <a + href={team.href} + class={classNames( + team.current + ? "bg-indigo-700 text-white" + : "text-indigo-200 hover:text-white hover:bg-indigo-700", + "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", + )} + > + <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-indigo-400 bg-indigo-500 text-[0.625rem] font-medium text-white"> + {team.initial} + </span> + <span class="truncate">{team.name}</span> + </a> + </li> + ))} + </ul> + </li> */} + <li class="mt-auto"> + <a + href={Pages.settings.url} + class="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-indigo-200 hover:bg-indigo-700 hover:text-white" + > + <Cog6ToothIcon + class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white" + aria-hidden="true" + /> + Settings + </a> + </li> + </ul> + </nav> + <div class="text-white text-sm"> + <pre ref={logRef}></pre> + </div> + </div> + </div> + </Fragment> + ); +} + +function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) { + return ( + <div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"> + <button + type="button" + class="-m-2.5 p-2.5 text-gray-700 lg:hidden" + onClick={onOpenSidebar} + > + <span class="sr-only">Open sidebar</span> + <Bars3Icon class="h-6 w-6" aria-hidden="true" /> + </button> + + {/* Separator */} + <div class="h-6 w-px bg-gray-900/10 lg:hidden" aria-hidden="true" /> + + <div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6"> + <form class="relative flex flex-1" action="#" method="GET"> + <label htmlFor="search-field" class="sr-only"> + Search + </label> + <MagnifyingGlassIcon + class="pointer-events-none absolute inset-y-0 left-0 h-full w-5 text-gray-400" + aria-hidden="true" + /> + <input + id="search-field" + class="block h-full w-full border-0 py-0 pl-8 pr-0 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm" + placeholder="Search..." + type="search" + name="search" + /> + </form> + <div class="flex items-center gap-x-4 lg:gap-x-6"> + <button + type="button" + class="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500" + > + <span class="sr-only">View notifications</span> + <BellIcon class="h-6 w-6" aria-hidden="true" /> + </button> + + {/* Separator */} + <div + class="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-900/10" + aria-hidden="true" + /> + + {/* Profile dropdown */} + <Menu + as="div" + /* @ts-ignore */ + class="relative" + > + <Menu.Button class="-m-1.5 flex items-center p-1.5"> + <span class="sr-only">Open user menu</span> + <img + class="h-8 w-8 rounded-full bg-gray-50" + src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" + alt="" + /> + <span class="hidden lg:flex lg:items-center"> + <span + class="ml-4 text-sm font-semibold leading-6 text-gray-900" + aria-hidden="true" + > + Tom Cook + </span> + <ChevronDownIcon + class="ml-2 h-5 w-5 text-gray-400" + aria-hidden="true" + /> + </span> + </Menu.Button> + <Transition + as={Fragment} + enter="transition ease-out duration-100" + enterFrom="transform opacity-0 scale-95" + enterTo="transform opacity-100 scale-100" + leave="transition ease-in duration-75" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95" + > + <Menu.Items class="absolute right-0 z-10 mt-2.5 w-32 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none"> + {userNavigation.map((item) => ( + <Menu.Item key={item.name}> + {({ active }: { active: boolean }) => ( + <a + href={item.href} + class={classNames( + active ? "bg-gray-50" : "", + "block px-3 py-1 text-sm leading-6 text-gray-900", + )} + > + {item.name} + </a> + )} + </Menu.Item> + ))} + </Menu.Items> + </Transition> + </Menu> + </div> + </div> + </div> + ); +} + +function Footer() { + return ( + <footer class="bg-white"> + <div class="mx-auto px-4 py-2 md:flex md:items-center md:justify-between lg:px-8"> + <div class="mt-8 md:order-1 md:mt-0"> + <p class="text-center text-xs leading-5 text-gray-500"> + Copyright &copy; 2014&mdash;2023 Taler Systems SA. + {versionText} + </p> + </div> + </div> + </footer> + ); +} diff --git a/packages/exchange-backoffice-ui/src/Dashborad.tsx b/packages/exchange-backoffice-ui/src/Dashborad.tsx @@ -1,638 +0,0 @@ -import { Dialog, Menu, Transition } from "@headlessui/react"; -import { - ChevronDownIcon, - MagnifyingGlassIcon, -} from "@heroicons/react/20/solid"; -import { - Bars3Icon, - BellIcon, - Cog6ToothIcon, - DocumentDuplicateIcon, - XMarkIcon, -} from "@heroicons/react/24/outline"; -import { ComponentChildren, Fragment, VNode, h } from "preact"; -import { useEffect, useReducer, useRef, useState } from "preact/hooks"; -import { NiceForm } from "./NiceForm.js"; -import { v1 as form_902_11e_v1 } from "./forms/902_11e.js"; -import { v1 as form_902_12e_v1 } from "./forms/902_12e.js"; -import { v1 as form_902_13e_v1 } from "./forms/902_13e.js"; -import { v1 as form_902_15e_v1 } from "./forms/902_15e.js"; -import { v1 as form_902_1e_v1 } from "./forms/902_1e.js"; -import { v1 as form_902_4e_v1 } from "./forms/902_4e.js"; -import { v1 as form_902_5e_v1 } from "./forms/902_5e.js"; -import { v1 as form_902_9e_v1 } from "./forms/902_9e.js"; -import { FlexibleForm } from "./forms/index.js"; -import { forwardRef } from "preact/compat"; -import { ForwardedRef } from "preact/compat"; -import { createHashHistory } from "history"; - -const history = createHashHistory(); - -/** - * references between forms - * - * 902.1e - * --> 902.11 (operational legal entity or partnership) - * --> 902.12 (a foundation) - * --> 902.13 (a trust) - * --> 902.15 (life insurance policy) - * --> 902.9 (all other cases) - * --> 902.5 (cash transaction with no customer profile) - * --> 902.4 (risk profile) - * - * 902.11 - * --> 902.9 (beneficial owner in fiduciary holding assets) - * - * 902.12 - * - * 902.13 - * - * 902.15 - * - * 902.9 - * - * 902.5 - * - * 902.4 - */ - -const allForms = [ - { - name: "Identification form (902.1e)", - icon: DocumentDuplicateIcon, - impl: form_902_1e_v1, - }, - { - name: "Operational legal entity or partnership (902.11e)", - icon: DocumentDuplicateIcon, - impl: form_902_11e_v1, - }, - { - name: "Foundations (902.12e)", - icon: DocumentDuplicateIcon, - impl: form_902_12e_v1, - }, - { - name: "Declaration for trusts (902.13e)", - icon: DocumentDuplicateIcon, - impl: form_902_13e_v1, - }, - { - name: "Information on life insurance policies (902.15e)", - icon: DocumentDuplicateIcon, - impl: form_902_15e_v1, - }, - { - name: "Declaration of beneficial owner (902.9e)", - icon: DocumentDuplicateIcon, - impl: form_902_9e_v1, - }, - { - name: "Customer profile (902.5e)", - icon: DocumentDuplicateIcon, - impl: form_902_5e_v1, - }, - { - name: "Risk profile (902.4e)", - icon: DocumentDuplicateIcon, - impl: form_902_4e_v1, - }, -]; -const teams = [ - { id: 1, name: "Heroicons", href: "#", initial: "H", current: false }, - { id: 2, name: "Tailwind Labs", href: "#", initial: "T", current: false }, - { id: 3, name: "Workcation", href: "#", initial: "W", current: false }, -]; -const userNavigation = [ - { name: "Your profile", href: "#" }, - { name: "Sign out", href: "#" }, -]; - -function classNames(...classes: string[]) { - return classes.filter(Boolean).join(" "); -} - -/** - * mapping route to view - * not found (error page) - * nested, index element, relative routes - * link interception - * form POST interception, call action - * fromData => Object.fromEntries - * segments in the URL - * navigationState: idle, submitting, loading - * form GET interception: does a navigateTo - * form GET Sync: - * 1.- back after submit: useEffect to sync URL to form - * 2.- refresh after submit: input default value - * useSubmit for form submission onChange, history replace - * - * post form without redirect - * - * - * @param param0 - * @returns - */ - -const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; -const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; - -const versionText = VERSION - ? GIT_HASH - ? `Version ${VERSION} (${GIT_HASH.substring(0, 8)})` - : VERSION - : ""; - -/** - * TO BE FIXED: - * - * 1.- when the form change to other form and both form share the same structure - * the same input component may be rendered in the same place, - * since input are uncontrolled the are not re-rendered and since they are - * uncontrolled it will keep the value of the previous form. - * One solutions could be to remove the form when unloading and when the new - * form load it will start without previous vdom, preventing the cache - * to create this behavior. - * Other solutions could be using IDs in the fields that are constructed - * with the ID of the form, so two fields of different form will need to re-render - * cleaning up the state of the previous form. - * - * 2.- currently the design prop and the behavior prop of the flexible form - * are two side of the same coin. From the design point of view, it is important - * to design the form in a list-of-field manner and there may be additional - * content that is not directly mapped to the form structure (object) - * So maybe we want to change the current shape so the computation of the state - * of the form is in a field level, but this computation required the field value and - * the whole form values and state (since one field may be disabled/hidden) because - * of the value of other field. - * - * 3.- given the previous requirement, maybe the name of the field of the form could be - * a function (P: F -> V) where F is the form (or parent object) and V is the type of the - * property. That will help with the typing of the forms props - * - * 4.- tooltip are not placed correctly: the arrow should point the question mark - * and the text area should be bigger - * - * 5.- date field should have the calendar icon clickable so the user can select date without - * writing text with the correct format - */ - -export function Dashboard({ - children, -}: { - children?: ComponentChildren; -}): VNode { - const [sidebarOpen, setSidebarOpen] = useState(false); - - const logRef = useRef<HTMLPreElement>(null); - function showFormOnSidebar(v: any) { - if (!logRef.current) return; - logRef.current.innerHTML = JSON.stringify(v, undefined, 1); - } - - const Nav = forwardRef(NavigationBar); - return ( - <Fragment> - <Nav ref={logRef} isOpen={sidebarOpen} setOpen={setSidebarOpen} /> - <div class="lg:pl-72"> - <TopBar - onOpenSidebar={() => { - setSidebarOpen(true); - }} - /> - <main class="py-10 px-4 sm:px-6 lg:px-8"> - <div class="mx-auto max-w-3xl"> - <Route - onUpdate={(v) => { - showFormOnSidebar(v); - }} - /> - </div> - </main> - - <Footer /> - </div> - </Fragment> - ); -} - -function Route({ onUpdate }: { onUpdate: (v: any) => void }) { - const [page, setPage] = useState<undefined | number>(); - function doSync(path: string) { - try { - if (path.startsWith("/form")) { - const formNumber = path.substring("/form".length); - const num = Number.parseInt(formNumber, 10); - if (!Number.isNaN(num) && num >= 0 && num < allForms.length) { - setPage(num); - } else { - setPage(undefined); - } - } else { - setPage(undefined); - } - } catch (e) { - setPage(undefined); - } - } - useEffect(() => { - doSync(history.location.pathname); - return history.listen((location, action) => { - doSync(location.pathname); - }); - }, []); - if (page !== undefined) { - return <Content onUpdate={onUpdate} selectedForm={page} />; - } - return <div>not found</div>; -} - -function useCurrentLocation() { - const [currentLocation, setCurrentLocation] = useState( - history.location.pathname, - ); - useEffect(() => { - return history.listen((location) => { - setCurrentLocation(location.pathname); - }); - }); - return currentLocation; -} - -function NavigationBar( - { isOpen, setOpen }: { isOpen: boolean; setOpen: (v: boolean) => void }, - logRef: ForwardedRef<HTMLPreElement>, -) { - const currentLocation = useCurrentLocation(); - return ( - <Fragment> - <Transition.Root show={isOpen} as={Fragment}> - <Dialog - as="div" - /* @ts-ignore */ - class="relative z-50 lg:hidden" - onClose={setOpen} - > - <Transition.Child - as={Fragment} - enter="transition-opacity ease-linear duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="transition-opacity ease-linear duration-300" - leaveFrom="opacity-100" - leaveTo="opacity-0" - > - <div class="fixed inset-0 bg-gray-900/80" /> - </Transition.Child> - - <div class="fixed inset-0 flex"> - <Transition.Child - as={Fragment} - enter="transition ease-in-out duration-300 transform" - enterFrom="-translate-x-full" - enterTo="translate-x-0" - leave="transition ease-in-out duration-300 transform" - leaveFrom="translate-x-0" - leaveTo="-translate-x-full" - > - <Dialog.Panel class="relative mr-16 flex w-full max-w-xs flex-1"> - <Transition.Child - as={Fragment} - enter="ease-in-out duration-300" - enterFrom="opacity-0" - enterTo="opacity-100" - leave="ease-in-out duration-300" - leaveFrom="opacity-100" - leaveTo="opacity-0" - > - <div class="absolute left-full top-0 flex w-16 justify-center pt-5"> - <button - type="button" - class="-m-2.5 p-2.5" - onClick={() => setOpen(false)} - > - <span class="sr-only">Close sidebar</span> - <XMarkIcon - class="h-6 w-6 text-white" - aria-hidden="true" - /> - </button> - </div> - </Transition.Child> - <div class="flex grow flex-col gap-y-5 overflow-y-auto bg-indigo-600 px-6 pb-4"> - <div class="flex h-16 shrink-0 items-center"> - <img - class="h-8 w-auto" - src="https://tailwindui.com/img/logos/mark.svg?color=white" - alt="Your Company" - /> - </div> - <nav class="flex flex-1 flex-col"> - <ul role="list" class="flex flex-1 flex-col gap-y-7"> - <li> - <ul role="list" class="-mx-2 space-y-1"> - {allForms.map((item, idx) => ( - <li key={item.name}> - <a - href={`#/form${idx}`} - class={classNames( - `/${idx}` === currentLocation - ? "bg-indigo-700 text-white" - : "text-indigo-200 hover:text-white hover:bg-indigo-700", - "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", - )} - > - <item.icon - class={classNames( - `/${idx}` === currentLocation - ? "text-white" - : "text-indigo-200 group-hover:text-white", - "h-6 w-6 shrink-0", - )} - aria-hidden="true" - /> - {item.name} - </a> - </li> - ))} - </ul> - </li> - {/* <li> - <div class="text-xs font-semibold leading-6 text-indigo-200"> - Your teams - </div> - <ul role="list" class="-mx-2 mt-2 space-y-1"> - {teams.map((team) => ( - <li key={team.name}> - <a - href={team.href} - class={classNames( - team.current - ? "bg-indigo-700 text-white" - : "text-indigo-200 hover:text-white hover:bg-indigo-700", - "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", - )} - > - <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-indigo-400 bg-indigo-500 text-[0.625rem] font-medium text-white"> - {team.initial} - </span> - <span class="truncate">{team.name}</span> - </a> - </li> - ))} - </ul> - </li> */} - <li class="mt-auto"> - <a - href="#" - class="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-indigo-200 hover:bg-indigo-700 hover:text-white" - > - <Cog6ToothIcon - class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white" - aria-hidden="true" - /> - Settings - </a> - </li> - </ul> - </nav> - </div> - </Dialog.Panel> - </Transition.Child> - </div> - </Dialog> - </Transition.Root> - - <div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col"> - <div class="flex grow flex-col gap-y-5 overflow-y-auto bg-indigo-600 px-6 pb-4"> - <div class="flex h-16 shrink-0 items-center"> - <img - class="h-8 w-auto" - src="https://tailwindui.com/img/logos/mark.svg?color=white" - alt="Your Company" - /> - </div> - <nav class="flex flex-1 flex-col"> - <ul role="list" class="flex flex-1 flex-col gap-y-7"> - <li> - <ul role="list" class="-mx-2 space-y-1"> - {allForms.map((item, idx) => ( - <li key={item.name}> - <a - href={`#/form${idx}`} - class={classNames( - `/${idx}` === currentLocation - ? "bg-indigo-700 text-white" - : "text-indigo-200 hover:text-white hover:bg-indigo-700", - "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", - )} - > - <item.icon - class={classNames( - `/${idx}` === currentLocation - ? "text-white" - : "text-indigo-200 group-hover:text-white", - "h-6 w-6 shrink-0", - )} - aria-hidden="true" - /> - {item.name} - </a> - </li> - ))} - </ul> - </li> - {/* <li> - <div class="text-xs font-semibold leading-6 text-indigo-200"> - Your teams - </div> - <ul role="list" class="-mx-2 mt-2 space-y-1"> - {teams.map((team) => ( - <li key={team.name}> - <a - href={team.href} - class={classNames( - team.current - ? "bg-indigo-700 text-white" - : "text-indigo-200 hover:text-white hover:bg-indigo-700", - "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold", - )} - > - <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border border-indigo-400 bg-indigo-500 text-[0.625rem] font-medium text-white"> - {team.initial} - </span> - <span class="truncate">{team.name}</span> - </a> - </li> - ))} - </ul> - </li> */} - <li class="mt-auto"> - <a - href="#" - class="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-indigo-200 hover:bg-indigo-700 hover:text-white" - > - <Cog6ToothIcon - class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white" - aria-hidden="true" - /> - Settings - </a> - </li> - </ul> - </nav> - <div class="text-white text-sm"> - <pre ref={logRef}></pre> - </div> - </div> - </div> - </Fragment> - ); -} - -function Content({ - onUpdate, - selectedForm, -}: { - onUpdate: (v: any) => void; - selectedForm: number; -}) { - const showingFrom = allForms[selectedForm].impl; - const storedValue = { - fullName: "loggedIn_user_fullname", - when: { - t_ms: new Date().getTime(), - }, - }; - useEffect(() => { - // initial render - onUpdate(storedValue); - }); - return ( - <Fragment> - <NiceForm initial={storedValue} form={showingFrom} onUpdate={onUpdate} /> - </Fragment> - ); -} - -function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) { - return ( - <div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"> - <button - type="button" - class="-m-2.5 p-2.5 text-gray-700 lg:hidden" - onClick={onOpenSidebar} - > - <span class="sr-only">Open sidebar</span> - <Bars3Icon class="h-6 w-6" aria-hidden="true" /> - </button> - - {/* Separator */} - <div class="h-6 w-px bg-gray-900/10 lg:hidden" aria-hidden="true" /> - - <div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6"> - <form class="relative flex flex-1" action="#" method="GET"> - <label htmlFor="search-field" class="sr-only"> - Search - </label> - <MagnifyingGlassIcon - class="pointer-events-none absolute inset-y-0 left-0 h-full w-5 text-gray-400" - aria-hidden="true" - /> - <input - id="search-field" - class="block h-full w-full border-0 py-0 pl-8 pr-0 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm" - placeholder="Search..." - type="search" - name="search" - /> - </form> - <div class="flex items-center gap-x-4 lg:gap-x-6"> - <button - type="button" - class="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500" - > - <span class="sr-only">View notifications</span> - <BellIcon class="h-6 w-6" aria-hidden="true" /> - </button> - - {/* Separator */} - <div - class="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-900/10" - aria-hidden="true" - /> - - {/* Profile dropdown */} - <Menu - as="div" - /* @ts-ignore */ - class="relative" - > - <Menu.Button class="-m-1.5 flex items-center p-1.5"> - <span class="sr-only">Open user menu</span> - <img - class="h-8 w-8 rounded-full bg-gray-50" - src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" - alt="" - /> - <span class="hidden lg:flex lg:items-center"> - <span - class="ml-4 text-sm font-semibold leading-6 text-gray-900" - aria-hidden="true" - > - Tom Cook - </span> - <ChevronDownIcon - class="ml-2 h-5 w-5 text-gray-400" - aria-hidden="true" - /> - </span> - </Menu.Button> - <Transition - as={Fragment} - enter="transition ease-out duration-100" - enterFrom="transform opacity-0 scale-95" - enterTo="transform opacity-100 scale-100" - leave="transition ease-in duration-75" - leaveFrom="transform opacity-100 scale-100" - leaveTo="transform opacity-0 scale-95" - > - <Menu.Items class="absolute right-0 z-10 mt-2.5 w-32 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none"> - {userNavigation.map((item) => ( - <Menu.Item key={item.name}> - {({ active }: { active: boolean }) => ( - <a - href={item.href} - class={classNames( - active ? "bg-gray-50" : "", - "block px-3 py-1 text-sm leading-6 text-gray-900", - )} - > - {item.name} - </a> - )} - </Menu.Item> - ))} - </Menu.Items> - </Transition> - </Menu> - </div> - </div> - </div> - ); -} - -function Footer() { - return ( - <footer class="bg-white"> - <div class="mx-auto px-4 py-2 md:flex md:items-center md:justify-between lg:px-8"> - <div class="mt-8 md:order-1 md:mt-0"> - <p class="text-center text-xs leading-5 text-gray-500"> - Copyright &copy; 2014&mdash;2023 Taler Systems SA. - {versionText} - </p> - </div> - </div> - </footer> - ); -} diff --git a/packages/exchange-backoffice-ui/src/pages.ts b/packages/exchange-backoffice-ui/src/pages.ts @@ -0,0 +1,25 @@ +import { Home } from "./pages/Home.js"; +import { Settings } from "./pages/Settings.js"; +import { ShowForm } from "./pages/ShowForm.js"; +import { Welcome } from "./pages/Welcome.js"; +import { PageEntry, pageDefinition } from "./route.js"; + +const home: PageEntry = { + url: "#/", + view: Home, +}; + +const settings: PageEntry = { + url: "#/settings", + view: Settings, +}; +const welcome: PageEntry<{ asd?: string; name?: string }> = { + url: pageDefinition("#/welcome/:name?"), + view: Welcome, +}; +const form: PageEntry<{ number?: string }> = { + url: pageDefinition("#/form/:number?"), + view: ShowForm, +}; + +export const Pages = { home, settings, welcome, form }; diff --git a/packages/exchange-backoffice-ui/src/pages/Home.tsx b/packages/exchange-backoffice-ui/src/pages/Home.tsx @@ -0,0 +1,5 @@ +import { h } from "preact"; + +export function Home() { + return <div>Home</div>; +} diff --git a/packages/exchange-backoffice-ui/src/pages/Settings.tsx b/packages/exchange-backoffice-ui/src/pages/Settings.tsx @@ -0,0 +1,5 @@ +import { h } from "preact"; + +export function Settings() { + return <div>Settings</div>; +} diff --git a/packages/exchange-backoffice-ui/src/pages/ShowForm.tsx b/packages/exchange-backoffice-ui/src/pages/ShowForm.tsx @@ -0,0 +1,20 @@ +import { h } from "preact"; +import { allForms } from "../Dashboard.js"; +import { NiceForm } from "../NiceForm.js"; + +export function ShowForm({ number }: { number?: string }) { + const selectedForm = Number.parseInt(number ?? "0", 10); + if (Number.isNaN(selectedForm)) { + return <div>WHAT! {number}</div>; + } + const showingFrom = allForms[selectedForm].impl; + const storedValue = { + fullName: "loggedIn_user_fullname", + when: { + t_ms: new Date().getTime(), + }, + }; + return ( + <NiceForm initial={storedValue} form={showingFrom} onUpdate={() => {}} /> + ); +} diff --git a/packages/exchange-backoffice-ui/src/pages/Welcome.tsx b/packages/exchange-backoffice-ui/src/pages/Welcome.tsx @@ -0,0 +1,9 @@ +import { h } from "preact"; + +export function Welcome({ name, asd }: { asd?: string; name?: string }) { + return ( + <div> + {asd} Hello {name} + </div> + ); +} diff --git a/packages/exchange-backoffice-ui/src/route.ts b/packages/exchange-backoffice-ui/src/route.ts @@ -0,0 +1,167 @@ +import { createHashHistory } from "history"; +import { VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +const history = createHashHistory(); + +type PageDefinition<DynamicPart extends Record<string, string>> = { + pattern: string; + (params: DynamicPart): string; +}; + +function replaceAll( + pattern: string, + vars: Record<string, string>, + values: Record<string, string>, +): string { + let result = pattern; + for (const v in vars) { + result = result.replace(vars[v], !values[v] ? "" : values[v]); + } + return result; +} + +export function pageDefinition<T extends Record<string, string>>( + pattern: string, +): PageDefinition<T> { + const patternParams = pattern.match(/(:[\w?]*)/g); + if (!patternParams) + throw Error( + `page definition pattern ${pattern} doesn't have any parameter`, + ); + + const vars = patternParams.reduce((prev, cur) => { + const pName = cur.match(/(\w+)/g); + + //skip things like :? in the path pattern + if (!pName || !pName[0]) return prev; + const name = pName[0]; + return { ...prev, [name]: cur }; + }, {} as Record<string, string>); + + const f = (values: T): string => replaceAll(pattern, vars, values); + f.pattern = pattern; + return f; +} + +export type PageEntry<T = unknown> = T extends Record<string, string> + ? { + url: PageDefinition<T>; + view: (props: T) => VNode; + } + : T extends unknown + ? { + url: string; + view: (props: {}) => VNode; + } + : never; + +export function Router({ + pageList, + onNotFound, +}: { + pageList: Array<PageEntry<any>>; + onNotFound: () => VNode; +}): VNode { + const current = useCurrentLocation(pageList); + if (current !== undefined) { + return current.page.view(current.values ?? {}); + } + return onNotFound(); +} + +type Location = { + page: PageEntry<any>; + path: string; + values: Record<string, string>; +}; +export function useCurrentLocation(pageList: Array<PageEntry<any>>) { + const [currentLocation, setCurrentLocation] = useState<Location>(); + /** + * Search path in the pageList + * get the values from the path found + * add params from searchParams + * + * @param path + * @param params + */ + function doSync(path: string, params: URLSearchParams) { + let result: typeof currentLocation; + for (let idx = 0; idx < pageList.length; idx++) { + const page = pageList[idx]; + if (typeof page.url === "string") { + if (page.url === path) { + const values: Record<string, string> = {}; + params.forEach((v, k) => { + values[k] = v; + }); + result = { page, values, path }; + break; + } + } else { + const values = doestUrlMatchToRoute(path, page.url.pattern); + if (values !== undefined) { + params.forEach((v, k) => { + values[k] = v; + }); + result = { page, values, path }; + break; + } + } + } + setCurrentLocation(result); + } + useEffect(() => { + doSync(window.location.hash, new URLSearchParams(window.location.search)); + return history.listen(() => { + doSync(window.location.hash, new URLSearchParams(window.location.search)); + }); + }, []); + return currentLocation; +} + +function doestUrlMatchToRoute( + url: string, + route: string, +): undefined | Record<string, string> { + const paramsPattern = /(?:\?([^#]*))?$/; + // const paramsPattern = /(?:\?([^#]*))?(#.*)?$/; + const params = url.match(paramsPattern); + const urlWithoutParams = url.replace(paramsPattern, ""); + + const result: Record<string, string> = {}; + if (params && params[1]) { + const paramList = params[1].split("&"); + for (let i = 0; i < paramList.length; i++) { + const idx = paramList[i].indexOf("="); + const name = paramList[i].substring(0, idx); + const value = paramList[i].substring(idx + 1); + result[decodeURIComponent(name)] = decodeURIComponent(value); + } + } + const urlSeg = urlWithoutParams.split("/"); + const routeSeg = route.split("/"); + let max = Math.max(urlSeg.length, routeSeg.length); + for (let i = 0; i < max; i++) { + if (routeSeg[i] && routeSeg[i].charAt(0) === ":") { + const param = routeSeg[i].replace(/(^:|[+*?]+$)/g, ""); + + const flags = (routeSeg[i].match(/[+*?]+$/) || EMPTY)[0] || ""; + const plus = ~flags.indexOf("+"); + const star = ~flags.indexOf("*"); + const val = urlSeg[i] || ""; + + if (!val && !star && (flags.indexOf("?") < 0 || plus)) { + return undefined; + } + result[param] = decodeURIComponent(val); + if (plus || star) { + result[param] = urlSeg.slice(i).map(decodeURIComponent).join("/"); + break; + } + } else if (routeSeg[i] !== urlSeg[i]) { + return undefined; + } + } + return result; +} +const EMPTY: Record<string, string> = {}; diff --git a/packages/exchange-backoffice-ui/tsconfig.json b/packages/exchange-backoffice-ui/tsconfig.json @@ -5,7 +5,7 @@ "module": "ES6", "lib": [ "DOM", - "ES2016" + "ES2017" ], "allowJs": true /* Allow javascript files to be compiled. */, // "checkJs": true, /* Report errors in .js files. */