diff --git a/typescript/monadic/.vscode/settings.json b/typescript/monadic/.vscode/settings.json index b8f81fe..12ddcd5 100644 --- a/typescript/monadic/.vscode/settings.json +++ b/typescript/monadic/.vscode/settings.json @@ -1,8 +1,10 @@ { "cSpell.words": [ + "Typesafe", "adriel", "esnext", "matei", + "todos", "tslib" ] } \ No newline at end of file diff --git a/typescript/monadic/demos/basic/main.ts b/typescript/monadic/demos/basic/main.ts index 4837852..e6e193c 100644 --- a/typescript/monadic/demos/basic/main.ts +++ b/typescript/monadic/demos/basic/main.ts @@ -1,38 +1,102 @@ -import { runUi } from '../../src'; +import { runUi, makeComponent, Dispatcher, mkChild } from '../../src'; import { render, html, TemplateResult } from 'lit-html'; -type Action = 'increase' | 'decrease'; - -type State = { - count: number; +type TodoState = { + name: string; + done: boolean; }; -runUi({ +type TodoAction = 'complete'; + +const todo = makeComponent( + ({ name, done }: TodoState, dispatch: Dispatcher) => { + return html` +
+ Name: ${name} Completed: ${done} + +
+ `; + }, + (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, child) => { + return html` +
+ dispatch({ label: 'setInput', value })} + /> + +
+ ${state.todos.map((_, index) => child('todo', String(index), index))} +
+
+ `; + }, + (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'), render, - initialState: { count: 0 }, - component: { - render: (state, dispatch) => { - return html` -
-
${state.count}
- - -
- `; - }, - handleActions: (action: Action, state) => { - if (action === 'increase') { - return { - count: state.count + 1, - }; - } else if (action === 'decrease') { - return { - count: state.count - 1, - }; - } - - return state; - }, - }, + initialState: { todos: [], inputValue: '' }, + component: todoList, }); diff --git a/typescript/monadic/package.json b/typescript/monadic/package.json index 9eb96d0..08e5446 100644 --- a/typescript/monadic/package.json +++ b/typescript/monadic/package.json @@ -38,5 +38,8 @@ "tsdx": "^0.13.2", "tslib": "^1.11.1", "typescript": "^3.8.3" + }, + "dependencies": { + "helpers": "^0.0.6" } } diff --git a/typescript/monadic/src/Component.ts b/typescript/monadic/src/Component.ts new file mode 100644 index 0000000..14e521f --- /dev/null +++ b/typescript/monadic/src/Component.ts @@ -0,0 +1,143 @@ +import { mapRecord } from './Record'; +import { values } from './helpers'; + +/** + * Helper type for dispatcher functions + */ +export type Dispatcher = (action: A) => void; + +export type ComponentConfig< + T, + S, + A, + N extends string, + C extends ChildrenConfigs +> = { + render: ( + state: S, + dispatch: Dispatcher, + child: (key: K, name: string, input: C[K]['input']) => T + ) => T; + handleAction: (action: A, state: S) => S; + children: ChildrenTemplates; +}; + +type GenericLens = { + get: (v: T) => U; + set: (v: T, n: U) => T; +}; + +type Child = { + input: I; + state: S; +}; + +type ChildrenConfigs = Record; + +type ChildrenTemplates> = { + [K in N]: { + lens: (input: C[K]['input']) => GenericLens; + component: ComponentConfig; + }; +}; + +type Children> = { + [K in N]: Record< + string, + { + component: Component; + lens: GenericLens; + } + >; +}; + +export class Component< + T, + S, + A, + N extends string, + C extends ChildrenConfigs +> { + private childrenMap: Children; + public constructor( + protected state: S, + private config: ComponentConfig, + 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; + + 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[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 +>( + render: ComponentConfig['render'], + handleAction: ComponentConfig['handleAction'], + children: ComponentConfig['children'] +) => ({ render, handleAction, children }); + +export const mkChild = ( + lens: (input: I) => GenericLens, + component: ComponentConfig +) => ({ lens, component }); diff --git a/typescript/monadic/src/Record.ts b/typescript/monadic/src/Record.ts new file mode 100644 index 0000000..a00b6e2 --- /dev/null +++ b/typescript/monadic/src/Record.ts @@ -0,0 +1,11 @@ +export const mapRecord = ( + record: Record, + mapper: (v: V, k: K) => W +): Record => { + return Object.fromEntries( + Object.entries(record).map(([key, value]) => [ + key, + mapper(value as V, key as K), + ]) + ) as any; +}; diff --git a/typescript/monadic/src/component.ts b/typescript/monadic/src/component.ts deleted file mode 100644 index d0fa2f3..0000000 --- a/typescript/monadic/src/component.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { IterableEmitter } from './iterableEmitter'; - -export type Component = { - render: (state: S, dispatch: (a: A) => () => void) => T; - handleActions: (action: A, state: S) => S; -}; - -export async function* runComponent( - component: Component, - initialState: S -): AsyncGenerator { - 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)); - } -} diff --git a/typescript/monadic/src/environment.ts b/typescript/monadic/src/environment.ts index 0701917..7c011d6 100644 --- a/typescript/monadic/src/environment.ts +++ b/typescript/monadic/src/environment.ts @@ -1,18 +1,20 @@ -import { Component, runComponent } from './component'; +import { ComponentConfig, Component } from './Component'; export type EnvConfig = { render: (template: T, parent: HTMLElement) => void; parent: HTMLElement; - component: Component; + component: ComponentConfig; initialState: S; }; -export const runUi = async ( - config: EnvConfig -): Promise => { - const component = runComponent(config.component, config.initialState); +export const runUi = (config: EnvConfig) => { + const reRender = () => config.render(component.getTemplate(), config.parent); - for await (const template of component) { - config.render(template, config.parent); - } + const component = new Component(config.initialState, config.component, _ => { + reRender(); + }); + + reRender(); + + return component; }; diff --git a/typescript/monadic/src/helpers.ts b/typescript/monadic/src/helpers.ts new file mode 100644 index 0000000..e387b60 --- /dev/null +++ b/typescript/monadic/src/helpers.ts @@ -0,0 +1,6 @@ +/** + * Typesafe version of Object.values + */ +export const values = Object.values as ( + object: Record +) => U[]; diff --git a/typescript/monadic/src/index.ts b/typescript/monadic/src/index.ts index 5d04aeb..3886465 100644 --- a/typescript/monadic/src/index.ts +++ b/typescript/monadic/src/index.ts @@ -1 +1,7 @@ -export * from './environment'; +export { runUi, EnvConfig } from './environment'; +export { + ComponentConfig, + Dispatcher, + makeComponent, + mkChild, +} from './Component'; diff --git a/typescript/monadic/src/iterableEmitter.ts b/typescript/monadic/src/iterableEmitter.ts index 5996c12..f058c26 100644 --- a/typescript/monadic/src/iterableEmitter.ts +++ b/typescript/monadic/src/iterableEmitter.ts @@ -10,6 +10,10 @@ export class IterableEmitter { public constructor(private state: T) {} + public alter(mapper: (f: T) => T) { + this.next(mapper(this.state)); + } + async *[Symbol.asyncIterator](): AsyncGenerator { const createPromise = () => new Promise(resolve => { diff --git a/typescript/monadic/yarn.lock b/typescript/monadic/yarn.lock index 6105b84..12a143e 100644 --- a/typescript/monadic/yarn.lock +++ b/typescript/monadic/yarn.lock @@ -3709,6 +3709,11 @@ hash.js@^1.0.0, hash.js@^1.0.3: inherits "^2.0.3" 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: version "1.1.0" resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e"