579 lines
14 KiB
TypeScript
579 lines
14 KiB
TypeScript
/*
|
|
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;
|
|
}}
|
|
/>
|
|
);
|
|
}
|