From 6e634e5d646f057ce88db79ad452091af5c80433 Mon Sep 17 00:00:00 2001 From: Kuba Orlik Date: Sat, 10 Feb 2024 17:09:37 +0100 Subject: [PATCH] More powerful component preview and parameter editor --- package-lock.json | 41 +++- package.json | 9 +- src/back/html.ts | 20 +- src/back/jdd-components/components.ts | 3 - src/back/routes/components.css | 27 +++ src/back/routes/components.sreact.tsx | 308 ++++++++++++++++++++++---- src/includes.css | 4 +- 7 files changed, 346 insertions(+), 66 deletions(-) create mode 100644 src/back/routes/components.css diff --git a/package-lock.json b/package-lock.json index eac091f..4841e70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 11455f8..5369dc2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/back/html.ts b/src/back/html.ts index 3fe104a..728d6fd 100644 --- a/src/back/html.ts +++ b/src/back/html.ts @@ -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} · ${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>`; } diff --git a/src/back/jdd-components/components.ts b/src/back/jdd-components/components.ts index 9a89599..c63b7a9 100644 --- a/src/back/jdd-components/components.ts +++ b/src/back/jdd-components/components.ts @@ -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); diff --git a/src/back/routes/components.css b/src/back/routes/components.css new file mode 100644 index 0000000..3e5f341 --- /dev/null +++ b/src/back/routes/components.css @@ -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; + } +} diff --git a/src/back/routes/components.sreact.tsx b/src/back/routes/components.sreact.tsx index 1e6ef37..a4e56ac 100644 --- a/src/back/routes/components.sreact.tsx +++ b/src/back/routes/components.sreact.tsx @@ -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> `; + // } })(); diff --git a/src/includes.css b/src/includes.css index f273194..d499317 100644 --- a/src/includes.css +++ b/src/includes.css @@ -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";