From bbe4209654bf4896f59e1bcf4537ebb389b010de Mon Sep 17 00:00:00 2001 From: Chris Thielen Date: Thu, 16 Mar 2017 14:55:13 -0500 Subject: [PATCH] feat(UrlService): Add `rules.initial("/home")` to config initial state (like otherwise) docs(otherwise): new words Closes https://github.com/angular-ui/ui-router/issues/3336 --- src/state/interface.ts | 7 ++--- src/url/interface.ts | 69 ++++++++++++++++++++++++++++++++++++------ src/url/urlRouter.ts | 47 +++++++++++++++++----------- src/url/urlService.ts | 2 +- test/urlRouterSpec.ts | 62 +++++++++++++++++++++++++++++++++++++ test/urlServiceSpec.ts | 6 +++- tslint.json | 2 +- 7 files changed, 161 insertions(+), 34 deletions(-) diff --git a/src/state/interface.ts b/src/state/interface.ts index a9bc264b..659fd44e 100644 --- a/src/state/interface.ts +++ b/src/state/interface.ts @@ -435,9 +435,7 @@ export interface StateDeclaration { * - If the value is a [[TargetState]] the Transition is redirected to the `TargetState` * * - If the property is a function: - * - The function is called with two parameters: - * - The current [[Transition]] - * - An [[UIInjector]] which can be used to get dependencies using [[UIInjector.get]] or resolves using [[UIInjector.getAsync]] + * - The function is called with the current [[Transition]] * - The return value is processed using the previously mentioned rules. * - If the return value is a promise, the promise is waited for, then the resolved async value is processed using the same rules. * @@ -489,7 +487,8 @@ export interface StateDeclaration { * }) * ``` */ - redirectTo?: RedirectToResult | Promise + redirectTo?: RedirectToResult | + ((transition: Transition) => Promise); /** * A Transition Hook called with the state is being entered. See: [[IHookRegistry.onEnter]] diff --git a/src/url/interface.ts b/src/url/interface.ts index 5894d91a..8495b8a9 100644 --- a/src/url/interface.ts +++ b/src/url/interface.ts @@ -308,9 +308,12 @@ export interface UrlRulesApi { when(matcher: (RegExp|UrlMatcher|string), handler: string|UrlRuleHandlerFn, options?: { priority: number }): UrlRule; /** - * Defines the path or behavior to use when no url can be matched. + * Defines the state, url, or behavior to use when no other rule matches the URL. * - * - If a string, it is treated as a url redirect + * This rule is matched when *no other rule* matches. + * It is generally used to handle unknown URLs (similar to "404" behavior, but on the client side). + * + * - If `handler` a string, it is treated as a url redirect * * #### Example: * When no other url rule matches, redirect to `/index` @@ -318,28 +321,74 @@ export interface UrlRulesApi { * .otherwise('/index'); * ``` * - * - 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. + * - If `handler` is an object with a `state` property, the state is activated. * * #### Example: - * When no other url rule matches, redirect to `/index` + * When no other url rule matches, redirect to `home` and provide a `dashboard` parameter value. * ```js - * .otherwise(() => '/index'); + * .otherwise({ state: 'home', params: { dashboard: 'default' } }); * ``` * + * - If `handler` is a function, the function receives the current url ([[UrlParts]]) and the [[UIRouter]] object. + * The function can perform actions, and/or return a value. + * * #### Example: - * When no other url rule matches, go to `home` state + * When no other url rule matches, manually trigger a transition to the `home` state * ```js - * .otherwise((url, router) => { + * .otherwise((urlParts, router) => { * router.stateService.go('home'); - * return; - * } + * }); + * ``` + * + * #### Example: + * When no other url rule matches, go to `home` state + * ```js + * .otherwise((urlParts, router) => { + * return { state: 'home' }; + * }); * ``` * * @param handler The url path to redirect to, or a function which returns the url path (or performs custom logic). */ otherwise(handler: string|UrlRuleHandlerFn|TargetState|TargetStateDef): void; + /** + * Defines the initial state, path, or behavior to use when the app starts. + * + * This rule defines the initial/starting state for the application. + * + * This rule is triggered the first time the URL is checked (when the app initially loads). + * The rule is triggered only when the url matches either `""` or `"/"`. + * + * Note: The rule is intended to be used when the root of the application is directly linked to. + * When the URL is *not* `""` or `"/"` and doesn't match other rules, the [[otherwise]] rule is triggered. + * This allows 404-like behavior when an unknown URL is deep-linked. + * + * #### Example: + * Start app at `home` state. + * ```js + * .initial({ state: 'home' }); + * ``` + * + * #### Example: + * Start app at `/home` (by url) + * ```js + * .initial('/home'); + * ``` + * + * #### Example: + * When no other url rule matches, go to `home` state + * ```js + * .initial((url, router) => { + * console.log('initial state'); + * return { state: 'home' }; + * }) + * ``` + * + * @param handler The initial state or url path, or a function which returns the state or url path (or performs custom logic). + */ + initial(handler: string|UrlRuleHandlerFn|TargetState|TargetStateDef, options?: { priority: number }): void; + /** * Gets all registered rules * diff --git a/src/url/urlRouter.ts b/src/url/urlRouter.ts index 29b2374f..9206cae5 100644 --- a/src/url/urlRouter.ts +++ b/src/url/urlRouter.ts @@ -3,17 +3,17 @@ * @module url */ /** for typedoc */ -import { removeFrom, createProxyFunctions, inArray, composeSort, sortBy, extend } 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, prop, pipe } from "../common/hof"; -import { UrlRuleFactory } from "./urlRule"; -import { TargetState } from "../state/targetState"; -import { UrlRule, UrlRuleHandlerFn, UrlParts, UrlRulesApi, UrlSyncApi, MatchResult } from "./interface"; -import { TargetStateDef } from "../state/interface"; +import { composeSort, createProxyFunctions, extend, inArray, removeFrom, sortBy } from '../common/common'; +import { isDefined, isFunction, isString } from '../common/predicates'; +import { UrlMatcher } from './urlMatcher'; +import { RawParams } from '../params/interface'; +import { Disposable } from '../interface'; +import { UIRouter } from '../router'; +import { is, pattern, pipe, prop, val } from '../common/hof'; +import { UrlRuleFactory } from './urlRule'; +import { TargetState } from '../state/targetState'; +import { MatchResult, UrlParts, UrlRule, UrlRuleHandlerFn, UrlRuleMatchFn, UrlRulesApi, UrlSyncApi } from './interface'; +import { TargetStateDef } from '../state/interface'; /** @hidden */ function appendBasePath(url: string, isHtml5: boolean, absolute: boolean, baseHref: string): string { @@ -42,7 +42,7 @@ let defaultRuleSortFn: (a: UrlRule, b: UrlRule) => number; 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, + (a, b) => (getMatcher(a) && getMatcher(b)) ? UrlMatcher.compare(getMatcher(a), getMatcher(b)) : 0, sortBy(prop("$id"), inArray([ "REGEXP", "RAW", "OTHER" ])), ); @@ -140,7 +140,7 @@ export class UrlRouter implements UrlRulesApi, UrlSyncApi, Disposable { $state = router.stateService; let url: UrlParts = { - path: $url.path(), search: $url.search(), hash: $url.hash() + path: $url.path(), search: $url.search(), hash: $url.hash(), }; let best = this.match(url); @@ -273,15 +273,22 @@ export class UrlRouter implements UrlRulesApi, UrlSyncApi, Disposable { /** @inheritdoc */ otherwise(handler: string|UrlRuleHandlerFn|TargetState|TargetStateDef) { - if (!isFunction(handler) && !isString(handler) && !is(TargetState)(handler) && !TargetState.isDef(handler)) { - throw new Error("'handler' must be a string, function, TargetState, or have a state: 'newtarget' property"); - } + let handlerFn: UrlRuleHandlerFn = getHandlerFn(handler); - let handlerFn: UrlRuleHandlerFn = isFunction(handler) ? handler as UrlRuleHandlerFn : val(handler); this._otherwiseFn = this.urlRuleFactory.create(val(true), handlerFn); this._sorted = false; }; + /** @inheritdoc */ + initial(handler: string | UrlRuleHandlerFn | TargetState | TargetStateDef) { + let handlerFn: UrlRuleHandlerFn = getHandlerFn(handler); + + let matchFn: UrlRuleMatchFn = (urlParts, router) => + router.globals.transitionHistory.size() === 0 && !!/^\/?$/.exec(urlParts.path); + + this.rule(this.urlRuleFactory.create(matchFn, handlerFn)); + }; + /** @inheritdoc */ when(matcher: (RegExp|UrlMatcher|string), handler: string|UrlRuleHandlerFn, options?: { priority: number }): UrlRule { let rule = this.urlRuleFactory.create(matcher, handler); @@ -297,3 +304,9 @@ export class UrlRouter implements UrlRulesApi, UrlSyncApi, Disposable { }; } +function getHandlerFn(handler: string|UrlRuleHandlerFn|TargetState|TargetStateDef): UrlRuleHandlerFn { + if (!isFunction(handler) && !isString(handler) && !is(TargetState)(handler) && !TargetState.isDef(handler)) { + throw new Error("'handler' must be a string, function, TargetState, or have a state: 'newtarget' property"); + } + return isFunction(handler) ? handler as UrlRuleHandlerFn : val(handler); +} diff --git a/src/url/urlService.ts b/src/url/urlService.ts index fdf638b1..e0715ad9 100644 --- a/src/url/urlService.ts +++ b/src/url/urlService.ts @@ -15,7 +15,7 @@ const makeStub = (keys: string[]): any => /** @hidden */ const locationServicesFns = ["url", "path", "search", "hash", "onChange"]; /** @hidden */ const locationConfigFns = ["port", "protocol", "host", "baseHref", "html5Mode", "hashPrefix"]; /** @hidden */ const umfFns = ["type", "caseInsensitive", "strictMode", "defaultSquashPolicy"]; -/** @hidden */ const rulesFns = ["sort", "when", "otherwise", "rules", "rule", "removeRule"]; +/** @hidden */ const rulesFns = ["sort", "when", "initial", "otherwise", "rules", "rule", "removeRule"]; /** @hidden */ const syncFns = ["deferIntercept", "listen", "sync", "match"]; /** diff --git a/test/urlRouterSpec.ts b/test/urlRouterSpec.ts index 80739e5b..83dfb4c5 100644 --- a/test/urlRouterSpec.ts +++ b/test/urlRouterSpec.ts @@ -61,6 +61,68 @@ describe("UrlRouter", function () { expect(locationService.path()).toBe("/lastrule"); }); + describe('.initial(string)', () => { + beforeEach(() => { + router.stateRegistry.register({ name: 'foo', url: '/foo' }); + router.stateRegistry.register({ name: 'bar', url: '/bar' }); + router.stateRegistry.register({ name: 'otherwise', url: '/otherwise' }); + + urlRouter.initial('/foo'); + urlRouter.otherwise('/otherwise'); + }); + + it("should activate the initial path when initial path matches ''" , function () { + locationService.url(""); + expect(locationService.path()).toBe("/foo"); + }); + + it("should activate the initial path when initial path matches '/'" , function () { + locationService.url("/"); + expect(locationService.path()).toBe("/foo"); + }); + + it("should not activate the initial path after the initial transition" , function (done) { + stateService.go('bar').then(() => { + locationService.url("/"); + expect(locationService.path()).toBe("/otherwise"); + done(); + }); + }); + }); + + describe('.initial({ state: "state" })', () => { + let goSpy = null; + beforeEach(() => { + router.stateRegistry.register({ name: 'foo', url: '/foo' }); + router.stateRegistry.register({ name: 'bar', url: '/bar' }); + router.stateRegistry.register({ name: 'otherwise', url: '/otherwise' }); + + urlRouter.initial({ state: 'foo' }); + urlRouter.otherwise({ state: 'otherwise' }); + + goSpy = spyOn(stateService, "transitionTo").and.callThrough(); + }); + + it("should activate the initial path when initial path matches ''" , function () { + locationService.url(""); + expect(goSpy).toHaveBeenCalledWith("foo", undefined, jasmine.anything()); + }); + + it("should activate the initial path when initial path matches '/'" , function () { + locationService.url("/"); + expect(goSpy).toHaveBeenCalledWith("foo", undefined, jasmine.anything()); + }); + + it("should not activate the initial path after the initial transition" , function (done) { + stateService.go('bar').then(() => { + locationService.url("/"); + expect(goSpy).toHaveBeenCalledWith("otherwise", undefined, jasmine.anything()); + done(); + }); + }); + }); + + it('`rule` should return a deregistration function', function() { let count = 0; let rule: UrlRule = { diff --git a/test/urlServiceSpec.ts b/test/urlServiceSpec.ts index 20507a81..849984fb 100644 --- a/test/urlServiceSpec.ts +++ b/test/urlServiceSpec.ts @@ -4,7 +4,7 @@ import { TestingPlugin } from "./_testingPlugin"; import { UrlService } from "../src/url/urlService"; describe('UrlService facade', () => { - var router: UIRouter; + let router: UIRouter; beforeEach(() => { router = new UIRouter(); @@ -111,6 +111,10 @@ describe('UrlService facade', () => { expectProxyCall(() => router.urlService.rules, router.urlRouter, "otherwise", ["foo"]); }); + it("should pass rules.initial() through to UrlRouter", () => { + expectProxyCall(() => router.urlService.rules, router.urlRouter, "initial", ["foo"]); + }); + it("should pass rules.rules() through to UrlRouter", () => { expectProxyCall(() => router.urlService.rules, router.urlRouter, "rules"); }); diff --git a/tslint.json b/tslint.json index a0114468..ed1ca89e 100644 --- a/tslint.json +++ b/tslint.json @@ -28,7 +28,7 @@ "no-eval": true, "no-internal-module": true, "no-require-imports": true, - "no-string-literal": true, + "no-string-literal": false, "no-switch-case-fall-through": true, "no-trailing-whitespace": false, "no-unused-expression": [true, "allow-fast-null-checks"],