Skip to content

Instantly share code, notes, and snippets.

@monzee
Created March 30, 2021 11:03
Show Gist options
  • Save monzee/de9f61520dc1562da8e598151720dea5 to your computer and use it in GitHub Desktop.
Save monzee/de9f61520dc1562da8e598151720dea5 to your computer and use it in GitHub Desktop.
Fowler GUI Architectures running example - https://www.martinfowler.com/eaaDev/uiArchs.html
/// <reference path="./mvvm.ts" />
namespace Fowler {
export function mvvm(
ids: Dict<string> & { stations: string },
dataSet: Reading[]
): Promise<never> {
return connect(
appOf(dataSet),
viewOf({
stationId: document.getElementById(ids.stationId) as Input,
date: document.getElementById(ids.date) as Input,
target: document.getElementById(ids.target) as Input,
actual: document.getElementById(ids.actual) as Input,
variance: document.getElementById(ids.variance) as Input,
stations: document.getElementById(ids.stations) as Select,
finish: null
})
);
}
type Reading = {
stationId: string
date: Date
target: number
actual: number
variance?: number
finish?: void
}
type State = {
loading: []
ready: [dataSet: Reading[], selectedIndex?: number]
}
type Action = {
pull: []
push: [data: Reading]
select: [index: number]
}
type Detail = {
none: []
ready: [data: Reading, quality: Quality]
}
type Quality = "good" | "bad" | "normal"
type Dict<T> = {
[K in keyof Reading]-?: Reading[K] extends void ? T | null : T
}
type Input = HTMLInputElement
type Option = HTMLOptionElement
type Select = HTMLSelectElement
const Quality: Quality[] = ["good", "bad", "normal"];
function appOf(dataSet: Reading[]): ViewModel<never, State, Action> {
const views = observersOf<State>({ loading: 0, ready: 1 });
return {
attach: views.add,
dispatch: {
select(index) {
views.notify.ready(dataSet, index);
},
pull() {
throw new Error("unimpemented: #pull");
},
push() {
throw new Error("unimplemented: #push");
}
},
start(_, reject) {
console.info("Ready!!");
views.catch(reject);
views.notify.ready(dataSet);
}
};
}
function viewOf({ stations, ...elems }: Dict<Input> & {
stations: Select
}): View<State, Action> {
let formViewModel: ViewModel<void, Detail, Editor<Reading>> | null;
const form = detailOf(elems);
const options: Option[] = [];
const proto = document.createElement("option");
function removeOptions(fromIndex = 0) {
if (fromIndex < options.length) {
let removed = options.splice(fromIndex, options.length - fromIndex);
for (let opt of removed) {
stations.removeChild(opt);
}
}
}
function redraw(opt: Option) {
return (newValue: string) => {
opt.textContent = newValue;
};
}
return {
on(dispatch) {
const select = () => dispatch.select(stations.selectedIndex);
stations.addEventListener("input", select);
return () => {
removeOptions();
formViewModel?.dispatch.finish();
formViewModel = null;
stations.removeEventListener("input", select);
};
},
render: {
loading() {
form.render.none();
removeOptions();
},
ready(dataSet, selectedIndex = -1) {
if (dataSet.length < options.length) {
removeOptions(dataSet.length);
}
else if (dataSet.length > options.length) {
for (let i = options.length; i < dataSet.length; i++) {
let opt = proto.cloneNode() as Option;
stations.add(opt);
options.push(opt);
}
}
options.forEach((opt, i) => {
opt.value = i.toString();
opt.textContent = dataSet[i].stationId;
opt.selected = i === selectedIndex;
});
formViewModel?.dispatch.finish();
if (selectedIndex < 0) {
form.render.none();
}
else {
formViewModel = editorOf(
dataSet[selectedIndex],
redraw(options[selectedIndex])
);
connect(formViewModel, form);
}
}
}
};
}
function editorOf(
row: Reading,
onStationIdChange: (newValue: string) => void
): ViewModel<void, Detail, Editor<Reading>> {
let end: Dispose | null;
const views = observersOf<Detail>({ none: 0, ready: 2 });
function quality() {
row.variance = row.actual - row.target;
let pct = row.variance / row.target;
return (
pct > 0.05 ? "good" :
pct < -0.1 ? "bad" :
"normal"
);
}
return {
attach: views.add,
dispatch: {
setStationId(value) {
row.stationId = value;
onStationIdChange(value);
},
setDate(value) {
row.date = value;
},
setTarget(value) {
row.target = value || 0;
views.notify.ready(row, quality());
},
setActual(value) {
row.actual = value || 0;
views.notify.ready(row, quality());
},
setVariance(value) {
throw new Error("Tried to set a computed field, #variance.");
},
finish() {
end?.();
end = null;
}
},
start(resolve, reject) {
end = resolve;
views.catch(reject);
views.notify.ready(row, quality());
}
};
}
function detailOf(elems: Dict<Input>): View<Detail, Editor<Reading>> {
let activeBoundary = 0;
const listeners: [Input, EventListener][] = [];
const { stationId, date, target, actual, variance } = elems;
function onInput(el: Input, listener: EventListener) {
listeners.push([el, listener]);
el.addEventListener("input", listener);
}
function format(date: Date) {
return date.toJSON().substring(0, 10);
}
return {
on(dispatch) {
activeBoundary = listeners.length;
variance && (variance.disabled = true);
onInput(stationId, () => dispatch.setStationId(stationId.value));
onInput(date, () => dispatch.setDate(new Date(date.value)));
onInput(target, () => dispatch.setTarget(parseInt(target.value, 10)));
onInput(actual, () => dispatch.setActual(parseInt(actual.value, 10)));
return () => {
for (let [el, listener] of listeners.splice(0, activeBoundary)) {
el.removeEventListener("input", listener);
}
};
},
render: {
none() {
stationId.value = "";
date.value = "";
target.value = "";
actual.value = "";
variance.value = "";
variance.classList.remove(...Quality);
},
ready(data, quality) {
stationId.value = data.stationId.toString();
date.value = format(data.date);
target.value = data.target.toString();
actual.value = data.actual.toString();
variance.value = data.variance?.toString() || "";
variance.classList.remove(...Quality);
variance.classList.add(quality);
}
}
};
}
}
<!doctype html>
<html>
<head>
<title>Fowler GUI App</title>
<style>
* { font-family: monospace; }
#stations { width: 30em }
input.normal {}
input.good { color: green }
input.bad { color: red }
</style>
</head>
<body>
<form>
<table>
<tr>
<td>
<select id="stations" size=10></select>
</td>
<td>
<table>
<tr>
<td>
<label for="station-id">Station ID</label>
</td>
<td>
<input id="station-id" />
</td>
</tr>
<tr>
<td>
<label for="date">Date</label>
</td>
<td>
<input id="date" type="date" />
</td>
</tr>
<tr>
<td>
<label for="target">Target</label>
</td>
<td>
<input id="target" type="number" />
</td>
</tr>
<tr>
<td>
<label for="actual">Actual</label>
</td>
<td>
<input id="actual" type="number" />
</td>
</tr>
<tr>
<td>
<label for="variance">Variance</label>
</td>
<td>
<input id="variance" />
</td>
</tr>
</table>
</td>
</tr>
</table>
</form>
<script src="index.bundle.js"></script>
<script>
Fowler.mvvm({
stations: "stations",
stationId: "station-id",
date: "date",
target: "target",
actual: "actual",
variance: "variance"
}, [{
stationId: "MK76Y",
date: new Date("2006-05-24"),
target: 42,
actual: 48
}, {
stationId: "NV140",
date: new Date("2006-05-25"),
target: 42,
actual: 55
}, {
stationId: "NV141",
date: new Date("2006-05-26"),
target: 42,
actual: 33
}, {
stationId: "NV142",
date: new Date("2006-05-25"),
target: 42,
actual: 52
}, {
stationId: "NV143",
date: new Date("2006-05-25"),
target: 42,
actual: 42
}, {
stationId: "RLD8",
date: new Date("2006-05-26"),
target: 42,
actual: 48
}, {
stationId: "RLD9",
date: new Date("2006-05-26"),
target: 42,
actual: 50
}, {
stationId: "RN341",
date: new Date("2006-05-24"),
target: 42,
actual: 39
}, {
stationId: "RN342",
date: new Date("2006-05-24"),
target: 42,
actual: 49
}]).catch(console.error);
</script>
</body>
</html>
/// <reference path="./visitor.ts" />
interface ViewModel<T, S extends Variants, A extends Variants> {
attach: Observable<S>
dispatch: Observer<A>
start(resolve: Receive<T>, reject: Handler): void
}
interface View<S extends Variants, A extends Variants> {
on: Observable<A>
render: Observer<S>
}
type Editor<Entity> = {
[K in keyof Entity as
Entity[K] extends void ? K : `set${Capitalize<K & string>}`
]-?: (
Entity[K] extends void ? [] :
Entity[K] extends undefined ? [value?: Entity[K] | undefined] :
[value: Entity[K]]
)
}
async function connect<T, S extends Variants, A extends Variants>(
model: ViewModel<T, S, A>,
view: View<S, A>
): Promise<T> {
const detach = model.attach(view.render);
const unbind = view.on(model.dispatch);
try {
return await new Promise(model.start);
}
finally {
detach();
unbind();
}
}
type Variants = Record<string, any[]>
type TotalVisitor<T, V extends Variants> = {
[K in keyof V]: (...pattern: V[K]) => T
}
type Visitor<T, V extends Variants> = Partial<TotalVisitor<T, V>> & {
else?(): T
}
type Sum<V extends Variants> = <T>(visitor: Visitor<T, V>) => T
type Dispose = () => void
type Receive<T> = (value: T) => void
type Handler = (reason: any) => void
type Observer<V extends Variants> = TotalVisitor<void, V>
type Observable<V extends Variants> = (onNext: Observer<V>) => Dispose
type VariantsOf<S extends Sum<any>> = S extends Sum<infer V> ? V : never
type Construct<S extends Sum<any>> = {
[K in keyof VariantsOf<S>]-?: (...data: VariantsOf<S>[K]) => S
}
interface CompositeObserver<V extends Variants> {
add: Observable<V>
clear: Dispose
notify: Observer<V>
catch(handler: Handler): void
}
function companionOf<S extends Sum<V>, V extends Variants = VariantsOf<S>>(
shape: Record<keyof V, any>
): Construct<S> {
for (let key in shape) {
shape[key] = (...data: any) => <T>(visitor: Visitor<T, V>) => {
let branch = visitor[key];
if (branch) {
return branch(...data);
}
else if (visitor.else) {
return visitor.else();
}
else {
throw new Error(`Missing branch: #${key}`);
}
};
}
return shape as Construct<S>;
}
function multicast<V extends Variants>(
shape: Record<keyof V, any>,
observers: (Observer<V> | null)[],
handle: Handler
): Observer<V> {
for (let key in shape) {
shape[key] = (...pattern: V[typeof key]) => {
try {
for (let obs of observers) if (obs) {
obs[key](...pattern);
}
}
catch (e) {
handle(e);
}
};
}
return shape as TotalVisitor<void, V>;
}
function observersOf<V extends Variants>(
shape: Record<keyof V, any>
): CompositeObserver<V> {
let handleError: Handler | null;
const observers: (Observer<V> | null)[] = [];
return {
notify: multicast(shape, observers, (e) => {
if (handleError) {
handleError(e);
}
else throw e;
}),
add(observer) {
const i = observers.length;
observers.push(observer);
return () => { observers[i] = null };
},
clear() {
observers.splice(0);
},
catch(handler) {
handleError = handler;
}
}
}
@monzee
Copy link
Author

monzee commented Mar 30, 2021

tsc --outFile index.bundle.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment