UNPKG

ui-router

Version:

State-based routing for Javascript

437 lines (388 loc) 16.1 kB
/** @module ng1_directives */ /** for typedoc */ "use strict"; 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);