From 4b583603d7cb63ef1cd36728763e2b028934a63f Mon Sep 17 00:00:00 2001 From: Sarah Padovani Date: Mon, 21 Nov 2016 11:59:12 -0400 Subject: [PATCH] feat(view): Route a view to a directive using `componentProvider` State definitions can dynamically load a component's template by using componentProvider. --- src/ng1/interface.ts | 22 ++++++++++ src/ng1/statebuilders/views.ts | 55 ++---------------------- src/ng1/templateFactory.ts | 77 +++++++++++++++++++++++++++++++++- test/viewDirectiveSpec.js | 52 +++++++++++++++++++++++ test/viewHookSpec.ts | 2 +- 5 files changed, 154 insertions(+), 54 deletions(-) diff --git a/src/ng1/interface.ts b/src/ng1/interface.ts index 3ca516ae8..490b4d111 100644 --- a/src/ng1/interface.ts +++ b/src/ng1/interface.ts @@ -369,6 +369,28 @@ export interface Ng1ViewDeclaration extends _ViewDeclaration { */ bindings?: { [key: string]: string }; + /** + * Dynamic component provider function. + * + * A property of [[Ng1StateDeclaration]] or [[Ng1ViewDeclaration]]: + * + * This is an injectable provider function which returns the component's wrapper template. + * The provider will invoked during a Transition in which the view's state is + * entered. The provider is called after the resolve data is fetched. + * + * #### Example: + * ```js + * componentProvider: function(MyResolveData, $transition$) { + * if (MyResolveData.foo) { + * return "fooComponent" + * } else if ($transition$.to().name === 'bar') { + * return "barComponent"; + * } + * } + * ``` + */ + componentProvider?: IInjectable; + /** * The view's controller function or name * diff --git a/src/ng1/statebuilders/views.ts b/src/ng1/statebuilders/views.ts index 3c0a2b4fe..a1b2854fa 100644 --- a/src/ng1/statebuilders/views.ts +++ b/src/ng1/statebuilders/views.ts @@ -1,8 +1,8 @@ /** @module ng1 */ /** */ import { ng as angular } from "../../angular"; import { - State, Obj, pick, forEach, anyTrueR, unnestR, tail, extend, kebobString, - isArray, isInjectable, isDefined, isString, isObject, services, trace, + State, pick, forEach, anyTrueR, tail, extend, + isArray, isInjectable, isDefined, isString, services, trace, ViewConfig, ViewService, ViewConfigFactory, PathNode, ResolveContext, Resolvable, RawParams, IInjectable } from "ui-router-core"; import { Ng1ViewDeclaration } from "../interface"; @@ -24,7 +24,7 @@ export const ng1ViewConfigFactory: ViewConfigFactory = (path, view) => export function ng1ViewsBuilder(state: State) { let tplKeys = ['templateProvider', 'templateUrl', 'template', 'notify', 'async'], ctrlKeys = ['controller', 'controllerProvider', 'controllerAs', 'resolveAs'], - compKeys = ['component', 'bindings'], + compKeys = ['component', 'bindings', 'componentProvider'], nonCompKeys = tplKeys.concat(ctrlKeys), allKeys = compKeys.concat(nonCompKeys); @@ -43,24 +43,6 @@ export function ng1ViewsBuilder(state: State) { if (nonCompKeys.map(key => isDefined(config[key])).reduce(anyTrueR, false)) { throw new Error(`Cannot combine: ${compKeys.join("|")} with: ${nonCompKeys.join("|")} in stateview: 'name@${state.name}'`); } - - // Dynamically build a template like "" - config.templateProvider = ['$injector', function ($injector: IInjectorService) { - const resolveFor = (key: string) => - config.bindings && config.bindings[key] || key; - const prefix = angular.version.minor >= 3 ? "::" : ""; - const attributeTpl = (input: BindingTuple) => { - var attrName = kebobString(input.name); - var resolveName = resolveFor(input.name); - if (input.type === '@') - return `${attrName}='{{${prefix}$resolve.${resolveName}}}'`; - return `${attrName}='${prefix}$resolve.${resolveName}'`; - }; - - let attrs = getComponentInputs($injector, config.component).map(attributeTpl).join(" "); - let kebobName = kebobString(config.component); - return `<${kebobName} ${attrs}>`; - }]; } config.resolveAs = config.resolveAs || '$resolve'; @@ -77,35 +59,6 @@ export function ng1ViewsBuilder(state: State) { return views; } -interface BindingTuple { - name: string; - type: string; -} - -// for ng 1.2 style, process the scope: { input: "=foo" } -// for ng 1.3 through ng 1.5, process the component's bindToController: { input: "=foo" } object -const scopeBindings = (bindingsObj: Obj) => Object.keys(bindingsObj || {}) -// [ 'input', [ '=foo', '=', 'foo' ] ] - .map(key => [key, /^([=<@])[?]?(.*)/.exec(bindingsObj[key])]) - // skip malformed values - .filter(tuple => isDefined(tuple) && isArray(tuple[1])) - // { name: ('foo' || 'input'), type: '=' } - .map(tuple => ({ name: tuple[1][2] || tuple[0], type: tuple[1][1] } as BindingTuple)); - -// Given a directive definition, find its object input attributes -// Use different properties, depending on the type of directive (component, bindToController, normal) -const getBindings = (def: any) => { - if (isObject(def.bindToController)) return scopeBindings(def.bindToController); - return scopeBindings(def.scope); -}; - -// Gets all the directive(s)' inputs ('@', '=', and '<') -function getComponentInputs($injector: IInjectorService, name: string) { - let cmpDefs = $injector.get(name + "Directive"); // could be multiple - if (!cmpDefs || !cmpDefs.length) throw new Error(`Unable to find component named '${name}'`); - return cmpDefs.map(getBindings).reduce(unnestR, []); -} - let id = 0; export class Ng1ViewConfig implements ViewConfig { $id = id++; @@ -144,7 +97,7 @@ export class Ng1ViewConfig implements ViewConfig { * @return {boolean} Returns `true` if the configuration contains a valid template, otherwise `false`. */ hasTemplate() { - return !!(this.viewDecl.template || this.viewDecl.templateUrl || this.viewDecl.templateProvider); + return !!(this.viewDecl.template || this.viewDecl.templateUrl || this.viewDecl.templateProvider || this.viewDecl.component || this.viewDecl.componentProvider); } getTemplate(params: RawParams, $factory: TemplateFactory, context: ResolveContext) { diff --git a/src/ng1/templateFactory.ts b/src/ng1/templateFactory.ts index 65dfd4c66..69e0918dd 100644 --- a/src/ng1/templateFactory.ts +++ b/src/ng1/templateFactory.ts @@ -1,6 +1,7 @@ +import { ng as angular } from "../angular"; /** @module view */ /** for typedoc */ import { - isArray, isDefined, isFunction, services, IInjectable, tail, ResolveContext, Resolvable, RawParams + isArray, isDefined, isFunction, isObject, services, Obj, IInjectable, tail, kebobString, unnestR, ResolveContext, Resolvable, RawParams } from "ui-router-core"; import { Ng1ViewDeclaration } from "./interface"; @@ -26,6 +27,8 @@ export class TemplateFactory { isDefined(config.template) ? this.fromString(config.template, params) : isDefined(config.templateUrl) ? this.fromUrl(config.templateUrl, params) : isDefined(config.templateProvider) ? this.fromProvider(config.templateProvider, params, context) : + isDefined(config.component) ? this.fromComponent(config.component, config.bindings) : + isDefined(config.componentProvider) ? this.fromComponentProvider(config.componentProvider, params, context) : null ); }; @@ -72,4 +75,74 @@ export class TemplateFactory { let resolvable = new Resolvable("", providerFn, deps); return resolvable.get(context); }; -} \ No newline at end of file + + /** + * Creates a template from a component's name + * + * @param component {string} Component's name in camel case. + * @param bindings An object defining the component's bindings: {foo: '<'} + * @return {string} The template as a string: "". + */ + fromComponent(component: string, bindings?: any) { + const resolveFor = (key: string) => + bindings && bindings[key] || key; + const prefix = angular.version.minor >= 3 ? "::" : ""; + const attributeTpl = (input: BindingTuple) => { + var attrName = kebobString(input.name); + var resolveName = resolveFor(input.name); + if (input.type === '@') + return `${attrName}='{{${prefix}$resolve.${resolveName}}}'`; + return `${attrName}='${prefix}$resolve.${resolveName}'`; + }; + + let attrs = getComponentInputs(component).map(attributeTpl).join(" "); + let kebobName = kebobString(component); + return `<${kebobName} ${attrs}>`; + }; + + /** + * Creates a component's template by invoking an injectable provider function. + * + * @param provider Function to invoke via `locals` + * @param {Function} injectFn a function used to invoke the template provider + * @return {string} The template html as a string: "". + */ + fromComponentProvider(provider: IInjectable, params: any, context: ResolveContext) { + let deps = services.$injector.annotate(provider); + let providerFn = isArray(provider) ? tail( provider) : provider; + let resolvable = new Resolvable("", providerFn, deps); + return resolvable.get(context).then((componentName) => { + return this.fromComponent(componentName); + }); + }; +} + +// Gets all the directive(s)' inputs ('@', '=', and '<') +function getComponentInputs(name: string) { + let cmpDefs = services.$injector.get(name + "Directive"); // could be multiple + if (!cmpDefs || !cmpDefs.length) throw new Error(`Unable to find component named '${name}'`); + return cmpDefs.map(getBindings).reduce(unnestR, []); +} + +// Given a directive definition, find its object input attributes +// Use different properties, depending on the type of directive (component, bindToController, normal) +const getBindings = (def: any) => { + if (isObject(def.bindToController)) return scopeBindings(def.bindToController); + return scopeBindings(def.scope); +}; + +interface BindingTuple { + name: string; + type: string; +} + +// for ng 1.2 style, process the scope: { input: "=foo" } +// for ng 1.3 through ng 1.5, process the component's bindToController: { input: "=foo" } object +const scopeBindings = (bindingsObj: Obj) => Object.keys(bindingsObj || {}) +// [ 'input', [ '=foo', '=', 'foo' ] ] + .map(key => [key, /^([=<@])[?]?(.*)/.exec(bindingsObj[key])]) + // skip malformed values + .filter(tuple => isDefined(tuple) && isArray(tuple[1])) + // { name: ('foo' || 'input'), type: '=' } + .map(tuple => ({ name: tuple[1][2] || tuple[0], type: tuple[1][1] } as BindingTuple)); + diff --git a/test/viewDirectiveSpec.js b/test/viewDirectiveSpec.js index d896fb3ea..9de7c14cf 100644 --- a/test/viewDirectiveSpec.js +++ b/test/viewDirectiveSpec.js @@ -806,6 +806,13 @@ describe('angular 1.5+ style .component()', function() { } }); + app.directive('ng12DynamicDirective', function() { + return { + restrict: 'E', + template: 'dynamic directive' + } + }); + // ng 1.5+ component if (angular.version.minor >= 5) { app.component('ngComponent', { @@ -837,6 +844,10 @@ describe('angular 1.5+ style .component()', function() { bindings: { evt: '&' }, template: 'eventCmp', }); + + app.component('dynamicComponent', { + template: 'dynamicComponent' + }) } })); @@ -1188,4 +1199,45 @@ describe('angular 1.5+ style .component()', function() { }); } }); + + describe('componentProvider', function() { + it('should work with angular 1.2+ directives', function () { + $stateProvider.state('ng12-dynamic-directive', { + url: '/ng12dynamicDirective/:type', + componentProvider: ['$stateParams', function($stateParams) { + return $stateParams.type; + }] + }); + + var $state = svcs.$state, $q = svcs.$q; + + $state.transitionTo('ng12-dynamic-directive', {type: 'ng12DynamicDirective'}); $q.flush(); + + directiveEl = el[0].querySelector('div ui-view ng12-dynamic-directive'); + expect(directiveEl).toBeDefined(); + expect($state.current.name).toBe('ng12-dynamic-directive'); + expect(el.text()).toBe('dynamic directive'); + }); + + if (angular.version.minor >= 5) { + it('should load correct component when using componentProvider', function() { + $stateProvider.state('dynamicComponent', { + url: '/dynamicComponent/:type', + componentProvider: ['$stateParams', function($stateParams) { + return $stateParams.type; + }] + }); + + var $state = svcs.$state, $httpBackend = svcs.$httpBackend, $q = svcs.$q; + + $state.transitionTo('dynamicComponent', {type: 'dynamicComponent'}); + $q.flush(); + + directiveEl = el[0].querySelector('div ui-view dynamic-component'); + expect(directiveEl).toBeDefined(); + expect($state.current.name).toBe('dynamicComponent'); + expect(el.text()).toBe('dynamicComponent'); + }); + } + }); }); diff --git a/test/viewHookSpec.ts b/test/viewHookSpec.ts index fcb918718..ef61ca842 100644 --- a/test/viewHookSpec.ts +++ b/test/viewHookSpec.ts @@ -167,4 +167,4 @@ describe("view hooks", () => { }); }); -}); \ No newline at end of file +});