main
idylls 1 year ago
parent 80ce5bda66
commit 9bff98525a
Signed by: idylls
GPG Key ID: 52D7502B0C319049

@ -1,7 +1,7 @@
import { emptyDir } from "https://deno.land/std@0.162.0/fs/mod.ts";
import sass from "https://deno.land/x/denosass@1.0.6/mod.ts";
import { build, stop } from "https://deno.land/x/esbuild@v0.17.5/mod.js";
import { httpImports } from "https://deno.land/x/esbuild_plugin_http_imports@v1.2.4/index.ts";
import { httpImports } from "https://deno.land/x/esbuild_plugin_http_imports@v1.3.0/index.ts";
await emptyDir("./build");

@ -3,7 +3,19 @@ import { MakeHandlers, makeServe } from "../common/deps/yaypi.ts";
const handlers: MakeHandlers<typeof Api> = {
v1: {
listSchemas: async (limit) => [],
listSchemas: async (limit) => [
{
id: 0,
name: "Test",
fields: [
{
kind: "string",
name: "test",
required: true,
},
],
},
],
saveSchema: async (schema) => ({ ...schema, id: 0 }),
},
};

@ -1,22 +1,77 @@
import { z } from "./deps/zod.ts";
import { zUnionLiterals } from "./utils.ts";
import { Const } from "./utils.ts";
import { ValidationErrors } from "./validation.ts";
export const FieldKinds = ["string" as const, "textarea" as const] as const;
const fDef = <OptionsSchema extends z.ZodTypeAny>(p: {
optionsSchema: OptionsSchema;
defaultOptions: z.infer<OptionsSchema>;
}) => p;
export const Fields = {
string: fDef({
optionsSchema: z.object({
displayAs: z.union([z.literal("input"), z.literal("textarea")]),
minLength: z.number().min(0),
maxLength: z.number().optional(),
}),
defaultOptions: {
displayAs: "input",
minLength: 0,
},
}),
number: fDef({
optionsSchema: z.object({
min: z.number().optional(),
max: z.number().optional(),
}),
defaultOptions: {},
}),
};
export type FieldOptions = {
[Kind in FieldKind]: z.infer<(typeof Fields)[Kind]["optionsSchema"]>;
};
export const FieldKind = zUnionLiterals(FieldKinds);
export type FieldKind = z.infer<typeof FieldKind>;
export type FieldKind = keyof typeof Fields;
export const FieldKinds: readonly FieldKind[] = Object.keys(
Fields,
) as readonly FieldKind[];
export const FieldShape = z.object({
name: z.string(),
kind: FieldKind,
required: z.boolean(),
});
export type FieldShape = z.infer<typeof FieldShape>;
export const defaultOptionsForFieldKind = <K extends FieldKind>(
kind: K,
): (typeof Fields)[K]["defaultOptions"] => Fields[kind].defaultOptions;
type ZodField<Kind extends FieldKind> = z.ZodObject<{
name: z.ZodString;
required: z.ZodBoolean;
kind: z.ZodLiteral<Kind>;
options: (typeof Fields)[Kind]["optionsSchema"];
}>;
type AtLeastTwo<V> = [V, V, ...V[]];
type ZodFields = {
[Kind in FieldKind]: ZodField<Kind>;
}[FieldKind];
const makeZodFields = (): AtLeastTwo<ZodFields> => {
const keys = Object.keys(Fields) as FieldKind[];
return keys.map((k) => {
const zf: ZodField<typeof k> = z.object({
name: z.string(),
required: z.boolean(),
kind: z.literal(k),
options: Fields[k].optionsSchema,
});
return zf;
}) as any;
};
export const Field = z.union(makeZodFields());
export type Field = z.infer<typeof Field>;
const SchemaBase = {
id: z.number(),
fields: z.array(FieldShape),
fields: z.array(Field),
name: z.string(),
};
export const Schema = z.object(SchemaBase);
@ -39,7 +94,7 @@ export type MaybePersistedSchema = z.infer<typeof MaybePersistedSchema>;
export type ValidationErrors_<T> = ValidationErrors<Omit<T, "id">>;
export const validateSchema = (
schema: MaybePersistedSchema,
schema: Const<MaybePersistedSchema>,
): ValidationErrors_<Schema> => {
const errors: ReturnType<typeof validateSchema> = {};
@ -76,3 +131,4 @@ export const validateSchema = (
return errors;
};
export type ValidateSchemaResult = ReturnType<typeof validateSchema>;

@ -64,6 +64,7 @@
"https://deno.land/std@0.182.0/async/pool.ts": "fd082bd4aaf26445909889435a5c74334c017847842ec035739b4ae637ae8260",
"https://deno.land/std@0.182.0/async/retry.ts": "dd19d93033d8eaddbfcb7654c0366e9d3b0a21448bdb06eba4a7d8a8cf936a92",
"https://deno.land/std@0.182.0/async/tee.ts": "47e42d35f622650b02234d43803d0383a89eb4387e1b83b5a40106d18ae36757",
"https://deno.land/std@0.182.0/fmt/colors.ts": "d67e3cd9f472535241a8e410d33423980bec45047e343577554d3356e1f0ef4e",
"https://deno.land/std@0.182.0/http/server.ts": "cbb17b594651215ba95c01a395700684e569c165a567e4e04bba327f41197433",
"https://deno.land/x/denoflate@1.2.1/mod.ts": "f5628e44b80b3d80ed525afa2ba0f12408e3849db817d47a883b801f9ce69dd6",
"https://deno.land/x/denoflate@1.2.1/pkg/denoflate.js": "b9f9ad9457d3f12f28b1fb35c555f57443427f74decb403113d67364e4f2caf4",
@ -73,9 +74,13 @@
"https://deno.land/x/denosass@1.0.6/src/mod.ts": "d2b63172f98238f77831995a5d6c8a06af5252ad8fbe7b9ec40b60eae86f2164",
"https://deno.land/x/denosass@1.0.6/src/types/module.types.ts": "7a5027482ded1d2967fbe690ef8f928446c5de8811c3333f9b09ae6e8122f9ba",
"https://deno.land/x/denosass@1.0.6/src/wasm/grass.deno.js": "a72432ce8d6b8f9c31e31c71415fdca03fe36aa22417e414bc81e2e21a8a687b",
"https://deno.land/x/esbuild@v0.17.15/mod.d.ts": "dc279a3a46f084484453e617c0cabcd5b8bd1920c0e562e4ea02dfc828c8f968",
"https://deno.land/x/esbuild@v0.17.17/mod.d.ts": "dc279a3a46f084484453e617c0cabcd5b8bd1920c0e562e4ea02dfc828c8f968",
"https://deno.land/x/esbuild@v0.17.5/mod.d.ts": "dc279a3a46f084484453e617c0cabcd5b8bd1920c0e562e4ea02dfc828c8f968",
"https://deno.land/x/esbuild@v0.17.5/mod.js": "dc1fca58bbb66e7e87d2234d6518a718ef54019525c2523506cbb6aa619eaa98",
"https://deno.land/x/esbuild_plugin_http_imports@v1.2.4/index.ts": "a71e0483757a0c838bd6799c101cfe7a25513fd4a4905870b0d1d35d2ed96af3",
"https://deno.land/x/esbuild_plugin_http_imports@v1.3.0/index.ts": "85a9ca7359b59d51a3324e54422b35a223cc490656a0c266944583bf51580d3a",
"https://deno.land/x/esbuild_serve@1.2.3/features/httpImports.ts": "c91205899f4d0019db5355c7e8cdf872491a286e87702e378e7ca8c3353d0eab",
"https://deno.land/x/zod@v3.17.10/ZodError.ts": "8a5272bdd5e7ac8194a3ddb5a12ab21e036275e28905d9469b16965335f4af19",
"https://deno.land/x/zod@v3.17.10/external.ts": "6f79b9f9cd6a8ba8dce13bce680e2875995dff3849b6ed1c184cf53ef26df954",
"https://deno.land/x/zod@v3.17.10/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c",
@ -100,6 +105,13 @@
"https://deno.land/x/zod@v3.21.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c",
"https://deno.land/x/zod@v3.21.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4",
"https://deno.land/x/zod@v3.21.4/types.ts": "b5d061babea250de14fc63764df5b3afa24f2b088a1d797fc060ba49a0ddff28",
"https://esm.sh/preact@10.13.2": "5ebf0838bbc3c32fc6e78a4cd9dd672f2c36386d0595815d721a6b0941278488",
"https://esm.sh/preact@10.13.2/hooks": "884334b1560448cf16b4f14841fffdb8707615373a3c76c676a6f9e5c77e43b2",
"https://esm.sh/stable/preact@10.13.2/deno/hooks.js": "c7a8e703bcbc6a05949f329b618c33d5d1ea5fee113ddcea44ff0f527af8556f",
"https://esm.sh/stable/preact@10.13.2/deno/preact.mjs": "365fab897381f4f403f859c5d12939084560545567108cc90dae901bbe892578",
"https://esm.sh/v118/preact@10.13.2/hooks/src/index.d.ts": "5c29febb624fc25d71cb0e125848c9b711e233337a08f7eacfade38fd4c14cc3",
"https://esm.sh/v118/preact@10.13.2/src/index.d.ts": "65398710de6aa0a07412da79784e05e6e96763f51c7c91b77344d2d0af06385c",
"https://esm.sh/v118/preact@10.13.2/src/jsx.d.ts": "9ac9b82c199fa7b04748807d750eba1a106c0be52041b8617416f88d6fc0a257",
"https://git.idylls.net/idylls/shirt/raw/tag/2022.07.27/deps/mediaTypes.ts": "a962ebd6aaa5b2f908bf8aca7f926aedd42cda09f59b928d1a5dbc27dd5a9f99",
"https://git.idylls.net/idylls/shirt/raw/tag/2022.07.27/deps/path.ts": "1181eaa5350db0c5461678e53ed796122bcc4a1e7fd43d49fcc50be80e2fff88",
"https://git.idylls.net/idylls/shirt/raw/tag/2022.07.27/mod.ts": "0a9bd75a2d7982526df49d800224ee6bf06aee7cb16f9a8d217220c6bdbbbfdb",

@ -0,0 +1,92 @@
import { swel } from "../deps/swel.ts";
import { Callback } from "../utils.ts";
export class TextInputWithValidation {
private input = swel("input");
private errorText = swel("div", { className: "error-message" }, "&nbsp;");
public el = swel("div", { className: "validated-input" }, [
this.input,
this.errorText,
]);
set placeholder(s: string) {
this.input.placeholder = s;
}
set onInput(cb: Callback<[string]>) {
this.input.oninput = () => cb(this.input.value);
}
set error(msg: string | undefined) {
if (msg) {
this.el.classList.add("error");
this.errorText.replaceChildren(msg);
} else {
this.el.classList.remove("error");
this.errorText.replaceChildren("&nbsp;");
}
}
set value(v: string) {
this.input.value = v;
}
}
export class LabeledCheckbox {
private checkbox = swel("input", { type: "checkbox" });
private labelText: ReturnType<typeof document.createTextNode>;
private label: HTMLLabelElement;
constructor(p: { labelText: string; checked?: boolean }) {
this.checkbox.checked = p.checked ?? false;
this.labelText = document.createTextNode(p.labelText);
this.label = swel("label", [this.labelText, this.checkbox]);
}
get el() {
return this.label;
}
set checked(c: boolean) {
this.checkbox.checked = c;
}
set onChange(cb: Callback<[boolean]>) {
this.checkbox.onchange = () => cb(this.checkbox.checked);
}
}
export type Choice<V> = {
value: V;
display: string;
};
export class LabeledChoices<V> {
private label: HTMLLabelElement;
private select: HTMLSelectElement;
private _choices: Choice<V>[] = [];
constructor(labelText: string) {
this.select = swel("select");
this.label = swel("label", [labelText, this.select]);
}
set choices(choices: typeof this._choices) {
this._choices = choices;
this.select.replaceChildren(
...this._choices.map((c, i) =>
swel("option", { value: `${i}` }, c.display),
),
);
}
get el() {
return this.label;
}
set onChange(cb: Callback<[V]>) {
this.select.onchange = () => {
cb(this._choices[Number(this.select.value)].value);
};
}
}

@ -0,0 +1,8 @@
import { swel } from "../deps/swel.ts";
export const Loader = (p: Promise<HTMLElement>) => {
const el = swel("div", { className: "loader" }, "Loading...");
p.then((e) => el.replaceWith(e));
return el;
};

@ -0,0 +1,42 @@
import { Opts, swel } from "../deps/swel.ts";
export const WithValidationError = (p: { c: HTMLElement }) => {
const errorMsg = swel("div", { className: "error-message" }, "Space");
const wrapper = swel("div", { className: "with-validation-error" }, [
p.c,
errorMsg,
]);
return {
el: wrapper,
updateError: (e?: string) => {
if (e) {
errorMsg.innerText = e;
wrapper.classList.add("error");
} else {
wrapper.classList.remove("error");
}
},
};
};
export const InputWithValidationError = (opts: Opts<"input">) => {
const input = swel("input", opts);
const withError = WithValidationError({ c: input });
return {
...withError,
input,
};
};
export const LabeledCheckbox = (p: {
labelText: string;
labelOpts?: Opts<"label">;
inputOpts?: Omit<Opts<"input">, "type">;
}) => {
const input = swel("input", { ...p.inputOpts, type: "checkbox" });
const label = swel("label", p.labelOpts, [input, p.labelText]);
return { label, input };
};

@ -0,0 +1,50 @@
import { swel } from "../deps/swel.ts";
export class RecyclerList<View, Model> {
private activeViews = 0;
private _views: View[] = [];
private container: HTMLElement;
constructor(
private createView: () => View,
private getViewDOMElement: (view: View) => HTMLElement,
private bindModel: (view: View, model: Model, index: number) => void,
container?: HTMLElement,
) {
this.container = container ?? swel("div", { className: "recycler-list" });
}
get el() {
return this.container;
}
changed(models: Model[]) {
if (this.activeViews < models.length) {
let i = 0;
for (; i < this.activeViews; ++i) {
const view = this._views[i];
this.bindModel(view, models[i], i);
}
for (; i < models.length; ++i) {
const view = this.createView();
this._views.push(view);
this.bindModel(view, models[i], i);
this.container.appendChild(this.getViewDOMElement(view));
}
} else {
let i = 0;
for (; i < models.length; ++i) {
const view = this._views[i];
this.bindModel(view, models[i], i);
}
for (; i < this.activeViews; ++i) {
this.container.removeChild(this.container.childNodes.item(i));
}
}
this.activeViews = models.length;
}
}

@ -0,0 +1,272 @@
import {
Field,
FieldKind,
FieldKinds,
FieldOptions,
MaybePersistedSchema,
ValidateSchemaResult,
defaultOptionsForFieldKind,
validateSchema,
} from "../../common/schema.ts";
import { swel } from "../deps/swel.ts";
import { Callback, Update } from "../utils.ts";
import {
LabeledChoices,
LabeledCheckbox,
TextInputWithValidation,
} from "./input.ts";
import { RecyclerList } from "./recycler_list.ts";
class StringOptionsEditor {
private displayAs = new LabeledChoices<FieldOptions["string"]["displayAs"]>(
"Display as",
);
private container = swel("div", { className: "string-options" }, [
this.displayAs.el,
]);
private _updateOptions: Update<FieldOptions["string"]> | null = null;
constructor() {
this.displayAs.choices = [
{
value: "input",
display: "input",
},
{
value: "textarea",
display: "textarea",
},
];
this.displayAs.onChange = (s) =>
this._updateOptions?.((o) => (o.displayAs = s));
}
get el() {
return this.container;
}
set updateOptions(u: typeof this._updateOptions) {
this._updateOptions = u;
}
optionsChanged(options: FieldOptions["string"]) {}
}
class NumberOptionsEditor {
private container = swel("div", { className: "number-options" });
private _updateOptions: Update<FieldOptions["string"]> | null = null;
get el() {
return this.container;
}
set updateOptions(u: typeof this._updateOptions) {
this._updateOptions = u;
}
optionsChanged(options: FieldOptions["number"]) {}
}
class FieldOptionsEditor {
private fieldKindToView = {
string: new StringOptionsEditor(),
number: new NumberOptionsEditor(),
};
private container = swel("div", { className: "options-editor" });
private _updateField: Update<Field> | null = null;
set updateField(update: Update<Field>) {
this._updateField = update;
}
get el() {
return this.container;
}
constructor() {
Object.values(this.fieldKindToView).forEach((v) => {
v.updateOptions = (cb) =>
this._updateField?.((v) => cb(v.options as any));
});
}
fieldChanged(field: Field) {
const view = this.fieldKindToView[field.kind];
view.optionsChanged(field.options as any);
this.container.replaceChildren(view.el);
}
}
class FieldEditor {
private nameInput = new TextInputWithValidation();
private kind = new LabeledChoices<FieldKind>("Kind");
private required = new LabeledCheckbox({ labelText: "Required" });
private optionsEditor = new FieldOptionsEditor();
private container = swel("div", { className: "field" }, [
this.nameInput.el,
this.kind.el,
this.optionsEditor.el,
this.required.el,
]);
private _updateField: Update<Field> | null = null;
set updateField(update: Update<Field>) {
this._updateField = update;
}
constructor() {
this.nameInput.placeholder = "Field name";
this.kind.choices = FieldKinds.map((k) => ({
display: k,
value: k,
}));
this.nameInput.onInput = (s) => this._updateField?.((f) => (f.name = s));
this.required.onChange = (s) =>
this._updateField?.((f) => (f.required = s));
this.kind.onChange = (k) =>
this._updateField?.((f) => {
f.kind = k;
f.options = defaultOptionsForFieldKind(k);
});
this.optionsEditor.updateField = (cb) => this._updateField?.((f) => cb(f));
}
get el() {
return this.container;
}
set checked(c: boolean) {
this.required.checked = c;
}
fieldChanged(field: Field, error: string | undefined) {
this.nameInput.value = field.name;
this.nameInput.error = error;
this.optionsEditor.fieldChanged(field);
}
}
class FieldEditors {
private _updateField: ((index: number) => Update<Field>) | null = null;
private list = new RecyclerList(
() => new FieldEditor(),
(view) => view.el,
(view, model: [Field, string | undefined], index) => {
const [field, error] = model;
view.fieldChanged(field, error);
view.updateField = (field) => this._updateField?.(index)(field);
},
);
private addFieldButton = swel(
"button",
{
className: "add",
},
"Add field",
);
private container = swel("div", { className: "fields" }, [
this.list.el,
this.addFieldButton,
]);
get el() {
return this.container;
}
set updateField(fn: Exclude<typeof this._updateField, null>) {
this._updateField = fn;
}
set onAdd(cb: Callback<[Field]>) {
this.addFieldButton.onclick = () =>
cb({
kind: "string",
options: defaultOptionsForFieldKind("string"),
name: "",
required: false,
});
}
fieldsChanged(fields: Field[], errors: ValidateSchemaResult) {
this.list.changed(
fields.map((f, i) => [f, errors[`fields.${i}.name`]?.[0]]),
);
}
}
class Editor {
private nameInput = new TextInputWithValidation();
private fields = new FieldEditors();
private container = swel("div", { className: "schema-editor" }, [
this.nameInput.el,
this.fields.el,
]);
private _updateSchema: Update<MaybePersistedSchema> | null = null;
constructor() {
this.nameInput.placeholder = "Schema name";
this.nameInput.onInput = (s) => {
this._updateSchema?.((sc) => (sc.name = s));
};
this.fields.onAdd = (f) => {
this._updateSchema?.((sc) => sc.fields.push(f));
};
this.fields.updateField = (idx) => (cb) => {
this._updateSchema?.((sc) => cb(sc.fields[idx]));
};
}
get el() {
return this.container;
}
set updateSchema(cb: Update<MaybePersistedSchema>) {
this._updateSchema = cb;
}
schemaChanged(schema: MaybePersistedSchema, errors: ValidateSchemaResult) {
this.nameInput.value = schema.name;
this.nameInput.error = errors.name?.[0];
this.fields.fieldsChanged(schema.fields, errors);
}
}
export class SchemaEditor {
private editor = new Editor();
get el() {
return this.editor.el;
}
editSchema(schema: MaybePersistedSchema) {
const schemaChanged = () => {
const errors = validateSchema(schema);
this.editor.schemaChanged(schema, errors);
};
const updateSchema = (cb: Callback<[typeof schema]>) => {
cb(schema);
console.debug({ schema });
schemaChanged();
};
this.editor.updateSchema = updateSchema;
schemaChanged();
}
}

@ -1,72 +1,50 @@
import { makeClient, assert } from "../common/deps/yaypi.ts";
import { Api, replacer, reviver } from "../common/api.ts";
import { makeClient } from "../../yaypi/mod.ts";
import { reviver } from "../common/api.ts";
import { replacer } from "../common/api.ts";
import { Api } from "../common/api.ts";
import { MaybePersistedSchema } from "../common/schema.ts";
import { SchemaEditor } from "./components/schema_editor.ts";
import { swel } from "./deps/swel.ts";
import { SchemaEditorView } from "./schema_editor.ts";
const client = makeClient(Api, "/api", replacer, reviver);
export type Client = typeof client;
type ShowView = (node: HTMLElement) => void;
const Home = async (p: { showView: ShowView; client: Client }) => {
const schemas = assert(await client.v1.listSchemas(null));
const container = swel("div", { className: "home" }, [
swel("header", "Schemas"),
swel(
"ul",
schemas.map((s) => swel("li", `${s.name}`)),
),
swel(
"button",
{
on: { click: () => p.showView(SchemaEditorView(p)) },
const Home = (showSchemaEditor: () => void) => {
const newSchemaButton = swel(
"button",
{
className: "new-schema",
on: {
click: showSchemaEditor,
},
"New Schema",
),
]);
return container;
};
const init = async () => {
let viewIdx = -1;
const viewStack: HTMLElement[] = [];
},
"New schema",
);
self.addEventListener("popstate", (ev) => {
const state = ev.state as typeof viewIdx;
viewIdx = state;
const el = swel("div", { className: "home" }, newSchemaButton);
const view = viewStack[viewIdx];
if (!view) {
return;
}
return el;
};
main.replaceChildren(view);
});
const App = () => {
const showSchemaEditor = () => {
editor.editSchema({
fields: [],
name: "New schema",
});
el.replaceChildren(editor.el);
};
const home = Home(showSchemaEditor);
const editor = new SchemaEditor();
const showView = (view: HTMLElement) => {
if (viewIdx == -1) {
history.replaceState(++viewIdx, "", null);
} else {
history.pushState(++viewIdx, "", null);
}
viewStack[viewIdx] = view;
const el = swel("div", { className: "app" }, home);
main.replaceChildren(view);
};
return el;
};
const chrome = swel("nav", { className: "nav-chrome" }, [
swel(
"a",
{ on: { click: async () => showView(await Home({ showView, client })) } },
"Home",
),
]);
const main = swel("div", { className: "main" });
const init = () => {
const app = App();
showView(await Home({ showView, client }));
document.body.replaceChildren(chrome, main);
document.body.replaceChildren(app);
};
document.addEventListener("DOMContentLoaded", init);

@ -1,222 +0,0 @@
import { swel } from "./deps/swel.ts";
import {
type MaybePersistedSchema,
validateSchema,
FieldShape,
} from "../common/schema.ts";
import { Client } from "./main.ts";
import { Const } from "../common/utils.ts";
import { Update } from "./utils.ts";
const WithValidationError = (p: { c: HTMLElement }) => {
const errorMsg = swel("div", { className: "error-message" }, "Space");
const wrapper = swel("div", { className: "with-validation-error" }, [
p.c,
errorMsg,
]);
return {
el: wrapper,
paint: (e?: string) => {
if (e) {
errorMsg.innerText = e;
wrapper.classList.add("error");
} else {
wrapper.classList.remove("error");
}
},
};
};
const FieldElement = () => {
const nameInput = swel("input", {
className: "name",
});
const nameInputWithValidationError = WithValidationError({ c: nameInput });
const deleteButton = swel("button", { className: "delete" }, "Delete");
const el = swel("div", { className: "field-element" }, [
nameInputWithValidationError.el,
deleteButton,
]);
const paint = (p: {
field: Const<FieldShape>;
errors: Const<{ [K in keyof FieldShape]?: string[] }>;
updateField: Update<FieldShape>;
deleteField: () => void;
}) => {
nameInput.value = p.field.name;
nameInput.oninput = () => {
p.updateField((f) => (f.name = nameInput.value));
};
deleteButton.onclick = p.deleteField;
nameInputWithValidationError.paint(p.errors.name?.[0]);
};
return {
el,
paint,
};
};
const FieldElements = (p: { updateFields: Update<FieldShape[]> }) => {
const elements: ReturnType<typeof FieldElement>[] = [];
const { updateFields } = p;
let prevLength = 0;
const paint = (p: {
fields: Const<FieldShape[]>;
errors: Const<ReturnType<typeof validateSchema>>;
}) => {
const lenDiff = p.fields.length - prevLength;
if (lenDiff > 0) {
for (let i = prevLength; i < p.fields.length; ++i) {
const fe = FieldElement();
elements[i] = fe;
el.appendChild(fe.el);
}
} else if (lenDiff < 0) {
for (let i = prevLength - 1; i >= p.fields.length; --i) {
el.removeChild(elements[i].el);
delete elements[i];
}
}
for (let i = 0; i < p.fields.length; ++i) {
const field = p.fields[i];
const element = elements[i];
const updateField: Update<FieldShape> = (fn) => {
updateFields((fs) => {
fn?.(fs[i]);
});
};
const deleteField = () => {
updateFields((fs) => {
fs.splice(i, 1);
});
};
element.paint({
field,
updateField,
deleteField,
errors: {
name: p.errors[`fields.${i}.name`],
kind: p.errors[`fields.${i}.kind`],
required: p.errors[`fields.${i}.required`],
},
});
}
prevLength = p.fields.length;
};
const el = swel("div", { className: "field-elements" });
return {
el,
paint,
};
};
export const SchemaEditor = (p: {
updateSchema: Update<MaybePersistedSchema>;
}) => {
const nameInput = swel("input", {
className: "name",
on: {
input: () => {
p.updateSchema((sc) => {
sc.name = nameInput.value;
});
},
},
});
const nameInputWithValidationError = WithValidationError({ c: nameInput });
const fieldElements = FieldElements({
updateFields: (fn) => p.updateSchema((sc) => fn?.(sc.fields)),
});
const addFieldButton = swel(
"button",
{
className: "add-field",
on: {
click: () => {
p.updateSchema((sc) => {
sc.fields.push({
kind: "string",
name: "",
required: true,
});
});
},
},
},
"Add field",
);
const saveButton = swel(
"button",
{
className: "save",
on: { click: () => console.log("save clicked") },
},
"Save",
);
const controls = swel("div", { className: "controls" }, [
addFieldButton,
saveButton,
]);
const el = swel("div", { className: "schema-editor" }, [
nameInputWithValidationError.el,
fieldElements.el,
controls,
]);
const paint = (p: {
schema: Const<MaybePersistedSchema>;
errors: Const<ReturnType<typeof validateSchema>>;
}) => {
nameInput.value = p.schema.name;
saveButton.disabled = Object.keys(p.errors).length > 0;
nameInputWithValidationError.paint(p.errors.name?.[0]);
fieldElements.paint({ fields: p.schema.fields, errors: p.errors });
};
return { paint, el };
};
export const SchemaEditorView = (p: {
maybeSchema?: MaybePersistedSchema;
client: Client;
}) => {
const schema =
p.maybeSchema ??
({
fields: [],
name: "New Schema",
} satisfies MaybePersistedSchema);
const errors = validateSchema(schema);
const updateSchema: Update<MaybePersistedSchema> = (fn) => {
fn?.(schema);
const errors = validateSchema(schema);
paint({ schema, errors });
};
const { paint, el } = SchemaEditor({
updateSchema,
});
paint({ schema, errors });
return el;
};

@ -38,7 +38,7 @@ body {
}
}
.with-validation-error {
.validated-input {
.error-message {
color: red;
visibility: hidden;

@ -1 +1,4 @@
export type Update<T> = (fn?: (arg: T) => void) => void;
import { Const } from "../common/utils.ts";
export type Callback<T extends unknown[]> = (...args: T) => unknown;
export type Update<T> = (cb: (value: T) => void) => void;

Loading…
Cancel
Save