From e382b022030db96b8282337b304ec5e599a5f405 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 6 Dec 2022 09:21:17 -0300 Subject: web-util: utils for developing webapps --- packages/web-util/src/stories.tsx | 580 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 packages/web-util/src/stories.tsx (limited to 'packages/web-util/src/stories.tsx') diff --git a/packages/web-util/src/stories.tsx b/packages/web-util/src/stories.tsx new file mode 100644 index 000000000..a8a9fdf77 --- /dev/null +++ b/packages/web-util/src/stories.tsx @@ -0,0 +1,580 @@ +/* + This file is part of GNU Taler + (C) 2022 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 + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { setupI18n } from "@gnu-taler/taler-util"; +import e from "express"; +import { + ComponentChild, + ComponentChildren, + Fragment, + FunctionalComponent, + FunctionComponent, + h, + JSX, + render, + VNode, +} from "preact"; +import { useEffect, useErrorBoundary, useState } from "preact/hooks"; + +const Page: FunctionalComponent = ({ children }): VNode => { + return ( +
+ {children} +
+ ); +}; + +const SideBar: FunctionalComponent<{ width: number }> = ({ + width, + children, +}): VNode => { + return ( +
+ {children} +
+ ); +}; + +const ResizeHandleDiv: FunctionalComponent< + JSX.HTMLAttributes +> = ({ children, ...props }): VNode => { + return ( +
+ {children} +
+ ); +}; + +const Content: FunctionalComponent = ({ children }): VNode => { + return ( +
+ {children} +
+ ); +}; + +function findByGroupComponentName( + allExamples: Group[], + group: string, + component: string, + name: string, +): ExampleItem | undefined { + const gl = allExamples.filter((e) => e.title === group); + if (gl.length === 0) { + return undefined; + } + const cl = gl[0].list.filter((l) => l.name === component); + if (cl.length === 0) { + return undefined; + } + const el = cl[0].examples.filter((c) => c.name === name); + if (el.length === 0) { + return undefined; + } + return el[0]; +} + +function getContentForExample( + item: ExampleItem | undefined, + allExamples: Group[], +): FunctionalComponent { + if (!item) + return function SelectExampleMessage() { + return
select example from the list on the left
; + }; + const example = findByGroupComponentName( + allExamples, + item.group, + item.component, + item.name, + ); + if (!example) { + return function ExampleNotFoundMessage() { + return
example not found
; + }; + } + return () => example.render.component(example.render.props); +} + +function ExampleList({ + name, + list, + selected, + onSelectStory, +}: { + name: string; + list: { + name: string; + examples: ExampleItem[]; + }[]; + selected: ExampleItem | undefined; + onSelectStory: (i: ExampleItem, id: string) => void; +}): VNode { + const [isOpen, setOpen] = useState(selected && selected.group === name); + return ( +
    +
    setOpen(!isOpen)} + > + {name} +
    +
    + {list.map((k) => ( +
  1. +
    +
    {k.name}
    + {k.examples.map((r, i) => { + const e = encodeURIComponent; + const eId = `${e(r.group)}-${e(r.component)}-${e(r.name)}`; + const isSelected = + selected && + selected.component === r.component && + selected.group === r.group && + selected.name === r.name; + return ( +
    + { + e.preventDefault(); + location.hash = `#${eId}`; + onSelectStory(r, eId); + history.pushState({}, "", `#${eId}`); + }} + > + {r.name} + +
    + ); + })} +
    +
  2. + ))} +
    +
+ ); +} + +/** + * Prevents the UI from redirecting and inform the dev + * where the should have redirected + * @returns + */ +function PreventLinkNavigation({ + children, +}: { + children: ComponentChildren; +}): VNode { + return ( +
{ + let t: any = e.target; + do { + if (t.localName === "a" && t.getAttribute("href")) { + alert(`should navigate to: ${t.attributes.href.value}`); + e.stopImmediatePropagation(); + e.stopPropagation(); + e.preventDefault(); + return false; + } + } while ((t = t.parentNode)); + return true; + }} + > + {children} +
+ ); +} + +function ErrorReport({ + children, + selected, +}: { + children: ComponentChild; + selected: ExampleItem | undefined; +}): VNode { + const [error, resetError] = useErrorBoundary(); + //if there is an error, reset when unloading this component + useEffect(() => (error ? resetError : undefined)); + if (error) { + return ( +
+

Error was thrown trying to render

+ {selected && ( +
    +
  • + group: {selected.group} +
  • +
  • + component: {selected.component} +
  • +
  • + example: {selected.name} +
  • +
  • + args:{" "} +
    {JSON.stringify(selected.render.props, undefined, 2)}
    +
  • +
+ )} +

{error.message}

+
{error.stack}
+
+ ); + } + return {children}; +} + +function getSelectionFromLocationHash( + hash: string, + allExamples: Group[], +): ExampleItem | undefined { + if (!hash) return undefined; + const parts = hash.substring(1).split("-"); + if (parts.length < 3) return undefined; + return findByGroupComponentName( + allExamples, + decodeURIComponent(parts[0]), + decodeURIComponent(parts[1]), + decodeURIComponent(parts[2]), + ); +} + +function parseExampleImport( + group: string, + componentName: string, + im: MaybeComponent, +): ComponentItem { + const examples: ExampleItem[] = Object.entries(im) + .filter(([k]) => k !== "default") + .map(([exampleName, exampleValue]): ExampleItem => { + if (!exampleValue) { + throw Error( + `example "${exampleName}" from component "${componentName}" in group "${group}" is undefined`, + ); + } + + if (typeof exampleValue === "function") { + return { + group, + component: componentName, + name: exampleName, + render: { + component: exampleValue as FunctionComponent, + props: {}, + }, + }; + } + const v: any = exampleValue; + if ( + "component" in v && + typeof v.component === "function" && + "props" in v + ) { + return { + group, + component: componentName, + name: exampleName, + render: v, + }; + } + throw Error( + `example "${exampleName}" from component "${componentName}" in group "${group}" doesn't follow one of the two ways of example`, + ); + }); + return { + name: componentName, + examples, + }; +} + +export function parseGroupImport( + groups: Record, +): Group[] { + return Object.entries(groups).map(([groupName, value]) => { + return { + title: groupName, + list: Object.entries(value).flatMap(([key, value]) => + folder(groupName, value), + ), + }; + }); +} + +export interface Group { + title: string; + list: ComponentItem[]; +} + +export interface ComponentItem { + name: string; + examples: ExampleItem[]; +} + +export interface ExampleItem { + group: string; + component: string; + name: string; + render: { + component: FunctionalComponent; + props: object; + }; +} + +type ComponentOrFolder = MaybeComponent | MaybeFolder; +interface MaybeFolder { + default?: { title: string }; + // [exampleName: string]: FunctionalComponent; +} +interface MaybeComponent { + // default?: undefined; + [exampleName: string]: undefined | object; +} + +function folder(groupName: string, value: ComponentOrFolder): ComponentItem[] { + let title: string | undefined = undefined; + try { + title = + typeof value === "object" && + typeof value.default === "object" && + value.default !== undefined && + "title" in value.default && + typeof value.default.title === "string" + ? value.default.title + : undefined; + } catch (e) { + throw Error( + `Could not defined if it is component or folder ${groupName}: ${JSON.stringify( + value, + undefined, + 2, + )}`, + ); + } + if (title) { + const c = parseExampleImport(groupName, title, value as MaybeComponent); + return [c]; + } + return Object.entries(value).flatMap(([subkey, value]) => + folder(groupName, value), + ); +} + +interface Props { + getWrapperForGroup: (name: string) => FunctionComponent; + examplesInGroups: Group[]; + langs: Record; +} + +function Application({ + langs, + examplesInGroups, + getWrapperForGroup, +}: Props): VNode { + const initialSelection = getSelectionFromLocationHash( + location.hash, + examplesInGroups, + ); + + const url = new URL(window.location.href); + const currentLang = url.searchParams.get("lang") || "en"; + + if (!langs["en"]) { + langs["en"] = {}; + } + setupI18n(currentLang, langs); + + const [selected, updateSelected] = useState( + initialSelection, + ); + const [sidebarWidth, setSidebarWidth] = useState(200); + useEffect(() => { + if (location.hash) { + const hash = location.hash.substring(1); + const found = document.getElementById(hash); + if (found) { + setTimeout(() => { + found.scrollIntoView({ + block: "center", + }); + }, 10); + } + } + }, []); + + const GroupWrapper = getWrapperForGroup(selected?.group || "default"); + const ExampleContent = getContentForExample(selected, examplesInGroups); + + //style={{ "--with-size": `${sidebarWidth}px` }} + return ( + + {/* */} + +
+ Language: + +
+ {examplesInGroups.map((group) => ( + { + document.getElementById(htmlId)?.scrollIntoView({ + block: "center", + }); + updateSelected(item); + }} + /> + ))} +
+
+ { + setSidebarWidth((s) => s + x); + }} + /> + + + + + + + + + +
+ ); +} + +export interface Options { + id?: string; + strings?: any; + getWrapperForGroup?: (name: string) => FunctionComponent; +} + +export function renderStories( + groups: Record, + options: Options = {}, +): void { + const examples = parseGroupImport(groups); + + try { + const cid = options.id ?? "container"; + const container = document.getElementById(cid); + if (!container) { + throw Error( + `container with id ${cid} not found, can't mount page contents`, + ); + } + render( + Fragment)} + langs={options.strings ?? { en: {} }} + />, + container, + ); + } catch (e) { + console.error("got error", e); + if (e instanceof Error) { + document.body.innerText = `Fatal error: "${e.message}". Please report this bug at https://bugs.gnunet.org/.`; + } + } +} + +function ResizeHandle({ onUpdate }: { onUpdate: (x: number) => void }): VNode { + const [start, setStart] = useState(undefined); + return ( + { + setStart(e.pageX); + console.log("active", e.pageX); + return false; + }} + onMouseMove={(e: any) => { + if (start !== undefined) { + onUpdate(e.pageX - start); + } + return false; + }} + onMouseUp={() => { + setStart(undefined); + return false; + }} + /> + ); +} -- cgit v1.2.3