diff --git a/src/common/common.ts b/src/common/common.ts index 7bf85fea..2781bba2 100644 --- a/src/common/common.ts +++ b/src/common/common.ts @@ -7,10 +7,10 @@ * @module common */ /** for typedoc */ -import {isFunction, isString, isArray, isRegExp, isDate} from "./predicates"; -import { all, any, not, prop, curry } from "./hof"; -import {services} from "./coreservices"; -import {State} from "../state/stateObject"; +import { isFunction, isString, isArray, isRegExp, isDate } from "./predicates"; +import { all, any, prop, curry, val } from "./hof"; +import { services } from "./coreservices"; +import { State } from "../state/stateObject"; let w: any = typeof window === 'undefined' ? {} : window; let angular = w.angular || {}; @@ -607,6 +607,59 @@ function _arraysEq(a1: any[], a2: any[]) { return arrayTuples(a1, a2).reduce((b, t) => b && _equals(t[0], t[1]), true); } +export type sortfn = (a,b) => number; + +/** + * Create a sort function + * + * Creates a sort function which sorts by a numeric property. + * + * The `propFn` should return the property as a number which can be sorted. + * + * #### Example: + * This example returns the `priority` prop. + * ```js + * var sortfn = sortBy(obj => obj.priority) + * // equivalent to: + * var longhandSortFn = (a, b) => a.priority - b.priority; + * ``` + * + * #### Example: + * This example uses [[prop]] + * ```js + * var sortfn = sortBy(prop('priority')) + * ``` + * + * The `checkFn` can be used to exclude objects from sorting. + * + * #### Example: + * This example only sorts objects with type === 'FOO' + * ```js + * var sortfn = sortBy(prop('priority'), propEq('type', 'FOO')) + * ``` + * + * @param propFn a function that returns the property (as a number) + * @param checkFn a predicate + * + * @return a sort function like: `(a, b) => (checkFn(a) && checkFn(b)) ? propFn(a) - propFn(b) : 0` + */ +export const sortBy = (propFn: (a) => number, checkFn: Predicate = val(true)) => + (a, b) => + (checkFn(a) && checkFn(b)) ? propFn(a) - propFn(b) : 0; + +/** + * Composes a list of sort functions + * + * Creates a sort function composed of multiple sort functions. + * Each sort function is invoked in series. + * The first sort function to return non-zero "wins". + * + * @param sortFns list of sort functions + */ +export const composeSort = (...sortFns: sortfn[]): sortfn => + (a, b) => + sortFns.reduce((prev, fn) => prev || fn(a, b), 0); + // issue #2676 export const silenceUncaughtInPromise = (promise: Promise) => promise.catch(e => 0) && promise; diff --git a/src/common/strings.ts b/src/common/strings.ts index 2eb3a001..11c5b539 100644 --- a/src/common/strings.ts +++ b/src/common/strings.ts @@ -6,12 +6,12 @@ * @module common_strings */ /** */ -import {isString, isArray, isDefined, isNull, isPromise, isInjectable, isObject} from "./predicates"; -import {Rejection} from "../transition/rejectFactory"; -import {IInjectable, identity, Obj} from "./common"; -import {pattern, is, not, val, invoke} from "./hof"; -import {Transition} from "../transition/transition"; -import {Resolvable} from "../resolve/resolvable"; +import { isString, isArray, isDefined, isNull, isPromise, isInjectable, isObject } from "./predicates"; +import { Rejection } from "../transition/rejectFactory"; +import { IInjectable, identity, Obj, tail, pushR } from "./common"; +import { pattern, is, not, val, invoke } from "./hof"; +import { Transition } from "../transition/transition"; +import { Resolvable } from "../resolve/resolvable"; /** * Returns a string shortened to a maximum length @@ -116,4 +116,40 @@ export const beforeAfterSubstr = (char: string) => (str: string) => { let idx = str.indexOf(char); if (idx === -1) return [str, ""]; return [str.substr(0, idx), str.substr(idx + 1)]; -}; \ No newline at end of file +}; + +/** + * Splits on a delimiter, but returns the delimiters in the array + * + * #### Example: + * ```js + * var splitOnSlashes = splitOnDelim('/'); + * splitOnSlashes("/foo"); // ["/", "foo"] + * splitOnSlashes("/foo/"); // ["/", "foo", "/"] + * ``` + */ +export function splitOnDelim(delim: string) { + let re = new RegExp("(" + delim + ")", "g"); + return (str: string) => + str.split(re).filter(identity); +}; + + +/** + * Reduce fn that joins neighboring strings + * + * Given an array of strings, returns a new array + * where all neighboring strings have been joined. + * + * #### Example: + * ```js + * let arr = ["foo", "bar", 1, "baz", "", "qux" ]; + * arr.reduce(joinNeighborsR, []) // ["foobar", 1, "bazqux" ] + * ``` + */ +export function joinNeighborsR(acc: any[], x: any) { + if (isString(tail(acc)) && isString(x)) + return acc.slice(0, -1).concat(tail(acc)+ x); + return pushR(acc, x); +}; + diff --git a/src/state/interface.ts b/src/state/interface.ts index 4dc6b3e9..431616d4 100644 --- a/src/state/interface.ts +++ b/src/state/interface.ts @@ -2,17 +2,15 @@ * @coreapi * @module state */ /** for typedoc */ -import { ParamDeclaration, RawParams } from "../params/interface"; - -import {State} from "./stateObject"; -import {ViewContext} from "../view/interface"; -import {IInjectable} from "../common/common"; -import {Transition} from "../transition/transition"; -import {TransitionStateHookFn} from "../transition/interface"; -import {ResolvePolicy, ResolvableLiteral} from "../resolve/interface"; -import {Resolvable} from "../resolve/resolvable"; -import {ProviderLike} from "../resolve/interface"; -import {TargetState} from "./targetState"; +import { ParamDeclaration, RawParams, ParamsOrArray } from "../params/interface"; +import { State } from "./stateObject"; +import { ViewContext } from "../view/interface"; +import { IInjectable } from "../common/common"; +import { Transition } from "../transition/transition"; +import { TransitionStateHookFn, TransitionOptions } from "../transition/interface"; +import { ResolvePolicy, ResolvableLiteral, ProviderLike } from "../resolve/interface"; +import { Resolvable } from "../resolve/resolvable"; +import { TargetState } from "./targetState"; export type StateOrName = (string|StateDeclaration|State); @@ -21,6 +19,12 @@ export interface TransitionPromise extends Promise { transition: Transition; } +export interface TargetStateDef { + state: StateOrName; + params?: ParamsOrArray; + options?: TransitionOptions; +} + export type ResolveTypes = Resolvable | ResolvableLiteral | ProviderLike; /** * Base interface for [[Ng1ViewDeclaration]] and [[Ng2ViewDeclaration]] diff --git a/src/state/stateObject.ts b/src/state/stateObject.ts index 20dedc2c..c8042be7 100644 --- a/src/state/stateObject.ts +++ b/src/state/stateObject.ts @@ -45,9 +45,6 @@ export class State { /** A compiled URLMatcher which detects when the state's URL is matched */ public url: UrlMatcher; - /** @hidden temporary place to put the rule registered with $urlRouter.when() */ - public _urlRule: any; - /** The parameters for the state, built from the URL and [[StateDefinition.params]] */ public params: { [key: string]: Param }; diff --git a/src/state/stateQueueManager.ts b/src/state/stateQueueManager.ts index 1a91d3e4..c6297901 100644 --- a/src/state/stateQueueManager.ts +++ b/src/state/stateQueueManager.ts @@ -100,7 +100,6 @@ export class StateQueueManager implements Disposable { attachRoute(state: State) { if (state.abstract || !state.url) return; - state._urlRule = this.$urlRouter.urlRuleFactory.fromState(state); - this.$urlRouter.addRule(state._urlRule); + this.$urlRouter.rule(this.$urlRouter.urlRuleFactory.create(state)); } } diff --git a/src/state/stateRegistry.ts b/src/state/stateRegistry.ts index bdac411b..e247a5cb 100644 --- a/src/state/stateRegistry.ts +++ b/src/state/stateRegistry.ts @@ -10,9 +10,9 @@ import { StateQueueManager } from "./stateQueueManager"; import { StateDeclaration } from "./interface"; import { BuilderFunction } from "./stateBuilder"; import { StateOrName } from "./interface"; -import { UrlRouter } from "../url/urlRouter"; import { removeFrom } from "../common/common"; import { UIRouter } from "../router"; +import { propEq } from "../common/hof"; /** * The signature for the callback function provided to [[StateRegistry.onStateRegistryEvent]]. @@ -31,13 +31,11 @@ export class StateRegistry { matcher: StateMatcher; private builder: StateBuilder; stateQueue: StateQueueManager; - urlRouter: UrlRouter; listeners: StateRegistryListener[] = []; /** @internalapi */ constructor(private _router: UIRouter) { - this.urlRouter = _router.urlRouter; this.matcher = new StateMatcher(this.states); this.builder = new StateBuilder(this.matcher, _router.urlMatcherFactory); this.stateQueue = new StateQueueManager(this, _router.urlRouter, this.states, this.builder, this.listeners); @@ -143,10 +141,13 @@ export class StateRegistry { }; let children = getChildren([state]); - let deregistered = [state].concat(children).reverse(); + let deregistered: State[] = [state].concat(children).reverse(); deregistered.forEach(state => { - this.urlRouter.removeRule(state._urlRule); + let $ur = this._router.urlRouter; + // Remove URL rule + $ur.rules().filter(propEq("state", state)).forEach($ur.removeRule.bind($ur)); + // Remove state from registry delete this.states[state.name]; }); diff --git a/src/state/targetState.ts b/src/state/targetState.ts index f539b273..ef95035d 100644 --- a/src/state/targetState.ts +++ b/src/state/targetState.ts @@ -3,12 +3,12 @@ * @module state */ /** for typedoc */ -import {StateDeclaration, StateOrName} from "./interface"; -import {ParamsOrArray} from "../params/interface"; -import {TransitionOptions} from "../transition/interface"; - -import {State} from "./stateObject"; -import {toJson} from "../common/common"; +import { StateDeclaration, StateOrName, TargetStateDef } from "./interface"; +import { ParamsOrArray } from "../params/interface"; +import { TransitionOptions } from "../transition/interface"; +import { State } from "./stateObject"; +import { toJson } from "../common/common"; +import { isString } from "../common/predicates"; /** * Encapsulate the target (destination) state/params/options of a [[Transition]]. @@ -53,48 +53,59 @@ export class TargetState { * @param _definition The internal state representation, if exists. * @param _params Parameters for the target state * @param _options Transition options. + * + * @internalapi */ constructor( private _identifier: StateOrName, private _definition?: State, - _params: ParamsOrArray = {}, + _params?: ParamsOrArray, private _options: TransitionOptions = {} ) { this._params = _params || {}; } + /** The name of the state this object targets */ name(): String { return this._definition && this._definition.name || this._identifier; } + /** The identifier used when creating this TargetState */ identifier(): StateOrName { return this._identifier; } + /** The target parameter values */ params(): ParamsOrArray { return this._params; } + /** The internal state object (if it was found) */ $state(): State { return this._definition; } + /** The internal state declaration (if it was found) */ state(): StateDeclaration { return this._definition && this._definition.self; } + /** The target options */ options() { return this._options; } + /** True if the target state was found */ exists(): boolean { return !!(this._definition && this._definition.self); } + /** True if the object is valid */ valid(): boolean { return !this.error(); } + /** If the object is invalid, returns the reason why */ error(): string { let base = this.options().relative; if (!this._definition && !!base) { @@ -110,4 +121,21 @@ export class TargetState { toString() { return `'${this.name()}'${toJson(this.params())}`; } + + /** Returns true if the object has a state property that might be a state or state name */ + static isDef = (obj): obj is TargetStateDef => + obj && obj.state && (isString(obj.state) || isString(obj.state.name)); + + // /** Returns a new TargetState based on this one, but using the specified options */ + // withOptions(_options: TransitionOptions): TargetState { + // return extend(this._clone(), { _options }); + // } + // + // /** Returns a new TargetState based on this one, but using the specified params */ + // withParams(_params: ParamsOrArray): TargetState { + // return extend(this._clone(), { _params }); + // } + + // private _clone = () => + // new TargetState(this._identifier, this._definition, this._params, this._options); } diff --git a/src/url/interface.ts b/src/url/interface.ts index 8f24fcb9..3ed11d63 100644 --- a/src/url/interface.ts +++ b/src/url/interface.ts @@ -1,9 +1,11 @@ import { LocationConfig } from "../common/coreservices"; -import { Obj } from "../common/common"; import { ParamType } from "../params/type"; import { Param } from "../params/param"; -import { UrlService } from "./urlService"; import { UIRouter } from "../router"; +import { TargetState } from "../state/targetState"; +import { TargetStateDef } from "../state/interface"; +import { UrlMatcher } from "./urlMatcher"; +import { State } from "../state/stateObject"; export interface ParamFactory { /** Creates a new [[Param]] from a CONFIG block */ @@ -23,6 +25,12 @@ export interface UrlMatcherConfig { paramType(name, type?) } +export interface UrlParts { + path: string; + search: { [key: string]: any }; + hash: string; +} + /** * A function that matches the URL for a [[UrlRule]] * @@ -32,7 +40,7 @@ export interface UrlMatcherConfig { * @return truthy or falsey */ export interface UrlRuleMatchFn { - (urlService?: UrlService, router?: UIRouter): any; + (url?: UrlParts, router?: UIRouter): any; } /** @@ -42,11 +50,38 @@ export interface UrlRuleMatchFn { * The handler should return a string (to redirect), or void */ export interface UrlRuleHandlerFn { - (matchValue?: any, urlService?: UrlService, router?: UIRouter): (string|void); + (matchValue?: any, url?: UrlParts, router?: UIRouter): (string|TargetState|TargetStateDef|void); } -export type UrlRuleType = "STATE" | "URLMATCHER" | "STRING" | "REGEXP" | "RAW" | "OTHER"; +export type UrlRuleType = "STATE" | "URLMATCHER" | "REGEXP" | "RAW" | "OTHER"; + export interface UrlRule { + /** + * The rule's ID. + * + * IDs are auto-assigned when the rule is registered, in increasing order. + */ + $id: number; + + /** + * The rule's priority (defaults to 0). + * + * This can be used to explicitly modify the rule's priority. + * Higher numbers are higher priority. + */ + priority: number; + + /** + * The priority of a given match. + * + * Sometimes more than one UrlRule might have matched. + * This method is used to choose the best match. + * + * If multiple rules matched, each rule's `matchPriority` is called with the value from [[match]]. + * The rule with the highest `matchPriority` has its [[handler]] called. + */ + matchPriority(match: any): number; + /** The type of the rule */ type: UrlRuleType; @@ -60,8 +95,21 @@ export interface UrlRule { * This function handles the rule match event. */ handler: UrlRuleHandlerFn; +} - priority: number; +export interface MatcherUrlRule extends UrlRule { + type: "URLMATCHER"|"STATE"; + urlMatcher: UrlMatcher; +} + +export interface StateRule extends MatcherUrlRule { + type: "STATE"; + state: State; +} + +export interface RegExpRule extends UrlRule { + type: "REGEXP"; + regexp: RegExp; } @@ -94,7 +142,7 @@ export interface UrlRule { // // rules: { // // UrlRouterProvider -// addRule(rule: UrlRule): UrlRouterProvider; +// rule(rule: UrlRule): UrlRouterProvider; // otherwise(rule: string | (($injector: $InjectorLike, $location: LocationServices) => string)): UrlRouterProvider ; // when(what: (RegExp|UrlMatcher|string), handler: string|IInjectable, ruleCallback?) ; // } diff --git a/src/url/urlMatcher.ts b/src/url/urlMatcher.ts index 4e83e2c8..63910fc9 100644 --- a/src/url/urlMatcher.ts +++ b/src/url/urlMatcher.ts @@ -3,19 +3,15 @@ * @module url */ /** for typedoc */ import { - map, defaults, extend, inherit, identity, - unnest, tail, forEach, find, Obj, pairs, allTrueR + map, defaults, inherit, identity, unnest, tail, find, Obj, pairs, allTrueR, unnestR, arrayTuples } from "../common/common"; -import {prop, propEq } from "../common/hof"; -import {isArray, isString} from "../common/predicates"; -import {Param} from "../params/param"; -import {ParamTypes} from "../params/paramTypes"; -import {isDefined} from "../common/predicates"; -import {DefType} from "../params/param"; -import {unnestR} from "../common/common"; -import {arrayTuples} from "../common/common"; -import {RawParams} from "../params/interface"; +import { prop, propEq, pattern, eq, is, val } from "../common/hof"; +import { isArray, isString, isDefined } from "../common/predicates"; +import { Param, DefType } from "../params/param"; +import { ParamTypes } from "../params/paramTypes"; +import { RawParams } from "../params/interface"; import { ParamFactory } from "./interface"; +import { joinNeighborsR, splitOnDelim } from "../common/strings"; /** @hidden */ function quoteRegExp(string: any, param?: any) { @@ -39,6 +35,12 @@ function quoteRegExp(string: any, param?: any) { const memoizeTo = (obj: Obj, prop: string, fn: Function) => obj[prop] = obj[prop] || fn(); +interface UrlMatcherCache { + path: UrlMatcher[]; + parent: UrlMatcher; + pattern: RegExp; +} + /** * Matches URLs against patterns. * @@ -95,7 +97,7 @@ export class UrlMatcher { static nameValidator: RegExp = /^\w+([-.]+\w+)*(?:\[\])?$/; /** @hidden */ - private _cache: { path: UrlMatcher[], pattern?: RegExp } = { path: [], pattern: null }; + private _cache: UrlMatcherCache = { path: [this], parent: null, pattern: null }; /** @hidden */ private _children: UrlMatcher[] = []; /** @hidden */ @@ -202,8 +204,6 @@ export class UrlMatcher { this._segments.push(segment); this._compiled = patterns.map(pattern => quoteRegExp.apply(null, pattern)).concat(quoteRegExp(segment)); - - Object.freeze(this); } /** @@ -215,14 +215,17 @@ export class UrlMatcher { */ 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); + url._cache = { + path: this._cache.path.concat(url), + parent: this, + pattern: null, + }; return url; } /** @hidden */ isRoot(): boolean { - return this._cache.path.length === 0; + return this._cache.path[0] === this; } /** Returns the input pattern string */ @@ -260,7 +263,7 @@ export class UrlMatcher { let match = memoizeTo(this._cache, 'pattern', () => { return new RegExp([ '^', - unnest(this._cache.path.concat(this).map(prop('_compiled'))).join(''), + unnest(this._cache.path.map(prop('_compiled'))).join(''), this.config.strict === false ? '\/?' : '', '$' ].join(''), this.config.caseInsensitive ? 'i' : undefined); @@ -273,7 +276,7 @@ export class UrlMatcher { let 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), + nPathSegments = this._cache.path.map(urlm => urlm._segments.length - 1).reduce((a, x) => a + x), values: RawParams = {}; if (nPathSegments !== match.length - 1) @@ -323,7 +326,7 @@ export class UrlMatcher { */ parameters(opts: any = {}): Param[] { if (opts.inherit === false) return this._params; - return unnest(this._cache.path.concat(this).map(prop('_params'))); + return unnest(this._cache.path.map(prop('_params'))); } /** @@ -335,11 +338,10 @@ export class UrlMatcher { * @returns {T|Param|any|boolean|UrlMatcher|null} */ parameter(id: string, opts: any = {}): Param { - const parent = tail(this._cache.path); - + let parent = this._cache.parent; return ( find(this._params, propEq('id', id)) || - (opts.inherit !== false && parent && parent.parameter(id)) || + (opts.inherit !== false && parent && parent.parameter(id, opts)) || null ); } @@ -378,7 +380,7 @@ export class UrlMatcher { if (!this.validates(values)) return null; // Build the full path of UrlMatchers (including all parent UrlMatchers) - let urlMatchers = this._cache.path.slice().concat(this); + let urlMatchers = this._cache.path; // Extract all the static segments and Params into an ordered array let pathSegmentsAndParams: Array = @@ -451,13 +453,58 @@ export class UrlMatcher { static pathSegmentsAndParams(matcher: UrlMatcher) { let staticSegments = matcher._segments; let pathParams = matcher._params.filter(p => p.location === DefType.PATH); - return arrayTuples(staticSegments, pathParams.concat(undefined)).reduce(unnestR, []).filter(x => x !== "" && isDefined(x)); + return arrayTuples(staticSegments, pathParams.concat(undefined)) + .reduce(unnestR, []) + .filter(x => x !== "" && isDefined(x)); } /** @hidden Given a matcher, return an array with the matcher's query params */ static queryParams(matcher: UrlMatcher): Param[] { return matcher._params.filter(p => p.location === DefType.SEARCH); } + + /** + * Compare two UrlMatchers + * + * This comparison function converts a UrlMatcher into static and dynamic path segments. + * Each static path segment is a static string between a path separator (slash character). + * Each dynamic segment is a path parameter. + * + * The comparison function sorts static segments before dynamic ones. + */ + static compare(a: UrlMatcher, b: UrlMatcher): number { + const splitOnSlash = splitOnDelim('/'); + + /** + * Turn a UrlMatcher and all its parent matchers into an array + * of slash literals '/', string literals, and Param objects + * + * This example matcher matches strings like "/foo/:param/tail": + * var matcher = $umf.compile("/foo").append($umf.compile("/:param")).append($umf.compile("/")).append($umf.compile("tail")); + * var result = segments(matcher); // [ '/', 'foo', '/', Param, '/', 'tail' ] + * + */ + const segments = (matcher: UrlMatcher) => + matcher._cache.path.map(UrlMatcher.pathSegmentsAndParams) + .reduce(unnestR, []) + .reduce(joinNeighborsR, []) + .map(x => isString(x) ? splitOnSlash(x) : x) + .reduce(unnestR, []); + + let aSegments = segments(a), bSegments = segments(b); + // console.table( { aSegments, bSegments }); + + // Sort slashes first, then static strings, the Params + const weight = pattern([ + [eq("/"), val(1)], + [isString, val(2)], + [is(Param), val(3)] + ]); + let pairs = arrayTuples(aSegments.map(weight), bSegments.map(weight)); + // console.table(pairs); + + return pairs.reduce((cmp, weightPair) => cmp !== 0 ? cmp : weightPair[0] - weightPair[1], 0); + } } /** @hidden */ diff --git a/src/url/urlRouter.ts b/src/url/urlRouter.ts index 957417a6..3a3389d0 100644 --- a/src/url/urlRouter.ts +++ b/src/url/urlRouter.ts @@ -2,16 +2,17 @@ * @coreapi * @module url */ /** for typedoc */ -import { removeFrom, createProxyFunctions, find } from "../common/common"; -import { isFunction, isString } from "../common/predicates"; +import { removeFrom, createProxyFunctions, inArray, composeSort, sortBy } from "../common/common"; +import { isFunction, isString, isDefined } from "../common/predicates"; import { UrlMatcher } from "./urlMatcher"; import { RawParams } from "../params/interface"; import { Disposable } from "../interface"; import { UIRouter } from "../router"; -import { val, is, pattern } from "../common/hof"; +import { val, is, pattern, prop, pipe } from "../common/hof"; import { UrlRuleFactory } from "./urlRule"; import { TargetState } from "../state/targetState"; -import { UrlRule, UrlRuleMatchFn, UrlRuleHandlerFn } from "./interface"; +import { UrlRule, UrlRuleHandlerFn, UrlParts } from "./interface"; +import { TargetStateDef } from "../state/interface"; /** @hidden */ function appendBasePath(url: string, isHtml5: boolean, absolute: boolean, baseHref: string): string { @@ -21,16 +22,27 @@ function appendBasePath(url: string, isHtml5: boolean, absolute: boolean, baseHr return url; } +/** @hidden */ +const getMatcher = prop("urlMatcher"); + +/** + * Updates URL and responds to URL changes + * + * This class updates the URL when the state changes. + * It also responds to changes in the URL. + */ export class UrlRouter implements Disposable { /** used to create [[UrlRule]] objects for common cases */ public urlRuleFactory: UrlRuleFactory; /** @hidden */ private _router: UIRouter; /** @hidden */ private location: string; - /** @hidden */ private stopFn: Function; - /** @hidden */ rules: UrlRule[] = []; - /** @hidden */ otherwiseFn: UrlRule; + /** @hidden */ private _sortFn = UrlRouter.defaultRuleSortFn; + /** @hidden */ private _stopFn: Function; + /** @hidden */ _rules: UrlRule[] = []; + /** @hidden */ private _otherwiseFn: UrlRule; /** @hidden */ interceptDeferred = false; + /** @hidden */ private _id = 0; /** @hidden */ constructor(router: UIRouter) { @@ -42,8 +54,54 @@ export class UrlRouter implements Disposable { /** @internalapi */ dispose() { this.listen(false); - this.rules = []; - delete this.otherwiseFn; + this._rules = []; + delete this._otherwiseFn; + } + + /** + * Defines URL Rule priorities + * + * More than one rule ([[UrlRule]]) might match a given URL. + * This `compareFn` is used to sort the rules by priority. + * Higher priority rules should sort earlier. + * + * The [[defaultRuleSortFn]] is used by default. + * + * You only need to call this function once. + * The `compareFn` will be used to sort the rules as each is registered. + * + * If called without any parameter, it will re-sort the rules. + * + * --- + * + * Url rules may come from multiple sources: states's urls ([[StateDeclaration.url]]), [[when]], and [[rule]]. + * Each rule has a (user-provided) [[UrlRule.priority]], a [[UrlRule.type]], and a [[UrlRule.$id]] + * The `$id` is is the order in which the rule was registered. + * + * The sort function should use these data, or data found on a specific type + * of [[UrlRule]] (such as [[StateUrlRule.state]]), to order the rules as desired. + * + * #### Example: + * This compare function prioritizes rules by the order in which the rules were registered. + * A rule registered earlier has higher priority. + * + * ```js + * function compareFn(a, b) { + * return a.$id - b.$id; + * } + * ``` + * + * @param compareFn a function that compares to [[UrlRule]] objects. + * The `compareFn` should abide by the `Array.sort` compare function rules. + * Given two rules, `a` and `b`, return a negative number if `a` should be higher priority. + * Return a positive number if `b` should be higher priority. + * Return `0` if the rules are identical. + * + * See the [mozilla reference](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#Description) + * for details. + */ + sort(compareFn?: (a: UrlRule, b: UrlRule) => number) { + this._rules.sort(this._sortFn = compareFn || this._sortFn); } /** @@ -72,20 +130,45 @@ export class UrlRouter implements Disposable { sync(evt?) { if (evt && evt.defaultPrevented) return; - let router = this._router; - let $url = router.urlService; + let router = this._router, + $url = router.urlService, + $state = router.stateService; + + let rules = this.rules(); + if (this._otherwiseFn) rules.push(this._otherwiseFn); + + let url: UrlParts = { + path: $url.path(), search: $url.search(), hash: $url.hash() + }; + + // Checks a single rule. Returns { rule: rule, match: match, weight: weight } if it matched, or undefined + interface MatchResult { match: any, rule: UrlRule, weight: number } + let checkRule = (rule: UrlRule): MatchResult => { + let match = rule.match(url, router); + return match && { match, rule, weight: rule.matchPriority(match) }; + }; - function check(rule: UrlRule) { - let match = rule && rule.match($url, router); - if (!match) return false; + // The rules are pre-sorted. + // - Find the first matching rule. + // - Find any other matching rule that sorted *exactly the same*, according to `.sort()`. + // - Choose the rule with the highest match weight. + let best: MatchResult; + for (let i = 0; i < rules.length; i++) { + // Stop when there is a 'best' rule and the next rule sorts differently than it. + if (best && this._sortFn(rules[i], best.rule) !== 0) break; - let result = rule.handler(match, $url, router); - if (isString(result)) $url.url(result, true); - return true; + let current = checkRule(rules[i]); + // Pick the best MatchResult + best = (!best || current && current.weight > best.weight) ? current : best; } - let found = find(this.rules, check); - if (!found) check(this.otherwiseFn); + let applyResult = pattern([ + [isString, (newurl: string) => $url.url(newurl)], + [TargetState.isDef, (def: TargetStateDef) => $state.go(def.state, def.params, def.options)], + [is(TargetState), (target: TargetState) => $state.go(target.state(), target.params(), target.options())], + ]); + + applyResult(best && best.rule.handler(best.match, url, router)); } /** @@ -98,10 +181,10 @@ export class UrlRouter implements Disposable { */ listen(enabled?: boolean): Function { if (enabled === false) { - this.stopFn && this.stopFn(); - delete this.stopFn; + this._stopFn && this._stopFn(); + delete this._stopFn; } else { - return this.stopFn = this.stopFn || this._router.urlService.onChange(evt => this.sync(evt)); + return this._stopFn = this._stopFn || this._router.urlService.onChange(evt => this.sync(evt)); } } @@ -177,9 +260,22 @@ export class UrlRouter implements Disposable { return [cfg.protocol(), '://', cfg.host(), port, slash, url].join(''); } - addRule(rule: UrlRule) { + + /** + * Manually adds a URL Rule. + * + * Usually, a url rule is added using [[StateDeclaration.url]] or [[when]]. + * This api can be used directly for more control (to register [[RawUrlRule]], for example). + * Rules can be created using [[UrlRouter.ruleFactory]], or create manually as simple objects. + * + * @return a function that deregisters the rule + */ + rule(rule: UrlRule): Function { if (!UrlRuleFactory.isUrlRule(rule)) throw new Error("invalid rule"); - this.rules.push(rule); + rule.$id = this._id++; + rule.priority = rule.priority || 0; + this._rules.push(rule); + this.sort(); return () => this.removeRule(rule); } @@ -187,97 +283,124 @@ export class UrlRouter implements Disposable { * Remove a rule previously registered * * @param rule the matcher rule that was previously registered using [[rule]] - * @return true if the rule was found (and removed) */ - removeRule(rule): boolean { - return this.rules.length !== removeFrom(this.rules, rule).length; + removeRule(rule): void { + removeFrom(this._rules, rule); + this.sort(); } + /** + * Gets all registered rules + * + * @returns an array of all the registered rules + */ + rules = (): UrlRule[] => this._rules.slice(); + /** * Defines the path or behavior to use when no url can be matched. * + * - If a string, it is treated as a url redirect + * * #### Example: + * When no other url rule matches, redirect to `/index` * ```js - * var app = angular.module('app', ['ui.router.router']); + * .otherwise('/index'); + * ``` * - * app.config(function ($urlRouterProvider) { - * // if the path doesn't match any of the urls you configured - * // otherwise will take care of routing the user to the - * // specified url - * $urlRouterProvider.otherwise('/index'); - * - * // Example of using function rule as param - * $urlRouterProvider.otherwise(function ($injector, $location) { - * return '/a/valid/url'; - * }); - * }); + * - If a function, the function receives the current url ([[UrlParts]]) and the [[UIRouter]] object. + * If the function returns a string, the url is redirected to the return value. + * + * #### Example: + * When no other url rule matches, redirect to `/index` + * ```js + * .otherwise(() => '/index'); * ``` * - * @param redirectTo - * The url path you want to redirect to or a function rule that returns the url path or performs a `$state.go()`. - * The function version is passed two params: `$injector` and `$location` services, and should return a url string. + * #### Example: + * When no other url rule matches, go to `home` state + * ```js + * .otherwise((url, router) => { + * router.stateService.go('home'); + * return; + * } + * ``` * - * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance + * @param handler The url path to redirect to, or a function which returns the url path (or performs custom logic). */ - otherwise(redirectTo: string|UrlRuleMatchFn|TargetState) { - let rf = this.urlRuleFactory; - let $state = this._router.stateService; - - let makeRule = pattern([ - [isString, (_redirectTo: string) => rf.fromMatchFn(val(_redirectTo))], - [isFunction, (_redirectTo: UrlRuleMatchFn) => rf.fromMatchFn(_redirectTo)], - [is(TargetState), (target: TargetState) => ($state.go(target.name(), target.params(), target.options()), true)], - [val(true), error] - ]); - - this.otherwiseFn = makeRule(redirectTo); - - function error() { - throw new Error("'redirectTo' must be a string, function, TargetState, or have a state: 'target' property"); + otherwise(handler: string|UrlRuleHandlerFn|TargetState|TargetStateDef) { + if (!isFunction(handler) && !isString(handler) && !is(TargetState)(handler) && !TargetState.isDef(handler)) { + throw new Error("'redirectTo' must be a string, function, TargetState, or have a state: 'newtarget' property"); } + + let handlerFn: UrlRuleHandlerFn = isFunction(handler) ? handler as UrlRuleHandlerFn : val(handler); + this._otherwiseFn = this.urlRuleFactory.create(val(true), handlerFn); + this.sort(); }; /** - * Registers a handler for a given url matching. + * Registers a `matcher` and `handler` for custom URLs handling. + * + * The `matcher` can be: + * + * - a [[UrlMatcher]]: See: [[UrlMatcherFactory.compile]] + * - a `string`: The string is compiled to a [[UrlMatcher]] + * - a `RegExp`: The regexp is used to match the url. * - * If the handler is a string, it is - * treated as a redirect, and is interpolated according to the syntax of match - * (i.e. like `String.replace()` for `RegExp`, or like a `UrlMatcher` pattern otherwise). + * The `handler` can be: * - * If the handler is a function, it is injectable. - * It gets invoked if `$location` matches. - * You have the option of inject the match object as `$match`. + * - a string: The url is redirected to the value of the string. + * - a function: The url is redirected to the return value of the function. * - * The handler can return + * --- * - * - **falsy** to indicate that the rule didn't match after all, then `$urlRouter` - * will continue trying to find another one that matches. - * - **string** which is treated as a redirect and passed to `$location.url()` - * - **void** or any **truthy** value tells `$urlRouter` that the url was handled. + * When the `handler` is a `string` and the `matcher` is a `UrlMatcher` (or string), the redirect + * string is interpolated with parameter values. * - * @example + * #### Example: + * When the URL is `/foo/123` the rule will redirect to `/bar/123`. * ```js + * .when("/foo/:param1", "/bar/:param1") + * ``` * - * var app = angular.module('app', ['ui.router.router']); + * --- * - * app.config(function ($urlRouterProvider) { - * $urlRouterProvider.when($state.url, function ($match, $stateParams) { - * if ($state.$current.navigable !== state || - * !equalForKeys($match, $stateParams) { - * $state.transitionTo(state, $match, false); - * } - * }); - * }); + * When the `handler` is a string and the `matcher` is a `RegExp`, the redirect string is + * interpolated with capture groups from the RegExp. + * + * #### Example: + * When the URL is `/foo/123` the rule will redirect to `/bar/123`. + * ```js + * .when(new RegExp("^/foo/(.*)$"), "/bar/$1"); * ``` * - * @param what A pattern string to match, compiled as a [[UrlMatcher]]. - * @param handler The path (or function that returns a path) that you want to redirect your user to. - * @return a function that deregisters the rule + * --- * - * Note: the handler may also invoke arbitrary code, such as `$state.go()` + * When the handler is a function, it receives the matched value, the current URL, and the `UIRouter` object (See [[UrlRuleHandlerFn]]). + * The "matched value" differs based on the `matcher`. + * For [[UrlMatcher]]s, it will be the matched state params. + * For `RegExp`, it will be the match array from `regexp.exec()`. + * + * If the handler returns a string, the URL is redirected to the string. + * + * #### Example: + * When the URL is `/foo/123` the rule will redirect to `/bar/123`. + * ```js + * .when(new RegExp("^/foo/(.*)$"), match => "/bar/" + match[1]); + * ``` + * + * @param matcher A pattern `string` to match, compiled as a [[UrlMatcher]], or a `RegExp`. + * @param handler The path to redirect to, or a function that returns the path. + * @param options `{ priority: number }` + * + * @return the registered [[UrlRule]] + * + * Note: the `handler` may also invoke arbitrary code, such as `$state.go()` */ - when(what: (RegExp|UrlMatcher|string), handler: string|UrlRuleHandlerFn): Function { - return this.addRule(this.urlRuleFactory.create(what, handler)); + when(matcher: (RegExp|UrlMatcher|string), handler: string|UrlRuleHandlerFn, options?: { priority: number }): UrlRule { + let rule = this.urlRuleFactory.create(matcher, handler); + if (isDefined(options && options.priority)) rule.priority = options.priority; + this.rule(rule); + return rule; }; /** @@ -314,5 +437,21 @@ export class UrlRouter implements Disposable { if (defer === undefined) defer = true; this.interceptDeferred = defer; }; -} + /** + * Default rule priority sorting function. + * + * Sorts rules by: + * + * - Explicit priority (set rule priority using [[UrlRouter.when]]) + * - Rule type (STATE: 4, URLMATCHER: 4, REGEXP: 3, RAW: 2, OTHER: 1) + * - `UrlMatcher` specificity ([[UrlMatcher.compare]]): works for STATE and URLMATCHER types to pick the most specific rule. + * - Registration order (for rule types other than STATE and URLMATCHER) + */ + static defaultRuleSortFn = composeSort( + sortBy(pipe(prop("priority"), x => -x)), + sortBy(pipe(prop("type"), type => ({ "STATE": 4, "URLMATCHER": 4, "REGEXP": 3, "RAW": 2, "OTHER": 1 })[type])), + (a,b) => (getMatcher(a) && getMatcher(b)) ? UrlMatcher.compare(getMatcher(a), getMatcher(b)) : 0, + sortBy(prop("$id"), inArray([ "REGEXP", "RAW", "OTHER" ])), + ); +} diff --git a/src/url/urlRule.ts b/src/url/urlRule.ts index ddb79cbb..8ca76f39 100644 --- a/src/url/urlRule.ts +++ b/src/url/urlRule.ts @@ -1,14 +1,17 @@ +/** + * @internalapi + * @module url + */ /** */ import { UrlMatcher } from "./urlMatcher"; -import { isString, isDefined } from "../common/predicates"; +import { isString, isDefined, isFunction } from "../common/predicates"; import { UIRouter } from "../router"; -import { extend, identity } from "../common/common"; +import { identity, extend } from "../common/common"; import { is, pattern } from "../common/hof"; import { State } from "../state/stateObject"; import { RawParams } from "../params/interface"; -import { UrlRule, UrlRuleMatchFn, UrlRuleHandlerFn, UrlRuleType } from "./interface"; -import { StateService } from "../state/stateService"; -import { UIRouterGlobals } from "../globals"; -import { UrlService } from "./urlService"; +import { + UrlRule, UrlRuleMatchFn, UrlRuleHandlerFn, UrlRuleType, UrlParts, MatcherUrlRule, StateRule, RegExpRule +} from "./interface"; /** * Creates a [[UrlRule]] @@ -23,207 +26,189 @@ import { UrlService } from "./urlService"; export class UrlRuleFactory { constructor(public router: UIRouter) { } - compile(pattern: string): UrlMatcher { - return this.router.urlMatcherFactory.compile(pattern); + compile(str: string) { + return this.router.urlMatcherFactory.compile(str); } static isUrlRule = obj => - obj && ['type', 'match', 'handler', 'priority'].every(key => isDefined(obj[key])); + obj && ['type', 'match', 'handler'].every(key => isDefined(obj[key])); - create(what: string|State|UrlMatcher|RegExp, handler?): UrlRule { + create(what: string|UrlMatcher|State|RegExp|UrlRuleMatchFn, handler?: string|UrlRuleHandlerFn): UrlRule { const makeRule = pattern([ - [isString, () => this.fromString(what as string, handler)], - [is(UrlMatcher), () => this.fromMatcher(what as UrlMatcher, handler)], - [is(RegExp), () => this.fromRegExp(what as RegExp, handler)], - [is(State), () => this.fromState(what as State)], + [isString, (_what: string) => makeRule(this.compile(_what))], + [is(UrlMatcher), (_what: UrlMatcher) => this.fromUrlMatcher(_what, handler)], + [is(State), (_what: State) => this.fromState(_what, this.router)], + [is(RegExp), (_what: RegExp) => this.fromRegExp(_what, handler)], + [isFunction, (_what: UrlRuleMatchFn) => new BaseUrlRule(_what, handler as UrlRuleHandlerFn)], ]); + let rule = makeRule(what); if (!rule) throw new Error("invalid 'what' in when()"); return rule; } - fromString = (pattern: string, handler: string|UrlMatcher|UrlRuleHandlerFn) => - extend(this.fromMatcher(this.compile(pattern), handler), { type: "STRING" }); - - fromMatcher = (urlMatcher: UrlMatcher, handler: string|UrlMatcher|UrlRuleHandlerFn) => - new UrlMatcherRule(urlMatcher, (isString(handler) ? this.compile(handler) : handler)); - - fromRegExp = (pattern: RegExp, handler: string|UrlRuleHandlerFn) => - new RegExpRule(pattern, handler); - - fromState = (state: State) => - new StateUrlRule(state, this.router); - - fromMatchFn = (match: UrlRuleMatchFn) => - new RawUrlRule(match); -} - -/** - * A UrlRule which matches based on a regular expression - * - * The `handler` may be either a [[UrlRuleHandlerFn]] or a string. - * - * ## Handler as a function - * - * If `handler` is a function, the function is invoked with: - * - * - regexp match array (from `regexp`) - * - path: current path - * - search: current search - * - hash: current hash - * - * #### Example: - * ```js - * var rule = RegExpRule(/^\/foo\/(bar|baz)$/, match => "/home/" + match[1]) - * var match = rule.match('/foo/bar'); // results in [ '/foo/bar', 'bar' ] - * var result = rule.handler(match); // '/home/bar' - * ``` - * - * ## Handler as string - * - * If `handler` is a string, the url is *replaced by the string* when the Rule is invoked. - * The string is first interpolated using `string.replace()` style pattern. - * - * #### Example: - * ```js - * var rule = RegExpRule(/^\/foo\/(bar|baz)$/, "/home/$1") - * var match = rule.match('/foo/bar'); // results in [ '/foo/bar', 'bar' ] - * var result = rule.handler(match); // '/home/bar' - * ``` - */ -export class RegExpRule implements UrlRule { - type: UrlRuleType = "REGEXP"; - handler: UrlRuleHandlerFn; - priority = 0; - - constructor(public regexp: RegExp, handler: string|UrlRuleHandlerFn) { - if (regexp.global || regexp.sticky) { - throw new Error("Rule RegExp must not be global or sticky"); - } - this.handler = isString(handler) ? this.redirectUrlTo(handler) : handler; - } - /** - * If handler is a string, the url will be replaced by the string. - * If the string has any String.replace() style variables in it (like `$2`), - * they will be replaced by the captures from [[match]] + * A UrlRule which matches based on a UrlMatcher + * + * The `handler` may be either a `string`, a [[UrlRuleHandlerFn]] or another [[UrlMatcher]] + * + * ## Handler as a function + * + * If `handler` is a function, the function is invoked with: + * + * - matched parameter values ([[RawParams]] from [[UrlMatcher.exec]]) + * - url: the current Url ([[UrlParts]]) + * - router: the router object ([[UIRouter]]) + * + * #### Example: + * ```js + * var urlMatcher = $umf.compile("/foo/:fooId/:barId"); + * var rule = factory.fromUrlMatcher(urlMatcher, match => "/home/" + match.fooId + "/" + match.barId); + * var match = rule.match('/foo/123/456'); // results in { fooId: '123', barId: '456' } + * var result = rule.handler(match); // '/home/123/456' + * ``` + * + * ## Handler as UrlMatcher + * + * If `handler` is a UrlMatcher, the handler matcher is used to create the new url. + * The `handler` UrlMatcher is formatted using the matched param from the first matcher. + * The url is replaced with the result. + * + * #### Example: + * ```js + * var urlMatcher = $umf.compile("/foo/:fooId/:barId"); + * var handler = $umf.compile("/home/:fooId/:barId"); + * var rule = factory.fromUrlMatcher(urlMatcher, handler); + * var match = rule.match('/foo/123/456'); // results in { fooId: '123', barId: '456' } + * var result = rule.handler(match); // '/home/123/456' + * ``` */ - redirectUrlTo = (newurl: string) => - (match: RegExpExecArray) => - // Interpolates matched values into $1 $2, etc using a String.replace()-style pattern - newurl.replace(/\$(\$|\d{1,2})/, (m, what) => - match[what === '$' ? 0 : Number(what)]); - - match($url: UrlService): RegExpExecArray { - return this.regexp.exec($url.path()); - } -} + fromUrlMatcher(urlMatcher: UrlMatcher, handler: string|UrlMatcher|UrlRuleHandlerFn): MatcherUrlRule { + let _handler: UrlRuleHandlerFn = handler as any; + if (isString(handler)) handler = this.router.urlMatcherFactory.compile(handler); + if (is(UrlMatcher)(handler)) _handler = (match: RawParams) => (handler as UrlMatcher).format(match); + + function match(url: UrlParts) { + let match = urlMatcher.exec(url.path, url.search, url.hash); + return urlMatcher.validates(match) && match; + } -/** - * A UrlRule which matches based on a UrlMatcher - * - * The `handler` may be either a [[UrlRuleHandlerFn]] or a different [[UrlMatcher]] - * - * ## Handler as a function - * - * If `handler` is a function, the function is invoked with: - * - * - matched parameter values (from `urlMatcher`) - * - path: current path - * - search: current search - * - hash: current hash - * - * #### Example: - * ```js - * var urlMatcher = $umf.compile("/foo/:fooId/:barId"); - * var rule = UrlMatcherRule(urlMatcher, match => "/home/" + match.fooId + "/" + match.barId); - * var match = rule.match('/foo/123/456'); // results in { fooId: '123', barId: '456' } - * var result = rule.handler(match); // '/home/123/456' - * ``` - * - * ## Handler as UrlMatcher - * - * If `handler` is a UrlMatcher, the handler matcher is used to create the new url. - * The `handler` UrlMatcher is formatted, using the param values matched from the first matcher. - * The url is replaced with the result. - * - * #### Example: - * ```js - * var urlMatcher = $umf.compile("/foo/:fooId/:barId"); - * var handler = $umf.compile("/home/:fooId/:barId"); - * var rule = UrlMatcherRule(urlMatcher, handler); - * var match = rule.match('/foo/123/456'); // results in { fooId: '123', barId: '456' } - * var result = rule.handler(match); // '/home/123/456' - * ``` - */ -export class UrlMatcherRule implements UrlRule { - type: UrlRuleType = "URLMATCHER"; - handler: UrlRuleHandlerFn; - priority = 0; + // Prioritize URLs, lowest to highest: + // - Some optional URL parameters, but none matched + // - No optional parameters in URL + // - Some optional parameters, some matched + // - Some optional parameters, all matched + function matchPriority(params: RawParams): number { + let optional = urlMatcher.parameters().filter(param => param.isOptional); + if (!optional.length) return 0.000001; + let matched = optional.filter(param => params[param.id]); + return matched.length / optional.length; + } - constructor(public urlMatcher: UrlMatcher, handler: UrlMatcher|UrlRuleHandlerFn) { - this.handler = is(UrlMatcher)(handler) ? this.redirectUrlTo(handler) : handler; + let details = { urlMatcher, matchPriority, type: "URLMATCHER" }; + return extend(new BaseUrlRule(match, _handler), details) as MatcherUrlRule; } - redirectUrlTo = (newurl: UrlMatcher) => - (match: RawParams) => - newurl.format(match); - - match = ($url: UrlService) => - this.urlMatcher.exec($url.path(), $url.search(), $url.hash()); -} -/** - * A UrlRule which matches a state by its url - * - * #### Example: - * ```js - * var rule = new StateUrlRule($state.get('foo'), router); - * var match = rule.match('/foo/123/456'); // results in { fooId: '123', barId: '456' } - * var result = rule.handler(match); // '/home/123/456' - * ``` - */ -export class StateUrlRule implements UrlRule { - type: UrlRuleType = "STATE"; - priority = 0; - $state: StateService; - globals: UIRouterGlobals; - - constructor(public state: State, router: UIRouter) { - this.globals = router.globals; - this.$state = router.stateService; + /** + * A UrlRule which matches a state by its url + * + * #### Example: + * ```js + * var rule = factory.fromState($state.get('foo'), router); + * var match = rule.match('/foo/123/456'); // results in { fooId: '123', barId: '456' } + * var result = rule.handler(match); + * // Starts a transition to 'foo' with params: { fooId: '123', barId: '456' } + * ``` + */ + fromState(state: State, router: UIRouter): StateRule { + /** + * Handles match by transitioning to matched state + * + * First checks if the router should start a new transition. + * A new transition is not required if the current state's URL + * and the new URL are already identical + */ + const handler = (match: RawParams) => { + let $state = router.stateService; + let globals = router.globals; + if ($state.href(state, match) !== $state.href(globals.current, globals.params)) { + $state.transitionTo(state, match, { inherit: true, source: "url" }); + } + }; + + let details = { state, type: "STATE" }; + return extend(this.fromUrlMatcher(state.url, handler), details) as StateRule; } - match = ($url: UrlService) => - this.state.url.exec($url.path(), $url.search(), $url.hash()); - /** - * Checks if the router should start a new transition. + * A UrlRule which matches based on a regular expression + * + * The `handler` may be either a [[UrlRuleHandlerFn]] or a string. + * + * ## Handler as a function + * + * If `handler` is a function, the function is invoked with: + * + * - regexp match array (from `regexp`) + * - url: the current Url ([[UrlParts]]) + * - router: the router object ([[UIRouter]]) * - * A new transition is not required if the current state's - * URL and the new URL are identical + * #### Example: + * ```js + * var rule = factory.fromRegExp(/^\/foo\/(bar|baz)$/, match => "/home/" + match[1]) + * var match = rule.match('/foo/bar'); // results in [ '/foo/bar', 'bar' ] + * var result = rule.handler(match); // '/home/bar' + * ``` + * + * ## Handler as string + * + * If `handler` is a string, the url is *replaced by the string* when the Rule is invoked. + * The string is first interpolated using `string.replace()` style pattern. + * + * #### Example: + * ```js + * var rule = factory.fromRegExp(/^\/foo\/(bar|baz)$/, "/home/$1") + * var match = rule.match('/foo/bar'); // results in [ '/foo/bar', 'bar' ] + * var result = rule.handler(match); // '/home/bar' + * ``` */ - shouldTransition(match: RawParams) { - return this.$state.href(this.state, match) !== this.$state.href(this.globals.current, this.globals.params); + fromRegExp(regexp: RegExp, handler: string|UrlRuleHandlerFn): RegExpRule { + if (regexp.global || regexp.sticky) throw new Error("Rule RegExp must not be global or sticky"); + + /** + * If handler is a string, the url will be replaced by the string. + * If the string has any String.replace() style variables in it (like `$2`), + * they will be replaced by the captures from [[match]] + */ + const redirectUrlTo = (match: RegExpExecArray) => + // Interpolates matched values into $1 $2, etc using a String.replace()-style pattern + (handler as string).replace(/\$(\$|\d{1,2})/, (m, what) => + match[what === '$' ? 0 : Number(what)]); + + const _handler = isString(handler) ? redirectUrlTo : handler; + + const match = (url: UrlParts): RegExpExecArray => + regexp.exec(url.path); + + let details = { regexp, type: "REGEXP" }; + return extend(new BaseUrlRule(match, _handler), details) as RegExpRule } - - handler = (match: RawParams) => { - if (this.shouldTransition(match)) { - this.$state.transitionTo(this.state, match, { inherit: true, source: "url" }); - } - }; } /** - * A "raw" rule which calls `match` + * A base rule which calls `match` * - * The value from the `match` function is passed through as the `handler` result. + * The value from the `match` function is passed through to the `handler`. */ -export class RawUrlRule implements UrlRule { +export class BaseUrlRule implements UrlRule { + $id: number; + priority: number; type: UrlRuleType = "RAW"; - priority = 0; - - constructor(public match: UrlRuleMatchFn) { } + handler: UrlRuleHandlerFn; + matchPriority = (match) => 0 - this.$id; - handler = identity + constructor(public match: UrlRuleMatchFn, handler?: UrlRuleHandlerFn) { + this.handler = handler || identity; + } } \ No newline at end of file diff --git a/test/stateRegistrySpec.ts b/test/stateRegistrySpec.ts index 3f4fe1af..9a128dee 100644 --- a/test/stateRegistrySpec.ts +++ b/test/stateRegistrySpec.ts @@ -72,7 +72,7 @@ describe("StateRegistry", () => { $state.transitionTo['calls'].reset(); router.urlRouter.sync(); expect($state.transitionTo['calls'].count()).toBe(0); - expect(router.urlRouter.rules.length).toBe(0); + expect(router.urlRouter._rules.length).toBe(0); }); }); diff --git a/test/urlRouterSpec.ts b/test/urlRouterSpec.ts index 025ff156..93f69cdd 100644 --- a/test/urlRouterSpec.ts +++ b/test/urlRouterSpec.ts @@ -2,173 +2,274 @@ import { UrlMatcher, UrlMatcherFactory, UrlRouter, StateService, UIRouter } from import { TestingPlugin } from "./_testingPlugin"; import { LocationServices } from "../src/common/coreservices"; import { UrlService } from "../src/url/urlService"; +import { StateRegistry } from "../src/state/stateRegistry"; +import { noop } from "../src/common/common"; +import { UrlRule } from "../src/url/interface"; -declare var inject; +declare var jasmine; +var _anything = jasmine.anything(); describe("UrlRouter", function () { var router: UIRouter; - var $ur: UrlRouter, - $url: UrlService, - $umf: UrlMatcherFactory, - $s: StateService, - location: LocationServices, - match; + var urlRouter: UrlRouter, + urlService: UrlService, + urlMatcherFactory: UrlMatcherFactory, + stateService: StateService, + stateRegistry: StateRegistry, + locationService: LocationServices; + + const matcher = (...strings: string[]) => + strings.reduce((prev: UrlMatcher, str) => + prev ? prev.append(urlMatcherFactory.compile(str)) : urlMatcherFactory.compile(str), undefined); beforeEach(function() { router = new UIRouter(); router.plugin(TestingPlugin); - $ur = router.urlRouter; - $url = router.urlService; - $umf = router.urlMatcherFactory; - $s = router.stateService; - location = router.locationService; - }); - - beforeEach(function () { - let rule1 = $ur.urlRuleFactory.fromRegExp(/\/baz/, "/b4z"); - // let rule1 = $ur.urlRuleFactory.fromMatchFn($url => /baz/.test($url.path()) && $url.path().replace('baz', 'b4z')); - $ur.addRule(rule1); - - $ur.when('/foo/:param', function ($match) { - match = ['/foo/:param', $match]; - }); - $ur.when('/bar', function ($match) { - match = ['/bar', $match]; - }); + urlRouter = router.urlRouter; + urlService = router.urlService; + urlMatcherFactory = router.urlMatcherFactory; + stateService = router.stateService; + stateRegistry = router.stateRegistry; + locationService = router.locationService; }); - it("should throw on non-function rules", function () { - expect(function() { $ur.addRule(null); }).toThrowError(/invalid rule/); - expect(function() { $ur.otherwise(null); }).toThrowError(/must be a/); + expect(function() { urlRouter.rule(null); }).toThrowError(/invalid rule/); + expect(function() { urlRouter.otherwise(null); }).toThrowError(/must be a/); }); it("should execute rewrite rules", function () { - location.url("/foo"); - expect(location.path()).toBe("/foo"); + urlRouter.rule(urlRouter.urlRuleFactory.create(/\/baz/, "/b4z")); - location.url("/baz"); - expect(location.path()).toBe("/b4z"); + locationService.url("/foo"); + expect(locationService.path()).toBe("/foo"); + + locationService.url("/baz"); + expect(locationService.path()).toBe("/b4z"); }); it("should keep otherwise last", function () { - $ur.otherwise('/otherwise'); + urlRouter.otherwise('/otherwise'); - location.url("/lastrule"); - expect(location.path()).toBe("/otherwise"); + locationService.url("/lastrule"); + expect(locationService.path()).toBe("/otherwise"); - $ur.when('/lastrule', function($match) { - match = ['/lastrule', $match]; - }); + urlRouter.when('/lastrule', noop); - location.url("/lastrule"); - expect(location.path()).toBe("/lastrule"); + locationService.url("/lastrule"); + expect(locationService.path()).toBe("/lastrule"); }); - it('addRule should return a deregistration function', function() { - var count = 0, rule = { + it('`rule` should return a deregistration function', function() { + var count = 0; + var rule: UrlRule = { match: () => count++, handler: match => match, + matchPriority: () => 0, + $id: 0, priority: 0, type: "OTHER", }; - let dereg = $ur.addRule(rule as any); + let dereg = urlRouter.rule(rule as any); - $ur.sync(); + urlRouter.sync(); expect(count).toBe(1); - $ur.sync(); + urlRouter.sync(); expect(count).toBe(2); dereg(); - $ur.sync(); + urlRouter.sync(); expect(count).toBe(2); }); - it('removeRule should remove a previously registered rule', function() { - var count = 0, rule = { + it('`removeRule` should remove a previously registered rule', function() { + var count = 0; + var rule: UrlRule = { match: () => count++, handler: match => match, + matchPriority: () => 0, + $id: 0, priority: 0, type: "OTHER", }; - $ur.addRule(rule as any); + urlRouter.rule(rule as any); - $ur.sync(); + urlRouter.sync(); expect(count).toBe(1); - $ur.sync(); + urlRouter.sync(); expect(count).toBe(2); - $ur.removeRule(rule); - $ur.sync(); + urlRouter.removeRule(rule); + urlRouter.sync(); expect(count).toBe(2); }); - it('when should return a deregistration function', function() { + it('`when` should return the new rule', function() { let calls = 0; - location.url('/foo'); - let dereg = $ur.when('/foo', function() { calls++; }); + locationService.url('/foo'); + let rule = urlRouter.when('/foo', function() { calls++; }); - $ur.sync(); + urlRouter.sync(); expect(calls).toBe(1); - dereg(); - $ur.sync(); - expect(calls).toBe(1); + expect(typeof rule.match).toBe('function'); + expect(typeof rule.handler).toBe('function'); }); describe("location updates", function() { it('can push location changes', function () { spyOn(router.locationService, "url"); - $ur.push($umf.compile("/hello/:name"), { name: "world" }); + urlRouter.push(matcher("/hello/:name"), { name: "world" }); expect(router.locationService.url).toHaveBeenCalledWith("/hello/world", undefined); }); it('can push a replacement location', function () { spyOn(router.locationService, "url"); - $ur.push($umf.compile("/hello/:name"), { name: "world" }, { replace: true }); + urlRouter.push(matcher("/hello/:name"), { name: "world" }, { replace: true }); expect(router.locationService.url).toHaveBeenCalledWith("/hello/world", true); }); it('can push location changes with no parameters', function () { spyOn(router.locationService, "url"); - $ur.push($umf.compile("/hello/:name", { params: { name: "" } })); + urlRouter.push(urlMatcherFactory.compile("/hello/:name", { params: { name: "" } })); expect(router.locationService.url).toHaveBeenCalledWith("/hello/", undefined); }); it('can push location changes that include a #fragment', function () { // html5mode disabled - $ur.push($umf.compile('/hello/:name'), { name: 'world', '#': 'frag' }); - expect($url.path()).toBe('/hello/world'); - expect($url.hash()).toBe('frag'); + urlRouter.push(matcher('/hello/:name'), { name: 'world', '#': 'frag' }); + expect(urlService.path()).toBe('/hello/world'); + expect(urlService.hash()).toBe('frag'); }); it('can read and sync a copy of location URL', function () { - $url.url('/old'); + urlService.url('/old'); spyOn(router.locationService, 'path').and.callThrough(); - $ur.update(true); + urlRouter.update(true); expect(router.locationService.path).toHaveBeenCalled(); - $url.url('/new'); - $ur.update(); + urlService.url('/new'); + urlRouter.update(); - expect($url.path()).toBe('/old'); + expect(urlService.path()).toBe('/old'); }); }); describe("URL generation", function() { it("should return null when UrlMatcher rejects parameters", function () { - $umf.type("custom", { is: val => val === 1138 }); - var matcher = $umf.compile("/foo/{param:custom}"); + urlMatcherFactory.type("custom", { is: val => val === 1138 }); + var urlmatcher = matcher("/foo/{param:custom}"); - expect($ur.href(matcher, { param: 1138 })).toBe('#/foo/1138'); - expect($ur.href(matcher, { param: 5 })).toBeNull(); + expect(urlRouter.href(urlmatcher, { param: 1138 })).toBe('#/foo/1138'); + expect(urlRouter.href(urlmatcher, { param: 5 })).toBeNull(); }); it('should return URLs with #fragments', function () { - expect($ur.href($umf.compile('/hello/:name'), { name: 'world', '#': 'frag' })).toBe('#/hello/world#frag'); + expect(urlRouter.href(matcher('/hello/:name'), { name: 'world', '#': 'frag' })).toBe('#/hello/world#frag'); + }); + }); + + describe('Url Rule priority', () => { + + var matchlog: string[]; + beforeEach(() => matchlog = []); + const log = (id) => () => (matchlog.push(id), null); + + it("should prioritize a path with a static string over a param 1", () => { + var spy = spyOn(stateService, "transitionTo"); + let A = stateRegistry.register({ name: 'A', url: '/:pA' }); + let B = stateRegistry.register({ name: 'B', url: '/BBB' }); + + urlService.url("/AAA"); + expect(spy).toHaveBeenCalledWith(A, { pA: 'AAA' }, _anything); + + urlService.url("/BBB"); + expect(spy).toHaveBeenCalledWith(B, { }, _anything); + }); + + it("should prioritize a path with a static string over a param 2", () => { + var spy = spyOn(stateService, "transitionTo"); + stateRegistry.register({ name: 'foo', url: '/foo' }); + let A = stateRegistry.register({ name: 'foo.A', url: '/:pA' }); + let B = stateRegistry.register({ name: 'B', url: '/foo/BBB' }); + + urlService.url("/foo/AAA"); + expect(spy).toHaveBeenCalledWith(A, { pA: 'AAA' }, _anything); + + urlService.url("/foo/BBB"); + expect(spy).toHaveBeenCalledWith(B, { }, _anything); + }); + + it("should prioritize a path with a static string over a param 3", () => { + urlRouter.when(matcher('/foo', '/:p1', '/tail'), log('p1')); + urlRouter.when(matcher('/foo', '/AAA', '/tail'), log('AAA')); + urlRouter.when(matcher('/foo', '/BBB/tail'), log('BBB')); + + urlService.url("/foo/AAA/tail"); + expect(matchlog).toEqual(['AAA']); + + urlService.url("/foo/BBB/tail"); + expect(matchlog).toEqual(['AAA', 'BBB']); + + urlService.url("/foo/XXX/tail"); + expect(matchlog).toEqual(['AAA', 'BBB', 'p1']); + }); + + it("should prioritize a path with a static string over a param 4", () => { + urlRouter.when(matcher('/foo', '/:p1/:p2', '/tail'), log('p1')); + urlRouter.when(matcher('/foo', '/:p1/AAA', '/tail'), log('AAA')); + + urlService.url("/foo/xyz/AAA/tail"); + expect(matchlog).toEqual(['AAA']); + + urlService.url("/foo/xyz/123/tail"); + expect(matchlog).toEqual(['AAA', 'p1']); + }); + + it("should prioritize a path with a static string over a param 5", () => { + urlRouter.when(matcher('/foo/:p1/:p2/tail'), log('p1')); + urlRouter.when(matcher('/foo', '/:p1/AAA', '/tail'), log('AAA')); + + urlService.url("/foo/xyz/AAA/tail"); + expect(matchlog).toEqual(['AAA']); + + urlService.url("/foo/xyz/123/tail"); + expect(matchlog).toEqual(['AAA', 'p1']); + }); + + it("should prioritize a path with a static string over a param 6", () => { + urlRouter.when(matcher('/foo/:p1/:p2/tail'), log('p1')); + urlRouter.when(matcher('/foo', '/:p1/AAA', '/:p2'), log('AAA')); + + urlService.url("/foo/xyz/AAA/tail"); + expect(matchlog).toEqual(['AAA']); + + urlService.url("/foo/xyz/123/tail"); + expect(matchlog).toEqual(['AAA', 'p1']); + }); + + it("should prioritize a rule with a higher priority", () => { + urlRouter.when(matcher('/foo', '/:p1', '/:p2'), log(1), { priority: 1 }); + urlRouter.when(matcher('/foo/123/456'), log(2)); + urlService.url("/foo/123/456"); + + expect(matchlog).toEqual([1]); + }); + + describe('rules which sort identically', () => { + it("should prioritize the rule with the highest number of matched param values", () => { + urlRouter.when(matcher('/foo/:p1/:p2'), log(1)); + urlRouter.when(matcher('/foo/:p1/:p2?query'), log(2)); + + urlService.url("/foo/123/456"); + expect(matchlog).toEqual([1]); + + urlService.url("/foo/123/456?query=blah"); + expect(matchlog).toEqual([1, 2]); + }) }); }); }); @@ -186,7 +287,7 @@ describe('UrlRouter.deferIntercept', () => { it("should allow location changes to be deferred", function () { var log = []; - $ur.addRule($ur.urlRuleFactory.create(/.*/, () => log.push($url.path()))); + $ur.rule($ur.urlRuleFactory.create(/.*/, () => log.push($url.path()))); $url.url('/foo');