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,447