Google analytics script

Latest jQuery CDN with code tiggling.

Friday, 30 January 2015

Data binding a single shared view to different controllers using "ControllerType as instanceName" syntax

If you haven't already I strongly suggest you first read John Papa's AngularJS styleguide. It's a magnificent document of an evolving set of AngularJS development best practices. Among them there's also one that says to abolish $scope use and rather provide view model data as part of controller instance because view bindings become more contextual amid other reasons. To accomplish this you need to use the ControllerType as instanceName syntax. This is usually a blessing but sometimes it may seem to be a curse especially when you give controllers contextual instance names (i.e. UserController as user) instead of some common name (i.e. UserController as vm). This is especially useful if you're nesting controllers and don't want to access parent controllers using scope's $parent property.

Contextual instance naming plays along nicely until you introduce shared views. Now when you want to bind your shared view to a controller instance you don't really know its name. It can be any controller instance name that will be using this shared view. Now what?

If you strictly follow John Papa's guidelines, this is trivial as he suggests to always name controller contexts vm (shorter for view model). In this case you know that controller's context name is always the same so it will simply work. But this somehow beats the purpose of having contextual values to some degree. What if we don't name them vm? What if we rather give controllers better contextual instance names like QuestionController as question and AnswerController as answer? What happens when we use a shared view that displays comments? It should of course bind to each of these as both have an array of comments?

What do we need for our scenario

If we continue on the upper premise we have two controllers

  • QuestionController and
  • AnswerController
where they both have some properties of their own but also a matching comments array property. Then we also need three views:
  • shared comments.html view that displays comments,
  • question.html details view that displays question specific properties and includes comments view and
  • answer.html details view that works analogous to question details view

If you're in a particular hurry or you came here just to see the code go and run my Plunkr example where this is implemented. It's simplified to the very bone so it shouldn't be too hard or too long to comprehend.

Comments shared view

This is a simplified code example of the shared comments view

   1:  <ul>
   2:      <li ng-repeat="comment in context.comments">
   3:          <span ng-bind="comment"></span>
   4:      </li>
   5:  </ul>

As you can see we're binding to some arbitrary context property that should represent our controller instance context. So instead of saying question or answer we rather use something common. I used context but you may use whatever seems suitable for your needs.

Note: As you'll see later where this context gets created it would be much better for this specific example that comments view would rather consume comments directly and not whole controllers. It would make more sense and we could use the same technique to accomplish for the same. I encourage you to only pass through as much data as is relevant for the included view. But let's stick to this example where we need to bind to various controller instance names.

The magic

Now that we have our shared view we'll have to use some common AngularJS binding attributes that we normally don't use aside ng-include directive. The thing is that we need to map our controller instance to the common context scope value so the shared view template can use it. So we'll create and initialize a new scope variable with appropriate name that shared view uses.

Views question.html and answer.html will have an include of the comments shared view with this definition: <div ng-include="'comments.html'" ng-init="context = question"></div>

So what have we done here? We've simply (created and) initialized a new scope variable context and set its value to controller instance. Shared comments view is therefore able to access our controller regardless of its instance name as we're assigning it to a common scope variable in a non-shared view (question and answer view).

What are the main benefits

The main benefit is that we don't have to deal with this in our controllers' code. Just create controllers and views as you usually do. The only additional thing that needs to be done is mapping on the ng-include element where you have to initialize the shared view's common context scope variable. This means that this is completely code independent and done in view-only on the shared view insertion point.

But this can't work everywhere, can it?

Of course not. This is applicable to ng-included shared views. There are two caveats I would like to point out though:

  1. You can't use the same trick with routing; if you define two routes with a shared view but different controller as instance names, you can't simply map controller instance to an additional scope variable without doing it in controller code itself (may be dynamic with hints from route definition resolve property but still)
  2. I've italicized "completely code independent" in the upper text because if you already have a scope property with the same name as this common controller instance name we're mapping to, you'll end up with variable value overriding if nothing else

These caveats may put you off using this technique, but if not, I hope it's helpful. The proper way would of course be to componentize shared view as a directive with its own isolated scope as can be seen in this blog post.

Let's see some code

As linked earlier in the post this is a working example on Plunkr. I'll add just the important bits, leaving CSS out and stuff that's superfluous to understanding this concept. I'm not going to show you the main page HTML that has the ng-view, because that's trivial and you can also check it on Plunkr.

I've already shown a stripped down version of the shared comments view. SO the following are going to be other missing parts. Let's first see routing configuration.

   1:  $routeProvider
   2:      .when("/question", {
   3:          templateUrl: "question.html",
   4:          controller: "QuestionController as question"
   5:      })
   6:      .when("/answer", {
   7:          templateUrl: "answer.html",
   8:          controller: "AnswerController as answer"
   9:      })
  10:      .otherwise({
  11:          redirectTo: "/question"
  12:      });
I only define two routes. The important part is that they both point to their own specific view template which means that they don't share the general view, but only part of it within.

So these are the view templates question and answer.

   1:  <h1>Question</h1>
   2:  <h2 ng-bind="question.title"></h2>
   3:  <p ng-bind="question.body"></p>
   4:  <div ng-include="'comments.html'" ng-init="context = question"></div>
   5:  <div>This question has <span ng-bind="question.answers.length"></span> answer(s).</div>
   1:  <h1>Answer</h1>
   2:  <h2>This is an answer to <em ng-bind="answer.question.title"></em></h2>
   3:  <p ng-bind="answer.body"></p>
   4:  <div ng-include="'comments.html'" ng-init="context = answer"></div>

The outstanding part now are only the controllers.

   1:  function QuestionController() {
   2:      this.title = "How do we do this anyway?";
   3:      this.body = "I was wondering how can I solve this thing using my brain only? It seems my brain isn't enough hence the question.";
   4:      this.comments = [
   5:          "I think this has already been answered elsewhere.",
   6:          "Please provide a source link.",
   7:          "Here you go: http://some.place.com/else",
   8:          "Thanks, but that resource is dealing with a similar yet different problem."];
   9:      this.answers = [{
  10:          body: "This is the answer to your problems.",
  11:          comments: [
  12:              "I don't think so.",
  13:              "Well maybe.",
  14:              "I'm pretty sure you're on the right track.",
  15:              "Brilliant solution! +1"]
  16:      }];
  17:  }
   1:  function AnswerController() {
   2:      this.question = {
   3:          title: "What's the answer to the ultimate question of life, the universe, and everything?"
   4:      };
   5:      this.body = "The answer is 42.";
   6:      this.comments = [
   7:          "Are you sure, you've calculated it right?",
   8:          "Well I've invested a week into this and tripple-checked my results.",
   9:          "Seems legit, thanks..."];
  10:  }

1 comment: