ui-router
Version:
State-based routing for Javascript
437 lines (388 loc) • 16.1 kB
text/typescript
/** @module ng1_directives */ /** for typedoc */
;
import {extend, map, unnestR, filter} from "../common/common";
import {isDefined, isFunction, isString} from "../common/predicates";
import {trace} from "../common/trace";
import {ActiveUIView} from "../view/interface";
import {Ng1ViewConfig} from "./viewsBuilder";
import {RejectType} from "../transition/rejectFactory";
import {TransitionService} from "../transition/transitionService";
import {parse} from "../common/hof";
import {ResolveContext} from "../resolve/resolveContext";
import {Transition} from "../transition/transition";
import {Node} from "../path/node";
import {Param} from "../params/param";
import {kebobString} from "../common/strings";
import {HookRegOptions} from "../transition/interface";
import {Ng1Controller} from "./interface";
/** @hidden */
export type UIViewData = {
$cfg: Ng1ViewConfig;
$uiView: ActiveUIView;
$animEnter: Promise<any>;
$animLeave: Promise<any>;
$$animLeave: { resolve(); } // "deferred"
}
/**
* `ui-view`: A viewport directive which is filled in by a view from the active state.
*
* @param {string=} name A view name. The name should be unique amongst the other views in the
* same state. You can have views of the same name that live in different states.
*
* @param {string=} autoscroll It allows you to set the scroll behavior of the browser window
* when a view is populated. By default, $anchorScroll is overridden by ui-router's custom scroll
* service, {@link ui.router.state.$uiViewScroll}. This custom service let's you
* scroll ui-view elements into view when they are populated during a state activation.
*
* *Note: To revert back to old [`$anchorScroll`](http://docs.angularjs.org/api/ng.$anchorScroll)
* functionality, call `$uiViewScrollProvider.useAnchorScroll()`.*
*
* @param {string=} onload Expression to evaluate whenever the view updates.
*
* A view can be unnamed or named.
* @example
* ```html
*
* <!-- Unnamed -->
* <div ui-view></div>
*
* <!-- Named -->
* <div ui-view="viewName"></div>
* ```
*
* You can only have one unnamed view within any template (or root html). If you are only using a
* single view and it is unnamed then you can populate it like so:
* ```
*
* <div ui-view></div>
* $stateProvider.state("home", {
* template: "<h1>HELLO!</h1>"
* })
* ```
*
* The above is a convenient shortcut equivalent to specifying your view explicitly with the {@link ui.router.state.$stateProvider#views `views`}
* config property, by name, in this case an empty name:
* ```js
*
* $stateProvider.state("home", {
* views: {
* "": {
* template: "<h1>HELLO!</h1>"
* }
* }
* })
* ```
*
* But typically you'll only use the views property if you name your view or have more than one view
* in the same template. There's not really a compelling reason to name a view if its the only one,
* but you could if you wanted, like so:
*
* ```html
*
* <div ui-view="main"></div>
* ```
*
* ```js
*
* $stateProvider.state("home", {
* views: {
* "main": {
* template: "<h1>HELLO!</h1>"
* }
* }
* })
* ```
*
* Really though, you'll use views to set up multiple views:
* ```html
*
* <div ui-view></div>
* <div ui-view="chart"></div>
* <div ui-view="data"></div>
* ```
*
* ```js
* $stateProvider.state("home", {
* views: {
* "": {
* template: "<h1>HELLO!</h1>"
* },
* "chart": {
* template: "<chart_thing/>"
* },
* "data": {
* template: "<data_thing/>"
* }
* }
* })
* ```
*
* Examples for `autoscroll`:
*
* ```html
*
* <!-- If autoscroll present with no expression,
* then scroll ui-view into view -->
* <ui-view autoscroll/>
*
* <!-- If autoscroll present with valid expression,
* then scroll ui-view into view if expression evaluates to true -->
* <ui-view autoscroll='true'/>
* <ui-view autoscroll='false'/>
* <ui-view autoscroll='scopeVariable'/>
* ```
*
* Resolve data:
*
* The resolved data from the state's `resolve` block is placed on the scope as `$resolve` (this
* can be customized using [[ViewDeclaration.resolveAs]]). This can be then accessed from the template.
*
* Note that when `controllerAs` is being used, `$resolve` is set on the controller instance *after* the
* controller is instantiated. The `$onInit()` hook can be used to perform initialization code which
* depends on `$resolve` data.
*
* @example
* ```js
*
* $stateProvider.state('home', {
* template: '<my-component user="$resolve.user"></my-component>',
* resolve: {
* user: function(UserService) { return UserService.fetchUser(); }
* }
* });
* ```
*/
let uiViewNg1 = ['$view', '$animate', '$uiViewScroll', '$interpolate', '$q',
function $ViewDirective( $view, $animate, $uiViewScroll, $interpolate, $q) {
function getRenderer(attrs, scope) {
return {
enter: function(element, target, cb) {
if (angular.version.minor > 2) {
$animate.enter(element, null, target).then(cb);
} else {
$animate.enter(element, null, target, cb);
}
},
leave: function(element, cb) {
if (angular.version.minor > 2) {
$animate.leave(element).then(cb);
} else {
$animate.leave(element, cb);
}
}
};
}
function configsEqual(config1, config2) {
return config1 === config2;
}
let rootData = {
$cfg: { viewDecl: { $context: $view.rootContext() } },
$uiView: { }
};
let directive = {
count: 0,
restrict: 'ECA',
terminal: true,
priority: 400,
transclude: 'element',
compile: function (tElement, tAttrs, $transclude) {
return function (scope, $element, attrs) {
let previousEl, currentEl, currentScope, unregister,
onloadExp = attrs.onload || '',
autoScrollExp = attrs.autoscroll,
renderer = getRenderer(attrs, scope),
viewConfig = undefined,
inherited = $element.inheritedData('$uiView') || rootData,
name = $interpolate(attrs.uiView || attrs.name || '')(scope) || '$default';
let activeUIView: ActiveUIView = {
id: directive.count++, // Global sequential ID for ui-view tags added to DOM
name: name, // ui-view name (<div ui-view="name"></div>
fqn: inherited.$uiView.fqn ? inherited.$uiView.fqn + "." + name : name, // fully qualified name, describes location in DOM
config: null, // The ViewConfig loaded (from a state.views definition)
configUpdated: configUpdatedCallback, // Called when the matching ViewConfig changes
get creationContext() { // The context in which this ui-view "tag" was created
return parse('$cfg.viewDecl.$context')(inherited);
}
};
trace.traceUiViewEvent("Linking", activeUIView);
function configUpdatedCallback(config?: Ng1ViewConfig) {
if (configsEqual(viewConfig, config)) return;
trace.traceUiViewConfigUpdated(activeUIView, config && config.viewDecl && config.viewDecl.$context);
viewConfig = config;
updateView(config);
}
$element.data('$uiView', { $uiView: activeUIView });
updateView();
unregister = $view.registerUiView(activeUIView);
scope.$on("$destroy", function() {
trace.traceUiViewEvent("Destroying/Unregistering", activeUIView);
unregister();
});
function cleanupLastView() {
if (previousEl) {
trace.traceUiViewEvent("Removing (previous) el", previousEl.data('$uiView'));
previousEl.remove();
previousEl = null;
}
if (currentScope) {
trace.traceUiViewEvent("Destroying scope", activeUIView);
currentScope.$destroy();
currentScope = null;
}
if (currentEl) {
let _viewData = currentEl.data('$uiView');
trace.traceUiViewEvent("Animate out", _viewData);
renderer.leave(currentEl, function() {
_viewData.$$animLeave.resolve();
previousEl = null;
});
previousEl = currentEl;
currentEl = null;
}
}
function updateView(config?: Ng1ViewConfig) {
let newScope = scope.$new();
trace.traceUiViewScopeCreated(activeUIView, newScope);
let animEnter = $q.defer(), animLeave = $q.defer();
let $uiViewData: UIViewData = {
$cfg: config,
$uiView: activeUIView,
$animEnter: animEnter.promise,
$animLeave: animLeave.promise,
$$animLeave: animLeave
};
let cloned = $transclude(newScope, function(clone) {
renderer.enter(clone.data('$uiView', $uiViewData), $element, function onUiViewEnter() {
animEnter.resolve();
if (currentScope) currentScope.$emit('$viewContentAnimationEnded');
if (isDefined(autoScrollExp) && !autoScrollExp || scope.$eval(autoScrollExp)) {
$uiViewScroll(clone);
}
});
cleanupLastView();
});
currentEl = cloned;
currentScope = newScope;
/**
* @ngdoc event
* @name ui.router.state.directive:ui-view#$viewContentLoaded
* @eventOf ui.router.state.directive:ui-view
* @eventType emits on ui-view directive scope
* @description *
* Fired once the view is **loaded**, *after* the DOM is rendered.
*
* @param {Object} event Event object.
*/
currentScope.$emit('$viewContentLoaded', config || viewConfig);
currentScope.$eval(onloadExp);
}
};
}
};
return directive;
}];
$ViewDirectiveFill.$inject = ['$compile', '$controller', '$transitions', '$view', '$timeout'];
/** @hidden */
function $ViewDirectiveFill ( $compile, $controller, $transitions, $view, $timeout) {
const getControllerAs = parse('viewDecl.controllerAs');
const getResolveAs = parse('viewDecl.resolveAs');
const getResolveContext = parse('node.resolveContext');
return {
restrict: 'ECA',
priority: -400,
compile: function (tElement) {
let initial = tElement.html();
return function (scope, $element) {
let data: UIViewData = $element.data('$uiView');
if (!data) return;
let cfg: Ng1ViewConfig = data.$cfg || <any> { viewDecl: {} };
$element.html(cfg.template || initial);
trace.traceUiViewFill(data.$uiView, $element.html());
let link = $compile($element.contents());
let controller = cfg.controller;
let controllerAs: string = getControllerAs(cfg);
let resolveAs: string = getResolveAs(cfg);
let resolveCtx: ResolveContext = getResolveContext(cfg);
let locals = resolveCtx && map(resolveCtx.getResolvables(), r => r.data);
scope[resolveAs] = locals;
if (controller) {
let controllerInstance = $controller(controller, extend({}, locals, { $scope: scope, $element: $element }));
if (controllerAs) {
scope[controllerAs] = controllerInstance;
scope[controllerAs][resolveAs] = locals;
}
// TODO: Use $view service as a central point for registering component-level hooks
// Then, when a component is created, tell the $view service, so it can invoke hooks
// $view.componentLoaded(controllerInstance, { $scope: scope, $element: $element });
// scope.$on('$destroy', () => $view.componentUnloaded(controllerInstance, { $scope: scope, $element: $element }));
$element.data('$ngControllerController', controllerInstance);
$element.children().data('$ngControllerController', controllerInstance);
registerControllerCallbacks($transitions, controllerInstance, scope, cfg);
}
// Wait for the component to appear in the DOM
if (isString(cfg.viewDecl.component)) {
let cmp = cfg.viewDecl.component;
let kebobName = kebobString(cmp);
let getComponentController = () => {
let directiveEl = [].slice.call($element[0].children)
.filter(el => el && el.tagName && el.tagName.toLowerCase() === kebobName) ;
return directiveEl && angular.element(directiveEl).data(`$${cmp}Controller`);
};
let deregisterWatch = scope.$watch(getComponentController, function(ctrlInstance) {
if (!ctrlInstance) return;
registerControllerCallbacks($transitions, ctrlInstance, scope, cfg);
deregisterWatch();
});
}
link(scope);
};
}
};
}
/** @hidden */
let hasComponentImpl = typeof angular.module('ui.router')['component'] === 'function';
/** @hidden TODO: move these callbacks to $view and/or `/hooks/components.ts` or something */
function registerControllerCallbacks($transitions: TransitionService, controllerInstance: Ng1Controller, $scope, cfg: Ng1ViewConfig) {
// Call $onInit() ASAP
if (isFunction(controllerInstance.$onInit) && !(cfg.viewDecl.component && hasComponentImpl)) controllerInstance.$onInit();
var hookOptions: HookRegOptions = { bind: controllerInstance };
// Add component-level hook for onParamsChange
if (isFunction(controllerInstance.uiOnParamsChanged)) {
// Fire callback on any successful transition
const paramsUpdated = ($transition$: Transition) => {
let ctx: ResolveContext = cfg.node.resolveContext;
let viewCreationTrans = ctx.getResolvables()['$transition$'].data;
// Exit early if the $transition$ is the same as the view was created within.
// Exit early if the $transition$ will exit the state the view is for.
if ($transition$ === viewCreationTrans || $transition$.exiting().indexOf(cfg.node.state.self) !== -1) return;
let toParams = $transition$.params("to");
let fromParams = $transition$.params("from");
let toSchema: Param[] = $transition$.treeChanges().to.map((node: Node) => node.paramSchema).reduce(unnestR, []);
let fromSchema: Param[] = $transition$.treeChanges().from.map((node: Node) => node.paramSchema).reduce(unnestR, []);
// Find the to params that have different values than the from params
let changedToParams = toSchema.filter((param: Param) => {
let idx = fromSchema.indexOf(param);
return idx === -1 || !fromSchema[idx].type.equals(toParams[param.id], fromParams[param.id]);
});
// Only trigger callback if a to param has changed or is new
if (changedToParams.length) {
let changedKeys = changedToParams.map(x => x.id);
// Filter the params to only changed/new to params. `$transition$.params()` may be used to get all params.
controllerInstance.uiOnParamsChanged(filter(toParams, (val, key) => changedKeys.indexOf(key) !== -1), $transition$);
}
};
$scope.$on('$destroy', $transitions.onSuccess({}, ['$transition$', paramsUpdated]), hookOptions);
// Fire callback on any IGNORED transition
let onDynamic = ($error$, $transition$) => {
if ($error$.type === RejectType.IGNORED) paramsUpdated($transition$);
};
$scope.$on('$destroy', $transitions.onError({}, ['$error$', '$transition$', onDynamic]), hookOptions);
}
// Add component-level hook for uiCanExit
if (isFunction(controllerInstance.uiCanExit)) {
var criteria = {exiting: cfg.node.state.name};
$scope.$on('$destroy', $transitions.onBefore(criteria, controllerInstance.uiCanExit, hookOptions));
}
}
angular.module('ui.router.state').directive('uiView', uiViewNg1);
angular.module('ui.router.state').directive('uiView', $ViewDirectiveFill);