1
Fork 0

typescript(monadic): feat: working event raising

Signed-off-by: prescientmoon <git@moonythm.dev>
This commit is contained in:
Matei Adriel 2020-05-12 14:24:14 +03:00 committed by prescientmoon
parent 5a1e511a6d
commit 17f813ea0c
Signed by: prescientmoon
SSH key fingerprint: SHA256:UUF9JT2s8Xfyv76b8ZuVL7XrmimH4o49p4b+iexbVH4
6 changed files with 255 additions and 82 deletions

View file

@ -1,8 +1,5 @@
import { import {
runUi, runUi,
makeComponent,
Dispatcher,
ComponentSpec,
withAction, withAction,
mkChild, mkChild,
buildSpec, buildSpec,
@ -10,12 +7,16 @@ import {
emptySpec, emptySpec,
withTemplate, withTemplate,
withChild, withChild,
withOutput,
} from '../../src'; } from '../../src';
import { render, html, TemplateResult } from 'lit-html'; import { render, html, TemplateResult } from 'lit-html';
import { pipe } from 'fp-ts/lib/pipeable'; import { pipe } from 'fp-ts/lib/pipeable';
import { constant } from 'fp-ts/es6/function';
import * as O from 'fp-ts/es6/Option';
const baseSpec = pipe(emptySpec, withTemplate<TemplateResult>()); const baseSpec = pipe(emptySpec, withTemplate<TemplateResult>());
// Todo component
type TodoState = { type TodoState = {
name: string; name: string;
done: boolean; done: boolean;
@ -23,16 +24,20 @@ type TodoState = {
type TodoAction = 'complete'; type TodoAction = 'complete';
type TodoOutput = 'delete';
const todo = pipe( const todo = pipe(
baseSpec, baseSpec,
withAction<TodoAction>(), withAction<TodoAction>(),
withState<TodoState>(), withState<TodoState>(),
withOutput<TodoOutput>(),
buildSpec( buildSpec(
({ name, done }, { dispatch }) => { ({ name, done }, { dispatch, raise }) => {
return html` return html`
<div> <div>
Name: ${name} Completed: ${done} Name: ${name} Completed: ${done}
<button @click=${() => dispatch('complete')}>Complete todo</button> <button @click=${() => dispatch('complete')}>Complete todo</button>
<button @click=${() => raise('delete')}>Delete todo</button>
</div> </div>
`; `;
}, },
@ -50,32 +55,65 @@ const todo = pipe(
) )
); );
type TodoListState = { // Todo list input component
todos: TodoState[]; type TodoListInputAction =
inputValue: string;
};
type TodoListAction =
| { | {
label: 'setInput'; label: 'setInput';
value: string; value: string;
} }
| 'create'; | 'create';
type TodoListInputOutput = 'createTodo';
const todoListInput = pipe(
baseSpec,
withAction<TodoListInputAction>(),
withState<string>(),
withOutput<TodoListInputOutput>(),
buildSpec(
(value, { dispatch }) => {
return html`
<input
value=${value}
@input=${(event: InputEvent) => {
const target = event.target as HTMLInputElement;
dispatch({ label: 'setInput', value: target.value });
}}
/>
<button @click=${() => dispatch('create')}>Add todo</button>
`;
},
(action, state, { raise }) => {
if (action === 'create') {
raise('createTodo');
return state;
}
return action.value;
},
{}
)
);
// Todo list component
type TodoListState = {
todos: TodoState[];
inputValue: string;
};
type TodoListAction = 'createTodo' | { label: 'deleteTodo'; index: number };
const todoList = pipe( const todoList = pipe(
emptySpec, baseSpec,
withAction<TodoListAction>(), withAction<TodoListAction>(),
withState<TodoListState>(), withState<TodoListState>(),
withChild<'todo', number, TodoState, null>(), withChild<'todo', number, TodoState, null>(),
withChild<'input', null, string, TodoListInputOutput>(),
buildSpec( buildSpec(
(state, { dispatch, child }) => { (state, { child }) => {
return html` return html`
<div> <div>
<input ${child('input', 'input', null)}
value="${state.inputValue}"
@input=${({ value }) => dispatch({ label: 'setInput', value })}
/>
<button @click=${() => dispatch('create')}>Add todo</button>
<div> <div>
${state.todos.map((_, index) => ${state.todos.map((_, index) =>
child('todo', String(index), index) child('todo', String(index), index)
@ -84,8 +122,8 @@ const todoList = pipe(
</div> </div>
`; `;
}, },
(action: TodoListAction, state: TodoListState) => { (action, state) => {
if (action === 'create') { if (action === 'createTodo') {
return { return {
inputValue: '', inputValue: '',
todos: [ todos: [
@ -96,18 +134,16 @@ const todoList = pipe(
}, },
], ],
}; };
} else if (action.label === 'setInput') { } else if (action.label === 'deleteTodo') {
return { return {
...state, ...state,
inputValue: action.value, todos: state.todos.filter((_, index) => index !== action.index),
}; };
} }
return state;
}, },
{ {
todo: mkChild( todo: mkChild(
(index: number) => ({ index => ({
get: ({ todos }) => todos[index], get: ({ todos }) => todos[index],
set: (state, newTodoState) => ({ set: (state, newTodoState) => ({
...state, ...state,
@ -116,15 +152,40 @@ const todoList = pipe(
), ),
}), }),
}), }),
todo todo,
(index, output) =>
output === 'delete'
? O.some({ label: 'deleteTodo', index } as TodoListAction)
: O.none
),
input: mkChild(
constant({
get: ({ inputValue }) => inputValue,
set: (state, newInputValue) => ({
...state,
inputValue: newInputValue,
}),
}),
todoListInput,
constant(O.some('createTodo' as TodoListAction))
), ),
} }
) )
); );
const main = async () => {
runUi({ const output = runUi({
parent: document.getElementById('app'), parent: document.getElementById('app'),
render, render,
initialState: { todos: [], inputValue: '' }, initialState: { todos: [], inputValue: '' },
component: todoList, component: todoList,
});
for await (const event of output) {
console.log('Received an event from the todo list component!');
}
};
main().catch(err => {
console.log('An error ocurred while running the root component');
throw err;
}); });

View file

@ -6,9 +6,12 @@ import {
Child, Child,
} from './Component'; } from './Component';
import { GenericLens } from './helpers'; import { GenericLens } from './helpers';
import { constant, identity } from 'fp-ts/es6/function'; import { constant, identity, flow } from 'fp-ts/es6/function';
import * as O from 'fp-ts/es6/Option'; import * as O from 'fp-ts/es6/Option';
// This is here since all the builders are pretty much this
const alwaysIdentity = constant(identity);
type ComponentBuilder = < type ComponentBuilder = <
T, T,
S, S,
@ -47,6 +50,9 @@ export const mkChild: ChildBuilder = (
handleOutput = constant(O.none) handleOutput = constant(O.none)
) => ({ lens, component, handleOutput }); ) => ({ lens, component, handleOutput });
/**
* Structure which keeps track of the different types needed to build a component.
*/
export type ComponentSpec< export type ComponentSpec<
T, T,
S, S,
@ -56,16 +62,29 @@ export type ComponentSpec<
C extends ChildrenConfigs<N> C extends ChildrenConfigs<N>
> = [T, S, A, O, N, C]; > = [T, S, A, O, N, C];
export const withChild = constant(identity) as < /**
* Add a child to a spec (type only, the implementation is given when building the spec)
*/
export const withChild = alwaysIdentity as <
N1 extends keyof any, N1 extends keyof any,
I, I,
S, S,
O O
>() => <T, PS, A, PO, N0 extends string, C extends ChildrenConfigs<N0>>( >() => <
T,
PS,
A,
PO,
N0 extends Exclude<string, N1>,
C extends ChildrenConfigs<N0>
>(
spec: ComponentSpec<T, PS, A, PO, N0, C> spec: ComponentSpec<T, PS, A, PO, N0, C>
) => ComponentSpec<T, PS, A, PO, N1 & N0, C & Record<N1, Child<I, S, O>>>; ) => ComponentSpec<T, PS, A, PO, N1 & N0, C & Record<N1, Child<I, S, O>>>;
export const withAction = constant(identity) as <A1>() => < /**
* Specify the action type of a spec
*/
export const withAction = alwaysIdentity as <A1>() => <
T, T,
S, S,
A0, A0,
@ -76,7 +95,24 @@ export const withAction = constant(identity) as <A1>() => <
spec: ComponentSpec<T, S, A0, O, N, C> spec: ComponentSpec<T, S, A0, O, N, C>
) => ComponentSpec<T, S, A1, O, N, C>; ) => ComponentSpec<T, S, A1, O, N, C>;
export const withState = constant(identity) as <S1>() => < /**
* Specify the output type of a spec
*/
export const withOutput = alwaysIdentity as <O1>() => <
T,
S,
A,
O0,
N extends string,
C extends ChildrenConfigs<N>
>(
spec: ComponentSpec<T, S, A, O0, N, C>
) => ComponentSpec<T, S, A, O1, N, C>;
/**
* Specify the type of state in a spec
*/
export const withState = alwaysIdentity as <S1>() => <
T, T,
S0, S0,
A, A,
@ -87,7 +123,10 @@ export const withState = constant(identity) as <S1>() => <
spec: ComponentSpec<T, S0, A, O, N, C> spec: ComponentSpec<T, S0, A, O, N, C>
) => ComponentSpec<T, S1, A, O, N, C>; ) => ComponentSpec<T, S1, A, O, N, C>;
export const withTemplate = constant(identity) as <T1>() => < /**
* Specify what a component renders to
*/
export const withTemplate = alwaysIdentity as <T1>() => <
T0, T0,
S, S,
A, A,
@ -98,7 +137,10 @@ export const withTemplate = constant(identity) as <T1>() => <
spec: ComponentSpec<T0, S, A, O, N, C> spec: ComponentSpec<T0, S, A, O, N, C>
) => ComponentSpec<T1, S, A, O, N, C>; ) => ComponentSpec<T1, S, A, O, N, C>;
export const buildSpec = (constant(makeComponent) as any) as < /**
* Takes implementations of the components watching the spec and creates the component
*/
export const buildSpec = flow(makeComponent, constant) as <
T, T,
S, S,
A, A,

View file

@ -1,25 +1,25 @@
import { mapRecord } from './Record'; import { mapRecord } from './Record';
import { values, GenericLens } from './helpers'; import { values, GenericLens, areEqual } from './helpers';
import O from 'fp-ts/es6/Option'; import * as O from 'fp-ts/es6/Option';
import { constant } from 'fp-ts/es6/function'; import { constant, flow } from 'fp-ts/es6/function';
/** /**
* Helper type for dispatcher functions * Helper type for dispatcher functions
*/ */
export type Dispatcher<A> = (action: A) => void; export type Dispatcher<A> = (action: A) => void;
export type Communicate< export type Communicate<A, O> = {
T,
A,
O,
N extends string,
C extends ChildrenConfigs<N>
> = {
dispatch: Dispatcher<A>; dispatch: Dispatcher<A>;
child: <K extends N>(key: K, name: string, input: C[K]['input']) => T;
raise: (event: O) => void; raise: (event: O) => void;
}; };
export type CanAddChildren<
T,
N extends string,
C extends ChildrenConfigs<N>
> = {
child: <K extends N>(key: K, name: string, input: C[K]['input']) => T;
};
export type RenderFunction< export type RenderFunction<
T, T,
S, S,
@ -27,7 +27,7 @@ export type RenderFunction<
O, O,
N extends string, N extends string,
C extends ChildrenConfigs<N> C extends ChildrenConfigs<N>
> = (state: S, communicate: Communicate<T, A, O, N, C>) => T; > = (state: S, communicate: Communicate<A, O> & CanAddChildren<T, N, C>) => T;
export type ComponentConfig< export type ComponentConfig<
T, T,
@ -38,7 +38,7 @@ export type ComponentConfig<
C extends ChildrenConfigs<N> C extends ChildrenConfigs<N>
> = { > = {
render: RenderFunction<T, S, A, O, N, C>; render: RenderFunction<T, S, A, O, N, C>;
handleAction: (action: A, state: S) => S; handleAction: (action: A, state: S, communication: Communicate<A, O>) => S;
children: ChildrenTemplates<T, S, A, N, C>; children: ChildrenTemplates<T, S, A, N, C>;
}; };
@ -102,28 +102,50 @@ export class Component<
public constructor( public constructor(
protected state: S, protected state: S,
private config: ComponentConfig<T, S, A, O, N, C>, private config: ComponentConfig<T, S, A, O, N, C>,
private raise: Communicate<A, O>['raise'],
private pushDownwards: (state: S) => void private pushDownwards: (state: S) => void
) { ) {
this.childrenMap = mapRecord(this.config.children, constant({})); this.childrenMap = mapRecord(this.config.children, constant({}));
} }
protected pushStateUpwards(state: S) { /**
* Do something only if the state changed.
*
* @param state The new state.
* @param then The thing you want to do with the state
*/
private setStateAndThen = (then: (state: S) => unknown) => (state: S) => {
if (areEqual(state, this.state)) {
return;
}
this.state = state; this.state = state;
for (const { component, lens } of this.children()) { then(state);
component.pushStateUpwards(lens.set(state, component.state)); };
}
}
public getTemplate(): T { /**
return this.config.render(this.state, { * Propagate a new state upstream the tree
dispatch: value => this.dispatch(value), *
raise: _ => undefined, * @param state The new state
*/
protected pushStateUpwards = this.setStateAndThen(state => {
for (const { component, lens } of this.children()) {
component.pushStateUpwards(lens.get(state));
}
});
/**
* Function to get a child or create it if it doesn't exist
*/
private getChild: CanAddChildren<T, N, C> = {
child: (key, name, input) => { child: (key, name, input) => {
const hasName = Reflect.has(this.childrenMap[key], name); const hasName = Reflect.has(this.childrenMap[key], name);
if (!hasName) { if (!hasName) {
const lens = this.config.children[key].lens(input); const config = this.config.children[key];
const lens = config.lens(input);
const raise = flow(config.handleOutput, O.map(this.dispatch));
const child = this.childrenMap[key] as Record<string, any>; const child = this.childrenMap[key] as Record<string, any>;
child[name] = { child[name] = {
@ -131,22 +153,47 @@ export class Component<
component: new Component( component: new Component(
lens.get(this.state), lens.get(this.state),
this.config.children[key].component, this.config.children[key].component,
state => this.pushDownwards(lens.set(this.state, state)) event => raise(input, event),
childState => {
const newState = lens.set(this.state, childState);
this.setStateAndThen(this.pushDownwards)(newState);
}
), ),
}; };
} }
return this.childrenMap[key][name].component.getTemplate(); return this.childrenMap[key][name].component.getTemplate();
}, },
};
private getCommunication(): Communicate<A, O> {
return {
dispatch: value => this.dispatch(value),
raise: this.raise,
};
}
public getTemplate(): T {
return this.config.render(this.state, {
...this.getCommunication(),
...this.getChild,
}); });
} }
public dispatch(action: A) { /**
const newState = this.config.handleAction(action, this.state); * Dispatch an arbitrary action on the component.
*/
public dispatch = (action: A) => {
const newState = this.config.handleAction(
action,
this.state,
this.getCommunication()
);
this.pushStateUpwards(newState); this.pushStateUpwards(newState);
this.pushDownwards(newState); this.pushDownwards(newState);
} };
/** /**
* Get a list of all the children of the component * Get a list of all the children of the component

View file

@ -1,4 +1,5 @@
import { ComponentConfig, Component } from './Component'; import { ComponentConfig, Component } from './Component';
import { IterableEmitter } from './iterableEmitter';
export type EnvConfig<T, S, A, O> = { export type EnvConfig<T, S, A, O> = {
render: (template: T, parent: HTMLElement) => void; render: (template: T, parent: HTMLElement) => void;
@ -7,14 +8,29 @@ export type EnvConfig<T, S, A, O> = {
initialState: S; initialState: S;
}; };
export const runUi = <T, S, A, O>(config: EnvConfig<T, S, A, O>) => { // Not an arrow function cause it's a generator
/**
* Run a component and render it to the dom
*/
export async function* runUi<T, S, A, O>(config: EnvConfig<T, S, A, O>) {
const reRender = () => config.render(component.getTemplate(), config.parent); const reRender = () => config.render(component.getTemplate(), config.parent);
const component = new Component(config.initialState, config.component, _ => { const outputEmitter = new IterableEmitter<null | O>(null);
const component = new Component(
config.initialState,
config.component,
outputEmitter.next,
_ => {
reRender(); reRender();
}); }
);
reRender(); reRender();
return component; for await (const event of outputEmitter) {
}; if (event) {
yield event;
}
}
}

View file

@ -12,3 +12,9 @@ export type GenericLens<T, U> = {
get: (v: T) => U; get: (v: T) => U;
set: (v: T, n: U) => T; set: (v: T, n: U) => T;
}; };
/**
* Some basic equality to not re-render with the same state
*/
export const areEqual = <T>(a: T, b: T) =>
a === b || JSON.stringify(a) === JSON.stringify(b);

View file

@ -11,4 +11,5 @@ export {
buildSpec, buildSpec,
withTemplate, withTemplate,
withChild, withChild,
withOutput,
} from './Builder'; } from './Builder';