ng-cabled
v4.0.5
Published
Library for ease of creating extensible angular components
Downloads
99
Readme
Ng Cabled
Or, extending Angular components the proper way.
This library contains a few decorators which will make extending components practical and easy.
Install
npm install --save ng-cabled
Background
Why is there a need
Unfortunatelly, Angular is not really oriented via extending components or services. I heard a lot of times the question "why do you need to extend a component?" during some interviews or during working on a project. Well, every time the answer is quite simple: there are lots of cases where inherinting from a base component is very usefull:
- I want to have a unified API (I want all my components for example to use a label input or to use an ID input). Also, I want this ID to be initialized by default with an UID if not defined by the user.
- I want proper observables subscriptions handling without a lot of boilerplate code and of course without repeating myself over and over again (and yes, I also want to unsubscribe even if I know that the service that I'm subscribing to is just a REST call).
- I want to have some common behavior (for example I would like a dropdown component and an autocomplete component to have a common behaviour regarding the data source and the sharing of the model).
- I want to be able to define abstract components or (more important) abstract services.
These are just a few examples where inheritance is the most straight forward solution.
Counter-arguments / other options / ...
Between the arguments that I've heard since I'm doing Angular development, the only valid one was "use object composition". The others like finding different more complicated solutions just because the code would otherwise be more complicated are not even worth discussing. I'm not going to discuss the reasoning that because some misterious reason OOP is evil.
Regarding object composition, I've tried this and it poses one major issue: when it comes to modern developing tools, you are left on your own. There is no IDE nor LSP server for angular which will properly interpret something like this:
const A = {
method1(): void{
...
}
}
const B = {
method2(): void{
...
}
}
const c = Object.create(Object.assign({}, A, B));
Following such a code, c
will be of type any
, because Object.create
returns any
. So, good luck finding definitions, references, and so on. Of
course, a solution would be to have a lot of interfaces, but this is a poor
solution compared with just extending the damn class. That is the
straightforward solution. So, until the IDE's, the tools and the typescript
language will catch up with this, object composition is simply not a useful
practical solution to the problems enumerated previously.
Challenges
But, when extending components and services in Angular, you are facing some major issues:
- Due to the dependency injection made via constructors (one of the worst patterns ever invented, but this is another discussion that you can see here) you need to know all the private dependencies of the component or service you want to extend.
- A parent component implementing one of the life cycle hooks (like
ngOninit
) would risk that hook being overwritten in a child class and so having its behaviour broken.
ng-cabled solution
In order to solve this issues, this library provides a few decorators and a few base classes.
DecoratedClass
and Cabled
annotations
These two annotations will help with dependency injection. They will be
responsible for injecting dependencies in a class. Use the DecoratedClass
annotation on top of all the classes that should contain any of the decorators
provided by the library. This will tell the compiler to check the class for
dependencies. And then, you add the Cabled
annotation on all the properties
that should be automatically injected.
Normal Angular way
export class ParentService {
constructor(private _parentPrivateService: ParentPrivateService) {}
}
@Injectable({
...
})
export class ChildService extends ParentService {
constructor(_baseClassPrivateService: ParentPrivateService, private _childPrivateService: ChildPrivateService) {
super(_baseClassPrivateService);
}
}
ng-cabled way
@DecoratedClass
export class ParentService {
@Cabled(ParentPrivateService) private _myPrivateService: ParentPrivateService;
}
@Injectable({
...
})
@DecoratedClass
export class ChildService extends ParentService {
@Cabled(ChildPrivateService) private _childPrivateService: ChildPrivateService;
}
Notice how the ChildService
now does not know and does not care about the
internal private dependencies of ParentService
(i.e. the private
ParentPrivateService
).
The first argument of the Cabled
annotation can be any Angular accepted
dependency injection token (it can be a class, it can be an InjectionToken
,
it can be a string etc.).
Also, you can pass a second argument to the Cabled
annotation which would
represent the default value. This would make the injection optional. If the
token is not solved by the dependency injection, no error is thrown and the
property is initialized with the given default value.
The only issue this poses is that you have no way to know in which order the
services will be instantiated. So, you can't use constructors for anything (so
to let any injected service get instantiated). If you need to run some
initialization code for a given service, just move that code in a private
method annotated with the PostConstruct
annotation like this:
@Injectable()
@DecoratedClass
export class MyBaseService {
@PostConstruct()
private _init() {
// Here comes the initialization code that normally would've been run
// in the constructor:
...
}
}
The PostConstruct
annotation will not work on components (is meant to be
used only for services). For components see the next paragraph (basically you
should use life cycle hooks).
DecoratedClass
usage
Decorating classes with DecoratedClass
annotation will work without any
issues on singleton services on the current version of Angular and on future
versions.
Since the components, as you will see bellow, will all extend the
BaseComponent
class, they do not need the DecoratedClass
annotation. For
example:
@Component({
...
})
export class MyComponent extends BaseComponent {
@Cabled(MyService) private _service: MyService;
}
The services will be injected properly after the PostConstruct
is run.
When it comes to pipes and multiple instance services (like services provided
in components, although I would suggest to use pipes), for the moment you can
use the DecoratedClass
annotation. This will work for any Angular version
from 9 to 13.
However, due to this bug,
the DecoratedClass
annotation might stop working in the future for pipes and
multiple instances services. In this case, if you want to be bullet proof, use
the CabledClass
. Instead of annotating the pipes with DecoratedClass
, you
can extend the CabledClass
class.
Bullet proof (will work in any angular version)
@Pipe(...)
export class MyPipe extends CabledClass {
@Cabled(MyService) private _service: MyService;
public transform(...) {
...
}
}
Angular 9 to 13 works for sure, not guaranteed to work in the future
@Pipe(...)
@DecoratedClass
export class MyPipe {
@Cabled(MyService) private _service: MyService;
public transform(...) {
...
}
}
When extending the CabledClass
, you don't need to decorate the class with
DecoratedClass
.
NgCycle
annotation
This annotation will solve the second problem of extending components: when using any of the lifecycle hooks, they can be later overriden in a child component.
export class ParentComponent implements OnInit {
@Input() public id: string;
ngOnInit() {
if (!id) {
this.id = UUID();
}
}
}
export class ChildComponent extends ParentComponent implements OnInit {
private _myProperty: string;
ngOnInit() {
super.ngOnInit(); //problematic - see below
this._myProperty = 'value';
}
}
Notice how now the behaviour of the component is changed if you don't call
super.ngOnInit()
. The one who extends the ParentComponent
needs to know to
call super.ngOnInit()
in ChildComponent
's ngOnInit
. Of course, when you
have to deal with big development teams, such a mistake could slip in the code
resulting in unexpecting behaviour.
The solution to this problem is given by the NgCycle
annotation. When using
this library, every component should extend BaseComponent
class inside the
package. The BaseComponent
contains the ngOnInit
, ngOnDestroy
,
ngAfterViewInit
and ngOnChange
methods as private. So, there is no danger
of overriding those methods in a child class, since that would result in a
compilation error.
But this means that you also can't use them in ChildComponent
. A better of
way of implementing lifecycle hooks (that Angular should've implemented
itself) is via an annotation. So, instead of using any of those 4 lifecycles,
use the NgCycle
annotation with any of the arguments: 'init'
,
'afterViewInit'
, 'destroy'
or 'change'
. Those method will be run on each
of the respective cycle. The example from above can be rewritten safely and
more elegant like this:
export class ParentComponent extends BaseComponent {
@Input() public id: string;
@NgCycle('init')
private __initParentComponent__() {
if (!id) {
this.id = UUID();
}
}
}
@Component({
...
})
export class ChildComponent extends ParentComponent {
private _myProperty: string;
@NgCycle('init')
private __initChildComponent__() {
this._myProperty = 'value';
}
}
Problem solved. No danger of overwritting the parent class method and breaking the expected behaviour.
Bonus: watching for input changes
How many times did you write or encounter this in your angular applications?
@Component({...})
export class MyComponent implements OnChange {
ngOnChange(changes: SimpleChanges) {
if (changes['input1']) {
// do stuff related with input1
}
if (changes['input2']) {
// do stuff related with input2
}
}
}
This looks ugly, right?
Check out a better way of doing it via NgCabled
, using the Watcher
annotation:
@Component({...})
export class MyComponent extends BaseComponent {
@Watcher('input1')
private _input1Changed(c: SimpleChanges) {
// do stuff related to input1 changing.
}
@Watcher('input2')
private _input2Changed(c: SimpleChanges) {
// do stuff related to input2 changing.
}
}
Much, much nicer, right? You can also watch for more than one input changing, like this:
@Component({...})
export class MyComponent extends BaseComponent {
@Watcher('input1')
@Watcher('input2')
private _input1or2Changed(c: SimpleChanges) {
// do stuff related with input1 or input2 changing.
}
}
Bonus: handling of observables
BaseComponent
brings as bonus the handling of the observables subscription.
By extending BaseComponent
and using BaseComponent::connect
function
instead of subscribing to observables you can forget about observables and
unsubscribing from them.
Angular way of handling subscriptions
@Component({
...
})
export class MyCoolComponent implements OnDestroy, OnInit {
private _subs: Array<Subscription> = [];
constructor(private _myService: MyService, private _mySecondService: MySecondService){}
ngOnInit() {
this._subs.push(
this._myService.observable$.subscribe(() => this._doStuff()),
);
this._subs.push(
this._mySecondService.otherObservable$.subscribe(() => this._doOtherStuff()),
)
}
ngOnDestroy() {
this._subs.forEach(s => s.unsubscribe());
}
}
Notice how ugly this is and also notice all the boilerplate code that we need to write in each component (this code has to appear in each component subscribing to an observable). Ugh... Ugly, ugly, ugly.
Now, compare this ugly thing with the following beauty:
Ng-cabled way
@Component({
...
})
export class MyCoolComponent extends BaseComponent {
@Cabled(MyService) private _myService: MyService;
@Cabled(MySecondService) private _mySecondService: MySecondService;
@NgCycle('init')
private _init() {
this.connect(
this._myService.observable$,
() => this._doStuff(),
);
this.connect(
this._mySecondService.otherObservable$,
() => this._doOtherStuff(),
)
}
}
Ta daaaaa!!!! This is it. Nice, right? No boiler plate code, no worries about unsubscribing.
Also, you can have a third parameter to the BaseComponent::connect
function,
in case you want to group the subscriptions to manually unsubscribe from them
at a certain point, like this:
const SUBSCRIPTION_TYPE = 'my-separate-subscription';
@Component({
...
})
export class MyCoolComponent extends BaseComponent {
@Cabled(MyService) private _myService: MyService;
@NgCycle('init')
private _init() {
this.connect(
this._myService.observable$,
() => {
// If we have any subscriptions already running,
const subs = this.getSubscriptionsByType(SUBSCRIPTION_TYPE);
// Unsubscribe from them
subs.forEach(s => s.unsubscribe());
// Then subscribe again to the other observable
this.connect(
this._myService.otherObservable$,
() => this._doStuff(),
// And group the subscription.
SUBSCRIPTION_TYPE,
);
}
)
}
}
Yes, I know, I know, I could've used a switchMap
for this example. But this
is just an example to give you an idea. Maybe there are scenarios where you
can't use a switchMap
, I don't know. And fine. If you really think that
there is no other use case, just don't use this feature. You can still use the
other beauties of this library, right? And I'm sure for those you don't have
such a strong counter argument. You see?
BaseModule
In order to use all these beauties, you only need in each of your module to
pass the Injector
to a parent module. So, in order for these annotations to
function, all your modules need to extend BaseModule
and pass the Injector
like this:
@NgModule({
...
})
export class MyCoolModule extends BaseModule {
constructor(inj: Injector) {
super(inj);
}
}
That is it. This is the only price (including no money) that you have to pay to use all these marvels that I just presented to you.
Helping ng-cable
When using the BaseModule
, there is a second parameter that you can pass to
ng-cabled
. The second parameter represents the list of providers. This is
not mandatory. If you don't pass it, the ng-cabled
library will try to
retrieve it from the angular module. The problem with this is that this is not
a public API. So, it might fail in a future version.
Of course, I will try to maintain this also in future versions, but since it's not using a public API, it might stop working without any deperecation notices. If you want to be sure you won't be experiencing any issues, you can set your module like this:
const PROVIDERS = [
MyService1, MyService2, ... // list is my list of providers
];
@NgModule({
...
providers: PROVIDERS,
...
})
export class MyModule extends BaseModule {
constructor(inj: Injector) {
super(inj, PROVIDERS);
}
}
Notice that I write the list of providers only once, and I also pass it to
ng-cabled
. Like this, I am making sure ng-cabled
is not using Angular
private API's.
Good luck.
Testing
ng-cabled
provides also testing via annotations using ng-cabled/testing
.
In my oppinion, describe
, it
, xit
and fit
are very verbose. I don't
like all the boilerplate code that needs to be written to set the test bed.
ng-cabled
provides some annotations that will help testing: NgTestUnit
and
NgTest
, FNgTest
and XNgTest
.
Another priciple that I follow when writting unit test cases is that I don't
do unit test cases for components. For testing component we have the e2e
testing. If you need to test the component with a unit test, it means that you
did it wrong. According to the MVC
principles, the component (which can be
seen as the controller) has to be a dumb class. It should just take values
from the services and pass them to the view.
I received quite a lot of times the question: "but if you don't test
components, how are you going to test for example that something is displayed
when using an ngIf
".
Well, if the component is well designed, that ngIf
should be something very
simple like *ngIf="value"
and that value should come from a service, or
something like *ngIf="model | myPipe"
. What you need to test in an unit test
is the respective service or the pipe. After that you will trust Angular to do
it's job. Testing the fact that the element is displayed in the DOM would mean
actually testing that Angular is doing it's job. I'm sure they have their own
test cases, we don't need to build new ones.
So, to recap. If you build your components properly and you don't need to unit
test them, if you only test the services in isolation, then you can use the
annotations provided by ng-cabled/testing
.
NgTestUnit
This should annotate a test unit. It takes as a parameter an array of
providers. Basically, instead of using configureTestingModule
, you pass the
providers via the NgTestUnit
. Then, each method of the class, annotated with
NgTest
is a test case. For example, the following:
@NgTestUnit([
MyService1, MyService2
])
export class MyFirstTestUnit {
@Cabled(MyService1) private _service1: MyService1;
@Cabled(MyService2) private _service2: MyService2;
@NgTest()
public testCase1() {
...
}
@NgTest()
public testCase2() {
...
}
}
would be translated like this:
describe('...', () => {
let _service1: MyService1;
let _service2: MyService2;
beforeAll(() => {
TestBed.configureTestingModule({
providers: [MyService1, MyService2],
});
_service1 = TestBed.get(MyService1);
_service2 = TestBed.get(MyService2);
});
it('runs test case 1', () => {
...
});
it('runs test case 2', () => {
...
});
})
Personally, I prefer to write it the java way, using a simple annotated class.
The methods annotated with XNgTest
or FNgTest
are translated using xit
or fit
instead of it
.
In order to use these, you need to include all the specs containing the test
units and then to call the method startTesting
from ng-cabled/testing
.
For an example, check the file main.test.ts
:
import { startTesting } from 'ng-cabled/testing';
import './testing/decorators.spec';
startTesting();
Another advantage that you have if you build your components properly, is that you don't need karma or a browser (meaning a DOM) to run the unit tests. You can run them as a simple node process.
If you are interested in how to do this, check out the angular.json
file of
ng-cabled
. See the "js" application. Or you can simply build them with
tsc
. However, running the tests with tsc
will probably require babel
.
Even though you will not be testing the components, it is possible that some
services will call in some DOM parts which will fail in node. So you will need
Babel
to remove those parts. If you don't want to complicate your life with
Babel
, see the job "test" from the package.json
of ng-cabled
.