Skip to content

Commit 1552032

Browse files
feat(view): Route a view to a directive using component:
closes #2627
1 parent 961c96d commit 1552032

File tree

7 files changed

+514
-16
lines changed

7 files changed

+514
-16
lines changed

config/karma.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ module.exports = function (karma) {
3434
],
3535

3636
// Karma files available to serve is overridden using files.karmaServedFiles() in some grunt tasks (e.g., karma:ng12)
37-
files: files.karmaServedFiles('1.4.9'),
37+
// files: files.karmaServedFiles('1.2.28'),
38+
// files: files.karmaServedFiles('1.3.16'),
39+
// files: files.karmaServedFiles('1.4.9'),
40+
files: files.karmaServedFiles('1.5.0'),
3841
// Actual tests to load is configured in systemjs.files block
3942
systemjs: {
4043
// Set up systemjs paths

src/common/common.ts

+1
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,7 @@ export function tail<T>(arr: T[]): T {
504504
return arr.length && arr[arr.length - 1] || undefined;
505505
}
506506

507+
export const kebobString = (camelCase: string) => camelCase.replace(/([A-Z])/g, $1 => "-"+$1.toLowerCase());
507508

508509
function _toJson(obj) {
509510
return JSON.stringify(obj);

src/ng1/interface.ts

+91-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** @module ng1 */ /** */
22
import {StateDeclaration, _ViewDeclaration} from "../state/interface";
33
import {ParamDeclaration} from "../params/interface";
4+
import {IInjectable} from "../common/common";
45

56
/**
67
* The StateDeclaration object is used to define a state or nested state.
@@ -242,8 +243,7 @@ export interface Ng1StateDeclaration extends StateDeclaration, Ng1ViewDeclaratio
242243
* ```
243244
*/
244245
views?: { [key: string]: Ng1ViewDeclaration; };
245-
data?: any;
246-
onEnter?: Function;
246+
data?: any; onEnter?: Function;
247247
onRetain?: Function;
248248
onExit?: Function;
249249

@@ -254,6 +254,94 @@ export interface Ng1StateDeclaration extends StateDeclaration, Ng1ViewDeclaratio
254254
}
255255

256256
export interface Ng1ViewDeclaration extends _ViewDeclaration {
257+
/**
258+
* The name of the component to use for this view.
259+
*
260+
* The name of an [angular 1.5+ `.component()`](https://docs.angularjs.org/guide/component) (or directive with
261+
* bindToController and/or scope declaration) which will be used for this view.
262+
*
263+
* Resolve data can be provided to the component via the component's `bindings` object (for 1.3+ directives, the
264+
* `bindToController` is used; for other directives, the `scope` declaration is used). For each binding declared
265+
* on the component, any resolve with the same name is set on the component's controller instance. The binding
266+
* is provided to the component as a one-time-binding. In general, * components should likewise declare their
267+
* input bindings as [one-way (`"<"`)](https://docs.angularjs.org/api/ng/service/$compile#-scope-).
268+
*
269+
* Note: inside a "views:" block, a bare string `"foo"` is shorthand for `{ component: "foo" }`
270+
*
271+
* Note: Mapping from resolve names to component inputs may be specified using [[bindings]].
272+
*
273+
* @example:
274+
* ```
275+
*
276+
* .state('profile', {
277+
* // Unnamed view should be <my-profile></my-profile> component
278+
* component: 'MyProfile',
279+
* }
280+
* .state('messages', {
281+
* // 'header' named view should be <nav-bar></nav-bar> component
282+
* // 'content' named view should be <message-list></message-list> component
283+
* views: {
284+
* header: { component: 'NavBar' },
285+
* content: { component: 'MessageList' }
286+
* }
287+
* }
288+
* .state('contacts', {
289+
* // Inside a "views:" block, a bare string "NavBar" is shorthand for { component: "NavBar" }
290+
* // 'header' named view should be <nav-bar></nav-bar> component
291+
* // 'content' named view should be <contact-list></contact-list> component
292+
* views: {
293+
* header: 'NavBar',
294+
* content: 'ContactList'
295+
* }
296+
* }
297+
* ```
298+
*
299+
* Note: When using `component` to define a view, you may _not_ use any of: `template`, `templateUrl`,
300+
* `templateProvider`, `controller`, `controllerProvider`, `controllerAs`.
301+
*
302+
* See also: Todd Motto's angular 1.3 and 1.4 [backport of .component()](https://github.com/toddmotto/angular-component)
303+
*/
304+
component?: string;
305+
306+
/**
307+
* An object to map from component `bindings` names to `resolve` names, for [[component]] style view.
308+
*
309+
* When using a [[component]] declaration, each component's input binding is supplied data from a resolve of the
310+
* same name, by default. You may supply data from a different resolve name by mapping it here.
311+
*
312+
* Each key in this object is the name of one of the component's input bindings.
313+
* Each value is the name of the resolve that should be provided to that binding.
314+
*
315+
* Any component bindings that are omitted from this map get the default behavior of mapping to a resolve of the
316+
* same name.
317+
*
318+
* @example
319+
* ```
320+
* $stateProvider.state('foo', {
321+
* resolve: {
322+
* foo: function(FooService) { return FooService.get(); },
323+
* bar: function(BarService) { return BarService.get(); }
324+
* },
325+
* component: 'Baz',
326+
* // The component's `baz` binding gets data from the `bar` resolve
327+
* // The component's `foo` binding gets data from the `foo` resolve (default behavior)
328+
* bindings: {
329+
* baz: 'bar'
330+
* }
331+
* });
332+
*
333+
* app.component('Baz', {
334+
* templateUrl: 'baz.html',
335+
* controller: 'BazController',
336+
* bindings: {
337+
* foo: '<', // foo binding
338+
* baz: '<' // baz binding
339+
* }
340+
* });
341+
* ```
342+
*
343+
*/
344+
bindings?: { [key: string]: string };
257345

258346
/**
259347
* A property of [[StateDeclaration]] or [[ViewDeclaration]]:
@@ -372,7 +460,7 @@ export interface Ng1ViewDeclaration extends _ViewDeclaration {
372460
* }
373461
* ```
374462
*/
375-
templateProvider?: Function;
463+
templateProvider?: IInjectable;
376464

377465
}
378466

src/ng1/viewDirective.ts

+24-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** @module view */ /** for typedoc */
22
"use strict";
3-
import {extend, map, unnestR, filter} from "../common/common";
3+
import {extend, map, unnestR, filter, kebobString} from "../common/common";
44
import {isDefined, isFunction} from "../common/predicates";
55
import {trace} from "../common/trace";
66
import {ActiveUIView} from "../view/interface";
@@ -338,18 +338,40 @@ function $ViewDirectiveFill ( $compile, $controller, $transitions, $view,
338338
scope[resolveAs] = locals;
339339

340340
if (controller) {
341-
let controllerInstance = $controller(controller, extend(locals, { $scope: scope, $element: $element }));
341+
let controllerInstance = $controller(controller, extend({}, locals, { $scope: scope, $element: $element }));
342342
if (controllerAs) {
343343
scope[controllerAs] = controllerInstance;
344344
scope[controllerAs][resolveAs] = locals;
345345
}
346346

347+
// TODO: Use $view service as a central point for registering component-level hooks
348+
// Then, when a component is created, tell the $view service, so it can invoke hooks
349+
// $view.componentLoaded(controllerInstance, { $scope: scope, $element: $element });
350+
// scope.$on('$destroy', () => $view.componentUnloaded(controllerInstance, { $scope: scope, $element: $element }));
351+
347352
$element.data('$ngControllerController', controllerInstance);
348353
$element.children().data('$ngControllerController', controllerInstance);
349354

350355
registerControllerCallbacks($transitions, controllerInstance, scope, cfg);
351356
}
352357

358+
// Wait for the component to appear in the DOM
359+
if (cfg.viewDecl.component) {
360+
let cmp = cfg.viewDecl.component;
361+
let kebobName = kebobString(cmp);
362+
let getComponentController = () => {
363+
let directiveEl = [].slice.call($element[0].children)
364+
.filter(el => el && el.tagName && el.tagName.toLowerCase() === kebobName) ;
365+
return directiveEl && angular.element(directiveEl).data(`$${cmp}Controller`);
366+
};
367+
368+
let deregisterWatch = scope.$watch(getComponentController, function(ctrlInstance) {
369+
if (!ctrlInstance) return;
370+
registerControllerCallbacks($transitions, ctrlInstance, scope, cfg);
371+
deregisterWatch();
372+
});
373+
}
374+
353375
link(scope);
354376
};
355377
}

src/ng1/viewsBuilder.ts

+54-9
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
/** @module ng1 */ /** */
22
import {State} from "../state/stateObject";
3-
import {pick, forEach} from "../common/common";
3+
import {pick, forEach, anyTrueR, unnestR, kebobString} from "../common/common";
44
import {ViewConfig, ViewContext} from "../view/interface";
55
import {Ng1ViewDeclaration} from "./interface";
66
import {ViewService} from "../view/view";
7-
import {isInjectable} from "../common/predicates";
7+
import {isInjectable, isDefined, isString, isObject} from "../common/predicates";
88
import {services} from "../common/coreservices";
99
import {trace} from "../common/trace";
1010
import {Node} from "../path/node";
1111
import {TemplateFactory} from "../view/templateFactory";
1212
import {ResolveContext} from "../resolve/resolveContext";
13+
import {prop, parse} from "../common/hof";
1314

1415
export const ng1ViewConfigFactory = (node, view) => new Ng1ViewConfig(node, view);
1516

@@ -24,19 +25,36 @@ export const ng1ViewConfigFactory = (node, view) => new Ng1ViewConfig(node, view
2425
*/
2526
export function ng1ViewsBuilder(state: State) {
2627
let tplKeys = ['templateProvider', 'templateUrl', 'template', 'notify', 'async'],
27-
ctrlKeys = ['component', 'controller', 'controllerProvider', 'controllerAs', 'resolveAs'],
28-
allKeys = tplKeys.concat(ctrlKeys);
28+
ctrlKeys = ['controller', 'controllerProvider', 'controllerAs', 'resolveAs'],
29+
compKeys = ['component', 'bindings'],
30+
nonCompKeys = tplKeys.concat(ctrlKeys),
31+
allKeys = compKeys.concat(nonCompKeys);
2932

3033
let views = {}, viewsObject = state.views || {"$default": pick(state, allKeys)};
3134

3235
forEach(viewsObject, function (config: Ng1ViewDeclaration, name) {
33-
name = name || "$default"; // Account for views: { "": { template... } }
34-
// Allow controller settings to be defined at the state level for all views
35-
forEach(ctrlKeys, (key) => {
36-
if (state[key] && !config[key]) config[key] = state[key];
37-
});
36+
// Account for views: { "": { template... } }
37+
name = name || "$default";
38+
// Account for views: { header: "headerComponent" }
39+
if (isString(config)) config = { component: <string> config };
3840
if (!Object.keys(config).length) return;
3941

42+
// Configure this view for routing to an angular 1.5+ style .component (or any directive, really)
43+
if (config.component) {
44+
if (nonCompKeys.map(key => isDefined(config[key])).reduce(anyTrueR, false)) {
45+
throw new Error(`Cannot combine: ${compKeys.join("|")} with: ${nonCompKeys.join("|")} in stateview: 'name@${state.name}'`);
46+
}
47+
48+
// Dynamically build a template like "<component-name input1='$resolve.foo'></component-name>"
49+
config.templateProvider = ['$injector', function($injector) {
50+
const resolveFor = key => config.bindings && config.bindings[key] || key;
51+
const prefix = angular.version.minor >= 3 ? "::" : "";
52+
let attrs = getComponentInputs($injector, config.component).map(key => `${kebobString(key)}='${prefix}$resolve.${resolveFor(key)}'`).join(" ");
53+
let kebobName = kebobString(config.component);
54+
return `<${kebobName} ${attrs}></${kebobName}>`;
55+
}];
56+
}
57+
4058
config.resolveAs = config.resolveAs || '$resolve';
4159
config.$type = "ng1";
4260
config.$context = state;
@@ -51,6 +69,33 @@ export function ng1ViewsBuilder(state: State) {
5169
return views;
5270
}
5371

72+
// for ng 1.2 style, process the scope: { input: "=foo" } object
73+
const scopeBindings = bindingsObj => Object.keys(bindingsObj)
74+
.map(key => [key, /^[=<](.*)/.exec(bindingsObj[key])])
75+
.filter(tuple => isDefined(tuple[1]))
76+
.map(tuple => tuple[1][1] || tuple[0]);
77+
78+
// for ng 1.3+ bindToController or 1.5 component style, process a $$bindings object
79+
const bindToCtrlBindings = bindingsObj => Object.keys(bindingsObj)
80+
.filter(key => !!/[=<]/.exec(bindingsObj[key].mode))
81+
.map(key => bindingsObj[key].attrName);
82+
83+
// Given a directive definition, find its object input attributes
84+
// Use different properties, depending on the type of directive (component, bindToController, normal)
85+
const getBindings = def => {
86+
if (isObject(def.bindToController)) return scopeBindings(def.bindToController);
87+
if (def.$$bindings && def.$$bindings.bindToController) return bindToCtrlBindings(def.$$bindings.bindToController);
88+
if (def.$$isolateBindings) return bindToCtrlBindings(def.$$isolateBindings);
89+
return <any> scopeBindings(def.scope);
90+
};
91+
92+
// Gets all the directive(s)' inputs ('=' and '<')
93+
function getComponentInputs($injector, name) {
94+
let cmpDefs = $injector.get(name + "Directive"); // could be multiple
95+
if (!cmpDefs || !cmpDefs.length) throw new Error(`Unable to find component named '${name}'`);
96+
return cmpDefs.map(getBindings).reduce(unnestR, []);
97+
}
98+
5499
export class Ng1ViewConfig implements ViewConfig {
55100
loaded: boolean = false;
56101
controller: Function;

src/view/templateFactory.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {isDefined, isFunction} from "../common/predicates";
33
import {services} from "../common/coreservices";
44
import {Ng1ViewDeclaration} from "../ng1/interface";
5+
import {IInjectable} from "../common/common";
56

67
/**
78
* Service which manages loading of templates from a ViewConfig.
@@ -66,7 +67,7 @@ export class TemplateFactory {
6667
* @return {string|Promise.<string>} The template html as a string, or a promise
6768
* for that string.
6869
*/
69-
fromProvider(provider: Function, params: any, injectFn: Function) {
70+
fromProvider(provider: IInjectable, params: any, injectFn: Function) {
7071
return injectFn(provider);
7172
};
7273
}

0 commit comments

Comments
 (0)