Created
March 30, 2021 11:03
-
-
Save monzee/de9f61520dc1562da8e598151720dea5 to your computer and use it in GitHub Desktop.
Fowler GUI Architectures running example - https://www.martinfowler.com/eaaDev/uiArchs.html
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 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); | |
} | |
} | |
}; | |
} | |
} |
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
<!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> |
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 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(); | |
} | |
} |
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
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; | |
} | |
} | |
} |
Author
monzee
commented
Mar 30, 2021
•
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment