Skip to content

Instantly share code, notes, and snippets.

@monzee
Last active March 3, 2021 14:21
Show Gist options
  • Save monzee/03a2b374d5495da1857fbd6b60bcff0c to your computer and use it in GitHub Desktop.
Save monzee/03a2b374d5495da1857fbd6b60bcff0c to your computer and use it in GitHub Desktop.
MVx pattern, not-invented-here edition
/// <reference lib="es2015" />
type Visitor<R, V> = {
[K in keyof V]: V[K] extends any[]
? (...pattern: V[K]) => R
: never;
};
type Sum<V> = <R>(visitor: Visitor<R, V>) => R;
type Observer<V> = Visitor<void, V>;
type Dispose = () => void;
type Observable<V> = (callback: Observer<V>) => Dispose;
type Credentials = Record<"username" | "password", string>;
interface Form extends Credentials {
errors: Credentials;
}
type State = {
ready: [formData: Form];
invalid: [formData: Form];
busy: [formData: Form];
cantLogin: [formData: Form, reason: "denied" | "unavailable"];
};
type Action = {
setUsername: [value: string];
setPassword: [value: string];
login: [];
};
interface Model {
attach: Observable<State>;
dispatch: Observer<Action>;
finish(): Promise<string>;
}
interface View {
on: Observable<Action>;
render: Observer<State>;
}
function modelOf(
login: (username: string, password: string) => Promise<string>,
validate: (form: Credentials, errors: Credentials) => void
): Model {
type Tag = "idle" | "busy" | "failed" | "done";
type Reason = "denied" | "unavailable" | Error;
let client: Observer<State> | null = null;
let tag: Tag = "idle";
let reason: Reason | null = null;
let token = "";
let resolve: ((value: string) => void) | null = null;
let reject: ((reason: any) => void) | null = null;
const form = {
username: "",
password: "",
errors: {
username: "",
password: ""
},
isValid() {
return !form.errors.username && !form.errors.password;
}
};
function notify() {
switch (tag) {
case "idle":
if (form.isValid()) {
client?.ready(form);
} else {
client?.invalid(form);
}
break;
case "busy":
client?.busy(form);
break;
case "failed":
if (reason === "denied" || reason === "unavailable") {
client?.cantLogin(form, reason);
} else {
reject?.(reason);
reject = null;
resolve = null;
}
break;
case "done":
resolve?.(token);
resolve = null;
reject = null;
break;
}
}
function doValidate(): boolean {
form.errors.username = "";
form.errors.password = "";
validate(form, form.errors);
return form.isValid();
}
return {
attach(callback) {
client = callback;
return () => { client = null };
},
dispatch: {
setUsername(value) {
form.username = value;
if (tag !== "busy") {
doValidate();
tag = "idle";
notify();
}
},
setPassword(value) {
form.password = value;
if (tag !== "busy") {
doValidate();
tag = "idle";
notify();
}
},
async login() {
if (tag === "busy") return;
if (!doValidate()) {
tag = "idle";
notify();
return;
}
tag = "busy";
notify();
try {
token = await login(form.username, form.password);
tag = "done";
} catch (e) {
reason = e;
tag = "failed";
} finally {
notify();
}
}
},
finish() {
notify();
return new Promise((ok, err) => {
resolve = ok;
reject = err;
});
}
};
}
function viewOf(ids: {
username: string,
password: string,
submit: string,
form: string,
indicator: string
}): View {
const username = document.getElementById(ids.username) as HTMLInputElement;
const password = document.getElementById(ids.password) as HTMLInputElement;
const submit = document.getElementById(ids.submit) as HTMLButtonElement;
const form = document.getElementById(ids.form) as HTMLFormElement;
const indicator = document.getElementById(ids.indicator);
let model: Observer<Action> | null = null;
username.addEventListener("input", () => model?.setUsername(username.value));
password.addEventListener("input", () => model?.setPassword(password.value));
form.addEventListener("submit", (ev) => {
ev.preventDefault();
model?.login();
});
function show(formData: Form) {
username.value = formData.username;
password.value = formData.password;
username.setCustomValidity(formData.errors.username);
password.setCustomValidity(formData.errors.password);
submit.setCustomValidity("");
}
function disableForm(disabled: boolean) {
[username, password, submit].forEach((el) => el.disabled = disabled);
indicator.style.display = disabled ? "" : "none";
}
return {
on(callback) {
model = callback;
return () => { model = null };
},
render: {
ready(formData) {
show(formData);
disableForm(false);
},
invalid(formData) {
show(formData);
disableForm(false);
submit.disabled = true;
},
busy(formData) {
show(formData);
disableForm(true);
},
cantLogin(formData, reason) {
show(formData);
disableForm(false);
submit.setCustomValidity(reason);
}
}
};
}
async function start(model: Model, view: View): Promise<string> {
const un = view.on(model.dispatch);
const detach = model.attach(view.render);
try {
return await model.finish();
} finally {
un();
detach();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment