wallet-core/packages/web-util/src/stories.tsx
2023-04-21 11:05:26 -03:00

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;
}}
/>
);
}