From 3db9ddd3d2ab9aa97dfe2d0bdd5631190f6c6a56 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Fri, 7 Feb 2014 17:02:03 -0500 Subject: [PATCH] feat(routing): new DSL and deferred module loading Introduced new routing DSL that allows adding new modules for views. Modules can be loaded synchronously or asynchronously, for example in case of deferred library loading. --- lib/core_dom/common.dart | 11 ++ lib/routing/module.dart | 1 + lib/routing/ng_view.dart | 20 +- lib/routing/routing.dart | 117 ++++++++++-- test/core_dom/block_spec.dart | 87 +++++++-- test/routing/routing_spec.dart | 334 ++++++++++++++++++++++++++++++++- 6 files changed, 538 insertions(+), 32 deletions(-) diff --git a/lib/core_dom/common.dart b/lib/core_dom/common.dart index f95789020..012023c00 100644 --- a/lib/core_dom/common.dart +++ b/lib/core_dom/common.dart @@ -23,3 +23,14 @@ class DirectiveRef { } } +/** + * Creates a child injector that allows loading new directives, filters and + * services from the provided modules. + */ +Injector forceNewDirectivesAndFilters(Injector injector, List modules) { + modules.add(new Module() + ..factory(Scope, + (i) => i.parent.get(Scope).$new(filters: i.get(FilterMap)))); + return injector.createChild(modules, + forceNewInstances: [DirectiveMap, FilterMap]); +} diff --git a/lib/routing/module.dart b/lib/routing/module.dart index e32c9682a..ef5998f2c 100644 --- a/lib/routing/module.dart +++ b/lib/routing/module.dart @@ -175,6 +175,7 @@ class NgRoutingModule extends Module { type(NgRoutingHelper); value(RouteProvider, null); value(RouteInitializer, null); + value(RouteInitializerFn, null); // directives value(NgViewDirective, null); diff --git a/lib/routing/ng_view.dart b/lib/routing/ng_view.dart index fb8da9988..74a65a81b 100644 --- a/lib/routing/ng_view.dart +++ b/lib/routing/ng_view.dart @@ -64,18 +64,15 @@ part of angular.routing; class NgViewDirective implements NgDetachAware, RouteProvider { final NgRoutingHelper locationService; final BlockCache blockCache; - final Scope scope; final Injector injector; final Element element; - final DirectiveMap directives; RouteHandle _route; Block _previousBlock; Scope _previousScope; Route _viewRoute; - NgViewDirective(this.element, this.blockCache, this.scope, Injector injector, - Router router, this.directives) + NgViewDirective(this.element, this.blockCache, Injector injector, Router router) : injector = injector, locationService = injector.get(NgRoutingHelper) { RouteProvider routeProvider = injector.parent.get(NgViewDirective); if (routeProvider != null) { @@ -98,7 +95,7 @@ class NgViewDirective implements NgDetachAware, RouteProvider { locationService._unregisterPortal(this); } - _show(String templateUrl, Route route) { + _show(String templateUrl, Route route, List modules) { assert(route.isActive); if (_viewRoute != null) return; @@ -112,11 +109,18 @@ class NgViewDirective implements NgDetachAware, RouteProvider { _cleanUp(); }); - blockCache.fromUrl(templateUrl, directives).then((blockFactory) { + var viewInjector = injector; + if (modules != null) { + viewInjector = forceNewDirectivesAndFilters(viewInjector, modules); + } + + var newDirectives = viewInjector.get(DirectiveMap); + blockCache.fromUrl(templateUrl, newDirectives).then((blockFactory) { _cleanUp(); - _previousScope = scope.$new(); + _previousScope = viewInjector.get(Scope).$new(); _previousBlock = blockFactory( - injector.createChild([new Module()..value(Scope, _previousScope)])); + viewInjector.createChild( + [new Module()..value(Scope, _previousScope)])); _previousBlock.elements.forEach((elm) => element.append(elm)); }); diff --git a/lib/routing/routing.dart b/lib/routing/routing.dart index 098cb2e13..4a26bf6f8 100644 --- a/lib/routing/routing.dart +++ b/lib/routing/routing.dart @@ -9,8 +9,76 @@ class ViewFactory { ViewFactory(this.locationService); call(String templateUrl) => - (RouteEvent event) => - locationService._route(event.route, templateUrl, fromEvent: true); + (RouteEnterEvent event) => _enterHandler(event, templateUrl); + + _enterHandler(RouteEnterEvent event, String templateUrl, [List modules]) => + locationService._route(event.route, templateUrl, fromEvent: true, modules: modules); + + configure(Map config) => + _configure(locationService.router.root, config); + + _configure(Route route, Map config) { + config.forEach((name, cfg) { + var moduledCalled = false; + List newModules; + route.addRoute( + name: name, + path: cfg.path, + defaultRoute: cfg.defaultRoute, + enter: (RouteEnterEvent e) { + if (cfg.view != null) { + _enterHandler(e, cfg.view, newModules); + } + if (cfg.enter != null) { + cfg.enter(e); + } + }, + preEnter: (RoutePreEnterEvent e) { + if (cfg.modules != null && !moduledCalled) { + moduledCalled = true; + var modules = cfg.modules(); + if (modules is Future) { + e.allowEnter(modules.then((List m) { + newModules = m; + return true; + })); + } else { + newModules = modules; + } + } + if (cfg.preEnter != null) { + cfg.preEnter(e); + } + }, + leave: cfg.leave, + mount: (Route mountRoute) { + if (cfg.mount != null) { + _configure(mountRoute, cfg.mount); + } + }); + }); + } +} + +NgRouteCfg ngRoute({String path, String view, Map mount, + modules(), bool defaultRoute: false, RoutePreEnterEventHandler preEnter, + RouteEnterEventHandler enter, RouteLeaveEventHandler leave}) => + new NgRouteCfg(path: path, view: view, mount: mount, modules: modules, + defaultRoute: defaultRoute, preEnter: preEnter, enter: enter, + leave: leave); + +class NgRouteCfg { + final String path; + final String view; + final Map mount; + final Function modules; + final bool defaultRoute; + final RouteEnterEventHandler enter; + final RoutePreEnterEventHandler preEnter; + final RouteLeaveEventHandler leave; + + NgRouteCfg({this.view, this.path, this.mount, this.modules, this.defaultRoute, + this.enter, this.preEnter, this.leave}); } /** @@ -19,11 +87,23 @@ class ViewFactory { * * The [init] method will be called by the framework once the router is * instantiated but before [NgBindRouteDirective] and [NgViewDirective]. + * + * Deprecated: use RouteInitializerFn instead. */ +@deprecated abstract class RouteInitializer { void init(Router router, ViewFactory viewFactory); } +/** + * An typedef that must be implemented by the user of routing library and + * should include the route initialization. + * + * The function will be called by the framework once the router is + * instantiated but before [NgBindRouteDirective] and [NgViewDirective]. + */ +typedef void RouteInitializerFn(Router router, ViewFactory viewFactory); + /** * A singleton helper service that handles routing initialization, global * events and view registries. @@ -33,15 +113,22 @@ class NgRoutingHelper { final Router router; final NgApp _ngApp; List portals = []; - Map _templates = new Map(); + Map _templates = new Map(); - NgRoutingHelper(RouteInitializer initializer, this.router, this._ngApp) { - if (initializer == null) { + NgRoutingHelper(RouteInitializer initializer, Injector injector, this.router, this._ngApp) { + // TODO: move this to constructor parameters when di issue is fixed: + // https://github.com/angular/di.dart/issues/40 + RouteInitializerFn initializerFn = injector.get(RouteInitializerFn); + if (initializer == null && initializerFn == null) { window.console.error('No RouteInitializer implementation provided.'); return; }; - initializer.init(router, new ViewFactory(this)); + if (initializerFn != null) { + initializerFn(router, new ViewFactory(this)); + } else { + initializer.init(router, new ViewFactory(this)); + } router.onRouteStart.listen((RouteStartEvent routeEvent) { routeEvent.completed.then((success) { if (success) { @@ -60,23 +147,24 @@ class NgRoutingHelper { activePath = activePath.skip(_routeDepth(startingFrom)); } for (Route route in activePath) { - var templateUrl = _templates[_routePath(route)]; - if (templateUrl == null) continue; + var viewDef = _templates[_routePath(route)]; + if (viewDef == null) continue; + var templateUrl = viewDef.template; NgViewDirective view = portals.lastWhere((NgViewDirective v) { return _routePath(route) != _routePath(v._route) && _routePath(route).startsWith(_routePath(v._route)); }, orElse: () => null); if (view != null && !alreadyActiveViews.contains(view)) { - view._show(templateUrl, route); + view._show(templateUrl, route, viewDef.modules); alreadyActiveViews.add(view); break; } } } - _route(Route route, String template, {bool fromEvent}) { - _templates[_routePath(route)] = template; + _route(Route route, String template, {bool fromEvent, List modules}) { + _templates[_routePath(route)] = new _View(template, modules); } _registerPortal(NgViewDirective ngView) { @@ -88,6 +176,13 @@ class NgRoutingHelper { } } +class _View { + final String template; + final List modules; + + _View(this.template, this.modules); +} + String _routePath(Route route) { var path = []; var p = route; diff --git a/test/core_dom/block_spec.dart b/test/core_dom/block_spec.dart index e5a08bcc7..c94fee0ed 100644 --- a/test/core_dom/block_spec.dart +++ b/test/core_dom/block_spec.dart @@ -2,6 +2,12 @@ library block_spec; import '../_specs.dart'; +class Log { + List log = []; + + add(String msg) => log.add(msg); +} + @NgDirective(children: NgAnnotation.TRANSCLUDE_CHILDREN, selector: 'foo') class LoggerBlockDirective { LoggerBlockDirective(BlockHole hole, BlockFactory blockFactory, @@ -16,24 +22,43 @@ class LoggerBlockDirective { } } -class ReplaceBlockDirective { - ReplaceBlockDirective(BlockHole hole, BoundBlockFactory boundBlockFactory, Node node, Scope scope) { - var block = boundBlockFactory(scope); - block.insertAfter(hole); - node.remove(); +@NgDirective(selector: 'dir-a') +class ADirective { + ADirective(Log log) { + log.add('ADirective'); + } +} + +@NgDirective(selector: 'dir-b') +class BDirective { + BDirective(Log log) { + log.add('BDirective'); + } +} + +@NgFilter(name:'filterA') +class AFilter { + Log log; + + AFilter(this.log) { + log.add('AFilter'); } + + call(value) => value; } -class ShadowBlockDirective { - ShadowBlockDirective(BlockHole hole, BoundBlockFactory boundBlockFactory, Element element, Scope scope) { - var block = boundBlockFactory(scope); - var shadowRoot = element.createShadowRoot(); - for (var i = 0, ii = block.elements.length; i < ii; i++) { - shadowRoot.append(block.elements[i]); - } +@NgFilter(name:'filterB') +class BFilter { + Log log; + + BFilter(this.log) { + log.add('BFilter'); } + + call(value) => value; } + main() { describe('Block', () { var anchor; @@ -201,6 +226,44 @@ main() { }); }); + describe('deferred', () { + + it('should load directives/filters from the child injector', () { + Module rootModule = new Module() + ..type(Probe) + ..type(Log) + ..type(AFilter) + ..type(ADirective); + + Injector rootInjector = + new DynamicInjector(modules: [new AngularModule(), rootModule]); + Log log = rootInjector.get(Log); + Scope rootScope = rootInjector.get(Scope); + + Compiler compiler = rootInjector.get(Compiler); + DirectiveMap directives = rootInjector.get(DirectiveMap); + compiler(es('{{\'a\' | filterA}}'), directives)(rootInjector); + rootScope.$digest(); + + expect(log.log, equals(['ADirective', 'AFilter'])); + + + Module childModule = new Module() + ..type(BFilter) + ..type(BDirective); + + var childInjector = forceNewDirectivesAndFilters(rootInjector, [childModule]); + + DirectiveMap newDirectives = childInjector.get(DirectiveMap); + compiler(es('{{\'a\' | filterA}}' + '{{\'b\' | filterB}}'), newDirectives)(childInjector); + rootScope.$digest(); + + expect(log.log, equals(['ADirective', 'AFilter', 'ADirective', 'BDirective', 'BFilter'])); + }); + + }); + //TODO: tests for attach/detach //TODO: animation/transitions //TODO: tests for re-usability of blocks diff --git a/test/routing/routing_spec.dart b/test/routing/routing_spec.dart index a01833407..be2b6aba9 100644 --- a/test/routing/routing_spec.dart +++ b/test/routing/routing_spec.dart @@ -1,8 +1,8 @@ library routing_spec; import '../_specs.dart'; -import 'package:angular/routing/module.dart'; import 'package:angular/mock/module.dart'; +import 'dart:async'; main() { describe('routing', () { @@ -35,6 +35,322 @@ main() { })); }); + + describe('routing DSL', () { + Router router; + TestBed _; + + afterEach(() { + router = _ = null; + }); + + initRouter(initializer) { + var module = new Module() + ..value(RouteInitializerFn, initializer); + var injector = new DynamicInjector( + modules: [new AngularModule(), new AngularMockModule(), module]); + injector.get(NgRoutingHelper); // force routing initialization + router = injector.get(Router); + _ = injector.get(TestBed); + } + + it('should configure route hierarchy from provided config', async(() { + var counters = { + 'foo': 0, + 'bar': 0, + 'baz': 0, + 'aux': 0, + }; + initRouter((Router router, ViewFactory views) { + views.configure({ + 'foo': ngRoute( + path: '/foo', + enter: (_) => counters['foo']++, + mount: { + 'bar': ngRoute( + path: '/bar', + enter: (_) => counters['bar']++ + ), + 'baz': ngRoute( + path: '/baz', + enter: (_) => counters['baz']++ + ) + } + ), + 'aux': ngRoute( + path: '/aux', + enter: (_) => counters['aux']++ + ) + }); + }); + + expect(router.root.getRoute('foo').name).toEqual('foo'); + expect(router.root.getRoute('foo.bar').name).toEqual('bar'); + expect(router.root.getRoute('foo.baz').name).toEqual('baz'); + expect(router.root.getRoute('aux').name).toEqual('aux'); + + router.route('/foo'); + microLeap(); + expect(counters, equals({ + 'foo': 1, + 'bar': 0, + 'baz': 0, + 'aux': 0, + })); + + router.route('/foo/bar'); + microLeap(); + expect(counters, equals({ + 'foo': 1, + 'bar': 1, + 'baz': 0, + 'aux': 0, + })); + + router.route('/foo/baz'); + microLeap(); + expect(counters, equals({ + 'foo': 1, + 'bar': 1, + 'baz': 1, + 'aux': 0, + })); + + router.route('/aux'); + microLeap(); + expect(counters, equals({ + 'foo': 1, + 'bar': 1, + 'baz': 1, + 'aux': 1, + })); + })); + + + it('should set the default route', async(() { + int enterCount = 0; + initRouter((Router router, ViewFactory views) { + views.configure({ + 'foo': ngRoute(path: '/foo'), + 'bar': ngRoute(path: '/bar', defaultRoute: true), + 'baz': ngRoute(path: '/baz'), + }); + }); + + router.route('/invalidRoute'); + microLeap(); + + expect(router.activePath.length).toBe(1); + expect(router.activePath.first.name).toBe('bar'); + })); + + + it('should call enter callback and show the view when routed', async(() { + int enterCount = 0; + initRouter((Router router, ViewFactory views) { + views.configure({ + 'foo': ngRoute( + path: '/foo', + enter: (_) => enterCount++, + view: 'foo.html' + ), + }); + }); + _.injector.get(TemplateCache) + .put('foo.html', new HttpResponse(200, '

Foo

')); + + Element root = _.compile(''); + expect(root.text).toEqual(''); + + router.route('/foo'); + microLeap(); + + expect(enterCount).toBe(1); + expect(root.text).toEqual('Foo'); + })); + + + it('should call preEnter callback and load modules', async(() { + int preEnterCount = 0; + int modulesCount = 0; + initRouter((Router router, ViewFactory views) { + views.configure({ + 'foo': ngRoute( + path: '/foo', + preEnter: (_) => preEnterCount++, + modules: () { + modulesCount++; + return new Future.value(); + } + ), + 'bar': ngRoute( + path: '/bar' + ) + }); + }); + + router.route('/foo'); + microLeap(); + + expect(preEnterCount).toBe(1); + expect(modulesCount).toBe(1); + + router.route('/foo'); + microLeap(); + + expect(preEnterCount).toBe(1); + expect(modulesCount).toBe(1); + + router.route('/bar'); + microLeap(); + + expect(preEnterCount).toBe(1); + expect(modulesCount).toBe(1); + + router.route('/foo'); + microLeap(); + + expect(preEnterCount).toBe(2); + expect(modulesCount).toBe(1); + })); + + + it('should clear view on leave an call leave callback', async(() { + int leaveCount = 0; + initRouter((Router router, ViewFactory views) { + views.configure({ + 'foo': ngRoute( + path: '/foo', + leave: (_) => leaveCount++, + view: 'foo.html' + ), + 'bar': ngRoute( + path: '/bar' + ), + }); + }); + _.injector.get(TemplateCache) + .put('foo.html', new HttpResponse(200, '

Foo

')); + + Element root = _.compile(''); + expect(root.text).toEqual(''); + + router.route('/foo'); + microLeap(); + + expect(root.text).toEqual('Foo'); + expect(leaveCount).toBe(0); + + router.route('/bar'); + microLeap(); + + expect(root.text).toEqual(''); + expect(leaveCount).toBe(1); + })); + + + it('should synchronously load new directives from modules ', async(() { + initRouter((Router router, ViewFactory views) { + views.configure({ + 'foo': ngRoute( + path: '/foo', + modules: () => [ + new Module()..type(NewDirective) + ], + view: 'foo.html' + ), + }); + }); + _.injector.get(TemplateCache) + .put('foo.html', new HttpResponse(200, '
Old!
')); + + Element root = _.compile(''); + expect(root.text).toEqual(''); + + router.route('/foo'); + microLeap(); + + expect(root.text).toEqual('New!'); + })); + + + it('should asynchronously load new directives from modules ', async(() { + initRouter((Router router, ViewFactory views) { + views.configure({ + 'foo': ngRoute( + path: '/foo', + modules: () => new Future.value([ + new Module()..type(NewDirective) + ]), + view: 'foo.html' + ), + }); + }); + _.injector.get(TemplateCache) + .put('foo.html', new HttpResponse(200, '
Old!
')); + + Element root = _.compile(''); + expect(root.text).toEqual(''); + + router.route('/foo'); + microLeap(); + + expect(root.text).toEqual('New!'); + })); + + + it('should synchronously load new filters from modules ', async(() { + initRouter((Router router, ViewFactory views) { + views.configure({ + 'foo': ngRoute( + path: '/foo', + modules: () => [ + new Module()..type(HelloFilter) + ], + view: 'foo.html' + ), + }); + }); + _.injector.get(TemplateCache) + .put('foo.html', new HttpResponse(200, '
{{\'World\' | hello}}
')); + + Element root = _.compile(''); + expect(root.text).toEqual(''); + + router.route('/foo'); + microLeap(); + _.rootScope.$digest(); + + expect(root.text).toEqual('Hello, World!'); + })); + + + it('should asynchronously load new filters from modules ', async(() { + initRouter((Router router, ViewFactory views) { + views.configure({ + 'foo': ngRoute( + path: '/foo', + modules: () => new Future.value([ + new Module()..type(HelloFilter) + ]), + view: 'foo.html' + ), + }); + }); + _.injector.get(TemplateCache) + .put('foo.html', new HttpResponse(200, '
{{\'World\' | hello}}
')); + + Element root = _.compile(''); + expect(root.text).toEqual(''); + + router.route('/foo'); + microLeap(); + _.rootScope.$digest(); + + expect(root.text).toEqual('Hello, World!'); + })); + + }); } class TestRouteInitializer implements RouteInitializer { @@ -46,3 +362,19 @@ class TestRouteInitializer implements RouteInitializer { this.router = router; } } + + +@NgDirective(selector: '[make-it-new]') +class NewDirective { + NewDirective(Element element) { + element.innerHtml = 'New!'; + } +} + +@NgFilter(name:'hello') +class HelloFilter { + call(String str) { + return 'Hello, $str!'; + } +} +