Skip to content

Commit

Permalink
feat(view): Route a view to a directive using component:
Browse files Browse the repository at this point in the history
closes #2627
  • Loading branch information
christopherthielen committed Mar 26, 2016
1 parent 961c96d commit 1552032
Show file tree
Hide file tree
Showing 7 changed files with 514 additions and 16 deletions.
5 changes: 4 additions & 1 deletion config/karma.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ module.exports = function (karma) {
],

// Karma files available to serve is overridden using files.karmaServedFiles() in some grunt tasks (e.g., karma:ng12)
files: files.karmaServedFiles('1.4.9'),
// files: files.karmaServedFiles('1.2.28'),
// files: files.karmaServedFiles('1.3.16'),
// files: files.karmaServedFiles('1.4.9'),
files: files.karmaServedFiles('1.5.0'),
// Actual tests to load is configured in systemjs.files block
systemjs: {
// Set up systemjs paths
Expand Down
1 change: 1 addition & 0 deletions src/common/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ export function tail<T>(arr: T[]): T {
return arr.length && arr[arr.length - 1] || undefined;
}

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

function _toJson(obj) {
return JSON.stringify(obj);
Expand Down
94 changes: 91 additions & 3 deletions src/ng1/interface.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @module ng1 */ /** */
import {StateDeclaration, _ViewDeclaration} from "../state/interface";
import {ParamDeclaration} from "../params/interface";
import {IInjectable} from "../common/common";

/**
* The StateDeclaration object is used to define a state or nested state.
Expand Down Expand Up @@ -242,8 +243,7 @@ export interface Ng1StateDeclaration extends StateDeclaration, Ng1ViewDeclaratio
* ```
*/
views?: { [key: string]: Ng1ViewDeclaration; };
data?: any;
onEnter?: Function;
data?: any; onEnter?: Function;
onRetain?: Function;
onExit?: Function;

Expand All @@ -254,6 +254,94 @@ export interface Ng1StateDeclaration extends StateDeclaration, Ng1ViewDeclaratio
}

export interface Ng1ViewDeclaration extends _ViewDeclaration {
/**
* The name of the component to use for this view.
*
* The name of an [angular 1.5+ `.component()`](https://docs.angularjs.org/guide/component) (or directive with
* bindToController and/or scope declaration) which will be used for this view.
*
* Resolve data can be provided to the component via the component's `bindings` object (for 1.3+ directives, the
* `bindToController` is used; for other directives, the `scope` declaration is used). For each binding declared
* on the component, any resolve with the same name is set on the component's controller instance. The binding
* is provided to the component as a one-time-binding. In general, * components should likewise declare their
* input bindings as [one-way (`"<"`)](https://docs.angularjs.org/api/ng/service/$compile#-scope-).
*
* Note: inside a "views:" block, a bare string `"foo"` is shorthand for `{ component: "foo" }`
*
* Note: Mapping from resolve names to component inputs may be specified using [[bindings]].
*
* @example:
* ```
*
* .state('profile', {
* // Unnamed view should be <my-profile></my-profile> component
* component: 'MyProfile',
* }
* .state('messages', {
* // 'header' named view should be <nav-bar></nav-bar> component
* // 'content' named view should be <message-list></message-list> component
* views: {
* header: { component: 'NavBar' },
* content: { component: 'MessageList' }
* }
* }
* .state('contacts', {
* // Inside a "views:" block, a bare string "NavBar" is shorthand for { component: "NavBar" }
* // 'header' named view should be <nav-bar></nav-bar> component
* // 'content' named view should be <contact-list></contact-list> component
* views: {
* header: 'NavBar',
* content: 'ContactList'
* }
* }
* ```
*
* Note: When using `component` to define a view, you may _not_ use any of: `template`, `templateUrl`,
* `templateProvider`, `controller`, `controllerProvider`, `controllerAs`.
*
* See also: Todd Motto's angular 1.3 and 1.4 [backport of .component()](https://github.com/toddmotto/angular-component)
*/
component?: string;

/**
* An object to map from component `bindings` names to `resolve` names, for [[component]] style view.
*
* When using a [[component]] declaration, each component's input binding is supplied data from a resolve of the
* same name, by default. You may supply data from a different resolve name by mapping it here.
*
* Each key in this object is the name of one of the component's input bindings.
* Each value is the name of the resolve that should be provided to that binding.
*
* Any component bindings that are omitted from this map get the default behavior of mapping to a resolve of the
* same name.
*
* @example
* ```
* $stateProvider.state('foo', {
* resolve: {
* foo: function(FooService) { return FooService.get(); },
* bar: function(BarService) { return BarService.get(); }
* },
* component: 'Baz',
* // The component's `baz` binding gets data from the `bar` resolve
* // The component's `foo` binding gets data from the `foo` resolve (default behavior)
* bindings: {
* baz: 'bar'
* }
* });
*
* app.component('Baz', {
* templateUrl: 'baz.html',
* controller: 'BazController',
* bindings: {
* foo: '<', // foo binding
* baz: '<' // baz binding
* }
* });
* ```
*
*/
bindings?: { [key: string]: string };

/**
* A property of [[StateDeclaration]] or [[ViewDeclaration]]:
Expand Down Expand Up @@ -372,7 +460,7 @@ export interface Ng1ViewDeclaration extends _ViewDeclaration {
* }
* ```
*/
templateProvider?: Function;
templateProvider?: IInjectable;

}

26 changes: 24 additions & 2 deletions src/ng1/viewDirective.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** @module view */ /** for typedoc */
"use strict";
import {extend, map, unnestR, filter} from "../common/common";
import {extend, map, unnestR, filter, kebobString} from "../common/common";
import {isDefined, isFunction} from "../common/predicates";
import {trace} from "../common/trace";
import {ActiveUIView} from "../view/interface";
Expand Down Expand Up @@ -338,18 +338,40 @@ function $ViewDirectiveFill ( $compile, $controller, $transitions, $view,
scope[resolveAs] = locals;

if (controller) {
let controllerInstance = $controller(controller, extend(locals, { $scope: scope, $element: $element }));
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 (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);
};
}
Expand Down
63 changes: 54 additions & 9 deletions src/ng1/viewsBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
/** @module ng1 */ /** */
import {State} from "../state/stateObject";
import {pick, forEach} from "../common/common";
import {pick, forEach, anyTrueR, unnestR, kebobString} from "../common/common";
import {ViewConfig, ViewContext} from "../view/interface";
import {Ng1ViewDeclaration} from "./interface";
import {ViewService} from "../view/view";
import {isInjectable} from "../common/predicates";
import {isInjectable, isDefined, isString, isObject} from "../common/predicates";
import {services} from "../common/coreservices";
import {trace} from "../common/trace";
import {Node} from "../path/node";
import {TemplateFactory} from "../view/templateFactory";
import {ResolveContext} from "../resolve/resolveContext";
import {prop, parse} from "../common/hof";

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

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

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

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

// Configure this view for routing to an angular 1.5+ style .component (or any directive, really)
if (config.component) {
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 "<component-name input1='$resolve.foo'></component-name>"
config.templateProvider = ['$injector', function($injector) {
const resolveFor = key => config.bindings && config.bindings[key] || key;
const prefix = angular.version.minor >= 3 ? "::" : "";
let attrs = getComponentInputs($injector, config.component).map(key => `${kebobString(key)}='${prefix}$resolve.${resolveFor(key)}'`).join(" ");
let kebobName = kebobString(config.component);
return `<${kebobName} ${attrs}></${kebobName}>`;
}];
}

config.resolveAs = config.resolveAs || '$resolve';
config.$type = "ng1";
config.$context = state;
Expand All @@ -51,6 +69,33 @@ export function ng1ViewsBuilder(state: State) {
return views;
}

// for ng 1.2 style, process the scope: { input: "=foo" } object
const scopeBindings = bindingsObj => Object.keys(bindingsObj)
.map(key => [key, /^[=<](.*)/.exec(bindingsObj[key])])
.filter(tuple => isDefined(tuple[1]))
.map(tuple => tuple[1][1] || tuple[0]);

// for ng 1.3+ bindToController or 1.5 component style, process a $$bindings object
const bindToCtrlBindings = bindingsObj => Object.keys(bindingsObj)
.filter(key => !!/[=<]/.exec(bindingsObj[key].mode))
.map(key => bindingsObj[key].attrName);

// Given a directive definition, find its object input attributes
// Use different properties, depending on the type of directive (component, bindToController, normal)
const getBindings = def => {
if (isObject(def.bindToController)) return scopeBindings(def.bindToController);
if (def.$$bindings && def.$$bindings.bindToController) return bindToCtrlBindings(def.$$bindings.bindToController);
if (def.$$isolateBindings) return bindToCtrlBindings(def.$$isolateBindings);
return <any> scopeBindings(def.scope);
};

// Gets all the directive(s)' inputs ('=' and '<')
function getComponentInputs($injector, name) {
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, []);
}

export class Ng1ViewConfig implements ViewConfig {
loaded: boolean = false;
controller: Function;
Expand Down
3 changes: 2 additions & 1 deletion src/view/templateFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import {isDefined, isFunction} from "../common/predicates";
import {services} from "../common/coreservices";
import {Ng1ViewDeclaration} from "../ng1/interface";
import {IInjectable} from "../common/common";

/**
* Service which manages loading of templates from a ViewConfig.
Expand Down Expand Up @@ -66,7 +67,7 @@ export class TemplateFactory {
* @return {string|Promise.<string>} The template html as a string, or a promise
* for that string.
*/
fromProvider(provider: Function, params: any, injectFn: Function) {
fromProvider(provider: IInjectable, params: any, injectFn: Function) {
return injectFn(provider);
};
}
Loading

0 comments on commit 1552032

Please sign in to comment.