diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..6413b092 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2013-2015 The AngularUI Team, Karsten Sperling + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..48eff3ea --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# UI-Router Core  [![Build Status](https://travis-ci.org/ui-router/core.svg?branch=master)](https://travis-ci.org/ui-router/core) + +UI-Router core provides client-side [Single Page Application](https://en.wikipedia.org/wiki/Single-page_application) +routing for JavaScript. +This core is framework agnostic. +It is used to build +[UI-Router for Angular 1](//ui-router.github.io/ng1), +[UI-Router for Angular 2](//ui-router.github.io/ng2), and +[UI-Router React](//ui-router.github.io/react). + +## SPA Routing + +Routing frameworks for SPAs update the browser's URL as the user navigates through the app. Conversely, this allows +changes to the browser's URL to drive navigation through the app, thus allowing the user to create a bookmark to a +location deep within the SPA. + +UI-Router applications are modeled as a hierarchical tree of states. UI-Router provides a +[*state machine*](https://en.wikipedia.org/wiki/Finite-state_machine) to manage the transitions between those +application states in a transaction-like manner. + +## Features + +UI-Router Core provides the following features: + +- State-machine based routing + - Hierarchical states + - Enter/Exit hooks +- Name based hierarchical state addressing + - Absolute, e.g., `admin.users` + - Relative, e.g., `.users` +- Flexible Views + - Nested Views + - Multiple Named Views +- Flexible URLs and parameters + - Path, Query, and non-URL parameters + - Typed parameters + - Built in: `int`, `string`, `date`, `json` + - Custom: define your own encoding/decoding + - Optional or required parameters + - Default parameter values (optionally squashed from URL) +- Transaction-like state transitions + - Transition Lifecycle Hooks + - First class async support + +## Get Started + +Get started using one of the existing UI-Router projects: + +- [UI-Router for Angular 1](https://ui-router.github.io/ng1) +- [UI-Router for Angular 2](https://ui-router.github.io/ng2) +- [UI-Router for React](https://ui-router.github.io/react) + +## Build your own + +UI-Router core can be used implement a router for any web-based component framework. +There are four basic things to build for a specific component framework: + +### UIView + +A UIView is a component which acts as a viewport for another component, defined by a state. +When the state is activated, the UIView should render the state's component. + +### UISref (optional, but useful) + +A `UISref` is a link (absolute, or relative) which activates a specific state and/or parameters. +When the `UISref` is clicked, it should initiate a transition to the linked state. + +### UISrefActive (optional) + +When combined with a `UISref`, a `UISrefActive` toggles a CSS class on/off when its `UISref` is active/inactive. + +### Bootstrap mechanism (optional) + +Implement framework specific bootstrap requirements, if any. +For example, UI-Router for Angular 1 and Angular 2 integrates with the ng1/ng2 Dependency Injection lifecycles. +On the other hand, UI-Router for React uses a simple JavaScript based bootstrap, i.e., `new UIRouterReact().start();`. + +## Getting help + +[Create an issue](https://github.com/ui-router/core/issues) or contact us on [Gitter](https://gitter.im/angular-ui/ui-router). diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 00000000..e697edbe --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,65 @@ +// Karma configuration file +var karma = require('karma'); + +module.exports = function (karma) { + var config = { + singleRun: true, + autoWatch: false, + autoWatchInterval: 0, + + // level of logging + // possible values: LOG_DISABLE, LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG + logLevel: "warn", + // possible values: 'dots', 'progress' + reporters: 'dots', + colors: true, + + port: 8080, + + // base path, that will be used to resolve files and exclude + basePath: '.', + + // Start these browsers, currently available: + // Chrome, ChromeCanary, Firefox, Opera, Safari, PhantomJS + browsers: ['PhantomJS'], + + frameworks: ['jasmine'], + + plugins: [ + require('karma-webpack'), + require('karma-sourcemap-loader'), + require('karma-jasmine'), + require('karma-phantomjs-launcher'), + require('karma-chrome-launcher') + ], + + webpack: { + devtool: 'inline-source-map', + + resolve: { + modulesDirectories: ['node_modules'], + extensions: ['', '.js', '.ts'] + }, + + module: { + loaders: [ + { test: /\.ts$/, loader: "awesome-typescript-loader?declaration=false&tsconfig=test/tsconfig.json" } + ] + }, + + }, + + webpackMiddleware: { + stats: { chunks: false }, + }, + + files: ['test/index.js'], + + preprocessors: { + 'test/index.js': ['webpack', 'sourcemap'], + }, + + }; + + karma.set(config); +}; diff --git a/package.json b/package.json new file mode 100644 index 00000000..6ddd57c1 --- /dev/null +++ b/package.json @@ -0,0 +1,69 @@ +{ + "name": "ui-router-dsr", + "description": "UI-Router Deep State Redirect: redirect to the most recently activated child state", + "version": "1.0.0", + "scripts": { + "clean": "shx rm -rf lib lib-esm", + "build": "npm run clean && tsc && tsc -p tsconfig.esm.json", + "test": "karma start", + "watch": "run-p watch:*", + "watch:buildjs": "tsc -w", + "watch:test": "karma start --singleRun=false --autoWatch=true --autoWatchInterval=1", + "debug": "karma start --singleRun=false --autoWatch=true --autoWatchInterval=1 --browsers=Chrome" + }, + "homepage": "https://ui-router.github.io", + "contributors": [ + { + "name": "Chris Thielen", + "web": "https://github.com/christopherthielen" + } + ], + "maintainers": [ + { + "name": "UIRouter Team", + "web": "https://github.com/ui-router?tab=members" + } + ], + "repository": { + "type": "git", + "url": "https://github.com/ui-router/dsr.git" + }, + "bugs": { + "url": "https://github.com/ui-router/dsr/issues" + }, + "engines": { + "node": ">=4.0.0" + }, + "jsnext:main": "lib-esm/index.js", + "main": "lib/index.js", + "typings": "lib/index.d.ts", + "license": "MIT", + "devDependencies": { + "@types/jasmine": "^2.2.34", + "@types/jquery": "^1.10.31", + "@types/lodash": "^4.14.38", + "awesome-typescript-loader": "^2.2.4", + "conventional-changelog": "^1.1.0", + "conventional-changelog-cli": "^1.1.1", + "conventional-changelog-ui-router-core": "^1.3.0", + "core-js": "^2.4.1", + "jasmine-core": "^2.4.1", + "karma": "^1.2.0", + "karma-chrome-launcher": "~0.1.0", + "karma-coverage": "^0.5.3", + "karma-jasmine": "^1.0.2", + "karma-phantomjs-launcher": "^1.0.2", + "karma-script-launcher": "~0.1.0", + "karma-sourcemap-loader": "^0.3.7", + "karma-webpack": "^1.8.0", + "lodash": "^4.16.6", + "npm-run-all": "^3.1.1", + "readline-sync": "^1.4.4", + "shelljs": "^0.7.0", + "shx": "^0.1.4", + "tslint": "=2.5.0", + "typescript": "^2.1.1", + "ui-router-core": "^1.0.1", + "webpack": "^1.13.3" + } +} diff --git a/src/deepStateRedirect.ts b/src/deepStateRedirect.ts new file mode 100644 index 00000000..bd6ff294 --- /dev/null +++ b/src/deepStateRedirect.ts @@ -0,0 +1,136 @@ +import { + State, StateDeclaration, Param, UIRouter, RawParams, StateOrName, TargetState, Transition +} from "ui-router-core"; +export { deepStateRedirect }; + +declare module "ui-router-core" { + interface StateDeclaration { + dsr?: any; + deepStateRedirect?: any; + } +} + +function deepStateRedirect($uiRouter: UIRouter): any { + let $transitions = $uiRouter.transitionService; + let $state = $uiRouter.stateService; + + $transitions.onRetain({ retained: getDsr }, recordDeepState); + $transitions.onEnter({ entering: getDsr }, recordDeepState); + $transitions.onBefore({ to: getDsr }, deepStateRedirect); + + function getDsr(state: StateDeclaration) { + return state.deepStateRedirect || state.dsr; + } + + function getConfig(state: StateDeclaration) { + let dsrProp: any = getDsr(state); + let propType: string = typeof dsrProp; + if (propType === 'undefined') return; + + let params; + let defaultTarget = propType === 'string' ? dsrProp : undefined; + let fn: Function = propType === 'function' ? dsrProp : undefined; + + if (propType === 'object') { + fn = dsrProp.fn; + let defaultType = typeof dsrProp.default; + if (defaultType === 'object') { + defaultTarget = $state.target(dsrProp.default.state, dsrProp.default.params, dsrProp.default.options); + } else if (defaultType === 'string') { + defaultTarget = $state.target(dsrProp.default); + } + if (dsrProp.params === true) { + params = function () { + return true; + } + } else if (Array.isArray(dsrProp.params)) { + params = function (param: Param) { + return dsrProp.params.indexOf(param.id) !== -1; + } + } + } + + fn = fn || ((transition, target) => target); + + return { params: params, default: defaultTarget, fn: fn }; + } + + function paramsEqual(state: State, transParams: RawParams, schemaMatchFn?: (param?: Param) => boolean, negate = false) { + schemaMatchFn = schemaMatchFn || (() => true); + let schema = state.parameters({ inherit: true }).filter(schemaMatchFn); + return function (redirect) { + let equals = Param.equals(schema, redirect.triggerParams, transParams); + return negate ? !equals : equals; + } + } + + function recordDeepState(transition, state) { + let paramsConfig = getConfig(state).params; + + transition.promise.then(function () { + let transTo = transition.to(); + let transParams = transition.params(); + let recordedDsrTarget = $state.target(transTo, transParams); + + if (paramsConfig) { + state.$dsr = (state.$dsr || []).filter(paramsEqual(transTo.$$state(), transParams, undefined, true)); + state.$dsr.push({ triggerParams: transParams, target: recordedDsrTarget }); + } else { + state.$dsr = recordedDsrTarget; + } + }); + } + + function deepStateRedirect(transition: Transition) { + let opts = transition.options(); + if (opts['ignoreDsr'] || (opts.custom && opts.custom.ignoreDsr)) return; + + let config = getConfig(transition.to()); + let redirect = getDeepStateRedirect(transition.to(), transition.params()); + redirect = config.fn(transition, redirect); + if (redirect && redirect.state() === transition.to()) return; + + return redirect + } + + function getDeepStateRedirect(stateOrName: StateOrName, params: RawParams) { + let state = $state.get(stateOrName); + let dsrTarget, config = getConfig(state); + let $$state = state.$$state(); + + if (config.params) { + var predicate = paramsEqual($$state, params, config.params, false); + let match = $$state['$dsr'] && $$state['$dsr'].filter(predicate)[0]; + dsrTarget = match && match.target; + } else { + dsrTarget = $$state['$dsr']; + } + + dsrTarget = dsrTarget || config.default; + + if (dsrTarget) { + // merge original params with deep state redirect params + let targetParams = Object.assign({}, params, dsrTarget.params()); + dsrTarget = $state.target(dsrTarget.state(), targetParams, dsrTarget.options()); + } + + return dsrTarget; + } + + return { + reset: function(state: StateOrName, params?: RawParams) { + if (!state) { + $state.get().forEach(state => delete state.$$state()['$dsr']); + } else if (!params) { + delete $state.get(state).$$state()['$dsr'] + } else { + var $$state = $state.get(state).$$state(); + $$state['$dsr'] = $$state['$dsr'].filter(paramsEqual($$state, params, null, true)); + } + }, + + getRedirect: function (state: StateOrName, params?: RawParams) { + return getDeepStateRedirect(state, params); + } + } +} diff --git a/test/deepStateRedirectSpec.ts b/test/deepStateRedirectSpec.ts new file mode 100644 index 00000000..d939ad8d --- /dev/null +++ b/test/deepStateRedirectSpec.ts @@ -0,0 +1,330 @@ +import { getTestGoFn, addCallbacks, resetTransitionLog, pathFrom } from "./util" +import { UIRouter, StateService } from "ui-router-core"; +import { deepStateRedirect } from "../src/deepStateRedirect" + +const equalityTester = (first, second) => + Object.keys(second).reduce((acc, key) => + first[key] == second[key] && acc, true); + +function getDSRStates() { + // This function effectively returns the default DSR state at runtime + function p7DSRFunction(transition, pendingRedirect) { + // allow standard DSR behavior by returning pendingRedirect $dsr$.redirect has a state set + if (pendingRedirect && pendingRedirect.state()) return pendingRedirect; + + // Otherwise, return a redirect object {state: "foo", params: {} } + let redirectState = (transition.params().param == 2) ? "p7.child2" : "p7.child1"; + return transition.router.stateService.target(redirectState); + } + + return [ + { name: 'other' }, + { name: 'tabs' }, + { name: 'tabs.tabs1', deepStateRedirect: true }, + { name: 'tabs.tabs1.deep' }, + { name: 'tabs.tabs1.deep.nest' }, + { name: 'tabs.tabs2', deepStateRedirect: true }, + { name: 'tabs.tabs2.deep' }, + { name: 'tabs.tabs2.deep.nest' }, + { name: 'p1', url: '/p1/:param1/:param2', deepStateRedirect: { params: ['param1'] }, params: { param1: null, param2: null } }, + { name: 'p1.child' }, + { name: 'p2', url: '/p2/:param1/:param2', deepStateRedirect: { params: true } }, + { name: 'p2.child' }, + { name: 'p3', url: '/p3/:param1', deepStateRedirect: { params: true } }, + { name: 'p3.child' }, + { name: 'p4', url: '/p4', dsr: { default: "p4.child" } }, + { name: 'p4.child' }, + { name: 'p4.child2' }, + { name: 'p5', url: '/p5', dsr: { default: { state: "p5.child", params: { p5param: "1" } } } }, + { name: 'p5.child', url: '/child/:p5param' }, + { name: 'p6', url: '/p6/:param', dsr: { params: true, default: "p6.child1" }, params: { param: null } }, + { name: 'p6.child1' }, + { name: 'p6.child2' }, + { name: 'p6.child3' }, + { name: 'p7', url: '/p7/:param', dsr: { default: {}, fn: p7DSRFunction }, params: { param: null } }, + { name: 'p7.child1' }, + { name: 'p7.child2' }, + { name: 'p8', dsr: true }, + { name: 'p8child1', parent: 'p8' }, + { name: 'p8child2', parent: 'p8' } + ]; +} + +function dsrReset(newStates) { + addCallbacks(newStates); + resetTransitionLog(); +} + +let router: UIRouter = undefined; +let $state: StateService = undefined; +let $deepStateRedirect = undefined; +let testGo = undefined; + +describe('deepStateRedirect', function () { + beforeEach(async function (done) { + jasmine.addCustomEqualityTester(equalityTester); + jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000; + + router = new UIRouter(); + router.urlRouterProvider.otherwise('/'); + $state = router.stateService; + $deepStateRedirect = deepStateRedirect(router); + router.stateRegistry.stateQueue.autoFlush($state); + + testGo = getTestGoFn(router); + + let newStates = getDSRStates(); + dsrReset(newStates); + newStates.forEach(state => router.stateRegistry.register(state)); + + done(); + }); + + describe(' - ', function () { + + it("should toggle between tab states", async function (done) { + await testGo("tabs", { entered: 'tabs' }); + await testGo("tabs.tabs2", { entered: 'tabs.tabs2' }); + await testGo("tabs.tabs1", { entered: 'tabs.tabs1', exited: 'tabs.tabs2' }); + + done(); + }); + + it("should redirect to tabs.tabs1.deep.nest", async function (done) { + await testGo("tabs", { entered: 'tabs' }); + await testGo("tabs.tabs2.deep.nest", { entered: ['tabs.tabs2', 'tabs.tabs2.deep', 'tabs.tabs2.deep.nest'] }); + await testGo("tabs.tabs1", { + entered: 'tabs.tabs1', + exited: ['tabs.tabs2.deep.nest', 'tabs.tabs2.deep', 'tabs.tabs2'] + }); + await testGo("tabs.tabs2", { + entered: ['tabs.tabs2', 'tabs.tabs2.deep', 'tabs.tabs2.deep.nest'], + exited: 'tabs.tabs1' + }, { redirect: 'tabs.tabs2.deep.nest' }); + + done(); + }); + + it("should forget a previous redirect to tabs.tabs2.deep.nest", async function (done) { + await testGo("tabs", { entered: 'tabs' }); + await testGo("tabs.tabs2.deep.nest", { entered: ['tabs.tabs2', 'tabs.tabs2.deep', 'tabs.tabs2.deep.nest'] }); + await testGo("tabs.tabs1.deep.nest", { + entered: ['tabs.tabs1', 'tabs.tabs1.deep', 'tabs.tabs1.deep.nest'], + exited: ['tabs.tabs2.deep.nest', 'tabs.tabs2.deep', 'tabs.tabs2'] + }); + await testGo("tabs.tabs2", { + entered: ['tabs.tabs2', 'tabs.tabs2.deep', 'tabs.tabs2.deep.nest'], + exited: ['tabs.tabs1.deep.nest', 'tabs.tabs1.deep', 'tabs.tabs1'] + }, { redirect: 'tabs.tabs2.deep.nest' }); + await testGo("tabs.tabs1", { + entered: ['tabs.tabs1', 'tabs.tabs1.deep', 'tabs.tabs1.deep.nest'], + exited: ['tabs.tabs2.deep.nest', 'tabs.tabs2.deep', 'tabs.tabs2'] + }, { redirect: 'tabs.tabs1.deep.nest' }); + + $deepStateRedirect.reset("tabs.tabs2"); + + await testGo("tabs.tabs2", { + entered: ['tabs.tabs2'], + exited: ['tabs.tabs1.deep.nest', 'tabs.tabs1.deep', 'tabs.tabs1'] + }); + await testGo("tabs.tabs1", { + entered: ['tabs.tabs1', 'tabs.tabs1.deep', 'tabs.tabs1.deep.nest'], + exited: ['tabs.tabs2'] + }, { redirect: 'tabs.tabs1.deep.nest' }); + + $deepStateRedirect.reset(); + + await testGo("tabs.tabs2", { + entered: 'tabs.tabs2', + exited: ['tabs.tabs1.deep.nest', 'tabs.tabs1.deep', 'tabs.tabs1'] + }); + await testGo("tabs.tabs1", { entered: 'tabs.tabs1', exited: ['tabs.tabs2'] }); + + done(); + }); + }); + + describe("with child substates configured using {parent: parentState}", function () { + it("should remember and redirect to the last deepest state", async function (done) { + await testGo("p8child1"); + await testGo("other"); + await testGo("p8", undefined, { redirect: 'p8child1' }); + + done(); + }); + }); + + describe('with configured params', function () { + it("should redirect only when params match", async function (done) { + await $state.go("p1", { param1: "foo", param2: "foo2" }); + expect($state.current.name).toEqual("p1"); + expect($state.params).toEqual({ "#": null, param1: "foo", param2: "foo2" }); + + await $state.go(".child"); + expect($state.current.name).toEqual("p1.child"); + + await $state.go("p1", { param1: "bar" }); + expect($state.current.name).toEqual("p1"); + + await $state.go("p1", { param1: "foo", param2: "somethingelse" }); + expect($state.current.name).toEqual("p1.child"); // DSR + + done(); + }); + + // Test for issue #184 getRedirect() + it("should be returned from getRedirect() for matching DSR params", async function (done) { + await $state.go("p1", { param1: "foo", param2: "foo2" }); + await $state.go(".child"); + + expect($deepStateRedirect.getRedirect("p1", { param1: "foo" }).state().name).toBe("p1.child"); + expect($deepStateRedirect.getRedirect("p1", { param1: "bar" })).toBeUndefined(); + + done(); + }); + + it("should not redirect if a param is resetted", async function (done) { + await $state.go("p3", { param1: "foo" }); + await $state.go(".child"); + await $state.go("p3", { param1: "bar" }); + await $state.go(".child"); + + $deepStateRedirect.reset("p3", { param1: 'foo' }); + + await $state.go("p3", { param1: "foo" }); + expect($state.current.name).toEqual("p3"); // DSR + + await $state.go("p3", { param1: "bar" }); + expect($state.current.name).toEqual("p3.child"); // DSR + + done(); + }); + + it("should redirect only when all params match if 'params: true'", async function (done) { + await $state.go("p2", { param1: "foo", param2: "foo2" }); + + expect($state.current.name).toEqual("p2"); + expect($state.params).toEqual({ param1: "foo", param2: "foo2" }); + + await $state.go(".child"); + expect($state.current.name).toEqual("p2.child"); + + await $state.go("p2", { param1: "bar" }); + expect($state.current.name).toEqual("p2"); + + await $state.go("p2", { param1: "foo", param2: "somethingelse" }); + expect($state.current.name).toEqual("p2"); + + await $state.go("p2", { param1: "foo", param2: "foo2" }); + expect($state.current.name).toEqual("p2.child"); // DSR + + done(); + }); + }); + + describe('ignoreDsr option', function () { + it("should not redirect to tabs.tabs2.deep.nest when options are: { ignoreDsr: true }", async function (done) { + await testGo("tabs", { entered: 'tabs' }); + await testGo("tabs.tabs2.deep.nest", { entered: pathFrom('tabs.tabs2', 'tabs.tabs2.deep.nest') }); + await testGo("tabs.tabs1.deep.nest", { + entered: pathFrom('tabs.tabs1', 'tabs.tabs1.deep.nest'), + exited: pathFrom('tabs.tabs2.deep.nest', 'tabs.tabs2') + }); + await $state.go("tabs.tabs2", {}, { custom: { ignoreDsr: true } }); + + expect($state.current.name).toBe("tabs.tabs2"); + + done(); + }); + + it("should redirect to tabs.tabs2.deep.nest after a previous ignoreDsr transition", async function (done) { + await testGo("tabs", { entered: 'tabs' }); + await testGo("tabs.tabs2.deep.nest", { entered: pathFrom('tabs.tabs2', 'tabs.tabs2.deep.nest') }); + await testGo("tabs.tabs1.deep.nest", { + entered: pathFrom('tabs.tabs1', 'tabs.tabs1.deep.nest'), + exited: pathFrom('tabs.tabs2.deep.nest', 'tabs.tabs2') + }); + + await $state.go("tabs.tabs2", {}, { custom: { ignoreDsr: true } }); + + expect($state.current.name).toBe("tabs.tabs2"); + + resetTransitionLog(); + await testGo("tabs.tabs1", { + exited: 'tabs.tabs2', + entered: pathFrom('tabs.tabs1', 'tabs.tabs1.deep.nest') + }, { redirect: 'tabs.tabs1.deep.nest' }); + + done(); + }); + + it("should remember the DSR state itself when transitioned to using ignoreDsr ", async function (done) { + await testGo("tabs.tabs1.deep", { entered: pathFrom('tabs', 'tabs.tabs1.deep') }); + await testGo("tabs.tabs2", { entered: 'tabs.tabs2', exited: pathFrom('tabs.tabs1.deep', 'tabs.tabs1') }); + + await $state.go("tabs.tabs1", {}, { custom: { ignoreDsr: true } }); + + expect($state.current.name).toBe("tabs.tabs1"); + await $state.go("tabs.tabs2", {}, {}); + + expect($state.current.name).toBe("tabs.tabs2"); + await $state.go("tabs.tabs1", {}, {}); + + expect($state.current.name).toBe("tabs.tabs1"); + + done(); + }); + }); + + describe("default substates", function () { + // Test for issue #184 getRedirect() + it("should be returned by getRedirect", function () { + expect($deepStateRedirect.getRedirect("p4").state().name).toBe("p4.child"); + }); + + it("should affect the first transition to the DSR state", async function (done) { + await testGo("p4", undefined, { redirect: 'p4.child' }); + await testGo("p4.child2"); + await testGo("p4", undefined, { redirect: 'p4.child2' }); + + done() + }); + + it("should provide default parameters", async function (done) { + await testGo("p5", undefined, { redirect: 'p5.child' }); + expect($state.params).toEqual({ p5param: "1" }); + + done(); + }); + + it("should redirect to the default state when params: true and transition to DSR with un-seen param values", async function (done) { + await testGo("p6", undefined, { params: { param: "1" }, redirect: 'p6.child1' }); + await testGo("p6.child2"); + await testGo("p6", undefined, { params: { param: "1" }, redirect: 'p6.child2' }); + // await testGo("p6", undefined, { params: { param: "2" }, redirect: 'p6.child1' }); + + done(); + }); + + describe("in conjunction with a dsr fn", function () { + it("should still invoke the dsr fn and use the result", async function (done) { + // This effectively allows a function to determine DSR default + await testGo("p7", undefined, { params: { param: "2" }, redirect: 'p7.child2' }); + await testGo("p7.child1"); + await testGo("p7", undefined, { params: { param: "2" }, redirect: 'p7.child1' }); + + done(); + }); + + it("should still invoke the dsr fn and use the result", async function (done) { + // This effectively allows the default DSR to be determined by a fn + await testGo("p7", undefined, { redirect: 'p7.child1' }); + await testGo("p1"); + await testGo("p7", undefined, { params: { param: "2" }, redirect: 'p7.child1' }); + + done(); + }); + + }) + }) +}); diff --git a/test/index.js b/test/index.js new file mode 100644 index 00000000..3e813e66 --- /dev/null +++ b/test/index.js @@ -0,0 +1,10 @@ +// require all source files ending in "Spec" from the +// current directory and all subdirectories + +require('core-js'); +require('ui-router-core'); +require('ui-router-core/lib/justjs'); +require('../src/deepStateRedirect'); + +var testsContext = require.context(".", true, /Spec$/); +testsContext.keys().forEach(testsContext); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..b1da014d --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "module": "commonjs", + "target": "es5", + "lib": [ "es6", "dom" ], + "allowSyntheticDefaultImports": true, + "outDir": "../.lib-test", + "declaration": true, + "sourceMap": true + }, + "exclude": ["node_modules", "lib", "lib-esm"] +} diff --git a/test/util.ts b/test/util.ts new file mode 100644 index 00000000..c5afa8b1 --- /dev/null +++ b/test/util.ts @@ -0,0 +1,150 @@ +var tLog, tExpected; +import * as _ from "lodash"; + +var TransitionAudit = function () { + this.entered = []; + this.exited = []; + this.reactivated = []; + this.inactivated = []; + this.views = []; + + // this.toString = angular.bind(this, + // function toString() { + // var copy = {}; + // angular.forEach(this, function(value, key) { + // if (key === 'inactivated' || key === 'reactivated' || + // key === 'entered' || key === 'exited') { + // copy[key] = value; + // } + // }); + // return angular.toJson(copy); + // } + // ); +}; + +// Add callbacks to each +export function addCallbacks (basicStates) { + basicStates.forEach(function (state) { + function deregisterView(state, cause) { + var views = _.keys(state.$$state().views); + tLog.views = _.difference(tLog.views, views); +// console.log(cause + ":Deregistered Inactive view " + views + " for state " + state.name + ": ", tLog.views); + } + function registerView(state, cause) { + var views = _.keys(state.$$state().views); + tLog.views = _.union(tLog.views, views); +// console.log(cause + ": Registered Inactive view " + views + " for state " + state.name + ": ", tLog.views); + } + + state.onInactivate = function () { + tLog.inactivated.push(state.name); registerView(state, 'Inactivate'); + }; + state.onReactivate = function () { + tLog.reactivated.push(state.name); deregisterView(state,'Reactivate'); + }; + state.onEnter = function () { + tLog.entered.push(state.name); deregisterView(state,'Enter '); + }; + state.onExit = function () { + tLog.exited.push(state.name); deregisterView(state,'Exit '); + }; + }); +} + +export function pathFrom(start, end) { + var startNodes = start.split("."); + var endNodes = end.split("."); + var reverse = startNodes.length > endNodes.length; + if (reverse) { + var tmp = startNodes; + startNodes = endNodes; + endNodes = tmp; + } + + var common = _.intersection(endNodes, startNodes); + var difference = _.difference(endNodes, startNodes); + difference.splice(0, 0, common.pop()); + + var name = common.join("."); + var path = _.map(difference, function(segment) { + name = (name ? name + "." : "") + segment; + return name; + }); + if (reverse) path.reverse(); + return path; +} + +export function getTestGoFn($uiRouter) { + var $state = $uiRouter.stateService; + +/** + * This test function does the following: + * - Go to a state `state`. + * - Flush transition + * - Expect the current state to be the target state, or the expected redirect state + * - analyse the transition log and expect + * - The entered states to match tAdditional.entered + * - The exited states to match tAdditional.exited + * - The inactivated states to match tAdditional.inactivated + * - The reactivated states to match tAdditional.reactivated + * - Expect the active+inactive states to match the active+inactive views + * + * @param state: The target state + * @param tAdditional: An object with the expected transitions + * { + * entered: statename or [ statenamearray ], + * exited: statename or [ statenamearray ], + * inactivated: statename or [ statenamearray ], + * reactivated: statename or [ statenamearray ] + * } + * note: statenamearray may be built using the pathFrom helper function + * @param options: options which modify the expected transition behavior + * { redirect: redirectstatename } + */ +async function testGo(state, tAdditional, options) { + await $state.go(state, options && options.params, options); + + var expectRedirect = options && options.redirect; + if (!expectRedirect) + expect($state.current.name).toBe(state); + else + expect($state.current.name).toBe(expectRedirect); + + // var root = $state.$current.path[0].parent; + // var __inactives = root.parent; + + // If ct.ui.router.extras.sticky module is included, then root.parent holds the inactive states/views + // if (__inactives) { + // var __inactiveViews = _.keys(__inactives.locals); + // var extra = _.difference(__inactiveViews, tLog.views); + // var missing = _.difference(tLog.views, __inactiveViews); + // + // expect("Extra Views: " + extra).toEqual("Extra Views: " + []); + // expect("Missing Views: " + missing).toEqual("Missing Views: " + []); + // } + + if (tExpected && tAdditional) { + // append all arrays in tAdditional to arrays in tExpected + Object.keys(tAdditional).forEach(key => tExpected[key] = tExpected[key].concat(tAdditional[key])); + // angular.forEach(tAdditional, function (value, key) { + // tExpected[key] = tExpected[key].concat(tAdditional[key]); + // }); + + Object.keys(tLog).filter(x => x !== 'views').forEach(key => { + // angular.forEach(_.without(_.keys(tLog), 'views'), function(key) { + var left = key + ": " + JSON.stringify(tLog[key]); + var right = key + ": " + JSON.stringify(tExpected[key]); + expect(left).toBe(right); + }); + } + + return Promise.resolve(); +} + +return testGo; +} + +export function resetTransitionLog() { + tLog = new TransitionAudit(); + tExpected = new TransitionAudit(); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..4d39cac7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "module": "commonjs", + "target": "es5", + "lib": [ "es6", "dom" ], + "allowSyntheticDefaultImports": true, + "outDir": "lib", + "declaration": true, + "sourceMap": true + }, + "files": ["src/deepStateRedirect.ts"] +}