From 17f813ea0cbd67b06636e0f49961d71b95359052 Mon Sep 17 00:00:00 2001
From: Matei Adriel <rafaeladriel11@gmail.com>
Date: Tue, 12 May 2020 14:24:14 +0300
Subject: [PATCH] typescript(monadic): feat: working event raising

Signed-off-by: prescientmoon <git@moonythm.dev>
---
 typescript/monadic/demos/basic/main.ts | 121 ++++++++++++++++++------
 typescript/monadic/src/Builder.ts      |  56 +++++++++--
 typescript/monadic/src/Component.ts    | 125 +++++++++++++++++--------
 typescript/monadic/src/environment.ts  |  28 ++++--
 typescript/monadic/src/helpers.ts      |   6 ++
 typescript/monadic/src/index.ts        |   1 +
 6 files changed, 255 insertions(+), 82 deletions(-)

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<TemplateResult>());
 
+// 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<TodoAction>(),
   withState<TodoState>(),
+  withOutput<TodoOutput>(),
   buildSpec(
-    ({ name, done }, { dispatch }) => {
+    ({ name, done }, { dispatch, raise }) => {
       return html`
         <div>
           Name: ${name} Completed: ${done}
           <button @click=${() => dispatch('complete')}>Complete todo</button>
+          <button @click=${() => raise('delete')}>Delete todo</button>
         </div>
       `;
     },
@@ -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<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(
-  emptySpec,
+  baseSpec,
   withAction<TodoListAction>(),
   withState<TodoListState>(),
   withChild<'todo', number, TodoState, null>(),
+  withChild<'input', null, string, TodoListInputOutput>(),
   buildSpec(
-    (state, { dispatch, child }) => {
+    (state, { child }) => {
       return html`
         <div>
-          <input
-            value="${state.inputValue}"
-            @input=${({ value }) => dispatch({ label: 'setInput', value })}
-          />
-          <button @click=${() => dispatch('create')}>Add todo</button>
+          ${child('input', 'input', null)}
           <div>
             ${state.todos.map((_, index) =>
               child('todo', String(index), index)
@@ -84,8 +122,8 @@ const todoList = pipe(
         </div>
       `;
     },
-    (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<N>
 > = [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 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>
 ) => 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,
   S,
   A0,
@@ -76,7 +95,24 @@ export const withAction = constant(identity) as <A1>() => <
   spec: ComponentSpec<T, S, A0, 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,
   S0,
   A,
@@ -87,7 +123,10 @@ export const withState = constant(identity) as <S1>() => <
   spec: ComponentSpec<T, S0, 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,
   S,
   A,
@@ -98,7 +137,10 @@ export const withTemplate = constant(identity) as <T1>() => <
   spec: ComponentSpec<T0, 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,
   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<A> = (action: A) => void;
 
-export type Communicate<
-  T,
-  A,
-  O,
-  N extends string,
-  C extends ChildrenConfigs<N>
-> = {
+export type Communicate<A, O> = {
   dispatch: Dispatcher<A>;
-  child: <K extends N>(key: K, name: string, input: C[K]['input']) => T;
   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<
   T,
   S,
@@ -27,7 +27,7 @@ export type RenderFunction<
   O,
   N extends string,
   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<
   T,
@@ -38,7 +38,7 @@ export type ComponentConfig<
   C extends ChildrenConfigs<N>
 > = {
   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>;
 };
 
@@ -102,51 +102,98 @@ export class Component<
   public constructor(
     protected state: S,
     private config: ComponentConfig<T, S, A, O, N, C>,
+    private raise: Communicate<A, O>['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<T, N, C> = {
+    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<string, any>;
+
+        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<A, O> {
+    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<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();
-      },
+      ...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<T, S, A, O> = {
   render: (template: T, parent: HTMLElement) => void;
@@ -7,14 +8,29 @@ export type EnvConfig<T, S, A, O> = {
   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 component = new Component(config.initialState, config.component, _ => {
-    reRender();
-  });
+  const outputEmitter = new IterableEmitter<null | O>(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<T, U> = {
   get: (v: T) => U;
   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);
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';