Preactify

main
idylls 1 year ago
parent 8079a1bd79
commit 4cd9a47dc3
Signed by: idylls
GPG Key ID: 52D7502B0C319049

@ -2,6 +2,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.3.0/index.ts";
import * as importMap from "npm:esbuild-plugin-import-map";
await emptyDir("./build");
@ -13,13 +14,17 @@ compiler.to_file({
format: "compressed",
});
const denoJson = JSON.parse(await Deno.readTextFile("./deno.json"));
importMap.load(denoJson);
const res = await build({
entryPoints: ["./frontend/main.ts"],
write: true,
bundle: true,
outfile: "./build/app.js",
sourcemap: "inline",
plugins: [httpImports()],
plugins: [httpImports(), importMap.plugin()],
});
console.debug(res);

@ -6,21 +6,26 @@ const handlers: MakeHandlers<typeof Api> = {
listSchemas: async (limit) => [
{
id: 0,
name: "Test",
fields: [
{
kind: "string",
name: "test",
required: false,
options: {
displayAs: "input",
minLength: 0,
},
latestVersion: {
version: 1,
schema: {
name: "Test",
fields: [
{
name: "name",
options: {
displayAs: "input",
minLength: 0,
},
kind: "string",
required: true,
},
],
},
],
},
},
],
saveSchema: async (schema) => ({ ...schema, id: 0 }),
saveSchema: async ({ schema, id }) => "todo",
},
};

@ -1,12 +1,35 @@
import { z } from "./deps/zod.ts";
import { ReplacerFn, ReviverFn, sig } from "./deps/yaypi.ts";
import { MaybePersistedSchema, Schema } from "./schema.ts";
import { Schema } from "./schema.ts";
export const Api = {
v1: {
listSchemas: sig(z.number().nullable(), z.array(Schema)),
saveSchema: sig(MaybePersistedSchema, Schema),
listSchemas: sig(
z.number().nullable(),
z.array(
z.object({
id: z.number(),
latestVersion: z.object({
version: z.number(),
schema: Schema,
}),
}),
),
),
saveSchema: sig(
z.object({
id: z.number().optional(),
schema: Schema,
}),
z.union([
z.object({
id: z.number(),
version: z.number(),
}),
z.string(),
]),
),
},
};

@ -1,23 +1,24 @@
import { Const } from "../utils.ts";
export const setIdx = <T>(
arr: Const<T[]>,
index: number,
newValue: Const<T>,
): Const<T[]> => {
const newArr = [...arr] as Const<T>[];
export const setIdx = <T>(arr: T[], index: number, newValue: T): T[] => {
const newArr = [...arr];
newArr[index] = newValue;
return newArr;
};
export const push = <T>(arr: Const<T[]>, newValue: T) => [...arr, newValue];
export const push = <T>(arr: T[], newValue: T) => [...arr, newValue];
export const deleteIdx = <T>(arr: T[], index: number): T[] => {
const newArr = [...arr];
newArr.splice(index, 1);
return newArr;
};
export const setProp = <T, K extends keyof T>(
obj: Const<T>,
obj: T,
key: K,
newValue: Const<T[K]>,
): Const<T> => ({
newValue: T[K],
): T => ({
...obj,
[key]: newValue,
});

@ -1,6 +1,5 @@
import { z } from "./deps/zod.ts";
import { autoProxy, unproxy } from "./lib/autoproxy.ts";
import { MaybePersisted } from "./persisted.ts";
import { Const } from "./utils.ts";
import { ValidationErrors } from "./validation.ts";
@ -72,7 +71,6 @@ export const Field = z.union(makeZodFields());
export type Field = z.infer<typeof Field>;
const SchemaBase = {
id: z.number(),
fields: z.array(Field),
name: z.string(),
};
@ -91,6 +89,7 @@ export type Schema = RefineID<z.infer<typeof Schema>, SchemaID>;
export const MaybePersistedSchema = z.object({
...SchemaBase,
id: z.number().optional(),
version: z.number().optional(),
});
export type MaybePersistedSchema = z.infer<typeof MaybePersistedSchema>;
@ -127,14 +126,21 @@ export const validateSchema = (
const kind = field.kind;
if (kind == "number") {
const options = field.options;
if (options.max && options.min && options.min >= options.max) {
if (
options.max != undefined &&
options.min != undefined &&
options.min >= options.max
) {
(errors.fields[i].options as any).min = [
"Min must be less than or equal to max",
];
}
} else if (kind == "string") {
const options = field.options;
if (options.maxLength && options.maxLength < options.minLength) {
if (
options.maxLength != undefined &&
options.maxLength < options.minLength
) {
(errors.fields[i].options as any).maxLength = [
"Max length must be greater than or equal to min",
];

@ -27,3 +27,9 @@ export type ReturnTypes<T extends ((...args: any) => any)[]> = {
[K in keyof T]: ReturnType<T[K]>;
}[number];
type A = ReturnTypes<[() => number, () => string]>;
export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never;

@ -1,2 +1,9 @@
{
"imports": {
"frontend": "./frontend",
"swel": "../swel/mod.ts",
"preact": "https://esm.sh/preact@10.13.2",
"preact/debug": "https://esm.sh/preact@10.13.2/debug",
"preact/hooks": "https://esm.sh/preact@10.13.2/hooks"
}
}

@ -106,7 +106,10 @@
"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/debug": "5c2442ec06daa3bf686c41b3e4020b4ee23e046a4fcc034e319429942308855e",
"https://esm.sh/preact@10.13.2/hooks": "884334b1560448cf16b4f14841fffdb8707615373a3c76c676a6f9e5c77e43b2",
"https://esm.sh/stable/preact@10.13.2/deno/debug.js": "1a982c30a858af0ab7f9893fc981bb14ad5cbeffef1a8c3f33c4fbe7d80109a3",
"https://esm.sh/stable/preact@10.13.2/deno/devtools.js": "dcda4e6c6f034d19f220e6b44e070e1dc81e841b0c03bb1048c8fde88935a5c8",
"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",
@ -116,5 +119,16 @@
"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",
"https://git.idylls.net/idylls/yaypi/raw/tag/2022.07.27/mod.ts": "5ed6326b9f440e7a36266dbeb255d1f32d24a65d7fd2454e2d10355bd96e54c5"
},
"npm": {
"specifiers": {
"esbuild-plugin-import-map": "esbuild-plugin-import-map@2.1.0"
},
"packages": {
"esbuild-plugin-import-map@2.1.0": {
"integrity": "sha512-rlI9H8f1saIqYEUNHxDmIMGZZFroANyD6q3Aht6aXyOq/aOdO6jp5VFF1+n3o9AUe+wAtQcn93Wv1Vuj9na0hg==",
"dependencies": {}
}
}
}
}

@ -0,0 +1,82 @@
import { ComponentChild, Fragment, VNode, createContext, h } from "preact";
import { useContext, useEffect, useMemo, useReducer } from "preact/hooks";
import { Home } from "../Home/Home.ts";
import { Action, State, ViewState, ViewStateKind, reduce } from "./state.ts";
import { Dispatch } from "../../utils.ts";
import { saveStateToLocalStorage } from "../../state/store.ts";
import { Const } from "../../../common/utils.ts";
import { SchemaEditor } from "../SchemaEditor/SchemaEditor.ts";
import { goHome } from "./state.ts";
import { MakeClient, makeClient } from "../../../common/deps/yaypi.ts";
import { Api } from "../../../common/api.ts";
type ViewStates = {
[K in ViewState["kind"]]: ViewState & { kind: K };
};
type ViewRegistry_ = {
// @ts-ignore: weird
[K in keyof ViewStates]: ViewStates[K]["state"];
};
type ViewRegistry = {
[K in keyof ViewRegistry_]: (p: {
state: ViewRegistry_[K];
dispatch: Dispatch<Action>;
client: Client;
}) => VNode;
};
const VIEW_REGISTRY: ViewRegistry = {
Home: (p) => h(Home, p),
SchemaEditor: (p) => h(SchemaEditor, p),
};
const AppView = (
p: ViewState & { dispatch: Dispatch<Action>; client: Client },
) => {
const View = VIEW_REGISTRY[p.kind];
return h(View as any, p);
};
const VIEW_NAMES: Record<ViewStateKind, string> = {
Home: "Home",
SchemaEditor: "Schema Editor",
};
const Layout = (p: {
state: State;
dispatch: Dispatch<Action>;
client: Client;
}) => {
const view = h(AppView, {
...p.state.view,
dispatch: p.dispatch,
client: p.client,
});
const name = VIEW_NAMES[p.state.view.kind];
const onClickHome = () => {
p.dispatch(goHome());
};
return h(
Fragment,
{},
h("nav", {}, h("a", { onClick: onClickHome }, "Zchema"), h("a", {}, name)),
h("main", {}, view),
);
};
export const App = (p: State) => {
const [appState, dispatch] = useReducer(reduce, p);
const client = useMemo(() => makeClient(Api, "/api"), []);
useEffect(() => {
saveStateToLocalStorage(appState as Const<State>);
}, [appState]);
return h(Layout, { state: appState, dispatch, client });
};

@ -1,33 +0,0 @@
import { unreachable } from "../../../common/lib/dev.ts";
import { Reduce } from "../../state/lib/store.ts";
import { Action as HomeAction, reduce as reduceHome } from "../Home/actions.ts";
import {
Action as SchemaEditorAction,
reduce as reduceSchemaEditor,
} from "../SchemaEditor/actions.ts";
import { AppState } from "./view.ts";
export type Action = HomeAction | SchemaEditorAction;
export const reduce: Reduce<AppState, Action> = (state, action) => {
if (state.view.kind == "Home") {
return {
view: reduceHome(state.view.state, action as HomeAction),
};
}
if (state.view.kind == "SchemaEditor") {
return {
...state,
view: {
...state.view,
state: reduceSchemaEditor(
state.view.state,
action as SchemaEditorAction,
),
},
};
}
return unreachable();
};

@ -0,0 +1,47 @@
import { Reducer } from "preact/hooks";
import {
Action as HomeAction,
State as HomeState,
reduce as reduceHome,
} from "../Home/state.ts";
import {
Action as SchemaEditorAction,
State as SchemaEditorState,
reduce as reduceSchemaEditor,
} from "../SchemaEditor/state.ts";
import { ReturnTypes } from "../../../common/utils.ts";
export const goHome = () => ({ kind: "goHome" });
export type Action =
| HomeAction
| SchemaEditorAction
| ReturnTypes<[typeof goHome]>;
type ViewState_<Kind extends string, T> = { kind: Kind; state: T };
export type ViewState =
| ViewState_<"Home", HomeState>
| ViewState_<"SchemaEditor", SchemaEditorState>;
export type ViewStateKind = ViewState["kind"];
export type State = {
view: ViewState;
};
export const reduce: Reducer<State, Action> = (state, action) => {
if (action.kind == "goHome") {
return {
...state,
view: { kind: "Home", state: null },
};
} else if (state.view.kind === "Home") {
return {
...state,
view: reduceHome(state.view, action as HomeAction),
};
} else {
return {
...state,
view: reduceSchemaEditor(state.view, action as SchemaEditorAction),
};
}
};

@ -1,57 +0,0 @@
import { Const } from "../../../common/utils.ts";
import { swel } from "../../deps/swel.ts";
import { Dispatch } from "../../state/lib/store.ts";
import { Home, State as HomeState } from "../Home/view.ts";
import {
SchemaEditor,
State as SchemaEditorState,
} from "../SchemaEditor/view.ts";
import { CmpPaintComponent, Paint } from "../component.ts";
import { Action } from "./actions.ts";
type ViewState_<Kind extends string, T> = { kind: Kind; state: T };
export type ViewState =
| ViewState_<"Home", HomeState>
| ViewState_<"SchemaEditor", SchemaEditorState>;
export type ViewStateKind = ViewState["kind"];
export type AppState = {
view: ViewState;
};
type ViewRegistry_ = {
[K in ViewStateKind]: ViewState & { kind: K };
};
type ViewRegistry = {
// @ts-ignore: Weird
[K in keyof ViewRegistry_]: CmpPaintComponent<ViewRegistry_[K]["state"]>;
};
export class App extends CmpPaintComponent<AppState> {
private viewRegistry: ViewRegistry;
constructor(private dispatch: Dispatch<Action>) {
super();
this.viewRegistry = {
SchemaEditor: new SchemaEditor(this.dispatch),
Home: new Home(this.dispatch),
};
}
private container = swel("div", { className: "app" });
get el() {
return this.container;
}
paint_: Paint<AppState> = (cur) => {
const view = this.viewRegistry[cur.view.kind];
if (!this.previousModel || cur.view.kind != this.previousModel.view.kind) {
this.container.replaceChildren(view.el);
}
view.paint(cur.view.state as any as never);
};
}

@ -0,0 +1,24 @@
import { h } from "preact";
import { Dispatch } from "../../utils.ts";
import { Action } from "./state.ts";
import { Client } from "../../fetch.ts";
export const Home = (p: { dispatch: Dispatch<Action>; client: Client }) => {
const button = h(
"button",
{
onClick: () => p.dispatch({ kind: "editNewSchema" }),
},
"New schema",
);
const b = h(
"button",
{
onClick: () => p.dispatch({ kind: "hello" }),
},
"Say hello",
);
return h("div", {}, button, b);
};

@ -1,27 +0,0 @@
import { unreachable } from "../../../common/lib/dev.ts";
import { Const, ReturnTypes } from "../../../common/utils.ts";
import { ViewState } from "../App/view.ts";
import { State } from "./view.ts";
export const newSchema = () => ({ kind: "newSchema" as const });
export type Action = ReturnTypes<[typeof newSchema]>;
export const reduce = (
state: Const<State>,
action: Action,
): Const<ViewState> => {
if (action.kind == "newSchema") {
return {
kind: "SchemaEditor",
state: {
schema: {
fields: [],
name: "New schema",
},
errors: {},
},
};
}
return unreachable();
};

@ -0,0 +1,19 @@
import { Reducer } from "preact/hooks";
import { ViewState } from "../App/state.ts";
export type State = null;
export type Action = { kind: "editNewSchema" } | { kind: "hello" };
export const reduce = (state: ViewState, action: Action): ViewState => {
if (action.kind == "hello") {
return state;
}
return {
kind: "SchemaEditor",
state: {
fields: [],
name: "New schema",
},
};
};

@ -1,30 +0,0 @@
import { swel } from "../../deps/swel.ts";
import { Dispatch } from "../../state/lib/store.ts";
import { CmpPaintComponent, Paint } from "../component.ts";
import { Action, newSchema } from "./actions.ts";
export type State = undefined;
export class Home extends CmpPaintComponent<State> {
private newSchemaButton = swel(
"button",
{
on: { click: () => this.dispatch(newSchema()) },
},
"New schema",
);
private container = swel("div", { className: "home" }, [
this.newSchemaButton,
]);
constructor(private dispatch: Dispatch<Action>) {
super();
}
get el() {
return this.container;
}
paint_: Paint<State> = (cur) => {};
}

@ -74,7 +74,7 @@ class NumberFieldOptions extends CmpPaintComponent<
this.minInput = new Validated(
new ToggleField(
new NumberInput({
onChange: (n) => {
onInput: (n) => {
dispatch(setFieldOptionProp(fieldIndex, "min", n));
},
}),
@ -90,7 +90,7 @@ class NumberFieldOptions extends CmpPaintComponent<
this.maxInput = new Validated(
new ToggleField(
new NumberInput({
onChange: (n) => {
onInput: (n) => {
dispatch(setFieldOptionProp(fieldIndex, "max", n));
},
}),
@ -163,7 +163,7 @@ class StringFieldOptions extends CmpPaintComponent<
new Labeled(
"Min length",
new NumberInput({
onChange: (v) =>
onInput: (v) =>
dispatch(setFieldOptionProp(fieldIndex, "minLength", v)),
}),
),
@ -173,7 +173,7 @@ class StringFieldOptions extends CmpPaintComponent<
this.maxLength = new Validated(
new ToggleField(
new NumberInput({
onChange: (n) =>
onInput: (n) =>
dispatch(setFieldOptionProp(fieldIndex, "maxLength", n)),
}),
{

@ -0,0 +1,151 @@
import { VNode, h } from "preact";
import {
FieldKind,
FieldOptions as FieldOptionsModels,
} from "../../../../common/schema.ts";
import { ValidationErrors } from "../../../../common/validation.ts";
import { Select } from "../../lib/input/Select.ts";
import { Labeled } from "../../lib/Labeled.ts";
import { NumberInput } from "../../lib/input/NumberInput.ts";
import { Callback } from "../../../utils.ts";
import { Checkbox } from "../../lib/input/Checkbox.ts";
import { ShowHide } from "../../lib/ShowHide.ts";
import { Validated } from "../../lib/input/Validated.ts";
const ToggleField = (p: {
label: string;
onToggle: Callback<[boolean]>;
errors?: string[];
show: boolean;
inner: () => VNode;
}) => {
return h(
"div",
{ class: "toggle-field" },
h(
Validated,
{ errors: p.errors },
h(
Labeled,
{ label: p.label },
h(Checkbox, { onToggle: p.onToggle, checked: p.show }),
),
h(ShowHide, { show: p.show, inner: p.inner }),
),
);
};
export type SetFieldOption<K extends FieldKind = FieldKind> = <
KK extends keyof FieldOptionsModels[K],
>(
optionProp: KK,
value: FieldOptionsModels[K][KK],
) => void;
type Props<K extends FieldKind> = {
options: FieldOptionsModels[K];
errors?: ValidationErrors<FieldOptionsModels[K]>;
setFieldOption: SetFieldOption<K>;
};
const StringFieldOptions = (p: Props<"string">) => {
type DisplayAs = FieldOptionsModels["string"]["displayAs"];
const displayAs = h(
Labeled,
{ label: "Display as" },
h(Select<DisplayAs>, {
value: p.options.displayAs,
options: {
input: "input",
textarea: "textarea",
},
onChange: (v) => p.setFieldOption("displayAs", v),
}),
);
const minLength = h(
Validated,
{ errors: p.errors?.minLength },
h(
Labeled,
{ label: "Min length" },
h(NumberInput, {
max: p.options.maxLength,
value: p.options.minLength,
onInput: (v) => p.setFieldOption("minLength", v),
}),
),
);
const maxLength = h(ToggleField, {
label: "Max length?",
errors: p.errors?.maxLength,
show: p.options.maxLength != undefined,
onToggle: (active) => p.setFieldOption("maxLength", active ? 0 : undefined),
inner: () => {
return h(NumberInput, {
value: p.options.maxLength ?? 0,
min: p.options.minLength,
onInput: (v) => p.setFieldOption("maxLength", v),
});
},
});
return h(
"div",
{ class: "field-options string" },
displayAs,
minLength,
maxLength,
);
};
const NumberFieldOptions = (p: Props<"number">) => {
const min = h(ToggleField, {
errors: p.errors?.min,
label: "Min?",
show: p.options.min != undefined,
onToggle: (b) => p.setFieldOption("min", b ? 0 : undefined),
inner: () =>
h(NumberInput, {
max: p.options.max,
value: p.options.min ?? 0,
onInput: (v) => p.setFieldOption("min", v),
}),
});
const max = h(ToggleField, {
errors: p.errors?.max,
label: "Max?",
show: p.options.max != undefined,
onToggle: (b) => p.setFieldOption("max", b ? 0 : undefined),
inner: () =>
h(NumberInput, {
min: p.options.min,
value: p.options.max ?? 0,
onInput: (v) => p.setFieldOption("max", v),
}),
});
return h("div", { class: "field-options number" }, min, max);
};
type FieldOptionComponentRegistry = {
[K in FieldKind]: (p: Props<K>) => VNode;
};
const FIELD_OPTION_COMPONENT_REGISTRY: FieldOptionComponentRegistry = {
number: NumberFieldOptions,
string: StringFieldOptions,
};
export const FieldOptions = (p: {
kind: FieldKind;
options: FieldOptionsModels[FieldKind];
errors?: ValidationErrors<FieldOptionsModels[FieldKind]>;
setFieldOption: SetFieldOption<FieldKind>;
}) => {
const Component = FIELD_OPTION_COMPONENT_REGISTRY[p.kind];
return h(Component as any, p);
};

@ -0,0 +1,78 @@
import { h } from "preact";
import { FieldKind, Field as FieldModel } from "../../../../common/schema.ts";
import { ValidationErrors } from "../../../../common/validation.ts";
import { Callback, Dispatch } from "../../../utils.ts";
import { Action, deleteField, setFieldOption, setFieldProp } from "../state.ts";
import { Validated } from "../../lib/input/Validated.ts";
import { TextInput } from "../../lib/input/TextInput.ts";
import { Select } from "../../lib/input/Select.ts";
import { Labeled } from "../../lib/Labeled.ts";
import { FieldOptions, SetFieldOption } from "./FieldOptions.ts";
const Field = (p: {
field: FieldModel;
errors?: ValidationErrors<FieldModel>;
setName: Callback<[string]>;
setKind: Callback<[FieldKind]>;
setFieldOption: SetFieldOption;
delete: Callback<[]>;
}) => {
const nameInput = h(
Validated,
{ errors: p.errors?.name },
h(TextInput, {
value: p.field.name,
placeholder: "Field name",
onInput: p.setName,
}),
);
const kindSelect = h(
Labeled,
{ label: "Kind" },
h(Select<FieldKind>, {
options: { string: "string", number: "number" },
value: p.field.kind,
onChange: p.setKind,
}),
);
const options = h(FieldOptions, {
kind: p.field.kind,
options: p.field.options,
errors: p.errors?.options,
setFieldOption: p.setFieldOption,
});
const deleteBtn = h("button", { onClick: p.delete }, "Delete");
return h(
"div",
{ class: "field" },
nameInput,
kindSelect,
options,
deleteBtn,
);
};
export const Fields = (p: {
fields: FieldModel[];
errors?: ValidationErrors<FieldModel[]>;
dispatch: Dispatch<Action>;
}) => {
const fields = p.fields.map((f, i) => {
const errors = p.errors?.[i];
return h(Field, {
field: f,
errors,
setName: (s) => p.dispatch(setFieldProp(i, "name", s)),
setKind: (s) => p.dispatch(setFieldProp(i, "kind", s)),
setFieldOption: (pr, v) => p.dispatch(setFieldOption(i, pr, v)),
delete: () => p.dispatch(deleteField(i)),
});
});
return h("div", { class: "fields" }, ...fields);
};

@ -0,0 +1,63 @@
import { h } from "preact";
import { Dispatch } from "../../utils.ts";
import { State, Action, setName, addField } from "./state.ts";
import { Action as AppAction } from "../App/state.ts";
import { TextInput } from "../lib/input/TextInput.ts";
import { Validated } from "../lib/input/Validated.ts";
import { validateSchema } from "../../../common/schema.ts";
import { Fields } from "./Fields/Fields.ts";
import { Client } from "../App/App.ts";
import { assert } from "../../../common/deps/yaypi.ts";
import { goHome } from "../App/state.ts";
export const SchemaEditor = (p: {
state: State;
dispatch: Dispatch<AppAction>;
client: Client;
}) => {
const errors = validateSchema(p.state);
const nameInput = h(
Validated,
{
errors: errors.name,
},
h(TextInput, {
placeholder: "Schema name",
value: p.state.name,
onInput: (s) => p.dispatch(setName(s)),
class: "name",
}),
);
const fields = h(Fields, {
fields: p.state.fields,
dispatch: p.dispatch,
errors: errors.fields,
});
const addFieldBtn = h(
"button",
{ onClick: () => p.dispatch(addField()) },
"Add field",
);
const save = async () => {
const result = assert(await p.client.v1.saveSchema(p.state));
if (typeof result === "string") {
console.error(result);
}
p.dispatch(goHome());
};
const saveBtn = h("button", { onClick: () => save() }, "Save");
return h(
"div",
{ class: "schema-editor" },
nameInput,
fields,
addFieldBtn,
saveBtn,
);
};

@ -1,118 +0,0 @@
import { unreachable } from "../../../common/lib/dev.ts";
import { push, setIdx, setProp } from "../../../common/lib/imm.ts";
import {
Field,
FieldKind,
FieldOptions,
MaybePersistedSchema,
defaultOptionsForFieldKind,
validateSchema,
} from "../../../common/schema.ts";
import { Const, ReturnTypes } from "../../../common/utils.ts";
import { Reduce } from "../../state/lib/store.ts";
import { State } from "./view.ts";
export const setName = (name: string) => ({ setName: name });
export const addField = () => "addField" as const;
export const setFieldProp = <P extends keyof Field>(
index: number,
prop: P,
value: Field[P],
) => ({
index,
prop,
value,
});
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;
export type AllOptions = UnionToIntersection<FieldOptions[FieldKind]>;
export const setFieldOptionProp = <P extends keyof AllOptions>(
index: number,
optionProp: P,
value: Required<AllOptions[P]>,
) => ({
index,
optionProp,
value,
});
export type Action = ReturnTypes<
[
typeof setName,
typeof addField,
typeof setFieldProp,
typeof setFieldOptionProp,
]
>;
const reduceSchema = (
schema: Const<MaybePersistedSchema>,
action: Action,
): Const<MaybePersistedSchema> => {
console.debug(action);
if (action == "addField") {
return setProp(
schema,
"fields",
push(schema.fields, {
kind: "string",
name: "New field",
options: defaultOptionsForFieldKind("string"),
required: false,
}),
);
}
if ("setName" in action) {
return setProp(schema, "name", action.setName);
}
if ("prop" in action) {
let field = setProp(schema.fields[action.index], action.prop, action.value);
if (action.prop == "kind") {
field = setProp(field, "options", {
...defaultOptionsForFieldKind(action.value as Field["kind"]),
...field.options,
});
}
return setProp(
schema,
"fields",
setIdx(schema.fields, action.index, field),
);
}
if ("optionProp" in action) {
return setProp(
schema,
"fields",
setIdx(
schema.fields,
action.index,
setProp(
schema.fields[action.index],
"options",
setProp(
schema.fields[action.index].options as any,
action.optionProp as never,
action.value as never,
),
),
),
);
}
return unreachable();
};
export const reduce: Reduce<State, Action> = (state, action) => {
const schema = reduceSchema(state.schema, action);
const errors = validateSchema(schema);
return { schema, errors };
};

@ -0,0 +1,130 @@
import { Reducer } from "preact/hooks";
import {
Field,
FieldKind,
FieldOptions,
MaybePersistedSchema,
defaultOptionsForFieldKind,
} from "../../../common/schema.ts";
import { ViewState } from "../App/state.ts";
import { ReturnTypes, UnionToIntersection } from "../../../common/utils.ts";
import { deleteIdx, setIdx, setProp } from "../../../common/lib/imm.ts";
export const setName = (name: string) => ({ kind: "setName" as const, name });
export const addField = () => ({ kind: "addField" as const });
export const setFieldProp = <K extends keyof Field>(
index: number,
prop: K,
value: Field[K],
) => ({
kind: "setFieldProp" as const,
index,
prop,
value,
});
export type AllOptions = UnionToIntersection<FieldOptions[FieldKind]>;
export const setFieldOption = <K extends keyof AllOptions>(
index: number,
option: K,
value: AllOptions[K],
) => ({
kind: "setFieldOption" as const,
index,
option,
value,
});
export const deleteField = (index: number) => ({
kind: "deleteField" as const,
index,
});
export type Action = ReturnTypes<
[
typeof setName,
typeof addField,
typeof setFieldProp,
typeof deleteField,
typeof setFieldOption,
]
>;
export type State = MaybePersistedSchema;
const reduce_ = (state: State, action: Action): ViewState => {
if (action.kind == "setName") {
return {
kind: "SchemaEditor",
state: {
...state,
name: action.name,
},
};
} else if (action.kind == "addField") {
return {
kind: "SchemaEditor",
state: {
...state,
fields: [
...state.fields,
{
kind: "string",
name: "New field",
options: defaultOptionsForFieldKind("string"),
required: false,
},
],
},
};
} else if (action.kind == "setFieldProp") {
let field = setProp(state.fields[action.index], action.prop, action.value);
if (action.prop == "kind") {
field = setProp(field, "options", {
...defaultOptionsForFieldKind(field.kind),
...field.options,
});
}
return {
kind: "SchemaEditor",
state: {
...state,
fields: setIdx(state.fields, action.index, field),
},
};
} else if (action.kind == "setFieldOption") {
return {
kind: "SchemaEditor",
state: setProp(
state,
"fields",
setIdx(
state.fields,
action.index,
setProp(
state.fields[action.index],
"options",
setProp(
state.fields[action.index].options,
action.option as never,
action.value as never,
),
),
),
),
};
} else if (action.kind == "deleteField") {
return {
kind: "SchemaEditor",
state: setProp(state, "fields", deleteIdx(state.fields, action.index)),
};
}
return {
kind: "SchemaEditor",
state,
};
};
export const reduce: Reducer<ViewState, Action> = (state, action) => {
return reduce_(state.state as State, action);
};

@ -1,71 +0,0 @@
import {
MaybePersistedSchema,
ValidateSchemaResult,
validateSchema,
} from "../../../common/schema.ts";
import { swel } from "../../deps/swel.ts";
import { Dispatch } from "../../state/lib/store.ts";
import { CmpPaintComponent, Paint } from "../component.ts";
import { TextInput } from "../lib/input/TextInput.ts";
import { Validated } from "../lib/input/Validated.ts";
import { Fields } from "./Fields.ts";
import { Action, setName, addField } from "./actions.ts";
export type State = {
schema: MaybePersistedSchema;
errors: ValidateSchemaResult;
};
export class SchemaEditor extends CmpPaintComponent<State> {
private nameInput: Validated<string, TextInput>;
private fields: Fields;
private container: HTMLDivElement;
constructor(dispatch: Dispatch<Action>) {
super();
this.nameInput = new Validated(
new TextInput({
onInput: (s) => dispatch(setName(s)),
}),
);
const addFieldBtn = swel(
"button",
{
on: {
click: () => {
dispatch(addField());
},
},
},
"Add field",
);
this.fields = new Fields(dispatch);
this.container = swel("div", { className: "schema-editor" }, [
this.nameInput.el,
this.fields.el,
addFieldBtn,
]);
}
get el() {
return this.container;
}
paint_: Paint<State> = (cur) => {
this.nameInput.paint({
model: cur.schema.name,
error: cur.errors.name?.[0],
});
const fields = cur.schema.fields.map((f, i) => ({
field: f,
errors: cur.errors.fields?.[i],
}));
this.fields.paint(fields);
};
}

@ -1,27 +1,5 @@
import { swel } from "../../deps/swel.ts";
import {
CmpPaintComponent,
ComponentModel,
Paint,
PaintComponent,
} from "../component.ts";
import { h, ComponentChildren } from "preact";
export class Labeled<
Child extends PaintComponent<any>,
> extends CmpPaintComponent<ComponentModel<Child>> {
private label: HTMLLabelElement;
constructor(text: string, public child: Child) {
super();
this.label = swel("label", {}, [text, this.child.el]);
}
get el(): HTMLElement {
return this.label;
}
paint_: Paint<ComponentModel<Child>> = (c) => {
this.child.paint(c);
};
}
export const Labeled = (p: { label: string; children?: ComponentChildren }) => {
return h("label", {}, p.label, p.children);
};

@ -1,35 +1,9 @@
import { swel } from "../../deps/swel.ts";
import { ComponentModel, Paint } from "../component.ts";
import { CmpPaintComponent } from "../component.ts";
import { Fragment, VNode, h } from "preact";
export type ShowHideModel<T> = {
show: boolean;
model: T;
};
export class ShowHide<
Child extends CmpPaintComponent<any>,
> extends CmpPaintComponent<ShowHideModel<ComponentModel<Child>>> {
private container: HTMLDivElement;
constructor(private child: Child) {
super();
this.container = swel("div", {}, this.child.el);
export const ShowHide = (p: { show: boolean; inner: () => VNode }) => {
if (p.show) {
return p.inner();
}
get el(): HTMLElement {
return this.container;
}
protected paint_: Paint<ShowHideModel<ComponentModel<Child>>> = (cur) => {
if (cur.show) {
this.child.paint(cur.model);
if (!this.previousModel?.show) {
this.container.replaceChildren(this.child.el);
}
} else {
this.container.replaceChildren("");
}
};
}
return h(Fragment, {});
};

@ -0,0 +1,17 @@
import { h } from "preact";
import { useRef } from "preact/hooks";
import { Callback } from "../../../utils.ts";
export const Checkbox = (p: {
checked: boolean;
onToggle: Callback<[boolean]>;
}) => {
const ref = useRef<HTMLInputElement>(null);
return h("input", {
ref,
type: "checkbox",
checked: p.checked,
onChange: () => p.onToggle(ref.current!.checked),
});
};

@ -1,33 +1,24 @@
import { swel } from "../../../deps/swel.ts";
import { JSX, h } from "preact";
import { useRef } from "preact/hooks";
import { Callback } from "../../../utils.ts";
import { CmpPaintComponent, Paint } from "../../component.ts";
export class NumberInput extends CmpPaintComponent<number> {
private input: HTMLInputElement = swel("input", { type: "number" });
export const NumberInput = (
p: Omit<JSX.HTMLAttributes<HTMLInputElement>, "onInput"> & {
value: number;
min?: number;
max?: number;
onInput: Callback<[number]>;
},
) => {
const ref = useRef<HTMLInputElement>(null);
constructor(p?: Partial<{ onChange: Callback<[number]> }>) {
super();
if (p?.onChange) {
this.input.addEventListener("input", () =>
p.onChange!(Number(this.input.value)),
);
}
}
get el() {
return this.input;
}
setMin = (n?: number) => {
this.input.min = `${n ?? ""}`;
};
setMax = (n?: number) => {
this.input.max = `${n ?? ""}`;
};
paint_: Paint<number> = (cur) => {
this.input.value = `${cur}`;
};
}
return h("input", {
...p,
type: "number",
ref,
min: p.min,
max: p.max,
value: p.value,
onInput: () => p.onInput(Number(ref.current!.value)),
});
};

@ -1,33 +1,26 @@
import { swel } from "../../../deps/swel.ts";
import { h } from "preact";
import { useRef } from "preact/hooks";
import { Callback } from "../../../utils.ts";
import { CmpPaintComponent, Paint } from "../../component.ts";
export type Options<Values extends string> = Record<Values, string>;
export class Select<Values extends string> extends CmpPaintComponent<Values> {
private select: HTMLSelectElement;
export const Select = <Values extends string>(p: {
value: Values;
options: Options<Values>;
onChange: Callback<[Values]>;
}) => {
const ref = useRef<HTMLSelectElement>(null);
const select = h(
"select",
{
ref,
value: p.value,
onChange: () => p.onChange(ref.current!.value as Values),
},
...Object.entries(p.options).map(([k, v]) =>
h("option", { value: `${v}` }, k),
),
);
constructor(options: Options<Values>, onChange: Callback<[Values]>) {
super();
this.select = swel(
"select",
{
on: {
change: () => onChange(this.select.value as Values),
},
},
(Object.keys(options) as (keyof typeof options)[]).map((o) =>
swel("option", { value: `${o}` }, options[o]),
),
);
}
get el(): HTMLElement {
return this.select;
}
protected paint_: Paint<Values> = (cur) => {
this.select.value = cur as string;
};
}
return select;
};

@ -1,23 +1,19 @@
import { swel } from "../../../deps/swel.ts";
import { h, JSX } from "preact";
import { useRef } from "preact/hooks";
import { Callback } from "../../../utils.ts";
import { CmpPaintComponent, Paint } from "../../component.ts";
export class TextInput extends CmpPaintComponent<string> {
private input: HTMLInputElement = swel("input");
export const TextInput = (
p: Omit<JSX.HTMLAttributes<HTMLInputElement>, "onInput"> & {
placeholder?: string;
value: string;
onInput: Callback<[string]>;
},
) => {
const ref = useRef<HTMLInputElement>(null);
constructor(p?: Partial<{ onInput: Callback<[string]> }>) {
super();
if (p?.onInput) {
this.input.addEventListener("input", () => p.onInput!(this.input.value));
}
}
get el() {
return this.input;
}
paint_: Paint<string> = (cur) => {
this.input.value = cur;
};
}
return h("input", {
...p,
ref,
onInput: () => p.onInput(ref.current!.value),
});
};

@ -1,39 +1,15 @@
import { swel } from "../../../deps/swel.ts";
import { CmpPaintComponent, ComponentModel, Paint } from "../../component.ts";
export type ValidatedModel<T> = {
model: T;
error?: string;
import { ComponentChildren, h } from "preact";
export const Validated = (p: {
errors?: string[];
children?: ComponentChildren;
}) => {
const errorText = h("div", { class: "error-message" }, ...(p.errors ?? []));
return h(
"div",
{ class: `validated ${p.errors ? "error" : ""}` },
p.children,
errorText,
);
};
export class Validated<
Child extends CmpPaintComponent<any>,
> extends CmpPaintComponent<ValidatedModel<ComponentModel<Child>>> {
private container: HTMLDivElement;
private errorMessage = swel("div", { className: "error-message" });
constructor(public child: Child) {
super();
this.container = swel("div", { className: "validated" }, [
(this.child as any).el,
this.errorMessage,
]);
}
get el() {
return this.container;
}
paint_: Paint<ValidatedModel<ComponentModel<Child>>> = ({ model, error }) => {
if (error) {
this.container.classList.add("error");
this.errorMessage.replaceChildren(error);
} else {
this.container.classList.remove("error");
this.errorMessage.replaceChildren("&nbsp;");
}
(this.child as any).paint(model);
};
}

@ -0,0 +1,37 @@
import { Reducer, useMemo, useReducer } from "preact/hooks";
import { MakeClient, assert, makeClient } from "../common/deps/yaypi.ts";
import { Api } from "../common/api.ts";
export type Client = MakeClient<typeof Api>;
export type Loadable<T, E = string> =
| null
| { loading: true }
| { loading: boolean; data: T }
| { loading: boolean; error: string };
export const isLoading = <T>(loadable: Loadable<T>) => loadable?.loading;
export type Data = {
schemas: Loadable<ReturnType<Client["v1"]["listSchemas"]>>;
};
export type Action = never;
const reduce: Reducer<Data, Action> = (data, action) => {
return data;
};
const emptyData: Data = {
schemas: null,
};
// NOTE: this should only be used in App.ts
export const useFetch = () => {
const client = useMemo(() => makeClient(Api, "/api"), []);
const [data, reducer] = useReducer(reduce, emptyData);
const listSchemas = async (
...args: Parameters<typeof client.v1.listSchemas>
) => {
const schemas = assert(await client.v1.listSchemas(...args));
};
};

@ -1,12 +1,20 @@
import { App } from "./components/App/view.ts";
import { LOCAL_STORAGE_KEY, initStore } from "./state/store.ts";
import "preact/debug";
import { h, render } from "preact";
import { App } from "./components/App/App.ts";
import {
LOCAL_STORAGE_KEY,
defaultAppState,
loadStateFromLocalStorage,
} from "./state/store.ts";
const init = () => {
let app: App | null = null;
const { dispatch } = initStore((cur, prev) => app?.paint(cur));
app = new App(dispatch);
const state = loadStateFromLocalStorage();
// const state = defaultAppState;
const app = h(App, state);
document.body.replaceChildren(app.el);
render(app, document.body);
};
document.addEventListener("DOMContentLoaded", init);

@ -1,34 +1,20 @@
import { Const } from "../../common/utils.ts";
import { OnChange, createStore } from "./lib/store.ts";
import { Action, reduce } from "../components/App/actions.ts";
import { AppState } from "../components/App/view.ts";
import { State } from "../components/App/state.ts";
const defaultAppState: AppState = {
view: { kind: "Home", state: undefined },
export const defaultAppState: State = {
view: { kind: "Home", state: null },
};
export const LOCAL_STORAGE_KEY = "zchemaAppState";
const saveStateToLocalStorage = (state: Const<AppState>) => {
export const saveStateToLocalStorage = (state: Const<State>) => {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
};
const loadStateFromLocalStorage = (): AppState | null => {
export const loadStateFromLocalStorage = (): State => {
const r = localStorage.getItem(LOCAL_STORAGE_KEY);
if (r == null) {
return r;
return defaultAppState;
}
return JSON.parse(r);
};
export const initStore = (paint: OnChange<AppState>) => {
const state = loadStateFromLocalStorage() ?? defaultAppState;
const store = createStore<AppState, Action>(state, reduce, (cur, prev) => {
console.debug({ state: cur, prev });
saveStateToLocalStorage(cur);
paint(cur, prev);
});
return store;
};

@ -13,6 +13,14 @@ input, button, select {
font-size: inherit;
}
input {
width: 100%;
&[type="checkbox"] {
width: unset;
}
}
// Page Layout
body {
margin: 0;
@ -20,11 +28,15 @@ body {
}
// Components
.nav-chrome {
nav {
padding: 1em;
display: flex;
flex-direction: row;
gap: 1em;
}
.main {
main {
padding: 1em;
}

@ -2,3 +2,5 @@ import { Const } from "../common/utils.ts";
export type Callback<T extends unknown[]> = (...args: T) => unknown;
export type Update<T> = (cb: (value: T) => void) => void;
export type Dispatch<Action> = (a: Action) => unknown;

Loading…
Cancel
Save