In this tutorial we are going to use ember-concurrency to make a medium like editor which should do the following things

Save as the user types

On error allow user to retry save

Force a save after continous typing

Getting Started

Clone the following project to get started.



cd autosave-editor-post

npm install && bower install && ember s

visit git clone https://github.com/ConorLinehan/autosave-editor-post.git cd autosave-editor-postnpm install && bower install && ember svisit http://localhost:4200/

The starter project is a simple app that defines a “Post” model and a form that one element a component wrapping a medium like editor . All of the network logic is handled by another amazing addon Mirage

Going through a few of the files we’ll be editing

controller/editor.js import Ember from 'ember';

import { task, timeout } from 'ember-concurrency'; // 1

const DEBOUNCE_TIME = (Ember.testing) ? 20 : 500;

const FIVE_SECONDS = (Ember.testing) ? 40 : 5000; export default Ember.Controller.extend({

});

We define two constants that we use for timings, they are switched between real worlds values and values used for testing.

<nav class="navigation">

<section class="container">

<h4 class="float-left">My Editor</h4>

</section>

</nav>

<div class="container">

<div class="row">

<div class="column">

<label>Body</label>

// 1

{{medium-editor}}

</div>

</div>

</div>

The medium-editor component wraps the medium-editor lib, it has one action updateText which passes the html within the editor on change.

acceptance/editor-test.js import { test } from 'qunit';

import moduleForAcceptance from 'autosave-editor-post/tests/helpers/module-for-acceptance';

import Ember from 'ember';

import { timeout } from 'ember-concurrency'; // 1

const triggerInput = (editor, text, $element) =>{

editor.trigger('editableInput', {

target: {

innerHTML: text

}

}, $element);

}; moduleForAcceptance('Acceptance | editor', {

beforeEach() {

server.create('post', {text: ''}); // 2

}

}); test('it debounces a save', function(assert) {

// [Enter Code Here]

}); test('it forces a save', function(assert) {

// [Enter Code Here]

});

To simulate the user editing the editor we grab an editor instance and fire the event directly with a passed in value. Before each test we use mirage to seed a Post value

Our First Task

Now lets hook up the logic. Open controllers/editor.js and add the following

import Ember from 'ember';

import { task, timeout } from 'ember-concurrency'; const DEBOUNCE_TIME = (Ember.testing) ? 20 : 500;

const FIVE_SECONDS = (Ember.testing) ? 40 : 5000; export default Ember.Controller.extend({

//1

saveModelTask: task(function *() {

yield this.get('model').save();

}).keepLatest(), // 2

updatedModelTask: task(function *() {

yield timeout(DEBOUNCE_TIME);

this.get('saveModelTask').perform();

}).restartable()

});

This defines a one liner task which yields on the model being saved. It also has the modifier of keepLatest on it (you can read more about modifiers here) . The reason we use this modifer is because firstly we want there to be a max of one save in motion at once, we then want to drop all subsequent attempts to save except for the last one which will be the most accurate representation of what the user is typing. The first line of this task, yields for a moment before calling save. This combined with the restartable modifier gives us the debouncing effect we are looking for.

Now lets hook this up to our view. Going to the editor.hbs file make the following change to the editor component

{{medium-editor

updateText=

(pipe-action

(action (mut model.text))

(perform updatedModelTask))}}

We use the pipe helper to perform a sequence of actions (for those that havin’t used ember-composable-helpers you can read more here)

Update the model using the mut helper

perform the updatedModelTask task using the perform helper

Now start to type in the body field and inspect your console. Notice how the patch request is only sent once you’ve stopped typing?

Indication

Next we have to indicate to the user that a save is in progress. Luckily a task has derived state which we can use in our templates. Add the bellow line 3 in editor.hbs

{{#if saveModelTask.isRunning}}

<h4 class="float-right">Saving..</h4>

{{/if}}

Now start to type in the editor again…

This is the first reason we used two tasks, it allows us to use the saveModelTask.isRunning state to show the user when the model is actually being persisted and not the debounced state.

Error Handling

Again due to the taskStates we get from e-concurrency error handling is also a breeze to implement. Firstly go to mirage/config.js and uncomment the lines that mention throwing an error

import Mirage from 'ember-cli-mirage'; export default function() {

this.timing = 800; this.get('/posts/:id');

this.patch('/posts/:id'); // Uncomment to fake error case

this.patch('/posts/:id', () =>{

return new Mirage.Response(500);

});

}

Now add the following lines to editor.hbs

{{#if saveModelTask.isRunning}}

<h4 class="float-right">Saving..</h4>

{{else if saveModelTask.last.error}}

<a {{action (perform saveModelTask)}} class="float-right">

<h4>Failed Retry?</h4>

</a>

{{/if}}

Now let’s go through this else-if statement, again it uses derived state on the task instance to tell if an error was thrown. We then allow the user to perform saveModelTask directly thus skiping any debouncing. Make sure you recomment the error line in mirage/config.js to continue on with the tutorial.

Force Save

The final piece of func we’ll be adding is to force a save after the user has continousaly typed for five seconds. To do this we’ll add another task to our controller.

forceSaveTask: task(function *() {

yield timeout(FIVE_SECONDS);

this.get('saveModelTask').perform();

}).drop(),

We yield for five seconds at the start and then perform the save action. We add the drop modifier since we only want to save every five seconds and not have them batch up. Now make the following changes to our updatedModelTask.

updatedModelTask: task(function *() {

this.get('forceSaveTask').perform();

yield timeout(DEBOUNCE_TIME);

this.get('forceSaveTask').cancelAll();

this.get('saveModelTask').perform();

}).restartable()

The first line starts the forceSaveTask and since it’s behind the timeout it’ll be performed straight away and since we have the drop modifier on forceSaveTask, it will only force a save every five seconds. The second line we added cancels the forceSaveTask so we don’t save twice.

Took more goes than I’d like to admit o_0

Testing

Now to wrap things up and test our functionality (note since this is a tutorial we’re only going to lightly test the debounced saving tasks).

Go to acceptance/editor-test.js and add the following into it debounces save

// 1

visit('/');

andThen(() =>{

// 2

let $editor = Ember.$('.editor')[0];

let editor = MediumEditor.getEditorFromElement($editor);

triggerInput(editor, 'old Text', $editor); // 3

timeout(5)

.then(() =>{

triggerInput(editor, 'new text', $editor);

timeout(25)

.then(() =>{

// 4

assert.equal(server.db.posts[0].text, 'new text');

let numberOfSaves = server.pretender.handledRequests

.filter(r => r.method === 'PATCH').length;

assert.equal(numberOfSaves, 1);

});

});

});

Let’s go through this line by line.

We visit the editor page (read more about acceptance testing here) We trigger the editors update event directly. Note the text passed doesn’t act as fill-in but more like an overwrite. To test the debounce we use e-concurrency’s timeout method each callback waits a number of miliseconds and then calls triggerInput again.

The way we used timeout we expect the following to happen trigger -> timeout(5) [debounced] -> trigger -> timeout(25) [saveCalled]. To test that the correct input was save we assert on the db. Next thing we want to check is that save was only called once. To this we check the handledRequests and filter out ones that aren’t PATCH, we then want to make sure that there’s only one.

The final thing we will test is that it forces a save after continous input, the editor forces a save. Add the following to the “it forces a save test”

// Same as above

visit('/');

andThen(() =>{ let $editor = Ember.$('.editor')[0];

let editor = MediumEditor.getEditorFromElement($editor); // 1

triggerInput(editor, '1', $editor);

timeout(15).then(() =>{

triggerInput(editor, '2', $editor);

timeout(15).then(() =>{

triggerInput(editor, '3', $editor);

timeout(15).then(() =>{

// 2

assert.equal(server.db.posts[0].text, '3');

let numberOfSaves = server.pretender.handledRequests

.filter(r => r.method === 'PATCH').length;

assert.equal(numberOfSaves, 1);

});

});

}); });

Step by Step

We go through the following processes [input] -> timeout(15) -> [input] -> timeout(15) -> [input] -> timeout(15) Total 45ms. After above we expect a forces save to happen so like a above we assert that the text is correct and only one save happens.

Summary

I hope you enjoyed the tutorial and learned a bit about how ember-concurrency can make you life a bit easier :)