Using Angular 5 and UIRouter state routing. I'm using an additional custom route state property as per this interface.
interface AugmentedNg2RouteDefinition extends Ng2StateDeclaration {
default?: string | ((...args: any[]) => string | Promise<string>);
}
When I define an abstract state, I can now add a default
property to it as well, so when one would try to route to an abstract state, the default should redirect them to configured default child state.
As can be understood from the interface above, the default
may be defined as any of the following:
// relative state name
default: '.child',
// absolute state name
default: 'parent.child',
// function with DI injectables
default: (auth: AuthService, stateService: StateService) => {
if (auth.isAuthenticated) {
return '.child';
} else {
return stateService.target('.login', { ... });
}
}
// function with DI injectables returning a promise
default: (items: ItemsService) => {
return items
.getTotal()
.then((count) => {
return count > 7
? '.simple'
: '.paged';
});
}
To actually make the default
work, I have to configure route transition service:
@NgModule({
imports: [
...
UIRouterModule.forChild({ // or "forRoot"
states: ...
// THIS SHOULD PROCESS "default" PROPERTY ON ABSTRACT STATES
config: (uiRouter: UIRouter, injector: Injector, module: StatesModule) => {
uiRouter.transitionService.onBefore(
// ONLY RUN THIS ON ABSTRACTS WITH "default" SET
{
to: state => state.abstract === true && !!state.self.default
},
// PROCESS "default" VALUE
transition => {
let to: transition.to();
if (angular.isFunction(to.default)) {
// OK WE HAVE TO EXECUTE THE FUNCTION WITH INJECTABLES SOMEHOW
} else {
// this one's simple as "default" is a string
if (to.default[0] === '.') {
to.default = to.name + to.default;
}
return transition.router.stateService.target(to.default);
}
}
);
}
})
]
})
export class SomeFeatureModule { }
So the problem is invoking the default
when it's a function that likely has some injectable services/values...
Configuration function's injector (config: (uiRouter: UIRouter, injector: Injector, module: StatesModule)
) can only be used to get service instances, but can't invoke functions with injectable parameters.
In AngularJS, this would be accomplished by $injector.invoke(...)
which would call the function and inject its parameters.
The main question
How should I handle default
when it's defined as a function with injectables.
There is no direct counterpart to AngularJS $injector.invoke
in Angular, because injectable functions are expected to be useFactory
providers that are defined on design time.
There is only one injector instance in AngularJS but a hierarchy of injectors in Angular, this also complicates things because a dependency is supposed to exist on injector that invokes a function.
The idiomatic way to handle this this is to define all functions that are expected to be invoked as providers. This means that a function is restricted to use instances from injector it was defined on (root or child module):
export function fooDefaultStateFactory(baz) {
return () => baz.getStateName();
}
@NgModule({
providers: [
Baz,
{
provider: fooDefaultStateFactory,
useFactory: fooDefaultStateFactory,
deps: [Baz]
}
],
...
})
...
// relative state name
default: '.child',
...
// function with DI injectables
default: fooDefaultStateFactory
Then factory functions can be retrieved as any other dependencies from injector and called:
transition => {
...
if (typeof to.default === 'string') {
...
} else if (to.default) {
const defaultState = injector.get(to.default);
if (typeof defaultState === 'function') {
// possibly returns a promise
Promise.resolve(defaultState()).then(...)
} else { ... }
}
}
A counterpart to $injector.invoke
that works with any function should loosely resemble how constructor definition works in Angular 2/4 Class
helper (deprecated in Angular 5). The difference is that Class
accepts constructor function that is annotated either with array or parameters
static property, the annotations are expected to be an array of arrays because dependencies can involve decorators (Inject
, Optional
, etc).
Since decorators aren't applicable to a function that wasn't registered as provider, the array is expected to be plain, similarly to AngularJS implicit annotations or deps
in Angular useFactory
provider:
function invoke(injector, fnOrArr) {
if (Array.isArray(fnOrArr)) {
const annotations = [...fnOrArr];
const fn = annotations.pop();
const deps = annotations.map(annotation => injector.get(annotation));
return fn(...deps);
} else {
return fnOrArr();
}
}
Can be bound to injector instance:
const injectorInvoke = invoke.bind(injector);
injectorInvoke([Foo, Bar, (foo: Foo, bar: Bar) => {...}]);
And the snippet that invokes a function is modified to:
...
if (typeof defaultState === 'function' || Array.isArray(defaultState)) {
// possibly returns a promise
Promise.resolve(injectorInvoke(defaultState)).then(...)
} else { ... }
...
This is one way you can solve this with added part about resolving services.
// THE IMPORTANT PART
config: (uiRouter: UIRouter, injector: Injector, module: StatesModule) => {
uiRouter.transitionService.onBefore(
// HookMatchCriteria
{
to: state => state.abstract === true && !!state.self.default
},
// TransitionHookFn
transition => {
let to: transition.to();
if (typeof to.default === "string") {
return transition.router.stateService.target(to.default);
} else if (typeof to.default === "function") {
let functionPromise = Promise.resolve(to.default(injector));
return functionPromise.then((toDefault) => transition.router.stateService.target(toDefault));
}
}
The first part of checking if the default is string
is obvious.
In the second if
I check if the parameter is a function this automatically treats it as function in TypeScript so it can be called directly. On the result which can be a Promise<string>
or string
i then use the Promise.resolve(value) method. This method returns a new Promise<string>
for both inputs so then we can just chain it with the call to the stateService and it will return a Promise<TargetState>
which is a valid return for the TransitionHookFn.
For resolving the services you can change you interface like this:
interface AugmentedNg2RouteDefinition extends Ng2StateDeclaration {
default?: string | ((injector: Injector, ...args: any[]) => string | Promise<string>);
}
Then you also send the injector always when calling the function since you have it in the config method. If all the arguments are expected to be services then remove the ...args
an just send the injector.
Then the functions you gave as a sample would then look like this:
// relative state name
default: '.child',
// absolute state name
default: 'parent.child',
// function with DI injectables
default: (injector: Injector) => {
let auth: AuthService = injector.get(AuthService);
let stateService: StateService = injector.get(StateService);
if (auth.isAuthenticated) {
return '.child';
} else {
return stateService.target('.login', { ... });
}
}
// function with DI injectables returning a promise
default: (injector: Injector) => {
let items: ItemsService = injector.get(ItemsService);
return items
.getTotal()
.then((count) => {
return count > 7
? '.simple'
: '.paged';
});