Last active
March 3, 2021 14:21
-
-
Save monzee/03a2b374d5495da1857fbd6b60bcff0c to your computer and use it in GitHub Desktop.
MVx pattern, not-invented-here edition
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// <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