Skip to content

10 Updating Notes & Custom Directives

Dave Strus edited this page Aug 20, 2015 · 2 revisions

Updating Notes

If we submit the form after loading in an existing note, what do you think is going to happen?

We will actually be posting to our API and creating a new, duplicate note.

That's not good. Let's change the form to post to the update endpoint when an already-saved note is loaded. We can tell the note has been saved previously if it has an id.

Let's update the $scope.commit() function to check if there's an id, and if there is, perform an update instead of a create.

app/notes/notes.js

  $scope.commit = function() {
    if ($scope.note.id) {
      NotesBackend.updateNote($scope.note, self.refreshNotes);
    }
    else {
      NotesBackend.postNote($scope.note, self.refreshNotes);
    }
  };

We haven't implemented NotesBackend.updateNote(), so let's do that now. It needs to make an HTTP request, so it belongs in our NotesBackend service module.

  this.updateNote = function(noteData, callback) {
    var self = this;
    $http.put(nevernoteBasePath + 'notes/' + noteData.id, {
      api_key: apiKey,
      note: noteData
    }).success(function(newNoteData){
      self.fetchNotes(callback);
    });
  };

Calling fetchNotes() here is kind of a cop-out because it reloads data for all the notes at once, let's make a function that just finds the note we actually updated, and replace the old copy with the one got back from the API upon updating.

app/notes/notes.js

  this.replaceNote = function(note, callback) {
    for(var i=0; i < notes.length; i++) {
      if (notes[i].id === note.id) {
        notes[i] = note;
      }
    }
    typeof callback === 'function' && callback(notes);
  };

  this.updateNote = function(noteData, callback) {
    var self = this;
    $http.put(nevernoteBasePath + 'notes/' + noteData.id, {
      api_key: apiKey,
      note: noteData
    }).success(function(newNoteData){
      self.replaceNote(newNoteData.note, callback);
    });
  };

But the Button!

If we choose a note from the sidebar and change it, it will now update the note on the server when we click "Create Note". It will also update that note in the sidebar.

But there's still something weird here. The button still says "Create Note" even when it's actually set to update a note.

We could fix this a couple of ways. We could put a ternary in the view:

app/notes/notes.html

<input type="submit" name="commit" value="{{ note.id ? 'Update' : 'Create' }}" class="btn btn-default">

Or we could extract that logic into the controller:

<input type="submit" name="commit" value="{{ buttonText(note) }}" class="btn btn-default">

app/notes/notes.js

$scope.buttonText = function(note) {
  if (note && note.id) {
    return 'Update Note';
  } else {
    return 'Create Note';
  }
};

That conditional is simple enough that a ternary might still read OK, even in the controller:

$scope.buttonText = function(note) {
  return (note && note.id) ? 'Update Note' : 'Create Note';
};

Now, if you click one of the notes in the sidebar, the button text also changes.

That's good enough for a commit.

Fixing a Bug

When we create a new note, our text remains in the form. But if we change it and submit again, a brand new note will be saved, rather than updating the existing note. The object bound to $scope.note still doesn't have an id value, so commit still does a create rather than an update. When we save a new note, the API returns the note, complete with id. Let's set $scope.note to the object we get back from the API.

Let's update postNote to return the new note object so we can use it in our controller. We'll also change postNote to push just the new note onto the sidebar rather than reloading the whole thing.

Here's a first pass:

app/notes/notes.js

  $scope.note = NotesBackend.postNote($scope.note, self.refreshNotes);
...
  this.postNote = function(noteData, callback) {
    var self = this;
    var note;
    $http.post(nevernoteBasePath + 'notes', {
      api_key: apiKey,
      note: noteData
    }).success(function(newNoteData){
      note = newNoteData.note;
      notes.push(note);
      typeof callback === 'function' && callback(notes);
    });
    return note;
  };

For some reason, this isn't working. Let's break out the debugger and take a closer look.

  this.postNote = function(noteData, callback) {
    var self = this;
    var note;
    $http.post(nevernoteBasePath + 'notes', {
      api_key: apiKey,
      note: noteData
    }).success(function(newNoteData){
      note = newNoteData.note;
      debugger;
      notes.push(note);
      typeof callback === 'function' && callback(notes);
    });
    debugger;
    return note;
  };

When we trigger this by creating a note, the code halts at the bottom debugger! What gives? This is because Javascript, though it executes sequentially, doesn't wait for one line to finish executing before moving to the next one at the same level.

Click the "step over" icon to make the debugger execute the next line, and you'll see that note is still just an undefined var, and never got set inside our .success callback.

The call out to the API service with $http is going to be relatively slow, so we'll never get the return value we want without changing this to use a callback.

Let's update our callback function (refreshNotes) to set $scope.note if a single note is passed as a second argument.

app/notes/notes.js

  this.postNote = function(noteData, callback) {
    var self = this;
    $http.post(nevernoteBasePath + 'notes', {
      api_key: apiKey,
      note: noteData
    }).success(function(newNoteData){
      var note = newNoteData.note;
      notes.push(note);
      typeof callback === 'function' && callback(notes, note);
    });
  };

// ...
  this.refreshNotes = function(notes, note) {
    if (note) {
      $scope.note = note;
    }
    self.sidebarScope().notes = notes;
  };

Now when you edit a note immediately after creating it, the note will be updated as expected; however, you'll find that editing the title will update the sidebar as you type. We can use the object cloning hack here, too:

  this.refreshNotes = function(notes, note) {
    if (note) {
      $scope.note = this.cloneNote(note);
    }
    self.sidebarScope().notes = notes;
  };

New note button.

Now once you load an existing note into the form, there's no way to add a brand new one. So let's add a global "New Note" button.

LAB: Add a button (inside at the top of <nav id="sidebar">) and bind it to a function to clear the contents of the form, if there is anything there. BONUS: Focus the title input when you click this "New Note" button. HINT: use ng-click HINT2: What if we set $scope.note to an empty object?

SOLUTION:

app/index.html

<nav id="sidebar">
  <button class="new-note btn btn-default" ng-click="clearNote()">
    New Note
  </button>
  <ul id="notes" ng-show="hasNotes()">

app/notes/notes.js

$scope.clearNote = function() {
  $scope.note = {};
  document.getElementById('note_title').focus();
};

Now, when the note clears, the title field is focused, so we can start typing in a title right away.

Reaching into the guts of the HTML from our controller feels distinctly un-Angular-like.

AngularJS provides a mechanism for creating our own ng-* like directives. Let's add one to autofocus an element. We'll call the directive focus-on. Note that we don't actually use the ng- prefix when defining our own directives. We'll reserve those for AngularJS itself.

Since this should be available across the whole application, let's add the directive definition to app.js.

First, we need to save the module's return to a variable so we can chain extra things to it. Then we can add the directive (focusOn) shown here:

app/app.js

'use strict';

// Declare app level module which depends on views, and components
var app = angular.module('notely', [
  'ngRoute',
  'notely.view1',
  'notely.view2',
  'notely.version',
  'notely.notes'
]);


app.config(['$routeProvider', function($routeProvider) {
  $routeProvider.otherwise({redirectTo: '/notes'});
}]);

app.directive('focusOn', function() {
  return function(scope, elem, attr) {
    scope.$on(attr.focusOn, function(e) {
      elem[0].focus();
    });
  };
});

Now, in our views, we can add the focus-on directive with a custom event name, and in the controllers, we can broadcast a message with that name that the $on handler will pickup to perform the focus.

app/notes/notes.html

<input type="text" name="title" id="note_title" placeholder="Title your note" ng-model="note.title" value="{{ note.title }}" focus-on="noteCleared">

app/notes/notes.js

$scope.clearNote = function() {
  $scope.note = {};
  $scope.$broadcast('noteCleared');
};

This might seem like a lot of code to replace a one-liner with another one, but now that directive is available across our application on any element to respond to any broadcast.

Commit!