diff --git a/src/common/common.ts b/src/common/common.ts index c8430ff69..05ee83b89 100644 --- a/src/common/common.ts +++ b/src/common/common.ts @@ -58,6 +58,15 @@ export function pipe(...funcs: Function[]): (obj: any) => any { */ export const prop = (name: string) => (obj: any) => obj && obj[name]; +/** + * Given a property name and a value, returns a function that returns a boolean based on whether + * the passed object has a property that matches the value + * let obj = { foo: 1, name: "blarg" }; + * let getName = propEq("name", "blarg"); + * getName(obj) === true + */ +export const propEq = curry((name: string, val: any, obj: any) => obj && obj[name] === val); + /** * Given a dotted property name, returns a function that returns a nested property from an object, or undefined * let obj = { id: 1, nestedObj: { foo: 1, name: "blarg" }, }; @@ -72,16 +81,14 @@ export const parse = (name: string) => pipe.apply(null, name.split(".").map(prop * Given a function that returns a truthy or falsey value, returns a * function that returns the opposite (falsey or truthy) value given the same inputs */ -export const not = (fn) => (function() { return !fn.apply(null, [].slice.call(arguments)); }); +export const not = (fn) => (...args) => !fn.apply(null, args); /** * Given two functions that return truthy or falsey values, returns a function that returns truthy * if both functions return truthy for the given arguments */ export function and(fn1, fn2): Function { - return function() { - return fn1.apply(null, [].slice.call(arguments)) && fn2.apply(null, [].slice.call(arguments)); - }; + return (...args) => fn1.apply(null, args) && fn2.apply(null, args); } /** @@ -89,9 +96,7 @@ export function and(fn1, fn2): Function { * if at least one of the functions returns truthy for the given arguments */ export function or(fn1, fn2): Function { - return function() { - return fn1.apply(null, [].slice.call(arguments)) || fn2.apply(null, [].slice.call(arguments)); - }; + return (...args) => fn1.apply(null, args) || fn2.apply(null, args); } /** Given a class, returns a Predicate function that returns true if the object is of that class */ @@ -182,6 +187,9 @@ export function merge(dst, ...objs: Object[]) { return dst; } +/** Reduce function that merges each element of the list into a single object, using angular.extend */ +export const mergeR = (memo, item) => extend(memo, item); + /** * Finds the common ancestor path between two states. * @@ -282,12 +290,20 @@ export function map(collection: any, callback: any): any { return result; } -/** Push an object to an array, return the array */ -export const push = (arr: any[], obj) => { arr.push(obj); return arr; }; +/** Given an object, return its enumerable property values */ +export const values: ( (obj: TypedMap) => T[]) = (obj) => Object.keys(obj).map(key => obj[key]); + + /** Reduce function that returns true if all of the values are truthy. */ +export const allTrueR = (memo: boolean, elem) => memo && elem; +/** Reduce function that returns true if any of the values are truthy. */ +export const anyTrueR = (memo: boolean, elem) => memo || elem; + +/** Reduce function that pushes an object to an array, then returns the array */ +export const pushR = (arr: any[], obj) => { arr.push(obj); return arr; }; /** Reduce function which un-nests a single level of arrays */ export const unnestR = (memo: any[], elem) => memo.concat(elem); /** Reduce function which recursively un-nests all arrays */ -export const flattenR = (memo: any[], elem) => isArray(elem) ? memo.concat(elem.reduce(flattenR, [])) : push(memo, elem); +export const flattenR = (memo: any[], elem) => isArray(elem) ? memo.concat(elem.reduce(flattenR, [])) : pushR(memo, elem); /** Return a new array with a single level of arrays unnested. */ export const unnest = (arr: any[]) => arr.reduce(unnestR, []); /** Return a completely flattened version of an array. */ @@ -307,21 +323,27 @@ export function assertPredicate(fn: Predicate, errMsg: string = "assert fa export const pairs = (object) => Object.keys(object).map(key => [ key, object[key]] ); /** - * Sets a key/val pair on an object, then returns the object. + * Given two or more parallel arrays, returns an array of tuples where + * each tuple is composed of [ a[i], b[i], ... z[i] ] * - * Use as a reduce function for an array of key/val pairs + * let foo = [ 0, 2, 4, 6 ]; + * let bar = [ 1, 3, 5, 7 ]; + * let baz = [ 10, 30, 50, 70 ]; + * tuples(foo, bar); // [ [0, 1], [2, 3], [4, 5], [6, 7] ] + * tuples(foo, bar, baz); // [ [0, 1, 10], [2, 3, 30], [4, 5, 50], [6, 7, 70] ] * - * Given: - * var keys = [ "fookey", "barkey" ] - * var pairsToObj = keys.reduce((memo, key) => applyPairs(memo, key, true), {}) - * Then: - * true === angular.equals(pairsToObj, { fookey: true, barkey: true }) */ -export function applyPairs(obj: TypedMap, arrayOrKey: string, val: any); +export function arrayTuples(...arrayArgs: any[]): any[] { + if (arrayArgs.length === 0) return []; + let length = arrayArgs.reduce((min, arr) => Math.min(arr.length, min), 9007199254740991); // aka 2^53 − 1 aka Number.MAX_SAFE_INTEGER + return Array.apply(null, Array(length)).map((ignored, idx) => arrayArgs.map(arr => arr[idx]).reduce(pushR, [])); +} + /** - * Sets a key/val pair on an object, then returns the object. + * Reduce function which builds an object from an array of [key, value] pairs. + * Each iteration sets the key/val pair on the memo object, then returns the memo for the next iteration. * - * Use as a reduce function for an array of key/val pairs + * Each keyValueTuple should be an array with values [ key: string, value: any ] * * Given: * var pairs = [ ["fookey", "fooval"], ["barkey","barval"] ] @@ -330,14 +352,12 @@ export function applyPairs(obj: TypedMap, arrayOrKey: string, val: any); * Then: * true === angular.equals(pairsToObj, { fookey: "fooval", barkey: "barval" }) */ -export function applyPairs(obj: TypedMap, arrayOrKey: any[]); -export function applyPairs(obj: TypedMap, arrayOrKey: (string|any[]), val?: any) { - let key; - if (isDefined(val)) key = arrayOrKey; - if (isArray(arrayOrKey)) [key, val] = arrayOrKey; +export function applyPairs(memo: TypedMap, keyValTuple: any[]) { + let key, value; + if (isArray(keyValTuple)) [key, value] = keyValTuple; if (!isString(key)) throw new Error("invalid parameters to applyPairs"); - obj[key] = val; - return obj; + memo[key] = value; + return memo; } // Checks if a value is injectable @@ -373,6 +393,11 @@ export function padString(length: number, str: string) { return str; } +export function tail(collection: T[]): T; +export function tail(collection: any[]): any { + return collection.length && collection[collection.length - 1] || undefined; +} + /** * @ngdoc overview diff --git a/src/common/trace.ts b/src/common/trace.ts index 5b89be7d8..6f5da6add 100644 --- a/src/common/trace.ts +++ b/src/common/trace.ts @@ -26,17 +26,17 @@ function normalizedCat(input: Category): string { return isNumber(input) ? Category[input] : Category[Category[input]]; } -let format = pattern([ - [not(isDefined), val("undefined")], - [isNull, val("null")], - [isPromise, promiseToString], - [is(Transition), invoke("toString")], - [is(Resolvable), invoke("toString")], - [isInjectable, functionToString], - [val(true), identity] -]); - function stringify(o) { + let format = pattern([ + [not(isDefined), val("undefined")], + [isNull, val("null")], + [isPromise, promiseToString], + [is(Transition), invoke("toString")], + [is(Resolvable), invoke("toString")], + [isInjectable, functionToString], + [val(true), identity] + ]); + return JSON.stringify(o, (key, val) => format(val)).replace(/\\"/g, '"'); } diff --git a/src/params/interface.ts b/src/params/interface.ts index 14502226b..6049348d9 100644 --- a/src/params/interface.ts +++ b/src/params/interface.ts @@ -1,6 +1,4 @@ -import ParamValues from "./paramValues"; - export interface IRawParams { [key: string]: any } -export type IParamsOrArray = (IRawParams|IRawParams[]|ParamValues); \ No newline at end of file +export type IParamsOrArray = (IRawParams|IRawParams[]); \ No newline at end of file diff --git a/src/params/module.ts b/src/params/module.ts index ef2928dc5..2970a3631 100644 --- a/src/params/module.ts +++ b/src/params/module.ts @@ -1,14 +1,8 @@ import * as param from "./param"; export {param}; -import * as paramSet from "./paramSet"; -export {paramSet}; - import * as paramTypes from "./paramTypes"; export {paramTypes}; -import * as paramValues from "./paramValues"; -export {paramValues}; - import * as type from "./type"; export {type}; diff --git a/src/params/param.ts b/src/params/param.ts index 49e22ee54..50d560dbd 100644 --- a/src/params/param.ts +++ b/src/params/param.ts @@ -1,13 +1,21 @@ -import {isInjectable, extend, isDefined, isString, isArray, filter, map, prop, curry} from "../common/common"; +import {isInjectable, extend, isDefined, isString, isArray, filter, map, pick, prop, propEq, curry, applyPairs} from "../common/common"; +import {IRawParams} from "../params/interface"; import {runtime} from "../common/angular1"; import matcherConfig from "../url/urlMatcherConfig"; import paramTypes from "./paramTypes"; import Type from "./type"; +let hasOwn = Object.prototype.hasOwnProperty; +let isShorthand = cfg => ["value", "type", "squash", "array", "dynamic"].filter(hasOwn.bind(cfg || {})).length === 0; + +enum DefType { + PATH, SEARCH, CONFIG +} + export default class Param { id: string; type: Type; - location: string; + location: DefType; array: boolean; squash: (boolean|string); replace: any; @@ -15,40 +23,36 @@ export default class Param { dynamic: boolean; config: any; - constructor(id, type, config, location) { + constructor(id: string, type: Type, config: any, location: DefType) { config = unwrapShorthand(config); type = getType(config, type, location); var arrayMode = getArrayMode(); - type = arrayMode ? type.$asArray(arrayMode, location === "search") : type; + type = arrayMode ? type.$asArray(arrayMode, location === DefType.SEARCH) : type; var isOptional = config.value !== undefined; var dynamic = config.dynamic === true; var squash = getSquashPolicy(config, isOptional); var replace = getReplace(config, arrayMode, isOptional, squash); function unwrapShorthand(config) { - var configKeys = ["value", "type", "squash", "array", "dynamic"].filter(function (key) { - return (config || {}).hasOwnProperty(key); + config = isShorthand(config) && { value: config } || config; + + return extend(config, { + $$fn: isInjectable(config.value) ? config.value : () => config.value }); - var isShorthand = configKeys.length === 0; - if (isShorthand) config = {value: config}; - config.$$fn = isInjectable(config.value) ? config.value : function () { - return config.value; - }; - return config; } function getType(config, urlType, location) { if (config.type && urlType && urlType.name !== 'string') throw new Error(`Param '${id}' has two type configurations.`); if (config.type && urlType && urlType.name === 'string' && paramTypes.type(config.type)) return paramTypes.type(config.type); if (urlType) return urlType; - if (!config.type) return (location === "config" ? paramTypes.type("any") : paramTypes.type("string")); + if (!config.type) return (location === DefType.CONFIG ? paramTypes.type("any") : paramTypes.type("string")); return config.type instanceof Type ? config.type : paramTypes.type(config.type); } // array config: param name (param[]) overrides default settings. explicit config overrides param name. function getArrayMode() { - var arrayDefaults = {array: (location === "search" ? "auto" : false)}; - var arrayParamNomenclature = id.match(/\[\]$/) ? {array: true} : {}; + var arrayDefaults = { array: (location === DefType.SEARCH ? "auto" : false) }; + var arrayParamNomenclature = id.match(/\[\]$/) ? { array: true } : {}; return extend(arrayDefaults, arrayParamNomenclature, config).array; } @@ -60,7 +64,7 @@ export default class Param { if (!isOptional || squash === false) return false; if (!isDefined(squash) || squash == null) return matcherConfig.defaultSquashPolicy(); if (squash === true || isString(squash)) return squash; - throw new Error("Invalid squash policy: '" + squash + "'. Valid policies: false, true, or arbitrary string"); + throw new Error(`Invalid squash policy: '${squash}'. Valid policies: false, true, or arbitrary string`); } function getReplace(config, arrayMode, isOptional, squash) { @@ -69,7 +73,7 @@ export default class Param { {from: null, to: (isOptional || arrayMode ? undefined : "")} ]; replace = isArray(config.replace) ? config.replace : []; - if (isString(squash)) replace.push({from: squash, to: undefined}); + if (isString(squash)) replace.push({ from: squash, to: undefined }); configuredKeys = map(replace, prop("from")); return filter(defaultPolicy, item => configuredKeys.indexOf(item.from) === -1).concat(replace); } @@ -77,7 +81,7 @@ export default class Param { extend(this, {id, type, location, squash, replace, isOptional, dynamic, config, array: arrayMode}); } - isDefaultValue(value: any) { + isDefaultValue(value: any): boolean { return this.isOptional && this.type.equals(this.value(), value); } @@ -85,7 +89,7 @@ export default class Param { * [Internal] Gets the decoded representation of a value if the value is defined, otherwise, returns the * default value, which may be the result of an injectable function. */ - value(value?: any) { + value(value?: any): any { /** * [Internal] Get the default value of a parameter, which may be an injectable function. */ @@ -97,10 +101,8 @@ export default class Param { return defaultValue; }; - const hasReplaceVal = curry((val, obj) => obj.from === val); - const $replace = (value) => { - var replacement: any = map(filter(this.replace, hasReplaceVal(value)), prop("to")); + var replacement: any = map(filter(this.replace, propEq('from', value)), prop("to")); return replacement.length ? replacement[0] : value; }; @@ -108,8 +110,54 @@ export default class Param { return !isDefined(value) ? $$getDefaultValue() : this.type.$normalize(value); } + isSearch(): boolean { + return this.location === DefType.SEARCH; + } + + validates(value: any): boolean { + // There was no parameter value, but the param is optional + if ((!isDefined(value) || value === null) && this.isOptional) return true; + + // The value was not of the correct Type, and could not be decoded to the correct Type + const normalized = this.type.$normalize(value); + if (!this.type.is(normalized)) return false; + + // The value was of the correct type, but when encoded, did not match the Type's regexp + const encoded = this.type.encode(normalized); + if (isString(encoded) && !this.type.pattern.exec( encoded)) return false; + + return true; + } + toString() { return `{Param:${this.id} ${this.type} squash: '${this.squash}' optional: ${this.isOptional}}`; } -} + static fromConfig(id: string, type: Type, config: any): Param { + return new Param(id, type, config, DefType.CONFIG); + } + + static fromPath(id: string, type: Type, config: any): Param { + return new Param(id, type, config, DefType.PATH); + } + + static fromSearch(id: string, type: Type, config: any): Param { + return new Param(id, type, config, DefType.SEARCH); + } + + static values(params: Param[], values): IRawParams { + values = values || {}; + return params.map(param => [param.id, param.value(values[param.id])]).reduce(applyPairs, {}); + } + + static equals(params: Param[], values1, values2): boolean { + values1 = values1 || {}; + values2 = values2 || {}; + return params.map(param => param.type.equals(values1[param.id], values2[param.id])).indexOf(false) === -1; + } + + static validates(params: Param[], values): boolean { + values = values || {}; + return params.map(param => param.validates(values[param.id])).indexOf(false) === -1; + } +} \ No newline at end of file diff --git a/src/params/paramSet.ts b/src/params/paramSet.ts deleted file mode 100644 index 9b6a58cf5..000000000 --- a/src/params/paramSet.ts +++ /dev/null @@ -1,75 +0,0 @@ -import {extend, inherit, forEach, isString} from "../common/common"; - -import {IRawParams} from "../params/interface" - -export default class ParamSet { - constructor(params?: any) { - extend(this, params || {}); - } - - $$new(params) { - return inherit(inherit(this, { $$parent: () => this }), params); - } - - $$own() { - return extend(new ParamSet(), this); - } - - $$keys() { - var keys = [], chain = [], parent = this, ignore = Object.keys(ParamSet.prototype); - while (parent) { chain.push(parent); parent = parent.$$parent(); } - chain.reverse(); - forEach(chain, function(paramset) { - forEach(Object.keys(paramset), function(key) { - if (keys.indexOf(key) === -1 && ignore.indexOf(key) === -1) keys.push(key); - }); - }); - return keys; - } - - $$values(paramValues): IRawParams { - var values = {}; - forEach(this.$$keys(), (key: string) => { - values[key] = this[key].value(paramValues && paramValues[key]); - }); - return values; - } - - $$equals(paramValues1, paramValues2) { - var equal = true, self = this; - forEach(self.$$keys(), function(key) { - var left = paramValues1 && paramValues1[key], right = paramValues2 && paramValues2[key]; - if (!self[key].type.equals(left, right)) equal = false; - }); - return equal; - } - - $$validates(paramValues) { - var keys = this.$$keys(), i, param, rawVal, normalized, encoded; - paramValues = paramValues || {}; - for (i = 0; i < keys.length; i++) { - param = this[keys[i]]; - rawVal = paramValues[keys[i]]; - if ((rawVal === undefined || rawVal === null) && param.isOptional) - continue; // There was no parameter value, but the param is optional - normalized = param.type.$normalize(rawVal); - if (!param.type.is(normalized)) - return false; // The value was not of the correct Type, and could not be decoded to the correct Type - encoded = param.type.encode(normalized); - if (isString(encoded) && !param.type.pattern.exec(encoded)) - return false; // The value was of the correct type, but when encoded, did not match the Type's regexp - } - return true; - } - - $$filter(filterFn) { - var self = this, subset = new ParamSet(); - forEach(this.$$keys(), function(key) { - if (filterFn(self[key])) - subset[key] = self[key]; - }); - return subset; - } - - $$parent() { return null; } -} diff --git a/src/params/paramTypes.ts b/src/params/paramTypes.ts index 4f1144a9c..e1d60f43e 100644 --- a/src/params/paramTypes.ts +++ b/src/params/paramTypes.ts @@ -1,64 +1,64 @@ -import {isDefined, fromJson, toJson, isObject, identity, equals, inherit, map, extend} from "../common/common"; +import {isDefined, fromJson, toJson, is, identity, equals, inherit, map, extend, val} from "../common/common"; import Type from "./type"; import {runtime} from "../common/angular1"; -function valToString(val) { return val != null ? val.toString().replace(/\//g, "%2F") : val; } -function valFromString(val) { return val != null ? val.toString().replace(/%2F/g, "/") : val; } +const swapString = (search, replace) => val => val != null ? val.toString().replace(search, replace) : val; +const valToString = swapString(/\//g, "%2F"); +const valFromString = swapString(/%2F/g, "/"); class ParamTypes { types: any; enqueue: boolean = true; typeQueue: any[] = []; - private defaultTypes:any = { + private defaultTypes: any = { hash: { encode: valToString, decode: valFromString, - is: function(val) { return typeof val === "string"; }, + is: is(String), pattern: /.*/, - equals: function() { return true; } + equals: val(true) }, string: { encode: valToString, decode: valFromString, - is: function(val) { return typeof val === "string"; }, + is: is(String), pattern: /[^/]*/ }, int: { encode: valToString, - decode: function(val) { return parseInt(val, 10); }, - is: function(val) { return isDefined(val) && this.decode(val.toString()) === val; }, + decode(val) { return parseInt(val, 10); }, + is(val) { return isDefined(val) && this.decode(val.toString()) === val; }, pattern: /\d+/ }, bool: { - encode: function(val) { return val ? 1 : 0; }, - decode: function(val) { return parseInt(val, 10) !== 0; }, - is: function(val) { return val === true || val === false; }, + encode: val => val && 1 || 0, + decode: val => parseInt(val, 10) !== 0, + is: is(Boolean), pattern: /0|1/ }, date: { - encode: function (val) { - if (!this.is(val)) - return undefined; - return [ val.getFullYear(), + encode(val) { + return !this.is(val) ? undefined : [ + val.getFullYear(), ('0' + (val.getMonth() + 1)).slice(-2), ('0' + val.getDate()).slice(-2) ].join("-"); }, - decode: function (val) { + decode(val) { if (this.is(val)) return val; var match = this.capture.exec(val); return match ? new Date(match[1], match[2] - 1, match[3]) : undefined; }, - is: function(val) { return val instanceof Date && !isNaN(val.valueOf()); }, - equals: function (a, b) { return this.is(a) && this.is(b) && a.toISOString() === b.toISOString(); }, + is: (val) => val instanceof Date && !isNaN(val.valueOf()), + equals(a, b) { return this.is(a) && this.is(b) && a.toISOString() === b.toISOString(); }, pattern: /[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[1-2][0-9]|3[0-1])/, capture: /([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])/ }, json: { encode: toJson, decode: fromJson, - is: isObject, + is: is(Object), equals: equals, pattern: /[^/]*/ }, @@ -72,17 +72,18 @@ class ParamTypes { constructor() { // Register default types. Store them in the prototype of this.types. - const makeType = (definition, name) => new Type(extend({name: name}, definition)); + const makeType = (definition, name) => new Type(extend({ name }, definition)); this.types = inherit(map(this.defaultTypes, makeType), {}); } type(name, definition?: any, definitionFn?: Function) { if (!isDefined(definition)) return this.types[name]; - if (this.types.hasOwnProperty(name)) throw new Error("A type named '" + name + "' has already been defined."); + if (this.types.hasOwnProperty(name)) throw new Error(`A type named '${name}' has already been defined.`); + + this.types[name] = new Type(extend({ name }, definition)); - this.types[name] = new Type(extend({ name: name }, definition)); if (definitionFn) { - this.typeQueue.push({ name: name, def: definitionFn }); + this.typeQueue.push({ name, def: definitionFn }); if (!this.enqueue) this._flushTypeQueue(); } return this; diff --git a/src/params/paramValues.ts b/src/params/paramValues.ts deleted file mode 100644 index 3fd79035b..000000000 --- a/src/params/paramValues.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {IParamsPath} from "../path/interface"; -import {IRawParams} from "../params/interface"; - -import {extend, find} from "../common/common"; -/** - * This class closes over a Path and encapsulates the parameter values from the Path's Nodes. - * The param values for the path are flattened and copied to the resulting ParamValues object. - * Param values for a specific state are exposed with the $byState(stateName) function. - */ -const stateNameMatches = (stateName: string) => (node) => node.state.name === stateName; - -export default class ParamValues implements IRawParams { - [key: string]: any - private $$path: IParamsPath; - - constructor($$path: IParamsPath) { - Object.defineProperty(this, "$$path", { value: $$path }); - $$path.nodes().reduce((memo, node) => extend(memo, node.ownParams), this); - } - - /** Gets the param values for a given state (by state name) */ - $byState(stateName: string) { - let found = find(this.$$path.nodes(), stateNameMatches(stateName)); - return found && found.ownParams; - } - - /** Returns a new ParamValues object which closes over a subpath of this ParamValue's Path. */ - $isolateRootTo(stateName: string): ParamValues { - return new ParamValues(this.$$path.pathFromRootTo(stateName)); - } -} diff --git a/src/params/type.ts b/src/params/type.ts index 02c54231b..6ece253e8 100644 --- a/src/params/type.ts +++ b/src/params/type.ts @@ -1,5 +1,54 @@ import {extend, isArray, isDefined, filter, map} from "../common/common"; +/** + * Wraps up a `Type` object to handle array values. + */ +function ArrayType(type, mode) { + // Wrap non-array value as array + function arrayWrap(val): any[] { return isArray(val) ? val : (isDefined(val) ? [ val ] : []); } + + // Unwrap array value for "auto" mode. Return undefined for empty array. + function arrayUnwrap(val) { + switch(val.length) { + case 0: return undefined; + case 1: return mode === "auto" ? val[0] : val; + default: return val; + } + } + + // Wraps type (.is/.encode/.decode) functions to operate on each value of an array + function arrayHandler(callback, allTruthyMode?: boolean) { + return function handleArray(val) { + let arr = arrayWrap(val); + var result = map(arr, callback); + return (allTruthyMode === true) ? filter(result, val => !val).length === 0 : arrayUnwrap(result); + }; + } + + // Wraps type (.equals) functions to operate on each value of an array + function arrayEqualsHandler(callback) { + return function handleArray(val1, val2) { + var left = arrayWrap(val1), right = arrayWrap(val2); + if (left.length !== right.length) return false; + for (var i = 0; i < left.length; i++) { + if (!callback(left[i], right[i])) return false; + } + return true; + }; + } + + ['encode', 'decode', 'equals', '$normalize'].map(name => { + this[name] = (name === 'equals' ? arrayEqualsHandler : arrayHandler)(type[name].bind(type)); + }); + + extend(this, { + name: type.name, + pattern: type.pattern, + is: arrayHandler(type.is.bind(type), true), + $arrayMode: mode + }); +} + /** * @ngdoc object * @name ui.router.util.type:Type @@ -31,7 +80,7 @@ import {extend, isArray, isDefined, filter, map} from "../common/common"; * @returns {Object} Returns a new `Type` object. */ export default class Type { - pattern: RegExp; + pattern: RegExp = /.*/; name: string; raw: boolean; @@ -116,7 +165,7 @@ export default class Type { } toString() { - return "{Type:" + this.name + "}"; + return `{Type:${this.name}}`; } /** Given an encoded string, or a decoded object, returns a decoded object */ @@ -137,61 +186,6 @@ export default class Type { $asArray(mode, isSearch) { if (!mode) return this; if (mode === "auto" && !isSearch) throw new Error("'auto' array mode is for query parameters only"); - - function ArrayType(type, mode) { - function bindTo(type, callbackName) { - return function() { - return type[callbackName].apply(type, arguments); - }; - } - - // Wrap non-array value as array - function arrayWrap(val): any[] { return isArray(val) ? val : (isDefined(val) ? [ val ] : []); } - // Unwrap array value for "auto" mode. Return undefined for empty array. - function arrayUnwrap(val) { - switch(val.length) { - case 0: return undefined; - case 1: return mode === "auto" ? val[0] : val; - default: return val; - } - } - function falsey(val) { return !val; } - - // Wraps type (.is/.encode/.decode) functions to operate on each value of an array - function arrayHandler(callback, allTruthyMode?: boolean) { - return function handleArray(val) { - let arr = arrayWrap(val); - var result = map(arr, callback); - if (allTruthyMode === true) - return filter(result, falsey).length === 0; - return arrayUnwrap(result); - }; - } - - // Wraps type (.equals) functions to operate on each value of an array - function arrayEqualsHandler(callback) { - return function handleArray(val1, val2) { - var left = arrayWrap(val1), right = arrayWrap(val2); - if (left.length !== right.length) return false; - for (var i = 0; i < left.length; i++) { - if (!callback(left[i], right[i])) return false; - } - return true; - }; - } - - this.encode = arrayHandler(bindTo(type, 'encode')); - this.decode = arrayHandler(bindTo(type, 'decode')); - this.is = arrayHandler(bindTo(type, 'is'), true); - this.equals = arrayEqualsHandler(bindTo(type, 'equals')); - this.pattern = type.pattern; - this.$normalize = arrayHandler(bindTo(type, '$normalize')); - this.name = type.name; - this.$arrayMode = mode; - } - return new ArrayType(this, mode); } -} - -Type.prototype.pattern = /.*/; +} \ No newline at end of file diff --git a/src/path/interface.ts b/src/path/interface.ts index d3ea04624..9f6f92133 100644 --- a/src/path/interface.ts +++ b/src/path/interface.ts @@ -1,49 +1,29 @@ - -import Path from "./../path/path"; - -import {IState} from "../state/interface"; +import {State} from "../state/state"; +import Node from "../path/node"; import {ViewConfig} from "../view/view"; import {IRawParams} from "../params/interface"; -import ParamValues from "../params/paramValues"; import {IResolvables} from "../resolve/interface"; import ResolveContext from "../resolve/resolveContext"; import ResolveInjector from "../resolve/resolveInjector"; -/** Base data (contains a state) for a node in a Path */ -export interface INode { - state: IState; -} -/** A basic Path. Each node contains an IState */ -export interface IPath extends Path {} - -/** Contains INode base data plus raw params values for the node */ -export interface IParamsNode extends INode { - ownParams: IRawParams; +/** Contains Node base data plus raw params values for the node */ +export interface IParamsNode extends Node { + values: IRawParams; } -/** A Path of IParamsNode(s) */ -export interface IParamsPath extends Path {} - /** Contains IParamsNode data, plus Resolvables for the node */ export interface IResolveNode extends IParamsNode { - ownResolvables: IResolvables; + resolves: IResolvables; } -/** A Path of IResolveNode(s) */ -export interface IResolvePath extends Path {} /** Contains IResolveNode data, plus a ResolveContext and ParamsValues (bound to a full path) for the node, */ export interface ITransNode extends IResolveNode { resolveContext: ResolveContext; resolveInjector: ResolveInjector; views: ViewConfig[]; - paramValues: ParamValues; + // paramValues: ParamValues; } -/** - * A Path of ITransNode(s). Each node contains raw param values, Resolvables and also a ResolveContext - * and ParamValues which are bound to the overall path, but isolated to the node. - */ -export interface ITransPath extends Path {} diff --git a/src/path/module.ts b/src/path/module.ts index fc0c970a1..739d6d068 100644 --- a/src/path/module.ts +++ b/src/path/module.ts @@ -1,5 +1,5 @@ -import * as path from "./path"; -export {path}; +import * as Node from "./node"; +export {Node}; import * as pathFactory from "./pathFactory"; export {pathFactory}; diff --git a/src/path/node.ts b/src/path/node.ts new file mode 100644 index 000000000..88fd87002 --- /dev/null +++ b/src/path/node.ts @@ -0,0 +1,61 @@ +/// +import {extend, pick, prop, propEq, pairs, applyPairs, map, find, allTrueR, values} from "../common/common"; +import {State} from "../state/state"; +import Param from "../params/param"; +import Type from "../params/type"; +import {IRawParams} from "../params/interface"; +import Resolvable from "../resolve/resolvable"; +import ResolveContext from "../resolve/resolveContext"; +import ResolveInjector from "../resolve/resolveInjector"; +import {ViewConfig} from "../view/view"; + +export default class Node { + + public schema: Param[]; + public values: { [key: string]: any }; + public resolves: any; + public views: ViewConfig[]; + public resolveContext: ResolveContext; + public resolveInjector: ResolveInjector; + + // Possibly extract this logic into an intermediary object that maps states to nodes + constructor(public state: State, params: IRawParams, resolves: any = {}) { + // Object.freeze(extend(this, { ... })) + this.schema = state.parameters({ inherit: false }); + + const getParamVal = (paramDef: Param) => [ paramDef.id, paramDef.value(params[paramDef.id]) ]; + this.values = this.schema.reduce((memo, pDef) => applyPairs(memo, getParamVal(pDef)), {}); + + this.resolves = extend(map(state.resolve, (fn: Function, name: string) => new Resolvable(name, fn, state)), resolves); + + const makeViewConfig = (viewDeclarationObj, rawViewName) => + new ViewConfig({ rawViewName, viewDeclarationObj, context: state, params}); + this.views = values(map(state.views, makeViewConfig)); + } + + parameter(name: string): Param { + return find(this.schema, propEq("id", name)); + } + + equals(node: Node, keys = this.schema.map(prop('id'))): boolean { + const paramValsEq = key => this.parameter(key).type.equals(this.values[key], node.values[key]); + return this.state === node.state && keys.map(paramValsEq).reduce(allTrueR, true); + } + + static clone(node: Node, update: any = {}) { + return new Node(node.state, (update.values || node.values), (update.resolves || node.resolves)); + } + + /** + * Returns a new path which is a subpath of this path. The new path starts from root and contains any nodes + * that match the nodes in the second path. Nodes are compared using their state properties. + * @param first {Node[]} + * @param second {Node[]} + * @returns {Node[]} + */ + static matching(first: Node[], second: Node[]): Node[] { + let matchedCount = first.reduce((prev, node, i) => + prev === i && i < second.length && node.state === second[i].state ? i + 1 : prev, 0); + return first.slice(matchedCount); + } +} \ No newline at end of file diff --git a/src/path/path.ts b/src/path/path.ts deleted file mode 100644 index 68ad4dfd8..000000000 --- a/src/path/path.ts +++ /dev/null @@ -1,96 +0,0 @@ -/// - -import {extend, isString, find, pipe, parse, prop, eq} from "../common/common"; -import {INode} from "./interface"; - -import {IState, IStateDeclaration, IStateOrName} from "../state/interface"; - -const stateMatches = (state: IState|IStateDeclaration) => (node) => node.state === state || node.state.self === state; -const stateNameMatches = (stateName: string) => (node) => node.state.name === stateName; -const shallowNodeCopy = node => extend({}, node); - -/** - * A Path Object represents a Path of nested States within the State Hierarchy. - * Each node of the path holds the IState object, and additional data, according - * to the use case. - * - * A Path can be used to construct new Paths based on the current Path via the concat - * and slice helper methods. - * - * @param _nodes [array]: an array of INode data - */ -export default class Path { - constructor(private _nodes: NODE[]) { } - - /** - * returns a subpath of this path from the root path element up to and including the toState parameter. - * Each node of the subpath is a shallow copy of the original node. - * - * @param toState A state or name of a state - */ - pathFromRootTo(toState: IStateOrName): Path { - let predicate = isString(toState) ? stateNameMatches( toState) : stateMatches( toState); - let node = find(this._nodes, predicate); - let elementIdx = this._nodes.indexOf(node); - if (elementIdx === -1) throw new Error("This Path does not contain the toPathElement"); - return this.slice(0, elementIdx + 1); - } - - /** - * Returns a new Path which contains this Path's nodes, concatenated with another Path's nodes. - * Each node of the concatenated Path is a shallow copy of the original nodes. - */ - concat(path: Path): Path { - return new Path(this._nodes.concat(path._nodes).map(shallowNodeCopy)); - } - - /** - * Returns a new Path which is a subpath of this Path. The new Path contains nodes starting from "start" and - * ending at "end". Each node of the subpath is a shallow copy of the original Path's node. - */ - slice(start: number, end?: number): Path { - return new Path(this._nodes.slice(start, end).map(shallowNodeCopy)); - } - - /** - * Returns a new Path which is a copy of this Path, but with nodes in reverse order. - * Each node in the reversed path is a shallow copy of the original Path's node. - */ - reverse(): Path { - let copy = [].concat(this._nodes).map(shallowNodeCopy); - copy.reverse(); - return new Path(copy); - } - - /** Returns the "state" property of each node in this Path */ - states(): IState[] { - return this._nodes.map(prop("state")); - } - - /** Gets the first node that exactly matches the given state */ - nodeForState(state: IStateOrName): NODE { - let propName = (isString(state) ? "state.name" : "state"); - return find(this._nodes, pipe(parse(propName), eq(state))); - } - - /** Returns the Path's nodes wrapped in a new array */ - nodes(): NODE[] { - return [].concat(this._nodes); - } - - /** Returns the last node in the Path */ - last(): NODE { - return this._nodes.length ? this._nodes[this._nodes.length - 1] : null; - } - - /** Returns a new path where each path element is mapped using the nodeMapper function */ - adapt(nodeMapper: (NODE, idx?) => T): Path { - var adaptedNodes = this._nodes.map(nodeMapper); - return new Path(adaptedNodes); - } - - toString() { - var elements = this._nodes.map(e => e.state.name).join(", "); - return `Path([${elements}])`; - } -} \ No newline at end of file diff --git a/src/path/pathFactory.ts b/src/path/pathFactory.ts index 4b58aae22..5e4d737a8 100644 --- a/src/path/pathFactory.ts +++ b/src/path/pathFactory.ts @@ -1,15 +1,13 @@ -import {map, extend, pairs, prop, pick, omit, not, curry} from "../common/common"; +import {map, extend, find, pairs, prop, propEq, pick, omit, not, curry, tail, applyPairs, mergeR} from "../common/common"; import {IRawParams} from "../params/interface"; -import ParamValues from "../params/paramValues"; import {ITreeChanges} from "../transition/interface"; -import {IState} from "../state/interface"; +import {State} from "../state/state"; import TargetState from "../state/targetState"; -import {INode, IParamsNode, IResolveNode, ITransNode, IParamsPath, IResolvePath, ITransPath} from "../path/interface"; -import Path from "../path/path"; +import Node from "../path/node"; import Resolvable from "../resolve/resolvable"; import ResolveContext from "../resolve/resolveContext"; @@ -18,47 +16,28 @@ import {ViewConfig} from "../view/view"; import ResolveInjector from "../resolve/resolveInjector"; /** - * This class contains functions which convert TargetStates, Nodes and Paths from one type to another. + * This class contains functions which convert TargetStates, Nodes and paths from one type to another. */ export default class PathFactory { - constructor() { } - /** Given a TargetState, create an IParamsPath */ - static makeParamsPath(ref: TargetState): IParamsPath { - let states = ref ? ref.$state().path : []; - let params = ref ? ref.params() : {}; - const toParamsNodeFn: (IState) => IParamsNode = PathFactory.makeParamsNode(params); - return new Path(states.map(toParamsNodeFn)); - } + constructor() { } - /** Given a IParamsPath, create an TargetState */ - static makeTargetState(path: IParamsPath): TargetState { - let state = path.last().state; - return new TargetState(state, state, new ParamValues(path)); + /** Given a Node[], create an TargetState */ + static makeTargetState(path: Node[]): TargetState { + let state = tail(path).state; + return new TargetState(state, state, path.map(prop("values")).reduce(mergeR, {})); } - /* Given params and a state, creates an IParamsNode */ - static makeParamsNode = curry(function(params: IRawParams, state: IState) { - return { - state, - ownParams: state.ownParams.$$values(params) - }; - }); - - /** Given an IParamsNode, make an IResolveNode by creating resolvables for resolves on the state's declaration */ - static makeResolveNode(node: IParamsNode): IResolveNode { - const makeResolvable = (_node: INode) => (resolveFn: Function, name: string) => new Resolvable(name, resolveFn, _node.state); - let ownResolvables = map(node.state.resolve || {}, makeResolvable(node)); - return extend({}, node, {ownResolvables}); - } + /* Given params and a state, creates an Node */ + static makeParamsNode = curry((params: IRawParams, state: State) => new Node(state, params)); - /** Given a fromPath: ITransPath and a TargetState, builds a toPath: IParamsPath */ - static buildToPath(fromPath: ITransPath, targetState: TargetState): IParamsPath { + /** Given a fromPath: Node[] and a TargetState, builds a toPath: Node[] */ + static buildToPath(fromPath: Node[], targetState: TargetState): Node[] { let toParams = targetState.params(); - const toParamsNodeFn: (IState) => IParamsNode = PathFactory.makeParamsNode(toParams); - let toPath: IParamsPath = new Path(targetState.$state().path.map(toParamsNodeFn)); - if (targetState.options().inherit) - toPath = PathFactory.inheritParams(fromPath, toPath, Object.keys(toParams)); + const toParamsNodeFn: (State) => Node = PathFactory.makeParamsNode(toParams); + let toPath: Node[] = targetState.$state().path.map(toParamsNodeFn); + + if (targetState.options().inherit) toPath = PathFactory.inheritParams(fromPath, toPath, Object.keys(toParams)); return toPath; } @@ -73,102 +52,83 @@ export default class PathFactory { * caller, for instance, $state.transitionTo(..., toParams). If a key was found in toParams, * it is not inherited from the fromPath. */ - static inheritParams(fromPath: IParamsPath, toPath: IParamsPath, toKeys: string[] = []): IParamsPath { - function nodeParamVals(path: IParamsPath, state: IState): IRawParams { - let node = path.nodeForState(state); - return extend({}, node && node.ownParams); + static inheritParams(fromPath: Node[], toPath: Node[], toKeys: string[] = []): Node[] { + function nodeParamVals(path: Node[], state: State): IRawParams { + let node = find(path, propEq('state', state)); + return extend({}, node && node.values); } - - /** - * Given an IParamsNode "toNode", return a new IParamsNode with param values inherited from the + + /** + * Given an Node "toNode", return a new Node with param values inherited from the * matching node in fromPath. Only inherit keys that aren't found in "toKeys" from the node in "fromPath"" */ - let makeInheritedParamsNode = curry(function(_fromPath: IParamsPath, _toKeys: string[], toNode: IParamsNode): IParamsNode { + let makeInheritedParamsNode = curry(function(_fromPath: Node[], _toKeys: string[], toNode: Node): Node { // All param values for the node (may include default key/vals, when key was not found in toParams) - let toParamVals = extend({}, toNode && toNode.ownParams); + let toParamVals = extend({}, toNode && toNode.values); // limited to only those keys found in toParams let incomingParamVals = pick(toParamVals, _toKeys); toParamVals = omit(toParamVals, _toKeys); let fromParamVals = nodeParamVals(_fromPath, toNode.state) || {}; // extend toParamVals with any fromParamVals, then override any of those those with incomingParamVals let ownParamVals: IRawParams = extend(toParamVals, fromParamVals, incomingParamVals); - return { state: toNode.state, ownParams: ownParamVals }; + return new Node(toNode.state, ownParamVals); }); - + // The param keys specified by the incoming toParams - return new Path( toPath.nodes().map(makeInheritedParamsNode(fromPath, toKeys))); + return toPath.map(makeInheritedParamsNode(fromPath, toKeys)); } /** - * Given an IResolvePath, upgrades the path to an ITransPath. Each node is assigned a ResolveContext + * Given a path, upgrades the path to a Node[]. Each node is assigned a ResolveContext * and ParamValues object which is bound to the whole path, but closes over the subpath from root to the node. * The views are also added to the node. */ - static bindTransNodesToPath(resolvePath: IResolvePath): ITransPath { + static bindTransNodesToPath(resolvePath: Node[]): Node[] { let resolveContext = new ResolveContext(resolvePath); - let paramValues = new ParamValues(resolvePath); - let transPath = resolvePath; - - // TODO: this doesn't belong here. - function makeViews(node: ITransNode) { - let context = node.state, params = node.paramValues; - const makeViewConfig = ([rawViewName, viewDeclarationObj]) => - new ViewConfig({rawViewName, viewDeclarationObj, context, params}); - return pairs(node.state.views || {}).map(makeViewConfig); - } + // let paramValues = new ParamValues(resolvePath); // Attach bound resolveContext and paramValues to each node // Attach views to each node - transPath.nodes().forEach((node: ITransNode) => { - node.resolveContext = resolveContext.isolateRootTo(node.state); - node.resolveInjector = new ResolveInjector(node.resolveContext, node.state); - node.paramValues = paramValues.$isolateRootTo(node.state.name); - node.ownResolvables["$stateParams"] = new Resolvable("$stateParams", () => node.paramValues, node.state); - node.views = makeViews(node); - } - ); - - return transPath; + resolvePath.forEach((node: Node) => { + node.resolveContext = resolveContext.isolateRootTo(node.state); + node.resolveInjector = new ResolveInjector(node.resolveContext, node.state); + // node.paramValues = paramValues.$isolateRootTo(node.state.name); + node.resolves["$stateParams"] = new Resolvable("$stateParams", () => node.values, node.state); + }); + + return resolvePath; } /** * Computes the tree changes (entering, exiting) between a fromPath and toPath. */ - static treeChanges(fromPath: ITransPath, toPath: IParamsPath, reloadState: IState): ITreeChanges { - function nonDynamicParams(state) { - return state.params.$$filter(not(prop('dynamic'))); - } + static treeChanges(fromPath: Node[], toPath: Node[], reloadState: State): ITreeChanges { + let keep = 0, max = Math.min(fromPath.length, toPath.length); + const staticParams = (state) => state.parameters({ inherit: false }).filter(not(prop('dynamic'))).map(prop('id')); + const nodesMatch = (node1: Node, node2: Node) => node1.equals(node2, staticParams(node1.state)); - let fromNodes = fromPath.nodes(); - let toNodes = toPath.nodes(); - let keep = 0, max = Math.min(fromNodes.length, toNodes.length); - - const nodesMatch = (node1: IParamsNode, node2: IParamsNode) => - node1.state === node2.state && nonDynamicParams(node1.state).$$equals(node1.ownParams, node2.ownParams); - - while (keep < max && fromNodes[keep].state !== reloadState && nodesMatch(fromNodes[keep], toNodes[keep])) { + while (keep < max && fromPath[keep].state !== reloadState && nodesMatch(fromPath[keep], toPath[keep])) { keep++; } /** Given a retained node, return a new node which uses the to node's param values */ - function applyToParams(retainedNode: ITransNode, idx: number): ITransNode { - let toNodeParams = toPath.nodes()[idx].ownParams; - return extend({}, retainedNode, { ownParams: toNodeParams }); + function applyToParams(retainedNode: Node, idx: number): Node { + return Node.clone(retainedNode, { values: toPath[idx].values }); } - let from: ITransPath, retained: ITransPath, exiting: ITransPath, entering: ITransPath, to: ITransPath; + let from: Node[], retained: Node[], exiting: Node[], entering: Node[], to: Node[]; // intermediate vars - let retainedWithToParams: ITransPath, enteringResolvePath: IResolvePath, toResolvePath: IResolvePath; + let retainedWithToParams: Node[], enteringResolvePath: Node[], toResolvePath: Node[]; from = fromPath; retained = from.slice(0, keep); exiting = from.slice(keep); // Create a new retained path (with shallow copies of nodes) which have the params of the toPath mapped - retainedWithToParams = retained.adapt(applyToParams); - enteringResolvePath = toPath.slice(keep).adapt(PathFactory.makeResolveNode); + retainedWithToParams = retained.map(applyToParams); + enteringResolvePath = toPath.slice(keep); // "toResolvePath" is "retainedWithToParams" concat "enteringResolvePath". - toResolvePath = ( retainedWithToParams).concat(enteringResolvePath); + toResolvePath = (retainedWithToParams).concat(enteringResolvePath); // "to: is "toResolvePath" with ParamValues/ResolveContext added to each node and bound to the path context to = PathFactory.bindTransNodesToPath(toResolvePath); @@ -178,5 +138,4 @@ export default class PathFactory { return { from, to, retained, exiting, entering }; } - } diff --git a/src/resolve/resolvable.ts b/src/resolve/resolvable.ts index ea3839ba9..a0d69c377 100644 --- a/src/resolve/resolvable.ts +++ b/src/resolve/resolvable.ts @@ -4,13 +4,11 @@ import trace from "../common/trace"; import {runtime} from "../common/angular1" import {IPromise} from "angular"; -import {IState} from "../state/interface"; +import {State} from "../state/state"; import {IResolvables, IOptions1} from "./interface" import ResolveContext from "./resolveContext" -import {IResolvePath} from "../path/interface" - /** * The basic building block for the resolve system. * @@ -20,11 +18,11 @@ import {IResolvePath} from "../path/interface" * Resolvable.get() either retrieves the Resolvable's existing promise, or else invokes resolve() (which invokes the * resolveFn) and returns the resulting promise. * - * Resolvable.get() and Resolvable.resolve() both execute within a context Path, which is passed as the first + * Resolvable.get() and Resolvable.resolve() both execute within a context path, which is passed as the first * parameter to those fns. */ export default class Resolvable { - constructor(name: string, resolveFn: Function, state: IState) { + constructor(name: string, resolveFn: Function, state: State) { this.name = name; this.resolveFn = resolveFn; this.state = state; @@ -33,7 +31,7 @@ export default class Resolvable { name: string; resolveFn: Function; - state: IState; + state: State; deps: string[]; promise: IPromise = undefined; diff --git a/src/resolve/resolveContext.ts b/src/resolve/resolveContext.ts index 966627170..f89e5d4b9 100644 --- a/src/resolve/resolveContext.ts +++ b/src/resolve/resolveContext.ts @@ -1,14 +1,14 @@ /// -import {IInjectable, filter, map, noop, defaults, extend, prop, pick, omit, isString, isObject} from "../common/common"; +import {IInjectable, find, filter, map, noop, tail, defaults, extend, prop, propEq, pick, omit, isString, isObject} from "../common/common"; import trace from "../common/trace"; import {runtime} from "../common/angular1"; import {IPromise} from "angular"; -import {IResolvePath, IResolveNode} from "../path/interface"; +import Node from "../path/node"; import {IPromises, IResolvables, ResolvePolicy, IOptions1} from "./interface"; import Resolvable from "./resolvable"; -import {IState} from "../state/interface"; +import {State} from "../state/state"; // TODO: make this configurable let defaultResolvePolicy = ResolvePolicy[ResolvePolicy.LAZY]; @@ -16,8 +16,24 @@ let defaultResolvePolicy = ResolvePolicy[ResolvePolicy.LAZY]; interface IPolicies { [key: string]: string; } export default class ResolveContext { - constructor(private _path: IResolvePath) { } - + + private _nodeFor: Function; + private _pathTo: Function; + + constructor(private _path: Node[]) { + extend(this, { + _nodeFor(state: State): Node { + return find(this._path, propEq('state', state)); + }, + _pathTo(state: State): Node[] { + let node = this._nodeFor(state); + let elementIdx = this._path.indexOf(node); + if (elementIdx === -1) throw new Error("This path does not contain the state"); + return this._path.slice(0, elementIdx + 1); + } + }); + } + /** * Gets the available Resolvables for the last element of this path. * @@ -39,50 +55,51 @@ export default class ResolveContext { * state({ name: 'G.G2', resolve: { _G: function(_G) { return _G + "G2"; } } }); * where injecting _G into a controller will yield "GG2" */ - getResolvables(state?: IState, options?: any): IResolvables { + getResolvables(state?: State, options?: any): IResolvables { options = defaults(options, { omitOwnLocals: [] }); - let path: IResolvePath = (state ? this._path.pathFromRootTo(state) : this._path); - let last = path.last(); - - return path.nodes().reduce((memo, node) => { + + const offset = find(this._path, propEq('')); + const path = (state ? this._pathTo(state) : this._path); + const last = tail(path); + + return path.reduce((memo, node) => { let omitProps = (node === last) ? options.omitOwnLocals : []; - let filteredResolvables = omit(node.ownResolvables, omitProps); + let filteredResolvables = omit(node.resolves, omitProps); return extend(memo, filteredResolvables); }, {}); } /** Inspects a function `fn` for its dependencies. Returns an object containing any matching Resolvables */ - getResolvablesForFn(fn: IInjectable, resolveContext: ResolveContext = this): {[key: string]: Resolvable} { + getResolvablesForFn(fn: IInjectable, resolveContext: ResolveContext = this): { [key: string]: Resolvable } { let deps = runtime.$injector.annotate( fn); return pick(resolveContext.getResolvables(), deps); } - isolateRootTo(state: IState): ResolveContext { - return new ResolveContext(this._path.pathFromRootTo(state)); + isolateRootTo(state: State): ResolveContext { + return new ResolveContext(this._pathTo(state)); } - addResolvables(resolvables: IResolvables, state: IState) { - let node = this._path.nodeForState(state); - extend(node.ownResolvables, resolvables); + addResolvables(resolvables: IResolvables, state: State) { + extend(this._nodeFor(state).resolves, resolvables); } /** Gets the resolvables declared on a particular state */ - getOwnResolvables(state: IState): IResolvables { - return extend({}, this._path.nodeForState(state).ownResolvables); + getOwnResolvables(state: State): IResolvables { + return extend({}, this._nodeFor(state).resolves); } - // Returns a promise for an array of resolved Path Element promises + // Returns a promise for an array of resolved path Element promises resolvePath(options: IOptions1 = {}): IPromise { trace.traceResolvePath(this._path, options); - const promiseForNode = (node: IResolveNode) => this.resolvePathElement(node.state, options); - return runtime.$q.all( map(this._path.nodes(), promiseForNode)).then(noop); + const promiseForNode = (node: Node) => this.resolvePathElement(node.state, options); + return runtime.$q.all( map(this._path, promiseForNode)).then(noop); } // returns a promise for all the resolvables on this PathElement // options.resolvePolicy: only return promises for those Resolvables which are at // the specified policy, or above. i.e., options.resolvePolicy === 'lazy' will // resolve both 'lazy' and 'eager' resolves. - resolvePathElement(state: IState, options: IOptions1 = {}): IPromise { + resolvePathElement(state: State, options: IOptions1 = {}): IPromise { // The caller can request the path be resolved for a given policy and "below" let policy: string = options && options.resolvePolicy; let policyOrdinal: number = ResolvePolicy[policy || defaultResolvePolicy]; @@ -102,7 +119,7 @@ export default class ResolveContext { /** - * Injects a function given the Resolvables available in the IResolvePath, from the first node + * Injects a function given the Resolvables available in the path, from the first node * up to the node for the given state. * * First it resolves all the resolvable depencies. When they are done resolving, it invokes @@ -110,12 +127,12 @@ export default class ResolveContext { * * @return a promise for the return value of the function. * - * @param state: The state context object (within the Path) + * @param state: The state context object (within the path) * @param fn: the function to inject (i.e., onEnter, onExit, controller) * @param locals: are the angular $injector-style locals to inject * @param options: options (TODO: document) */ - invokeLater(state: IState, fn: IInjectable, locals: any = {}, options: IOptions1 = {}): IPromise { + invokeLater(state: State, fn: IInjectable, locals: any = {}, options: IOptions1 = {}): IPromise { let isolateCtx = this.isolateRootTo(state); let resolvables = this.getResolvablesForFn(fn, isolateCtx); trace.tracePathElementInvoke(state, fn, Object.keys(resolvables), extend({when: "Later"}, options)); @@ -132,21 +149,21 @@ export default class ResolveContext { } /** - * Immediately injects a function with the dependent Resolvables available in the IResolvePath, from + * Immediately injects a function with the dependent Resolvables available in the path, from * the first node up to the node for the given state. * * If a Resolvable is not yet resolved, then null is injected in place of the resolvable. * * @return the return value of the function. * - * @param state: The state context object (within the Path) + * @param state: The state context object (within the path) * @param fn: the function to inject (i.e., onEnter, onExit, controller) * @param locals: are the angular $injector-style locals to inject * @param options: options (TODO: document) */ // Injects a function at this PathElement level with available Resolvables // Does not wait until all Resolvables have been resolved; you must call PathElement.resolve() (or manually resolve each dep) first - invokeNow(state: IState, fn: IInjectable, locals: any, options: any = {}) { + invokeNow(state: State, fn: IInjectable, locals: any, options: any = {}) { let isolateCtx = this.isolateRootTo(state); let resolvables = this.getResolvablesForFn(fn, isolateCtx); trace.tracePathElementInvoke(state, fn, Object.keys(resolvables), extend({when: "Now "}, options)); diff --git a/src/resolve/resolveInjector.ts b/src/resolve/resolveInjector.ts index 67da206dd..1cd183eee 100644 --- a/src/resolve/resolveInjector.ts +++ b/src/resolve/resolveInjector.ts @@ -1,10 +1,10 @@ import {map} from "../common/common"; import ResolveContext from "../resolve/resolveContext"; -import {IState} from "../state/interface"; +import {State} from "../state/state"; import Resolvable from "./resolvable"; export default class ResolveInjector { - constructor(private _resolveContext: ResolveContext, private _state: IState) { } + constructor(private _resolveContext: ResolveContext, private _state: State) { } /** Returns a promise to invoke an annotated function in the resolve context */ invokeLater(injectedFn, locals) { diff --git a/src/state/hooks/enterExitHooks.ts b/src/state/hooks/enterExitHooks.ts new file mode 100644 index 000000000..e8b5d3fbc --- /dev/null +++ b/src/state/hooks/enterExitHooks.ts @@ -0,0 +1,30 @@ +import {Transition} from "../../transition/transition"; + +export default class EnterExitHooks { + private transition: Transition; + + constructor(transition: Transition) { + this.transition = transition; + } + + registerHooks() { + this.registerOnEnterHooks(); + this.registerOnRetainHooks(); + this.registerOnExitHooks(); + } + + registerOnEnterHooks() { + let onEnterRegistration = (state) => this.transition.onEnter({to: state.name}, state.onEnter); + this.transition.entering().filter(state => !!state.onEnter).forEach(onEnterRegistration); + } + + registerOnRetainHooks() { + let onRetainRegistration = (state) => this.transition.onRetain({}, state.onRetain); + this.transition.retained().filter(state => !!state.onRetain).forEach(onRetainRegistration); + } + + registerOnExitHooks() { + let onExitRegistration = (state) => this.transition.onExit({from: state.name}, state.onExit); + this.transition.exiting().filter(state => !!state.onExit).forEach(onExitRegistration); + } +} diff --git a/src/state/hooks/resolveHooks.ts b/src/state/hooks/resolveHooks.ts new file mode 100644 index 000000000..d774bd341 --- /dev/null +++ b/src/state/hooks/resolveHooks.ts @@ -0,0 +1,41 @@ +import {extend, find, propEq, tail} from "../../common/common"; + +import {ResolvePolicy} from "../../resolve/interface"; + +import {Transition} from "../../transition/transition"; + + +let LAZY = ResolvePolicy[ResolvePolicy.LAZY]; +let EAGER = ResolvePolicy[ResolvePolicy.EAGER]; + +/** + * Registers Eager and Lazy (for entering states) resolve hooks + * + * * registers a hook that resolves EAGER resolves, for the To Path, onStart of the transition + * * registers a hook that resolves LAZY resolves, for each state, before it is entered + */ +export default class ResolveHooks { + constructor(private transition: Transition) { } + + registerHooks() { + let treeChanges = this.transition.treeChanges(); + + /** a function which resolves any EAGER Resolvables for a Path */ + $eagerResolvePath.$inject = ['$transition$']; + function $eagerResolvePath($transition$) { + return tail( treeChanges.to).resolveContext.resolvePath(extend({ transition: $transition$ }, { resolvePolicy: EAGER })); + } + + /** Returns a function which pre-resolves any LAZY Resolvables for a Node in a Path */ + $lazyResolveEnteringState.$inject = ['$state$', '$transition$']; + function $lazyResolveEnteringState($state$, $transition$) { + let node = find( treeChanges.entering, propEq('state', $state$)); + return node.resolveContext.resolvePathElement(node.state, extend({transition: $transition$}, { resolvePolicy: LAZY })); + } + + // Resolve eager resolvables before when the transition starts + this.transition.onStart({}, $eagerResolvePath, { priority: 1000 }); + // Resolve lazy resolvables before each state is entered + this.transition.onEnter({}, $lazyResolveEnteringState, { priority: 1000 }); + } +} diff --git a/src/state/hooks/transitionManager.ts b/src/state/hooks/transitionManager.ts new file mode 100644 index 000000000..6fc5830c1 --- /dev/null +++ b/src/state/hooks/transitionManager.ts @@ -0,0 +1,131 @@ +import {IPromise, IQService} from "angular"; +import {copy, prop} from "../../common/common"; +import Queue from "../../common/queue"; +import Param from "../../params/param"; + +import {ITreeChanges} from "../../transition/interface"; +import {Transition} from "../../transition/transition"; +import {TransitionRejection, RejectType} from "../../transition/rejectFactory"; + +import {IStateService, IStateDeclaration} from "../interface"; +import ViewHooks from "./viewHooks"; +import EnterExitHooks from "./enterExitHooks"; +import ResolveHooks from "./resolveHooks"; + +/** + * This class: + * + * * Takes a blank transition object and adds all the hooks necessary for it to behave like a state transition. + * + * * Runs the transition, returning a chained promise which: + * * transforms the resolved Transition.promise to the final destination state. + * * manages the rejected Transition.promise, checking for Dynamic or Redirected transitions + * + * * Registers a handler to update global $state data such as "active transitions" and "current state/params" + * + * * Registers view hooks, which maintain the list of active view configs and sync with/update the ui-views + * + * * Registers onEnter/onRetain/onExit hooks which delegate to the state's hooks of the same name, at the appropriate time + * + * * Registers eager and lazy resolve hooks + */ +export default class TransitionManager { + private treeChanges: ITreeChanges; + private enterExitHooks: EnterExitHooks; + private viewHooks: ViewHooks; + private resolveHooks: ResolveHooks; + + constructor( + private transition: Transition, + private $transitions, + private $urlRouter, + private $view, // service + private $state: IStateService, + private $stateParams, // service/obj + private $q: IQService, // TODO: get from runtime.$q + private activeTransQ: Queue, + private changeHistory: Queue + ) { + this.viewHooks = new ViewHooks(transition, $view); + this.enterExitHooks = new EnterExitHooks(transition); + this.resolveHooks = new ResolveHooks(transition); + + this.treeChanges = transition.treeChanges(); + } + + registerHooks() { + this.registerUpdateGlobalState(); + + this.viewHooks.registerHooks(); + this.enterExitHooks.registerHooks(); + this.resolveHooks.registerHooks(); + } + + runTransition(): IPromise { + this.activeTransQ.clear(); // TODO: nuke this + this.activeTransQ.enqueue(this.transition); + return this.transition.run() + .then((trans: Transition) => trans.to()) // resolve to the final state (TODO: good? bad?) + .catch(error => this.transRejected(error)) // if rejected, handle dynamic and redirect + .finally(() => this.activeTransQ.remove(this.transition)); + } + + registerUpdateGlobalState() { + this.transition.onFinish({}, this.updateGlobalState.bind(this), {priority: -10000}); + } + + updateGlobalState() { + let {treeChanges, transition, $state, changeHistory} = this; + // Update globals in $state + $state.$current = transition.$to(); + $state.current = $state.$current.self; + changeHistory.enqueue(treeChanges); + this.updateStateParams(); + } + + transRejected(error): (IStateDeclaration|IPromise) { + let {transition, $state, $stateParams, $q} = this; + // Handle redirect and abort + if (error instanceof TransitionRejection) { + if (error.type === RejectType.IGNORED) { + // Update $stateParmas/$state.params/$location.url if transition ignored, but dynamic params have changed. + let dynamic = $state.$current.parameters().filter(prop('dynamic')); + if (!Param.equals(dynamic, $stateParams, transition.params())) { + this.updateStateParams(); + } + return $state.current; + } + + if (error.type === RejectType.SUPERSEDED) { + if (error.redirected && error.detail instanceof Transition) { + let tMgr = this._redirectMgr(error.detail); + tMgr.registerHooks(); + return tMgr.runTransition(); + } + } + } + + this.$transitions.defaultErrorHandler()(error); + + return $q.reject(error); + } + + updateStateParams() { + let {transition, $urlRouter, $state, $stateParams} = this; + let options = transition.options(); + $state.params = transition.params(); + copy($state.params, $stateParams); + $stateParams.$sync().$off(); + + if (options.location && $state.$current.navigable) { + $urlRouter.push($state.$current.navigable.url, $stateParams, { replace: options.location === 'replace' }); + } + + $urlRouter.update(true); + } + + private _redirectMgr(redirect: Transition): TransitionManager { + let {$transitions, $urlRouter, $view, $state, $stateParams, $q, activeTransQ, changeHistory} = this; + return new TransitionManager(redirect, $transitions, $urlRouter, $view, $state, $stateParams, $q, activeTransQ, changeHistory); + } +} diff --git a/src/state/hooks/viewHooks.ts b/src/state/hooks/viewHooks.ts new file mode 100644 index 000000000..0915b2c6a --- /dev/null +++ b/src/state/hooks/viewHooks.ts @@ -0,0 +1,63 @@ +import {IPromise} from "angular"; +import {find, propEq, noop} from "../../common/common"; +import {annotateController, runtime} from "../../common/angular1"; + +import {ITreeChanges} from "../../transition/interface"; +import {Transition} from "../../transition/transition"; + +import {ViewConfig} from "../../view/view"; + +export default class ViewHooks { + private treeChanges: ITreeChanges; + private enteringViews: ViewConfig[]; + private exitingViews: ViewConfig[]; + private transition: Transition; + private $view; // service + + constructor(transition: Transition, $view) { + this.transition = transition; + this.$view = $view; + + this.treeChanges = transition.treeChanges(); + this.enteringViews = transition.views("entering"); + this.exitingViews = transition.views("exiting"); + } + + loadAllEnteringViews() { + const loadView = (vc: ViewConfig) => { + let resolveInjector = find(this.treeChanges.to, propEq('state', vc.context)).resolveInjector; + return > this.$view.load(vc, resolveInjector); + }; + return runtime.$q.all(this.enteringViews.map(loadView)).then(noop); + } + + loadAllControllerLocals() { + const loadLocals = (vc: ViewConfig) => { + let deps = annotateController(vc.controller); + let resolveInjector = find(this.treeChanges.to, propEq('state', vc.context)).resolveInjector; + function $loadControllerLocals() { } + $loadControllerLocals.$inject = deps; + return runtime.$q.all(resolveInjector.getLocals($loadControllerLocals)).then((locals) => vc.locals = locals); + }; + + let loadAllLocals = this.enteringViews.filter(vc => !!vc.controller).map(loadLocals); + return runtime.$q.all(loadAllLocals).then(noop); + } + + updateViews() { + let $view = this.$view; + this.exitingViews.forEach((viewConfig: ViewConfig) => $view.reset(viewConfig)); + this.enteringViews.forEach((viewConfig: ViewConfig) => $view.registerStateViewConfig(viewConfig)); + $view.sync(); + } + + registerHooks() { + if (this.enteringViews.length) { + this.transition.onStart({}, this.loadAllEnteringViews.bind(this)); + this.transition.onFinish({}, this.loadAllControllerLocals.bind(this)); + } + + if (this.exitingViews.length || this.enteringViews.length) + this.transition.onSuccess({}, this.updateViews.bind(this)); + } +} diff --git a/src/state/interface.ts b/src/state/interface.ts index 9d7399ba5..a2dcb1237 100644 --- a/src/state/interface.ts +++ b/src/state/interface.ts @@ -3,16 +3,17 @@ import {IPromise} from "angular"; import UrlMatcher from "../url/urlMatcher"; import {IRawParams, IParamsOrArray} from "../params/interface"; -import ParamSet from "../params/paramSet"; +import Param from "../params/param"; import {IContextRef} from "../view/interface"; import TargetState from "./targetState"; +import {State} from "./state"; import {ITransitionOptions} from "../transition/interface"; import {Transition} from "../transition/transition"; -export type IStateOrName = (string|IStateDeclaration|IState); +export type IStateOrName = (string|IStateDeclaration|State); /** Context obj, State-view definition, transition params */ export interface IStateViewConfig { @@ -34,7 +35,7 @@ export interface IViewDeclaration { } /** hash of strings->views */ -interface IViewDeclarations { [key: string]: IViewDeclaration; } +export interface IViewDeclarations { [key: string]: IViewDeclaration; } /** hash of strings->resolve fns */ export interface IResolveDeclarations { [key: string]: Function; } /** hash of strings->param declarations */ @@ -63,28 +64,9 @@ export interface IStateDeclaration extends IViewDeclaration { // TODO: finish defining state definition API. Maybe start with what's on Definitely Typed. } -/** internal state API */ -export interface IState { - name: string; - abstract: boolean; - parent: IState; - resolve: IResolveDeclarations; // name->Function - resolvePolicy: (string|Object); - url: UrlMatcher; - params: ParamSet; - ownParams: ParamSet; - views: IViewDeclarations; - self: IStateDeclaration; - root: () => IState; - navigable: IState; - path: IState[]; - data: any; - includes: (name: string) => boolean; -} - export interface IStateParams { $digest: () => void; - $inherit: (newParams, $current: IState, $to: IState) => IStateParams; + $inherit: (newParams, $current: State, $to: State) => IStateParams; $set: (params, url) => boolean; $sync: () => IStateParams; $off: () => IStateParams; @@ -110,12 +92,12 @@ export interface IStateProvider { export interface IStateService { params: any; // TODO: StateParams current: IStateDeclaration; - $current: IState; + $current: State; transition: Transition; - reload (stateOrName: IStateOrName): IPromise; + reload (stateOrName: IStateOrName): IPromise; targetState (identifier: IStateOrName, params: IParamsOrArray, options: ITransitionOptions): TargetState; - go (to: IStateOrName, params: IRawParams, options: ITransitionOptions): IPromise; - transitionTo (to: IStateOrName, toParams: IParamsOrArray, options: ITransitionOptions): IPromise; + go (to: IStateOrName, params: IRawParams, options: ITransitionOptions): IPromise; + transitionTo (to: IStateOrName, toParams: IParamsOrArray, options: ITransitionOptions): IPromise; is (stateOrName: IStateOrName, params?: IRawParams, options?: ITransitionOptions): boolean; includes (stateOrName: IStateOrName, params?: IRawParams, options?: ITransitionOptions): boolean; href (stateOrName: IStateOrName, params?: IRawParams, options?: IHrefOptions): string; diff --git a/src/state/module.ts b/src/state/module.ts index 3ef39244d..4c5d146d3 100644 --- a/src/state/module.ts +++ b/src/state/module.ts @@ -16,8 +16,8 @@ export {stateEvents}; import * as stateFilters from "./stateFilters"; export {stateFilters}; -import * as stateHandler from "./stateHandler"; -export {stateHandler}; +import * as transitionManager from "./hooks/transitionManager"; +export {transitionManager}; import * as stateMatcher from "./stateMatcher"; export {stateMatcher}; diff --git a/src/state/state.ts b/src/state/state.ts index a8e3e69cc..391c71852 100644 --- a/src/state/state.ts +++ b/src/state/state.ts @@ -1,14 +1,18 @@ -import {extend, defaults, copy, equalForKeys, forEach, ancestors, noop, isDefined, isObject, isString} from "../common/common"; +import { + extend, defaults, copy, equalForKeys, forEach, find, prop, + propEq, ancestors, noop, isDefined, isObject, isString, values +} from "../common/common"; import Queue from "../common/queue"; import {IServiceProviderFactory, IPromise} from "angular"; -import {annotateController} from "../common/angular1"; -import {IStateService, IState, IStateDeclaration, IStateOrName, IHrefOptions} from "./interface"; +import { + IStateService, IStateDeclaration, IStateOrName, IHrefOptions, + IViewDeclarations, IResolveDeclarations +} from "./interface"; import Glob from "./glob"; import StateQueueManager from "./stateQueueManager"; import StateBuilder from "./stateBuilder"; import StateMatcher from "./stateMatcher"; -import StateHandler from "./stateHandler"; import TargetState from "./targetState"; import {ITransitionService, ITransitionOptions, ITreeChanges} from "../transition/interface"; @@ -16,12 +20,17 @@ import {Transition} from "../transition/transition"; import {RejectFactory} from "../transition/rejectFactory"; import {defaultTransOpts} from "../transition/transitionService"; -import {IParamsPath, ITransPath} from "../path/interface"; -import Path from "../path/path"; +import Node from "../path/node"; import PathFactory from "../path/pathFactory"; import {IRawParams, IParamsOrArray} from "../params/interface"; +import TransitionManager from "./hooks/transitionManager"; +import paramTypes from "../params/paramTypes"; +import Param from "../params/param"; +import Type from "../params/type"; + +import UrlMatcher from "../url/urlMatcher"; import {ViewConfig} from "../view/view"; /** @@ -36,69 +45,93 @@ import {ViewConfig} from "../view/view"; * * @returns {Object} Returns a new `State` object. */ -export function State(config?: IStateDeclaration) { - extend(this, config); -} +export class State { + + public parent: State; + public name: string; + public abstract: boolean; + public resolve: IResolveDeclarations; + public resolvePolicy: any; + public url: UrlMatcher; + public params: { [key: string]: Param }; + public views: IViewDeclarations; + public self: IStateDeclaration; + public navigable: State; + public path: State[]; + public data: any; + public includes: (name: string) => boolean; + + constructor(config?: IStateDeclaration) { + extend(this, config); + // Object.freeze(this); + } -/** - * @ngdoc function - * @name ui.router.state.type:State#is - * @methodOf ui.router.state.type:State - * - * @description - * Compares the identity of the state against the passed value, which is either an object - * reference to the actual `State` instance, the original definition object passed to - * `$stateProvider.state()`, or the fully-qualified name. - * - * @param {Object} ref Can be one of (a) a `State` instance, (b) an object that was passed - * into `$stateProvider.state()`, (c) the fully-qualified name of a state as a string. - * @returns {boolean} Returns `true` if `ref` matches the current `State` instance. - */ -State.prototype.is = function(ref) { - return this === ref || this.self === ref || this.fqn() === ref; -}; + /** + * @ngdoc function + * @name ui.router.state.type:State#is + * @methodOf ui.router.state.type:State + * + * @description + * Compares the identity of the state against the passed value, which is either an object + * reference to the actual `State` instance, the original definition object passed to + * `$stateProvider.state()`, or the fully-qualified name. + * + * @param {Object} ref Can be one of (a) a `State` instance, (b) an object that was passed + * into `$stateProvider.state()`, (c) the fully-qualified name of a state as a string. + * @returns {boolean} Returns `true` if `ref` matches the current `State` instance. + */ + is(ref: State|IStateDeclaration|string): boolean { + return this === ref || this.self === ref || this.fqn() === ref; + } -/** - * @ngdoc function - * @name ui.router.state.type:State#fqn - * @methodOf ui.router.state.type:State - * - * @description - * Returns the fully-qualified name of the state, based on its current position in the tree. - * - * @returns {string} Returns a dot-separated name of the state. - */ -State.prototype.fqn = function() { - if (!this.parent || !(this.parent instanceof this.constructor)) { - return this.name; + /** + * @ngdoc function + * @name ui.router.state.type:State#fqn + * @methodOf ui.router.state.type:State + * + * @description + * Returns the fully-qualified name of the state, based on its current position in the tree. + * + * @returns {string} Returns a dot-separated name of the state. + */ + fqn(): string { + if (!this.parent || !(this.parent instanceof this.constructor)) return this.name; + let name = this.parent.fqn(); + return name ? name + "." + this.name : this.name; } - let name = this.parent.fqn(); - return name ? name + "." + this.name : this.name; -}; -/** - * @ngdoc function - * @name ui.router.state.type:State#root - * @methodOf ui.router.state.type:State - * - * @description - * Returns the root node of this state's tree. - * - * @returns {State} The root of this state's tree. - */ -State.prototype.root = function() { - let result = this; + /** + * @ngdoc function + * @name ui.router.state.type:State#root + * @methodOf ui.router.state.type:State + * + * @description + * Returns the root node of this state's tree. + * + * @returns {State} The root of this state's tree. + */ + root(): State { + return this.parent && this.parent.root() || this; + } - while (result.parent) { - result = result.parent; + parameters(opts?): Param[] { + opts = defaults(opts, { inherit: true }); + var inherited = opts.inherit && this.parent && this.parent.parameters() || []; + return inherited.concat(values(this.params)); } - return result; -}; -State.prototype.toString = function() { - return this.fqn(); -}; + parameter(id: string, opts: any = {}): Param { + return ( + this.url && this.url.parameter(id, opts) || + find(values(this.params), propEq('id', id)) || + opts.inherit && this.parent && this.parent.parameter(id) + ); + } + toString() { + return this.fqn(); + } +} /** * @ngdoc object @@ -124,7 +157,7 @@ State.prototype.toString = function() { $StateProvider.$inject = ['$urlRouterProvider', '$urlMatcherFactoryProvider']; function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { - let root: IState, states: {[key: string]: IState} = {}; + let root: State, states: { [key: string]: State } = {}; let $state: IStateService = function $state() {}; let matcher = new StateMatcher(states); @@ -380,16 +413,16 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { let invalidCallbacks: Function[] = []; this.onInvalid = onInvalid; - + /** * @ngdoc function * @name ui.router.state.$stateProvider#onInvalid * @methodOf ui.router.state.$stateProvider * * @description - * Registers a function to be injected and invoked when transitionTo has been called with an invalid + * Registers a function to be injected and invoked when transitionTo has been called with an invalid * state reference parameter - * + * * This function can be injected with one some special values: * - **`$to$`**: TargetState * - **`$from$`**: TargetState @@ -399,7 +432,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { * The function may optionally return a {TargetState} or a Promise for a TargetState. If one * is returned, it is treated as a redirect. */ - + function onInvalid(callback: Function) { invalidCallbacks.push(callback); } @@ -441,25 +474,26 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { * If a callback returns an ITargetState, then it is used as arguments to $state.transitionTo() and * the result returned. */ - function handleInvalidTargetState(fromPath: IParamsPath, $to$: TargetState) { + function handleInvalidTargetState(fromPath: Node[], $to$: TargetState) { const latestThing = () => transQueue.peekTail() || treeChangesQueue.peekTail(); let latest = latestThing(); let $from$ = PathFactory.makeTargetState(fromPath); let callbackQueue = new Queue([].concat(invalidCallbacks)); - const invokeCallback = (callback: Function) => - $q.when($injector.invoke(callback, null, { $to$, $from$ })); + const invokeCallback = (callback: Function) => $q.when($injector.invoke(callback, null, { $to$, $from$ })); function checkForRedirect(result) { - if (result instanceof TargetState) { - let target = result; - // Recreate the TargetState, in case the state is now defined. - target = $state.targetState(target.identifier(), target.params(), target.options()); - if (!target.valid()) return rejectFactory.invalid(target.error()); - if (latestThing() !== latest) return rejectFactory.superseded(); - - return $state.transitionTo(target.identifier(), target.params(), target.options()); + if (!(result instanceof TargetState)) { + return; } + let target = result; + // Recreate the TargetState, in case the state is now defined. + target = $state.targetState(target.identifier(), target.params(), target.options()); + + if (!target.valid()) return rejectFactory.invalid(target.error()); + if (latestThing() !== latest) return rejectFactory.superseded(); + + return $state.transitionTo(target.identifier(), target.params(), target.options()); } function invokeNextCallback() { @@ -478,14 +512,16 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { url: '^', views: null, params: { - '#': { value: null, type: 'hash'} // Param to hold the "hash" at the end of the URL + '#': { value: null, type: 'hash' } }, + // params: [Param.fromPath('#', paramTypes.types.hash, { value: null })], + path: [], 'abstract': true }; root = stateQueue.register(rootStateDef, true); root.navigable = null; - const rootPath = () => PathFactory.bindTransNodesToPath(new Path([PathFactory.makeResolveNode(PathFactory.makeParamsNode({}, root))])); + const rootPath = () => PathFactory.bindTransNodesToPath([new Node(root, {})]); $view.rootContext(root); @@ -498,7 +534,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { stateQueue.flush($state); stateQueue.autoFlush = true; // Autoflush once we are in runtime - + /** * @ngdoc function * @name ui.router.state.$state#reload @@ -544,10 +580,9 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { * @returns {promise} A promise representing the state of the new transition. See * {@link ui.router.state.$state#methods_go $state.go}. */ - $state.reload = function reload(reloadState: IStateOrName): IPromise { - let reloadOpt = isDefined(reloadState) ? reloadState : true; + $state.reload = function reload(reloadState: IStateOrName): IPromise { return $state.transitionTo($state.current, $stateParams, { - reload: reloadOpt, + reload: isDefined(reloadState) ? reloadState : true, inherit: false, notify: false }); @@ -619,12 +654,12 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { * - *resolve error* - when an error has occurred with a `resolve` * */ - $state.go = function go(to: IStateOrName, params: IRawParams, options: ITransitionOptions): IPromise { + $state.go = function go(to: IStateOrName, params: IRawParams, options: ITransitionOptions): IPromise { let defautGoOpts = { relative: $state.$current, inherit: true }; let transOpts = defaults(options, defautGoOpts, defaultTransOpts); return $state.transitionTo(to, params, transOpts); }; - + /** Factory method for creating a TargetState */ $state.targetState = function targetState(identifier: IStateOrName, params: IParamsOrArray, options: ITransitionOptions = {}): TargetState { let stateDefinition = matcher.find(identifier, options.relative); @@ -669,8 +704,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { * @returns {promise} A promise representing the state of the new transition. See * {@link ui.router.state.$state#methods_go $state.go}. */ - $state.transitionTo = function transitionTo(to: IStateOrName, toParams: IRawParams, options: ITransitionOptions = {}): IPromise { - toParams = toParams || {}; + $state.transitionTo = function transitionTo(to: IStateOrName, toParams: IRawParams = {}, options: ITransitionOptions = {}): IPromise { options = defaults(options, defaultTransOpts); options = extend(options, { current: transQueue.peekTail.bind(transQueue)}); @@ -683,7 +717,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { let ref: TargetState = $state.targetState(to, toParams, options); let latestTreeChanges: ITreeChanges = treeChangesQueue.peekTail(); - let currentPath: ITransPath = latestTreeChanges ? latestTreeChanges.to : rootPath(); + let currentPath: Node[] = latestTreeChanges ? latestTreeChanges.to : rootPath(); if (!ref.exists()) return handleInvalidTargetState(currentPath, ref); @@ -694,73 +728,10 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { if (!transition.valid()) return $q.reject(transition.error()); - let stateHandler = new StateHandler($urlRouter, $view, $state, $stateParams, $q, transQueue, treeChangesQueue); - let hookBuilder = transition.hookBuilder(); - - // TODO: Move the Transition instance hook registration to its own function - // Add hooks - let enteringViews = transition.views("entering"); - let exitingViews = transition.views("exiting"); - let treeChanges = transition.treeChanges(); - - function $updateViews() { - exitingViews.forEach((viewConfig: ViewConfig) => $view.reset(viewConfig)); - enteringViews.forEach((viewConfig: ViewConfig) => $view.registerStateViewConfig(viewConfig)); - $view.sync(); - } - - function $loadAllEnteringViews() { - const loadView = (vc: ViewConfig) => { - let resolveInjector = treeChanges.to.nodeForState(vc.context.name).resolveInjector; - return $view.load(vc, resolveInjector); - }; - return $q.all(enteringViews.map(loadView)).then(noop); - } - - function $loadAllControllerLocals() { - const loadLocals = (vc: ViewConfig) => { - let deps = annotateController(vc.controller); - let resolveInjector = treeChanges.to.nodeForState(vc.context.name).resolveInjector; - function $loadControllerLocals() { } - $loadControllerLocals.$inject = deps; - return $q.all(resolveInjector.getLocals($loadControllerLocals)).then((locals) => vc.locals = locals); - }; - - let loadAllLocals = enteringViews.filter(vc => !!vc.controller).map(loadLocals); - return $q.all(loadAllLocals).then(noop); - } - - transition.onStart({}, hookBuilder.getEagerResolvePathFn(), { priority: 1000 }); - transition.onEnter({}, hookBuilder.getLazyResolveStateFn(), { priority: 1000 }); - - - let onEnterRegistration = (state) => transition.onEnter({to: state.name}, state.onEnter); - transition.entering().filter(state => !!state.onEnter).forEach(onEnterRegistration); - - let onRetainRegistration = (state) => transition.onRetain({}, state.onRetain); - transition.entering().filter(state => !!state.onRetain).forEach(onRetainRegistration); - - let onExitRegistration = (state) => transition.onExit({from: state.name}, state.onExit); - transition.exiting().filter(state => !!state.onExit).forEach(onExitRegistration); - - if (enteringViews.length) { - transition.onStart({}, $loadAllEnteringViews); - transition.onFinish({}, $loadAllControllerLocals); - } - if (exitingViews.length || enteringViews.length) - transition.onSuccess({}, $updateViews); - transition.onError({}, $transitions.defaultErrorHandler()); - - // Commit global state data as the last hook in the transition (using a very low priority onFinish hook) - function $commitGlobalData() { stateHandler.transitionSuccess(transition.treeChanges(), transition); } - transition.onFinish({}, $commitGlobalData, {priority: -10000}); - - function $handleError($error$) { return stateHandler.transitionFailure(transition, $error$); } - let result = stateHandler.runTransition(transition).catch($handleError); - result.finally(() => transQueue.remove(transition)); - + let tMgr = new TransitionManager(transition, $transitions, $urlRouter, $view, $state, $stateParams, $q, transQueue, treeChangesQueue); + tMgr.registerHooks(); // Return a promise for the transition, which also has the transition object on it. - return extend(result, { transition }); + return extend(tMgr.runTransition(), { transition }); }; /** @@ -802,7 +773,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { let state = matcher.find(stateOrName, options.relative); if (!isDefined(state)) return undefined; if ($state.$current !== state) return false; - return isDefined(params) && params !== null ? state.params.$$equals($stateParams, params) : true; + return isDefined(params) && params !== null ? Param.equals(state.parameters(), $stateParams, params) : true; }; /** @@ -868,7 +839,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { if (!isDefined(state)) return undefined; if (!isDefined(include[state.name])) return false; - return params ? equalForKeys(state.params.$$values(params), $stateParams, Object.keys(params)) : true; + // @TODO Replace with Param.equals() ? + return params ? equalForKeys(Param.values(state.parameters(), params), $stateParams, Object.keys(params)) : true; }; @@ -893,7 +865,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { * first parameter, then the constructed href url will be built from the first navigable ancestor (aka * ancestor with a valid url). * - **`inherit`** - {boolean=true}, If `true` will inherit url parameters from current url. - * - **`relative`** - {object=$state.$current}, When transitioning with relative path (e.g '^'), + * - **`relative`** - {object=$state.$current}, When transitioning with relative path (e.g '^'), * defines which state to be relative from. * - **`absolute`** - {boolean=false}, If true will generate an absolute url, e.g. "http://www.example.com/fullurl". * @@ -918,7 +890,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { if (!nav || nav.url === undefined || nav.url === null) { return null; } - return $urlRouter.href(nav.url, state.params.$$values(params), { + return $urlRouter.href(nav.url, Param.values(state.parameters(), params), { absolute: options.absolute }); }; @@ -1010,7 +982,7 @@ function $StateParamsProvider() { if (url) { forEach(params, function(val, key) { - if ((url.parameters(key) || {}).dynamic !== true) abort = true; + if ((url.parameter(key) || {}).dynamic !== true) abort = true; }); } if (abort) return false; @@ -1084,4 +1056,4 @@ function $StateParamsProvider() { angular.module('ui.router.state') .provider('$stateParams', $StateParamsProvider) .provider('$state', $StateProvider) - .run(['$state', function($state) { /* This effectively calls $get() to init when we enter runtime */ }]); \ No newline at end of file + .run(['$state', function($state) { /* This effectively calls $get() to init when we enter runtime */ }]); diff --git a/src/state/stateBuilder.ts b/src/state/stateBuilder.ts index 769e157c0..58de5295a 100644 --- a/src/state/stateBuilder.ts +++ b/src/state/stateBuilder.ts @@ -1,14 +1,20 @@ -import {noop, extend, pick, isArray, isDefined, isFunction, isString, forEach} from "../common/common"; -import ParamSet from "../params/paramSet"; +import {map, noop, extend, pick, omit, values, applyPairs, prop, isArray, isDefined, isFunction, isString, forEach} from "../common/common"; import Param from "../params/param"; +const parseUrl = (url: string): any => { + if (!isString(url)) return false; + var root = url.charAt(0) === '^'; + return { val: root ? url.substring(1) : url, root }; +}; + // Builds state properties from definition passed to StateQueueManager.register() export default function StateBuilder(root, matcher, $urlMatcherFactoryProvider) { let self = this, builders = { parent: function(state) { - return matcher.find(self.parentName(state)); + if (state === root()) return null; + return matcher.find(self.parentName(state)) || root(); }, data: function(state) { @@ -20,38 +26,30 @@ export default function StateBuilder(root, matcher, $urlMatcherFactoryProvider) // Build a URLMatcher if necessary, either via a relative or absolute URL url: function(state) { - let url = state.url, config = { params: state.params || {} }; - let parent = state.parent; + const parsed = parseUrl(state.url), parent = state.parent; + const url = !parsed ? state.url : $urlMatcherFactoryProvider.compile(parsed.val, { + params: state.params || {}, + paramMap: function(paramConfig, isSearch) { + if (state.reloadOnSearch === false && isSearch) paramConfig = extend(paramConfig || {}, { dynamic: true }); + return paramConfig; + } + }); - if (isString(url)) { - if (url.charAt(0) === '^') return $urlMatcherFactoryProvider.compile(url.substring(1), config); - return ((parent && parent.navigable) || root()).url.concat(url, config); - } - if (!url || $urlMatcherFactoryProvider.isMatcher(url)) return url; - throw new Error(`Invalid url '${url}' in state '${state}'`); + if (!url) return null; + if (!$urlMatcherFactoryProvider.isMatcher(url)) throw new Error(`Invalid url '${url}' in state '${state}'`); + return (parsed && parsed.root) ? url : ((parent && parent.navigable) || root()).url.append(url); }, // Keep track of the closest ancestor state that has a URL (i.e. is navigable) navigable: function(state) { - return (state !== root()) && state.url ? state : (state.parent ? state.parent.navigable : null); + return (state !== root()) && state.url ? state : (state.parent ? state.parent.navigable : null); }, - // Own parameters for this state. state.url.params is already built at this point. Create and add non-url params - ownParams: function(state) { - let params = state.url && state.url.params.$$own() || new ParamSet(); - forEach(state.params || {}, function(config, id) { - if (!params[id]) params[id] = new Param(id, null, config, "config"); - }); - if (state.reloadOnSearch === false) { - forEach(params, function(param) { if (param && param.location === 'search') param.dynamic = true; }); - } - return params; - }, - - // Derive parameters for this state and ensure they're a super-set of parent's parameters - params: function(state) { - let base = state.parent && state.parent.params ? state.parent.params.$$new() : new ParamSet(); - return extend(base, state.ownParams); + params: function(state): { [key: string]: Param } { + const makeConfigParam = (config: any, id: string) => Param.fromConfig(id, null, config); + let urlParams: Param[] = (state.url && state.url.parameters({ inherit: false })) || []; + let nonUrlParams: Param[] = values(map(omit(state.params || {}, urlParams.map(prop('id'))), makeConfigParam)); + return urlParams.concat(nonUrlParams).map(p => [p.id, p]).reduce(applyPairs, {}); }, // If there is no explicit multi-view configuration, make one up so we don't have @@ -68,10 +66,9 @@ export default function StateBuilder(root, matcher, $urlMatcherFactoryProvider) forEach(state.views || { "$default": pick(state, allKeys) }, function (config, name) { name = name || "$default"; // Account for views: { "": { template... } } // Allow controller settings to be defined at the state level for all views - forEach(ctrlKeys, function(key) { + forEach(ctrlKeys, (key) => { if (state[key] && !config[key]) config[key] = state[key]; }); - if (Object.keys(config).length > 0) views[name] = config; }); return views; diff --git a/src/state/stateEvents.ts b/src/state/stateEvents.ts index 5c9378978..a9e04c6ef 100644 --- a/src/state/stateEvents.ts +++ b/src/state/stateEvents.ts @@ -179,7 +179,7 @@ function $StateEventsProvider($stateProvider: IStateProvider) { let runtime = false; let allEvents = [ '$stateChangeStart', '$stateNotFound', '$stateChangeSuccess', '$stateChangeError' ]; - let enabledStateEvents: IEventsToggle = allEvents.reduce((memo, key) => applyPairs(memo, key, false), {}); + let enabledStateEvents: IEventsToggle = allEvents.map(e => [e, false]).reduce(applyPairs, {}); function assertNotRuntime() { if (runtime) throw new Error("Cannot enable events at runtime (use $stateEventsProvider"); diff --git a/src/state/stateHandler.ts b/src/state/stateHandler.ts deleted file mode 100644 index d2ab3e733..000000000 --- a/src/state/stateHandler.ts +++ /dev/null @@ -1,78 +0,0 @@ -import {IPromise, IQService} from "angular"; -import {copy, prop} from "../common/common"; -import Queue from "../common/queue"; - -import {ITreeChanges} from "../transition/interface"; -import {Transition} from "../transition/transition"; -import {TransitionRejection, RejectType} from "../transition/rejectFactory"; - -import {IStateService, IStateDeclaration} from "../state/interface"; - -export default class StateHandler { - constructor(private $urlRouter, - private $view, // service - private $state: IStateService, - private $stateParams, // service/obj - private $q: IQService, - private activeTransQ: Queue, - private changeHistory: Queue - ) { } - - runTransition(transition: Transition) { - this.activeTransQ.clear(); - this.activeTransQ.enqueue(transition); - return transition.run(); - } - - transitionSuccess(treeChanges: ITreeChanges, transition: Transition) { - let {$view, $state, activeTransQ, changeHistory} = this; - $view.sync(); - - // Update globals in $state - $state.$current = transition.$to(); - $state.current = $state.$current.self; - this.updateStateParams(transition); - activeTransQ.remove(transition); - changeHistory.enqueue(treeChanges); - - return transition; - } - - transitionFailure(transition: Transition, error): (IStateDeclaration|IPromise) { - let {$state, $stateParams, $q, activeTransQ} = this; - activeTransQ.remove(transition); - // Handle redirect and abort - if (error instanceof TransitionRejection) { - if (error.type === RejectType.IGNORED) { - // Update $stateParmas/$state.params/$location.url if transition ignored, but dynamic params have changed. - if (!$state.$current.params.$$filter(prop('dynamic')).$$equals($stateParams, transition.params())) { - this.updateStateParams(transition); - } - return $state.current; - } - - if (error.type === RejectType.SUPERSEDED) { - if (error.redirected && error.detail instanceof Transition) { - activeTransQ.enqueue(error.detail); - return this.runTransition(error.detail); - } - } - } - - return $q.reject(error); - } - - updateStateParams(transition: Transition) { - let {$urlRouter, $state, $stateParams} = this; - let options = transition.options(); - $state.params = transition.params(); - copy($state.params, $stateParams); - $stateParams.$sync().$off(); - - if (options.location && $state.$current.navigable) { - $urlRouter.push($state.$current.navigable.url, $stateParams, { replace: options.location === 'replace' }); - } - - $urlRouter.update(true); - } -} diff --git a/src/state/stateMatcher.ts b/src/state/stateMatcher.ts index 46886ee8e..95e8bcdd7 100644 --- a/src/state/stateMatcher.ts +++ b/src/state/stateMatcher.ts @@ -1,8 +1,9 @@ import {isString} from "../common/common"; -import {IState, IStateOrName} from "./interface"; +import {IStateOrName} from "./interface"; +import {State} from "./state"; export default class StateMatcher { - constructor (private _states: {[key: string]: IState}) { } + constructor (private _states: { [key: string]: State }) { } isRelative(stateName: string) { stateName = stateName || ""; @@ -10,7 +11,7 @@ export default class StateMatcher { } - find(stateOrName: IStateOrName, base?: IStateOrName): IState { + find(stateOrName: IStateOrName, base?: IStateOrName): State { if (!stateOrName && stateOrName !== "") return undefined; let isStr = isString(stateOrName); let name: string = isStr ? stateOrName : (stateOrName).name; @@ -27,7 +28,7 @@ export default class StateMatcher { resolvePath(name: string, base: IStateOrName) { if (!base) throw new Error(`No reference point given for path '${name}'`); - let baseState: IState = this.find(base); + let baseState: State = this.find(base); let splitName = name.split("."), i = 0, pathLength = splitName.length, current = baseState; diff --git a/src/state/stateQueueManager.ts b/src/state/stateQueueManager.ts index ed5a13c3f..0dd843d1d 100644 --- a/src/state/stateQueueManager.ts +++ b/src/state/stateQueueManager.ts @@ -8,6 +8,7 @@ export default function StateQueueManager(states, builder, $urlRouterProvider, $ let queueManager = extend(this, { register: function(config: IStateDeclaration, pre?: boolean) { // Wrap a new object around the state so we can store our private details easily. + // @TODO: state = new State(extend({}, config, { ... })) let state = inherit(new State(), extend({}, config, { self: config, resolve: config.resolve || {}, @@ -19,6 +20,7 @@ export default function StateQueueManager(states, builder, $urlRouterProvider, $ throw new Error(`State '${state.name}' is already defined`); queue[pre ? "unshift" : "push"](state); + if (queueManager.autoFlush) { queueManager.flush($state); } diff --git a/src/state/targetState.ts b/src/state/targetState.ts index 57c79576c..38a77f81f 100644 --- a/src/state/targetState.ts +++ b/src/state/targetState.ts @@ -1,4 +1,6 @@ -import {IState, IStateDeclaration, IStateOrName} from "./interface"; +import {State} from "./state"; + +import {IStateDeclaration, IStateOrName} from "./interface"; import {IParamsOrArray} from "../params/interface"; @@ -19,11 +21,15 @@ import {ITransitionOptions} from "../transition/interface"; * @param {ITransitionOptions} _options Transition options. */ export default class TargetState { + private _params: IParamsOrArray; + constructor( private _identifier: IStateOrName, - private _definition?: IState, - private _params: IParamsOrArray = {}, - private _options: ITransitionOptions = {}) { + private _definition?: State, + _params: IParamsOrArray = {}, + private _options: ITransitionOptions = {} + ) { + this._params = _params || {}; } name() { @@ -38,7 +44,7 @@ export default class TargetState { return this._params; } - $state(): IState { + $state(): State { return this._definition; } diff --git a/src/transition/hookBuilder.ts b/src/transition/hookBuilder.ts index 05b02ab13..4e80f3e62 100644 --- a/src/transition/hookBuilder.ts +++ b/src/transition/hookBuilder.ts @@ -1,22 +1,19 @@ import {IPromise} from "angular"; -import {IInjectable, extend, isPromise, isArray, assertPredicate, unnestR} from "../common/common"; +import {IInjectable, extend, tail, isPromise, isArray, assertPredicate, unnestR} from "../common/common"; import {runtime} from "../common/angular1"; -import {ITransitionOptions, ITransitionHookOptions, ITreeChanges, IEventHook, ITransitionService} from "./interface"; +import {ITransitionOptions, ITransitionHookOptions, IHookRegistry, ITreeChanges, IEventHook, ITransitionService} from "./interface"; import TransitionHook from "./transitionHook"; import {Transition} from "./transition"; -import {IState} from "../state/interface"; +import {State} from "../state/state"; -import {ITransPath, ITransNode} from "../path/interface"; - -import {ResolvePolicy, IOptions1} from "../resolve/interface"; -import {IHookRegistry} from "./interface"; +import Node from "../path/node"; interface IToFrom { - to: IState; - from: IState; + to: State; + from: State; } let successErrorOptions: ITransitionHookOptions = { @@ -40,16 +37,18 @@ let successErrorOptions: ITransitionHookOptions = { */ export default class HookBuilder { + treeChanges: ITreeChanges; transitionOptions: ITransitionOptions; - toState: IState; - fromState: IState; + toState: State; + fromState: State; - transitionLocals: {[key: string]: any}; + transitionLocals: { [key: string]: any }; - constructor(private $transitions: ITransitionService, private treeChanges: ITreeChanges, private transition: Transition, private baseHookOptions: ITransitionHookOptions) { - this.toState = treeChanges.to.last().state; - this.fromState = treeChanges.from.last().state; + constructor(private $transitions: ITransitionService, private transition: Transition, private baseHookOptions: ITransitionHookOptions) { + this.treeChanges = transition.treeChanges(); + this.toState = tail(this.treeChanges.to).state; + this.fromState = tail(this.treeChanges.from).state; this.transitionOptions = transition.options(); this.transitionLocals = { $transition$: transition }; } @@ -78,8 +77,8 @@ export default class HookBuilder { * Finds all registered IEventHooks which matched the hookType and toFrom criteria. * A TransitionHook is then built from each IEventHook with the context, locals, and options provided. */ - private _getTransitionHooks(hookType: string, context: (ITransPath|IState), locals = {}, options: ITransitionHookOptions = {}) { - let node = this.treeChanges.to.last(); + private _getTransitionHooks(hookType: string, context: (Node[]|State), locals = {}, options: ITransitionHookOptions = {}) { + let node = tail(this.treeChanges.to); let toFrom: IToFrom = this._toFrom(); options.traceData = { hookType, context }; @@ -96,8 +95,8 @@ export default class HookBuilder { * Finds all registered IEventHooks which matched the hookType and toFrom criteria. * A TransitionHook is then built from each IEventHook with the context, locals, and options provided. */ - private _getNodeHooks(hookType: string, path: ITransPath, toFromFn: (node: ITransNode) => IToFrom, locals: any = {}, options: ITransitionHookOptions = {}) { - const hooksForNode = (node: ITransNode) => { + private _getNodeHooks(hookType: string, path: Node[], toFromFn: (node: Node) => IToFrom, locals: any = {}, options: ITransitionHookOptions = {}) { + const hooksForNode = (node: Node) => { let toFrom = toFromFn(node); options.traceData = { hookType, context: node }; locals.$state$ = node.state; @@ -106,34 +105,11 @@ export default class HookBuilder { return this._matchingHooks(hookType, toFrom).map(transitionHook); }; - return path.nodes().map(hooksForNode); - } - - /** - * Given an array of TransitionHooks, runs each one synchronously and sequentially. - * - * Returns a promise chain composed of any promises returned from each hook.invokeStep() call - */ - runSynchronousHooks(hooks: TransitionHook[], locals = {}, swallowExceptions: boolean = false): IPromise { - let promises = []; - for (let i = 0; i < hooks.length; i++) { - try { - let hookResult = hooks[i].invokeStep(locals); - // If a hook returns a promise, that promise is added to an array to be resolved asynchronously. - if (hookResult && isPromise(hookResult)) - promises.push(hookResult); - } catch (ex) { - if (!swallowExceptions) throw ex; - console.log("Swallowed exception during synchronous hook handler: " + ex); // TODO: What to do here? - } - } - - let resolvedPromise = runtime.$q.when(undefined); - return promises.reduce((memo, val) => memo.then(() => val), resolvedPromise); + return path.map(hooksForNode); } /** Given a node and a callback function, builds a TransitionHook */ - buildHook(node: ITransNode, fn: IInjectable, moreLocals?, options: ITransitionHookOptions = {}): TransitionHook { + buildHook(node: Node, fn: IInjectable, moreLocals?, options: ITransitionHookOptions = {}): TransitionHook { let locals = extend({}, this.transitionLocals, moreLocals); let _options = extend({}, this.baseHookOptions, options); @@ -161,27 +137,4 @@ export default class HookBuilder { .filter(matchFilter) // Only those satisfying matchCriteria .sort(prioritySort); // Order them by .priority field } - - /** Returns a function which resolves the LAZY Resolvables for a Node in a Path */ - getLazyResolveStateFn() { - let options = { resolvePolicy: ResolvePolicy[ResolvePolicy.LAZY] }; - let treeChanges = this.treeChanges; - $lazyResolveEnteringState.$inject = ['$state$', '$transition$']; - function $lazyResolveEnteringState($state$, $transition$) { - let node = treeChanges.entering.nodeForState($state$); - return node.resolveContext.resolvePathElement(node.state, extend({transition: $transition$}, options)); - } - return $lazyResolveEnteringState; - } - - /** Returns a function which resolves the EAGER Resolvables for a Path */ - getEagerResolvePathFn() { - let options: IOptions1 = { resolvePolicy: ResolvePolicy[ResolvePolicy.EAGER] }; - let path = this.treeChanges.to; - $eagerResolvePath.$inject = ['$transition$']; - function $eagerResolvePath($transition$) { - return path.last().resolveContext.resolvePath(extend({transition: $transition$}, options)); - } - return $eagerResolvePath; - } } diff --git a/src/transition/hookRegistry.ts b/src/transition/hookRegistry.ts index c06f7bcb8..f74c976aa 100644 --- a/src/transition/hookRegistry.ts +++ b/src/transition/hookRegistry.ts @@ -1,6 +1,6 @@ -import {IInjectable, extend, val, isString, isFunction} from "../common/common"; +import {IInjectable, extend, val, isString, isFunction, removeFrom} from "../common/common"; -import {IState} from "../state/interface"; +import {State} from "../state/state"; import Glob from "../state/glob"; import {IMatchCriteria, IStateMatch, IEventHook, IHookRegistry, IHookRegistration} from "./interface"; @@ -15,7 +15,7 @@ import {IMatchCriteria, IStateMatch, IEventHook, IHookRegistry, IHookRegistratio * - If a function, matchState calls the function with the state and returns true if the function's result is truthy. * @returns {boolean} */ -export function matchState(state: IState, matchCriteria: (string|IStateMatch)) { +export function matchState(state: State, matchCriteria: (string|IStateMatch)) { let toMatch = isString(matchCriteria) ? [matchCriteria] : matchCriteria; function matchGlobs(_state) { @@ -45,7 +45,7 @@ export class EventHook implements IEventHook { this.priority = options.priority || 0; } - matches(to: IState, from: IState) { + matches(to: State, from: State) { return matchState(to, this.matchCriteria.to) && matchState(from, this.matchCriteria.from); } } @@ -53,15 +53,13 @@ export class EventHook implements IEventHook { interface ITransitionEvents { [key: string]: IEventHook[]; } // Return a registration function of the requested type. -function makeHookRegistrationFn(transitionEvents: ITransitionEvents, eventType: string): IHookRegistration { +function makeHookRegistrationFn(hooks: ITransitionEvents, name: string): IHookRegistration { return function (matchObject, callback, options = {}) { let eventHook = new EventHook(matchObject, callback, options); - let hooks = transitionEvents[eventType]; - hooks.push(eventHook); + hooks[name].push(eventHook); return function deregisterEventHook() { - let idx = hooks.indexOf(eventHook); - if (idx !== -1) hooks.splice(idx, 1); + removeFrom(hooks[name])(eventHook); }; }; } diff --git a/src/transition/interface.ts b/src/transition/interface.ts index 1adb379cd..b5871d03b 100644 --- a/src/transition/interface.ts +++ b/src/transition/interface.ts @@ -1,7 +1,7 @@ -import {IStateDeclaration, IState} from "../state/interface"; +import {State} from "../state/state"; +import {IStateDeclaration} from "../state/interface"; import TargetState from "../state/targetState"; - -import {ITransPath} from "../path/interface"; +import Node from "../path/node"; import {IInjectable, Predicate} from "../common/common"; @@ -9,11 +9,11 @@ import {Transition} from "./transition"; export interface ITransitionOptions { location ?: (boolean|string); - relative ?: (string|IStateDeclaration|IState); + relative ?: (string|IStateDeclaration|State); inherit ?: boolean; notify ?: boolean; - reload ?: (boolean|string|IStateDeclaration|IState); - reloadState ?: (IState); + reload ?: (boolean|string|IStateDeclaration|State); + reloadState ?: (State); custom ?: any; previous ?: Transition; current ?: () => Transition; @@ -30,18 +30,18 @@ export interface ITransitionHookOptions { } export interface ITreeChanges { - [key: string]: ITransPath; - from: ITransPath; - to: ITransPath; - retained: ITransPath; - entering: ITransPath; - exiting: ITransPath; + [key: string]: Node[]; + from: Node[]; + to: Node[]; + retained: Node[]; + entering: Node[]; + exiting: Node[]; } export type IErrorHandler = (error: Error) => void; export interface ITransitionService extends IHookRegistry { - create: (fromPath: ITransPath, targetState: TargetState) => Transition; + create: (fromPath: Node[], targetState: TargetState) => Transition; defaultErrorHandler: (handler?: IErrorHandler) => IErrorHandler; } @@ -60,7 +60,7 @@ export interface IHookRegistry { getHooks: IHookGetter; } -export type IStateMatch = Predicate +export type IStateMatch = Predicate export interface IMatchCriteria { to?: (string|IStateMatch); from?: (string|IStateMatch); @@ -69,5 +69,5 @@ export interface IMatchCriteria { export interface IEventHook { callback: IInjectable; priority: number; - matches: (a: IState, b: IState) => boolean; + matches: (a: State, b: State) => boolean; } \ No newline at end of file diff --git a/src/transition/transition.ts b/src/transition/transition.ts index a6105bf0d..d55724148 100644 --- a/src/transition/transition.ts +++ b/src/transition/transition.ts @@ -7,22 +7,27 @@ import {ITransitionOptions, ITransitionHookOptions, ITreeChanges, IHookRegistry, import $transitions from "./transitionService"; import {HookRegistry, matchState} from "./hookRegistry"; import HookBuilder from "./hookBuilder"; +import TransitionRunner from "./transitionRunner"; import {RejectFactory} from "./rejectFactory"; -import {ITransPath} from "../path/interface"; +import Node from "../path/node"; import PathFactory from "../path/pathFactory"; +import {State} from "../state/state"; import TargetState from "../state/targetState"; -import {IState, IStateDeclaration} from "../state/interface"; +import {IStateDeclaration} from "../state/interface"; -import ParamValues from "../params/paramValues"; +import Param from "../params/param"; import {ViewConfig} from "../view/view"; -import {extend, flatten, unnest, forEach, identity, omit, isObject, not, prop, toJson, val, abstractKey} from "../common/common"; +import { + map, find, extend, mergeR, flatten, unnest, tail, forEach, identity, + omit, isObject, not, prop, propEq, toJson, val, abstractKey, arrayTuples, allTrueR +} from "../common/common"; let transitionCount = 0, REJECT = new RejectFactory(); -const stateSelf: (_state: IState) => IStateDeclaration = prop("self"); +const stateSelf: (_state: State) => IStateDeclaration = prop("self"); /** * @ngdoc object @@ -58,12 +63,15 @@ export class Transition implements IHookRegistry { onError: IHookRegistration; getHooks: IHookGetter; - constructor(fromPath: ITransPath, targetState: TargetState) { - if (targetState.error()) throw new Error(targetState.error()); + constructor(fromPath: Node[], targetState: TargetState) { + if (!targetState.valid()) { + throw new Error(targetState.error()); + } + // Makes the Transition instance a hook registry (onStart, etc) HookRegistry.mixin(new HookRegistry(), this); - // current() is assumed to come from targetState.options, but provide a naive implemention otherwise. + // current() is assumed to come from targetState.options, but provide a naive implementation otherwise. this._options = extend({ current: val(this) }, targetState.options()); this.$id = transitionCount++; let toPath = PathFactory.buildToPath(fromPath, targetState); @@ -71,11 +79,11 @@ export class Transition implements IHookRegistry { } $from() { - return this._treeChanges.from.last().state; + return tail(this._treeChanges.from).state; } $to() { - return this._treeChanges.to.last().state; + return tail(this._treeChanges.to).state; } /** @@ -117,11 +125,11 @@ export class Transition implements IHookRegistry { is(compare: (Transition|{to: any, from: any})) { if (compare instanceof Transition) { // TODO: Also compare parameters - return this.is({to: compare.$to().name, from: compare.$from().name}); + return this.is({ to: compare.$to().name, from: compare.$from().name }); } return !( - (compare.to && !matchState(this.$to(), compare.to)) || - (compare.from && !matchState(this.$from(), compare.from)) + (compare.to && !matchState(this.$to(), compare.to)) || + (compare.from && !matchState(this.$from(), compare.from)) ); } @@ -136,8 +144,8 @@ export class Transition implements IHookRegistry { * @returns {StateParams} the StateParams object for the transition. */ // TODO - params(pathname: string = "to"): ParamValues { - return this._treeChanges[pathname].last().paramValues; + params(pathname: string = "to"): { [key: string]: any } { + return this._treeChanges[pathname].map(prop("values")).reduce(mergeR, {}); } /** @@ -177,7 +185,7 @@ export class Transition implements IHookRegistry { * @returns {Array} Returns an array of states that will be entered in this transition. */ entering(): IStateDeclaration[] { - return this._treeChanges.entering.states().map(stateSelf); + return map(this._treeChanges.entering, prop('state')).map(stateSelf); } /** @@ -191,7 +199,7 @@ export class Transition implements IHookRegistry { * @returns {Array} Returns an array of states that will be exited in this transition. */ exiting(): IStateDeclaration[] { - return this._treeChanges.exiting.states().map(stateSelf).reverse(); + return map(this._treeChanges.exiting, prop('state')).map(stateSelf).reverse(); } /** @@ -206,16 +214,16 @@ export class Transition implements IHookRegistry { * will not be exited. */ retained(): IStateDeclaration[] { - return this._treeChanges.retained.states().map(stateSelf); + return map(this._treeChanges.retained, prop('state')).map(stateSelf); } /** * Returns a list of ViewConfig objects for a given path. Returns one ViewConfig for each view in * each state in a named path of the transition's tree changes. Optionally limited to a given state in that path. */ - views(pathname: string = "entering", state?: IState): ViewConfig[] { + views(pathname: string = "entering", state?: State): ViewConfig[] { let path = this._treeChanges[pathname]; - return state ? path.nodeForState(state).views : unnest(path.nodes().map(prop("views"))); + return state ? find(path, propEq('state', state)).views : unnest(path.map(prop("views"))); } treeChanges = () => this._treeChanges; @@ -233,9 +241,19 @@ export class Transition implements IHookRegistry { * @returns {Transition} Returns a new `Transition` instance. */ redirect(targetState: TargetState): Transition { - let newOptions = extend({}, this.options(), targetState.options(), { previous: this} ); + let newOptions = extend({}, this.options(), targetState.options(), { previous: this }); targetState = new TargetState(targetState.identifier(), targetState.$state(), targetState.params(), newOptions); - return new Transition(this._treeChanges.from, targetState); + + let redirectTo = new Transition(this._treeChanges.from, targetState); + + // If the current transition has already resolved any resolvables which are also in the redirected "to path", then + // add those resolvables to the redirected transition. Allows you to define a resolve at a parent level, wait for + // the resolve, then redirect to a child state based on the result, and not have to re-fetch the resolve. + let redirectedPath = this.treeChanges().to; + let matching = Node.matching(redirectTo.treeChanges().to, redirectedPath); + matching.forEach((node, idx) => node.resolves = redirectedPath[idx].resolves); + + return redirectTo; } /** @@ -251,24 +269,29 @@ export class Transition implements IHookRegistry { */ ignored() { let {to, from} = this._treeChanges; - let [toState, fromState] = [to, from].map((path) => path.last().state); - let [toParams, fromParams] = [to, from].map((path) => path.last().paramValues); - return !this._options.reload && - toState === fromState && - toState.params.$$filter(not(prop('dynamic'))).$$equals(toParams, fromParams); + if (this._options.reload || tail(to).state !== tail(from).state) return false; + + let nodeSchemas: Param[][] = to.map(node => node.schema.filter(not(prop('dynamic')))); + let [toValues, fromValues] = [to, from].map(path => path.map(prop('values'))); + let tuples = arrayTuples(nodeSchemas, toValues, fromValues); + + return tuples.map(([schema, toVals, fromVals]) => Param.equals(schema, toVals, fromVals)).reduce(allTrueR, true); } hookBuilder(): HookBuilder { - let baseHookOptions: ITransitionHookOptions = { + return new HookBuilder($transitions, this, { transition: this, current: this._options.current - }; - - return new HookBuilder($transitions, this._treeChanges, this, baseHookOptions); + }); } run () { - if (this.error()) throw new Error(this.error()); + if (!this.valid()) { + let error = new Error(this.error()); + this._deferred.reject(error); + throw error; + } + trace.traceTransitionStart(this); if (this.ignored()) { @@ -278,39 +301,6 @@ export class Transition implements IHookRegistry { return this.promise; } - // ----------------------------------------------------------------------- - // Transition Steps - // ----------------------------------------------------------------------- - - let hookBuilder = this.hookBuilder(); - - let onBeforeHooks = hookBuilder.getOnBeforeHooks(); - // ---- Synchronous hooks ---- - // Run the "onBefore" hooks and save their promises - let chain = hookBuilder.runSynchronousHooks(onBeforeHooks); - - // Build the async hooks *after* running onBefore hooks. - // The synchronous onBefore hooks may register additional async hooks on-the-fly. - let onStartHooks = hookBuilder.getOnStartHooks(); - let onExitHooks = hookBuilder.getOnExitHooks(); - let onRetainHooks = hookBuilder.getOnRetainHooks(); - let onEnterHooks = hookBuilder.getOnEnterHooks(); - let onFinishHooks = hookBuilder.getOnFinishHooks(); - let onSuccessHooks = hookBuilder.getOnSuccessHooks(); - let onErrorHooks = hookBuilder.getOnErrorHooks(); - - // Set up a promise chain. Add the steps' promises in appropriate order to the promise chain. - let asyncSteps = flatten([onStartHooks, onExitHooks, onRetainHooks, onEnterHooks, onFinishHooks]).filter(identity); - - // ---- Asynchronous section ---- - // The results of the sync hooks is a promise chain (rejected or otherwise) that begins the async portion of the transition. - // Build the rest of the chain off the sync promise chain out of all the asynchronous steps - forEach(asyncSteps, function (step) { - // Don't pass prev as locals to invokeStep() - chain = chain.then((prev) => step.invokeStep()); - }); - - // When the chain is complete, then resolve or reject the deferred const resolve = () => { this._deferred.resolve(this); @@ -323,12 +313,7 @@ export class Transition implements IHookRegistry { return runtime.$q.reject(error); }; - chain = chain.then(resolve, reject); - - // When the promise has settled (i.e., the transition is complete), then invoke the registered success or error hooks - const runSuccessHooks = () => hookBuilder.runSynchronousHooks(onSuccessHooks, {}, true); - const runErrorHooks = ($error$) => hookBuilder.runSynchronousHooks(onErrorHooks, { $error$ }, true); - this.promise.then(runSuccessHooks).catch(runErrorHooks); + new TransitionRunner(this, resolve, reject).run(); return this.promise; } @@ -340,10 +325,11 @@ export class Transition implements IHookRegistry { } error() { - let state = this._treeChanges.to.last().state; + let state = this.$to(); + if (state.self[abstractKey]) return `Cannot transition to abstract state '${state.name}'`; - if (!state.params.$$validates(this.params())) + if (!Param.validates(state.parameters(), this.params())) return `Param values not valid for state '${state.name}'`; } @@ -357,7 +343,7 @@ export class Transition implements IHookRegistry { // (X) means the to state is invalid. let id = this.$id, from = isObject(fromStateOrName) ? fromStateOrName.name : fromStateOrName, - fromParams = toJson(avoidEmptyHash(this._treeChanges.from.last().paramValues)), + fromParams = toJson(avoidEmptyHash(this._treeChanges.from.map(prop('values')).reduce(mergeR, {}))), toValid = this.valid() ? "" : "(X) ", to = isObject(toStateOrName) ? toStateOrName.name : toStateOrName, toParams = toJson(avoidEmptyHash(this.params())); diff --git a/src/transition/transitionHook.ts b/src/transition/transitionHook.ts index 62742f907..53e6a0757 100644 --- a/src/transition/transitionHook.ts +++ b/src/transition/transitionHook.ts @@ -3,7 +3,8 @@ import {IInjectable, defaults, extend, noop, filter, not, isFunction, isDefined, import trace from "../common/trace"; import {RejectFactory} from "./rejectFactory"; import {Transition} from "./transition"; -import {IState, IResolveDeclarations} from "../state/interface"; +import {State} from "../state/state"; +import {IResolveDeclarations} from "../state/interface"; import Resolvable from "../resolve/resolvable"; import ResolveContext from "../resolve/resolveContext"; import {ITransitionHookOptions} from "./interface"; @@ -19,7 +20,7 @@ let defaultOptions = { }; export default class TransitionHook { - constructor(private state: IState, + constructor(private state: State, private fn: IInjectable, private locals: any, private resolveContext: ResolveContext, diff --git a/src/transition/transitionRunner.ts b/src/transition/transitionRunner.ts new file mode 100644 index 000000000..0c7478909 --- /dev/null +++ b/src/transition/transitionRunner.ts @@ -0,0 +1,79 @@ +import {val, flatten, identity, isPromise, Predicate} from "../common/common"; +import {runtime} from "../common/angular1"; +import {IPromise} from "angular"; +import HookBuilder from "./hookBuilder"; +import {Transition} from "./transition"; +import TransitionHook from "./transitionHook"; + +export default class TransitionRunner { + private hookBuilder: HookBuilder; + + constructor(private transition: Transition, private _resolve, private _reject) { + this.hookBuilder = transition.hookBuilder(); + } + + run(): IPromise { + const runSuccessHooks = () => runSynchronousHooks(this.success(), {}, true); + const runErrorHooks = ($error$) => runSynchronousHooks(this.error(), { $error$ }, true); + // Run the success/error hooks *after* the Transition promise is settled. + this.transition.promise.then(runSuccessHooks, runErrorHooks); + + // ---- Synchronous hooks ---- + // Run the "onBefore" sync hooks + // The results of the sync hooks is an async promise chain (which gets rejected or resolved) + let chain = runSynchronousHooks(this.before()); + + // ---- Asynchronous section ---- + // Chain off the promise, build the remainder of the chain using each async step. + chain = this.async().reduce((_chain, step) => _chain.then(step.invokeStep), chain); + + // Make sure to settle the Transition promise, using the supplied callbacks and return the full chain. + return chain.then(this._resolve, this._reject); + } + + before() { + return this.hookBuilder.getOnBeforeHooks(); + } + + async() { + let hookBuilder = this.hookBuilder; + // Build the async hooks *after* running onBefore hooks. + // The synchronous onBefore hooks may register additional async hooks on-the-fly. + let onStartHooks = hookBuilder.getOnStartHooks(); + let onExitHooks = hookBuilder.getOnExitHooks(); + let onRetainHooks = hookBuilder.getOnRetainHooks(); + let onEnterHooks = hookBuilder.getOnEnterHooks(); + let onFinishHooks = hookBuilder.getOnFinishHooks(); + + return flatten([onStartHooks, onExitHooks, onRetainHooks, onEnterHooks, onFinishHooks]).filter(identity); + } + + success() { + return this.hookBuilder.getOnSuccessHooks(); + } + + error() { + return this.hookBuilder.getOnErrorHooks(); + } +} + +/** + * Given an array of TransitionHooks, runs each one synchronously and sequentially. + * + * Returns a promise chain composed of any promises returned from each hook.invokeStep() call + */ +export function runSynchronousHooks(hooks: TransitionHook[], locals = {}, swallowExceptions: boolean = false): IPromise { + function invokeSwallowExceptions(hook) { + try { + return hook.invokeStep(locals); + } catch (exception) { + if (!swallowExceptions) + throw exception; + console.log("Swallowed exception during synchronous hook handler: " + exception); // TODO: What to do here? + } + } + + return hooks.map(invokeSwallowExceptions) + .filter(> isPromise) + .reduce((chain, promise) => chain.then(val(promise)), runtime.$q.when(undefined)); +} diff --git a/src/transition/transitionService.ts b/src/transition/transitionService.ts index 42c37f0bb..f82a43e81 100644 --- a/src/transition/transitionService.ts +++ b/src/transition/transitionService.ts @@ -6,7 +6,7 @@ import {Transition} from "./transition"; import TargetState from "../state/targetState"; -import {ITransPath} from "../path/interface"; +import Node from "../path/node"; import {IHookRegistry, ITransitionService, ITransitionOptions, IHookRegistration, IHookGetter} from "./interface"; @@ -28,7 +28,7 @@ export let defaultTransOpts: ITransitionOptions = { current : () => null }; -class TransitionService implements IHookRegistry { +class TransitionService implements ITransitionService, IHookRegistry { constructor() { this._reinit(); } @@ -58,13 +58,13 @@ class TransitionService implements IHookRegistry { HookRegistry.mixin(new HookRegistry(), this); } - create(fromPath: ITransPath, targetState: TargetState) { + create(fromPath: Node[], targetState: TargetState) { return new Transition(fromPath, targetState); } } -let $transitions: ITransitionService = new TransitionService(); +let $transitions = new TransitionService(); $TransitionProvider.prototype = $transitions; function $TransitionProvider() { diff --git a/src/url/urlMatcher.ts b/src/url/urlMatcher.ts index 1a9735726..528be2410 100644 --- a/src/url/urlMatcher.ts +++ b/src/url/urlMatcher.ts @@ -1,13 +1,28 @@ -import {map, extend, inherit, isDefined, isObject, isArray, isString} from "../common/common"; -import matcherConfig from "./urlMatcherConfig" -import paramTypes from "../params/paramTypes" -import ParamSet from "../params/paramSet" -import Param from "../params/param" +import { + map, prop, propEq, defaults, extend, inherit, identity, isDefined, isObject, isArray, isString, + invoke, unnest, tail, forEach, find, curry, omit, pairs, allTrueR +} from "../common/common"; +import paramTypes from "../params/paramTypes"; +import Param from "../params/param"; interface params { $$validates: (params: string) => Array; } +function quoteRegExp(string: any, param?: any) { + var surroundPattern = ['', ''], result = string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&"); + if (!param) return result; + + switch (param.squash) { + case false: surroundPattern = ['(', ')' + (param.isOptional ? '?' : '')]; break; + case true: surroundPattern = ['?(', ')?']; break; + default: surroundPattern = [`(${param.squash}|`, ')?']; break; + } + return result + surroundPattern[0] + param.type.pattern.source + surroundPattern[1]; +} + +const memoizeTo = (obj, prop, fn) => obj[prop] = obj[prop] || fn(); + /** * @ngdoc object * @name ui.router.util.type:UrlMatcher @@ -51,10 +66,7 @@ interface params { * in the built-in `date` Type matches `2014-11-12`) and provides a Date object in $stateParams.start * * @param {string} pattern The pattern to compile into a matcher. - * @param {Object} config A configuration object hash: - * @param {Object=} parentMatcher Used to concatenate the pattern/config onto - * an existing UrlMatcher - * + * @param {Object} config A configuration object hash * * `caseInsensitive` - `true` if URL matching should be case insensitive, otherwise `false`, the default value (for backward compatibility) is `false`. * * `strict` - `false` if matching against a URL with a trailing slash should be treated as equivalent to a URL without a trailing slash, the default value is `true`. * @@ -62,29 +74,29 @@ interface params { * URL matching this matcher (i.e. any string for which {@link ui.router.util.type:UrlMatcher#methods_exec exec()} returns * non-null) will start with this prefix. * - * @property {string} source The pattern that was passed into the constructor - * - * @property {string} sourcePath The path portion of the source property - * - * @property {string} sourceSearch The search portion of the source property - * - * @property {string} regex The constructed regex that will be used to match against the url when - * it is time to determine which url will match. + * @property {string} pattern The pattern that was passed into the constructor * * @returns {Object} New `UrlMatcher` object */ export default class UrlMatcher { - params: ParamSet; - prefix: string; - regexp: RegExp; - segments: Array; - source: string; - sourceSearch: string; - sourcePath: string; - $$paramNames: Array; - - constructor(pattern, config, parentMatcher?: any) { - config = extend({ params: {} }, isObject(config) ? config : {}); + + static nameValidator: RegExp = /^\w+(-+\w+)*(?:\[\])?$/; + + private _cache: { path: UrlMatcher[], pattern?: RegExp } = { path: [], pattern: null }; + private _children: UrlMatcher[] = []; + private _params: Param[] = []; + private _segments: string[] = []; + private _compiled: string[] = []; + + public prefix: string; + + constructor(public pattern: string, public config: any) { + this.config = defaults(this.config, { + params: {}, + strict: false, + caseInsensitive: false, + paramMap: identity + }); // Find all placeholders and create a compiled pattern, using either classic or curly syntax: // '*' name @@ -101,44 +113,28 @@ export default class UrlMatcher { // \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms var placeholder = /([:*])([\w\[\]]+)|\{([\w\[\]]+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, searchPlaceholder = /([:]?)([\w\[\]-]+)|\{([\w\[\]-]+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, - compiled = '^', last = 0, m, - segments = this.segments = [], - parentParams = parentMatcher ? parentMatcher.params : {}, - params = this.params = parentMatcher ? parentMatcher.params.$$new() : new ParamSet(), - paramNames = []; - - function addParameter(id, type, config, location) { - paramNames.push(id); - if (parentParams[id]) return parentParams[id]; - if (!/^\w+(-+\w+)*(?:\[\])?$/.test(id)) throw new Error(`Invalid parameter name '${id}' in pattern '${pattern}'`); - if (params[id]) throw new Error(`Duplicate parameter name '${id}' in pattern '${pattern}'`); - params[id] = new Param(id, type, config, location); - return params[id]; - } - - function quoteRegExp(string, pattern?: any, squash?: any, optional?: any) { - var surroundPattern = ['',''], result = string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&"); - if (!pattern) return result; - switch(squash) { - case false: surroundPattern = ['(', ')' + (optional ? "?" : "")]; break; - case true: surroundPattern = ['?(', ')?']; break; - default: surroundPattern = [`(${squash}|`, ')?']; break; - } - return result + surroundPattern[0] + pattern + surroundPattern[1]; - } + last = 0, m, patterns = []; - this.source = pattern; + const checkParamErrors = (id) => { + if (!UrlMatcher.nameValidator.test(id)) throw new Error(`Invalid parameter name '${id}' in pattern '${pattern}'`); + if (find(this._params, propEq('id', id))) throw new Error(`Duplicate parameter name '${id}' in pattern '${pattern}'`); + }; // Split into static segments separated by path parameter placeholders. // The number of segments is always 1 more than the number of parameters. - function matchDetails(m, isSearch) { - var id, regexp, segment, type, cfg, arrayMode; - id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null - cfg = config.params[id]; - segment = pattern.substring(last, m.index); - regexp = isSearch ? m[4] : m[4] || (m[1] == '*' ? '.*' : null); - type = paramTypes.type(regexp || "string") || inherit(paramTypes.type("string"), { pattern: new RegExp(regexp, config.caseInsensitive ? 'i' : undefined) }); - return {id, regexp, segment, type, cfg}; + const matchDetails = (m, isSearch) => { + // IE[78] returns '' for unmatched groups instead of null + var id = m[2] || m[3], regexp = isSearch ? m[4] : m[4] || (m[1] === '*' ? '.*' : null); + + return { + id, + regexp, + cfg: this.config.params[id], + segment: pattern.substring(last, m.index), + type: paramTypes.type(regexp || "string") || inherit(paramTypes.type("string"), { + pattern: new RegExp(regexp, this.config.caseInsensitive ? 'i' : undefined) + }) + }; } var p, param, segment; @@ -147,9 +143,10 @@ export default class UrlMatcher { p = matchDetails(m, false); if (p.segment.indexOf('?') >= 0) break; // we're into the search part - param = addParameter(p.id, p.type, p.cfg, "path"); - compiled += quoteRegExp(p.segment, param.type.pattern.source, param.squash, param.isOptional); - segments.push(p.segment); + checkParamErrors(p.id); + this._params.push(Param.fromPath(p.id, p.type, this.config.paramMap(p.cfg, false))); + this._segments.push(p.segment); + patterns.push([p.segment, tail(this._params)]); last = placeholder.lastIndex; } segment = pattern.substring(last); @@ -158,68 +155,58 @@ export default class UrlMatcher { var i = segment.indexOf('?'); if (i >= 0) { - var search = this.sourceSearch = segment.substring(i); + var search = segment.substring(i); segment = segment.substring(0, i); - this.sourcePath = pattern.substring(0, last + i); if (search.length > 0) { last = 0; + while ((m = searchPlaceholder.exec(search))) { p = matchDetails(m, true); - param = addParameter(p.id, p.type, p.cfg, "search"); + checkParamErrors(p.id); + this._params.push(Param.fromSearch(p.id, p.type, this.config.paramMap(p.cfg, true))); last = placeholder.lastIndex; // check if ?& } } - } else { - this.sourcePath = pattern; - this.sourceSearch = ''; } - compiled += quoteRegExp(segment) + (config.strict === false ? '\/?' : '') + '$'; - segments.push(segment); + this._segments.push(segment); - this.regexp = new RegExp(compiled, config.caseInsensitive ? 'i' : undefined); - this.prefix = segments[0]; - this.$$paramNames = paramNames; + extend(this, { + _compiled: patterns.map(pattern => quoteRegExp.apply(null, pattern)).concat(quoteRegExp(segment)), + prefix: this._segments[0] + }); + + Object.freeze(this); } /** * @ngdoc function - * @name ui.router.util.type:UrlMatcher#concat + * @name ui.router.util.type:UrlMatcher#append * @methodOf ui.router.util.type:UrlMatcher * * @description - * Returns a new matcher for a pattern constructed by appending the path part and adding the - * search parameters of the specified pattern to this pattern. The current pattern is not - * modified. This can be understood as creating a pattern for URLs that are relative to (or - * suffixes of) the current pattern. + * @TODO * * @example - * The following two matchers are equivalent: - *
-   * new UrlMatcher('/user/{id}?q').concat('/details?date');
-   * new UrlMatcher('/user/{id}/details?q&date');
-   * 
+ * @TODO * - * @param {string} pattern The pattern to append. - * @param {Object} config An object hash of the configuration for the matcher. - * @returns {UrlMatcher} A matcher for the concatenated pattern. + * @param {UrlMatcher} url A `UrlMatcher` instance to append as a child of the current `UrlMatcher`. */ - concat(pattern, config) { - // Because order of search parameters is irrelevant, we can add our own search - // parameters to the end of the new pattern. Parse the new pattern by itself - // and then join the bits together, but it's much easier to do this on a string level. - var defaultConfig = { - caseInsensitive: matcherConfig.caseInsensitive(), - strict: matcherConfig.strictMode(), - squash: matcherConfig.defaultSquashPolicy() - }; - return new UrlMatcher(this.sourcePath + pattern + this.sourceSearch, extend(defaultConfig, config), this); + append(url: UrlMatcher): UrlMatcher { + this._children.push(url); + forEach(url._cache, (val, key) => url._cache[key] = isArray(val) ? [] : null); + url._cache.path = this._cache.path.concat(this); + return url; + } + + isRoot(): boolean { + return this._cache.path.length === 0; } - toString() { - return this.source; + toString(): string { + return this.pattern; } /** @@ -231,56 +218,69 @@ export default class UrlMatcher { * Tests the specified path against this matcher, and returns an object containing the captured * parameter values, or null if the path does not match. The returned object contains the values * of any search parameters that are mentioned in the pattern, but their value may be null if - * they are not present in `searchParams`. This means that search parameters are always treated + * they are not present in `search`. This means that search parameters are always treated * as optional. * * @example *
    * new UrlMatcher('/user/{id}?q&r').exec('/user/bob', {
- *   x: '1', q: 'hello'
- * });
+   *   x: '1', q: 'hello'
+   * });
    * // returns { id: 'bob', q: 'hello', r: null }
    * 
* * @param {string} path The URL path to match, e.g. `$location.path()`. - * @param {Object} searchParams URL search parameters, e.g. `$location.search()`. + * @param {Object} search URL search parameters, e.g. `$location.search()`. + * @param {string} hash URL hash e.g. `$location.hash()`. + * @param {Object} options * @returns {Object} The captured parameter values. */ - exec(path, searchParams, hash) { - var m = this.regexp.exec(path); - if (!m) return null; - searchParams = searchParams || {}; + exec(path: string, search: any = {}, hash?: string, options: any = {}) { + var match = memoizeTo(this._cache, 'pattern', () => { + return new RegExp([ + '^', + unnest(this._cache.path.concat(this).map(prop('_compiled'))).join(''), + this.config.strict === false ? '\/?' : '', + '$' + ].join(''), this.config.caseInsensitive ? 'i' : undefined); + }).exec(path); - var paramNames = this.parameters(), nTotal = paramNames.length, - nPath = this.segments.length - 1, - values = {}, i, j, cfg, paramName; + if (!match) return null; - if (nPath !== m.length - 1) throw new Error(`Unbalanced capture group in route '${this.source}'`); + //options = defaults(options, { isolate: false }); + + var allParams: Param[] = this.parameters(), + pathParams: Param[] = allParams.filter(param => !param.isSearch()), + searchParams: Param[] = allParams.filter(param => param.isSearch()), + nPathSegments = this._cache.path.concat(this).map(urlm => urlm._segments.length - 1).reduce((a, x) => a + x), + values = {}; + + if (nPathSegments !== match.length - 1) + throw new Error(`Unbalanced capture group in route '${this.pattern}'`); function decodePathArray(string: string) { - function reverseString(str: string) { return str.split("").reverse().join(""); } - function unquoteDashes(str: string) { return str.replace(/\\-/g, "-"); } + const reverseString = (str: string) => str.split("").reverse().join(""); + const unquoteDashes = (str: string) => str.replace(/\\-/g, "-"); var split = reverseString(string).split(/-(?!\\)/); var allReversed = map(split, reverseString); return map(allReversed, unquoteDashes).reverse(); } - for (i = 0; i < nPath; i++) { - paramName = paramNames[i]; - var param = this.params[paramName]; - var paramVal: (any|any[]) = m[i+1]; + for (var i = 0; i < nPathSegments; i++) { + var param: Param = pathParams[i]; + var value: (any|any[]) = match[i + 1]; + // if the param value matches a pre-replace pair, replace the value before decoding. - for (j = 0; j < param.replace; j++) { - if (param.replace[j].from === paramVal) paramVal = param.replace[j].to; + for (var j = 0; j < param.replace; j++) { + if (param.replace[j].from === value) value = param.replace[j].to; } - if (paramVal && param.array === true) paramVal = decodePathArray(paramVal); - values[paramName] = param.value(paramVal); - } - for (/**/; i < nTotal; i++) { - paramName = paramNames[i]; - values[paramName] = this.params[paramName].value(searchParams[paramName]); + if (value && param.array === true) value = decodePathArray(value); + values[param.id] = param.value(value); } + forEach(searchParams, param => { + values[param.id] = param.value(search[param.id]) + }); if (hash) values["#"] = hash; @@ -293,19 +293,29 @@ export default class UrlMatcher { * @methodOf ui.router.util.type:UrlMatcher * * @description - * Returns the names of all path and search parameters of this pattern in an unspecified order. + * Returns the names of all path and search parameters of this pattern in order of appearance. * - * @returns {Array.} An array of parameter names. Must be treated as read-only. If the + * @returns {Array.} An array of parameter names. Must be treated as read-only. If the * pattern has no parameters, an empty array is returned. */ - parameters(param?: string) { - if (!isDefined(param)) return this.$$paramNames; - return this.params[param] || null; + parameters(opts: any = {}): Param[] { + if (opts.inherit === false) return this._params; + return unnest(this._cache.path.concat(this).map(prop('_params'))); + } + + parameter(id: string, opts: any = {}): Param { + const parent = tail(this._cache.path); + + return ( + find(this._params, propEq('id', id)) || + (opts.inherit !== false && parent && parent.parameter(id)) || + null + ); } /** * @ngdoc function - * @name ui.router.util.type:UrlMatcher#validate + * @name ui.router.util.type:UrlMatcher#validates * @methodOf ui.router.util.type:UrlMatcher * * @description @@ -315,8 +325,9 @@ export default class UrlMatcher { * @param {Object} params The object hash of parameters to validate. * @returns {boolean} Returns `true` if `params` validates, otherwise `false`. */ - validates(params) { - return this.params.$$validates(params); + validates(params): boolean { + const validParamVal = (param: Param, val) => !param || param.validates(val); + return pairs(params || {}).map(([key, val]) => validParamVal(this.parameter(key), val)).reduce(allTrueR, true); } /** @@ -339,12 +350,11 @@ export default class UrlMatcher { * @returns {string} the formatted URL (path and optionally search part). */ format(values = {}) { - const url = { - params: this.parameters(), - paramSet: this.params, - nPath: this.segments.length - 1 - }; - var i, search = false, result = this.segments[0]; + var segments: string[] = this._segments, + result: string = segments[0], + search: boolean = false, + params: Param[] = this.parameters({inherit: false}), + parent: UrlMatcher = tail(this._cache.path); if (!this.validates(values)) return null; @@ -352,9 +362,10 @@ export default class UrlMatcher { return encodeURIComponent(str).replace(/-/g, c => `%5C%${c.charCodeAt(0).toString(16).toUpperCase()}`); } - url.params.map((name, i) => { - var isPathParam = i < url.nPath; - var param: Param = url.paramSet[name], value = param.value(values[name]); + // TODO: rewrite as reduce over params with result as initial + params.map((param: Param, i) => { + var isPathParam = i < segments.length - 1; + var value = param.value(values[param.id]); var isDefaultValue = param.isDefaultValue(value); var squash = isDefaultValue ? param.squash : false; var encoded = param.type.encode(value); @@ -363,8 +374,8 @@ export default class UrlMatcher { if (encoded == null || (isDefaultValue && squash !== false)) return; if (!isArray(encoded)) encoded = [ encoded]; - encoded = map( encoded, encodeURIComponent).join(`&${name}=`); - result += (search ? '&' : '?') + (`${name}=${encoded}`); + encoded = map( encoded, encodeURIComponent).join(`&${param.id}=`); + result += (search ? '&' : '?') + (`${param.id}=${encoded}`); search = true; return; } @@ -377,11 +388,12 @@ export default class UrlMatcher { if (isArray(encoded)) return map( encoded, encodeDashes).join("-") + segment; if (param.type.raw) return encoded + segment; return encodeURIComponent( encoded) + segment; - })(this.segments[i + 1], result); + })(segments[i + 1], result); }); if (values["#"]) result += "#" + values["#"]; - return result; + var processedParams = ['#'].concat(params.map(prop('id'))); + return (parent && parent.format(omit(values, processedParams)) || '') + result; } } diff --git a/src/url/urlMatcherFactory.ts b/src/url/urlMatcherFactory.ts index bb9c58ff2..3a74e165a 100644 --- a/src/url/urlMatcherFactory.ts +++ b/src/url/urlMatcherFactory.ts @@ -6,7 +6,6 @@ import {forEach, extend, inherit, map, filter, isObject, isDefined, isArray, isS import matcherConfig from "./urlMatcherConfig"; import UrlMatcher from "./urlMatcher"; import Param from "../params/param"; -import ParamSet from "../params/paramSet"; import paramTypes from "../params/paramTypes"; import Type from "../params/type"; @@ -37,9 +36,7 @@ function $UrlMatcherFactory() { * @param {boolean} value `false` to match URL in a case sensitive manner; otherwise `true`; * @returns {boolean} the current value of caseInsensitive */ - this.caseInsensitive = function(value) { - return matcherConfig.caseInsensitive(value); - }; + this.caseInsensitive = matcherConfig.caseInsensitive.bind(matcherConfig); /** * @ngdoc function @@ -52,9 +49,7 @@ function $UrlMatcherFactory() { * @param {boolean=} value `false` to match trailing slashes in URLs, otherwise `true`. * @returns {boolean} the current value of strictMode */ - this.strictMode = function(value) { - return matcherConfig.strictMode(value); - }; + this.strictMode = matcherConfig.strictMode.bind(matcherConfig); /** * @ngdoc function @@ -71,9 +66,7 @@ function $UrlMatcherFactory() { * any other string, e.g. "~": When generating an href with a default parameter value, squash (remove) * the parameter value from the URL and replace it with this string. */ - this.defaultSquashPolicy = function(value) { - return matcherConfig.defaultSquashPolicy(value); - }; + this.defaultSquashPolicy = matcherConfig.defaultSquashPolicy.bind(matcherConfig); /** * @ngdoc function @@ -87,9 +80,7 @@ function $UrlMatcherFactory() { * @param {Object} config The config object hash. * @returns {UrlMatcher} The UrlMatcher. */ - this.compile = function (pattern, config) { - return new UrlMatcher(pattern, extend(getDefaultConfig(), config)); - }; + this.compile = (pattern: string, config: { [key: string]: any }) => new UrlMatcher(pattern, extend(getDefaultConfig(), config)); /** * @ngdoc function @@ -103,14 +94,12 @@ function $UrlMatcherFactory() { * @returns {Boolean} Returns `true` if the object matches the `UrlMatcher` interface, by * implementing all the same methods. */ - this.isMatcher = function (o) { + this.isMatcher = o => { if (!isObject(o)) return false; var result = true; - forEach(UrlMatcher.prototype, function(val, name) { - if (isFunction(val)) { - result = result && (isDefined(o[name]) && isFunction(o[name])); - } + forEach(UrlMatcher.prototype, (val, name) => { + if (isFunction(val)) result = result && (isDefined(o[name]) && isFunction(o[name])); }); return result; }; @@ -234,10 +223,7 @@ function $UrlMatcherFactory() { return this; }; - - this.UrlMatcher = UrlMatcher; - this.Param = Param; - this.ParamSet = ParamSet; + extend(this, { UrlMatcher, Param }); } // Register as a provider so it's available to other providers diff --git a/src/url/urlRouter.ts b/src/url/urlRouter.ts index 4ce9a00b7..7656bf617 100644 --- a/src/url/urlRouter.ts +++ b/src/url/urlRouter.ts @@ -1,6 +1,7 @@ /// import {isFunction, isString, isDefined, isArray, isObject, extend} from "../common/common"; import {IServiceProviderFactory} from "angular"; +import UrlMatcher from "./urlMatcher"; /** * @ngdoc object @@ -10,9 +11,9 @@ import {IServiceProviderFactory} from "angular"; * @requires $locationProvider * * @description - * `$urlRouterProvider` has the responsibility of watching `$location`. - * When `$location` changes it runs through a list of rules one by one until a - * match is found. `$urlRouterProvider` is used behind the scenes anytime you specify + * `$urlRouterProvider` has the responsibility of watching `$location`. + * When `$location` changes it runs through a list of rules one by one until a + * match is found. `$urlRouterProvider` is used behind the scenes anytime you specify * a url in a state configuration. All urls are compiled into a UrlMatcher object. * * There are several methods on `$urlRouterProvider` that make it useful to use directly @@ -97,19 +98,15 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { * }); * * - * @param {string|function} rule The url path you want to redirect to or a function - * rule that returns the url path. The function version is passed two params: + * @param {string|function} rule The url path you want to redirect to or a function + * rule that returns the url path. The function version is passed two params: * `$injector` and `$location` services, and must return a url string. * * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance */ this.otherwise = function (rule) { - if (isString(rule)) { - var redirect = rule; - rule = function () { return redirect; }; - } - else if (!isFunction(rule)) throw new Error("'rule' must be a function"); - otherwise = rule; + if (!isFunction(rule) && !isString(rule)) throw new Error("'rule' must be a string or function"); + otherwise = isString(rule) ? () => rule : rule; return this; }; @@ -172,7 +169,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { matcher: function (what, handler) { if (handlerIsString) { redirect = $urlMatcherFactory.compile(handler); - handler = ['$match', function ($match) { return redirect.format($match); }]; + handler = ['$match', redirect.format.bind(redirect)]; } return extend(function ($injector, $location) { return handleIfMatch($injector, handler, what.exec($location.path(), $location.search(), $location.hash())); @@ -185,7 +182,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { if (handlerIsString) { redirect = handler; - handler = ['$match', function ($match) { return interpolate(redirect, $match); }]; + handler = ['$match', ($match) => interpolate(redirect, $match)]; } return extend(function ($injector, $location) { return handleIfMatch($injector, handler, what.exec($location.path())); @@ -195,7 +192,10 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { } }; - var check = { matcher: $urlMatcherFactory.isMatcher(what), regex: what instanceof RegExp }; + var check = { + matcher: $urlMatcherFactory.isMatcher(what), + regex: what instanceof RegExp + }; for (var n in check) { if (check[n]) return this.rule(strategies[n](what, handler)); @@ -337,15 +337,15 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { * }); * */ - sync: function() { + sync() { update(); }, - listen: function() { + listen() { return listen(); }, - update: function(read) { + update(read) { if (read) { location = $location.url(); return; @@ -356,7 +356,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { $location.replace(); }, - push: function(urlMatcher, params, options) { + push(urlMatcher, params, options) { $location.url(urlMatcher.format(params || {})); if (options && options.replace) $location.replace(); }, @@ -386,7 +386,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { * * @returns {string} Returns the fully compiled URL, or `null` if `params` fail validation against `urlMatcher` */ - href: function(urlMatcher, params, options) { + href(urlMatcher: UrlMatcher, params: any, options: any): string { if (!urlMatcher.validates(params)) return null; var isHtml5 = $locationProvider.html5Mode(); diff --git a/test/resolveSpec.ts b/test/resolveSpec.ts index 09b3ff68e..19a3b6cec 100644 --- a/test/resolveSpec.ts +++ b/test/resolveSpec.ts @@ -6,17 +6,17 @@ import * as uiRouter from "../src/ui-router"; import ResolveContext from "../src/resolve/resolveContext" import Resolvable from "../src/resolve/resolvable" -import {IState} from "../src/state/interface" -import {IParamsPath, IResolvePath} from "../src/path/interface" -import Path from "../src/path/path" -import PathFactory from "../src/path/pathFactory" +import {State} from "../src/state/state"; +import Node from "../src/path/node"; +import PathFactory from "../src/path/pathFactory"; -import {omit, map, pick, prop} from "../src/common/common" +import {omit, map, pick, prop, extend, forEach} from "../src/common/common" +import {IStateDeclaration} from "../src/state/interface"; let module = angular.mock.module; /////////////////////////////////////////////// -var states, statesTree, statesMap: {[key:string]: IState} = {}; +var states, statesTree, statesMap: { [key:string]: State } = {}; var emptyPath; var vals, counts, expectCounts; var asyncCount; @@ -67,21 +67,21 @@ beforeEach(function () { thisState.template = thisState.template || "empty"; thisState.name = name; thisState.parent = parent.name; + thisState.params = {}; thisState.data = { children: [] }; angular.forEach(substates, function (value, key) { thisState.data.children.push(loadStates(thisState, value, key)); }); + thisState = new State(thisState); statesMap[name] = thisState; return thisState; } -// console.log(map(makePath([ "A", "B", "C" ]), function(s) { return s.name; })); }); -function makePath(names: string[]): IResolvePath { - let nodes = map(names, name => ({ state: statesMap[name], ownParams: {} })); - let pPath = new Path(nodes).adapt(PathFactory.makeResolveNode); - return PathFactory.bindTransNodesToPath(pPath); +function makePath(names: string[]): Node[] { + let nodes = map(names, name => new Node(statesMap[name])); + return PathFactory.bindTransNodesToPath(nodes); } function getResolvedData(pathContext: ResolveContext) { @@ -92,7 +92,7 @@ function getResolvedData(pathContext: ResolveContext) { describe('Resolvables system:', function () { beforeEach(inject(function ($transitions, $injector) { uiRouter.common.angular1.runtime.setRuntimeInjector($injector); - emptyPath = new Path([]); + emptyPath = []; asyncCount = 0; })); @@ -493,9 +493,9 @@ describe('Resolvables system:', function () { expect(asyncCount).toBe(1); let slicedPath = path.slice(0, 2); - expect(slicedPath.nodes().length).toBe(2); - expect(slicedPath.nodes()[0].state).toBe(path.nodes()[0].state); - expect(slicedPath.nodes()[1].state).toBe(path.nodes()[1].state); + expect(slicedPath.length).toBe(2); + expect(slicedPath[0].state).toBe(path[0].state); + expect(slicedPath[1].state).toBe(path[1].state); let path2 = path.concat(makePath([ "L", "M" ])); let ctx2 = new ResolveContext(path2); ctx2.resolvePath({resolvePolicy: "JIT"}).then(function () { @@ -528,7 +528,7 @@ describe("State transitions with resolves", function() { } } - angular.forEach(stateDefs, function(state, key) { + angular.forEach(stateDefs, function(state: IStateDeclaration, key) { if (!key) return; state.template = "
state"+key; state.controllerProvider = controllerProvider(state); @@ -546,7 +546,7 @@ describe("State transitions with resolves", function() { $timeout = _$timeout_; $scope = $rootScope.$new(); uiRouter.common.angular1.runtime.setRuntimeInjector($injector); - emptyPath = new Path([]); + emptyPath = []; asyncCount = 0; $compile(angular.element("
"))($scope); })); @@ -611,3 +611,39 @@ describe("State transitions with resolves", function() { expect(counts).toEqualData(expectCounts); })); }); + + + +// Integration tests +describe("Integration: Resolvables system", () => { + beforeEach(module(function ($stateProvider) { + let copy = {}; + forEach(statesMap, (stateDef, name) => { + copy[name] = extend({}, stateDef); + }); + + angular.forEach(copy, stateDef => { + if (stateDef.name) $stateProvider.state(stateDef); + }); + })); + + let $state, $rootScope, $transitions, $trace; + beforeEach(inject((_$state_, _$rootScope_, _$transitions_, _$trace_) => { + $state = _$state_; + $rootScope = _$rootScope_; + $transitions = _$transitions_; + $trace = _$trace_; + })); + + + it("should not re-resolve data, when redirecting to a child", () => { + $transitions.onStart({to: "J"}, ($transition$, _J) => { + expect(counts._J).toEqualData(1); + return $transition$.redirect($state.targetState("K")); + }); + $state.go("J"); + $rootScope.$digest(); + expect($state.current.name).toBe("K"); + expect(counts._J).toEqualData(1); + }); +}); \ No newline at end of file diff --git a/test/stateDirectivesSpec.js b/test/stateDirectivesSpec.js index 4ecb6d3c5..03b1c92f2 100644 --- a/test/stateDirectivesSpec.js +++ b/test/stateDirectivesSpec.js @@ -162,7 +162,7 @@ describe('uiStateRef', function() { timeoutFlush(); $q.flush(); - + expect($state.current.name).toEqual('top'); expect(obj($stateParams)).toEqualData({ }); })); @@ -481,11 +481,13 @@ describe('uiSrefActive', function() { $state.transitionTo('contacts.item.edit', { id: 1 }); $q.flush(); timeoutFlush(); + expect($state.params.id).toBe('1'); expect(a.attr('class')).toMatch(/active/); $state.transitionTo('contacts.item.edit', { id: 4 }); $q.flush(); timeoutFlush(); + expect($state.params.id).toBe('4'); expect(a.attr('class')).not.toMatch(/active/); })); diff --git a/test/stateSpec.js b/test/stateSpec.js index 6e5297b10..4c08f1b2f 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -131,19 +131,26 @@ describe('state helpers', function() { }); it('should compile a UrlMatcher for ^ URLs', function() { - var url = {}; + var url = new UrlMatcher('/'); spyOn(urlMatcherFactoryProvider, 'compile').and.returnValue(url); + spyOn(urlMatcherFactoryProvider, 'isMatcher').and.returnValue(true); expect(builder.builder('url')({ url: "^/foo" })).toBe(url); - expect(urlMatcherFactoryProvider.compile).toHaveBeenCalledWith("/foo", { params: {} }); + expect(urlMatcherFactoryProvider.compile).toHaveBeenCalledWith("/foo", { + params: {}, + paramMap: jasmine.any(Function) + }); + expect(urlMatcherFactoryProvider.isMatcher).toHaveBeenCalledWith(url); }); it('should concatenate URLs from root', function() { - root = { url: { concat: function() {} } }, url = {}; - spyOn(root.url, 'concat').and.returnValue(url); + root = { url: { append: function() {} } }, url = {}; + spyOn(root.url, 'append').and.returnValue(url); + spyOn(urlMatcherFactoryProvider, 'isMatcher').and.returnValue(true); + spyOn(urlMatcherFactoryProvider, 'compile').and.returnValue(url); expect(builder.builder('url')({ url: "/foo" })).toBe(url); - expect(root.url.concat).toHaveBeenCalledWith("/foo", { params: {} }); + expect(root.url.append).toHaveBeenCalledWith(url); }); it('should pass through empty URLs', function() { @@ -151,10 +158,12 @@ describe('state helpers', function() { }); it('should pass through custom UrlMatchers', function() { - var url = ["!"]; + var url = new UrlMatcher("/"); spyOn(urlMatcherFactoryProvider, 'isMatcher').and.returnValue(true); + spyOn(root.url, 'append').and.returnValue(url); expect(builder.builder('url')({ url: url })).toBe(url); expect(urlMatcherFactoryProvider.isMatcher).toHaveBeenCalledWith(url); + expect(root.url.append).toHaveBeenCalledWith(url); }); it('should throw on invalid UrlMatchers', function() { @@ -901,7 +910,7 @@ describe('state', function () { it('should work for relative states', inject(function ($state, $q) { var options = { relative: $state.get('about') }; - + $state.transitionTo('about.person', { person: 'jane' }); $q.flush(); expect($state.is('.person', undefined, options)).toBe(true); diff --git a/test/transitionSpec.js b/test/transitionSpec.ts similarity index 94% rename from test/transitionSpec.js rename to test/transitionSpec.ts index 94d89e69c..e0ec668f5 100644 --- a/test/transitionSpec.js +++ b/test/transitionSpec.ts @@ -1,26 +1,20 @@ +import Node from "../src/path/node"; var module = angular.mock.module; -var uiRouter = require("ui-router"); -var common = uiRouter.common.common; -var RejectType = uiRouter.transition.rejectFactory.RejectType; -var extend = common.extend, - forEach = common.forEach, - map = common.map, - omit = common.omit, - pick = common.pick, - pluck = common.pluck; -var PathFactory = uiRouter.path.pathFactory.default; -var state = uiRouter.state; -var StateMatcher = state.stateMatcher.default; -var StateBuilder = state.stateBuilder.default; -var TargetState = state.targetState.default; -var StateQueueManager = state.stateQueueManager.default; -var TransitionRejection = uiRouter.transition.rejectFactory.TransitionRejection; +import uiRouter from "../src/ui-router"; +import { RejectType } from "../src/transition/rejectFactory"; +import { extend, forEach, map, omit, pick, pluck } from "../src/common/common"; +import PathFactory from "../src/path/pathFactory"; +import StateMatcher from "../src/state/stateMatcher"; +import StateBuilder from "../src/state/stateBuilder"; +import TargetState from "../src/state/targetState"; +import StateQueueManager from "../src/state/stateQueueManager"; +import {TransitionRejection} from "../src/transition/rejectFactory"; describe('transition', function () { var transitionProvider, matcher, pathFactory, statesMap, queue; - var targetState = function(identifier, params, options) { + var targetState = function(identifier, params = {}, options?) { options = options || {}; var stateDefinition = matcher.find(identifier, options.relative); return new TargetState(identifier, stateDefinition, params, options); @@ -52,7 +46,7 @@ describe('transition', function () { matcher = new StateMatcher(statesMap = {}); pathFactory = new PathFactory(function() { return root; }); var builder = new StateBuilder(function() { return root; }, matcher, $urlMatcherFactoryProvider); - queue = new StateQueueManager(statesMap, builder, { when: function() {} }); + queue = new StateQueueManager(statesMap, builder, { when: function() {} }, null); var root = queue.register({ name: '', url: '^', views: null, 'abstract': true}); root.navigable = null; @@ -79,15 +73,15 @@ describe('transition', function () { matcher = new StateMatcher(statesMap); queue.flush($state); makeTransition = function makeTransition(from, to, options) { - var paramsPath = PathFactory.makeParamsPath(targetState(from)); - var fromPath = PathFactory.bindTransNodesToPath(paramsPath.adapt(PathFactory.makeResolveNode)); + let fromState = targetState(from).$state(); + let fromPath = PathFactory.bindTransNodesToPath(fromState.path.map(state => new Node(state))); return $transitions.create(fromPath, targetState(to, null, options)); }; })); describe('provider', function() { describe('async event hooks:', function() { - function PromiseResult(promise) { + function PromiseResult(promise?) { var self = this, _promise; var resolve, reject, complete; @@ -280,7 +274,7 @@ describe('transition', function () { })); it('should be called if any part of the transition fails.', inject(function($transitions, $q) { - transitionProvider.onEnter({ from: "A", to: "C" }, function($transition$) { throw new Erorr("oops!"); }); + transitionProvider.onEnter({ from: "A", to: "C" }, function($transition$) { throw new Error("oops!"); }); transitionProvider.onError({ from: "*", to: "*" }, function($transition$) { states.push($transition$.to().name); }); var states = []; @@ -289,7 +283,7 @@ describe('transition', function () { })); it('should be called for only handlers matching the transition.', inject(function($transitions, $q) { - transitionProvider.onEnter({ from: "A", to: "C" }, function($transition$) { throw new Erorr("oops!"); }); + transitionProvider.onEnter({ from: "A", to: "C" }, function($transition$) { throw new Error("oops!"); }); transitionProvider.onError({ from: "*", to: "*" }, function($transition$) { hooks.push("splatsplat"); }); transitionProvider.onError({ from: "A", to: "C" }, function($transition$) { hooks.push("AC"); }); transitionProvider.onError({ from: "A", to: "D" }, function($transition$) { hooks.push("AD"); }); @@ -314,7 +308,7 @@ describe('transition', function () { it("return value of type Transition should abort the transition with SUPERSEDED status", inject(function($transitions, $q) { var states = [], rejection, transition = makeTransition("A", "D"); transitionProvider.onEnter({ from: "*", to: "*" }, function($state$) { states.push($state$); }); - transitionProvider.onEnter({ from: "*", to: "C" }, function($state, $transition$) { + transitionProvider.onEnter({ from: "*", to: "C" }, function($state, $transition$) { return $transition$.redirect(targetState("B")); }); transition.promise.catch(function(err) { rejection = err; }); @@ -440,7 +434,7 @@ describe('transition', function () { })); it('should not include already entered elements', inject(function($transitions) { - t = makeTransition("B", "D"); + let t = makeTransition("B", "D"); expect(pluck(t.entering(), 'name')).toEqual([ "C", "D" ]); })); }); diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index dde60a947..20878c955 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -1,7 +1,8 @@ var module = angular.mock.module; var uiRouter = require("ui-router"); -var ParamSet = uiRouter.params.paramSet.default; var Param = uiRouter.params.param.default; +var common = uiRouter.common.common; +var prop = common.prop; var provide, UrlMatcher; beforeEach(function() { @@ -9,7 +10,6 @@ beforeEach(function() { app.config(function ($urlMatcherFactoryProvider) { provider = $urlMatcherFactoryProvider; UrlMatcher = provider.UrlMatcher; - ParamSet = provider.ParamSet; //Param = provider.Param; }); }); @@ -50,7 +50,7 @@ describe("UrlMatcher", function () { var m = new UrlMatcher("/"); expect(provider.isMatcher(m)).toBe(true); - m.validates = null; + m = angular.extend({}, m, { validates: null }); expect(provider.isMatcher(m)).toBe(false); }); }); @@ -64,14 +64,14 @@ describe("UrlMatcher", function () { }); it("should match against the entire path", function () { - var matcher = new UrlMatcher('/hello/world'); + var matcher = new UrlMatcher('/hello/world', { strict: true }); expect(matcher.exec('/hello/world/')).toBeNull(); expect(matcher.exec('/hello/world/suffix')).toBeNull(); }); it("should parse parameter placeholders", function () { var matcher = new UrlMatcher('/users/:id/details/{type}/{repeat:[0-9]+}?from&to'); - expect(matcher.parameters()).toEqual(['id', 'type', 'repeat', 'from', 'to']); + expect(matcher.parameters().map(prop('id'))).toEqual(['id', 'type', 'repeat', 'from', 'to']); }); it("should encode and decode duplicate query string values as array", function () { @@ -89,13 +89,7 @@ describe("UrlMatcher", function () { describe("snake-case parameters", function() { it("should match if properly formatted", function() { var matcher = new UrlMatcher('/users/?from&to&snake-case&snake-case-triple'); - var params = matcher.parameters(); - - expect(params.length).toBe(4); - expect(params).toContain('from'); - expect(params).toContain('to'); - expect(params).toContain('snake-case'); - expect(params).toContain('snake-case-triple'); + expect(matcher.parameters().map(prop('id'))).toEqual(['from', 'to', 'snake-case', 'snake-case-triple']); }); it("should not match if invalid", function() { @@ -109,7 +103,7 @@ describe("UrlMatcher", function () { describe(".exec()", function() { it("should capture parameter values", function () { - var m = new UrlMatcher('/users/:id/details/{type}/{repeat:[0-9]+}?from&to'); + var m = new UrlMatcher('/users/:id/details/{type}/{repeat:[0-9]+}?from&to', { strict: false }); expect(m.exec('/users/123/details//0', {})).toEqualData({ id:'123', type:'', repeat:'0'}); }); @@ -178,34 +172,28 @@ describe("UrlMatcher", function () { }); it("encodes URL parameters with hashes", function () { - var m = new UrlMatcher('/users/:id#:section'), - params = { id: 'bob', section: 'contact-details' }; - expect(m.format(params)).toEqual('/users/bob#contact-details'); + var m = new UrlMatcher('/users/:id#:section'); + expect(m.format({ id: 'bob', section: 'contact-details' })).toEqual('/users/bob#contact-details'); }); }); - describe(".concat()", function() { - it("should concatenate matchers", function () { - var matcher = new UrlMatcher('/users/:id/details/{type}?from').concat('/{repeat:[0-9]+}?to'); + describe(".append()", function() { + it("should append matchers", function () { + var matcher = new UrlMatcher('/users/:id/details/{type}?from').append(new UrlMatcher('/{repeat:[0-9]+}?to')); var params = matcher.parameters(); - expect(params.length).toBe(5); - expect(params).toContain('id'); - expect(params).toContain('type'); - expect(params).toContain('repeat'); - expect(params).toContain('from'); - expect(params).toContain('to'); + expect(params.map(prop('id'))).toEqual(['id', 'type', 'from', 'repeat', 'to']); }); it("should return a new matcher", function () { var base = new UrlMatcher('/users/:id/details/{type}?from'); - var matcher = base.concat('/{repeat:[0-9]+}?to'); + var matcher = base.append(new UrlMatcher('/{repeat:[0-9]+}?to')); expect(matcher).not.toBe(base); }); it("should respect $urlMatcherFactoryProvider.strictMode", function() { var m = new UrlMatcher('/'); provider.strictMode(false); - m = m.concat("foo"); + m = m.append(provider.compile("foo")); expect(m.exec("/foo")).toEqual({}); expect(m.exec("/foo/")).toEqual({}) }); @@ -213,7 +201,7 @@ describe("UrlMatcher", function () { it("should respect $urlMatcherFactoryProvider.caseInsensitive", function() { var m = new UrlMatcher('/'); provider.caseInsensitive(true); - m = m.concat("foo"); + m = m.append(provider.compile("foo")); expect(m.exec("/foo")).toEqual({}); expect(m.exec("/FOO")).toEqual({}); }); @@ -221,34 +209,40 @@ describe("UrlMatcher", function () { it("should respect $urlMatcherFactoryProvider.caseInsensitive when validating regex params", function() { var m = new UrlMatcher('/'); provider.caseInsensitive(true); - m = m.concat("foo/{param:bar}"); - expect(m.validates({param:'BAR'})).toEqual(true); + m = m.append(provider.compile("foo/{param:bar}")); + expect(m.validates({ param: 'BAR' })).toEqual(true); }); it("should generate/match params in the proper order", function() { var m = new UrlMatcher('/foo?queryparam'); - m = m.concat("/bar/:pathparam"); - expect(m.exec("/foo/bar/pathval", { queryparam: "queryval" })).toEqual({ pathparam: "pathval", queryparam: "queryval"}); + m = m.append(new UrlMatcher("/bar/:pathparam")); + expect(m.exec("/foo/bar/pathval", { queryparam: "queryval" })).toEqual({ + pathparam: "pathval", + queryparam: "queryval" + }); }); }); describe("multivalue-query-parameters", function() { it("should handle .is() for an array of values", inject(function($location) { - var m = new UrlMatcher('/foo?{param1:int}'); - expect(m.params.param1.type.is([1, 2, 3])).toBe(true); - expect(m.params.param1.type.is([1, "2", 3])).toBe(false); + var m = new UrlMatcher('/foo?{param1:int}'), param = m.parameter('param1'); + expect(param.type.is([1, 2, 3])).toBe(true); + expect(param.type.is([1, "2", 3])).toBe(false); })); it("should handle .equals() for two arrays of values", inject(function($location) { - var m = new UrlMatcher('/foo?{param1:int}&{param2:date}'); - expect(m.params.param1.type.equals([1, 2, 3], [1, 2, 3])).toBe(true); - expect(m.params.param1.type.equals([1, 2, 3], [1, 2 ])).toBe(false); - expect(m.params.param2.type.equals( + var m = new UrlMatcher('/foo?{param1:int}&{param2:date}'), + param1 = m.parameter('param1'), + param2 = m.parameter('param2'); + + expect(param1.type.equals([1, 2, 3], [1, 2, 3])).toBe(true); + expect(param1.type.equals([1, 2, 3], [1, 2 ])).toBe(false); + expect(param2.type.equals( [new Date(2014, 11, 15), new Date(2014, 10, 15)], [new Date(2014, 11, 15), new Date(2014, 10, 15)]) ).toBe(true); - expect(m.params.param2.type.equals( + expect(param2.type.equals( [new Date(2014, 11, 15), new Date(2014, 9, 15)], [new Date(2014, 11, 15), new Date(2014, 10, 15)]) ).toBe(false); @@ -267,9 +261,9 @@ describe("UrlMatcher", function () { expect(m.exec("/foo")).toEqual({ param1: undefined }); expect(m.exec("/foo", {})).toEqual({ param1: undefined }); - expect(m.exec("/foo", {param1: ""})).toEqual({ param1: undefined }); - expect(m.exec("/foo", {param1: "1"})).toEqual({ param1: "1" }); // auto unwrap single values - expect(m.exec("/foo", {param1: [ "1", "2" ]})).toEqual({ param1: [ "1", "2" ] }); + expect(m.exec("/foo", { param1: "" })).toEqual({ param1: undefined }); + expect(m.exec("/foo", { param1: "1" })).toEqual({ param1: "1" }); // auto unwrap single values + expect(m.exec("/foo", { param1: ["1", "2"]})).toEqual({ param1: ["1", "2"] }); $location.url("/foo"); expect(m.exec($location.path(), $location.search())).toEqual( { param1: undefined } ); @@ -494,9 +488,11 @@ describe("urlMatcherFactory", function () { var custom = { format: angular.noop, exec: angular.noop, - concat: angular.noop, + append: angular.noop, + isRoot: angular.noop, validates: angular.noop, - parameters: angular.noop + parameters: angular.noop, + parameter: angular.noop }; expect($umf.isMatcher(custom)).toBe(true); }); @@ -864,119 +860,25 @@ describe("urlMatcherFactory", function () { }); }); - describe("ParamSet", function() { - - var params = {}; - beforeEach(function() { - var types = { int: $umf.type("int"), string: $umf.type("string"), any: $umf.type("any") } - params.grandparent = new Param("grandparent", types.int, {}, "path"); - params.parent = new Param("parent", types.string, {}, "path"); - params.child = new Param("child", types.string, {}, "path"); - params.param4 = new Param("param4", types.any, {}, "path"); - }); - - describe(".$$new", function() { - it("should return a new ParamSet, which returns the previous paramset as $$parent()", function() { - var parent = new ParamSet(); - var child = parent.$$new(); - expect(child.$$parent()).toBe(parent); - }); - - it("should return a new ParamSet, which exposes parent params", function() { - var parent = new ParamSet({ parent: params.parent }); - var child = parent.$$new(); - expect(child.parent).toBe(params.parent); - }); - - it("should return a new ParamSet, which exposes ancestor params", function() { - var grandparent = new ParamSet({ grandparent: params.grandparent }); - var parent = grandparent.$$new({ parent: params.parent }); - var child = parent.$$new({ child: params.child }); - - expect(child.grandparent).toBe(params.grandparent); - expect(child.parent).toBe(params.parent); - expect(child.child).toBe(params.child); - }); - }); - - describe(".$$own", function() { - it("should return a new ParamSet which does not expose ancestor Params (only exposes own Params)", function() { - var grandparent = new ParamSet({ grandparent: params.grandparent }); - var parent = grandparent.$$new({ parent: params.parent }); - var child = parent.$$new({ child: params.child }); - - expect(child.grandparent).toBe(params.grandparent); - var own = child.$$own(); - - expect(own.$$keys()).toEqual(["child"]); - expect(own.child).toBe(params.child); - expect(own.parent).toBeUndefined(); - expect(own.grandparent).toBeUndefined(); - }); - }); - - describe(".$$keys", function() { - it("should return keys for current param set", function() { - var ps = new ParamSet(); - expect(ps.$$keys()).toEqual([]); - - ps = new ParamSet({ foo: {}, bar: {}}); - expect(ps.$$keys()).toEqual(['foo', 'bar']); - }); - - it("should return keys for current and ancestor paramset(s)", function () { - var gpa = new ParamSet({grandparent: params.grandparent}); - expect(gpa.$$keys()).toEqual(['grandparent']); - - var pa = gpa.$$new({ parent: params.parent }); - expect(pa.$$keys()).toEqual(['grandparent', 'parent']); - - var child = pa.$$new({ child: params.child }); - expect(child.$$keys()).toEqual(['grandparent', 'parent', 'child']); - }); - }); - - describe(".$$values", function() { - it("should return typed param values for current param set, from a set of input values", function() { - var gpa = new ParamSet({grandparent: params.grandparent}); - var pa = gpa.$$new({ parent: params.parent }); - var child = pa.$$new({ child: params.child }); - var values = { grandparent: "1", parent: 2, child: "3" }; - expect(child.$$values(values)).toEqual({ grandparent: 1, parent: "2", child: "3" }); - }); - }); - - describe(".$$filter", function() { - it("should return a new ParamSet which is a subset of the current param set", function() { - var gpa = new ParamSet({grandparent: params.grandparent}); - var pa = gpa.$$new({ parent: params.parent }); - var child = pa.$$new({ child: params.child }); - - var subset = child.$$filter(function(param) { return ['parent', 'grandparent'].indexOf(param.id) !== -1; }); - expect(subset.$$keys()).toEqual(['grandparent', 'parent']) - }); - }); - }); - xdescribe("parameter isolation", function() { it("should allow parameters of the same name in different segments", function() { - var m = new UrlMatcher('/users/:id').concat('/photos/:id'); + var m = new UrlMatcher('/users/:id').append(new UrlMatcher('/photos/:id')); expect(m.exec('/users/11/photos/38', {}, { isolate: true })).toEqual([{ id: '11' }, { id: '38' }]); }); it("should prioritize the last child when non-isolated", function() { - var m = new UrlMatcher('/users/:id').concat('/photos/:id'); + var m = new UrlMatcher('/users/:id').append(new UrlMatcher('/photos/:id')); expect(m.exec('/users/11/photos/38')).toEqual({ id: '38' }); }); it("should copy search parameter values to all matching segments", function() { - var m = new UrlMatcher('/users/:id?from').concat('/photos/:id?from'); + var m = new UrlMatcher('/users/:id?from').append(new UrlMatcher('/photos/:id?from')); var result = m.exec('/users/11/photos/38', { from: "bob" }, { isolate: true }); expect(result).toEqual([{ from: "bob", id: "11" }, { from: "bob", id: "38" }]); }); it("should pair empty objects with static segments", function() { - var m = new UrlMatcher('/users/:id').concat('/foo').concat('/photos/:id'); + var m = new UrlMatcher('/users/:id').append(new UrlMatcher('/foo')).append(new UrlMatcher('/photos/:id')); var result = m.exec('/users/11/foo/photos/38', {}, { isolate: true }); expect(result).toEqual([{ id: '11' }, {}, { id: '38' }]); }); diff --git a/test/urlRouterSpec.js b/test/urlRouterSpec.js index efaea230c..9f39c80f8 100644 --- a/test/urlRouterSpec.js +++ b/test/urlRouterSpec.js @@ -25,7 +25,7 @@ describe("UrlRouter", function () { it("should throw on non-function rules", function () { expect(function() { $urp.rule(null); }).toThrowError("'rule' must be a function"); - expect(function() { $urp.otherwise(null); }).toThrowError("'rule' must be a function"); + expect(function() { $urp.otherwise(null); }).toThrowError("'rule' must be a string or function"); }); it("should allow location changes to be deferred", inject(function ($urlRouter, $location, $rootScope) { @@ -106,9 +106,11 @@ describe("UrlRouter", function () { var custom = { url: { exec: function() {}, + isRoot: function() {}, format: function() {}, - concat: function() {}, + append: function() {}, validates: function() {}, + parameter: function() {}, parameters: function() {} }, handler: function() {} @@ -223,7 +225,7 @@ describe("UrlRouter", function () { })); it('should handle the new html5Mode object config from Angular 1.3', inject(function($urlRouter) { - + $lp.html5Mode({ enabled: false }); diff --git a/test/viewSpec.ts b/test/viewSpec.ts index 602f6b604..8f297d2c9 100644 --- a/test/viewSpec.ts +++ b/test/viewSpec.ts @@ -5,19 +5,18 @@ var module = angular.mock.module; import {inherit, extend, curry} from "../src/common/common"; -import Path from "../src/path/path"; +import Node from "../src/path/node"; import ResolveContext from "../src/resolve/resolveContext"; import PathFactory from "../src/path/pathFactory"; import {ViewConfig} from "../src/view/view"; import StateBuilder from "../src/state/stateBuilder"; import StateMatcher from "../src/state/stateMatcher"; -import {IState} from "../src/state/interface"; import {State} from "../src/state/state"; describe('view', function() { var scope, $compile, $injector, elem, $controllerProvider, $urlMatcherFactoryProvider; - let root: IState, states: {[key: string]: IState}; + let root: State, states: {[key: string]: State}; beforeEach(module('ui.router', function(_$provide_, _$controllerProvider_, _$urlMatcherFactoryProvider_) { _$provide_.factory('foo', function() { @@ -33,7 +32,7 @@ describe('view', function() { self: config, resolve: config.resolve || {} })); - let built: IState = stateBuilder.build(state); + let built: State = stateBuilder.build(state); return _states[built.name] = built; }); @@ -54,8 +53,7 @@ describe('view', function() { let ctx, state; beforeEach(() => { state = register({ name: "foo" }); - var nodes = [root, state].map(_state => ({ state: _state, ownParams: {}})); - var path = PathFactory.bindTransNodesToPath( new Path(nodes).adapt(PathFactory.makeResolveNode)); + var path = PathFactory.bindTransNodesToPath([root, state].map(_state => new Node(_state, {}))); ctx = new ResolveContext(path); });