├── .editorconfig ├── .gitattributes ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── Gruntfile.js ├── LICENSE ├── README.md ├── codecov.yml ├── deploy_key.enc ├── docs └── api.json ├── examples ├── ambigious-matches.ts ├── basic.ts ├── index.html ├── main.ts └── url-parameters.ts ├── intern.json ├── package-lock.json ├── package.json ├── src ├── Link.ts ├── Outlet.ts ├── Router.ts ├── RouterInjector.ts ├── history │ ├── HashHistory.ts │ ├── MemoryHistory.ts │ └── StateHistory.ts └── interfaces.d.ts ├── tests ├── functional │ └── all.ts ├── run.html ├── support │ └── sandbox.html └── unit │ ├── Link.ts │ ├── Outlet.ts │ ├── Router.ts │ ├── RouterInjector.ts │ ├── all.ts │ └── history │ ├── HashHistory.ts │ ├── MemoryHistory.ts │ ├── StateHistory.ts │ └── all.ts ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [{package.json,.travis.yml}] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case users don't have core.autocrlf set 2 | * text=auto 3 | 4 | # Files that should always be normalized and converted to native line 5 | # endings on checkout. 6 | *.js text 7 | *.json text 8 | *.ts text 9 | *.md text 10 | *.yml text 11 | LICENSE text 12 | 13 | # Files that are truly binary and should not be modified 14 | *.png binary 15 | *.jpg binary 16 | *.jpeg binary 17 | *.gif binary 18 | *.jar binary 19 | *.zip binary 20 | *.psd binary 21 | *.enc binary 22 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Thank You 2 | 3 | We very much welcome contributions to Dojo 2. 4 | 5 | Because we have so many repositories that are part of Dojo 2, we have located our [Contributing Guidelines](https://github.com/dojo/meta/blob/master/CONTRIBUTING.md) in our [Dojo 2 Meta Repository](https://github.com/dojo/meta#readme). 6 | 7 | Look forward to working with you on Dojo 2!!! 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 11 | 12 | **Bug / Enhancement** 13 | 14 | 15 | 16 | Package Version: 17 | 18 | **Code** 19 | 20 | 21 | 22 | **Expected behavior:** 23 | 24 | 25 | 26 | **Actual behavior:** 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Type:** bug / feature 2 | 3 | The following has been addressed in the PR: 4 | 5 | * [ ] There is a related issue 6 | * [ ] All code has been formatted with [`prettier`](https://prettier.io/) as per the [readme code style guidelines](./../#code-style) 7 | * [ ] Unit or Functional tests are included in the PR 8 | 9 | 17 | 18 | **Description:** 19 | 20 | Resolves #??? 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /bower_components 3 | /dist 4 | /html-report 5 | /node_modules 6 | /typings 7 | .baseDir.ts 8 | .tscache 9 | coverage-unmapped.json 10 | coverage-final.json 11 | coverage-final.lcov 12 | npm-debug.log 13 | /_apidoc 14 | deploy_key 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - '6' 5 | env: 6 | global: 7 | - SAUCE_USERNAME: dojo2-ts-ci 8 | - SAUCE_ACCESS_KEY: e92610e3-834e-4bec-a3b5-6f7b9d874601 9 | - BROWSERSTACK_USERNAME: dylanschiemann2 10 | - BROWSERSTACK_ACCESS_KEY: 4Q2g8YAc9qeZzB2hECnS 11 | before_install: 12 | - if [ ${TRAVIS_BRANCH-""} == "master" ] && [ -n ${encrypted_12c8071d2874_key-""} 13 | ]; then openssl aes-256-cbc -K $encrypted_12c8071d2874_key -iv $encrypted_12c8071d2874_iv 14 | -in deploy_key.enc -out deploy_key -d; fi 15 | install: 16 | - travis_retry npm install grunt-cli 17 | - travis_retry npm install 18 | script: 19 | - grunt 20 | - grunt intern:browserstack --test-reporter 21 | - grunt uploadCoverage 22 | - grunt doc 23 | notifications: 24 | slack: 25 | secure: SBkRu9JVLHLLGrak1VO14yI6sVQ6JZOV8ZMkzNRI+uhcAh9190q2n+1sgaEsknlMNW9ljp/clLpm6Gvw5uTxnqGslHtL4Pld/LKBax/f60C24cbrR+1iKpqoxPfCUAQtcM5UKqnG8MO89fFV4ERoBGEK4ytKQV80/cJbxe0zpOmrUbMhBKOK3T5UpULhyFEx9z1UbavKv/jcoAp70SucBZxcjUMTUJdI6YgUttjCtWdyvQ2tZwhBm9n9oJHjExN/XVXvfqFlEtFt1Uavmzg0JB5jvDZy4fCBKVYLAPbYZX6Nm7rBaPoIpw/VTeW0IZbUYu7K0jF6Rtb+YSA7nwG3YjuZjZ+X/fYT+5ZzHsG0dS50KYEWq+Elxwng9gR6XTw7xHg+wq7rit+H/1MJO3JXlZI8ugaTDDBWtyDQqz+fMNrLdyp21E4UiPCMkIBMgOf+ykHPpcTTsw/AVmB6cbgoAMrhzxetaR9fz3A1K62JG8QI7XvNpfNNb25tpNf7EYXEMqOxGr3M5S+cu+wcrYHL0i82swdmg0P2T/c6QYesg8u4Uwtrtgq2CcaUj5Xwl2vL4N5TsRF1RrCXYefRnF4LyBFjALaCss/EOhAxtVSbBq6HZIgWn0dsWd2adPPT/iWYgyPGctBOVLE4sV8JpeQROlDCdtFizK4iVWdSHFuoFPk= 26 | on_success: change 27 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | const staticExampleFiles = [ 'examples/index.html' ]; 3 | 4 | require('grunt-dojo2').initConfig(grunt, { 5 | copy: { 6 | staticExampleFiles: { 7 | expand: true, 8 | cwd: '.', 9 | src: staticExampleFiles, 10 | dest: '<%= devDirectory %>' 11 | } 12 | }, 13 | ts: { 14 | dist: { 15 | exclude: ['tests/**/*.ts', 'examples/**/*.ts'] 16 | } 17 | }, 18 | typedoc: { 19 | options: { 20 | ignoreCompilerErrors: true // Remove this once compile errors are resolved 21 | } 22 | } 23 | }); 24 | 25 | grunt.registerTask('dev', grunt.config.get('devTasks').concat([ 26 | 'copy:staticExampleFiles' 27 | ])); 28 | }; 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The "New" BSD License 2 | ********************* 3 | 4 | Copyright (c) 2015 - 2017, [JS Foundation](https://js.foundation/) 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | * Neither the name of the JS Foundation nor the names of its contributors 16 | may be used to endorse or promote products derived from this software 17 | without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## The `@dojo/routing` repository has been deprecated and merged into [`@dojo/framework`](https://github.com/dojo/framework) 2 | 3 | You can read more about this change on our [blog](https://dojo.io/blog/). We will continue providing patches for `routing` and other Dojo 2 repositories, and a [CLI migration tool](https://github.com/dojo/cli-upgrade) is available to aid in migrating projects from v2 to v3. 4 | 5 | *** 6 | 7 | # @dojo/routing 8 | 9 | 10 | 11 | [![Build Status](https://travis-ci.org/dojo/routing.svg?branch=master)](https://travis-ci.org/dojo/routing) 12 | [![codecov.io](https://codecov.io/github/dojo/routing/coverage.svg?branch=master)](https://codecov.io/github/dojo/routing?branch=master) 13 | [![npm version](https://badge.fury.io/js/%40dojo%2Frouting.svg)](https://badge.fury.io/js/%40dojo%2Frouting) 14 | 15 | A routing library for Dojo 2 applications. 16 | 17 | - [Features](#features) 18 | - [Route Configuration](#route-configuration) 19 | - [Router](#router) 20 | - [History Managers](#history-managers) 21 | - [Router Context Injection](#router-context-injection) 22 | - [Outlets](#outlets) 23 | - [Outlet Component Types](#outlet-component-types) 24 | - [Outlet Options](#outlet-options) 25 | - [Global Error Outlet](#global-error-outlet) 26 | - [Link](#link) 27 | 28 | 29 | 30 | ## Usage 31 | 32 | To use `@dojo/routing`, install the package along with its required peer dependencies: 33 | 34 | ```bash 35 | npm install @dojo/routing 36 | 37 | # peer dependencies 38 | npm install @dojo/core 39 | npm install @dojo/has 40 | npm install @dojo/shim 41 | npm install @dojo/widget-core 42 | ``` 43 | 44 | ## Features 45 | 46 | Widgets are a fundamental concept for any Dojo 2 application and as such Dojo 2 Routing provides a collection of components that integrate directly with existing widgets within an application. These components enable widgets to be registered against a route _without_ requiring any knowledge of the `Router`. Routing in a Dojo 2 application consists of: 47 | 48 | - `Outlet` widget wrappers that are assigned a specific outlet key and represent the view for a specific route 49 | - a configuration of individual `Route`s that map paths to outlet keys 50 | - a `Router` that resolves a `Route` based on the current path 51 | - a `History` provider that notifies the `Router` of path changes 52 | - a `Registry` that injects the `Router` into the widget ecosystem 53 | 54 | ### Route Configuration 55 | 56 | Application routes are registered using a `RouteConfig`, which defines a route's `path`, the associated `outlet`, and nested child `RouteConfig`s. The full routes are recursively constructed from the nested route structure. 57 | 58 | Example routing configuration: 59 | 60 | ```ts 61 | import { RouteConfig } from '@dojo/routing/interfaces'; 62 | 63 | const config: RouteConfig[] = [ 64 | { 65 | path: 'foo', 66 | outlet: 'root', 67 | children: [ 68 | { 69 | path: 'bar', 70 | outlet: 'bar' 71 | }, 72 | { 73 | path: 'baz', 74 | outlet: 'baz', 75 | children: [ 76 | { 77 | path: 'qux', 78 | outlet: 'qux', 79 | } 80 | ] 81 | } 82 | ] 83 | } 84 | ] 85 | ``` 86 | 87 | This configuration would register the following routes and outlets: 88 | 89 | | Route | Outlet | 90 | | ------------ | ------ | 91 | |`/foo` | `root` | 92 | |`/foo/bar` | `bar` | 93 | |`/foo/baz` | `baz` | 94 | |`/foo/baz/qux`| `qux` | 95 | 96 | #### Path Parameters 97 | 98 | Path parameters can be defined in a `path` using curly braces in the path attribute of a `RouteConfig`. Parameters will match any segment and the value of that segment is made available to matching outlets via the [mapParams](#mapParams) `Outlet` options. The parameters provided to child outlets will include any parameters from matching parent routes. 99 | 100 | ```ts 101 | const config = [ 102 | { 103 | path: 'foo/{foo}', 104 | outlet: 'foo' 105 | } 106 | ] 107 | ``` 108 | 109 | For routes with path parameters, a map of default params can be specified for each route. These parameters are used as a fallback when generating a link from an outlet without specifying parameters, or when parameters do not exist in the current route. 110 | 111 | ```ts 112 | const config = [ 113 | { 114 | path: 'foo/{foo}', 115 | outlet: 'foo', 116 | defaultParams: { 117 | foo: 'bar' 118 | } 119 | } 120 | ] 121 | ``` 122 | 123 | A default route can be specified using the optional configuration property `defaultRoute`, which will be used if the current route does not match a registered route. 124 | 125 | ```ts 126 | const config = [ 127 | { 128 | path: 'foo/{foo}', 129 | outlet: 'foo', 130 | defaultRoute: true 131 | } 132 | ] 133 | ``` 134 | 135 | Callbacks for `onEnter` and `onExit` can be set on the route configuration, these callbacks get called when an outlet is entered and exited. 136 | 137 | ```ts 138 | const config = [ 139 | { 140 | path: 'foo/{foo}', 141 | outlet: 'foo', 142 | onEnter: () => { 143 | console.log('outlet foo entered'); 144 | }, 145 | onExit: () => { 146 | console.log('outlet foo exited'); 147 | } 148 | } 149 | ] 150 | ``` 151 | 152 | ### Router 153 | 154 | A `Router` registers a [route configuration](#route-configuration) which is passed to the router on construction: 155 | 156 | ```ts 157 | const router = new Router(config); 158 | ``` 159 | 160 | The router will automatically be registered with a `HashHistory` history manager. This can be overridden by passing a different history manager as the second parameter. 161 | 162 | ```ts 163 | import { MemoryHistory } from '@dojo/routing/MemoryHistory'; 164 | 165 | const router = new Router(config, MemoryHistory); 166 | ``` 167 | 168 | Once the router has been created with the application route configuration, it needs to be made available to all the components within your application. This is done using a `Registry` from `@dojo/widget-core/Registry` and defining an `Injector` that contains the `router` instance as the `payload`. This `Injector` is defined using a known key, by default the key is `router` but this can be overridden if desired. 169 | 170 | ```ts 171 | import { Registry } from '@dojo/widget-core/Registry'; 172 | import { Injector } from '@dojo/widget-core/Injector'; 173 | 174 | const registry = new Registry(); 175 | 176 | // Assuming we have the router instance available 177 | registry.defineInjector('router', new Injector(router)); 178 | ``` 179 | 180 | Finally, the `registry` needs to be made available to all widgets within the application by setting it as a `property` to the application's top-level `Projector` instance. 181 | 182 | ```ts 183 | const projector = new Projector(); 184 | projector.setProperties({ registry }); 185 | ``` 186 | 187 | #### History Managers 188 | 189 | Routing comes with three history managers for monitoring and changing the navigation state, `HashHistory`, `StateHistory` and `MemoryHistory`. By default the `HashHistory` is used, however, this can be overridden by passing a different `HistoryManager` when creating the `Router`. 190 | 191 | ```ts 192 | const router = new Router(config, MemoryHistory); 193 | ``` 194 | 195 | ##### Hash History 196 | 197 | The hash-based manager uses the fragment identifier to store navigation state and is the default manager used within `@dojo/routing`. 198 | 199 | ```ts 200 | import { Router } from '@dojo/routing/Router'; 201 | import { HashHistory } from '@dojo/routing/history/HashHistory'; 202 | 203 | const router = new Router(config, HashHistory); 204 | ``` 205 | 206 | The history manager has `current` getter, `set(path: string)` and `prefix(path: string)` APIs. The `HashHistory` class assumes the global object is a browser `window` object, but an explicit object can be provided. The manager uses `window.location.hash` and adds an event listener for the `hashchange` event. The `current` getter returns the current path, without a # prefix. 207 | 208 | ##### State History 209 | 210 | The state history uses the browser's history API, `pushState()` and `replaceState()`, to add or modify history entries. The state history manager requires server-side support to work effectively. 211 | 212 | ##### Memory History 213 | 214 | The `MemoryHistory` does not rely on any browser API but keeps its own internal path state. It should not be used in production applications but is useful for testing routing. 215 | 216 | ```ts 217 | import { Router } from '@dojo/routing/Router'; 218 | import { MemoryHistory } from '@dojo/routing/history/MemoryHistory'; 219 | 220 | const router = new Router(config, MemoryHistory); 221 | ``` 222 | 223 | ### Router Context Injection 224 | 225 | The `RouterInjector` module exports a helper function, `registerRouterInjector`, that combines the instantiation of a `Router` instance, registering route configuration and defining injector in the provided registry. The `router` instance is returned. 226 | 227 | ```ts 228 | import { Registry } from '@dojo/widget-core/Registry'; 229 | import { registerRouterInjector } from '@dojo/routing/RoutingInjector'; 230 | 231 | const registry = new Registry(); 232 | const router = registerRouterInjector(config, registry); 233 | ``` 234 | 235 | The defaults can be overridden using `RouterInjectorOptions`: 236 | 237 | ```ts 238 | import { Registry } from '@dojo/widget-core/Registry'; 239 | import { registerRouterInjector } from '@dojo/routing/RoutingInjector'; 240 | import { MemoryHistory } from './history/MemoryHistory'; 241 | 242 | const registry = new Registry(); 243 | const history = new MemoryHistory(); 244 | 245 | const router = registerRouterInjector(config, registry, { history, key: 'custom-router-key' }); 246 | ``` 247 | 248 | ### Outlets 249 | 250 | The primary concept for the routing integration is an `outlet`, a unique identifier associated with the registered application route. Dojo 2 Widgets can then be configured with these outlet identifiers using the `Outlet` higher order component. `Outlet` returns a new widget that can be used like any other widget within a `render` method, e.g. `w(MyFooOutlet, { })`. 251 | 252 | Properties can be passed to an `Outlet` widget in the same way as if the original widget was being used. However, all properties are made optional to allow the properties to be injected using the [mapParams](#mapParams) function described below. 253 | 254 | The number of widgets that can be mapped to a single outlet identifier is not restricted. All configured widgets for a single outlet will be rendered when the route associated to the outlet is matched by the `router` and the `outlet`s are part of the current widget hierarchy. 255 | 256 | The following example configures a stateless widget with an outlet called `foo`. The resulting `FooOutlet` can be used in a widgets `render` in the same way as any other Dojo 2 Widget. 257 | 258 | ```ts 259 | import { Outlet } from '@dojo/routing/Outlet'; 260 | import { MyViewWidget } from './MyViewWidget'; 261 | 262 | const FooOutlet = Outlet(MyViewWidget, 'foo'); 263 | ``` 264 | 265 | Example usage of `FooOutlet`, where the widget will only be rendered when the route registered against outlet `foo` is matched. 266 | 267 | ```ts 268 | class App extends WidgetBase { 269 | protected render(): DNode { 270 | return v('div', [ 271 | w(FooOutlet, {}) 272 | ]); 273 | } 274 | } 275 | ``` 276 | 277 | #### Outlet Component Types 278 | 279 | When registering an outlet a different widget can be configured for each match type of a route: 280 | 281 | | Type | Description | 282 | | ------- | ------------ | 283 | |`index` | This is an exact match for the registered route. E.g. Navigating to `foo/bar` with a registered route `foo/bar`. | 284 | |`main`| Any match other than an index match, for example, `foo/bar` would partially match `foo/bar/qux`, but only if `foo/bar/qux` was also a registered route. Otherwise, it would be an `ERROR` match. | 285 | |`error` | When a partial match occurs but there is no match for the next section of the route. | 286 | 287 | To do this, instead of passing a widget as the first argument to the `Outlet`, use the `OutletComponents` object. 288 | 289 | ```ts 290 | import { MyViewWidget, MyErrorWidget } from './MyWidgets'; 291 | 292 | const fooWidgets: OutletComponents = { 293 | main: MyViewWidget, 294 | error: MyErrorWidget 295 | }; 296 | 297 | const FooOutlet = Outlet(fooWidgets, 'foo'); 298 | ``` 299 | 300 | It is important to note that a widget registered against match type `error` will not be used if the outlet also has a widget registered for match type `index`. 301 | 302 | #### Outlet Options 303 | 304 | Outlet Options of `mapParams`, `onEnter`, `onExit`, and `key` can be passed as an optional third argument to an `Outlet`. 305 | 306 | ##### Map Parameters 307 | 308 | When a widget is configured for an outlet it is possible to provide a callback function that is used to inject properties that will be available during render lifecycle of the widget. 309 | 310 | ```ts 311 | mapParams(type: 'error | index | main', location: string, params: {[key: string]: any}, router: Router) 312 | ``` 313 | 314 | | Argument | Description | 315 | | -------- | ---------------------------------------------------------------------- | 316 | | type | The `MatchType` that caused the outlet to render | 317 | | params | Key/Value object of the params that were parsed from the matched route | 318 | | router | The router instance that can be used to provide functions that go to other routes/outlets| 319 | 320 | The following example uses `mapParams` to inject an `onClose` function that will go to the route registered against the `other-outlet` route and `id` property extracted from `params` in the `MyViewWidget` properties: 321 | 322 | ```ts 323 | const mapParams = (options: MapParamsOptions) { 324 | const { type, params, router } = options; 325 | 326 | return { 327 | onClose() { 328 | // This creates a link for another outlet and sets the path 329 | router.setPath(router.link('other-outlet')); 330 | }, 331 | id: params.id 332 | } 333 | } 334 | 335 | const FooOutlet = Outlet(MyViewWidget, 'foo', { mapParams }); 336 | ``` 337 | 338 | ##### Key 339 | 340 | The `key` is the identifier used to locate the `router` from the `registry`, throughout the routing library this defaults to `router`. 341 | 342 | #### Global Error Outlet 343 | 344 | Whenever a match type of `error` is registered a global outlet is automatically added to the matched outlets called `errorOutlet`. This outlet can be used to render a widget for any unknown routes. 345 | 346 | ```ts 347 | const ErrorOutlet = Outlet(ErrorWidget, 'errorOutlet'); 348 | ``` 349 | 350 | ### Link 351 | 352 | The `Link` component is a wrapper around an `a` DOM element that enables consumers to specify an `outlet` to create a link to. It is also possible to use a static route by setting the `isOutlet` property to `false`. 353 | 354 | If the generated link requires specific path or query parameters that are not in the route, they can be passed via the `params` property. 355 | 356 | ```ts 357 | import { Link } from '@dojo/routing/Link'; 358 | 359 | render() { 360 | return v('div', [ 361 | w(Link, { to: 'foo', params: { foo: 'bar' }}, [ 'Link Text' ]), 362 | w(Link, { to: '#/static-route', isOutlet: false, [ 'Other Link Text' ]) 363 | ]); 364 | } 365 | ``` 366 | 367 | All the standard `VNodeProperties` are available for the `Link` component as they would be creating an `a` DOM Element using `v()` with `@dojo/widget-core`. 368 | 369 | ## How do I contribute? 370 | 371 | We appreciate your interest! Please see the [Dojo 2 Meta Repository](https://github.com/dojo/meta#readme) for the Contributing Guidelines. 372 | 373 | ### Code Style 374 | 375 | This repository uses [`prettier`](https://prettier.io/) for code styling rules and formatting. A pre-commit hook is installed automatically and configured to run `prettier` against all staged files as per the configuration in the project's `package.json`. 376 | 377 | An additional npm script to run `prettier` (with write set to `true`) against all `src` and `test` project files is available by running: 378 | 379 | ```bash 380 | npm run prettier 381 | ``` 382 | 383 | ### Installation 384 | 385 | To start working with this package, clone the repository and run `npm install`. 386 | 387 | In order to build the project run `grunt dev` or `grunt dist`. 388 | 389 | ### Testing 390 | 391 | Test cases MUST be written using [Intern](https://theintern.github.io) using the Object test interface and Assert assertion interface. 392 | 393 | 90% branch coverage MUST be provided for all code submitted to this repository, as reported by istanbul’s combined coverage results for all supported platforms. 394 | 395 | To test locally in node run: 396 | 397 | `grunt test` 398 | 399 | To test against browsers with a local selenium server run: 400 | 401 | `grunt test:local` 402 | 403 | To test against BrowserStack or Sauce Labs run: 404 | 405 | `grunt test:browserstack` 406 | 407 | or 408 | 409 | `grunt test:saucelabs` 410 | 411 | ## Licensing information 412 | 413 | © 2018 [JS Foundation](https://js.foundation/) & contributors. [New BSD](http://opensource.org/licenses/BSD-3-Clause) license. 414 | 415 | 420 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | notify: 3 | slack: 4 | default: 5 | url: "secret:KbC9Qz8XlHiHw+4VAo5ySxtr67vIkYQJKjaJARw6rt805oqfbTqGWdGBdpLAvAzn3zu2LDaHrDqOcwhtR5/8aEgoU5wkOSoc9lpIOvWquksV3goktSbYrsAm5A5958wv7kkhQPbl/w2kmUnnPMh/KTqVNP5bxiZW12mA8lMyJoM=" 6 | threshold: 2 7 | attachments: "sunburst, diff" 8 | comment: 9 | branches: 10 | - master 11 | - feature/* 12 | -------------------------------------------------------------------------------- /deploy_key.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dojo/routing/1e70fe6f8eeedb857d35dade1526378f40ddf3f2/deploy_key.enc -------------------------------------------------------------------------------- /examples/ambigious-matches.ts: -------------------------------------------------------------------------------- 1 | import { WidgetBase } from '@dojo/widget-core/WidgetBase'; 2 | import { v, w } from '@dojo/widget-core/d'; 3 | import { MapParamsOptions } from './../src/interfaces'; 4 | 5 | import { Link } from './../src/Link'; 6 | import { Outlet } from './../src/Outlet'; 7 | 8 | export interface ChildProperties { 9 | name: string; 10 | } 11 | 12 | export class About extends WidgetBase { 13 | protected render() { 14 | return v('h2', ['About']); 15 | } 16 | } 17 | 18 | export class Company extends WidgetBase { 19 | protected render() { 20 | return v('h2', ['Company']); 21 | } 22 | } 23 | 24 | export interface UserProperties { 25 | name: string; 26 | } 27 | 28 | export class User extends WidgetBase { 29 | protected render() { 30 | return v('div', [v('h2', [`User: ${this.properties.name}`])]); 31 | } 32 | } 33 | 34 | export const AboutOutlet = Outlet(About, 'about-us'); 35 | export const CompanyOutlet = Outlet(Company, 'company'); 36 | export const UserOutlet = Outlet(User, 'user', { 37 | mapParams: ({ params }: MapParamsOptions) => { 38 | return { name: params.user }; 39 | } 40 | }); 41 | 42 | export class App extends WidgetBase { 43 | protected render() { 44 | return v('div', [ 45 | v('ul', [ 46 | v('li', [w(Link, { key: '1', to: 'about-us' }, ['About Us (Static)'])]), 47 | v('li', [w(Link, { key: '2', to: 'company' }, ['Company (Static)'])]), 48 | v('li', [w(Link, { key: '3', to: 'user', params: { user: 'kim' } }, ['Kim (dynamic)'])]), 49 | v('li', [w(Link, { key: '4', to: 'user', params: { user: 'chris' } }, ['Chris (dynamic)'])]) 50 | ]), 51 | w(AboutOutlet, {}), 52 | w(CompanyOutlet, {}), 53 | w(UserOutlet, {}) 54 | ]); 55 | } 56 | } 57 | 58 | export const AmbiguousMatchesRouteConfig = { 59 | path: 'ambiguous-matches', 60 | outlet: 'ambiguous-matches', 61 | children: [ 62 | { 63 | path: 'about', 64 | outlet: 'about-us' 65 | }, 66 | { 67 | path: 'company', 68 | outlet: 'company' 69 | }, 70 | { 71 | path: '{user}', 72 | outlet: 'user' 73 | } 74 | ] 75 | }; 76 | 77 | export const AmbiguousMatchesOutlet = Outlet(App, 'ambiguous-matches'); 78 | -------------------------------------------------------------------------------- /examples/basic.ts: -------------------------------------------------------------------------------- 1 | import { WidgetBase } from '@dojo/widget-core/WidgetBase'; 2 | import { v, w } from '@dojo/widget-core/d'; 3 | 4 | import { Outlet } from './../src/Outlet'; 5 | import { Link } from './../src/Link'; 6 | import { MapParamsOptions } from './../src/interfaces'; 7 | 8 | export interface ChildProperties { 9 | name: string; 10 | } 11 | 12 | export class About extends WidgetBase { 13 | protected render() { 14 | return v('div', [v('h2', ['About'])]); 15 | } 16 | } 17 | 18 | export class Home extends WidgetBase { 19 | protected render() { 20 | return v('div', [v('h2', ['Home'])]); 21 | } 22 | } 23 | 24 | export interface TopicsProperties { 25 | showHeading: boolean; 26 | } 27 | 28 | export class Topics extends WidgetBase { 29 | protected render() { 30 | const { showHeading } = this.properties; 31 | 32 | return v('div', [ 33 | v('h2', ['Topics']), 34 | v('ul', [ 35 | v('li', [ 36 | w(Link, { key: 'rendering', to: 'topic', params: { topic: 'rendering' } }, [ 37 | 'Rendering with Dojo 2' 38 | ]) 39 | ]), 40 | v('li', [w(Link, { key: 'widgets', to: 'topic', params: { topic: 'widgets' } }, ['Widgets'])]), 41 | v('li', [w(Link, { key: 'props', to: 'topic', params: { topic: 'props-v-state' } }, ['Props v State'])]) 42 | ]), 43 | showHeading ? v('h3', ['Please select a topic.']) : null, 44 | w(TopicOutlet, {}) 45 | ]); 46 | } 47 | } 48 | 49 | export interface TopicProperties { 50 | topic: string; 51 | } 52 | 53 | export class Topic extends WidgetBase { 54 | protected render() { 55 | return v('div', [v('h3', [this.properties.topic])]); 56 | } 57 | } 58 | 59 | class ErrorWidget extends WidgetBase { 60 | protected render() { 61 | return v('div', ['ERROR 2']); 62 | } 63 | } 64 | 65 | export const AboutOutlet = Outlet(About, 'about'); 66 | export const HomeOutlet = Outlet({ index: Home }, 'home'); 67 | export const TopicsOutlet = Outlet(Topics, 'topics', { 68 | mapParams: ({ type }: MapParamsOptions) => { 69 | return { showHeading: type === 'index' }; 70 | } 71 | }); 72 | export const TopicOutlet = Outlet({ main: Topic, error: ErrorWidget }, 'topic', { 73 | mapParams: ({ params }) => { 74 | return { topic: params.topic }; 75 | } 76 | }); 77 | 78 | export class App extends WidgetBase { 79 | protected render() { 80 | return v('div', [ 81 | v('ul', [ 82 | v('li', [w(Link, { key: 'home', to: 'home' }, ['Home'])]), 83 | v('li', [w(Link, { key: 'about', to: 'about' }, ['About'])]), 84 | v('li', [w(Link, { key: 'topics', to: 'topics' }, ['Topics'])]) 85 | ]), 86 | w(AboutOutlet, {}), 87 | w(HomeOutlet, {}), 88 | w(TopicsOutlet, {}) 89 | ]); 90 | } 91 | } 92 | 93 | export const BasicAppRouteConfig = { 94 | path: 'basic', 95 | outlet: 'basic', 96 | children: [ 97 | { 98 | path: 'home', 99 | outlet: 'home' 100 | }, 101 | { 102 | path: 'about', 103 | outlet: 'about' 104 | }, 105 | { 106 | path: 'topics', 107 | outlet: 'topics', 108 | children: [ 109 | { 110 | path: '{topic}', 111 | outlet: 'topic' 112 | } 113 | ] 114 | } 115 | ] 116 | }; 117 | 118 | export const BasicAppOutlet = Outlet(App, 'basic'); 119 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Example 5 | 6 | 7 | 8 | 9 | 10 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /examples/main.ts: -------------------------------------------------------------------------------- 1 | import { WidgetBase } from '@dojo/widget-core/WidgetBase'; 2 | import { v, w } from '@dojo/widget-core/d'; 3 | import { ProjectorMixin } from '@dojo/widget-core/mixins/Projector'; 4 | import { Registry } from '@dojo/widget-core/Registry'; 5 | 6 | import { RouteConfig } from './../src/interfaces'; 7 | import { registerRouterInjector } from './../src/RouterInjector'; 8 | import { Link } from './../src/Link'; 9 | import { BasicAppOutlet, BasicAppRouteConfig } from './basic'; 10 | import { UrlParametersAppOutlet, UrlParametersRouteConfig } from './url-parameters'; 11 | import { AmbiguousMatchesOutlet, AmbiguousMatchesRouteConfig } from './ambigious-matches'; 12 | 13 | const applicationRoutes: RouteConfig[] = [BasicAppRouteConfig, UrlParametersRouteConfig, AmbiguousMatchesRouteConfig]; 14 | 15 | const registry = new Registry(); 16 | 17 | registerRouterInjector(applicationRoutes, registry); 18 | 19 | const sideBarStyles = { 20 | fontSize: '13px', 21 | background: '#eee', 22 | overflow: 'auto', 23 | position: 'fixed', 24 | height: '100vh', 25 | left: '0px', 26 | top: '0px', 27 | bottom: '0px', 28 | width: '250px', 29 | display: 'block' 30 | }; 31 | 32 | const linkStyles = { 33 | textDecoration: 'none', 34 | position: 'relative', 35 | display: 'block', 36 | lineHeight: '1.8', 37 | cursor: 'pointer', 38 | color: 'inherit' 39 | }; 40 | 41 | const contentStyles = { 42 | marginLeft: '250px', 43 | display: 'block' 44 | }; 45 | 46 | const menuStyles = { 47 | lineHeight: '1.8', 48 | padding: '10px', 49 | display: 'block' 50 | }; 51 | 52 | const titleStyles = { 53 | textTransform: 'uppercase', 54 | fontWeight: 'bold', 55 | color: 'hsl(0, 0%, 32%)', 56 | marginTop: '20px', 57 | display: 'block' 58 | }; 59 | 60 | const menuContainerStyles = { 61 | paddingLeft: '10px', 62 | display: 'block' 63 | }; 64 | 65 | class App extends WidgetBase { 66 | protected render() { 67 | return v('div', [ 68 | v('div', { styles: sideBarStyles }, [ 69 | v('div', { styles: menuStyles }, [ 70 | v('div', { styles: titleStyles }, ['Examples']), 71 | v('div', { styles: menuContainerStyles }, [ 72 | w(Link, { key: 'basic', to: 'basic', styles: linkStyles }, ['Basic']), 73 | w(Link, { key: 'url', to: 'url-parameters', styles: linkStyles }, ['Url Parameters']), 74 | w(Link, { key: 'amb', to: 'ambiguous-matches', styles: linkStyles }, ['Ambiguous Matches']) 75 | ]) 76 | ]) 77 | ]), 78 | v('div', { styles: contentStyles }, [ 79 | w(BasicAppOutlet, {}), 80 | w(UrlParametersAppOutlet, {}), 81 | w(AmbiguousMatchesOutlet, {}) 82 | ]) 83 | ]); 84 | } 85 | } 86 | 87 | const Projector = ProjectorMixin(App); 88 | const projector = new Projector(); 89 | projector.setProperties({ registry }); 90 | projector.append(); 91 | -------------------------------------------------------------------------------- /examples/url-parameters.ts: -------------------------------------------------------------------------------- 1 | import { WidgetBase } from '@dojo/widget-core/WidgetBase'; 2 | import { v, w } from '@dojo/widget-core/d'; 3 | import { MapParamsOptions } from './../src/interfaces'; 4 | 5 | import { Link } from './../src/Link'; 6 | import { Outlet } from './../src/Outlet'; 7 | 8 | export interface ChildProperties { 9 | name: string; 10 | } 11 | 12 | export class Child extends WidgetBase { 13 | protected render() { 14 | return v('div', [v('h3', [`ID: ${this.properties.name || 'this must be about'}`])]); 15 | } 16 | } 17 | 18 | export const ChildOutlet = Outlet(Child, 'child', { 19 | mapParams: ({ params }: MapParamsOptions) => { 20 | return { name: params.id }; 21 | } 22 | }); 23 | 24 | export class App extends WidgetBase { 25 | protected render() { 26 | return v('div', [ 27 | v('h2', ['Accounts']), 28 | v('ul', [ 29 | v('li', [w(Link, { key: '1', to: 'child', params: { id: 'netflix' } }, ['Netflix'])]), 30 | v('li', [w(Link, { key: '2', to: 'child', params: { id: 'zillow-group' } }, ['Zillow Group'])]), 31 | v('li', [w(Link, { key: '3', to: 'child', params: { id: 'yahoo' } }, ['Yahoo'])]), 32 | v('li', [w(Link, { key: '4', to: 'child', params: { id: 'modus-create' } }, ['Modus Create'])]) 33 | ]), 34 | w(ChildOutlet, {}) 35 | ]); 36 | } 37 | } 38 | 39 | export const UrlParametersRouteConfig = { 40 | path: 'url-parameters', 41 | outlet: 'url-parameters', 42 | children: [ 43 | { 44 | path: '{id}', 45 | outlet: 'child' 46 | } 47 | ] 48 | }; 49 | 50 | export const UrlParametersAppOutlet = Outlet(App, 'url-parameters'); 51 | -------------------------------------------------------------------------------- /intern.json: -------------------------------------------------------------------------------- 1 | { 2 | "capabilities+": { 3 | "browserstack.debug": false, 4 | "project": "Dojo 2", 5 | "name": "@dojo/routing" 6 | }, 7 | "environments": [ 8 | { "browserName": "node" } 9 | ], 10 | "functionalSuites": [ 11 | "./_build/tests/functional/**/*.js" 12 | ], 13 | "browser": { 14 | "suites": [ 15 | "./_build/tests/unit/all.js" 16 | ], 17 | "loader": { 18 | "script": "./node_modules/grunt-dojo2/lib/intern/internLoader.js", 19 | "options": { 20 | "packages": [ 21 | { "name": "src", "location": "_build/src" }, 22 | { "name": "tests", "location": "_build/tests" }, 23 | { "name": "dojo", "location": "node_modules/intern/browser_modules/dojo" } 24 | ] 25 | } 26 | } 27 | }, 28 | "node": { 29 | "suites": [ 30 | "./_build/tests/unit/**/*.js", 31 | "!./_build/tests/unit/**/all.js", 32 | "!./_build/tests/unit/history/StateHistory.js" 33 | ] 34 | }, 35 | "coverage": [ 36 | "./_build/src/**/*.js", 37 | "!./_build/examples/**" 38 | ], 39 | "configs": { 40 | "local": { 41 | "tunnel": "selenium", 42 | "tunnelOptions": { }, 43 | "environments+": [ 44 | { "browserName": "chrome" } 45 | ] 46 | }, 47 | "browserstack": { 48 | "tunnel": "browserstack", 49 | "tunnelOptions": { 50 | }, 51 | "environments": [ 52 | { "browserName": "internet explorer", "version": "11" }, 53 | { "browserName": "edge" }, 54 | { "browserName": "firefox", "platform": "WINDOWS" }, 55 | { "browserName": "chrome", "platform": "WINDOWS" }, 56 | { "browserName": "safari", "version": "9.1", "platform": "MAC" }, 57 | { "browserName": "iPhone", "version": "9.1" } 58 | ] 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dojo/routing", 3 | "version": "2.0.1-pre", 4 | "description": "A routing library for Dojo 2 applications", 5 | "private": true, 6 | "homepage": "https://dojo.io", 7 | "bugs": { 8 | "url": "https://github.com/dojo/routing/issues" 9 | }, 10 | "license": "BSD-3-Clause", 11 | "main": "main.js", 12 | "files": [ 13 | "dist", 14 | "src", 15 | "typings.json" 16 | ], 17 | "engines": { 18 | "npm": ">=3.0.0" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/dojo/routing.git" 23 | }, 24 | "scripts": { 25 | "prepublish": "grunt peerDepInstall", 26 | "precommit": "lint-staged", 27 | "prettier": "prettier --write 'src/**/*.ts' 'tests/**/*.ts' 'examples/**/*.ts'", 28 | "test": "grunt test" 29 | }, 30 | "peerDependencies": { 31 | "@dojo/core": "^2.0.0", 32 | "@dojo/has": "^2.0.0", 33 | "@dojo/shim": "^2.0.0", 34 | "@dojo/i18n": "^2.0.0", 35 | "@dojo/widget-core": "^2.0.0" 36 | }, 37 | "devDependencies": { 38 | "@dojo/loader": "^2.0.0", 39 | "@types/glob": "~5.0.0", 40 | "@types/grunt": "~0.4.0", 41 | "@types/node": "~9.6.5", 42 | "@types/sinon": "^4.1.2", 43 | "grunt": "~1.0.1", 44 | "grunt-dojo2": "latest", 45 | "grunt-tslint": "5.0.1", 46 | "husky": "0.14.3", 47 | "intern": "~4.1.0", 48 | "lint-staged": "6.0.0", 49 | "prettier": "1.9.2", 50 | "sinon": "^4.1.3", 51 | "tslint": "5.8.0", 52 | "typescript": "~2.6.1" 53 | }, 54 | "dependencies": { 55 | "tslib": "~1.8.1" 56 | }, 57 | "lint-staged": { 58 | "*.{ts,tsx}": [ 59 | "prettier --write", 60 | "git add" 61 | ] 62 | }, 63 | "prettier": { 64 | "singleQuote": true, 65 | "tabWidth": 4, 66 | "useTabs": true, 67 | "parser": "typescript", 68 | "printWidth": 120, 69 | "arrowParens": "always" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Link.ts: -------------------------------------------------------------------------------- 1 | import { WidgetBase } from '@dojo/widget-core/WidgetBase'; 2 | import { v } from '@dojo/widget-core/d'; 3 | import { inject } from '@dojo/widget-core/decorators/inject'; 4 | import { Constructor, DNode, VNodeProperties } from '@dojo/widget-core/interfaces'; 5 | import { LinkProperties } from './interfaces'; 6 | import { Router } from './Router'; 7 | 8 | const getProperties = (router: Router, properties: LinkProperties): VNodeProperties => { 9 | const { to, isOutlet = true, params = {}, onClick, ...props } = properties; 10 | const href = isOutlet ? router.link(to, params) : to; 11 | 12 | const handleOnClick = (event: MouseEvent) => { 13 | onClick && onClick(event); 14 | 15 | if (!event.defaultPrevented && event.button === 0 && !properties.target) { 16 | event.preventDefault(); 17 | href !== undefined && router.setPath(href); 18 | } 19 | }; 20 | return { 21 | href, 22 | onClick: handleOnClick, 23 | ...props 24 | }; 25 | }; 26 | 27 | export class BaseLink extends WidgetBase { 28 | private _onClick(event: MouseEvent): void { 29 | this.properties.onClick && this.properties.onClick(event); 30 | } 31 | 32 | protected render(): DNode { 33 | const props = { 34 | ...this.properties, 35 | onclick: this._onClick, 36 | onClick: undefined, 37 | to: undefined, 38 | isOutlet: undefined, 39 | params: undefined, 40 | routerKey: undefined, 41 | router: undefined 42 | }; 43 | return v('a', props, this.children); 44 | } 45 | } 46 | 47 | export function createLink(routerKey: string): Constructor { 48 | @inject({ name: routerKey, getProperties }) 49 | class Link extends BaseLink {} 50 | return Link; 51 | } 52 | 53 | export const Link = createLink('router'); 54 | 55 | export default Link; 56 | -------------------------------------------------------------------------------- /src/Outlet.ts: -------------------------------------------------------------------------------- 1 | import { DNode, WidgetBaseInterface } from '@dojo/widget-core/interfaces'; 2 | import { WidgetBase } from '@dojo/widget-core/WidgetBase'; 3 | import { w } from '@dojo/widget-core/d'; 4 | import { inject } from '@dojo/widget-core/decorators/inject'; 5 | import { alwaysRender } from '@dojo/widget-core/decorators/alwaysRender'; 6 | import { OnEnter, Component, OutletOptions, OutletComponents, Outlet, Params, OutletContext } from './interfaces'; 7 | import { Router } from './Router'; 8 | 9 | export function isComponent(value: any): value is Component { 10 | return Boolean(value && (typeof value === 'string' || typeof value === 'function' || typeof value === 'symbol')); 11 | } 12 | 13 | export function getProperties(router: Router, properties: any) { 14 | return { router }; 15 | } 16 | 17 | export function Outlet( 18 | outletComponents: Component | OutletComponents, 19 | outlet: string, 20 | options: OutletOptions = {} 21 | ): Outlet { 22 | const indexComponent = isComponent(outletComponents) ? undefined : outletComponents.index; 23 | const mainComponent = isComponent(outletComponents) ? outletComponents : outletComponents.main; 24 | const errorComponent = isComponent(outletComponents) ? undefined : outletComponents.error; 25 | const { mapParams, key = 'router' } = options; 26 | 27 | @inject({ name: key, getProperties }) 28 | @alwaysRender() 29 | class OutletComponent extends WidgetBase & { router: Router }, null> { 30 | private _matched = false; 31 | private _matchedParams: Params = {}; 32 | private _onExit?: () => void; 33 | 34 | private _hasRouteChanged(params: Params): boolean { 35 | if (!this._matched) { 36 | return true; 37 | } 38 | const newParamKeys = Object.keys(params); 39 | for (let i = 0; i < newParamKeys.length; i++) { 40 | const key = newParamKeys[i]; 41 | if (this._matchedParams[key] !== params[key]) { 42 | return true; 43 | } 44 | } 45 | return false; 46 | } 47 | 48 | private _onEnter(outletContext: OutletContext, onEnterCallback?: OnEnter) { 49 | const { params, type } = outletContext; 50 | if (this._hasRouteChanged(params)) { 51 | onEnterCallback && onEnterCallback(params, type); 52 | this._matched = true; 53 | this._matchedParams = params; 54 | } 55 | } 56 | 57 | protected onDetach() { 58 | if (this._matched) { 59 | this._onExit && this._onExit(); 60 | this._matched = false; 61 | } 62 | } 63 | 64 | protected render(): DNode { 65 | let { router, ...properties } = this.properties; 66 | 67 | const outletContext = router.getOutlet(outlet); 68 | if (outletContext) { 69 | const { queryParams, params, type, onEnter, onExit } = outletContext; 70 | this._onExit = onExit; 71 | if (mapParams) { 72 | properties = { ...properties, ...mapParams({ queryParams, params, type, router }) }; 73 | } 74 | 75 | if (type === 'index' && indexComponent) { 76 | this._onEnter(outletContext, onEnter); 77 | return w(indexComponent, properties, this.children); 78 | } else if (type === 'error' && errorComponent) { 79 | this._onEnter(outletContext, onEnter); 80 | return w(errorComponent, properties, this.children); 81 | } else if (type === 'error' && indexComponent) { 82 | this._onEnter(outletContext, onEnter); 83 | return w(indexComponent, properties, this.children); 84 | } else if (type !== 'error' && mainComponent) { 85 | this._onEnter(outletContext, onEnter); 86 | return w(mainComponent, properties, this.children); 87 | } 88 | } 89 | 90 | if (this._matched) { 91 | this._onExit && this._onExit(); 92 | this._matched = false; 93 | } 94 | return null; 95 | } 96 | } 97 | return OutletComponent; 98 | } 99 | 100 | export default Outlet; 101 | -------------------------------------------------------------------------------- /src/Router.ts: -------------------------------------------------------------------------------- 1 | import QueuingEvented from '@dojo/core/QueuingEvented'; 2 | import { 3 | RouteConfig, 4 | History, 5 | MatchType, 6 | OutletContext, 7 | Params, 8 | RouterInterface, 9 | Route, 10 | RouterOptions 11 | } from './interfaces'; 12 | import { HashHistory } from './history/HashHistory'; 13 | import { EventObject } from '@dojo/core/interfaces'; 14 | 15 | const PARAM = Symbol('routing param'); 16 | 17 | export interface NavEvent extends EventObject { 18 | outlet: string; 19 | context: OutletContext; 20 | } 21 | 22 | export class Router extends QueuingEvented<{ nav: NavEvent }> implements RouterInterface { 23 | private _routes: Route[] = []; 24 | private _outletMap: { [index: string]: Route } = Object.create(null); 25 | private _matchedOutlets: { [index: string]: OutletContext } = Object.create(null); 26 | private _currentParams: Params = {}; 27 | private _currentQueryParams: Params = {}; 28 | private _defaultOutlet: string | undefined; 29 | private _history: History; 30 | 31 | constructor(config: RouteConfig[], options: RouterOptions = {}) { 32 | super(); 33 | const { HistoryManager = HashHistory, base, window } = options; 34 | this._register(config); 35 | this._history = new HistoryManager({ onChange: this._onChange, base, window }); 36 | if (this._matchedOutlets.errorOutlet && this._defaultOutlet) { 37 | const path = this.link(this._defaultOutlet); 38 | if (path) { 39 | this.setPath(path); 40 | } 41 | } 42 | } 43 | 44 | /** 45 | * Sets the path against the registered history manager 46 | * 47 | * @param path The path to set on the history manager 48 | */ 49 | public setPath(path: string): void { 50 | this._history.set(path); 51 | } 52 | 53 | /** 54 | * Generate a link for a given outlet identifier and optional params. 55 | * 56 | * @param outlet The outlet to generate a link for 57 | * @param params Optional Params for the generated link 58 | */ 59 | public link(outlet: string, params: Params = {}): string | undefined { 60 | const { _outletMap, _currentParams, _currentQueryParams } = this; 61 | let route = _outletMap[outlet]; 62 | if (route === undefined) { 63 | return; 64 | } 65 | 66 | let linkPath: string | undefined = route.fullPath; 67 | if (route.fullQueryParams.length > 0) { 68 | let queryString = route.fullQueryParams.reduce((queryParamString, param, index) => { 69 | if (index > 0) { 70 | return `${queryParamString}&${param}={${param}}`; 71 | } 72 | return `?${param}={${param}}`; 73 | }, ''); 74 | linkPath = `${linkPath}${queryString}`; 75 | } 76 | params = { ...route.defaultParams, ..._currentQueryParams, ..._currentParams, ...params }; 77 | 78 | if (Object.keys(params).length === 0 && route.fullParams.length > 0) { 79 | return undefined; 80 | } 81 | 82 | const fullParams = [...route.fullParams, ...route.fullQueryParams]; 83 | for (let i = 0; i < fullParams.length; i++) { 84 | const param = fullParams[i]; 85 | if (params[param]) { 86 | linkPath = linkPath.replace(`{${param}}`, params[param]); 87 | } else { 88 | return undefined; 89 | } 90 | } 91 | return linkPath; 92 | } 93 | 94 | /** 95 | * Returns the outlet context for the outlet identifier if one has been matched 96 | * 97 | * @param outletIdentifier The outlet identifer 98 | */ 99 | public getOutlet(outletIdentifier: string): OutletContext | undefined { 100 | return this._matchedOutlets[outletIdentifier]; 101 | } 102 | 103 | /** 104 | * Returns all the params for the current matched outlets 105 | */ 106 | public get currentParams() { 107 | return this._currentParams; 108 | } 109 | 110 | /** 111 | * Strips the leading slash on a path if one exists 112 | * 113 | * @param path The path to strip a leading slash 114 | */ 115 | private _stripLeadingSlash(path: string): string { 116 | if (path[0] === '/') { 117 | return path.slice(1); 118 | } 119 | return path; 120 | } 121 | 122 | /** 123 | * Registers the routing configuration 124 | * 125 | * @param config The configuration 126 | * @param routes The routes 127 | * @param parentRoute The parent route 128 | */ 129 | private _register(config: RouteConfig[], routes?: Route[], parentRoute?: Route): void { 130 | routes = routes ? routes : this._routes; 131 | for (let i = 0; i < config.length; i++) { 132 | let { onEnter, onExit, path, outlet, children, defaultRoute = false, defaultParams = {} } = config[i]; 133 | let [parsedPath, queryParamString] = path.split('?'); 134 | let queryParams: string[] = []; 135 | parsedPath = this._stripLeadingSlash(parsedPath); 136 | 137 | const segments: (symbol | string)[] = parsedPath.split('/'); 138 | const route: Route = { 139 | params: [], 140 | outlet, 141 | path: parsedPath, 142 | segments, 143 | defaultParams: parentRoute ? { ...parentRoute.defaultParams, ...defaultParams } : defaultParams, 144 | children: [], 145 | fullPath: parentRoute ? `${parentRoute.fullPath}/${parsedPath}` : parsedPath, 146 | fullParams: [], 147 | fullQueryParams: [], 148 | onEnter, 149 | onExit 150 | }; 151 | if (defaultRoute) { 152 | this._defaultOutlet = outlet; 153 | } 154 | for (let i = 0; i < segments.length; i++) { 155 | const segment = segments[i]; 156 | if (typeof segment === 'string' && segment[0] === '{') { 157 | route.params.push(segment.replace('{', '').replace('}', '')); 158 | segments[i] = PARAM; 159 | } 160 | } 161 | if (queryParamString) { 162 | queryParams = queryParamString.split('$').map((queryParam) => { 163 | return queryParam.replace('{', '').replace('}', ''); 164 | }); 165 | } 166 | route.fullQueryParams = parentRoute ? [...parentRoute.fullQueryParams, ...queryParams] : queryParams; 167 | 168 | route.fullParams = parentRoute ? [...parentRoute.fullParams, ...route.params] : route.params; 169 | 170 | if (children && children.length > 0) { 171 | this._register(children, route.children, route); 172 | } 173 | this._outletMap[outlet] = route; 174 | routes.push(route); 175 | } 176 | } 177 | 178 | /** 179 | * Returns an object of query params 180 | * 181 | * @param queryParamString The string of query params, e.g `paramOne=one¶mTwo=two` 182 | */ 183 | private _getQueryParams(queryParamString?: string): { [index: string]: string } { 184 | const queryParams: { [index: string]: string } = {}; 185 | if (queryParamString) { 186 | const queryParameters = queryParamString.split('&'); 187 | for (let i = 0; i < queryParameters.length; i++) { 188 | const [key, value] = queryParameters[i].split('='); 189 | queryParams[key] = value; 190 | } 191 | } 192 | return queryParams; 193 | } 194 | 195 | /** 196 | * Called on change of the route by the the registered history manager. Matches the path against 197 | * the registered outlets. 198 | * 199 | * @param requestedPath The path of the requested route 200 | */ 201 | private _onChange = (requestedPath: string): void => { 202 | this.emit({ type: 'navstart' }); 203 | this._matchedOutlets = Object.create(null); 204 | this._currentParams = {}; 205 | requestedPath = this._stripLeadingSlash(requestedPath); 206 | 207 | const [path, queryParamString] = requestedPath.split('?'); 208 | this._currentQueryParams = this._getQueryParams(queryParamString); 209 | let matchedOutletContext: OutletContext | undefined; 210 | let matchedOutlet: string | undefined; 211 | let routes = [...this._routes]; 212 | let paramIndex = 0; 213 | let segments = path.split('/'); 214 | let routeMatched = false; 215 | let previousOutlet: string | undefined; 216 | while (routes.length > 0) { 217 | if (segments.length === 0) { 218 | break; 219 | } 220 | const route = routes.shift()!; 221 | const { onEnter, onExit } = route; 222 | let type: MatchType = 'index'; 223 | const segmentsForRoute = [...segments]; 224 | let routeMatch = true; 225 | let segmentIndex = 0; 226 | 227 | if (segments.length < route.segments.length) { 228 | routeMatch = false; 229 | } else { 230 | while (segments.length > 0) { 231 | if (route.segments[segmentIndex] === undefined) { 232 | type = 'partial'; 233 | break; 234 | } 235 | const segment = segments.shift()!; 236 | if (route.segments[segmentIndex] === PARAM) { 237 | this._currentParams[route.params[paramIndex++]] = segment; 238 | } else if (route.segments[segmentIndex] !== segment) { 239 | routeMatch = false; 240 | break; 241 | } 242 | segmentIndex++; 243 | } 244 | } 245 | if (routeMatch === true) { 246 | previousOutlet = route.outlet; 247 | routeMatched = true; 248 | this._matchedOutlets[route.outlet] = { 249 | queryParams: this._currentQueryParams, 250 | params: { ...this._currentParams }, 251 | type, 252 | onEnter, 253 | onExit 254 | }; 255 | matchedOutletContext = this._matchedOutlets[route.outlet]; 256 | matchedOutlet = route.outlet; 257 | if (route.children.length) { 258 | paramIndex = 0; 259 | } 260 | routes = [...route.children]; 261 | } else { 262 | if (previousOutlet !== undefined && routes.length === 0) { 263 | this._matchedOutlets[previousOutlet].type = 'error'; 264 | } 265 | segments = [...segmentsForRoute]; 266 | } 267 | } 268 | if (routeMatched === false) { 269 | this._matchedOutlets.errorOutlet = { 270 | queryParams: this._currentQueryParams, 271 | params: { ...this._currentParams }, 272 | type: 'error' 273 | }; 274 | } 275 | if (matchedOutlet && matchedOutletContext) { 276 | this.emit({ type: 'nav', outlet: matchedOutlet, context: matchedOutletContext }); 277 | } 278 | }; 279 | } 280 | 281 | export default Router; 282 | -------------------------------------------------------------------------------- /src/RouterInjector.ts: -------------------------------------------------------------------------------- 1 | import { Registry } from '@dojo/widget-core/Registry'; 2 | import { RegistryLabel } from '@dojo/widget-core/interfaces'; 3 | 4 | import { Router } from './Router'; 5 | import { RouteConfig, RouterOptions } from './interfaces'; 6 | 7 | /** 8 | * Router Injector Options 9 | * 10 | */ 11 | export interface RouterInjectorOptions extends RouterOptions { 12 | key?: RegistryLabel; 13 | } 14 | 15 | /** 16 | * Creates a router instance for a specific History manager (default is `HashHistory`) and registers 17 | * the route configuration. 18 | * 19 | * @param config The route config to register for the router 20 | * @param registry An optional registry that defaults to the global registry 21 | * @param options The router injector options 22 | */ 23 | export function registerRouterInjector( 24 | config: RouteConfig[], 25 | registry: Registry, 26 | options: RouterInjectorOptions = {} 27 | ): Router { 28 | const { key = 'router', ...routerOptions } = options; 29 | 30 | if (registry.hasInjector(key)) { 31 | throw new Error('Router has already been defined'); 32 | } 33 | const router = new Router(config, routerOptions); 34 | registry.defineInjector(key, (invalidator: () => void) => { 35 | router.on('navstart', () => invalidator()); 36 | return () => router; 37 | }); 38 | return router; 39 | } 40 | -------------------------------------------------------------------------------- /src/history/HashHistory.ts: -------------------------------------------------------------------------------- 1 | import global from '@dojo/shim/global'; 2 | import { History, HistoryOptions, OnChangeFunction } from './../interfaces'; 3 | 4 | export class HashHistory implements History { 5 | private _onChangeFunction: OnChangeFunction; 6 | private _current: string; 7 | private _window: Window; 8 | 9 | constructor({ window = global.window, onChange }: HistoryOptions) { 10 | this._onChangeFunction = onChange; 11 | this._window = window; 12 | this._window.addEventListener('hashchange', this._onChange, false); 13 | this._current = this.normalizePath(this._window.location.hash); 14 | this._onChangeFunction(this._current); 15 | } 16 | 17 | public normalizePath(path: string): string { 18 | return path.replace('#', ''); 19 | } 20 | 21 | public prefix(path: string) { 22 | if (path[0] !== '#') { 23 | return `#${path}`; 24 | } 25 | return path; 26 | } 27 | 28 | public set(path: string) { 29 | this._window.location.hash = this.prefix(path); 30 | } 31 | 32 | public get current(): string { 33 | return this._current; 34 | } 35 | 36 | public destroy() { 37 | this._window.removeEventListener('hashchange', this._onChange); 38 | } 39 | 40 | private _onChange = () => { 41 | this._current = this.normalizePath(this._window.location.hash); 42 | this._onChangeFunction(this._current); 43 | }; 44 | } 45 | 46 | export default HashHistory; 47 | -------------------------------------------------------------------------------- /src/history/MemoryHistory.ts: -------------------------------------------------------------------------------- 1 | import { History, HistoryOptions, OnChangeFunction } from './../interfaces'; 2 | 3 | export class MemoryHistory implements History { 4 | private _onChangeFunction: OnChangeFunction; 5 | private _current = '/'; 6 | 7 | constructor({ onChange }: HistoryOptions) { 8 | this._onChangeFunction = onChange; 9 | this._onChange(); 10 | } 11 | 12 | public prefix(path: string) { 13 | return path; 14 | } 15 | 16 | public set(path: string) { 17 | if (this._current === path) { 18 | return; 19 | } 20 | this._current = path; 21 | this._onChange(); 22 | } 23 | 24 | public get current(): string { 25 | return this._current; 26 | } 27 | 28 | private _onChange() { 29 | this._onChangeFunction(this._current); 30 | } 31 | } 32 | 33 | export default MemoryHistory; 34 | -------------------------------------------------------------------------------- /src/history/StateHistory.ts: -------------------------------------------------------------------------------- 1 | import global from '@dojo/shim/global'; 2 | import { History as HistoryInterface, HistoryOptions, OnChangeFunction } from './../interfaces'; 3 | 4 | export class StateHistory implements HistoryInterface { 5 | private _current: string; 6 | private _onChangeFunction: OnChangeFunction; 7 | private _window: Window; 8 | private _base: string; 9 | 10 | constructor({ onChange, window = global.window, base = '/' }: HistoryOptions) { 11 | if (/(#|\?)/.test(base)) { 12 | throw new TypeError("base must not contain '#' or '?'"); 13 | } 14 | this._onChangeFunction = onChange; 15 | this._window = window; 16 | this._base = base; 17 | this._current = this._window.location.pathname + this._window.location.search; 18 | this._window.addEventListener('popstate', this._onChange, false); 19 | this._onChange(); 20 | } 21 | 22 | public prefix(path: string) { 23 | const baseEndsWithSlash = /\/$/.test(this._base); 24 | const pathStartsWithSlash = /^\//.test(path); 25 | if (baseEndsWithSlash && pathStartsWithSlash) { 26 | return this._base + path.slice(1); 27 | } else if (!baseEndsWithSlash && !pathStartsWithSlash) { 28 | return `${this._base}/${path}`; 29 | } else { 30 | return this._base + path; 31 | } 32 | } 33 | 34 | public set(path: string) { 35 | const value = ensureLeadingSlash(path); 36 | this._window.history.pushState({}, '', this.prefix(value)); 37 | this._onChange(); 38 | } 39 | 40 | public get current(): string { 41 | return this._current; 42 | } 43 | 44 | private _onChange = () => { 45 | const value = stripBase(this._base, this._window.location.pathname + this._window.location.search); 46 | if (this._current === value) { 47 | return; 48 | } 49 | this._current = value; 50 | this._onChangeFunction(this._current); 51 | }; 52 | } 53 | 54 | function stripBase(base: string, path: string): string { 55 | if (base === '/') { 56 | return path; 57 | } 58 | 59 | if (path.indexOf(base) === 0) { 60 | return ensureLeadingSlash(path.slice(base.length)); 61 | } else { 62 | return '/'; 63 | } 64 | } 65 | 66 | function ensureLeadingSlash(path: string): string { 67 | if (path[0] !== '/') { 68 | return `/${path}`; 69 | } 70 | return path; 71 | } 72 | 73 | export default StateHistory; 74 | -------------------------------------------------------------------------------- /src/interfaces.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Constructor, 3 | RegistryLabel, 4 | VNodeProperties, 5 | WidgetBaseInterface, 6 | WidgetProperties 7 | } from '@dojo/widget-core/interfaces'; 8 | import { WidgetBase } from '@dojo/widget-core/WidgetBase'; 9 | 10 | /** 11 | * Description of a registered route 12 | */ 13 | export interface Route { 14 | path: string; 15 | outlet: string; 16 | params: string[]; 17 | segments: (symbol | string)[]; 18 | children: Route[]; 19 | fullPath: string; 20 | fullParams: string[]; 21 | fullQueryParams: string[]; 22 | defaultParams: Params; 23 | onEnter?: OnEnter; 24 | onExit?: OnExit; 25 | } 26 | 27 | /** 28 | * Route configuration 29 | */ 30 | export interface RouteConfig { 31 | path: string; 32 | outlet: string; 33 | children?: RouteConfig[]; 34 | defaultParams?: Params; 35 | defaultRoute?: boolean; 36 | onEnter?: OnEnter; 37 | onExit?: OnExit; 38 | } 39 | 40 | /** 41 | * Route Params 42 | */ 43 | export interface Params { 44 | [index: string]: string; 45 | } 46 | 47 | /** 48 | * Options passed to the mapParams callback 49 | */ 50 | export interface MapParamsOptions { 51 | queryParams: Params; 52 | params: Params; 53 | type: MatchType; 54 | router: RouterInterface; 55 | } 56 | 57 | /** 58 | * Type of outlet matches 59 | */ 60 | export type MatchType = 'error' | 'index' | 'partial'; 61 | 62 | /** 63 | * Context stored for matched outlets 64 | */ 65 | export interface OutletContext { 66 | /** 67 | * The type of match for the outlet 68 | */ 69 | type: MatchType; 70 | 71 | /** 72 | * The params for the specific outlet 73 | */ 74 | params: Params; 75 | 76 | /** 77 | * The query params for the route 78 | */ 79 | queryParams: Params; 80 | 81 | /** 82 | * On enter for the route 83 | */ 84 | onEnter?: OnEnter; 85 | 86 | /** 87 | * On exit for the route 88 | */ 89 | onExit?: OnExit; 90 | } 91 | 92 | /** 93 | * Interface for Router 94 | */ 95 | export interface RouterInterface { 96 | /** 97 | * Generates a link from the outlet and the optional params 98 | */ 99 | link(outlet: string, params?: Params): string | undefined; 100 | 101 | /** 102 | * Sets the path on the underlying history manager 103 | */ 104 | setPath(path: string): void; 105 | 106 | /** 107 | * Returns the outlet context if matched 108 | */ 109 | getOutlet(outletId: string): OutletContext | undefined; 110 | 111 | /** 112 | * The current params for matched routes 113 | */ 114 | readonly currentParams: Params; 115 | } 116 | 117 | /** 118 | * Function for mapping params to properties 119 | */ 120 | export interface MapParams { 121 | (options: MapParamsOptions): any; 122 | } 123 | 124 | export interface OnEnter { 125 | (params: Params, type: MatchType): void; 126 | } 127 | 128 | export interface OnExit { 129 | (): void; 130 | } 131 | 132 | /** 133 | * Outlet options that can be configured 134 | */ 135 | export interface OutletOptions { 136 | key?: RegistryLabel; 137 | mapParams?: MapParams; 138 | } 139 | 140 | /** 141 | * Component type 142 | */ 143 | export type Component = Constructor | RegistryLabel; 144 | 145 | /** 146 | * Outlet component options 147 | */ 148 | export interface OutletComponents< 149 | W extends WidgetBaseInterface, 150 | I extends WidgetBaseInterface, 151 | E extends WidgetBaseInterface 152 | > { 153 | main?: Component; 154 | index?: Component; 155 | error?: Component; 156 | } 157 | 158 | /** 159 | * Type for Outlet 160 | */ 161 | export type Outlet< 162 | W extends WidgetBaseInterface, 163 | F extends WidgetBaseInterface, 164 | E extends WidgetBaseInterface 165 | > = Constructor< 166 | WidgetBase & Partial & Partial & WidgetProperties, null> 167 | >; 168 | 169 | /** 170 | * Properties for the Link widget 171 | */ 172 | export interface LinkProperties extends VNodeProperties { 173 | key?: string; 174 | isOutlet?: boolean; 175 | params?: Params; 176 | onClick?: (event: MouseEvent) => void; 177 | to: string; 178 | } 179 | 180 | /** 181 | * The `onChange` function signature 182 | */ 183 | export interface OnChangeFunction { 184 | (path: string): void; 185 | } 186 | 187 | /** 188 | * Options for a history provider 189 | */ 190 | export interface HistoryOptions { 191 | onChange: OnChangeFunction; 192 | window?: Window; 193 | base?: string; 194 | } 195 | 196 | /** 197 | * History Constructor 198 | */ 199 | export interface HistoryConstructor { 200 | new (options: HistoryOptions): History; 201 | } 202 | 203 | /** 204 | * History interface 205 | */ 206 | export interface History { 207 | /** 208 | * Sets the path on the history manager 209 | */ 210 | set(path: string): void; 211 | 212 | /** 213 | * Adds a prefix to the path if the history manager requires 214 | */ 215 | prefix(path: string): string; 216 | 217 | /** 218 | * Returns the current path 219 | */ 220 | readonly current: string; 221 | } 222 | 223 | export interface RouterOptions { 224 | window?: Window; 225 | base?: string; 226 | HistoryManager?: HistoryConstructor; 227 | } 228 | -------------------------------------------------------------------------------- /tests/functional/all.ts: -------------------------------------------------------------------------------- 1 | export const removeThis = 1; 2 | -------------------------------------------------------------------------------- /tests/run.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Intern suite 6 | 7 | 8 | 9 | Redirecting to Intern client 10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/support/sandbox.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dojo/routing/1e70fe6f8eeedb857d35dade1526378f40ddf3f2/tests/support/sandbox.html -------------------------------------------------------------------------------- /tests/unit/Link.ts: -------------------------------------------------------------------------------- 1 | const { beforeEach, afterEach, describe, it } = intern.getInterface('bdd'); 2 | const { assert } = intern.getPlugin('chai'); 3 | import { spy, SinonSpy } from 'sinon'; 4 | 5 | import { Registry } from '@dojo/widget-core/Registry'; 6 | import { Link } from './../../src/Link'; 7 | import { Router } from './../../src/Router'; 8 | import { MemoryHistory } from './../../src/history/MemoryHistory'; 9 | 10 | const registry = new Registry(); 11 | 12 | const router = new Router( 13 | [ 14 | { 15 | path: 'foo', 16 | outlet: 'foo' 17 | }, 18 | { 19 | path: 'foo/{foo}', 20 | outlet: 'foo2' 21 | } 22 | ], 23 | { HistoryManager: MemoryHistory } 24 | ); 25 | 26 | registry.defineInjector('router', () => () => router); 27 | 28 | let routerSetPathSpy: SinonSpy; 29 | 30 | function createMockEvent(isRightClick: boolean = false) { 31 | return { 32 | defaultPrevented: false, 33 | preventDefault() { 34 | this.defaultPrevented = true; 35 | }, 36 | button: isRightClick ? undefined : 0 37 | }; 38 | } 39 | 40 | describe('Link', () => { 41 | beforeEach(() => { 42 | routerSetPathSpy = spy(router, 'setPath'); 43 | }); 44 | 45 | afterEach(() => { 46 | routerSetPathSpy.restore(); 47 | }); 48 | 49 | it('Generate link component for basic outlet', () => { 50 | const link = new Link(); 51 | link.__setCoreProperties__({ bind: link, baseRegistry: registry }); 52 | link.__setProperties__({ to: 'foo', registry }); 53 | const dNode: any = link.__render__(); 54 | assert.strictEqual(dNode.tag, 'a'); 55 | assert.strictEqual(dNode.properties.href, 'foo'); 56 | }); 57 | 58 | it('Generate link component for outlet with specified params', () => { 59 | const link = new Link(); 60 | link.__setCoreProperties__({ bind: link, baseRegistry: registry }); 61 | link.__setProperties__({ to: 'foo2', params: { foo: 'foo' }, registry }); 62 | const dNode: any = link.__render__(); 63 | assert.strictEqual(dNode.tag, 'a'); 64 | assert.strictEqual(dNode.properties.href, 'foo/foo'); 65 | }); 66 | 67 | it('Generate link component for fixed href', () => { 68 | const link = new Link(); 69 | link.__setCoreProperties__({ bind: link, baseRegistry: registry }); 70 | link.__setProperties__({ to: '#foo/static', isOutlet: false, registry }); 71 | const dNode: any = link.__render__(); 72 | assert.strictEqual(dNode.tag, 'a'); 73 | assert.strictEqual(dNode.properties.href, '#foo/static'); 74 | }); 75 | 76 | it('Set router path on click', () => { 77 | const link = new Link(); 78 | link.__setCoreProperties__({ bind: link, baseRegistry: registry }); 79 | link.__setProperties__({ to: '#foo/static', isOutlet: false, registry }); 80 | const dNode: any = link.__render__(); 81 | assert.strictEqual(dNode.tag, 'a'); 82 | assert.strictEqual(dNode.properties.href, '#foo/static'); 83 | dNode.properties.onclick.call(link, createMockEvent()); 84 | assert.isTrue(routerSetPathSpy.calledWith('#foo/static')); 85 | }); 86 | 87 | it('Custom onClick handler can prevent default', () => { 88 | const link = new Link(); 89 | link.__setCoreProperties__({ bind: link, baseRegistry: registry }); 90 | link.__setProperties__({ 91 | to: 'foo', 92 | registry, 93 | onClick(event: MouseEvent) { 94 | event.preventDefault(); 95 | } 96 | }); 97 | const dNode: any = link.__render__(); 98 | assert.strictEqual(dNode.tag, 'a'); 99 | assert.strictEqual(dNode.properties.href, 'foo'); 100 | dNode.properties.onclick.call(link, createMockEvent()); 101 | assert.isTrue(routerSetPathSpy.notCalled); 102 | }); 103 | 104 | it('Does not set router path when target attribute is set', () => { 105 | const link = new Link(); 106 | link.__setCoreProperties__({ bind: link, baseRegistry: registry }); 107 | link.__setProperties__({ 108 | to: 'foo', 109 | registry, 110 | target: '_blank' 111 | }); 112 | const dNode: any = link.__render__(); 113 | assert.strictEqual(dNode.tag, 'a'); 114 | assert.strictEqual(dNode.properties.href, 'foo'); 115 | dNode.properties.onclick.call(link, createMockEvent()); 116 | assert.isTrue(routerSetPathSpy.notCalled); 117 | }); 118 | 119 | it('Does not set router path on right click', () => { 120 | const link = new Link(); 121 | link.__setCoreProperties__({ bind: link, baseRegistry: registry }); 122 | link.__setProperties__({ 123 | to: 'foo', 124 | registry 125 | }); 126 | const dNode: any = link.__render__(); 127 | assert.strictEqual(dNode.tag, 'a'); 128 | assert.strictEqual(dNode.properties.href, 'foo'); 129 | dNode.properties.onclick.call(link, createMockEvent(true)); 130 | assert.isTrue(routerSetPathSpy.notCalled); 131 | }); 132 | 133 | it('throw error if the injected router cannot be found with the router key', () => { 134 | const link = new Link(); 135 | link.__setCoreProperties__({ bind: link, baseRegistry: registry }); 136 | link.__setProperties__({ to: '#foo/static', isOutlet: false, routerKey: 'fake-key' }); 137 | try { 138 | link.__render__(); 139 | assert.fail('Should throw an error when the injected router cannot be found with the routerKey'); 140 | } catch (err) { 141 | // nothing to see here 142 | } 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /tests/unit/Outlet.ts: -------------------------------------------------------------------------------- 1 | const { beforeEach, describe, it } = intern.getInterface('bdd'); 2 | const { assert } = intern.getPlugin('chai'); 3 | import { stub } from 'sinon'; 4 | 5 | import { WidgetBase } from '@dojo/widget-core/WidgetBase'; 6 | import { w } from '@dojo/widget-core/d'; 7 | import { WNode } from '@dojo/widget-core/interfaces'; 8 | import { Router } from './../../src/Router'; 9 | import { MemoryHistory as HistoryManager } from './../../src/history/MemoryHistory'; 10 | import { Outlet, getProperties } from './../../src/Outlet'; 11 | 12 | class Widget extends WidgetBase { 13 | render() { 14 | return 'widget'; 15 | } 16 | } 17 | 18 | const configOnEnter = stub(); 19 | const configOnExit = stub(); 20 | 21 | const routeConfig = [ 22 | { 23 | path: '/foo', 24 | outlet: 'foo', 25 | children: [ 26 | { 27 | path: '/bar', 28 | outlet: 'bar' 29 | } 30 | ] 31 | }, 32 | { 33 | path: 'baz/{baz}', 34 | outlet: 'baz' 35 | } 36 | ]; 37 | 38 | describe('Outlet', () => { 39 | beforeEach(() => { 40 | configOnEnter.reset(); 41 | }); 42 | 43 | it('Should render the main component for index matches when no index component is set', () => { 44 | const router = new Router(routeConfig, { HistoryManager }); 45 | router.setPath('/foo'); 46 | const TestOutlet = Outlet(Widget, 'foo'); 47 | const outlet = new TestOutlet(); 48 | outlet.__setProperties__({ router } as any); 49 | const renderResult = outlet.__render__() as WNode; 50 | assert.strictEqual(renderResult.widgetConstructor, Widget); 51 | assert.deepEqual(renderResult.children, []); 52 | assert.deepEqual(renderResult.properties, {}); 53 | }); 54 | 55 | it('Should render the main component for partial matches', () => { 56 | const router = new Router(routeConfig, { HistoryManager }); 57 | router.setPath('/foo/bar'); 58 | const TestOutlet = Outlet(Widget, 'foo'); 59 | const outlet = new TestOutlet(); 60 | outlet.__setProperties__({ router } as any); 61 | const renderResult = outlet.__render__() as WNode; 62 | assert.strictEqual(renderResult.widgetConstructor, Widget); 63 | assert.deepEqual(renderResult.children, []); 64 | assert.deepEqual(renderResult.properties, {}); 65 | }); 66 | 67 | it('Should render the index component only for index matches', () => { 68 | const router = new Router(routeConfig, { HistoryManager }); 69 | router.setPath('/foo'); 70 | const TestOutlet = Outlet({ index: Widget }, 'foo'); 71 | const outlet = new TestOutlet(); 72 | outlet.__setProperties__({ router } as any); 73 | let renderResult = outlet.__render__() as WNode; 74 | assert.strictEqual(renderResult.widgetConstructor, Widget); 75 | assert.deepEqual(renderResult.children, []); 76 | assert.deepEqual(renderResult.properties, {}); 77 | router.setPath('/foo/bar'); 78 | renderResult = outlet.__render__() as WNode; 79 | assert.isNull(renderResult); 80 | }); 81 | 82 | it('Should render the error component only for error matches', () => { 83 | const router = new Router(routeConfig, { HistoryManager }); 84 | router.setPath('/foo/other'); 85 | const TestOutlet = Outlet({ error: Widget }, 'foo'); 86 | const outlet = new TestOutlet(); 87 | outlet.__setProperties__({ router } as any); 88 | let renderResult = outlet.__render__() as WNode; 89 | assert.strictEqual(renderResult.widgetConstructor, Widget); 90 | assert.deepEqual(renderResult.children, []); 91 | assert.deepEqual(renderResult.properties, {}); 92 | }); 93 | 94 | it('Should render the index component only for error matches when there is no error component', () => { 95 | const router = new Router(routeConfig, { HistoryManager }); 96 | router.setPath('/foo/other'); 97 | const TestOutlet = Outlet({ index: Widget }, 'foo'); 98 | const outlet = new TestOutlet(); 99 | outlet.__setProperties__({ router } as any); 100 | let renderResult = outlet.__render__() as WNode; 101 | assert.strictEqual(renderResult.widgetConstructor, Widget); 102 | assert.deepEqual(renderResult.children, []); 103 | assert.deepEqual(renderResult.properties, {}); 104 | }); 105 | 106 | it('Map params is called with params, queryParams, match type and router', () => { 107 | const router = new Router(routeConfig, { HistoryManager }); 108 | router.setPath('/baz/bazParam?bazQuery=true'); 109 | const mapParams = stub(); 110 | const TestOutlet = Outlet({ index: Widget }, 'baz', { mapParams }); 111 | const outlet = new TestOutlet(); 112 | outlet.__setProperties__({ router } as any); 113 | outlet.__render__() as WNode; 114 | assert.isTrue(mapParams.calledOnce); 115 | assert.isTrue( 116 | mapParams.calledWith({ 117 | params: { 118 | baz: 'bazParam' 119 | }, 120 | queryParams: { 121 | bazQuery: 'true' 122 | }, 123 | router, 124 | type: 'index' 125 | }) 126 | ); 127 | }); 128 | 129 | it('configuration onEnter called when the outlet is rendered', () => { 130 | const routeConfig = [ 131 | { 132 | path: '/foo', 133 | outlet: 'foo', 134 | children: [ 135 | { 136 | path: '/bar', 137 | outlet: 'bar' 138 | } 139 | ] 140 | }, 141 | { 142 | path: 'baz/{baz}', 143 | outlet: 'baz', 144 | onEnter: configOnEnter, 145 | onExit: configOnExit 146 | } 147 | ]; 148 | 149 | const router = new Router(routeConfig, { HistoryManager }); 150 | router.setPath('/baz/param'); 151 | const TestOutlet = Outlet({ index: Widget }, 'baz'); 152 | const outlet = new TestOutlet(); 153 | outlet.__setProperties__({ router } as any); 154 | outlet.__render__() as WNode; 155 | assert.isTrue(configOnEnter.calledOnce); 156 | router.setPath('/baz/bar'); 157 | outlet.__render__() as WNode; 158 | assert.isTrue(configOnEnter.calledTwice); 159 | router.setPath('/baz/baz'); 160 | outlet.__render__() as WNode; 161 | assert.isTrue(configOnEnter.calledThrice); 162 | }); 163 | 164 | it('configuration onEnter called when the outlet if params change', () => { 165 | class InnerWidget extends WidgetBase { 166 | render() { 167 | return 'inner'; 168 | } 169 | } 170 | const InnerOutlet = Outlet({ index: InnerWidget }, 'qux'); 171 | class OuterWidget extends WidgetBase { 172 | render() { 173 | return w(InnerOutlet, {}); 174 | } 175 | } 176 | const routeConfig = [ 177 | { 178 | path: '/foo', 179 | outlet: 'foo', 180 | children: [ 181 | { 182 | path: '/bar', 183 | outlet: 'bar' 184 | } 185 | ] 186 | }, 187 | { 188 | path: 'baz/{baz}', 189 | outlet: 'baz', 190 | onEnter: configOnEnter, 191 | onExit: configOnExit, 192 | children: [ 193 | { 194 | path: 'qux', 195 | outlet: 'qux' 196 | } 197 | ] 198 | } 199 | ]; 200 | 201 | const router = new Router(routeConfig, { HistoryManager }); 202 | router.setPath('/baz/param'); 203 | const TestOutlet = Outlet(OuterWidget, 'baz'); 204 | const outlet = new TestOutlet(); 205 | outlet.__setProperties__({ router } as any); 206 | outlet.__render__() as WNode; 207 | assert.isTrue(configOnEnter.calledOnce); 208 | router.setPath('/baz/bar'); 209 | outlet.__render__() as WNode; 210 | assert.isTrue(configOnEnter.calledTwice); 211 | router.setPath('/baz/bar/qux'); 212 | outlet.__render__() as WNode; 213 | assert.isTrue(configOnEnter.calledTwice); 214 | router.setPath('/baz/foo/qux'); 215 | outlet.__render__() as WNode; 216 | assert.isTrue(configOnEnter.calledThrice); 217 | }); 218 | 219 | it('configuration onExit called when the outlet is rendered', () => { 220 | const routeConfig = [ 221 | { 222 | path: '/foo', 223 | outlet: 'foo', 224 | onEnter: configOnEnter, 225 | onExit: configOnExit, 226 | children: [ 227 | { 228 | path: '/bar', 229 | outlet: 'bar' 230 | } 231 | ] 232 | }, 233 | { 234 | path: 'baz/{baz}', 235 | outlet: 'baz' 236 | } 237 | ]; 238 | 239 | const router = new Router(routeConfig, { HistoryManager }); 240 | router.setPath('/foo'); 241 | const TestOutlet = Outlet({ index: Widget }, 'foo'); 242 | const outlet = new TestOutlet(); 243 | outlet.__setProperties__({ router } as any); 244 | outlet.__render__() as WNode; 245 | assert.isTrue(configOnExit.notCalled); 246 | router.setPath('/foo/bar'); 247 | outlet.__render__() as WNode; 248 | assert.isTrue(configOnExit.calledOnce); 249 | router.setPath('/baz'); 250 | outlet.__render__() as WNode; 251 | assert.isTrue(configOnExit.calledOnce); 252 | router.setPath('/foo'); 253 | outlet.__render__() as WNode; 254 | assert.isTrue(configOnExit.calledOnce); 255 | }); 256 | 257 | it('getProperties returns the payload as router', () => { 258 | const router = new Router(routeConfig, { HistoryManager }); 259 | assert.deepEqual(getProperties(router, {}), { router }); 260 | }); 261 | }); 262 | -------------------------------------------------------------------------------- /tests/unit/Router.ts: -------------------------------------------------------------------------------- 1 | const { describe, it } = intern.getInterface('bdd'); 2 | const { assert } = intern.getPlugin('chai'); 3 | 4 | import { Router } from './../../src/Router'; 5 | import { MemoryHistory as HistoryManager } from './../../src/history/MemoryHistory'; 6 | 7 | const routeConfig = [ 8 | { 9 | path: '/', 10 | outlet: 'home' 11 | }, 12 | { 13 | path: '/foo', 14 | outlet: 'foo', 15 | children: [ 16 | { 17 | path: '/bar', 18 | outlet: 'bar' 19 | }, 20 | { 21 | path: '/{baz}/baz', 22 | outlet: 'baz', 23 | children: [ 24 | { 25 | path: '/{qux}/qux', 26 | outlet: 'qux' 27 | } 28 | ] 29 | } 30 | ] 31 | } 32 | ]; 33 | 34 | const routeConfigNoRoot = [ 35 | { 36 | path: '/foo', 37 | outlet: 'foo' 38 | } 39 | ]; 40 | 41 | const routeConfigDefaultRoute = [ 42 | { 43 | path: '/foo/{bar}', 44 | outlet: 'foo', 45 | defaultRoute: true, 46 | defaultParams: { 47 | bar: 'defaultBar' 48 | }, 49 | children: [ 50 | { 51 | path: 'bar/{foo}', 52 | outlet: 'bar', 53 | defaultParams: { 54 | foo: 'defaultFoo' 55 | } 56 | } 57 | ] 58 | } 59 | ]; 60 | 61 | const routeConfigDefaultRouteNoDefaultParams = [ 62 | { 63 | path: '/foo/{bar}', 64 | outlet: 'foo', 65 | defaultRoute: true 66 | } 67 | ]; 68 | 69 | const routeWithChildrenAndMultipleParams = [ 70 | { 71 | path: '/foo/{foo}', 72 | outlet: 'foo', 73 | children: [ 74 | { 75 | path: '/bar/{bar}', 76 | outlet: 'bar', 77 | children: [ 78 | { 79 | path: '/baz/{baz}', 80 | outlet: 'baz' 81 | } 82 | ] 83 | } 84 | ] 85 | } 86 | ]; 87 | 88 | const routeConfigWithParamsAndQueryParams = [ 89 | { 90 | path: '/foo/{foo}?{fooQuery}', 91 | outlet: 'foo', 92 | defaultParams: { 93 | foo: 'foo', 94 | fooQuery: 'fooQuery' 95 | }, 96 | children: [ 97 | { 98 | path: '/bar/{bar}?{barQuery}', 99 | outlet: 'bar', 100 | defaultParams: { 101 | bar: 'bar', 102 | barQuery: 'barQuery' 103 | } 104 | } 105 | ] 106 | } 107 | ]; 108 | 109 | describe('Router', () => { 110 | it('Navigates to current route if matches against a registered outlet', () => { 111 | const router = new Router(routeConfig, { HistoryManager }); 112 | const context = router.getOutlet('home'); 113 | assert.isOk(context); 114 | }); 115 | 116 | it('Navigates to default route if current route does not matches against a registered outlet', () => { 117 | const router = new Router(routeConfigDefaultRoute, { HistoryManager }); 118 | const context = router.getOutlet('foo'); 119 | assert.isOk(context); 120 | }); 121 | 122 | it('Navigates to global "errorOutlet" if current route does not match a registered outlet and no default route is configured', () => { 123 | const router = new Router(routeConfigNoRoot, { HistoryManager }); 124 | const context = router.getOutlet('errorOutlet'); 125 | assert.isOk(context); 126 | assert.deepEqual(context!.params, {}); 127 | assert.deepEqual(context!.queryParams, {}); 128 | assert.deepEqual(context!.type, 'error'); 129 | }); 130 | 131 | it('Should navigates to global "errorOutlet" if default route requires params but none have been provided', () => { 132 | const router = new Router(routeConfigDefaultRouteNoDefaultParams, { HistoryManager }); 133 | const fooContext = router.getOutlet('foo'); 134 | assert.isNotOk(fooContext); 135 | const errorContext = router.getOutlet('errorOutlet'); 136 | assert.isOk(errorContext); 137 | assert.deepEqual(errorContext!.params, {}); 138 | assert.deepEqual(errorContext!.queryParams, {}); 139 | assert.deepEqual(errorContext!.type, 'error'); 140 | }); 141 | 142 | it('Should register as an index match for an outlet that index matches the route', () => { 143 | const router = new Router(routeConfig, { HistoryManager }); 144 | router.setPath('/foo'); 145 | const context = router.getOutlet('foo'); 146 | assert.isOk(context); 147 | assert.deepEqual(context!.params, {}); 148 | assert.deepEqual(context!.queryParams, {}); 149 | assert.deepEqual(context!.type, 'index'); 150 | }); 151 | 152 | it('Should register as a partial match for an outlet that matches a section of the route', () => { 153 | const router = new Router(routeConfig, { HistoryManager }); 154 | router.setPath('/foo/bar'); 155 | const fooContext = router.getOutlet('foo'); 156 | assert.isOk(fooContext); 157 | assert.deepEqual(fooContext!.params, {}); 158 | assert.deepEqual(fooContext!.queryParams, {}); 159 | assert.deepEqual(fooContext!.type, 'partial'); 160 | const barContext = router.getOutlet('bar'); 161 | assert.isOk(barContext); 162 | assert.deepEqual(barContext!.params, {}); 163 | assert.deepEqual(barContext!.queryParams, {}); 164 | assert.deepEqual(barContext!.type, 'index'); 165 | }); 166 | 167 | it('Should register as a error match for an outlet that matches a section of the route with no further matching registered outlets', () => { 168 | const router = new Router(routeConfig, { HistoryManager }); 169 | router.setPath('/foo/unknown'); 170 | const fooContext = router.getOutlet('foo'); 171 | assert.isOk(fooContext); 172 | assert.deepEqual(fooContext!.params, {}); 173 | assert.deepEqual(fooContext!.queryParams, {}); 174 | assert.deepEqual(fooContext!.type, 'error'); 175 | const barContext = router.getOutlet('bar'); 176 | assert.isNotOk(barContext); 177 | }); 178 | 179 | it('Matches routes against outlets with params', () => { 180 | const router = new Router(routeConfig, { HistoryManager }); 181 | router.setPath('/foo/baz/baz'); 182 | const fooContext = router.getOutlet('foo'); 183 | assert.isOk(fooContext); 184 | assert.deepEqual(fooContext!.params, {}); 185 | assert.deepEqual(fooContext!.queryParams, {}); 186 | assert.deepEqual(fooContext!.type, 'partial'); 187 | const context = router.getOutlet('baz'); 188 | assert.isOk(context); 189 | assert.deepEqual(context!.params, { baz: 'baz' }); 190 | assert.deepEqual(context!.queryParams, {}); 191 | assert.deepEqual(context!.type, 'index'); 192 | }); 193 | 194 | it('Should return params from all matching outlets', () => { 195 | const router = new Router(routeConfig, { HistoryManager }); 196 | router.setPath('/foo/baz/baz/qux/qux?hello=world'); 197 | const fooContext = router.getOutlet('foo'); 198 | assert.isOk(fooContext); 199 | assert.deepEqual(fooContext!.params, {}); 200 | assert.deepEqual(fooContext!.queryParams, { hello: 'world' }); 201 | assert.deepEqual(fooContext!.type, 'partial'); 202 | const bazContext = router.getOutlet('baz'); 203 | assert.isOk(bazContext); 204 | assert.deepEqual(bazContext!.params, { baz: 'baz' }); 205 | assert.deepEqual(bazContext!.queryParams, { hello: 'world' }); 206 | assert.deepEqual(bazContext!.type, 'partial'); 207 | const quxContext = router.getOutlet('qux'); 208 | assert.isOk(quxContext); 209 | assert.deepEqual(quxContext!.params, { baz: 'baz', qux: 'qux' }); 210 | assert.deepEqual(quxContext!.queryParams, { hello: 'world' }); 211 | assert.deepEqual(quxContext!.type, 'index'); 212 | }); 213 | 214 | it('Should pass query params to all matched outlets', () => { 215 | const router = new Router(routeConfig, { HistoryManager }); 216 | router.setPath('/foo/bar?query=true'); 217 | const fooContext = router.getOutlet('foo'); 218 | assert.deepEqual(fooContext!.params, {}); 219 | assert.deepEqual(fooContext!.queryParams, { query: 'true' }); 220 | assert.deepEqual(fooContext!.type, 'partial'); 221 | const barContext = router.getOutlet('bar'); 222 | assert.deepEqual(barContext!.params, {}); 223 | assert.deepEqual(barContext!.queryParams, { query: 'true' }); 224 | assert.deepEqual(barContext!.type, 'index'); 225 | }); 226 | 227 | it('Should pass params and query params to all matched outlets', () => { 228 | const config = [ 229 | { 230 | path: 'view/{view}?{filter}', 231 | outlet: 'foo' 232 | } 233 | ]; 234 | const router = new Router(config, { HistoryManager }); 235 | router.setPath('/view/bar?filter=true'); 236 | const fooContext = router.getOutlet('foo'); 237 | assert.deepEqual(fooContext!.params, { view: 'bar' }); 238 | assert.deepEqual(fooContext!.queryParams, { filter: 'true' }); 239 | assert.deepEqual(fooContext!.type, 'index'); 240 | }); 241 | 242 | it('Should return all params for a route', () => { 243 | const router = new Router(routeWithChildrenAndMultipleParams, { HistoryManager }); 244 | router.setPath('/foo/foo/bar/bar/baz/baz'); 245 | assert.deepEqual(router.currentParams, { 246 | foo: 'foo', 247 | bar: 'bar', 248 | baz: 'baz' 249 | }); 250 | }); 251 | 252 | it('Should create link using current params', () => { 253 | const router = new Router(routeWithChildrenAndMultipleParams, { HistoryManager }); 254 | router.setPath('/foo/foo/bar/bar/baz/baz'); 255 | const link = router.link('baz'); 256 | assert.strictEqual(link, 'foo/foo/bar/bar/baz/baz'); 257 | }); 258 | 259 | it('Will not generate a link if params are not available', () => { 260 | const router = new Router(routeWithChildrenAndMultipleParams, { HistoryManager }); 261 | const link = router.link('baz'); 262 | assert.isUndefined(link); 263 | }); 264 | 265 | it('Should use params passed to generate link', () => { 266 | const router = new Router(routeWithChildrenAndMultipleParams, { HistoryManager }); 267 | router.setPath('/foo/foo/bar/bar/baz/baz'); 268 | const link = router.link('baz', { bar: 'bar1' }); 269 | assert.strictEqual(link, 'foo/foo/bar/bar1/baz/baz'); 270 | }); 271 | 272 | it('Should return undefined from link if there is a missing param', () => { 273 | const router = new Router(routeWithChildrenAndMultipleParams, { HistoryManager }); 274 | const link = router.link('baz', { bar: 'bar1' }); 275 | assert.isUndefined(link); 276 | }); 277 | 278 | it('Should fallback to default params if params are not passed and no matching current params', () => { 279 | const router = new Router(routeConfigDefaultRoute, { HistoryManager }); 280 | const link = router.link('foo'); 281 | assert.strictEqual(link, 'foo/defaultBar'); 282 | }); 283 | 284 | it('Should fallback to full routes default params to generate link', () => { 285 | const router = new Router(routeConfigDefaultRoute, { HistoryManager }); 286 | const link = router.link('bar'); 287 | assert.strictEqual(link, 'foo/defaultBar/bar/defaultFoo'); 288 | }); 289 | 290 | it('Should create link with params and query params with default params', () => { 291 | const router = new Router(routeConfigWithParamsAndQueryParams, { HistoryManager }); 292 | assert.strictEqual(router.link('foo'), 'foo/foo?fooQuery=fooQuery'); 293 | assert.strictEqual(router.link('bar'), 'foo/foo/bar/bar?fooQuery=fooQuery&barQuery=barQuery'); 294 | }); 295 | 296 | it('Should create link with params and query params with current params', () => { 297 | const router = new Router(routeConfigWithParamsAndQueryParams, { HistoryManager }); 298 | router.setPath('foo/bar/bar/foo?fooQuery=bar&barQuery=foo'); 299 | assert.strictEqual(router.link('foo'), 'foo/bar?fooQuery=bar'); 300 | assert.strictEqual(router.link('bar'), 'foo/bar/bar/foo?fooQuery=bar&barQuery=foo'); 301 | }); 302 | 303 | it('Should create link with params and query params with specified params', () => { 304 | const router = new Router(routeConfigWithParamsAndQueryParams, { HistoryManager }); 305 | assert.strictEqual(router.link('foo', { foo: 'qux', fooQuery: 'quxQuery' }), 'foo/qux?fooQuery=quxQuery'); 306 | assert.strictEqual( 307 | router.link('bar', { foo: 'qux', bar: 'baz', fooQuery: 'quxQuery', barQuery: 'bazQuery' }), 308 | 'foo/qux/bar/baz?fooQuery=quxQuery&barQuery=bazQuery' 309 | ); 310 | }); 311 | 312 | it('Cannot generate link for an unknown outlet', () => { 313 | const router = new Router(routeConfigDefaultRoute, { HistoryManager }); 314 | const link = router.link('unknown'); 315 | assert.isUndefined(link); 316 | }); 317 | 318 | it('Queues the first event for the first registered listener', () => { 319 | let initialNavEvent = false; 320 | const router = new Router(routeConfigDefaultRoute, { HistoryManager }); 321 | router.on('nav', (event) => { 322 | assert.strictEqual(event.type, 'nav'); 323 | assert.strictEqual(event.outlet, 'foo'); 324 | assert.deepEqual(event.context, { 325 | queryParams: {}, 326 | params: { bar: 'defaultBar' }, 327 | type: 'index', 328 | onEnter: undefined, 329 | onExit: undefined 330 | }); 331 | initialNavEvent = true; 332 | }); 333 | assert.isTrue(initialNavEvent); 334 | }); 335 | }); 336 | -------------------------------------------------------------------------------- /tests/unit/RouterInjector.ts: -------------------------------------------------------------------------------- 1 | const { suite, test } = intern.getInterface('tdd'); 2 | const { assert } = intern.getPlugin('chai'); 3 | import { spy } from 'sinon'; 4 | import { Registry } from '@dojo/widget-core/Registry'; 5 | import { registerRouterInjector } from '../../src/RouterInjector'; 6 | import { MemoryHistory } from '../../src/history/MemoryHistory'; 7 | 8 | suite('RouterInjector', () => { 9 | test('registerRouterInjector', () => { 10 | const registry = new Registry(); 11 | const router = registerRouterInjector([{ path: 'path', outlet: 'path' }], registry, { 12 | HistoryManager: MemoryHistory 13 | }); 14 | const { injector, invalidator } = registry.getInjector('router')!; 15 | assert.isNotNull(injector); 16 | assert.strictEqual(injector(), router); 17 | const invalidatorSpy = spy(invalidator, 'emit'); 18 | router.emit({ type: 'navstart' }); 19 | assert.isTrue(invalidatorSpy.calledOnce); 20 | }); 21 | 22 | test('registerRouterInjector with custom key', () => { 23 | const registry = new Registry(); 24 | const router = registerRouterInjector([{ path: 'path', outlet: 'path' }], registry, { 25 | HistoryManager: MemoryHistory, 26 | key: 'custom-key' 27 | }); 28 | const { injector, invalidator } = registry.getInjector('custom-key')!; 29 | const invalidatorSpy = spy(invalidator, 'emit'); 30 | assert.isNotNull(injector); 31 | const registeredRouter = injector(); 32 | assert.strictEqual(router, registeredRouter); 33 | router.emit({ type: 'navstart' }); 34 | assert.isTrue(invalidatorSpy.calledOnce); 35 | }); 36 | 37 | test('throws error if a second router is registered for the same key', () => { 38 | const registry = new Registry(); 39 | registerRouterInjector([{ path: 'path', outlet: 'path' }], registry, { HistoryManager: MemoryHistory }); 40 | assert.throws( 41 | () => { 42 | registerRouterInjector([{ path: 'path', outlet: 'path' }], registry, { HistoryManager: MemoryHistory }); 43 | }, 44 | Error, 45 | 'Router has already been defined' 46 | ); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/unit/all.ts: -------------------------------------------------------------------------------- 1 | import './Link'; 2 | import './Outlet'; 3 | import './Router'; 4 | import './RouterInjector'; 5 | import './history/all'; 6 | -------------------------------------------------------------------------------- /tests/unit/history/HashHistory.ts: -------------------------------------------------------------------------------- 1 | const { beforeEach, describe, it } = intern.getInterface('bdd'); 2 | const { assert } = intern.getPlugin('chai'); 3 | import { stub } from 'sinon'; 4 | 5 | import { HashHistory } from '../../../src/history/HashHistory'; 6 | 7 | class MockLocation { 8 | private _hash = '#current'; 9 | private _change: Function; 10 | 11 | constructor(change: Function) { 12 | this._change = change; 13 | } 14 | 15 | set hash(value: string) { 16 | const newHash = value[0] !== '#' ? `#${value}` : value; 17 | if (newHash !== this._hash) { 18 | this._hash = newHash; 19 | this._change(); 20 | } 21 | } 22 | 23 | get hash() { 24 | return this._hash; 25 | } 26 | } 27 | 28 | class MockWindow { 29 | private _onhashchange: undefined | Function; 30 | onhashchange = () => { 31 | this._onhashchange && this._onhashchange(); 32 | }; 33 | public location = new MockLocation(this.onhashchange); 34 | addEventListener = (type: string, listener: Function) => { 35 | this._onhashchange = listener; 36 | }; 37 | 38 | removeEventListener = stub(); 39 | } 40 | 41 | let mockWindow: any; 42 | 43 | describe('HashHistory', () => { 44 | beforeEach(() => { 45 | mockWindow = new MockWindow(); 46 | }); 47 | 48 | it('Calls onChange for current hash', () => { 49 | const onChange = stub(); 50 | const history = new HashHistory({ onChange, window: mockWindow }); 51 | assert.isTrue(onChange.calledWith('current')); 52 | assert.isTrue(onChange.calledOnce); 53 | assert.strictEqual(history.current, 'current'); 54 | }); 55 | 56 | it('Calls onChange on hash change', () => { 57 | const onChange = stub(); 58 | const history = new HashHistory({ onChange, window: mockWindow }); 59 | assert.isTrue(onChange.calledWith('current')); 60 | assert.isTrue(onChange.calledOnce); 61 | assert.strictEqual(history.current, 'current'); 62 | mockWindow.location.hash = 'new'; 63 | assert.isTrue(onChange.calledTwice); 64 | assert.isTrue(onChange.secondCall.calledWith('new')); 65 | assert.strictEqual(history.current, 'new'); 66 | }); 67 | 68 | it('Calls onChange on set', () => { 69 | const onChange = stub(); 70 | const history = new HashHistory({ onChange, window: mockWindow }); 71 | assert.isTrue(onChange.calledWith('current')); 72 | assert.isTrue(onChange.calledOnce); 73 | assert.strictEqual(history.current, 'current'); 74 | history.set('new'); 75 | assert.isTrue(onChange.calledTwice); 76 | assert.isTrue(onChange.secondCall.calledWith('new')); 77 | assert.strictEqual(history.current, 'new'); 78 | }); 79 | 80 | it('should add hash prefix', () => { 81 | const onChange = stub(); 82 | const history = new HashHistory({ onChange, window: mockWindow }); 83 | assert.strictEqual(history.prefix('hash'), '#hash'); 84 | }); 85 | 86 | it('should not add hash prefix if it already exists', () => { 87 | const onChange = stub(); 88 | const history = new HashHistory({ onChange, window: mockWindow }); 89 | assert.strictEqual(history.prefix('#hash'), '#hash'); 90 | }); 91 | 92 | it('destroying removes the hashchange event listener', () => { 93 | const onChange = stub(); 94 | const history = new HashHistory({ onChange, window: mockWindow }); 95 | assert.isTrue(mockWindow.removeEventListener.notCalled); 96 | history.destroy(); 97 | assert.isTrue(mockWindow.removeEventListener.calledOnce); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /tests/unit/history/MemoryHistory.ts: -------------------------------------------------------------------------------- 1 | const { describe, it } = intern.getInterface('bdd'); 2 | const { assert } = intern.getPlugin('chai'); 3 | import { stub } from 'sinon'; 4 | 5 | import { MemoryHistory } from '../../../src/history/MemoryHistory'; 6 | 7 | describe('MemoryHistory', () => { 8 | it('Calls onChange on set', () => { 9 | const onChange = stub(); 10 | const history = new MemoryHistory({ onChange }); 11 | assert.isTrue(onChange.calledWith('/')); 12 | assert.isTrue(onChange.calledOnce); 13 | assert.strictEqual(history.current, '/'); 14 | history.set('new'); 15 | assert.isTrue(onChange.calledTwice); 16 | assert.isTrue(onChange.secondCall.calledWith('new')); 17 | assert.strictEqual(history.current, 'new'); 18 | }); 19 | 20 | it('Does not call onChange on set if paths match', () => { 21 | const onChange = stub(); 22 | const history = new MemoryHistory({ onChange }); 23 | assert.isTrue(onChange.calledWith('/')); 24 | assert.isTrue(onChange.calledOnce); 25 | assert.strictEqual(history.current, '/'); 26 | history.set('/'); 27 | assert.isTrue(onChange.calledOnce); 28 | }); 29 | 30 | it('should not add any prefix', () => { 31 | const onChange = stub(); 32 | const history = new MemoryHistory({ onChange }); 33 | assert.strictEqual(history.prefix('hash'), 'hash'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/unit/history/StateHistory.ts: -------------------------------------------------------------------------------- 1 | const { afterEach, beforeEach, describe, it } = intern.getInterface('bdd'); 2 | const { assert } = intern.getPlugin('chai'); 3 | import { stub } from 'sinon'; 4 | 5 | import { StateHistory } from '../../../src/history/StateHistory'; 6 | 7 | const onChange = stub(); 8 | 9 | describe('StateHistory', () => { 10 | let sandbox: HTMLIFrameElement; 11 | beforeEach(async () => { 12 | sandbox = document.createElement('iframe'); 13 | sandbox.src = '../../tests/support/sandbox.html'; 14 | document.body.appendChild(sandbox); 15 | return new Promise((resolve) => { 16 | sandbox.addEventListener('load', function() { 17 | resolve(); 18 | }); 19 | }); 20 | }); 21 | 22 | afterEach(() => { 23 | document.body.removeChild(sandbox); 24 | sandbox = null; 25 | onChange.reset(); 26 | }); 27 | 28 | it('initializes current path to current location', () => { 29 | sandbox.contentWindow.history.pushState({}, '', '/foo?bar'); 30 | assert.equal(new StateHistory({ onChange, window: sandbox.contentWindow }).current, '/foo?bar'); 31 | }); 32 | 33 | it('location defers to the global object', () => { 34 | assert.equal(new StateHistory({ onChange }).current, window.location.pathname + window.location.search); 35 | }); 36 | 37 | it('prefixes path with leading slash if necessary', () => { 38 | const history = new StateHistory({ onChange, window: sandbox.contentWindow }); 39 | assert.equal(history.prefix('/foo'), '/foo'); 40 | assert.equal(history.prefix('foo'), '/foo'); 41 | }); 42 | 43 | it('update path', () => { 44 | const history = new StateHistory({ onChange, window: sandbox.contentWindow }); 45 | history.set('/foo'); 46 | assert.equal(history.current, '/foo'); 47 | assert.equal(sandbox.contentWindow.location.pathname, '/foo'); 48 | }); 49 | 50 | it('update path, adds leading slash if necessary', () => { 51 | const history = new StateHistory({ onChange, window: sandbox.contentWindow }); 52 | history.set('foo'); 53 | assert.equal(history.current, '/foo'); 54 | assert.equal(sandbox.contentWindow.location.pathname, '/foo'); 55 | }); 56 | 57 | it('emits change when path is updated', () => { 58 | const history = new StateHistory({ onChange, window: sandbox.contentWindow }); 59 | history.set('/foo'); 60 | assert.deepEqual(onChange.firstCall.args, ['/foo']); 61 | }); 62 | 63 | it('does not emit change if path is set to the current value', () => { 64 | sandbox.contentWindow.history.pushState({}, '', '/foo'); 65 | const history = new StateHistory({ onChange, window: sandbox.contentWindow }); 66 | history.set('/foo'); 67 | assert.isTrue(onChange.notCalled); 68 | }); 69 | 70 | describe('with base', () => { 71 | it('throws if base contains #', () => { 72 | assert.throws( 73 | () => { 74 | new StateHistory({ onChange, base: '/foo#bar', window }); 75 | }, 76 | TypeError, 77 | "base must not contain '#' or '?'" 78 | ); 79 | }); 80 | 81 | it('throws if base contains ?', () => { 82 | assert.throws( 83 | () => { 84 | new StateHistory({ onChange, base: '/foo?bar', window }); 85 | }, 86 | TypeError, 87 | "base must not contain '#' or '?'" 88 | ); 89 | }); 90 | 91 | it('initializes current path, taking out the base, with trailing slash', () => { 92 | sandbox.contentWindow.history.pushState({}, '', '/foo/bar?baz'); 93 | assert.equal( 94 | new StateHistory({ onChange, base: '/foo/', window: sandbox.contentWindow }).current, 95 | '/bar?baz' 96 | ); 97 | }); 98 | 99 | it('initializes current path, taking out the base, without trailing slash', () => { 100 | sandbox.contentWindow.history.pushState({}, '', '/foo/bar?baz'); 101 | assert.equal( 102 | new StateHistory({ onChange, base: '/foo', window: sandbox.contentWindow }).current, 103 | '/bar?baz' 104 | ); 105 | }); 106 | 107 | it("initializes current path to / if it's not a base suffix", () => { 108 | sandbox.contentWindow.history.pushState({}, '', '/foo/bar?baz'); 109 | assert.equal(new StateHistory({ onChange, base: '/thud/', window: sandbox.contentWindow }).current, '/'); 110 | }); 111 | 112 | it('#prefix prefixes path with the base (with trailing slash)', () => { 113 | const history = new StateHistory({ onChange, base: '/foo/', window: sandbox.contentWindow }); 114 | assert.equal(history.prefix('/bar'), '/foo/bar'); 115 | assert.equal(history.prefix('bar'), '/foo/bar'); 116 | }); 117 | 118 | it('#prefix prefixes path with the base (without trailing slash)', () => { 119 | const history = new StateHistory({ onChange, base: '/foo', window: sandbox.contentWindow }); 120 | assert.equal(history.prefix('/bar'), '/foo/bar'); 121 | assert.equal(history.prefix('bar'), '/foo/bar'); 122 | }); 123 | 124 | it('#set expands the path with the base when pushing state, with trailing slash', () => { 125 | const history = new StateHistory({ onChange, base: '/foo/', window: sandbox.contentWindow }); 126 | history.set('/bar'); 127 | assert.equal(history.current, '/bar'); 128 | assert.equal(sandbox.contentWindow.location.pathname, '/foo/bar'); 129 | 130 | history.set('baz'); 131 | assert.equal(history.current, '/baz'); 132 | assert.equal(sandbox.contentWindow.location.pathname, '/foo/baz'); 133 | }); 134 | 135 | it('#set expands the path with the base when pushing state, without trailing slash', () => { 136 | const history = new StateHistory({ onChange, base: '/foo', window: sandbox.contentWindow }); 137 | history.set('/bar'); 138 | assert.equal(history.current, '/bar'); 139 | assert.equal(sandbox.contentWindow.location.pathname, '/foo/bar'); 140 | 141 | history.set('baz'); 142 | assert.equal(history.current, '/baz'); 143 | assert.equal(sandbox.contentWindow.location.pathname, '/foo/baz'); 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /tests/unit/history/all.ts: -------------------------------------------------------------------------------- 1 | import './MemoryHistory'; 2 | import './HashHistory'; 3 | import './StateHistory'; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": false, 4 | "downlevelIteration": true, 5 | "experimentalDecorators": true, 6 | "importHelpers": true, 7 | "lib": [ 8 | "dom", 9 | "es5", 10 | "es2015.promise", 11 | "es2015.iterable", 12 | "es2015.promise", 13 | "es2015.symbol", 14 | "es2015.symbol.wellknown" 15 | ], 16 | "module": "umd", 17 | "moduleResolution": "node", 18 | "noUnusedLocals": true, 19 | "outDir": "_build/", 20 | "removeComments": false, 21 | "sourceMap": true, 22 | "strict": true, 23 | "target": "es5", 24 | "types": [ "intern" ] 25 | }, 26 | "include": [ 27 | "./src/**/*.ts", 28 | "./tests/**/*.ts", 29 | "./examples/**/*.ts" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "align": false, 4 | "ban": [], 5 | "class-name": true, 6 | "comment-format": [ true, "check-space" ], 7 | "curly": true, 8 | "eofline": true, 9 | "forin": false, 10 | "indent": [ true, "tabs" ], 11 | "interface-name": [ true, "never-prefix" ], 12 | "jsdoc-format": true, 13 | "label-position": true, 14 | "max-line-length": 120, 15 | "member-access": false, 16 | "member-ordering": false, 17 | "no-any": false, 18 | "no-arg": true, 19 | "no-bitwise": false, 20 | "no-consecutive-blank-lines": true, 21 | "no-console": false, 22 | "no-construct": false, 23 | "no-debugger": true, 24 | "no-duplicate-variable": true, 25 | "no-empty": false, 26 | "no-eval": true, 27 | "no-inferrable-types": [ true, "ignore-params" ], 28 | "no-shadowed-variable": false, 29 | "no-string-literal": false, 30 | "no-switch-case-fall-through": false, 31 | "no-trailing-whitespace": true, 32 | "no-unused-expression": false, 33 | "no-use-before-declare": false, 34 | "no-var-keyword": true, 35 | "no-var-requires": false, 36 | "object-literal-sort-keys": false, 37 | "one-line": [ true, "check-whitespace" ], 38 | "radix": true, 39 | "trailing-comma": [ true, { 40 | "multiline": "never", 41 | "singleline": "never" 42 | } ], 43 | "triple-equals": [ true, "allow-null-check" ], 44 | "typedef": false, 45 | "typedef-whitespace": [ true, { 46 | "call-signature": "nospace", 47 | "index-signature": "nospace", 48 | "parameter": "nospace", 49 | "property-declaration": "nospace", 50 | "variable-declaration": "nospace" 51 | }, { 52 | "call-signature": "onespace", 53 | "index-signature": "onespace", 54 | "parameter": "onespace", 55 | "property-declaration": "onespace", 56 | "variable-declaration": "onespace" 57 | } ], 58 | "variable-name": [ true, "check-format", "allow-leading-underscore", "ban-keywords", "allow-pascal-case" ] 59 | } 60 | } 61 | --------------------------------------------------------------------------------