1
Fork 0

typescript(monadic): feat: rewrote everything I think

Signed-off-by: prescientmoon <git@moonythm.dev>
This commit is contained in:
Matei Adriel 2020-05-12 01:15:49 +03:00 committed by prescientmoon
parent db5b68592c
commit 9e6c150e5f
Signed by: prescientmoon
SSH key fingerprint: SHA256:UUF9JT2s8Xfyv76b8ZuVL7XrmimH4o49p4b+iexbVH4
11 changed files with 287 additions and 66 deletions

View file

@ -1,8 +1,10 @@
{ {
"cSpell.words": [ "cSpell.words": [
"Typesafe",
"adriel", "adriel",
"esnext", "esnext",
"matei", "matei",
"todos",
"tslib" "tslib"
] ]
} }

View file

@ -1,38 +1,102 @@
import { runUi } from '../../src'; import { runUi, makeComponent, Dispatcher, mkChild } from '../../src';
import { render, html, TemplateResult } from 'lit-html'; import { render, html, TemplateResult } from 'lit-html';
type Action = 'increase' | 'decrease'; type TodoState = {
name: string;
type State = { done: boolean;
count: number;
}; };
runUi<TemplateResult, State, Action>({ type TodoAction = 'complete';
const todo = makeComponent(
({ name, done }: TodoState, dispatch: Dispatcher<TodoAction>) => {
return html`
<div>
Name: ${name} Completed: ${done}
<button @click=${() => dispatch('complete')}>Complete todo</button>
</div>
`;
},
(action, state) => {
if (action === 'complete') {
return {
...state,
done: true,
};
}
return state;
},
{}
);
type TodoListState = {
todos: TodoState[];
inputValue: string;
};
type TodoListAction =
| {
label: 'setInput';
value: string;
}
| 'create';
const todoList = makeComponent(
(state: TodoListState, dispatch: Dispatcher<TodoListAction>, child) => {
return html`
<div>
<input
value="${state.inputValue}"
@input=${({ value }) => dispatch({ label: 'setInput', value })}
/>
<button @click=${() => dispatch('create')}>Add todo</button>
<div>
${state.todos.map((_, index) => child('todo', String(index), index))}
</div>
</div>
`;
},
(action: TodoListAction, state: TodoListState) => {
if (action === 'create') {
return {
inputValue: '',
todos: [
...state.todos,
{
name: state.inputValue,
done: false,
},
],
};
} else if (action.label === 'setInput') {
return {
...state,
inputValue: action.value,
};
}
return state;
},
{
todo: mkChild(
(index: number) => ({
get: ({ todos }) => todos[index],
set: (state, newTodoState) => ({
...state,
todos: state.todos.map((todoState, currentIndex) =>
index === currentIndex ? newTodoState : todoState
),
}),
}),
todo
),
}
);
runUi({
parent: document.getElementById('app'), parent: document.getElementById('app'),
render, render,
initialState: { count: 0 }, initialState: { todos: [], inputValue: '' },
component: { component: todoList,
render: (state, dispatch) => {
return html`
<div>
<div>${state.count}</div>
<button @click=${dispatch('increase')}>Increase</button>
<button @click=${dispatch('decrease')}>Decrease</button>
</div>
`;
},
handleActions: (action: Action, state) => {
if (action === 'increase') {
return {
count: state.count + 1,
};
} else if (action === 'decrease') {
return {
count: state.count - 1,
};
}
return state;
},
},
}); });

View file

@ -38,5 +38,8 @@
"tsdx": "^0.13.2", "tsdx": "^0.13.2",
"tslib": "^1.11.1", "tslib": "^1.11.1",
"typescript": "^3.8.3" "typescript": "^3.8.3"
},
"dependencies": {
"helpers": "^0.0.6"
} }
} }

View file

@ -0,0 +1,143 @@
import { mapRecord } from './Record';
import { values } from './helpers';
/**
* Helper type for dispatcher functions
*/
export type Dispatcher<A> = (action: A) => void;
export type ComponentConfig<
T,
S,
A,
N extends string,
C extends ChildrenConfigs<N>
> = {
render: (
state: S,
dispatch: Dispatcher<A>,
child: <K extends N>(key: K, name: string, input: C[K]['input']) => T
) => T;
handleAction: (action: A, state: S) => S;
children: ChildrenTemplates<T, S, N, C>;
};
type GenericLens<T, U> = {
get: (v: T) => U;
set: (v: T, n: U) => T;
};
type Child<I = unknown, S = unknown> = {
input: I;
state: S;
};
type ChildrenConfigs<N extends string> = Record<N, Child>;
type ChildrenTemplates<T, S, N extends string, C extends ChildrenConfigs<N>> = {
[K in N]: {
lens: (input: C[K]['input']) => GenericLens<S, C[K]['state']>;
component: ComponentConfig<T, C[K]['state'], unknown, string, {}>;
};
};
type Children<T, S, N extends string, C extends ChildrenConfigs<N>> = {
[K in N]: Record<
string,
{
component: Component<T, C[K]['state'], unknown, string, {}>;
lens: GenericLens<S, C[K]['state']>;
}
>;
};
export class Component<
T,
S,
A,
N extends string,
C extends ChildrenConfigs<N>
> {
private childrenMap: Children<T, S, N, C>;
public constructor(
protected state: S,
private config: ComponentConfig<T, S, A, N, C>,
private pushDownwards: (state: S) => void
) {
this.childrenMap = mapRecord(this.config.children, () => ({}));
}
protected pushStateUpwards(state: S) {
this.state = state;
for (const { component, lens } of this.children()) {
component.pushStateUpwards(lens.set(state, component.state));
}
}
public getTemplate(): T {
return this.config.render(
this.state,
value => this.dispatch(value),
(key, name, input) => {
const hasName = Reflect.has(this.childrenMap[key], name);
if (!hasName) {
const lens = this.config.children[key].lens(input);
const child = this.childrenMap[key] as Record<string, any>;
child[name] = {
lens,
component: new Component(
lens.get(this.state),
this.config.children[key].component,
state => this.pushDownwards(lens.set(this.state, state))
),
};
}
return this.childrenMap[key][name].component.getTemplate();
}
);
}
public dispatch(action: A) {
const newState = this.config.handleAction(action, this.state);
this.pushStateUpwards(newState);
this.pushDownwards(newState);
}
/**
* Get a list of all the children of the component
*/
private children() {
return values(this.childrenMap).flatMap(record =>
values(record)
) as Children<T, S, N, C>[N][string][];
}
}
/**
* Create a component
*
* @param render The render function of the component
* @param handleAction Function used to handle actions related to the component.
* @param children Child slots for the component
*/
export const makeComponent = <
T,
S,
A,
N extends string,
C extends ChildrenConfigs<N>
>(
render: ComponentConfig<T, S, A, N, C>['render'],
handleAction: ComponentConfig<T, S, A, N, C>['handleAction'],
children: ComponentConfig<T, S, A, N, C>['children']
) => ({ render, handleAction, children });
export const mkChild = <T, PS, I, S>(
lens: (input: I) => GenericLens<PS, S>,
component: ComponentConfig<T, S, unknown, string, {}>
) => ({ lens, component });

View file

@ -0,0 +1,11 @@
export const mapRecord = <K extends string | number, V, W>(
record: Record<K, V>,
mapper: (v: V, k: K) => W
): Record<K, W> => {
return Object.fromEntries(
Object.entries(record).map(([key, value]) => [
key,
mapper(value as V, key as K),
])
) as any;
};

View file

@ -1,25 +0,0 @@
import { IterableEmitter } from './iterableEmitter';
export type Component<T, S, A> = {
render: (state: S, dispatch: (a: A) => () => void) => T;
handleActions: (action: A, state: S) => S;
};
export async function* runComponent<T, S, A>(
component: Component<T, S, A>,
initialState: S
): AsyncGenerator<T> {
const emitter = new IterableEmitter(initialState);
const dispatch = (state: S) => (action: A) => {
const newState = component.handleActions(action, state);
return () => {
emitter.next(newState);
};
};
for await (const state of emitter) {
yield component.render(state, dispatch(state));
}
}

View file

@ -1,18 +1,20 @@
import { Component, runComponent } from './component'; import { ComponentConfig, Component } from './Component';
export type EnvConfig<T, S, A> = { export type EnvConfig<T, S, A> = {
render: (template: T, parent: HTMLElement) => void; render: (template: T, parent: HTMLElement) => void;
parent: HTMLElement; parent: HTMLElement;
component: Component<T, S, A>; component: ComponentConfig<T, S, A, string, {}>;
initialState: S; initialState: S;
}; };
export const runUi = async <T, S, A>( export const runUi = <T, S, A>(config: EnvConfig<T, S, A>) => {
config: EnvConfig<T, S, A> const reRender = () => config.render(component.getTemplate(), config.parent);
): Promise<void> => {
const component = runComponent(config.component, config.initialState);
for await (const template of component) { const component = new Component(config.initialState, config.component, _ => {
config.render(template, config.parent); reRender();
} });
reRender();
return component;
}; };

View file

@ -0,0 +1,6 @@
/**
* Typesafe version of Object.values
*/
export const values = Object.values as <T extends keyof object, U>(
object: Record<T, U>
) => U[];

View file

@ -1 +1,7 @@
export * from './environment'; export { runUi, EnvConfig } from './environment';
export {
ComponentConfig,
Dispatcher,
makeComponent,
mkChild,
} from './Component';

View file

@ -10,6 +10,10 @@ export class IterableEmitter<T> {
public constructor(private state: T) {} public constructor(private state: T) {}
public alter(mapper: (f: T) => T) {
this.next(mapper(this.state));
}
async *[Symbol.asyncIterator](): AsyncGenerator<T> { async *[Symbol.asyncIterator](): AsyncGenerator<T> {
const createPromise = () => const createPromise = () =>
new Promise<T>(resolve => { new Promise<T>(resolve => {

View file

@ -3709,6 +3709,11 @@ hash.js@^1.0.0, hash.js@^1.0.3:
inherits "^2.0.3" inherits "^2.0.3"
minimalistic-assert "^1.0.1" minimalistic-assert "^1.0.1"
helpers@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/helpers/-/helpers-0.0.6.tgz#b7cc9a46fffa877f8d58e447c8b8e74b830e08a9"
integrity sha1-t8yaRv/6h3+NWORHyLjnS4MOCKk=
hex-color-regex@^1.1.0: hex-color-regex@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"