Levelling Up With AngularJS: Building a Reusable Click to Edit Directive

In my last article about AngularJS, I shared how easy it was to build a simple "click to edit" field, and along the way introduced some of Angular's core concepts: controllers, expressions & two-way bindings between your markup and the controller's scope. This was a nice introduction, but the resulting code has a shortfall: it's not easily reusable. For something that behaves as a form field, you want the ability to sprinkle it across your pages wherever you like. This is where directives come in. Spurred on by Max's recent foray into data-backed directives, I'll now take the next steps and turn this click to edit example into a reusable Angular directive.

If you've sprinkled Angular into your apps at all, you've already worked with its built-in directives, things like ng-model, ng-repeat and even ng-controller and ng-app. With a directive you can add hooks into the HTML markup for injecting your own custom markup and logic. As the AngularJS documentation puts it, "directives are a way to teach HTML new tricks." They help you keep your markup declarative and intention-revealing, and your implementation packaged away in a self-contained, reusable components.

Here's how we'd want a click to edit directive to look in practice:

<div click-to-edit="someModelName"></div>

We want the click-to-edit attribute to bind to a model in the current scope (just like ng-model does) and have that entire <div> replaced with the markup needed to show the field. Here's the directive to do it:

app = angular.module("formDemo", []);

app.directive("clickToEdit", function() {
    var editorTemplate = '<div class="click-to-edit">' +
        '<div ng-hide="view.editorEnabled">' +
            '{{value}} ' +
            '<a ng-click="enableEditor()">Edit</a>' +
        '</div>' +
        '<div ng-show="view.editorEnabled">' +
            '<input ng-model="view.editableValue">' +
            '<a href="#" ng-click="save()">Save</a>' +
            ' or ' +
            '<a ng-click="disableEditor()">cancel</a>.' +
        '</div>' +
    '</div>';

    return {
        restrict: "A",
        replace: true,
        template: editorTemplate,
        scope: {
            value: "=clickToEdit",
        },
        controller: function($scope) {
            $scope.view = {
                editableValue: $scope.value,
                editorEnabled: false
            };

            $scope.enableEditor = function() {
                $scope.view.editorEnabled = true;
                $scope.view.editableValue = $scope.value;
            };

            $scope.disableEditor = function() {
                $scope.view.editorEnabled = false;
            };

            $scope.save = function() {
                $scope.value = $scope.view.editableValue;
                $scope.disableEditor();
            };
        }
    };
});

Let's break this down a little. Right at the top we have its name: "clickToEdit." Angular directives use camelCase names in the JavaScript, which are matched to their name-with-dashes equivalent in HTML. Next, you'll see we've provided an HTML template string for the editor. It can use all of the same Angular-provided facilities that you'd otherwise use in your own page templates.

The directive function must return an object that specifies its behaviour and configuration. In our case, we use restrict: "A" to specify that the directive will only match HTML attributes, and replace: true to have the directive's template entirely replacing any elements that it matches.

We also specify a custom scope for the directive. You'll want to at least provide an empty object here if you want your directives to have their own independent scope, but you can also use this scope object to create a mapping from the HTML element's attributes to varaibles in the directive's scope. In our case, value: =clickToEdit sets up a bi-directional binding between the value in the directive's scope and the value of the click-to-edit attribute in the parent scope.

The last thing we provide in the directive configuration is a controller that provides logic to back the directive's template. This packaging of a template and its behaviour is what can turn a directive into a truly reusable component. The controller here is just a direct copy of all the code from my earlier post, but because it's packaged along with the directive, we've now made it reusable.

How would it look in practice? We might have something like a LocationFormCtrl controller that manages the editable form for a "location" record:

app.controller("LocationFormCtrl", function($scope) {
    $scope.location = {
        state: "California",
        city: "San Francisco",
        neighbourhood: "Alamo Square"
    };
});

And if we wanted to make each of these attributes have a "click to edit" field, it's now as simple as this:

<div ng-controller="LocationFormCtrl">
    <h2>Editors</h2>
    <div class="field">
        <strong>State:</strong>
        <div click-to-edit="location.state"></div>
    </div>
    <div class="field">
        <strong>City:</strong>
        <div click-to-edit="location.city"></div>
    </div>
    <div class="field">
        <strong>Neighbourhood:</strong>
        <div click-to-edit="location.neighbourhood"></div>
    </div>
</div>

Straightforward, declarative, no-cruft. This is how Angular can transform your apps, and directives play a big part in that.

To give you something to play with, here's a working demo. Below the editable fields, we also directly print the LocationFormCtrl scope's location attributes, so you can see how changes of the attributes in the fields propogate back up to the parent controller's scope.

Of course, there's even more we could do. Right now, this directive only deals with straightforward <input type="text"> fields. What about if you wanted <input type="date"> or even a <select>? This is where you could add some extra attribute names to the directive's scope, like fieldType, and then change some elements in the template based on that value. Or for full customisation, you could even turn off replace: true and add a compile function that wraps the necessary click to edit markup around any existing content in the page. I'll leave both of these as exercises to the reader, for when it's time to level up yet again!