Cycle.js

an honestly reactive framework 

Why another JS framework?

Because this one is better!

It is different at least

We got used to MV*

Some of them provide kind of data streams

// Knockout.js

function MyViewModel() {
    this.produce = [ 'Apple', 'Banana', 'Celery', 'Corn', 'Orange', 'Spinach' ];
    this.selectedProduce = ko.observableArray([ 'Corn', 'Orange' ]);
    this.selectedAllProduce = ko.pureComputed({
        read: function () {
            return this.selectedProduce().length === this.produce.length;
        },
        write: function (value) {
            this.selectedProduce(value ? this.produce.slice(0) : []);
        },
        owner: this
    });
}

// Ampersand.js

var Person = AmpersandState.extend({
    props: {
        firstName: 'string',
        lastName: 'string'
    },
    session: {
        signedIn: ['boolean', true, false],
    },
    derived: {
        fullName: {
            deps: ['firstName', 'lastName'],
            fn: function () {
                return this.firstName + ' ' + this.lastName;
            }
        }
    }
});

Then flux appeared

  • unidirectional data flow
  • inversion of control (reactive)
  • one-way data binding

But it's still far from ideal

A lot of imperative calls

  • this.setState()
  • Store.waitFor([...])
  • Actions.action(...)

What if we want something truly reactive?

How human-computer interaction works?

© Andre Staltz

Computer translates user actions to view

© Andre Staltz

It takes input and produces output, like a...

Function!

© Andre Staltz

User is a function too

© Andre Staltz

© Andre Staltz

Output of user function is input of computer function

Output of computer function is input of user function

This is a cycle

© Andre Staltz

We can't program the user (yet)...

We can do it with computer

© Andre Staltz

Let's code!

Function is a main building block

function computer({ DOM }) {
    let initial$ = Rx.observable.just(0);
    



    




  





    return {





    };
}
function computer() {
    















    return {
        




    };
}
function computer({ DOM }) {
    let initial$ = Rx.observable.just(0);

    // intent
    let click$ = DOM.get('button', 'click');

    



    



  


    return {





    };
}
function computer({ DOM }) {
    let initial$ = Rx.observable.just(0);

    // intent
    let click$ = DOM.get('button', 'click');

    // model
    let counter$ = click$.merge(initial$).scan((counter) => counter + 1));

    // view
    let vtree$ = counter$.map((counter) => // view
        h('div', [
            h('span', `Counter value is: ${counter}`),
            h('button', 'Increase counter')
        ])
    );

    return {
        DOM: vtree$,
        



    };
}
function computer({ DOM, REST }) {
    let initial$ = Rx.observable.just(0);

    // intent
    let click$ = DOM.get('button', 'click');

    // model
    let counter$ = click$.merge(initial$).scan((counter) => counter + 1));

    // view
    let vtree$ = counter$.map((counter) => // view
        h('div', [
            h('span', `Counter value is: ${counter}`),
            h('button', 'Increase counter')
        ])
    );

    return {
        DOM: vtree$,
        REST: counter$.map((counter) => ({
            resource: 'increase-counter',
            method: 'UPDATE'
        })
    };
}
function computer({ DOM }) {
    let initial$ = Rx.observable.just(0);

    // intent
    let click$ = DOM.get('button', 'click');

    // model
    let counter$ = click$.merge(initial$).scan((counter) => counter + 1));


    



  


    return {





    };
}

And to wire things up...

/*
 * App definition function
 *          |                    drivers
 *          |                  /                   */
Cycle.run(computer, {/*       /                    */
    // DOM driver renders Virtual DOM to provided element, like BODY tag                 
    DOM: makeDomDriver(document.body),

    REST: makeRestDriver({
        root: '/rest',
        resources: {
            'increase-counter': '/counter/increase'
        }
    })
});
/*
 * App definition function
 *          |                    drivers
 *          |                  /                   */
Cycle.run(computer, {/*       /                    */
    // DOM driver renders Virtual DOM to provided element, like BODY tag                 
    DOM: makeDomDriver(document.body),
    // this one doesn't exits yet, you are welcome to make one!
    REST: makeRestDriver({
        root: '/rest',
        resources: {
            'increase-counter': '/counter/increase'
        }
    })
});
/*
 * App definition function
 *          |                    
 *          |                                      */
Cycle.run(computer, {                 

    DOM: makeDomDriver(document.body),

    REST: makeRestDriver({
        root: '/rest',
        resources: {
            'increase-counter': '/counter/increase'
        }
    })
});




Cycle.run(computer, {                 

    DOM: makeDomDriver(document.body),

    REST: makeRestDriver({
        root: '/rest',
        resources: {
            'increase-counter': '/counter/increase'
        }
    })
});

Componentize!

Function is main building block, right?

function computer({ DOM, REST }) {
    let click$ = DOM.get('button', 'click'); // intent
    let counter$ = click$.scan((counter) => counter + 1)); // model
    let vtree$ = counter$.map((counter) => // view

        // just extract counter UI definition to the function
        createCounter(counter)

    );

    return {
        DOM: vtree$,
        REST: counter$.map((counter) => ({
            resource: 'increase-counter',
            method: 'UPDATE'
        })
    };
}

function createCounter(counter) {
    return h('div', [
        h('span', `Counter value is: ${counter}`),
        h('button', 'Increase counter')
    ]);
}
function computer({ DOM, REST }) {
    let click$ = DOM.get('button', 'click'); // intent
    let counter$ = click$.scan((counter) => counter + 1)); // model
    let vtree$ = counter$.map((counter) => // view
        h('div', [
            h('span', `Counter value is: ${counter}`),
            h('button', 'Increase counter')
        ])
    );

    return {
        DOM: vtree$,
        REST: counter$.map((counter) => ({
            resource: 'increase-counter',
            method: 'UPDATE'
        })
    };
}

But what about logic?

Custom elements!

function computer({ DOM, REST }) {
    let initial$ = Rx.Observable.just(0);
    let click$ = DOM.get('button', 'click'); // intent
    let counter$ = click$.merge(initial$).scan((counter) => counter + 1));
    let vtree$ = counter$.map((counter) => // view
        h('div', [
            h('span', `Counter value is: ${counter}`),
            h('button', 'Increase counter')
        ])
    );

    return {
        DOM: vtree$,
        REST: counter$.map((counter) => ({
            resource: 'increase-counter',
            method: 'UPDATE'
        })
    };
}
function counterComponent({ DOM, props }) {
    let initial$ = props.get('value');
    let click$ = DOM.get('button', 'click'); // intent
    let counter$ = click$.merge(initial$).scan((counter) => counter + 1));
    let vtree$ = counter$.map((counter) => // view
        h('div', [
            h('span', `Counter value is: ${counter}`),
            h('button', 'Increase counter')
        ])
    );

    return {
        DOM: vtree$,
        events: {
            increase: counter$.map(() => true)
        }
    };
}
function computer({ DOM, REST }) {
    let increaseCounter$ = DOM.get('simple-counter', 'increase');
    let vtree$ = Rx.Observable.just(
        h('div', [
            h('h1', 'Simple counter example'),
            h('simple-counter', {
                value: 0
            })
        ])
    );

    return {
        DOM: vtree$,
        REST: increaseCounter$.map(() => ({
            resource: 'increase-counter',
            method: 'UPDATE'
        })
    };
}

Cycle.run(computer, {                 
    DOM: makeDomDriver(document.body, {
        'simple-counter': counterComponent
    }),

    REST: makeRestDriver({
        root: '/rest',
        resources: {
            'increase-counter': '/counter/increase'
        }
    })
});

Let's make things more complicated!

function computer(interactions) {
  let listId = uuid();
  let listClass = 'files';
  let listItemClass = listClass + '__item';
  let addNewClass = 'add-new';
  let addNewFormClass = addNewClass + '__form';
  let cancelAddingNewButtonClass = addNewClass + '__cancel';
  let navClass = 'nav';
  let buttonClass = navClass + '__button';
  let renameButtonClass = buttonClass + '--rename';
  let removeButtonClass = buttonClass + '--remove';
  let addNewButtonClass = buttonClass + '--add-new';
  let removalConfirmationClass = 'removal-confirmation';
  let removalConfirmationMessageClass = removalConfirmationClass + '__message';

  let model = createGroup({
    files$: (
      initialFiles$,
      selectedOptions$,
      addingNewConfirmed$,
      removalConfirmed$,
      fileNameChange$,
      files$
    ) =>
      Rx.Observable.merge(
        selectedOptions$
          .withLatestFrom(
            files$,
            (selectedFiles, files) =>
              files.map(({ fileName, uuid }) => ({
                fileName,
                uuid,
                selected: selectedFiles.indexOf(uuid) !== -1
              }))
          ),
        fileNameChange$
          .withLatestFrom(
            files$,
            (nameChange, files) =>
              files.map((file) =>
                nameChange.uuid === file.uuid ? ({
                  fileName: nameChange.fileName,
                  uuid: file.uuid,
                  selected: file.selected
                }) : file
              )
          ),
        removalConfirmed$
          .withLatestFrom(
            files$,
            (remove, files) =>
              files.filter(({ selected }) =>
                !selected
              )
          ),
        addingNewConfirmed$
          .withLatestFrom(
            files$,
            (newName, files) =>
              [{
                fileName: newName,
                uuid: uuid(),
                selected: true
              }].concat(files)
          ),
        initialFiles$
      ).distinctUntilChanged((files) =>
        JSON.stringify(files)
      ),
    anyFileSelected$: (files$) =>
      files$
        .map((files) => !!files.filter(({ selected }) => selected).length)
        .startWith(false)
        .distinctUntilChanged(),
    renameMode$: (renameButtonClick$) =>
      renameButtonClick$
        .scan(false, (previous) =>
          !previous
        )
        .startWith(false)
        .distinctUntilChanged(),
    addingNewMode$: (
      addNewButtonClick$,
      addingNewConfirmed$,
      addingNewCanceled$
    ) =>
      Rx.Observable.merge(
        Rx.Observable.merge(
          addingNewConfirmed$,
          addingNewCanceled$
        ).map(() => false),
        addNewButtonClick$.map(() => true)
      ).startWith(false)
      .distinctUntilChanged(),
    removingMode$: (
      removeButtonClick$,
      removalConfirmed$,
      removalCanceled$
    ) =>
      Rx.Observable.merge(
        Rx.Observable.merge(
          removalConfirmed$,
          removalCanceled$
        ).map(() => false),
        removeButtonClick$.map(() => true)
      ).startWith(false)
      .distinctUntilChanged()
  });
  model.inject({
      initialFiles$: Rx.Observable.just(
        Array.from(new Array(10), (value, index) =>
          `file${index+1}.${[ 'txt', 'doc', 'jpg' ][Math.floor(Math.random() * 3)]}`
        )
          .map((fileName) => ({
            fileName,
            uuid: uuid(),
            selected: false
          }))
      ),
      selectedOptions$: interactions.get(`.${listClass}`, 'selectedOptions')
        .map(({ detail }) => detail)
        .map((options) =>
          options.map((option) =>
            option.properties.id
          )
        ),
      renameButtonClick$: interactions.get(`.${renameButtonClass}`, 'click'),
      removeButtonClick$: interactions.get(`.${removeButtonClass}`, 'click'),
      addNewButtonClick$: interactions.get(`.${addNewButtonClass}`, 'click'),
      addingNewConfirmed$: interactions.get(`.${addNewFormClass}`, 'value')
        .map(({ detail }) => detail),
      addingNewCanceled$: interactions.get(`.${cancelAddingNewButtonClass}`, 'click'),
      removalConfirmed$: interactions.get(`.${removalConfirmationClass}`, 'confirm')
        .map(({ detail }) => detail)
        .share(),
      removalCanceled$: interactions.get(`.${removalConfirmationClass}`, 'cancel')
        .map(({ detail }) => detail),
      fileNameChange$: interactions.get(`.${listItemClass}`, 'name')
        .map(({ detail, target }) => ({
          uuid: target.id,
          fileName: detail
        }))
    },
    model
  );

  return {
    DOM: Rx.Observable.combineLatest(
      model.files$,
      model.renameMode$,
      model.addingNewMode$,
      model.anyFileSelected$,
      model.removalConfirmationVisible$,
      (files, renameMode, addingNewMode, anyFileSelected, removalConfirmationVisible) =>
        h('div', [
          h('div', {
            className: `${navClass}`
          }, [
            h('button', {
              className: `${renameButtonClass}`,
              disabled: !anyFileSelected || addingNewMode || removalConfirmationVisible
            }, renameMode ? 'Finish renaming' : 'Rename'),
            h('button', {
              className: `${removeButtonClass}`,
              disabled: renameMode || !anyFileSelected || addingNewMode || removalConfirmationVisible
            }, 'Remove'),
            h('button', {
              className: `${addNewButtonClass}`,
              disabled: renameMode || addingNewMode || removalConfirmationVisible
            }, 'New folder')
          ]),
          h('selectable-list', {
            disabled: renameMode || addingNewMode || removalConfirmationVisible,
            key: listId,
            className: `${listClass}`
          },  (addingNewMode ? [
              h('div', {
                selected: true
              }, h('div', {
                className: `${addNewClass}`
              }, [
                h('file-rename-form', {
                  className: `${addNewFormClass}`,
                  value: getNewFolderName(files),
                  key: 'adding-new-file-form'
                }),
                h('button', {
                  className: `${cancelAddingNewButtonClass}`
                }, 'Cancel')
              ]))
            ] : [ ]).concat(
            files.map(({ fileName, uuid, selected }) =>
              h('div', {
              // custom elements can't be embedded in other custom elements directly
              // until we fix it in the core, use plain DIV to wrap
                id: uuid,
                selected: selected
              },
                h('files-list-item', {
                  key: uuid,
                  id: uuid,
                  name: fileName,
                  renameMode: selected && renameMode,
                  className: `${listItemClass}`
                })
              )
            ))
          )
        ].concat(
          removalConfirmationVisible ? [ h('confirmation-popup', {
            className: `${removalConfirmationClass}`,
            key: 'files-removal-confirmation-popup',
            messages: {
              confirm: 'Delete',
              cancel: 'Cancel'
            }
          }, [
            h('p', {
              className: `${removalConfirmationMessageClass}`,
            }, dedent`Delete selected files?
            This operation cannot be undone!`),
            h('ul', files
              .filter(({ selected }) => selected)
              .map(({ fileName }) =>
                h('li', fileName)
              )
            )
          ])
        ] : [ ])
      )
    )
  };
}


let newFolderRegex = /^New folder(?: \((\d+)\))?$/;
function getNewFolderName(files) {
  let lastNewFolder = files.map(({ fileName }) =>
    fileName.match(newFolderRegex)
  ).filter((result) =>
    result
  ).map((result) =>
    'undefined' !== typeof result[1] ?
    parseInt(result[1], 10) :
    0
  ).sort((a, b) => b - a)[0];

  return 'New folder' + ('undefined' !== typeof lastNewFolder ? ` (${lastNewFolder + 1})` : '');
}

So much code!

Let's split it.

MVI

© Andre Staltz

© Andre Staltz

Intent

  • transformation from user action to semantic command (ex. "click on button" to "confirm files removal")
  • group of data streams needed to render the view
  • each model stream is combination of intent streams, initial data and another model streams

Model

View

  • definition of application UI for each possible state that can be expressed by taking values from model streams
function computer({ DOM }) {
  let intent = createIntent(DOM, classes);
  let model = createModel(initial, intent);
  let view = createView(model, classes);

  return {
     DOM: view.vtree$
  };
}

var initial = Array.from(new Array(10), (value, index) =>
  `file${index+1}.${[ 'txt', 'doc', 'jpg' ][Math.floor(Math.random() * 3)]}`
)
  .map((fileName) => ({
    fileName,
    uuid: uuid(),
    selected: false
  }));

var classes = {
  files: 'files',
  listItem: 'files__item',
  addNew: 'add-new',
  addNewForm: 'add-new__form',
  cancelAddingNewButton: 'add-new__cancel',
  nav: 'nav',
  button: 'nav__button',
  renameButton: 'nav__button--rename',
  removeButton: 'nav__button--remove',
  addNewButton: 'nav__button--add-new',
  removalConfirmation: 'removal-confirmation',
  removalConfirmationMessage: 'removal-confirmation__message'
};

Intent

function createIntent(DOM, classes) {
  return {
    renameButtonClick$: DOM.get(`.${classes.removeButton}`, 'click'),
    removeButtonClick$: DOM.get(`.${classes.removeButton}`, 'click'),
    addNewButtonClick$: DOM.get(`.${classes.addNewButton}`, 'click'),
    addingNewConfirmed$: DOM.get(`.${classes.addNewForm}`, 'value')
      .map(({ detail }) => detail),
    addingNewCanceled$: DOM.get(`.${classes.cancelAddingNewButton}`, 'click'),
    removalConfirmed$: DOM.get(`.${classes.removalConfirmation}`, 'confirm')
      .map(({ detail }) => detail)
      .share(),
    removalCanceled$: DOM.get(`.${classes.removalConfirmation}`, 'cancel')
      .map(({ detail }) => detail),
    fileNameChange$: DOM.get(`.${classes.listItem}`, 'name')
      .map(({ detail, target }) => ({
        uuid: target.id,
        fileName: detail
      })),
    selectedOptions$: DOM.get(`.${classes.list}`, 'selectedOptions')
      .map(({ detail }) => detail)
      .map((options) =>
        options.map((option) =>
          option.properties.id
        )
      ),
  };
}

Model

function createModel(initialFiles, intent) {
  let model = {
    files$: (
      initialFiles$,
      selectedOptions$,
      addingNewConfirmed$,
      removalConfirmed$,
      fileNameChange$,
      files$
    ) =>
      Rx.Observable.merge(
        selectedOptions$
          .withLatestFrom(
            files$,
            (selectedFiles, files) =>
              files.map(({ fileName, uuid }) => ({
                fileName,
                uuid,
                selected: selectedFiles.indexOf(uuid) !== -1
              }))
          ),
        fileNameChange$
          .withLatestFrom(
            files$,
            (nameChange, files) =>
              files.map((file) =>
                nameChange.uuid === file.uuid ? ({
                  fileName: nameChange.fileName,
                  uuid: file.uuid,
                  selected: file.selected
                }) : file
              )
          ),
        removalConfirmed$
          .withLatestFrom(
            files$,
            (remove, files) =>
              files.filter(({ selected }) =>
                !selected
              )
          ),
        addingNewConfirmed$
          .withLatestFrom(
            files$,
            (newName, files) =>
              [{
                fileName: newName,
                uuid: uuid(),
                selected: true
              }].concat(files)
          ),
        initialFiles$
      ).distinctUntilChanged((files) =>
        JSON.stringify(files)
      ),
    anyFileSelected$: (files$) =>
      files$
        .map((files) => !!files.filter(({ selected }) => selected).length)
        .startWith(false)
        .distinctUntilChanged(),
    renameMode$: (renameButtonClick$) =>
      renameButtonClick$
        .scan(false, (previous) =>
          !previous
        )
        .startWith(false)
        .distinctUntilChanged(),
    addingNewMode$: (
      addNewButtonClick$,
      addingNewConfirmed$,
      addingNewCanceled$
    ) =>
      Rx.Observable.merge(
        Rx.Observable.merge(
          addingNewConfirmed$,
          addingNewCanceled$
        ).map(() => false),
        addNewButtonClick$.map(() => true)
      ).startWith(false)
      .distinctUntilChanged(),
    removingMode$: (
      removeButtonClick$,
      removalConfirmed$,
      removalCanceled$
    ) =>
      Rx.Observable.merge(
        Rx.Observable.merge(
          removalConfirmed$,
          removalCanceled$
        ).map(() => false),
        removeButtonClick$.map(() => true)
      ).startWith(false)
      .distinctUntilChanged()
  };

  return createGroup(model).inject({
    initialFiles$: Rx.Observable.just(initialFiles)
  }, intent, model, );
}

View

function createView(model, classes) {
  let listId = uuid();

  return Rx.Observable.combineLatest(
    model.files$,
    model.renameMode$,
    model.addingNewMode$,
    model.anyFileSelected$,
    model.removingMode$, (
      files,
      renameMode,
      addingNewMode,
      anyFileSelected,
      removingMode
    ) =>
      h('div', [
        h('div', {
          className: `${classes.nav}`
        }, [
          h('button', {
            className: `${classes.renameButton}`,
            disabled: !anyFileSelected ||
              addingNewMode ||
              removingMode
          }, renameMode ? 'Finish renaming' : 'Rename'),
          h('button', {
            className: `${classes.removeButton}`,
            disabled: renameMode ||
              !anyFileSelected ||
              addingNewMode ||
              removingMode
          }, 'Remove'),
          h('button', {
            className: `${classes.addNewButton}`,
            disabled: renameMode ||
              addingNewMode ||
              removingMode
          }, 'New folder')
        ]),
        h('selectable-list', {
          disabled: renameMode ||
            addingNewMode ||
            removingMode,
          key: listId,
          className: `${classes.list}`
        }, (addingNewMode ? [
            h('div', {
              selected: true
            }, h('div', {
              className: `${classes.addNew}`
            }, [
              h('file-rename-form', {
                className: `${classes.addNewForm}`,
                value: getNewFolderName(files),
                key: 'adding-new-file-form'
              }),
              h('button', {
                className: `${classes.cancelAddingNewButton}`
              }, 'Cancel')
            ]))
          ] : [ ]).concat(
          files.map(({ fileName, uuid, selected }) =>
            h('div', {
              id: uuid,
              selected: selected
            },
              h('files-list-item', {
                key: uuid,
                id: uuid,
                name: fileName,
                renameMode: selected && renameMode,
                className: `${classes.listItem}`
              })
            )
          ))
        )
      ].concat(
        removingMode ? [ h('confirmation-popup', {
          className: `${classes.removalConfirmation}`,
          key: 'files-removal-confirmation-popup',
          messages: {
            confirm: 'Delete',
            cancel: 'Cancel'
          }
        }, [
          h('p', {
            className: `${classes.removalConfirmationMessage}`,
          }, dedent`Delete selected files?
          This operation cannot be undone!`),
          h('ul', files
            .filter(({ selected }) => selected)
            .map(({ fileName }) =>
              h('li', fileName)
            )
          )
        ])
      ] : [ ])
    )
  );
}


let newFolderRegex = /^New folder(?: \((\d+)\))?$/;
function getNewFolderName(files) {
  let lastNewFolder = files.map(({ fileName }) =>
    fileName.match(newFolderRegex)
  ).filter((result) =>
    result
  ).map((result) =>
    'undefined' !== typeof result[1] ?
    parseInt(result[1], 10) :
    0
  ).sort((a, b) => b - a)[0];

  return 'New folder' + ('undefined' !== typeof lastNewFolder ? ` (${lastNewFolder + 1})` : '');
}

Refactoring?

Functions, functions, functions...

View

function createView(model, classes) {
  let listId = uuid();

  function createNav(
    anyFileSelected,
    addingNewMode,
    removingMode,
    renameMode
  ) {
    return h('div', {
      className: `${classes.nav}`
    }, [
      h('button', {
        className: `${classes.renameButton}`,
        disabled: !anyFileSelected ||
            addingNewMode ||
            removingMode
      }, renameMode ? 'Finish renaming' : 'Rename'),
      h('button', {
        className: `${classes.removeButton}`,
        disabled: renameMode ||
            !anyFileSelected ||
            addingNewMode ||
            removingMode
      }, 'Remove'),
      h('button', {
        className: `${classes.addNewButton}`,
        disabled: renameMode ||
            addingNewMode ||
            removingMode
      }, 'New folder')
    ]);
  }

  
  function createNewRow(files) {
    return h('div', {
      selected: true
    }, h('div', {
      className: `${classes.addNew}`
    }, [
      h('file-rename-form', {
        className: `${classes.addNewForm}`,
        value: getNewFolderName(files),
        key: 'adding-new-file-form'
      }),
      h('button', {
        className: `${classes.cancelAddingNewButton}`
      }, 'Cancel')
    ]));
  }


  function createRemovalConfirmationPopup(files) {
    return h('confirmation-popup', {
      className: `${classes.removalConfirmation}`,
      key: 'files-removal-confirmation-popup',
      messages: {
        confirm: 'Delete',
        cancel: 'Cancel'
      }
    }, [
      h('p', {
        className: `${classes.removalConfirmationMessage}`,
      }, dedent`Delete selected files?
      This operation cannot be undone!`),
      h('ul', files
        .filter(({ selected }) => selected)
        .map(({ fileName }) =>
          h('li', fileName)
        )
      )
    ]);
  }


  return Rx.Observable.combineLatest(
    model.files$,
    model.renameMode$,
    model.addingNewMode$,
    model.anyFileSelected$,
    model.removingMode$, (
      files,
      renameMode,
      addingNewMode,
      anyFileSelected,
      removingMode
    ) =>
      h('div', [
        createNav(anyFileSelected, addingNewMode, removingMode, renameMode),
        h('selectable-list', {
          disabled: renameMode ||
            addingNewMode ||
            removingMode,
          key: listId,
          className: `${classes.list}`
        },
          (
            addingNewMode ? [ createNewRow(files) ] : []
          ).concat(
          files.map(({ fileName, uuid, selected }) =>
            h('div', {
              id: uuid,
              selected: selected
            },
              h('files-list-item', {
                key: uuid,
                id: uuid,
                name: fileName,
                renameMode: selected && renameMode,
                className: `${classes.listItem}`
              })
            )
          ))
        )
      ].concat(
        removingMode ? [ createRemovalConfirmationPopup(files) ] : []
      )
    )
  );
}


let newFolderRegex = /^New folder(?: \((\d+)\))?$/;
function getNewFolderName(files) {
  let lastNewFolder = files.map(({ fileName }) =>
    fileName.match(newFolderRegex)
  ).filter((result) =>
    result
  ).map((result) =>
    'undefined' !== typeof result[1] ?
    parseInt(result[1], 10) :
    0
  ).sort((a, b) => b - a)[0];

  return 'New folder' + ('undefined' !== typeof lastNewFolder ? ` (${lastNewFolder + 1})` : '');
}

Testing?

Functions, functions, functions...

test('Should use initial value', (done) => {

  let initialFiles = [
    { id: 'f1', fileName: 'file1.txt' },
    { id: 'f2', fileName: 'file2.doc' }
  ];

  // it's just a stateless function,
  // you can call it with everything you want
  model.files$(
    Rx.Observable.just(initialFiles),
    Rx.Observable.empty(),
    Rx.Observable.empty(),
    Rx.Observable.empty(),
    Rx.Observable.empty(),
    Rx.Observable.empty()
  ).subscribe((files) => {;    
    assert.deepEqual(
      files,
      initialFiles
    );

    done();
  });

});


test('Should add new file to the list', (done) => {

  let files = [
    { id: 'f1', fileName: 'file1.txt' },
    { id: 'f2', fileName: 'file2.doc' }
  ];

  model.files$(
    Rx.Observable.empty(),
    Rx.Observable.empty(),
    Rx.Observable.just(files),
    Rx.Observable.empty(),
    Rx.Observable.empty(),
    Rx.Observable.just('new file.jpg').delay(100)
  ).subscribe((files) => {;    
    assert.equal(files[0].fileName, 'new file.jpg');

    done();
  });

});

Even easier with Cycle.js Mock

test('Should use initial value',
mock((callWithObservables, getValues) => {

  let initialFiles = [
    { id: 'f1', fileName: 'file1.txt' },
    { id: 'f2', fileName: 'file2.doc' }
  ];

  let files$ = callWithObservables(model.files$, {
    initialFiles$: initialFiles
  });

  let files = getValues(files$)[0];
      
  assert.deepEqual(
    files,
    initialFiles
  );

}));

test('Should add new file to the list',
mock((callWithObservables, getValues, onNext) => {

  let files$ = callWithObservables(model.files$, {
    files$: [
      { id: 'f1', fileName: 'file1.txt' },
      { id: 'f2', fileName: 'file2.doc' }
    ],
    addingNewConfirmed$: onNext('new file.jpg', 100)
  });

  let files = getValues(files$)[0];
      
  assert.equal(files[0].fileName, 'new file.jpg');

}));

Further reading

it's over


Thank you!

 Eryk Napierała

@UsabilityTools.com

We

are

hiring!

Awesomeness

RxJS

Tests

Third-party scripts

Angular

the latest browsers

only

Pixi.js

WebSockets

SASS

Startup

Cycle.js – An honestly reactive framework for web user interfaces

By Eryk Napierała

Cycle.js – An honestly reactive framework for web user interfaces

  • 6,422