/* 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 <http://www.gnu.org/licenses/> */ /** * * @author Sebastian Javier Marchano (sebasjm) */ import { setupI18n } from "@gnu-taler/taler-util"; import { ComponentChild, ComponentChildren, Fragment, FunctionalComponent, FunctionComponent, h, JSX, render, VNode, } from "preact"; import { useEffect, useErrorBoundary, useState } from "preact/hooks"; import { ExampleItemSetup } from "./tests/hook.js"; const Page: FunctionalComponent = ({ children }): VNode => { return ( <div style={{ fontFamily: "Arial, Helvetica, sans-serif", width: "100%", display: "flex", flexDirection: "row", }} > {children} </div> ); }; const SideBar: FunctionalComponent<{ width: number }> = ({ width, children, }): VNode => { return ( <div style={{ minWidth: width, height: "calc(100vh - 20px)", overflowX: "hidden", overflowY: "visible", scrollBehavior: "smooth", }} > {children} </div> ); }; const ResizeHandleDiv: FunctionalComponent< JSX.HTMLAttributes<HTMLDivElement> > = ({ children, ...props }): VNode => { return ( <div {...props} style={{ width: 10, backgroundColor: "#ddd", cursor: "ew-resize", }} > {children} </div> ); }; const Content: FunctionalComponent = ({ children }): VNode => { return ( <div style={{ width: "100%", padding: 20, }} > {children} </div> ); }; 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 <div>select example from the list on the left</div>; }; const example = findByGroupComponentName( allExamples, item.group, item.component, item.name, ); if (!example) { return function ExampleNotFoundMessage() { return <div>example not found</div>; }; } 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 ( <ol style={{ padding: 4, margin: 0 }}> <div style={{ backgroundColor: "lightcoral", cursor: "pointer" }} onClick={() => setOpen(!isOpen)} > {name} </div> <div style={{ display: isOpen ? undefined : "none" }}> {list.map((k) => ( <li key={k.name}> <dl style={{ margin: 0 }}> <dt>{k.name}</dt> {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 ( <dd id={eId} key={r.name} style={{ backgroundColor: isSelected ? "green" : i % 2 ? "lightgray" : "lightblue", marginLeft: "1em", padding: 4, cursor: "pointer", borderRadius: 4, marginBottom: 4, }} > <a href={`#${eId}`} style={{ color: "black" }} onClick={(e) => { e.preventDefault(); location.hash = `#${eId}`; onSelectStory(r, eId); history.pushState({}, "", `#${eId}`); }} > {r.name} </a> </dd> ); })} </dl> </li> ))} </div> </ol> ); } /** * Prevents the UI from redirecting and inform the dev * where the <a /> should have redirected * @returns */ function PreventLinkNavigation({ children, }: { children: ComponentChildren; }): VNode { return ( <div onClick={(e) => { 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} </div> ); } 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 ( <div> <p>Error was thrown trying to render</p> {selected && ( <ul> <li> <b>group</b>: {selected.group} </li> <li> <b>component</b>: {selected.component} </li> <li> <b>example</b>: {selected.name} </li> <li> <b>args</b>:{" "} <pre>{JSON.stringify(selected.render.props, undefined, 2)}</pre> </li> </ul> )} <p>{error.message}</p> <pre>{error.stack}</pre> </div> ); } return <Fragment>{children}</Fragment>; } 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: {}, contextProps: {}, }, }; } 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<string, ComponentOrFolder>, ): 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<Props extends object = {}> { name: string; examples: ExampleItem<Props>[]; } export interface ExampleItem<Props extends object = {}> { group: string; component: string; name: string; render: ExampleItemSetup<Props>; } 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<string, object>; } 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<ExampleItem | undefined>( 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 ( <Page> {/* <LiveReload /> */} <SideBar width={sidebarWidth}> <div> Language: <select value={currentLang} onChange={(e) => { const url = new URL(window.location.href); url.searchParams.set("lang", e.currentTarget.value); window.location.href = url.href; }} > {Object.keys(langs).map((l) => ( <option key={l}>{l}</option> ))} </select> </div> {examplesInGroups.map((group) => ( <ExampleList key={group.title} name={group.title} list={group.list} selected={selected} onSelectStory={(item, htmlId) => { document.getElementById(htmlId)?.scrollIntoView({ block: "center", }); updateSelected(item); }} /> ))} <hr /> </SideBar> <ResizeHandle onUpdate={(x) => { setSidebarWidth((s) => s + x); }} /> <Content> <ErrorReport selected={selected}> <PreventLinkNavigation> <GroupWrapper> <ExampleContent /> </GroupWrapper> </PreventLinkNavigation> </ErrorReport> </Content> </Page> ); } export interface Options { id?: string; strings?: any; getWrapperForGroup?: (name: string) => FunctionComponent; } export function renderStories( groups: Record<string, ComponentOrFolder>, 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( <Application examplesInGroups={examples} getWrapperForGroup={options.getWrapperForGroup ?? (() => 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<number | undefined>(undefined); return ( <ResizeHandleDiv onMouseDown={(e: any) => { 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; }} /> ); }