diff --git a/typescript/monadic/demos/basic/main.ts b/typescript/monadic/demos/basic/main.ts index e66563b..bb54e0f 100644 --- a/typescript/monadic/demos/basic/main.ts +++ b/typescript/monadic/demos/basic/main.ts @@ -1,8 +1,5 @@ import { runUi, - makeComponent, - Dispatcher, - ComponentSpec, withAction, mkChild, buildSpec, @@ -10,12 +7,16 @@ import { emptySpec, withTemplate, withChild, + withOutput, } from '../../src'; import { render, html, TemplateResult } from 'lit-html'; 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()); +// Todo component type TodoState = { name: string; done: boolean; @@ -23,16 +24,20 @@ type TodoState = { type TodoAction = 'complete'; +type TodoOutput = 'delete'; + const todo = pipe( baseSpec, withAction(), withState(), + withOutput(), buildSpec( - ({ name, done }, { dispatch }) => { + ({ name, done }, { dispatch, raise }) => { return html`
Name: ${name} Completed: ${done} +
`; }, @@ -50,32 +55,65 @@ const todo = pipe( ) ); -type TodoListState = { - todos: TodoState[]; - inputValue: string; -}; - -type TodoListAction = +// Todo list input component +type TodoListInputAction = | { label: 'setInput'; value: string; } | 'create'; +type TodoListInputOutput = 'createTodo'; + +const todoListInput = pipe( + baseSpec, + withAction(), + withState(), + withOutput(), + buildSpec( + (value, { dispatch }) => { + return html` + { + const target = event.target as HTMLInputElement; + dispatch({ label: 'setInput', value: target.value }); + }} + /> + + `; + }, + (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( - emptySpec, + baseSpec, withAction(), withState(), withChild<'todo', number, TodoState, null>(), + withChild<'input', null, string, TodoListInputOutput>(), buildSpec( - (state, { dispatch, child }) => { + (state, { child }) => { return html`
- dispatch({ label: 'setInput', value })} - /> - + ${child('input', 'input', null)}
${state.todos.map((_, index) => child('todo', String(index), index) @@ -84,8 +122,8 @@ const todoList = pipe(
`; }, - (action: TodoListAction, state: TodoListState) => { - if (action === 'create') { + (action, state) => { + if (action === 'createTodo') { return { inputValue: '', todos: [ @@ -96,18 +134,16 @@ const todoList = pipe( }, ], }; - } else if (action.label === 'setInput') { + } else if (action.label === 'deleteTodo') { return { ...state, - inputValue: action.value, + todos: state.todos.filter((_, index) => index !== action.index), }; } - - return state; }, { todo: mkChild( - (index: number) => ({ + index => ({ get: ({ todos }) => todos[index], set: (state, newTodoState) => ({ ...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 () => { + const output = runUi({ + parent: document.getElementById('app'), + render, + initialState: { todos: [], inputValue: '' }, + component: todoList, + }); -runUi({ - parent: document.getElementById('app'), - render, - initialState: { todos: [], inputValue: '' }, - 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; }); diff --git a/typescript/monadic/src/Builder.ts b/typescript/monadic/src/Builder.ts index 0109bef..32aa5d1 100644 --- a/typescript/monadic/src/Builder.ts +++ b/typescript/monadic/src/Builder.ts @@ -6,9 +6,12 @@ import { Child, } from './Component'; 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'; +// This is here since all the builders are pretty much this +const alwaysIdentity = constant(identity); + type ComponentBuilder = < T, S, @@ -47,6 +50,9 @@ export const mkChild: ChildBuilder = ( handleOutput = constant(O.none) ) => ({ lens, component, handleOutput }); +/** + * Structure which keeps track of the different types needed to build a component. + */ export type ComponentSpec< T, S, @@ -56,16 +62,29 @@ export type ComponentSpec< C extends ChildrenConfigs > = [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, I, S, O ->() => >( +>() => < + T, + PS, + A, + PO, + N0 extends Exclude, + C extends ChildrenConfigs +>( spec: ComponentSpec ) => ComponentSpec>>; -export const withAction = constant(identity) as () => < +/** + * Specify the action type of a spec + */ +export const withAction = alwaysIdentity as () => < T, S, A0, @@ -76,7 +95,24 @@ export const withAction = constant(identity) as () => < spec: ComponentSpec ) => ComponentSpec; -export const withState = constant(identity) as () => < +/** + * Specify the output type of a spec + */ +export const withOutput = alwaysIdentity as () => < + T, + S, + A, + O0, + N extends string, + C extends ChildrenConfigs +>( + spec: ComponentSpec +) => ComponentSpec; + +/** + * Specify the type of state in a spec + */ +export const withState = alwaysIdentity as () => < T, S0, A, @@ -87,7 +123,10 @@ export const withState = constant(identity) as () => < spec: ComponentSpec ) => ComponentSpec; -export const withTemplate = constant(identity) as () => < +/** + * Specify what a component renders to + */ +export const withTemplate = alwaysIdentity as () => < T0, S, A, @@ -98,7 +137,10 @@ export const withTemplate = constant(identity) as () => < spec: ComponentSpec ) => ComponentSpec; -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, S, A, diff --git a/typescript/monadic/src/Component.ts b/typescript/monadic/src/Component.ts index 6283468..baaac3e 100644 --- a/typescript/monadic/src/Component.ts +++ b/typescript/monadic/src/Component.ts @@ -1,25 +1,25 @@ import { mapRecord } from './Record'; -import { values, GenericLens } from './helpers'; -import O from 'fp-ts/es6/Option'; -import { constant } from 'fp-ts/es6/function'; +import { values, GenericLens, areEqual } from './helpers'; +import * as O from 'fp-ts/es6/Option'; +import { constant, flow } from 'fp-ts/es6/function'; /** * Helper type for dispatcher functions */ export type Dispatcher = (action: A) => void; -export type Communicate< - T, - A, - O, - N extends string, - C extends ChildrenConfigs -> = { +export type Communicate = { dispatch: Dispatcher; - child: (key: K, name: string, input: C[K]['input']) => T; raise: (event: O) => void; }; +export type CanAddChildren< + T, + N extends string, + C extends ChildrenConfigs +> = { + child: (key: K, name: string, input: C[K]['input']) => T; +}; export type RenderFunction< T, S, @@ -27,7 +27,7 @@ export type RenderFunction< O, N extends string, C extends ChildrenConfigs -> = (state: S, communicate: Communicate) => T; +> = (state: S, communicate: Communicate & CanAddChildren) => T; export type ComponentConfig< T, @@ -38,7 +38,7 @@ export type ComponentConfig< C extends ChildrenConfigs > = { render: RenderFunction; - handleAction: (action: A, state: S) => S; + handleAction: (action: A, state: S, communication: Communicate) => S; children: ChildrenTemplates; }; @@ -102,51 +102,98 @@ export class Component< public constructor( protected state: S, private config: ComponentConfig, + private raise: Communicate['raise'], private pushDownwards: (state: S) => void ) { 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; + then(state); + }; + + /** + * Propagate a new state upstream the tree + * + * @param state The new state + */ + protected pushStateUpwards = this.setStateAndThen(state => { for (const { component, lens } of this.children()) { - component.pushStateUpwards(lens.set(state, component.state)); + component.pushStateUpwards(lens.get(state)); } + }); + + /** + * Function to get a child or create it if it doesn't exist + */ + private getChild: CanAddChildren = { + child: (key, name, input) => { + const hasName = Reflect.has(this.childrenMap[key], name); + + if (!hasName) { + 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; + + child[name] = { + lens, + component: new Component( + lens.get(this.state), + this.config.children[key].component, + event => raise(input, event), + childState => { + const newState = lens.set(this.state, childState); + + this.setStateAndThen(this.pushDownwards)(newState); + } + ), + }; + } + + return this.childrenMap[key][name].component.getTemplate(); + }, + }; + + private getCommunication(): Communicate { + return { + dispatch: value => this.dispatch(value), + raise: this.raise, + }; } public getTemplate(): T { return this.config.render(this.state, { - dispatch: value => this.dispatch(value), - raise: _ => undefined, - child: (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; - - 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(); - }, + ...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.pushDownwards(newState); - } + }; /** * Get a list of all the children of the component diff --git a/typescript/monadic/src/environment.ts b/typescript/monadic/src/environment.ts index b4c01a6..061aecd 100644 --- a/typescript/monadic/src/environment.ts +++ b/typescript/monadic/src/environment.ts @@ -1,4 +1,5 @@ import { ComponentConfig, Component } from './Component'; +import { IterableEmitter } from './iterableEmitter'; export type EnvConfig = { render: (template: T, parent: HTMLElement) => void; @@ -7,14 +8,29 @@ export type EnvConfig = { initialState: S; }; -export const runUi = (config: EnvConfig) => { +// Not an arrow function cause it's a generator +/** + * Run a component and render it to the dom + */ +export async function* runUi(config: EnvConfig) { const reRender = () => config.render(component.getTemplate(), config.parent); - const component = new Component(config.initialState, config.component, _ => { - reRender(); - }); + const outputEmitter = new IterableEmitter(null); + + const component = new Component( + config.initialState, + config.component, + outputEmitter.next, + _ => { + reRender(); + } + ); reRender(); - return component; -}; + for await (const event of outputEmitter) { + if (event) { + yield event; + } + } +} diff --git a/typescript/monadic/src/helpers.ts b/typescript/monadic/src/helpers.ts index 080ee80..176c5dd 100644 --- a/typescript/monadic/src/helpers.ts +++ b/typescript/monadic/src/helpers.ts @@ -12,3 +12,9 @@ export type GenericLens = { get: (v: T) => U; set: (v: T, n: U) => T; }; + +/** + * Some basic equality to not re-render with the same state + */ +export const areEqual = (a: T, b: T) => + a === b || JSON.stringify(a) === JSON.stringify(b); diff --git a/typescript/monadic/src/index.ts b/typescript/monadic/src/index.ts index 2fd1891..5c027c1 100644 --- a/typescript/monadic/src/index.ts +++ b/typescript/monadic/src/index.ts @@ -11,4 +11,5 @@ export { buildSpec, withTemplate, withChild, + withOutput, } from './Builder';