dmitriz / un

unframework for universal uncomponents - use your uncomponents with no boundaries
31 stars 3 forks source link

The Input component implementations in the TodoMVC examples #1

Open dmitriz opened 7 years ago

dmitriz commented 7 years ago

What is it about?

DISCLAIMER.

These are my notes, mostly for my own reference, to study and analyse the approaches by different frameworks to implement the popular TodoMVC example.

The comments record my own description of the approach for a more convenient future reference. No judgement and no neither positive nor negative connotation is neither implied nor intended.

Redux (with React),

The TodoTextInput component from the TodoMVC example:

<input className={
    type="text"

    // controlled by the `text` state
    value={this.state.text}

    // emits 3 event actions
    onChange={this.handleChange}
    onKeyDown={this.handleSubmit} 
/>

State updates:

handleChange = e => this.setState({ text: e.target.value })

// needs to filter out only the ENTER events
// complex logic based on knowing about `newTodo`
// the `handleSubmit` handles the `onKeyDown` event
handleSubmit = e => {
    const text = e.target.value.trim()
    if (e.which === 13) {
      this.props.onSave(text)
      if (this.props.newTodo) {
        this.setState({ text: '' })
      }
    }
}

// update the state
handleChange = e => {
    this.setState({ text: e.target.value })
}

-- The AddTodo component from the Todos example

let input
<form onSubmit={e => {
  e.preventDefault()
  if (!input.value.trim()) {
    return
  }

  // action dispatching from the `input` variable
  dispatch(addTodo(input.value))

  // directly reset the value
  input.value = ''
}}>

  // directly referencing the node
  <input ref={node => {

    // set the `input` variable
    input = node
  }} />
  <button type="submit">
    Add Todo
  </button>
</form>

Un - related:

Basic submit example with un implemented with pure functions

The same example with the reset upon submit functionality with pure functions

dmitriz commented 7 years ago

Angular 2

[The app.html file from the TodoMVC example] https://github.com/tastejs/todomvc/blob/master/examples/angular2/app/app.html

<input 

    <!-- two-way binding by name string to the model defined in the controller -->
    [(ngModel)]="newTodoText" 

    <!-- Angular expression attached by name to evaluate on the `keyup.enter` event -->
    (keyup.enter)="addTodo()"
>

The app.ts file:

addTodo() {
  if (this.newTodoText.trim().length) {

    // delegate to a `todoStore` method, need to know the API from another file
    this.todoStore.add(this.newTodoText);

   // directly reset the input value
    this.newTodoText = '';
  }
}

Delegates to the store.ts file:

add(title: String) {

  // directly update the store
  this.todos.push(new Todo(title));

  // write to the `localStorage`
  this.updateStore();
}
...
private updateStore() {
  localStorage.setItem('angular2-todos', JSON.stringify(this.todos));
}
dmitriz commented 7 years ago

VueJS 2

The index.html file from the TodoMVC example:

<input 

  <!-- two-way binding by name to the model defined in the controller -->
  v-model="newTodo"

  <!-- function defined in the controller is attached by name string  
    to the `keyup.enter` event -->
  @keyup.enter="addTodo"
>

The app.js file:

addTodo: function () {
  var value = this.newTodo && this.newTodo.trim()
  if (!value) {
    return
  }

  // directly update the `todos` property on the component
  this.todos.push({
    id: todoStorage.uid++,
    title: value,
    completed: false
  })

  // reset component's property and the input field via the 2-way binding
  this.newTodo = ''
},

// watch for changes to update the `localStorage`
watch: {
  todos: {
    handler: function (todos) {
      todoStorage.save(todos)
    },
    deep: true
  }
},
dmitriz commented 7 years ago

MithrilJS

The todomvc.js file from the TodoMVC example:

view: function(vnode) {
var ui = vnode.state
...
    // attach function referenced by state on the vnode argument
    m("input", {onkeypress: ui.add})
...
}

add: function(e) {
  if (e.keyCode === 13) {

    // call the `dispatch` function
    state.dispatch("createTodo", [e.target.value])

    // directly reset the input value referenced by the event target
    e.target.value = ""
  }
},
...
state.dispatch = function(action, args) {

  // run the action function
  // this is a driver type general purpose helper function, 
  // where imperative style is expected
  state[action].apply(state, args || [])
  requestAnimationFrame(function() {
    localStorage["todos-mithril"] = JSON.stringify(state.todos)
  })
},

createTodo: function(title) {

  // directly update the `state`
  state.todos.push({title: title.trim(), completed: false})
},
dmitriz commented 7 years ago

CycleJS

The view.js file in the TodoMVC example:

// actions are present but not attached here
input('.new-todo', {
  props: {
    type: 'text',
    name: 'newTodo'
  },

  // Cycle's specific API method?
  hook: {
    update: (oldVNode, {elm}) => {

      // directly updating the value by referencing the underlying element
      elm.value = '';
    },
  },
})

Actions attached are found in the intent.js file:

/* Original comments:
// CLEAR INPUT STREAM
// A stream of ESC key strokes in the `.new-todo` field.
*/
// Declaratively reference the `DOMSource` provided by the driver,
// the keydown event stream 
DOMSource.select('.new-todo').events('keydown')

  // is filtered by the key code
  .filter(ev => ev.keyCode === ESC_KEY)

  // and transformed into the `clearInput` action stream
  .map(payload => ({type: 'clearInput', payload})),

/* Original comments:
// ENTER KEY STREAM
// A stream of ENTER key strokes in the `.new-todo` field.
*/
DOMSource.select('.new-todo').events('keydown')

  /* Original comments:
  // Trim value and only let the data through when there
  // is anything but whitespace in the field and the ENTER key was hit.
  */
  .filter(ev => {
    let trimmedVal = String(ev.target.value).trim();
    return ev.keyCode === ENTER_KEY && trimmedVal;
  })

  /* Original comments:
  // Return the trimmed value.
  */
  .map(ev => String(ev.target.value).trim())

  // transform into the `insertTodo` action stream
  .map(payload => ({type: 'insertTodo', payload})),

Both clear input and enter key streams are merged and returned by the intent function. Next, the model function provides reducers in the model.js file:

/* Original comments:
// THIS IS THE MODEL FUNCTION
// It expects the actions coming in from the sources
*/
function model(action$, sourceTodosData$) {

  /* Original comments:
  // THE BUSINESS LOGIC
  // Actions are passed to the `makeReducer$` function
  // which creates a stream of reducer functions that needs
  // to be applied on the todoData when an action happens.
  */
  let reducer$ = makeReducer$(action$);

  /* Original comments:
  // RETURN THE MODEL DATA
  */
  return sourceTodosData$.map(sourceTodosData =>
    reducer$.fold((todosData, reducer) => reducer(todosData), sourceTodosData)
  ).flatten()

  /* Original comments:
  // Make this remember its latest event, so late listeners
  // will be updated with the latest state.
  */
  .remember();
...

let clearInputReducer$ = action$
    .filter(a => a.type === 'clearInput')
    .mapTo(function clearInputReducer(todosData) {
      return todosData;
    });
dmitriz commented 7 years ago

MostJS

General comment

The MostJS example here was one of the main inspiration sources for un and resembles some of the code in https://github.com/dmitriz/un/blob/master/index.js

The view.js file from the TodoMVC example:

export const renderHeader = () =>
    ...
 form('.add-todo', [

    // actions are present but not attached here
    input('.new-todo', { attrs: { 
       placeholder: 'What needs to be done?', 
       autofocus: true 
    } })
  ...

The index.js file:

// the whole app runs by this single command
// returns the stream of states
todos(window, container, initialState)

    // save into localStorage or read from it
    // both reads and writes into stream
    // similar to middleware
   .loop(updateStore, store)

    // update the dom via the snabbdom's `patch` method
    // the `reduce` method is used due to how the `patch` 
    // takes vnode and returns the next one
   .reduce(updateApp, root)

// elements are taken directly from the dom
const root = document.querySelector('.todoapp')
const container = root.parentNode
...
const todos = (window, container, initialState) => {

  // all actions streams are merged together
  const actions = merge(
      listActions(container), 
      editActions(container), 
      filterActions(window)
  )

  // and passed to the `scan` method by `mostjs` over `applyAction` reducer
  return scan(applyAction, initialState, actions)
}
...
// the root dom element
const listActions = el => {
  ...
  // is passed to the `submit` event stream creator
  // and filtered down to only match the `.add-todo` selector
  // and subsequently mapped over by the `addTodoAction` function
  const add = map(addTodoAction, preventDefault(match('.add-todo', submit(el))))
  ...
  // all streams are merged and returned by the `listActions`
  return merge(add, remove, complete, all, clear)
}

The actions are finally defined in the action.js file :

// The action function is composed from the pure action creator functions
export const addTodoAction = pipe(withFirstValue, resetForm, addTodo, setTodos)

which are defined in the todos.js file:

// addTodo :: string -> [Todo] -> [Todo]
export const addTodo = description => todos =>
  description.trim().length === 0 ? todos
    : todos.concat(newTodo(newId(), description, false))
dmitriz commented 7 years ago

The functional frontend framework

General comment

The example here was also one of the main inspiration sources for un and resembles some of the code in https://github.com/dmitriz/un/blob/master/index.js

The task-list.js file:

// action stream is created from merging with the router stream of the `url` values
const action$ = flyd.merge(MyRouter.stream, flyd.stream());

// the input virtual node (snabbdom) updates the action stream via dom events
h('input.new-todo', {
  props: {
    placeholder: 'What needs to be done?',

    // input value is controlled by the `model` property
    value: model.newTitle
  },
  on: {

    // on input field update, 
    // value is passed to the composition of the transformer functions
    // and gets subsequently written into the `action$` stream
    input: R.compose(action$, Action.ChangeNewTitle, targetValue),

    // the keydown event is passed through the action creator, 
    // and is piped into `action$` stream
    // and sent though the `ifEnter` filter to only contain the ENTER events
    keydown: ifEnter(action$, Action.Create())
  },
}),
...
// the action stream is scanned over the `update` reducer from the action stream
const model$ = flyd.scan(R.flip(update), restoreState(), action$)

// and gets mapped over the `view` function to generate vnode stream
const vnode$ = flyd.map(view(action$), model$)

// the state (aka model) stream is also passed to the saving middleware
flyd.map(saveState, model$);

Finally the app is run by

window.addEventListener('DOMContentLoaded', function() {
  const container = document.querySelector('.todoapp')

  // vnode stream is scanned over the snabbdom's `patch` method
  // updating the dom in reaction to the virtual dom changes
  flyd.scan(patch, container, vnode$)
})