diff --git a/pattern/sqlpage/component.ts b/pattern/sqlpage/component.ts index 3af51a1c..807f6acc 100644 --- a/pattern/sqlpage/component.ts +++ b/pattern/sqlpage/component.ts @@ -4,8 +4,10 @@ import * as SQLa from "../../render/mod.ts"; // deno-lint-ignore no-explicit-any type Any = any; +// TODO: create type-safe browsing with lists/combo-boxes using and hide/show for drill down pattern + // we want to auto-unindent our string literals and remove initial newline -export const markdown = ( +export const text = ( literals: TemplateStringsArray, ...expressions: unknown[] ) => { @@ -31,9 +33,132 @@ export const markdown = ( // see https://sql.ophir.dev/documentation.sql?component=debug#component -export interface Component - extends SQLa.SqlTextSupplier { - readonly name: "shell" | "list" | "text" | "table"; +export interface Component< + EmitContext extends SQLa.SqlEmitContext, + Name extends string = "shell" | "list" | "text" | "table" | "breadcrumb", +> extends SQLa.SqlTextSupplier { + readonly name: Name; +} + +export type FlexibleText = + | string + | SQLa.SqlTextSupplier; + +export type ComponentSelectExprArg< + EmitContext extends SQLa.SqlEmitContext, +> = [ + value: FlexibleText | undefined, + as: string, +] | undefined; + +export type ComponentSelectExpr = [ + value: FlexibleText, + as: string, +]; + +export class ComponentBuilder< + Name extends string, + EmitContext extends SQLa.SqlEmitContext, +> { + /** + * Accept a flexible list of key value pairs and prepare a `SELECT` clause + * from all arguments that are defined + * @param ctx an EmitContext instance + * @param args the arguments that should be inspected and emitted + * @returns string array suitable to emit after `SELECT` keyword + */ + selectables( + ctx: EmitContext, + ...args: ComponentSelectExprArg[] + ): string[] { + // find all defined arguments (filter any `undefined`); converts a + // ComponentSelectExprArg to ComponentSelectExpr + const code = args.filter((c) => c && c[0]) as ComponentSelectExpr< + EmitContext + >[]; + return code.map((c) => + `${ + typeof c[0] === "string" + ? `'${c[0].replaceAll("'", "''")}'` + : c[0].SQL(ctx) + } as ${c[1]}` + ); + } + + // create a selectables args list from an object when the proper name + // matches the `as` argument + select>( + args: Args, + ...propNames: (keyof Args)[] + ): ComponentSelectExpr[] { + return propNames.map((pn) => + [ + args[pn] ? String(args[pn]) : undefined, + String(pn), + ] as ComponentSelectExpr + ); + } + + component( + name: Name, + ...args: ComponentSelectExprArg[] + ): Component { + return { + name, + SQL: (ctx) => { + const select = this.selectables(ctx, ...args); + // deno-fmt-ignore + return `SELECT '${name}' as component${select.length ? `, ${select.join(", ")}` : ""}`; + }, + }; + } + + customTemplatePath(name: Name): `sqlpage/templates/${Name}.handlebars` { + return `sqlpage/templates/${name}.handlebars`; + } + + custom>( + name: Name, + args: Args, + sqlBuilder: ( + topLevel: Component, + ) => SQLa.SqlTextSupplier, + ...specificArgNames: (keyof Args)[] + ): Component { + // if no args are supplied, grab them all + const argNames = specificArgNames.length > 0 + ? specificArgNames + : Object.keys(args); + const sql = sqlBuilder( + args + ? this.component(name, ...this.select(args, ...argNames)) + : this.component(name), + ); + return { + name, + SQL: sql.SQL, + }; + } + + customNoArgs( + name: Name, + sqlBuilder: ( + topLevel: Component, + ) => SQLa.SqlTextSupplier, + ): Component { + return this.custom(name, {}, sqlBuilder); + } +} + +export interface Breadcrumbs + extends Component { + readonly name: "breadcrumb"; + readonly items: Iterable<{ + readonly caption: FlexibleText; + readonly href?: FlexibleText; + readonly active?: boolean; + readonly descr?: FlexibleText; + }>; } export interface Shell @@ -83,42 +208,102 @@ export interface Table< readonly sort?: boolean; readonly columns?: Record; readonly rows: Iterable>; } -export function typicalComponents< - Href extends string, +export type CustomTemplatePath = + `sqlpage/templates/${Name}.handlebars`; + +// Handlebars code is not really SQL but we use the SQLa SQL emit infrastructure +// in SQLPageNotebook instances so it's convenient to assume it's SqlTextSupplier +export type HandlebarsCode = + SQLa.SqlTextSupplier; + +export interface CustomTemplateSupplier< EmitContext extends SQLa.SqlEmitContext, ->() { - type CompCode = [value: string | undefined, as: string]; - const selectables = (...args: (CompCode | undefined)[]): string[] => { - const code = args.filter((c) => c && c[0]) as [value: string, as: string][]; - return code.map((c) => `'${c[0].replaceAll("'", "''")}' as ${c[1]}`); + Name extends string, + TopLevelArgs extends Record, + Row extends Record, + PageParams extends Record, +> { + readonly templatePath: CustomTemplatePath; + readonly handlebarsCode: ( + helpers: { + readonly tla: { readonly [A in keyof TopLevelArgs]: `{{${string & A}}}` }; + readonly row: { readonly [C in keyof Row]: `{{${string & C}}}` }; + readonly pn: { readonly [P in keyof PageParams]: `${string & P}` }; // pn = param-name + readonly pv: { readonly [P in keyof PageParams]: `{{${string & P}}}` }; // pv = param-var (value of param) + }, + ) => HandlebarsCode; + readonly component: ( + tla?: TopLevelArgs, + ...rows: Row[] + ) => Component & TopLevelArgs; +} + +/** + * Prepare an object proxy which takes each property of an object an returns a + * string named exactly the same as the key name. This is useful for type-safety + * in string template literals. + * @returns a read-only object proxy which takes the name of any key and returns the same key + */ +export function safePropNames>() { + type ShapeKeyNames = { + readonly [PropName in keyof Shape]: `${string & PropName}`; }; + return new Proxy({} as ShapeKeyNames, { + get: (_, p) => String(p), + }); +} - // create a selectables args list from an object when the proper name - // matches the `as` argument - const select = >( - args: Args, - ...propNames: (keyof Args)[] - ): CompCode[] => { - return propNames.map(( - pn, - ) => [args[pn] ? String(args[pn]) : undefined, String(pn)]); +/** + * Prepare an object proxy which takes each property of an object an returns + * name of that field as a type-safe handlebars variable like {{key}}. + * @returns a read-only object proxy which takes the name of any key and returns the same key as {{key}} + */ +export function safeHandlebars< + Shape extends Record, +>() { + type HandlebarsVars = { + readonly [PropName in keyof Shape]: `{{${string & PropName}}}`; }; + return new Proxy({} as HandlebarsVars, { + get: (_, p) => `{{${String(p)}}}`, + }); +} - const component = ["name"]>( - name: Name, - ...args: (CompCode | undefined)[] - ): Component => { - const select = selectables(...args); +export function typicalComponents< + Href extends string, + EmitContext extends SQLa.SqlEmitContext, +>() { + const builder = new ComponentBuilder< + Component["name"], + EmitContext + >(); + + const breadcrumbs = ( + init: Omit, "name" | "SQL">, + ): Breadcrumbs => { return { - name, - // deno-fmt-ignore - SQL: () => `SELECT '${name}' as component${select.length ? `, ${select.join(", ")}` : ''}`, + name: "breadcrumb", + ...init, + SQL: (ctx) => { + const topLevel = builder.component("breadcrumb"); + // deno-fmt-ignore + return `${topLevel.SQL(ctx)};\n` + + Array.from(init.items).map((i) => + `SELECT ${builder.selectables( + ctx, + [i.caption, "title"], + [i.href, "link"], + i.active ? ["true", "active"] : undefined, + [i.descr, "description"], + )}` + ).join(";\n"); + }, }; }; @@ -127,12 +312,12 @@ export function typicalComponents< ): Shell => { return { ...init, - ...component( + ...builder.component( "shell", - ...select(init, "title", "icon", "link"), + ...builder.select(init, "title", "icon", "link"), ...(init.menuItems ? Array.from(init.menuItems).map((mi) => - [mi.caption, "menu_item"] as CompCode + [mi.caption, "menu_item"] as ComponentSelectExpr ) : []), ), @@ -145,9 +330,9 @@ export function typicalComponents< ): Text => { return { ...init, - ...component( + ...builder.component( "text", - ...select(init, "title"), + ...builder.select(init, "title"), "text" in init.content ? [init.content.text, "contents"] : undefined, "markdown" in init.content ? [init.content.markdown, "contentsmd"] @@ -163,9 +348,9 @@ export function typicalComponents< return { ...init, // deno-fmt-ignore - SQL: () => - `SELECT ${selectables( - ...select(init, "title", "link"), + SQL: (ctx) => + `SELECT ${builder.selectables(ctx, + ...builder.select(init, "title", "link"), [init.descr, "description"], )}`, }; @@ -174,7 +359,10 @@ export function typicalComponents< const list = ( init: Omit, "name" | "SQL">, ): List => { - const topLevel = component("list", ...select(init, "title")); + const topLevel = builder.component( + "list", + ...builder.select(init, "title"), + ); return { name: "list", ...init, @@ -188,7 +376,7 @@ export function typicalComponents< const table = ( init: Omit, "name" | "SQL">, ): Table => { - const topLevel = select(init, "search", "sort"); + const topLevel = builder.select(init, "search", "sort"); if (init.columns) { for (const [columnName, v] of Object.entries(init.columns)) { const args = v as typeof init.columns[ColumnName]; @@ -201,11 +389,19 @@ export function typicalComponents< name: "table", ...init, SQL: (ctx) => { - return `${component("table", ...topLevel).SQL(ctx)};\n` + + return `${builder.component("table", ...topLevel).SQL(ctx)};\n` + Array.from(init.rows).map((i) => i.SQL(ctx)).join(";\n"); }, }; }; - return { selectables, select, component, shell, text, list, listItem, table }; + return { + builder, + breadcrumbs, + shell, + text, + list, + listItem, + table, + }; } diff --git a/pattern/sqlpage/notebook.ts b/pattern/sqlpage/notebook.ts index 6f9c3bdf..dc33f18a 100644 --- a/pattern/sqlpage/notebook.ts +++ b/pattern/sqlpage/notebook.ts @@ -11,7 +11,11 @@ export function sqlPageNotebook< EmitContext extends SQLa.SqlEmitContext, >( prototype: SQLPageNotebook, - instanceSupplier: () => SQLPageNotebook, + instanceSupplier: ( + registerCTS: ( + cc: c.CustomTemplateSupplier, + ) => void, + ) => SQLPageNotebook, ctxSupplier: () => EmitContext, nbDescr: chainNB.NotebookDescriptor< SQLPageNotebook, @@ -21,8 +25,15 @@ export function sqlPageNotebook< > >, ) { + const custom: Record< + string, + c.CustomTemplateSupplier + > = {}; const kernel = chainNB.ObservableKernel.create(prototype, nbDescr); - const instance = instanceSupplier(); + const instance = instanceSupplier((cc) => { + // if defined multiple times, the latest version will win + custom[cc.templatePath] = cc; + }); const pkcf = SQLa.primaryKeyColumnFactory< EmitContext, SQLa.SqlDomainQS @@ -34,8 +45,8 @@ export function sqlPageNotebook< }, { isIdempotent: true, qualitySystem: { - description: c.markdown` - [SQLPage](https://sql.ophir.dev/) app server content`, + description: c.text` + [SQLPage](https://sql.ophir.dev/) app server content`, }, }); const sqlPageFilesCRF = SQLa.tableColumnsRowFactory< @@ -74,6 +85,19 @@ export function sqlPageNotebook< } }; + for (const cc of Object.values(custom)) { + notebookSQL.push(sqlPageFilesCRF.insertDML({ + path: cc.templatePath, + contents: cc.handlebarsCode({ + tla: c.safeHandlebars(), + pv: c.safeHandlebars(), + pn: c.safePropNames(), + row: c.safeHandlebars(), + }).SQL(ctx), + last_modified: sqlEngineNow, + })); + } + await kernel.run(instance, irs); return notebookSQL; };