component shuffling before Reactification
parent
9bff98525a
commit
8079a1bd79
@ -0,0 +1,84 @@
|
||||
export type AutoProxyObj<T extends Record<string, any>> = {
|
||||
[K in keyof T]: AutoProxy<T[K]>;
|
||||
};
|
||||
|
||||
export type AutoProxyArr<T> = {
|
||||
[key: number]: AutoProxy<T>;
|
||||
};
|
||||
|
||||
export type AutoProxy<T> = T extends Record<string, any>
|
||||
? AutoProxyObj<Required<T>>
|
||||
: T extends (infer U)[]
|
||||
? AutoProxyArr<Required<U>>
|
||||
: T;
|
||||
|
||||
export const autoProxy = <T>(initialValue?: T): AutoProxy<T> => {
|
||||
let self: undefined | Record<string, any> | any[] = initialValue as any;
|
||||
|
||||
const p = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, prop, __) => {
|
||||
if (prop == "___self") {
|
||||
return self;
|
||||
}
|
||||
|
||||
if (self == undefined) {
|
||||
if (Number.isNaN(Number(prop))) {
|
||||
self = {};
|
||||
} else {
|
||||
self = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.hasOwn(self, prop)) {
|
||||
// @ts-ignore:
|
||||
self[prop] = autoProxy();
|
||||
}
|
||||
|
||||
// @ts-ignore:
|
||||
return self[prop];
|
||||
},
|
||||
|
||||
set: (_, prop, value, __) => {
|
||||
if (self == undefined) {
|
||||
if (Number.isNaN(prop)) {
|
||||
self = {};
|
||||
} else {
|
||||
self = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.hasOwn(self, prop)) {
|
||||
// @ts-ignore:
|
||||
self[prop] = autoProxy();
|
||||
}
|
||||
|
||||
// @ts-ignore:
|
||||
self[prop] = value;
|
||||
|
||||
return true;
|
||||
},
|
||||
ownKeys: () => Object.keys(self || {}),
|
||||
},
|
||||
);
|
||||
|
||||
return p as any;
|
||||
};
|
||||
|
||||
export const unproxy = <T>(proxy: AutoProxy<T>): T => {
|
||||
const self = (proxy as any).___self;
|
||||
if (!self) {
|
||||
return undefined as any;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(self).map(([k, v]) => {
|
||||
if ((v as any)["___self"]) {
|
||||
return [k, unproxy(v)];
|
||||
}
|
||||
|
||||
return [k, v];
|
||||
}),
|
||||
) as T;
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
export const unreachable = (): never => {
|
||||
throw new Error("unreachable");
|
||||
};
|
@ -0,0 +1,23 @@
|
||||
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>[];
|
||||
newArr[index] = newValue;
|
||||
|
||||
return newArr;
|
||||
};
|
||||
|
||||
export const push = <T>(arr: Const<T[]>, newValue: T) => [...arr, newValue];
|
||||
|
||||
export const setProp = <T, K extends keyof T>(
|
||||
obj: Const<T>,
|
||||
key: K,
|
||||
newValue: Const<T[K]>,
|
||||
): Const<T> => ({
|
||||
...obj,
|
||||
[key]: newValue,
|
||||
});
|
@ -0,0 +1,33 @@
|
||||
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,57 @@
|
||||
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,27 @@
|
||||
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,30 @@
|
||||
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) => {};
|
||||
}
|
@ -0,0 +1,324 @@
|
||||
import {
|
||||
FieldKind,
|
||||
Field as FieldModel,
|
||||
FieldOptions as FieldOptionsModels,
|
||||
} from "../../../common/schema.ts";
|
||||
import { ValidationErrors } from "../../../common/validation.ts";
|
||||
import { swel } from "../../deps/swel.ts";
|
||||
import { Dispatch, OnChange } from "../../state/lib/store.ts";
|
||||
import {
|
||||
CmpPaintComponent,
|
||||
ComponentModel,
|
||||
Paint,
|
||||
ShouldPaint,
|
||||
} from "../component.ts";
|
||||
import { TextInput } from "../lib/input/TextInput.ts";
|
||||
import { Validated } from "../lib/input/Validated.ts";
|
||||
import { Action, setFieldOptionProp, setFieldProp } from "./actions.ts";
|
||||
import { RecyclerList } from "../lib/RecyclerList.ts";
|
||||
import { Select } from "../lib/input/Select.ts";
|
||||
import { NumberInput } from "../lib/input/NumberInput.ts";
|
||||
import { Callback } from "../../utils.ts";
|
||||
import { LabeledCheckbox } from "../lib/input/LabeledCheckbox.ts";
|
||||
import { ShowHide, ShowHideModel } from "../lib/ShowHide.ts";
|
||||
import { Labeled } from "../lib/Labeled.ts";
|
||||
|
||||
class ToggleField<
|
||||
Child extends CmpPaintComponent<any>,
|
||||
> extends CmpPaintComponent<ShowHideModel<ComponentModel<Child>>> {
|
||||
private container: HTMLDivElement;
|
||||
private toggle: LabeledCheckbox;
|
||||
private field: ShowHide<Child>;
|
||||
|
||||
constructor(
|
||||
public child: Child,
|
||||
p: { label: string; onToggle: Callback<[boolean]> },
|
||||
) {
|
||||
super();
|
||||
|
||||
this.toggle = new LabeledCheckbox(p.label, p);
|
||||
this.field = new ShowHide(child);
|
||||
|
||||
this.container = swel("div", { className: "toggle-field" }, [
|
||||
this.toggle.el,
|
||||
this.field.el,
|
||||
]);
|
||||
}
|
||||
|
||||
protected paint_: Paint<ShowHideModel<ComponentModel<Child>>> = (cur) => {
|
||||
this.field.paint(cur);
|
||||
this.toggle.paint(cur.show);
|
||||
};
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
}
|
||||
|
||||
type ValidatedFieldOptions<Kind extends FieldKind> = {
|
||||
kind: Kind;
|
||||
options: FieldOptionsModels[Kind];
|
||||
errors?: ValidationErrors<FieldOptionsModels[Kind]>;
|
||||
};
|
||||
|
||||
class NumberFieldOptions extends CmpPaintComponent<
|
||||
ValidatedFieldOptions<"number">
|
||||
> {
|
||||
private container: HTMLDivElement;
|
||||
private minInput: Validated<ToggleField<NumberInput>>;
|
||||
private maxInput: Validated<ToggleField<NumberInput>>;
|
||||
|
||||
constructor(fieldIndex: number, dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.minInput = new Validated(
|
||||
new ToggleField(
|
||||
new NumberInput({
|
||||
onChange: (n) => {
|
||||
dispatch(setFieldOptionProp(fieldIndex, "min", n));
|
||||
},
|
||||
}),
|
||||
{
|
||||
label: "Min?",
|
||||
onToggle: (b) => {
|
||||
dispatch(setFieldOptionProp(fieldIndex, "min", b ? 0 : undefined));
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.maxInput = new Validated(
|
||||
new ToggleField(
|
||||
new NumberInput({
|
||||
onChange: (n) => {
|
||||
dispatch(setFieldOptionProp(fieldIndex, "max", n));
|
||||
},
|
||||
}),
|
||||
{
|
||||
label: "Max?",
|
||||
onToggle: (b) => {
|
||||
dispatch(setFieldOptionProp(fieldIndex, "max", b ? 0 : undefined));
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.container = swel("div", { className: "number-field-options" }, [
|
||||
this.minInput.el,
|
||||
this.maxInput.el,
|
||||
]);
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected paint_: Paint<ValidatedFieldOptions<"number">> = (cur) => {
|
||||
this.maxInput.paint({
|
||||
model: {
|
||||
show: cur.options.max != undefined,
|
||||
model: cur.options.max,
|
||||
},
|
||||
error: cur.errors?.max?.[0],
|
||||
});
|
||||
|
||||
this.minInput.paint({
|
||||
model: {
|
||||
show: cur.options.min != undefined,
|
||||
model: cur.options.min,
|
||||
},
|
||||
error: cur.errors?.min?.[0],
|
||||
});
|
||||
|
||||
if (cur.options.max) {
|
||||
this.maxInput.child.child.setMin(cur.options.min);
|
||||
}
|
||||
|
||||
if (cur.options.min) {
|
||||
this.minInput.child.child.setMax(cur.options.max);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class StringFieldOptions extends CmpPaintComponent<
|
||||
ValidatedFieldOptions<"string">
|
||||
> {
|
||||
private container: HTMLDivElement;
|
||||
private displayAs: Select<FieldOptionsModels["string"]["displayAs"]>;
|
||||
private minLength: Validated<Labeled<NumberInput>>;
|
||||
private maxLength: Validated<ToggleField<NumberInput>>;
|
||||
|
||||
constructor(fieldIndex: number, dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.displayAs = new Select(
|
||||
{
|
||||
input: "input",
|
||||
textarea: "textarea",
|
||||
},
|
||||
(v) => dispatch(setFieldOptionProp(fieldIndex, "displayAs", v)),
|
||||
);
|
||||
|
||||
this.minLength = new Validated(
|
||||
new Labeled(
|
||||
"Min length",
|
||||
new NumberInput({
|
||||
onChange: (v) =>
|
||||
dispatch(setFieldOptionProp(fieldIndex, "minLength", v)),
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.minLength.child.child.setMin(0);
|
||||
|
||||
this.maxLength = new Validated(
|
||||
new ToggleField(
|
||||
new NumberInput({
|
||||
onChange: (n) =>
|
||||
dispatch(setFieldOptionProp(fieldIndex, "maxLength", n)),
|
||||
}),
|
||||
{
|
||||
label: "Max length?",
|
||||
onToggle: (b) =>
|
||||
dispatch(
|
||||
setFieldOptionProp(fieldIndex, "maxLength", b ? 0 : undefined),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.container = swel("div", { className: "string-field-options" }, [
|
||||
this.displayAs.el,
|
||||
this.minLength.el,
|
||||
this.maxLength.el,
|
||||
]);
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected paint_: Paint<ValidatedFieldOptions<"string">> = (cur) => {
|
||||
this.displayAs.paint(cur.options.displayAs);
|
||||
this.minLength.paint({
|
||||
model: cur.options.minLength,
|
||||
error: cur.errors?.minLength?.[0],
|
||||
});
|
||||
this.minLength.child.child.setMax(cur.options.maxLength);
|
||||
|
||||
this.maxLength.paint({
|
||||
model: {
|
||||
show: cur.options.maxLength != undefined,
|
||||
model: cur.options.maxLength,
|
||||
},
|
||||
error: cur.errors?.maxLength?.[0],
|
||||
});
|
||||
this.maxLength.child.child.setMin(cur.options.minLength);
|
||||
};
|
||||
}
|
||||
|
||||
type FieldOptionComponentRegistry = {
|
||||
[K in FieldKind]: CmpPaintComponent<ValidatedFieldOptions<K>>;
|
||||
};
|
||||
|
||||
class FieldOptions extends CmpPaintComponent<ValidatedFieldOptions<FieldKind>> {
|
||||
private fieldOptionComponents: FieldOptionComponentRegistry;
|
||||
private container = swel("div", { className: "field-options" });
|
||||
|
||||
constructor(fieldIndex: number, dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.fieldOptionComponents = {
|
||||
number: new NumberFieldOptions(fieldIndex, dispatch),
|
||||
string: new StringFieldOptions(fieldIndex, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
protected paint_: Paint<ValidatedFieldOptions<FieldKind>> = (cur) => {
|
||||
const view = this.fieldOptionComponents[cur.kind];
|
||||
if (!this.previousModel || this.previousModel.kind != cur.kind) {
|
||||
this.container.replaceChildren(view.el);
|
||||
}
|
||||
|
||||
view.paint(cur as any);
|
||||
};
|
||||
}
|
||||
|
||||
export type ValidatedField = {
|
||||
field: FieldModel;
|
||||
errors?: ValidationErrors<FieldModel>;
|
||||
};
|
||||
|
||||
class Field extends CmpPaintComponent<ValidatedField> {
|
||||
private container: HTMLDivElement;
|
||||
private nameInput: Validated<TextInput>;
|
||||
private kindSelect: Select<FieldKind>;
|
||||
private fieldOptions: FieldOptions;
|
||||
|
||||
constructor(fieldIndex: number, dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.nameInput = new Validated(
|
||||
new TextInput({
|
||||
onInput: (s) => dispatch(setFieldProp(fieldIndex, "name", s)),
|
||||
}),
|
||||
);
|
||||
|
||||
this.kindSelect = new Select(
|
||||
{ string: "string", number: "number" },
|
||||
(v) => {
|
||||
dispatch(setFieldProp(fieldIndex, "kind", v));
|
||||
},
|
||||
);
|
||||
|
||||
this.fieldOptions = new FieldOptions(fieldIndex, dispatch);
|
||||
|
||||
this.container = swel("div", { className: "field" }, [
|
||||
this.nameInput.el,
|
||||
this.kindSelect.el,
|
||||
this.fieldOptions.el,
|
||||
]);
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
paint_: Paint<ValidatedField> = (cur) => {
|
||||
this.nameInput.paint({
|
||||
model: cur.field.name,
|
||||
error: cur.errors?.["name"]?.[0],
|
||||
});
|
||||
|
||||
this.kindSelect.paint(cur.field.kind);
|
||||
|
||||
this.fieldOptions.paint({
|
||||
kind: cur.field.kind,
|
||||
options: cur.field.options,
|
||||
errors: cur.errors?.options,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class Fields extends CmpPaintComponent<ValidatedField[]> {
|
||||
private container: HTMLDivElement;
|
||||
private list: RecyclerList<ValidatedField>;
|
||||
|
||||
constructor(dispatch: Dispatch<Action>) {
|
||||
super();
|
||||
|
||||
this.list = new RecyclerList((i) => new Field(i, dispatch));
|
||||
this.container = swel("div", { className: "fields" }, [this.list.el]);
|
||||
}
|
||||
|
||||
get el() {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
paint_: Paint<ValidatedField[]> = (cur) => {
|
||||
this.list.paint(cur);
|
||||
};
|
||||
}
|
@ -0,0 +1,118 @@
|
||||
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,71 @@
|
||||
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);
|
||||
};
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import { Const } from "../../common/utils.ts";
|
||||
|
||||
export type ShouldPaint<Model> = (model: Const<Model>) => boolean;
|
||||
export type Paint<Model> = (model: Const<Model>) => void;
|
||||
|
||||
export abstract class Component<El = HTMLElement> {
|
||||
abstract get el(): El;
|
||||
}
|
||||
|
||||
export abstract class ModelComponent<
|
||||
Model = never,
|
||||
El = HTMLElement,
|
||||
> extends Component<El> {}
|
||||
|
||||
export abstract class PaintComponent<
|
||||
Model = never,
|
||||
El = HTMLElement,
|
||||
> extends ModelComponent<El> {
|
||||
abstract paint: Paint<Model>;
|
||||
}
|
||||
|
||||
export abstract class CmpPaintComponent<
|
||||
Model = never,
|
||||
El = HTMLElement,
|
||||
> extends PaintComponent<Const<Model>, El> {
|
||||
protected previousModel: Model | undefined;
|
||||
|
||||
paint: Paint<Model> = (model) => {
|
||||
if (this.shouldPaint(model)) {
|
||||
this.paint_(model);
|
||||
}
|
||||
|
||||
this.previousModel = model as any;
|
||||
};
|
||||
|
||||
protected shouldPaint: ShouldPaint<Model> = (cur) => {
|
||||
return cur != this.previousModel;
|
||||
};
|
||||
|
||||
protected abstract paint_: Paint<Model>;
|
||||
}
|
||||
|
||||
export type ComponentModel<T extends ModelComponent<any>> =
|
||||
T extends ModelComponent<infer M> ? Const<M> : never;
|
@ -0,0 +1,27 @@
|
||||
import { swel } from "../../deps/swel.ts";
|
||||
import {
|
||||
CmpPaintComponent,
|
||||
ComponentModel,
|
||||
Paint,
|
||||
PaintComponent,
|
||||
} from "../component.ts";
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
import { swel } from "../../deps/swel.ts";
|
||||
import { CmpPaintComponent, Paint } from "../component.ts";
|
||||
|
||||
export class RecyclerList<T> extends CmpPaintComponent<T[]> {
|
||||
private views: CmpPaintComponent<T>[] = [];
|
||||
|
||||
constructor(
|
||||
private makeChild: (index: number) => CmpPaintComponent<T>,
|
||||
private container = swel("div"),
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
paint_: Paint<T[]> = (cur) => {
|
||||
const prev = this.previousModel;
|
||||
|
||||
if (!prev) {
|
||||
this.views = cur.map((c, i) => {
|
||||
const view = this.makeChild(i);
|
||||
view.paint(c);
|
||||
this.container.appendChild(view.el);
|
||||
|
||||
return view;
|
||||
});
|
||||
} else if (cur.length > prev.length) {
|
||||
let i = 0;
|
||||
for (; i < prev.length; ++i) {
|
||||
const c = cur[i];
|
||||
|
||||
this.views[i].paint(c);
|
||||
}
|
||||
|
||||
for (; i < cur.length; ++i) {
|
||||
const c = cur[i];
|
||||
|
||||
this.views[i] ??= this.makeChild(i);
|
||||
const view = this.views[i];
|
||||
view.paint(c);
|
||||
this.container.appendChild(view.el);
|
||||
}
|
||||
} else if (cur.length <= prev.length) {
|
||||
let i = 0;
|
||||
for (; i < cur.length; ++i) {
|
||||
const c = cur[i];
|
||||
|
||||
const view = this.views[i];
|
||||
view.paint(c);
|
||||
}
|
||||
|
||||
for (; i < prev.length; ++i) {
|
||||
this.container.children[i].remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { swel } from "../../deps/swel.ts";
|
||||
import { ComponentModel, Paint } from "../component.ts";
|
||||
import { CmpPaintComponent } from "../component.ts";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
};
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { swel } from "../../../deps/swel.ts";
|
||||
import { Callback } from "../../../utils.ts";
|
||||
import { CmpPaintComponent, Paint } from "../../component.ts";
|
||||
|
||||
export class LabeledCheckbox extends CmpPaintComponent<boolean> {
|
||||
private label: HTMLLabelElement;
|
||||
private input: HTMLInputElement;
|
||||
|
||||
constructor(label: string, p: { onToggle: Callback<[boolean]> }) {
|
||||
super();
|
||||
|
||||
this.input = swel("input", {
|
||||
type: "checkbox",
|
||||
on: { change: () => p.onToggle(this.input.checked) },
|
||||
});
|
||||
|
||||
this.label = swel("label", {}, [label, this.input]);
|
||||
}
|
||||
|
||||
get el(): HTMLElement {
|
||||
return this.label;
|
||||
}
|
||||
|
||||
protected paint_: Paint<boolean> = (cur) => {
|
||||
this.input.checked = cur;
|
||||
};
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import { swel } from "../../../deps/swel.ts";
|
||||
import { Callback } from "../../../utils.ts";
|
||||
import { CmpPaintComponent, Paint } from "../../component.ts";
|
||||
|
||||
export class NumberInput extends CmpPaintComponent<number> {
|
||||
private input: HTMLInputElement = swel("input", { type: "number" });
|
||||
|
||||
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}`;
|
||||
};
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import { swel } from "../../../deps/swel.ts";
|
||||
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;
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import { swel } from "../../../deps/swel.ts";
|
||||
import { Callback } from "../../../utils.ts";
|
||||
import { CmpPaintComponent, Paint } from "../../component.ts";
|
||||
|
||||
export class TextInput extends CmpPaintComponent<string> {
|
||||
private input: HTMLInputElement = swel("input");
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
import { swel } from "../../../deps/swel.ts";
|
||||
import { CmpPaintComponent, ComponentModel, Paint } from "../../component.ts";
|
||||
|
||||
export type ValidatedModel<T> = {
|
||||
model: T;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
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(" ");
|
||||
}
|
||||
|
||||
(this.child as any).paint(model);
|
||||
};
|
||||
}
|
@ -1,50 +1,16 @@
|
||||
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";
|
||||
|
||||
const client = makeClient(Api, "/api", replacer, reviver);
|
||||
|
||||
const Home = (showSchemaEditor: () => void) => {
|
||||
const newSchemaButton = swel(
|
||||
"button",
|
||||
{
|
||||
className: "new-schema",
|
||||
on: {
|
||||
click: showSchemaEditor,
|
||||
},
|
||||
},
|
||||
"New schema",
|
||||
);
|
||||
|
||||
const el = swel("div", { className: "home" }, newSchemaButton);
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const showSchemaEditor = () => {
|
||||
editor.editSchema({
|
||||
fields: [],
|
||||
name: "New schema",
|
||||
});
|
||||
el.replaceChildren(editor.el);
|
||||
};
|
||||
const home = Home(showSchemaEditor);
|
||||
const editor = new SchemaEditor();
|
||||
|
||||
const el = swel("div", { className: "app" }, home);
|
||||
|
||||
return el;
|
||||
};
|
||||
import { App } from "./components/App/view.ts";
|
||||
import { LOCAL_STORAGE_KEY, initStore } from "./state/store.ts";
|
||||
|
||||
const init = () => {
|
||||
const app = App();
|
||||
let app: App | null = null;
|
||||
const { dispatch } = initStore((cur, prev) => app?.paint(cur));
|
||||
app = new App(dispatch);
|
||||
|
||||
document.body.replaceChildren(app);
|
||||
document.body.replaceChildren(app.el);
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
(window as any)["resetApp"] = () => {
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY);
|
||||
location.reload();
|
||||
};
|
||||
|
@ -0,0 +1 @@
|
||||
export type Actions = null;
|
@ -0,0 +1,45 @@
|
||||
import { Const } from "../../../common/utils.ts";
|
||||
|
||||
export type OnChange<State, Returning = void> = (
|
||||
current: Const<State>,
|
||||
prev?: Const<State>,
|
||||
) => Returning;
|
||||
export type Reduce<State, Actions> = (
|
||||
current: Const<State>,
|
||||
action: Actions,
|
||||
) => Const<State>;
|
||||
export type Dispatch<Actions> = (action: Actions) => void;
|
||||
|
||||
export type Store<Actions> = {
|
||||
dispatch: Dispatch<Actions>;
|
||||
};
|
||||
|
||||
export const createStore = <State, Actions>(
|
||||
initialState: Const<State>,
|
||||
reduce: Reduce<State, Actions>,
|
||||
onChange: OnChange<State>,
|
||||
): Store<Actions> => {
|
||||
let dirty = true;
|
||||
let prevState: Const<State> | undefined = undefined;
|
||||
let state = initialState;
|
||||
|
||||
const doChange = () => {
|
||||
console.assert(dirty);
|
||||
dirty = false;
|
||||
|
||||
onChange(state, prevState);
|
||||
prevState = state;
|
||||
};
|
||||
requestAnimationFrame(doChange);
|
||||
|
||||
const dispatch = (action: Actions) => {
|
||||
if (!dirty) {
|
||||
dirty = true;
|
||||
requestAnimationFrame(doChange);
|
||||
}
|
||||
|
||||
state = reduce(state, action);
|
||||
};
|
||||
|
||||
return { dispatch };
|
||||
};
|
@ -0,0 +1 @@
|
||||
import { AppState } from "./state.ts";
|
@ -0,0 +1,34 @@
|
||||
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";
|
||||
|
||||
const defaultAppState: AppState = {
|
||||
view: { kind: "Home", state: undefined },
|
||||
};
|
||||
|
||||
export const LOCAL_STORAGE_KEY = "zchemaAppState";
|
||||
const saveStateToLocalStorage = (state: Const<AppState>) => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state));
|
||||
};
|
||||
|
||||
const loadStateFromLocalStorage = (): AppState | null => {
|
||||
const r = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (r == null) {
|
||||
return r;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
Loading…
Reference in New Issue