More powerful component preview and parameter editor

master
Kuba Orlik 11 months ago
parent ef4fa71838
commit 6e634e5d64

41
package-lock.json generated

@ -11,17 +11,19 @@
"license": "ISC",
"dependencies": {
"@babel/core": "^7.12.10",
"@hotwired/turbo": "^7.1.0",
"@hotwired/turbo": "^8.0.2",
"@koa/router": "^12.0.1",
"@playwright/test": "^1.36.1",
"@sealcode/jdd": "^0.2.4",
"@sealcode/sealgen": "^0.11.5",
"@sealcode/jdd": "^0.2.10",
"@sealcode/sealgen": "^0.11.6",
"@sealcode/ts-predicates": "^0.4.3",
"@types/kill-port": "^2.0.0",
"get-port": "^7.0.0",
"js-convert-case": "^4.2.0",
"locreq": "^3.0.0",
"multiple-scripts-tmux": "^1.0.4",
"nodemon": "^3.0.1",
"object-path": "^0.11.8",
"sealious": "^0.17.48",
"stimulus": "^2.0.0",
"tempstream": "^0.3.2",
@ -31,6 +33,7 @@
"@sealcode/ansi-html-stream": "^1.0.1",
"@types/koa__router": "^12.0.4",
"@types/node": "^20.8.4",
"@types/object-path": "^0.11.4",
"@types/tedious": "^4.0.7",
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.2",
@ -807,9 +810,9 @@
}
},
"node_modules/@hotwired/turbo": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-7.3.0.tgz",
"integrity": "sha512-Dcu+NaSvHLT7EjrDrkEmH4qET2ZJZ5IcCWmNXxNQTBwlnE5tBZfN6WxZ842n5cHV52DH/AKNirbPBtcEXDLW4g==",
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.2.tgz",
"integrity": "sha512-3K6QZkwWfosAV8zuM5bY+kKF02jp1lMQGsWfSE6wXdZBRBP3ah+Vj26YNqYtkEomBwRWA0QKhZgyJP7xOQkVEg==",
"engines": {
"node": ">= 14"
}
@ -1275,9 +1278,9 @@
}
},
"node_modules/@sealcode/jdd": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@sealcode/jdd/-/jdd-0.2.4.tgz",
"integrity": "sha512-Lf/UIgY0N8zNHHDonvF4WQufITjWhih9+FAbb+NO21pbygrZyIaXfKPW0Vp+Eh9blTZY6QEG40H7zouuVF55ew==",
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@sealcode/jdd/-/jdd-0.2.10.tgz",
"integrity": "sha512-8dQfskMUqotrh9Fbnk2sBcXJ12gXNM1ENPvrQOOX6VabXgE7eQc9gAZgmkcgA2prEwn1vbfpo+Lz9wxzpHOLDQ==",
"dependencies": {
"@sealcode/ts-predicates": "^0.5.3",
"marked": "^12.0.0",
@ -1301,9 +1304,9 @@
}
},
"node_modules/@sealcode/sealgen": {
"version": "0.11.5",
"resolved": "https://registry.npmjs.org/@sealcode/sealgen/-/sealgen-0.11.5.tgz",
"integrity": "sha512-7mb8zuz2Z3KHVcVeWTRN+f4c9IVPhHKX3OzIhNv1ZY/BfkWifU5lFsBrYJUaT4Zd8EYXQIU0DAsZy4WO1miJFQ==",
"version": "0.11.6",
"resolved": "https://registry.npmjs.org/@sealcode/sealgen/-/sealgen-0.11.6.tgz",
"integrity": "sha512-6GGZi59aia7ou2bDmejQedDNLyzfoo05bFnGVlsWXuCOMCUhBXuWGlFe3wqkSr+340iyvZiXJvBSfXg3DatX2Q==",
"dependencies": {
"@koa/router": "^12.0.1",
"@sealcode/ts-predicates": "^0.4.3",
@ -2100,6 +2103,12 @@
"resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-1.3.4.tgz",
"integrity": "sha512-xFdpkAkikBgqBdG9vIlsqffDV8GpvnPEzs0IUtr1v3BEB97ijsFQ4RXVbUZwjFThhB4MDSTUfvmxUD5PGx0wXA=="
},
"node_modules/@types/object-path": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/@types/object-path/-/object-path-0.11.4.tgz",
"integrity": "sha512-4tgJ1Z3elF/tOMpA8JLVuR9spt9Ynsf7+JjqsQ2IqtiPJtcLoHoXcT6qU4E10cPFqyXX5HDm9QwIzZhBSkLxsw==",
"dev": true
},
"node_modules/@types/qs": {
"version": "6.9.11",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz",
@ -8086,6 +8095,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-path": {
"version": "0.11.8",
"resolved": "https://registry.npmjs.org/object-path/-/object-path-0.11.8.tgz",
"integrity": "sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA==",
"engines": {
"node": ">= 10.12.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",

@ -32,17 +32,19 @@
"license": "ISC",
"dependencies": {
"@babel/core": "^7.12.10",
"@hotwired/turbo": "^7.1.0",
"@hotwired/turbo": "^8.0.2",
"@koa/router": "^12.0.1",
"@playwright/test": "^1.36.1",
"@sealcode/jdd": "^0.2.4",
"@sealcode/sealgen": "^0.11.5",
"@sealcode/jdd": "^0.2.10",
"@sealcode/sealgen": "^0.11.6",
"@sealcode/ts-predicates": "^0.4.3",
"@types/kill-port": "^2.0.0",
"get-port": "^7.0.0",
"js-convert-case": "^4.2.0",
"locreq": "^3.0.0",
"multiple-scripts-tmux": "^1.0.4",
"nodemon": "^3.0.1",
"object-path": "^0.11.8",
"sealious": "^0.17.48",
"stimulus": "^2.0.0",
"tempstream": "^0.3.2",
@ -52,6 +54,7 @@
"@sealcode/ansi-html-stream": "^1.0.1",
"@types/koa__router": "^12.0.4",
"@types/node": "^20.8.4",
"@types/object-path": "^0.11.4",
"@types/tedious": "^4.0.7",
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.2",

@ -1,7 +1,8 @@
import { Templatable, tempstream } from "tempstream";
import { FlatTemplatable, Templatable, tempstream } from "tempstream";
import { Readable } from "stream";
import { BaseContext } from "koa";
import navbar from "./routes/common/navbar.js";
import { default as default_navbar } from "./routes/common/navbar.js";
import { toKebabCase } from "js-convert-case";
export const defaultHead = (ctx: BaseContext, title: string) => /* HTML */ `<title>
${title} · ${ctx.$app.manifest.name}
@ -10,20 +11,31 @@ export const defaultHead = (ctx: BaseContext, title: string) => /* HTML */ `<tit
<script async src="/dist/bundle.js"></script>
<link href="/dist/main.css" rel="stylesheet" type="text/css" />`;
export type HTMLOptions = {
preserveScroll?: boolean;
morphing?: boolean;
navbar?: (ctx: BaseContext) => FlatTemplatable;
};
export default function html(
ctx: BaseContext,
title: string,
body: Templatable,
{ preserveScroll, morphing, navbar }: HTMLOptions = {},
makeHead: (ctx: BaseContext, title: string) => Templatable = defaultHead
): Readable {
ctx.set("content-type", "text/html;charset=utf-8");
return tempstream/* HTML */ ` <!DOCTYPE html>
<html lang="pl">
<html lang="pl" class="title--${toKebabCase(title)}">
<head>
${makeHead(ctx, title)}
${morphing ? `<meta name="turbo-refresh-method" content="morph" />` : ""}
${preserveScroll
? `<meta name="turbo-refresh-scroll" content="preserve">`
: ""}
</head>
<body>
${navbar(ctx)} ${body}
${(navbar || default_navbar)(ctx)} ${body}
</body>
</html>`;
}

@ -5,6 +5,3 @@ export const registry = new Registry();
import { NiceBox } from "./nice-box/nice-box.jdd.js";
registry.add("nice-box", NiceBox);
import { UsingImages } from "./using-images/using-images.jdd.js";
registry.add("using-images", UsingImages);

@ -0,0 +1,27 @@
.title--components {
body {
max-width: none;
}
.two-column {
display: grid;
grid-template-columns: min-content 15px 1fr;
}
.resize-gutter {
background-color: gray;
cursor: ew-resize;
height: 100%;
}
.resizable {
width: var(--resizable-column-width);
overflow-x: auto;
}
}
.component-preview-parameters {
fieldset {
background-color: #80808024;
}
}

@ -1,13 +1,53 @@
import { TempstreamJSX, Templatable } from "tempstream";
import { TempstreamJSX, Templatable, FlatTemplatable, tempstream } from "tempstream";
import { BaseContext } from "koa";
import { StatefulPage } from "@sealcode/sealgen";
import html from "../html.js";
import { registry } from "../jdd-components/components.js";
import { render, simpleJDDContext } from "@sealcode/jdd";
import {
ComponentArgument,
Enum,
List,
render,
simpleJDDContext,
Structured,
} from "@sealcode/jdd";
import objectPath from "object-path";
export const actionName = "Components";
const actions = {} as const;
const actions = {
add_array_item: (
state: State,
_: Record<string, string>,
arg_path: string[],
empty_value: unknown
) => {
const args = state.args;
objectPath.insert(
args,
arg_path,
empty_value,
((objectPath.get(args, arg_path) as unknown[]) || []).length
);
return {
...state,
args,
};
},
remove_array_item: (
state: State,
_: Record<string, string>,
arg_path: string[],
index_to_remove: number
) => {
const args = state.args;
objectPath.del(args, [...arg_path, index_to_remove]);
return {
...state,
args,
};
},
} as const;
type State = {
component: string;
@ -22,7 +62,150 @@ export default new (class ComponentsPage extends StatefulPage<State, typeof acti
}
wrapInLayout(ctx: BaseContext, content: Templatable): Templatable {
return html(ctx, "Components", content);
return html(ctx, "Components", content, {
morphing: true,
preserveScroll: true,
navbar: () => ``,
});
}
renderListArgument<T>(
state: State,
arg_path: string[],
arg: List<ComponentArgument<T>>,
value: T[] = []
): FlatTemplatable {
return (
<fieldset>
<legend>{arg_path.at(-1)}</legend>
{value.map((e, i) => (
<div style="display: flex">
{this.renderArgumentInput(
state,
[...arg_path, i.toString()],
arg.item_type,
e
)}
{this.makeActionButton(
state,
{ action: "remove_array_item", label: "❌" },
arg_path,
i
)}
</div>
))}
{this.makeActionButton(
state,
{
action: "add_array_item",
label: "",
},
arg_path,
arg.item_type.getEmptyValue()
)}
</fieldset>
);
}
renderStructuredArgument<
T extends Structured<Record<string, ComponentArgument<unknown>>>
>(
state: State,
arg_path: string[],
arg: T,
value: Record<string, unknown>
): FlatTemplatable {
return (
<fieldset>
<legend>{arg_path.at(-1)}</legend>
{Object.entries(arg.structure).map(([arg_name, arg]) => (
<div>
{this.renderArgumentInput(
state,
[...arg_path, arg_name],
arg,
(value as Record<string, unknown>)[arg_name]
)}
</div>
))}
</fieldset>
);
}
printArgPath(path: string[]): string {
return path.map((e) => `[${e}]`).join("");
}
renderEnumArgument<T extends Enum<any>>(
state: State,
arg_path: string[],
arg: T,
value: string
): FlatTemplatable {
return (
<div>
<label>
{arg_path.at(-1)}
<select name={`$.args${this.printArgPath(arg_path)}`}>
{arg.values.map((v) => (
<option value={v} selected={value == v}>
{v}
</option>
))}
</select>
</label>
</div>
);
}
renderArgumentInput<T>(
state: State,
arg_path: string[],
arg: ComponentArgument<T>,
value: T
): FlatTemplatable {
if (value === undefined) {
value = arg.getEmptyValue();
}
if (arg instanceof List) {
return this.renderListArgument(state, arg_path, arg, value as T[]);
}
if (arg instanceof Structured) {
return this.renderStructuredArgument(
state,
arg_path,
arg,
value as Record<string, unknown>
);
}
if (arg instanceof Enum) {
return this.renderEnumArgument(state, arg_path, arg, value as string);
}
return (
<div>
<label>
{arg_path.at(-1)}
{arg.getTypeName() == "markdown" ? (
<textarea
name={`$.args${this.printArgPath(arg_path)}`}
onblur="this.closest('form').requestSubmit()"
cols="70"
>
{value as string}
</textarea>
) : (
<input
type="text"
name={`$.args${this.printArgPath(arg_path)}`}
value={value as string}
size="70"
/>
)}
</label>
</div>
);
}
render(ctx: BaseContext, state: State, inputs: Record<string, string>) {
@ -30,46 +213,87 @@ export default new (class ComponentsPage extends StatefulPage<State, typeof acti
const component =
registry.get(state.component) || Object.values(all_components)[0];
return (
<div>
<div>{JSON.stringify(state)}</div>
<select name="$.component" onchange="this.closest('form').submit()">
{Object.entries(all_components).map(([name]) => (
<option value={name} selected={name == state.component}>
{name}
</option>
))}
</select>
<fieldset>
<legend>Parameters</legend>
{Object.entries(component.getArguments()).map(([arg_name, arg]) => (
<div>
<label>
{arg_name}
{arg.getTypeName() == "markdown" ? (
<textarea name={`$.args[${arg_name}]`}>
{state.args[arg_name] as string}
</textarea>
) : (
<input
type="text"
name={`$.args[${arg_name}]`}
value={state.args[arg_name] as string}
/>
)}
</label>
</div>
))}
<div class="two-column">
<div class="resizable">
{/*The below button has to be here in order for it to be the default behavior */}
<input type="submit" value="Preview" />
</fieldset>
<fieldset>
<legend>Preview</legend>
{render(
registry,
[{ component_name: state.component, args: state.args }],
simpleJDDContext
)}
</fieldset>
<select
name="$.component"
onchange="this.closest('form').requestSubmit()"
>
{Object.entries(all_components).map(([name]) => (
<option value={name} selected={name == state.component}>
{name}
</option>
))}
</select>
<fieldset class="component-preview-parameters">
<legend>Parameters</legend>
{Object.entries(component.getArguments()).map(([arg_name, arg]) =>
this.renderArgumentInput(
state,
[arg_name],
arg,
state.args[arg_name]
)
)}
<input type="submit" value="Preview" />
</fieldset>
<div>{JSON.stringify(state)}</div>
</div>
<div class="resize-gutter"></div>
{
/* HTML */ `<script>
(function () {
let is_resizing = false;
let origin_x;
let origin_width;
const gutter = document.querySelector(".resize-gutter");
const resizable = document.querySelector(".resizable");
const move_listener = (e) => {
const new_width = Math.max(
origin_width + (e.clientX - origin_x),
1
);
document.documentElement.style.setProperty(
"--resizable-column-width",
new_width + "px"
);
};
gutter.addEventListener("mousedown", (e) => {
is_resizing = true;
origin_x = e.clientX;
origin_width = resizable.getBoundingClientRect().width;
document.addEventListener("mousemove", move_listener);
document.addEventListener("mouseup", () => {
document.removeEventListener(
"mousemove",
move_listener
);
});
e.preventDefault();
});
})();
</script>`
}
<div>
<fieldset>
<legend>Preview</legend>
{render(
registry,
[{ component_name: state.component, args: state.args }],
simpleJDDContext
)}
</fieldset>
</div>
</div>
);
}
// wrapInForm(state: State, content: Templatable): Templatable {
// return tempstream/* HTML */ `<turbo-frame id="components">
// ${super.wrapInForm(state, content)}
// </turbo-frame> `;
// }
})();

@ -1,6 +1,6 @@
/* DO NOT EDIT! This file is generated automaticaly with npx sealgen generate-scss-includes */
/* DO NOT EDIT! This file is generated automaticaly with npx sealgen generate-css-includes */
@import "../node_modules/@sealcode/sealgen/src/forms/forms.css";
@import "back/jdd-components/using-images/using-images.css";
@import "back/routes/common/ui/input.css";
@import "back/routes/components.css";
@import "tables.css";

Loading…
Cancel
Save