AngularJS Component Directives
Marko V
Posted on March 5, 2020
A little background... Our application started way back when AngularJS was in it's infancy and it's SPA functionality and routing left much to desire. So we started off using AngularJS as just a binding framework ontop of MVC Views. Now we managed to create a lot before angularjs became manageable as a SPA framework, but by then, it would mean a lot of work to get it to be a SPA so we just skipped it and Angular 2 (TS) was just around the corner.
Years later...
Time has passed and management saw little use in updating the frameworks and keeping upto date, pushing for new features and functionality. Our technical debt increased. Now finally, after a few years, we have managed to convince management that certain efforts are required before we get to a point where we can't continue due to outdated frameworks and no support in the platform.
And now...
Part of that migration to Angular 8 and SPA, is an intermediary step to create AngularJS component directives for everything meaning all controllers, all directives, are converted. Services are services, just using the factory method to implement them. That last one was thankfully an easy conversion.
Our angular 1 controllers were HUGE. One controller could provide data, and juggle views between a small dashboard view, a list view and a detail view... all in one. And one big (CS)HTML file.
Now, when approaching Component Directives, we're going the opposite... as small as possible and as many reusable components as possible. One of these components that I just made, is a toggle component. We have something called featuretoggles in our application which means that product owners can pick and choose which parts of the developed application they want to activate in their own instance.
Now we used to use razor to select out which parts to show based on those featuretoggles, but now all razor stuff is moved out and refactored into api endpoints that the clientside can use. We have an angularjs service that provides the data and simple functions for lookups and other helper functions.
However, until recently, we used the service in just about every component we have and reused the service functions to find, check and choose paths regarding whether or not to activate a child-component based on the toggles used.
Now I abstracted this into it's own component using something called transclusion in angular.
So, what is transclusion? It is a way to make your component accept content inside it's tags and dedicate a location for that content inside your template.
Example;
<toggle>
<h1>Internal content in toggle-component</h1>
</toggle>
angular.module('myApp.shared').component("toggle", {
transclude: true,
template: `
<div ng-if="$ctrl.error !== \'\'">{{$ctrl.error}}</div>
<ng-transclude ng-if="$ctrl.sumToggle"></ng-transclude>
`,
...
});
So let's break this up in case you haven't encountered a component directive before.
angular.module(String name, String[] dependencies)
angular.module('myApp.shared')
This hooks into angular, telling it that we are about to register a component that belongs to the module "myApp.shared" and also that the myApp.shared is defined elsewhere with it's core dependencies because we are not providing it here. If we were, it would be a secondary parameter to the function containing an array of strings representing other modules that this module would be dependent upon.
Our convention is to register these in the angularApp.js bootstrapping script.
component(String name, Options opt)
component("toggle", { ... })
This registers the component that will be named "toggle" to the module defined previously. Now you can access this component as an element with the name provided. If you were to name it "featureToggle" (notice the camel case) you would be able to access that as <feature-toggle>. The camelcase makes it so that when using the element it itself requires kebab-case to invoke it. One of the quirks of angular. The secondary parameter to component function are the configurations for that component. Such as transclude, bindings, template (or templateUrl), controller and many more...
We will touch on just these but there are more. You will find details about them in the documentation(s) linked herein.
Component Option: transclude
Official documentation: https://docs.angularjs.org/api/ng/directive/ngTransclude
I've so far used transclude in two ways. One where it only says "true" which means it only has a single transclude directive in the template.
I've also used it as a multi-transclusion where we have multiple targets. An example of this would be to define for instance a header transclusion and a footer transclusion. Then you could direct content specifically for these areas in the component like this
<my-component>
<my-component-header>
<h1>This is content in the header transclusion</h1>
</my-component-header>
<!-- content that my template itself inserts and not shown in the parent DOM/component binding the my-component component-directive -->
<my-component-footer>
<h3>This is a footer content</h3>
</my-component-footer>
</my-component>
Phew, a lot of "component" in there, but it's just a sample. Now to achieve that, you would not just provide "true" but an object representing the ng-transclude targets. For the above example, it would look like
{
transclude: {
"header": "myComponentHeader",
"footer": "?myComponentFooter"
},
template: `
<ng-transclude ng-transclude-slot="header">
<!-- this is where h1 ends up -->
</ng-transclude>
<div>
<!-- content that is not visible in the parent component injecting this one -->
</div>
<ng-transclude ng-transclude-slot="footer">
<!-- this is where h3 ends up -->
</ng-transclude>
`
}
But... with our current example, we kept it simple and used only a single transclude directive in the template and thus also only needed "transclude: true"
Component Option: bindings
Official Documentation: https://docs.angularjs.org/guide/component#component-based-application-architecture
Do note that it says "bindings" not "binding" or "bind", it's "bindings". I know this, and yet I manage to do typos and then be left wondering why it's not working and getting undefined when trying to access the values I pass to the component.
So if you define bindings as such
{
bindings: {
debug: "<",
names: "<"
}
}
You will have a one-way binding for attributes to your component with the attribute-names "debug" and "names". Used like this;
<toggle debug="true" names="orders,!invoices"></toggle>
So now the binding properties are "magically" available to the controller of the component through "this.debug" and "this.names". However, due to javascript being javascript, I always defer "this" to it's own variable "self" that I can refer to even when I am deep inside nested blocks and scopes so that "this" isn't suddenly the window or document or such, so I refer to them as "self.debug" and "self.names" in my code.
You can pass function callbacks as bindings, so that you can make a on-update attribute that a parent component can bind a function to and you can just call that function inside your new component. One such functionality would be where you do some massaging of data based on user-input and perform a callback that treats the result as that parent component requires it. This is close to how you could use two-way binding, but then the child-component keps on getting updated even when you're not using it unless you despawn it through ng-if. They each have their use-cases so be sure to think things through first or revise it as needs arise. Try to not create hard dependencies from child components to parent components and vice versa. Keeping SOLID principles in mind.
Component Options: template
Now this is likely the simplest one. It's just the HTML string that you will use as a template for your component. Doing it inline will improve performance drastically. You can assign templateUrl option a url but then it will do an XHR request to get that html file when the component loads. So if you have a lot of components, it might take a while depending on the browser's capabilities and limitations. Some browsers only allow 8 concurrent XHR requests. Just an FYI.
Even worse is if you have a template that just adds a div with ng-include directive in it pointing to an HTML file. That will give you the worst of both worlds.
If you need to bind controller variables to the view in the template, you can access them, by default, with the $ctrl prefix like so "$ctrl.debug" much like "this.debug" or for my sake "self.debug" when in the controller itself. You can reassign $ctrl to something else if you like using the controllerAs option. But I prefer to keep it simple and maintain the default $ctrl.
Component Options: controller
The controller option takes a function as it's value. The function parameters provided would be services and providers that angular has available to it to inject based on the names of the parameters. So if you do function($http) it will inject the $http-provider native to Angular and you will be able to use it in your controller as any other variable passed as parameter to a function in javascript. Dependency Injection ftw.
controller: function (toggleService) {
var self = this;
///...
this.$onInit = function () {
self.togglesToFind = parseStringOrArray(self.names);
toggleService.initialized.then(function (toggles) {
for (var i = 0; i < self.togglesToFind.length; i++) {
var item = self.togglesToFind[i];
/// _redacted_
var foundToggle = toggleService.findToggle(toggles.data, item);
/// _redacted_
if (i === 0) self.sumToggle = foundToggle;
else self.sumToggle = self.sumToggle && foundToggle;
}
});
};
}
Now toggleService is being injected by angular into this controller. We use it inside the life-cycle event $onInit. This function, if defined in a controller, is called by angular when the component is being initialized. So this is the place to be when massaging inputs to outputs and views.
toggleService provides a promise that all interested parties can "await" before continuing using the data/functions that the service provides to ensure that the singleton service is done and available. The toggleService calls an external API to get data regarding the featuretoggles, so that's why we need to wait for it.
the toggleService also then provides a helper function to do lookup in the resulting toggle-data. We have sanitizied the "names" attribute/binding through the parseStringOrArray function defined in the controller, meaning we will always have an array of strings even if you only provide a single toggle, a comma separated list of toggles or an actual array of strings representing toggles. The redacted code was just allowing added logic-functionality to the inputs irrelevant to this blog post.
Summary
So it summarizes the feature toggles requested, and now looking back at the template, it will only show the content in the transcluded section IF we meet the requirements. Meaning we won't show the child-components if they have not been toggled on. Now we reduced a lot of code-repetition in each of the components by reusing this toggle-component instead. And it makes the html code much more legible.
Reference: https://docs.angularjs.org/guide/component
Posted on March 5, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 24, 2024