├── addon ├── .gitkeep ├── -private │ ├── utils.ts │ ├── key-generator.ts │ ├── actions │ │ ├── actions.ts │ │ └── types.ts │ ├── route-reducer.ts │ ├── routeable.ts │ ├── routers │ │ ├── tab-router.ts │ │ ├── base-router.ts │ │ ├── switch-router.ts │ │ └── stack-router.ts │ ├── mounted-router.ts │ ├── mounted-node.ts │ ├── navigator-route.ts │ └── utils │ │ └── state.ts ├── components │ ├── ecr-switch.hbs │ ├── ecr-app-container.hbs │ ├── ecr-header.hbs │ ├── ecr-stack.hbs │ ├── ecr-router-component.ts │ ├── ecr-switch.ts │ ├── ecr-header.ts │ └── ecr-stack.ts ├── actions.ts ├── services │ └── navigator-route-resolver.ts └── index.ts ├── vendor └── .gitkeep ├── tests ├── unit │ ├── .gitkeep │ ├── routers │ │ ├── helpers.ts │ │ ├── switch-router-test.ts │ │ ├── tab-router-test.ts │ │ └── stack-router-test.ts │ └── mounted-router-test.ts ├── integration │ └── .gitkeep ├── dummy │ ├── app │ │ ├── helpers │ │ │ ├── .gitkeep │ │ │ └── json-stringify.js │ │ ├── models │ │ │ └── .gitkeep │ │ ├── routes │ │ │ └── .gitkeep │ │ ├── components │ │ │ ├── .gitkeep │ │ │ ├── nested-a.hbs │ │ │ ├── enter-email.hbs │ │ │ ├── frame-root.hbs │ │ │ ├── frame-tweet.hbs │ │ │ ├── terms-of-service.hbs │ │ │ └── no-header.hbs │ │ ├── controllers │ │ │ ├── .gitkeep │ │ │ └── application.ts │ │ ├── resolver.ts │ │ ├── services │ │ │ └── navigator-route-resolver.js │ │ ├── navigator-routes │ │ │ ├── nested-a.ts │ │ │ ├── enter-email.ts │ │ │ ├── frame-root.ts │ │ │ ├── terms-of-service.ts │ │ │ └── frame-tweet.ts │ │ ├── router.js │ │ ├── app.ts │ │ ├── config │ │ │ └── environment.d.ts │ │ ├── index.html │ │ ├── styles │ │ │ └── app.css │ │ └── templates │ │ │ └── application.hbs │ ├── public │ │ ├── robots.txt │ │ └── crossdomain.xml │ └── config │ │ ├── targets.js │ │ └── environment.js ├── helpers │ ├── destroy-app.js │ ├── resolver.js │ ├── start-app.js │ └── module-for-acceptance.js ├── test-helper.ts ├── acceptance │ └── basic-test.ts └── index.html ├── types ├── dummy │ └── index.d.ts └── global.d.ts ├── .watchmanconfig ├── app ├── components │ ├── ecr-header.js │ ├── ecr-stack.js │ └── ecr-switch.js └── navigator-routes │ └── basic.js ├── .template-lintrc.js ├── index.js ├── .vscode └── settings.json ├── config ├── environment.js ├── deploy.js └── ember-try.js ├── .eslintrc.js ├── .ember-cli ├── .editorconfig ├── .eslintignore ├── .gitignore ├── .npmignore ├── testem.js ├── ember-cli-build.js ├── tsconfig.json ├── LICENSE.md ├── package.json ├── .github └── workflows │ └── ci.yml └── README.md /addon/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /types/dummy/index.d.ts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/dummy/app/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /tests/dummy/app/components/nested-a.hbs: -------------------------------------------------------------------------------- 1 |

I am nested-a

2 | -------------------------------------------------------------------------------- /tests/dummy/app/components/enter-email.hbs: -------------------------------------------------------------------------------- 1 |

Login

2 |

Enter Email

-------------------------------------------------------------------------------- /tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /app/components/ecr-header.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-navigator/components/ecr-header'; 2 | -------------------------------------------------------------------------------- /app/components/ecr-stack.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-navigator/components/ecr-stack'; 2 | -------------------------------------------------------------------------------- /app/components/ecr-switch.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-navigator/components/ecr-switch'; 2 | -------------------------------------------------------------------------------- /tests/dummy/app/components/frame-root.hbs: -------------------------------------------------------------------------------- 1 |

Root Cellar

2 |

I am frame-root

3 | -------------------------------------------------------------------------------- /.template-lintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'recommended', 5 | }; 6 | -------------------------------------------------------------------------------- /addon/-private/utils.ts: -------------------------------------------------------------------------------- 1 | import { guidFor } from '@ember/object/internals'; 2 | export { guidFor }; 3 | -------------------------------------------------------------------------------- /tests/dummy/app/components/frame-tweet.hbs: -------------------------------------------------------------------------------- 1 |

Tweet {{@route.tweetId}}

2 | 3 |

Tweet content.

-------------------------------------------------------------------------------- /tests/dummy/app/resolver.ts: -------------------------------------------------------------------------------- 1 | import Resolver from 'ember-resolver'; 2 | 3 | export default Resolver; 4 | -------------------------------------------------------------------------------- /app/navigator-routes/basic.js: -------------------------------------------------------------------------------- 1 | import { NavigatorRoute } from 'ember-navigator'; 2 | export default NavigatorRoute; 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = { 5 | name: 'ember-navigator', 6 | }; 7 | -------------------------------------------------------------------------------- /tests/dummy/app/services/navigator-route-resolver.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-navigator/services/navigator-route-resolver'; 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.insertSpaces": true, 4 | "editor.detectIndentation": false 5 | } -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = function (/* environment, appConfig */) {}; 5 | -------------------------------------------------------------------------------- /tests/dummy/app/components/terms-of-service.hbs: -------------------------------------------------------------------------------- 1 |

Terms of service

2 |

3 | Do you agree to our Terms of Service? 4 |

5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { configs } = require('@nullvoxpopuli/eslint-configs'); 4 | 5 | module.exports = configs.ember(); 6 | -------------------------------------------------------------------------------- /tests/dummy/app/components/no-header.hbs: -------------------------------------------------------------------------------- 1 |

2 | I'm a route without a header because 3 | I am not nested in a stackrouter that has a header. 4 |

5 | -------------------------------------------------------------------------------- /tests/helpers/destroy-app.js: -------------------------------------------------------------------------------- 1 | import { run } from '@ember/runloop'; 2 | 3 | export default function destroyApp(application) { 4 | run(application, 'destroy'); 5 | } 6 | -------------------------------------------------------------------------------- /addon/components/ecr-switch.hbs: -------------------------------------------------------------------------------- 1 | {{#each this.currentNodes key="key" as |childNode|}} 2 | {{component childNode.componentName node=childNode route=childNode.route}} 3 | {{/each}} 4 | -------------------------------------------------------------------------------- /tests/dummy/config/targets.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | browsers: ['ie 9', 'last 1 Chrome versions', 'last 1 Firefox versions', 'last 1 Safari versions'], 4 | }; 5 | -------------------------------------------------------------------------------- /addon/components/ecr-app-container.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{component this.mountedRouter.state.componentName 3 | mountedRouter=this.mountedRouter 4 | state=this.mountedRouter.state}} 5 |
-------------------------------------------------------------------------------- /tests/dummy/app/navigator-routes/nested-a.ts: -------------------------------------------------------------------------------- 1 | import { NavigatorRoute } from 'ember-navigator'; 2 | 3 | export default class FrameRootRoute extends NavigatorRoute { 4 | header = { 5 | title: 'Nested Route', 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /tests/dummy/app/navigator-routes/enter-email.ts: -------------------------------------------------------------------------------- 1 | import { NavigatorRoute } from 'ember-navigator'; 2 | 3 | export default class EnterEmailRoute extends NavigatorRoute { 4 | header = { 5 | title: 'Enter Email', 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /tests/dummy/app/navigator-routes/frame-root.ts: -------------------------------------------------------------------------------- 1 | import { NavigatorRoute } from 'ember-navigator'; 2 | 3 | export default class FrameRootRoute extends NavigatorRoute { 4 | header = { 5 | title: 'Root Header Title', 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /tests/dummy/app/navigator-routes/terms-of-service.ts: -------------------------------------------------------------------------------- 1 | import { NavigatorRoute } from 'ember-navigator'; 2 | 3 | export default class FrameRootRoute extends NavigatorRoute { 4 | header = { 5 | title: 'Terms of Service', 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /types/global.d.ts: -------------------------------------------------------------------------------- 1 | // Types for compiled templates 2 | declare module 'ember-navigator/templates/*' { 3 | import { TemplateFactory } from 'htmlbars-inline-precompile'; 4 | const tmpl: TemplateFactory; 5 | export default tmpl; 6 | } 7 | -------------------------------------------------------------------------------- /addon/components/ecr-header.hbs: -------------------------------------------------------------------------------- 1 |
2 | 8 | 9 | {{this.headerConfig.title}} 10 |
-------------------------------------------------------------------------------- /tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | 3 | import config from './config/environment'; 4 | 5 | class Router extends EmberRouter { 6 | location = config.locationType; 7 | rootURL = config.rootURL; 8 | } 9 | 10 | export default Router; 11 | -------------------------------------------------------------------------------- /addon/actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | back, 3 | batch, 4 | init, 5 | navigate, 6 | pop, 7 | popToTop, 8 | push, 9 | replace, 10 | reset, 11 | setParams, 12 | } from './-private/actions/actions'; 13 | 14 | export { back, batch, init, navigate, pop, popToTop, push, replace, reset, setParams }; 15 | -------------------------------------------------------------------------------- /addon/-private/key-generator.ts: -------------------------------------------------------------------------------- 1 | let uniqueBaseId = `id-${Date.now()}`; 2 | let uuidCount = 0; 3 | 4 | export function _TESTING_ONLY_normalize_keys(): void { 5 | uniqueBaseId = `id`; 6 | uuidCount = 0; 7 | } 8 | 9 | export function generateKey(): string { 10 | return `${uniqueBaseId}-${uuidCount++}`; 11 | } 12 | -------------------------------------------------------------------------------- /tests/dummy/app/navigator-routes/frame-tweet.ts: -------------------------------------------------------------------------------- 1 | import { NavigatorRoute } from 'ember-navigator'; 2 | 3 | export default class FrameTweetRoute extends NavigatorRoute { 4 | header = { 5 | title: 'Tweet omg2', 6 | }; 7 | get tweetId() { 8 | return this.node.routeableState.params.tweet_id; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /tests/helpers/resolver.js: -------------------------------------------------------------------------------- 1 | import config from '../../config/environment'; 2 | import Resolver from '../../resolver'; 3 | 4 | const resolver = Resolver.create(); 5 | 6 | resolver.namespace = { 7 | modulePrefix: config.modulePrefix, 8 | podModulePrefix: config.podModulePrefix, 9 | }; 10 | 11 | export default resolver; 12 | -------------------------------------------------------------------------------- /addon/components/ecr-stack.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if this.showHeader}} 3 | {{component this.headerComponentName node=this.node route=this.node.route}} 4 | {{/if}} 5 | 6 | {{#each this.currentNodes key="key" as |childNode|}} 7 | {{component childNode.componentName node=childNode route=childNode.route}} 8 | {{/each}} 9 |
10 | -------------------------------------------------------------------------------- /tests/dummy/app/helpers/json-stringify.js: -------------------------------------------------------------------------------- 1 | import { helper } from '@ember/component/helper'; 2 | 3 | export function jsonStringify(params, hash) { 4 | if (hash.pretty) { 5 | return JSON.stringify(params[0], null, 2); 6 | } else { 7 | return JSON.stringify(params[0]); 8 | } 9 | } 10 | 11 | export default helper(jsonStringify); 12 | -------------------------------------------------------------------------------- /tests/test-helper.ts: -------------------------------------------------------------------------------- 1 | import 'qunit-dom'; 2 | 3 | import { setApplication } from '@ember/test-helpers'; 4 | import * as QUnit from 'qunit'; 5 | import { setup } from 'qunit-dom'; 6 | import { start } from 'ember-qunit'; 7 | 8 | import Application from 'dummy/app'; 9 | import config from 'dummy/config/environment'; 10 | 11 | setup(QUnit.assert); 12 | 13 | setApplication(Application.create(config.APP)); 14 | 15 | start(); 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.hbs] 17 | insert_final_newline = false 18 | 19 | [*.{diff,md}] 20 | trim_trailing_whitespace = false 21 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | .*/ 17 | .eslintcache 18 | 19 | # ember-try 20 | /.node_modules.ember-try/ 21 | /bower.json.ember-try 22 | /package.json.ember-try 23 | 24 | # snippets 25 | /tests/dummy/snippets/ 26 | 27 | # jsdoc assets 28 | /tests/dummy/public/ 29 | -------------------------------------------------------------------------------- /tests/dummy/app/app.ts: -------------------------------------------------------------------------------- 1 | import Application from '@ember/application'; 2 | 3 | import loadInitializers from 'ember-load-initializers'; 4 | 5 | import config from './config/environment'; 6 | import Resolver from './resolver'; 7 | 8 | class App extends Application { 9 | modulePrefix = config.modulePrefix; 10 | podModulePrefix = config.podModulePrefix; 11 | Resolver = Resolver; 12 | } 13 | 14 | loadInitializers(App, config.modulePrefix); 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | 7 | # dependencies 8 | /node_modules 9 | /bower_components 10 | 11 | # misc 12 | /.sass-cache 13 | /connect.lock 14 | /coverage/* 15 | /libpeerconnection.log 16 | npm-debug.log* 17 | yarn-error.log 18 | testem.log 19 | 20 | # ember-try 21 | .node_modules.ember-try/ 22 | bower.json.ember-try 23 | package.json.ember-try 24 | 25 | TODO.md 26 | 27 | /.eslintcache 28 | -------------------------------------------------------------------------------- /addon/components/ecr-router-component.ts: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | 3 | import type { MountedNode } from 'ember-navigator/-private/mounted-node'; 4 | 5 | interface Args { 6 | node: MountedNode; 7 | } 8 | 9 | export default abstract class EcrRouterComponent extends Component { 10 | abstract get currentNodes(): MountedNode[]; 11 | 12 | get node() { 13 | return this.args.node; 14 | } 15 | 16 | get route() { 17 | return this.node.route; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/dummy/app/config/environment.d.ts: -------------------------------------------------------------------------------- 1 | export default config; 2 | 3 | /** 4 | * Type declarations for 5 | * import config from './config/environment' 6 | * 7 | * For now these need to be managed by the developer 8 | * since different ember addons can materialize new entries. 9 | */ 10 | declare const config: { 11 | environment: any; 12 | modulePrefix: string; 13 | podModulePrefix: string; 14 | locationType: string; 15 | rootURL: string; 16 | APP: Record; 17 | }; 18 | -------------------------------------------------------------------------------- /addon/components/ecr-switch.ts: -------------------------------------------------------------------------------- 1 | import EcrRouterComponent from './ecr-router-component'; 2 | 3 | import type { RouterState } from 'ember-navigator/-private/routeable'; 4 | 5 | export default class EcrSwitch extends EcrRouterComponent { 6 | state?: RouterState; 7 | 8 | get currentNodes() { 9 | let node = this.args.node; 10 | let routerState = node.routeableState as RouterState; 11 | let activeChild = routerState.routes[routerState.index]; 12 | let activeChildNode = node.childNodes[activeChild.key]; 13 | 14 | return [activeChildNode]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/helpers/start-app.js: -------------------------------------------------------------------------------- 1 | import { merge } from '@ember/polyfills'; 2 | import { run } from '@ember/runloop'; 3 | 4 | import Application from '../../app'; 5 | import config from '../../config/environment'; 6 | 7 | export default function startApp(attrs) { 8 | let attributes = merge({}, config.APP); 9 | attributes = merge(attributes, attrs); // use defaults, but you can override; 10 | 11 | return run(() => { 12 | let application = Application.create(attributes); 13 | application.setupForTesting(); 14 | application.injectTestHelpers(); 15 | return application; 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist/ 3 | /tmp/ 4 | 5 | # dependencies 6 | /bower_components/ 7 | 8 | # misc 9 | /.bowerrc 10 | /.editorconfig 11 | /.ember-cli 12 | /.env* 13 | /.eslintcache 14 | /.eslintignore 15 | /.eslintrc.js 16 | /.git/ 17 | /.github/ 18 | /.gitignore 19 | /.prettierignore 20 | /.prettierrc.js 21 | /.template-lintrc.js 22 | /.travis.yml 23 | /.watchmanconfig 24 | /bower.json 25 | /config/ember-try.js 26 | /CONTRIBUTING.md 27 | /ember-cli-build.js 28 | /testem.js 29 | /tests/ 30 | /yarn-error.log 31 | /yarn.lock 32 | .gitkeep 33 | 34 | # ember-try 35 | /.node_modules.ember-try/ 36 | /bower.json.ember-try 37 | /package.json.ember-try 38 | -------------------------------------------------------------------------------- /tests/dummy/public/crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /addon/components/ecr-header.ts: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import { action } from '@ember/object'; 3 | 4 | import type { MountedNode } from 'ember-navigator/-private/mounted-node'; 5 | import type NavigatorRoute from 'ember-navigator/-private/navigator-route'; 6 | 7 | interface Args { 8 | node: MountedNode; 9 | route: NavigatorRoute; 10 | } 11 | 12 | export default class EcrHeader extends Component { 13 | get headerConfig() { 14 | return this.args.node.getHeaderConfig(); 15 | } 16 | 17 | get route() { 18 | return this.args.node.route; 19 | } 20 | 21 | @action 22 | leftButton() { 23 | this.args.route.pop(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/helpers/module-for-acceptance.js: -------------------------------------------------------------------------------- 1 | import { module } from 'qunit'; 2 | 3 | import { resolve } from 'rsvp'; 4 | 5 | import destroyApp from '../helpers/destroy-app'; 6 | import startApp from '../helpers/start-app'; 7 | 8 | export default function (name, options = {}) { 9 | module(name, { 10 | beforeEach() { 11 | this.application = startApp(); 12 | 13 | if (options.beforeEach) { 14 | return options.beforeEach.apply(this, arguments); 15 | } 16 | }, 17 | 18 | afterEach() { 19 | let afterEach = options.afterEach && options.afterEach.apply(this, arguments); 20 | return resolve(afterEach).then(() => destroyApp(this.application)); 21 | }, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /addon/components/ecr-stack.ts: -------------------------------------------------------------------------------- 1 | import EcrRouterComponent from './ecr-router-component'; 2 | 3 | import type { RouterState } from 'ember-navigator/-private/routeable'; 4 | 5 | export default class EcrStack extends EcrRouterComponent { 6 | get currentNodes() { 7 | let routerState = this.args.node.routeableState as RouterState; 8 | let activeChild = routerState.routes[routerState.index]; 9 | let activeChildNode = this.args.node.childNodes[activeChild.key]; 10 | 11 | return [activeChildNode]; 12 | } 13 | 14 | get showHeader() { 15 | return this.args.node.routeableState.headerMode !== 'none'; 16 | } 17 | 18 | get headerComponentName() { 19 | return this.args.node.routeableState.headerComponentName; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test_page: 'tests/index.html?hidepassed', 5 | disable_watching: true, 6 | launch_in_ci: ['Chrome'], 7 | launch_in_dev: ['Chrome'], 8 | browser_start_timeout: 120, 9 | browser_args: { 10 | Chrome: { 11 | ci: [ 12 | // --no-sandbox is needed when running Chrome inside a container 13 | process.env.CI ? '--no-sandbox' : null, 14 | '--headless', 15 | '--disable-dev-shm-usage', 16 | '--disable-software-rasterizer', 17 | '--mute-audio', 18 | '--remote-debugging-port=0', 19 | '--window-size=1440,900', 20 | ].filter(Boolean), 21 | }, 22 | Firefox: { 23 | mode: 'ci', 24 | args: ['-headless'], 25 | }, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | 12 | 13 | 14 | 15 | {{content-for "head-footer"}} 16 | 17 | 18 | {{content-for "body"}} 19 | 20 | 21 | 22 | 23 | {{content-for "body-footer"}} 24 | 25 | 26 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 5 | 6 | module.exports = function (defaults) { 7 | let app = new EmberAddon(defaults, { 8 | babel: { 9 | exclude: ['transform-regenerator'], 10 | }, 11 | isDevelopingAddon: function () { 12 | return false; 13 | }, 14 | snippetPaths: ['tests/dummy/snippets'], 15 | snippetSearchPaths: ['app', 'tests/dummy/app', 'addon'], 16 | }); 17 | 18 | /* 19 | This build file specifies the options for the dummy test app of this 20 | addon, located in `/tests/dummy` 21 | This build file does *not* influence how the addon or the app using it 22 | behave. You most likely want to be modifying `./index.js` or app's build file 23 | */ 24 | 25 | return app.toTree(); 26 | }; 27 | -------------------------------------------------------------------------------- /tests/unit/routers/helpers.ts: -------------------------------------------------------------------------------- 1 | import { navigate as navigateAction } from 'ember-navigator/-private/actions/actions'; 2 | 3 | import type { NavigateParams, RouterActions } from 'ember-navigator/-private/actions/types'; 4 | import type { RouterReducer, RouterState } from 'ember-navigator/-private/routeable'; 5 | 6 | export function handle( 7 | router: RouterReducer, 8 | action: RouterActions, 9 | state: RouterState 10 | ): RouterState { 11 | let result = router.dispatch(action, state); 12 | 13 | if (!result.handled) { 14 | throw new Error('expected handled action'); 15 | } 16 | 17 | return result.state; 18 | } 19 | 20 | export function navigate( 21 | router: RouterReducer, 22 | state: RouterState, 23 | params: string | NavigateParams 24 | ): RouterState { 25 | let action = navigateAction(typeof params === 'string' ? { routeName: params } : params); 26 | 27 | return handle(router, action, state); 28 | } 29 | -------------------------------------------------------------------------------- /addon/services/navigator-route-resolver.ts: -------------------------------------------------------------------------------- 1 | import { getOwner } from '@ember/application'; 2 | import Service from '@ember/service'; 3 | 4 | import type NavigatorRoute from 'ember-navigator/-private/navigator-route'; 5 | import type { Resolver } from 'ember-navigator/-private/routeable'; 6 | 7 | // TODO: more official way to type getOwner? 8 | interface Owner { 9 | factoryFor(routeName: string): typeof NavigatorRoute; 10 | } 11 | 12 | export default class NavigatorRouteResolver extends Service implements Resolver { 13 | containerType = 'navigator-route'; 14 | 15 | resolve(routeName: string) { 16 | let owner = getOwner(this) as Owner; 17 | let fullNavigatorRouteName = `${this.containerType}:${routeName}`; 18 | let factory = owner.factoryFor(fullNavigatorRouteName); 19 | 20 | if (factory) { 21 | return factory; 22 | } 23 | 24 | return owner.factoryFor(`${this.containerType}:basic`); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /config/deploy.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = function (deployTarget) { 5 | let ENV = { 6 | build: {}, 7 | // include other plugin configuration that applies to all deploy targets here 8 | }; 9 | 10 | if (deployTarget === 'development') { 11 | ENV.build.environment = 'development'; 12 | // configure other plugins for development deploy target here 13 | } 14 | 15 | if (deployTarget === 'staging') { 16 | ENV.build.environment = 'production'; 17 | // configure other plugins for staging deploy target here 18 | } 19 | 20 | if (deployTarget === 'production') { 21 | ENV.build.environment = 'production'; 22 | // configure other plugins for production deploy target here 23 | } 24 | 25 | // Note: if you need to build some configuration asynchronously, you can return 26 | // a promise that resolves with the ENV object instead of returning the 27 | // ENV object synchronously. 28 | return ENV; 29 | }; 30 | -------------------------------------------------------------------------------- /tests/acceptance/basic-test.ts: -------------------------------------------------------------------------------- 1 | import { click, currentURL, visit } from '@ember/test-helpers'; 2 | import { module, test } from 'qunit'; 3 | import { setupApplicationTest } from 'ember-qunit'; 4 | 5 | module('Acceptance | basic', function (hooks) { 6 | setupApplicationTest(hooks); 7 | 8 | test('visiting /', async function (assert) { 9 | await visit('/'); 10 | assert.strictEqual(currentURL(), '/'); 11 | assert.dom('.ecr-app-container h3').hasText('Login'); 12 | await click('[data-test-navigate="logged-in-default-0"]'); 13 | assert.dom('.ecr-app-container h3').hasText('Root Cellar'); 14 | await click('[data-test-navigate="frame-tweet-123-3"]'); 15 | assert.dom('.ecr-app-container h3').hasText('Tweet 123'); 16 | await click('[data-test-navigate="frame-tweet-456-4"]'); 17 | assert.dom('.ecr-app-container h3').hasText('Tweet 456'); 18 | await click('[data-test-back]'); 19 | assert.dom('.ecr-app-container h3').hasText('Tweet 123'); 20 | await click('[data-test-back]'); 21 | assert.dom('.ecr-app-container h3').hasText('Root Cellar'); 22 | await click('[data-test-navigate="logged-out-default-0"]'); 23 | assert.dom('.ecr-app-container h3').hasText('Login'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy Tests 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | {{content-for "test-head"}} 12 | 13 | 14 | 15 | 16 | 17 | {{content-for "head-footer"}} 18 | {{content-for "test-head-footer"}} 19 | 20 | 21 | {{content-for "body"}} 22 | {{content-for "test-body"}} 23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {{content-for "body-footer"}} 38 | {{content-for "test-body-footer"}} 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | module.exports = function (environment) { 5 | let ENV = { 6 | modulePrefix: 'dummy', 7 | environment, 8 | rootURL: '/', 9 | locationType: 'auto', 10 | EmberENV: { 11 | FEATURES: { 12 | // Here you can enable experimental features on an ember canary build 13 | // e.g. 'with-controller': true 14 | }, 15 | EXTEND_PROTOTYPES: { 16 | // Prevent Ember Data from overriding Date.parse. 17 | Date: false, 18 | }, 19 | }, 20 | 21 | APP: { 22 | // Here you can pass flags/options to your application instance 23 | // when it is created 24 | }, 25 | }; 26 | 27 | if (environment === 'development') { 28 | // ENV.APP.LOG_RESOLVER = true; 29 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 30 | // ENV.APP.LOG_TRANSITIONS = true; 31 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 32 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 33 | } 34 | 35 | if (environment === 'test') { 36 | // Testem prefers this... 37 | ENV.locationType = 'none'; 38 | 39 | // keep test console output quieter 40 | ENV.APP.LOG_ACTIVE_GENERATION = false; 41 | ENV.APP.LOG_VIEW_LOOKUPS = false; 42 | 43 | ENV.APP.rootElement = '#ember-testing'; 44 | 45 | ENV.APP.autoboot = false; 46 | } 47 | 48 | if (environment === 'production') { 49 | // here you can enable a production-specific feature 50 | ENV.rootURL = '/ember-navigator'; 51 | ENV.locationType = 'hash'; 52 | } 53 | 54 | return ENV; 55 | }; 56 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "allowJs": true, 5 | "moduleResolution": "node", 6 | "allowSyntheticDefaultImports": true, 7 | "noImplicitAny": true, 8 | "noImplicitThis": true, 9 | "experimentalDecorators": true, 10 | "alwaysStrict": true, 11 | "strictNullChecks": true, 12 | "strictPropertyInitialization": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noImplicitReturns": true, 17 | "noEmitOnError": false, 18 | "noEmit": true, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "baseUrl": ".", 22 | "module": "es6", 23 | "paths": { 24 | "dummy/tests/*": [ 25 | "tests/*" 26 | ], 27 | "dummy/*": [ 28 | "tests/dummy/app/*", 29 | "app/*" 30 | ], 31 | "ember-navigator": [ 32 | "addon" 33 | ], 34 | "ember-navigator/*": [ 35 | "addon/*" 36 | ], 37 | "ember-navigator/test-support": [ 38 | "addon-test-support" 39 | ], 40 | "ember-navigator/test-support/*": [ 41 | "addon-test-support/*" 42 | ], 43 | "*": [ 44 | "types/*" 45 | ] 46 | } 47 | }, 48 | "include": [ 49 | "app/**/*", 50 | "addon/**/*", 51 | "tests/**/*", 52 | "types/**/*", 53 | "test-support/**/*", 54 | "addon-test-support/**/*" 55 | ], 56 | "exclude": [ 57 | "node_modules", 58 | "tmp", 59 | "vendor", 60 | ".git", 61 | "dist" 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /tests/dummy/app/styles/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0; 3 | margin: 0; 4 | font-family: Arial, sans-serif; 5 | } 6 | 7 | header { 8 | line-height: 60px; 9 | border-bottom: 1px solid #8800ff; 10 | display: flex; 11 | padding: 8px 12px; 12 | margin-bottom: 12px; 13 | } 14 | 15 | header img { 16 | flex: 0 0 auto; 17 | } 18 | 19 | header h1 { 20 | font-family: Georgia, serif; 21 | color: #8800ffcc; 22 | line-height: 60px; 23 | margin:0 0 0 8px; 24 | font-size: 40px; 25 | padding:0; 26 | } 27 | 28 | .outer-container { 29 | display: flex; 30 | justify-content: space-evenly; 31 | } 32 | 33 | .debug-info { 34 | max-width: 400px; 35 | padding: 15px; 36 | } 37 | 38 | .debug-info p { 39 | margin: 0; 40 | margin-bottom: 15px; 41 | } 42 | 43 | .ecr-app-container { 44 | background-color: #ccffee; 45 | width: 300px; 46 | height: 500px; 47 | flex-shrink: 0; 48 | } 49 | 50 | .app-header { 51 | background-color: #5555ff; 52 | color: white; 53 | font-size: 1.5rem; 54 | height: 3rem; 55 | line-height: 3rem; 56 | } 57 | 58 | .app-header-title { 59 | position: absolute; 60 | top: 10px; 61 | width: 100%; 62 | text-align: center; 63 | } 64 | 65 | a.back-button { 66 | text-decoration: none; 67 | color: white; 68 | } 69 | 70 | pre { 71 | margin: 0; 72 | margin-left: 3px; 73 | font-size: 11px; 74 | } 75 | 76 | /* begin app copy pasta */ 77 | 78 | .ecr-app-container, .ecr-stack, .ecr-switch { 79 | display: flex; 80 | flex-direction: column; 81 | overflow: hidden; 82 | } 83 | 84 | .ecr-stack, .ecr-switch { 85 | flex: 1; 86 | height: 100%; 87 | } 88 | -------------------------------------------------------------------------------- /addon/index.ts: -------------------------------------------------------------------------------- 1 | import MountedRouter from './-private/mounted-router'; 2 | import { RouteReducer } from './-private/route-reducer'; 3 | import { StackRouter } from './-private/routers/stack-router'; 4 | import { SwitchRouter } from './-private/routers/switch-router'; 5 | import { TabRouter } from './-private/routers/tab-router'; 6 | 7 | import type { RouteOptions } from './-private/route-reducer'; 8 | import type { Resolver, RouteableReducer, RouterReducer } from './-private/routeable'; 9 | import type { StackOptions } from './-private/routers/stack-router'; 10 | import type { SwitchOptions } from './-private/routers/switch-router'; 11 | import type { TabOptions } from './-private/routers/tab-router'; 12 | 13 | export { 14 | default as NavigatorRoute, 15 | type NavigatorRouteConstructorParams, 16 | } from './-private/navigator-route'; 17 | 18 | export function mount(routerMap: RouterReducer, resolver: Resolver): MountedRouter { 19 | return new MountedRouter(routerMap, resolver); 20 | } 21 | 22 | export function route(name: string, options: RouteOptions = {}) { 23 | return new RouteReducer(name, options); 24 | } 25 | 26 | export function stackRouter( 27 | name: string, 28 | children: RouteableReducer[], 29 | options: StackOptions = {} 30 | ) { 31 | return new StackRouter(name, children, options); 32 | } 33 | 34 | export function switchRouter( 35 | name: string, 36 | children: RouteableReducer[], 37 | options: SwitchOptions = {} 38 | ) { 39 | return new SwitchRouter(name, children, options); 40 | } 41 | 42 | export function tabRouter(name: string, children: RouteableReducer[], options: TabOptions = {}) { 43 | return new TabRouter(name, children, options); 44 | } 45 | -------------------------------------------------------------------------------- /addon/-private/actions/actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BACK, 3 | BATCH, 4 | INIT, 5 | NAVIGATE, 6 | POP, 7 | POP_TO_TOP, 8 | PUSH, 9 | REPLACE, 10 | RESET, 11 | SET_PARAMS, 12 | } from './types'; 13 | 14 | import type { 15 | BackAction, 16 | BackParams, 17 | BatchAction, 18 | BatchParams, 19 | InitAction, 20 | InitParams, 21 | NavigateAction, 22 | NavigateParams, 23 | PopAction, 24 | PopParams, 25 | PushAction, 26 | PushParams, 27 | ReplaceAction, 28 | ReplaceParams, 29 | ResetAction, 30 | ResetParams, 31 | SetParamsAction, 32 | SetParamsParams, 33 | } from './types'; 34 | 35 | export const back = (payload: BackParams = {}): BackAction => ({ 36 | type: BACK, 37 | payload, 38 | }); 39 | 40 | export const init = (payload: InitParams): InitAction => { 41 | return { type: INIT, payload }; 42 | }; 43 | 44 | export const navigate = (payload: NavigateParams): NavigateAction => { 45 | return { type: NAVIGATE, payload }; 46 | }; 47 | 48 | export const setParams = (payload: SetParamsParams): SetParamsAction => ({ 49 | type: SET_PARAMS, 50 | payload, 51 | }); 52 | 53 | export const pop = (payload: PopParams = {}): PopAction => ({ 54 | type: POP, 55 | payload, 56 | }); 57 | 58 | export const popToTop = () => ({ type: POP_TO_TOP }); 59 | 60 | export const push = (payload: PushParams): PushAction => ({ 61 | type: PUSH, 62 | payload, 63 | }); 64 | 65 | export const reset = (payload: ResetParams): ResetAction => ({ 66 | type: RESET, 67 | payload, 68 | }); 69 | 70 | export const replace = (payload: ReplaceParams): ReplaceAction => ({ 71 | type: REPLACE, 72 | payload, 73 | }); 74 | 75 | export const batch = (payload: BatchParams): BatchAction => ({ 76 | type: BATCH, 77 | payload, 78 | }); 79 | -------------------------------------------------------------------------------- /addon/-private/route-reducer.ts: -------------------------------------------------------------------------------- 1 | import { generateKey } from './key-generator'; 2 | 3 | import type { RouterActions } from './actions/types'; 4 | import type { MountedNode } from './mounted-node'; 5 | import type { 6 | InitialStateOptions, 7 | RouteableReducer, 8 | RouterState, 9 | RouteState, 10 | UnhandledReducerResult, 11 | } from './routeable'; 12 | 13 | export type RouteOptions = { 14 | componentName?: string; 15 | }; 16 | 17 | /** 18 | * This is the reducer object returned by the `route()` function in the mapping DSL, e.g. 19 | * 20 | * [ 21 | * route('home'), 22 | * route('customer', { path: 'customer/:customer_id' }), 23 | * ] 24 | * 25 | * It represents a leaf (child-less) route in the routing tree. 26 | */ 27 | export class RouteReducer implements RouteableReducer { 28 | name: string; 29 | children: RouteableReducer[]; 30 | options: RouteOptions; 31 | isRouter: false; 32 | componentName: string; 33 | 34 | constructor(name: string, options: RouteOptions) { 35 | this.isRouter = false; 36 | this.name = name; 37 | this.children = []; 38 | this.options = options; 39 | this.componentName = options.componentName || name; 40 | } 41 | 42 | getInitialState(options: InitialStateOptions = {}): RouteState { 43 | let routeName = this.name; 44 | 45 | return { 46 | params: options.params || {}, 47 | routeName, 48 | key: options.key || generateKey(), 49 | componentName: routeName, 50 | }; 51 | } 52 | 53 | // TODO: remove this? 54 | dispatch(_action: RouterActions, _state: RouterState): UnhandledReducerResult { 55 | return { handled: false }; 56 | } 57 | 58 | reconcile(routeState: RouteState, mountedNode: MountedNode) { 59 | mountedNode.update(routeState); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /addon/-private/routeable.ts: -------------------------------------------------------------------------------- 1 | import type { RouterActions } from './actions/types'; 2 | import type { MountedNode } from './mounted-node'; 3 | import type NavigatorRoute from './navigator-route'; 4 | 5 | export interface RouteableState { 6 | key: string; 7 | routeName: string; 8 | params: Record; 9 | componentName: string; 10 | 11 | // TODO: consider getting rid of these? Do any apps in the wild use these? 12 | headerComponentName?: string; 13 | headerMode?: string; 14 | } 15 | 16 | export type RouteState = RouteableState; 17 | 18 | export interface RouterState extends RouteableState { 19 | index: number; 20 | routes: RouteableState[]; 21 | } 22 | 23 | export interface StackRouterState extends RouterState { 24 | headerComponentName: string; 25 | headerMode: string; 26 | } 27 | 28 | export type HandledReducerResult = { 29 | handled: true; 30 | state: RouterState; 31 | }; 32 | 33 | export type UnhandledReducerResult = { 34 | handled: false; 35 | }; 36 | 37 | export type ReducerResult = HandledReducerResult | UnhandledReducerResult; 38 | 39 | export type InitialStateOptions = { 40 | key?: string; 41 | params?: Record; 42 | }; 43 | 44 | export interface RouteableReducer { 45 | name: string; 46 | children: RouteableReducer[]; 47 | isRouter: boolean; 48 | params?: Record; 49 | getInitialState: (options?: InitialStateOptions) => RouteableState; 50 | dispatch: (action: RouterActions, state?: RouteableState) => ReducerResult; 51 | reconcile(routerState: RouteableState, mountedNode: MountedNode): void; 52 | } 53 | 54 | export interface RouterReducer extends RouteableReducer { 55 | isRouter: true; 56 | getInitialState: (options?: InitialStateOptions) => RouterState; 57 | } 58 | 59 | export interface Resolver { 60 | resolve(componentName: string): typeof NavigatorRoute | null; 61 | } 62 | -------------------------------------------------------------------------------- /addon/-private/routers/tab-router.ts: -------------------------------------------------------------------------------- 1 | import { handledAction, unhandledAction } from './base-router'; 2 | import { SwitchRouter } from './switch-router'; 3 | 4 | import type { NavigateAction } from '../actions/types'; 5 | import type { ReducerResult, RouteableState, RouterReducer, RouterState } from '../routeable'; 6 | import type { SwitchOptions } from './switch-router'; 7 | 8 | export type TabOptions = SwitchOptions; 9 | 10 | /* A TabRouter is a SwitchRouter that doesn't reset child state when switching between child routes */ 11 | export class TabRouter extends SwitchRouter implements RouterReducer { 12 | defaultKey = 'TabRouterBase'; 13 | 14 | navigateAway(action: NavigateAction, state: RouterState): ReducerResult { 15 | // TODO: it seems wasteful to deeply recurse on every unknown route. 16 | // consider adding a cache, or building one at the beginning? 17 | for (let i = 0; i < this.children.length; ++i) { 18 | if (state.index === i) { 19 | // skip the active route, which we already checked. 20 | continue; 21 | } 22 | 23 | let routeable = this.children[i]; 24 | 25 | if (routeable.name === action.payload.routeName) { 26 | let childRouteState = state.routes[i]; 27 | 28 | return this.switchToRoute(state, childRouteState, i); 29 | } else if (routeable.isRouter) { 30 | let initialChildRouteState = this.resetChildRoute(routeable); 31 | let navigationResult = routeable.dispatch(action, initialChildRouteState); 32 | 33 | if (navigationResult.handled) { 34 | return this.switchToRoute(state, navigationResult.state, i); 35 | } 36 | } 37 | } 38 | 39 | return unhandledAction(); 40 | } 41 | 42 | switchToRoute(state: RouterState, childRouteState: RouteableState, i: number) { 43 | let routes = [...state.routes]; 44 | 45 | routes[i] = childRouteState; 46 | 47 | return handledAction({ 48 | ...state, 49 | routes, 50 | index: i, 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /addon/-private/routers/base-router.ts: -------------------------------------------------------------------------------- 1 | import type { RouterActions } from '../actions/types'; 2 | import type { 3 | ReducerResult, 4 | RouteableReducer, 5 | RouterReducer, 6 | RouterState, 7 | RouteState, 8 | } from '../routeable'; 9 | 10 | export interface BaseOptions { 11 | componentName?: string; 12 | } 13 | 14 | export function handledAction(state: RouterState): ReducerResult { 15 | return { handled: true, state }; 16 | } 17 | 18 | export function unhandledAction(): ReducerResult { 19 | return { handled: false }; 20 | } 21 | 22 | export class BaseRouter { 23 | name: string; 24 | children: RouteableReducer[]; 25 | componentName: string; 26 | isRouter: true; 27 | childRouteables: { [k: string]: RouteableReducer }; 28 | options: BaseOptions; 29 | routeNames: string[]; 30 | 31 | constructor(name: string, children: RouteableReducer[], options: BaseOptions) { 32 | this.isRouter = true; 33 | this.name = name; 34 | this.children = children; 35 | this.routeNames = []; 36 | this.childRouteables = {}; 37 | this.options = options; 38 | 39 | children.forEach((c) => { 40 | this.childRouteables[c.name] = c; 41 | this.routeNames.push(c.name); 42 | }); 43 | 44 | this.componentName = this.options.componentName || 'ecr-stack'; 45 | } 46 | 47 | childRouterNamed(name: string): RouterReducer | null { 48 | let child = this.childRouteables[name]; 49 | 50 | return child.isRouter ? (child as RouterReducer) : null; 51 | } 52 | 53 | dispatchTo(routeStates: RouteState[], action: RouterActions): RouterState | void { 54 | for (let routeState of routeStates) { 55 | let routeable = this.childRouteables[routeState.routeName]; 56 | 57 | let childAction = action; 58 | // TODO: write spec for child actions 59 | // action.routeName === routeState.routeName && action.action 60 | // ? action.action 61 | // : action; 62 | 63 | const result = routeable.dispatch(childAction, routeState); 64 | 65 | if (result.handled) { 66 | return result.state; 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /config/ember-try.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 'use strict'; 3 | 4 | const getChannelURL = require('ember-source-channel-url'); 5 | 6 | module.exports = async function () { 7 | return { 8 | useYarn: true, 9 | scenarios: [ 10 | { 11 | name: 'ember-lts-3.12', 12 | npm: { 13 | devDependencies: { 14 | 'ember-source': '~3.12.0', 15 | }, 16 | }, 17 | }, 18 | { 19 | name: 'ember-lts-3.16', 20 | npm: { 21 | devDependencies: { 22 | 'ember-source': '~3.16.0', 23 | }, 24 | }, 25 | }, 26 | { 27 | name: 'ember-lts-3.20', 28 | npm: { 29 | devDependencies: { 30 | 'ember-source': '~3.20.5', 31 | }, 32 | }, 33 | }, 34 | { 35 | name: 'ember-lts-3.24', 36 | npm: { 37 | devDependencies: { 38 | 'ember-source': '~3.24.3', 39 | }, 40 | }, 41 | }, 42 | { 43 | name: 'ember-lts-3.28', 44 | npm: { 45 | devDependencies: { 46 | 'ember-source': '~3.28.0', 47 | }, 48 | }, 49 | }, 50 | { 51 | name: 'ember-release', 52 | npm: { 53 | devDependencies: { 54 | 'ember-source': await getChannelURL('release'), 55 | 'ember-auto-import': '^2.0.0', 56 | webpack: '^5.0.0', 57 | }, 58 | }, 59 | }, 60 | { 61 | name: 'ember-beta', 62 | npm: { 63 | devDependencies: { 64 | 'ember-source': await getChannelURL('beta'), 65 | 'ember-auto-import': '^2.0.0', 66 | webpack: '^5.0.0', 67 | }, 68 | }, 69 | }, 70 | { 71 | name: 'ember-canary', 72 | npm: { 73 | devDependencies: { 74 | 'ember-source': await getChannelURL('canary'), 75 | 'ember-auto-import': '^2.0.0', 76 | webpack: '^5.0.0', 77 | }, 78 | }, 79 | }, 80 | { 81 | name: 'ember-default', 82 | npm: { 83 | devDependencies: {}, 84 | }, 85 | }, 86 | ], 87 | }; 88 | }; 89 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 |
2 | Ember Navigator 3 |

Ember Navigator

4 |
5 | 6 |
7 |
8 | {{component this.mountedRouter.rootNode.componentName node=this.mountedRouter.rootNode route=this.mountedRouter.rootNode.route}} 9 |
10 | 11 |
12 |

13 | This is a demo of ember-navigator. 14 |

15 |

16 | Try clicking some of the links below and watch how the state changes. 17 |

18 |

Note: GENERATE_UUID will be replaced with a random UUID at the time of navigation

19 | 40 |
41 | 42 |
43 |

44 | Router Map 45 |

46 |

47 | Should seem familiar to the classic API, but functions 48 | in a much simpler manner than the classic Ember DSL, and 49 | should be easier to configure and experiment with. 50 |

51 | {{#let (get-code-snippet "router-map.ts") as |snippet|}} 52 |
{{snippet.source}}
53 | {{/let}} 54 |
55 | 56 |
57 |

58 | Router State 59 |

60 |

61 | Like "outlet" state in vanilla Ember (or redux reducer state), this is built up 62 | by the various routers and passed to the various navigator 63 | components for rendering. 64 |

65 |
{{json-stringify this.mountedRouter.state pretty=true}}
66 |
67 |
68 | -------------------------------------------------------------------------------- /addon/-private/mounted-router.ts: -------------------------------------------------------------------------------- 1 | import { set } from '@ember/object'; 2 | import { sendEvent } from '@ember/object/events'; 3 | 4 | import { navigate, pop } from './actions/actions'; 5 | import { MountedNode } from './mounted-node'; 6 | 7 | import type { NavigateParams, PopParams, RouterActions } from './actions/types'; 8 | import type { Resolver, RouterReducer, RouterState } from './routeable'; 9 | 10 | export default class MountedRouter { 11 | router: RouterReducer; 12 | state: RouterState; 13 | resolver: Resolver; 14 | rootNode: MountedNode; 15 | 16 | constructor(router: RouterReducer, resolver: Resolver) { 17 | this.resolver = resolver; 18 | this.router = router; 19 | this.state = router.getInitialState(); 20 | this.rootNode = new MountedNode(this, null, this.state); 21 | this._update(); 22 | } 23 | 24 | dispatch(action: RouterActions) { 25 | let result = this.router.dispatch(action, this.state); 26 | 27 | if (result.handled) { 28 | if (this.state !== result.state) { 29 | set(this, 'state', result.state); 30 | this._update(); 31 | this._sendEvents(); 32 | } 33 | } else { 34 | console.warn(`mounted-router: unhandled action ${action.type}`); 35 | } 36 | } 37 | 38 | _sendEvents() { 39 | sendEvent(this, 'didTransition'); 40 | } 41 | 42 | _update() { 43 | this.router.reconcile(this.state, this.rootNode); 44 | } 45 | 46 | navigate(options: NavigateParams) { 47 | this.dispatch(navigate(options)); 48 | } 49 | 50 | pop(options: PopParams | undefined) { 51 | this.dispatch(pop(options)); 52 | } 53 | 54 | resolve(name: string) { 55 | return this.resolver.resolve(name); 56 | } 57 | 58 | // By default, we expect the resolver to return a factory that is invoked via `create()` as factories in 59 | // Ember's container are. If you want to plain non-container-aware classes, you should pass a custom 60 | // Resolver to MountedRouter that returns an ES6 class from resolve(...). The class should have a 61 | // constructor accepting one argument (a MountedNode instance). 62 | 63 | createNavigatorRoute(node: MountedNode) { 64 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 65 | let RouteFactory = this.resolve(node.routeName)!; 66 | 67 | if (RouteFactory.create) { 68 | return RouteFactory.create({ node }); 69 | } else { 70 | return new RouteFactory(node); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | ## React Navigation License 12 | 13 | Much of this project is inspired by React Navigation and in some cases 14 | the source code from the React Navigation was copied directly into this 15 | project, hence the React Navigation BSD License has been provided below: 16 | 17 | BSD License 18 | 19 | For React Navigation software 20 | 21 | Copyright (c) 2016-present, React Navigation Contributors. All rights reserved. 22 | 23 | Redistribution and use in source and binary forms, with or without modification, 24 | are permitted provided that the following conditions are met: 25 | 26 | * Redistributions of source code must retain the above copyright notice, this 27 | list of conditions and the following disclaimer. 28 | 29 | * Redistributions in binary form must reproduce the above copyright notice, 30 | this list of conditions and the following disclaimer in the documentation 31 | and/or other materials provided with the distribution. 32 | 33 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 34 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 35 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 36 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 37 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 38 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 39 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 40 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 41 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 42 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /tests/dummy/app/controllers/application.ts: -------------------------------------------------------------------------------- 1 | import Controller from '@ember/controller'; 2 | import { action } from '@ember/object'; 3 | import { inject as service } from '@ember/service'; 4 | 5 | import { mount } from 'ember-navigator'; 6 | import { route, stackRouter, switchRouter } from 'ember-navigator'; 7 | 8 | import type { NavigateParams } from 'ember-navigator/-private/actions/types'; 9 | import type NavigatorRouteResolver from 'ember-navigator/services/navigator-route-resolver'; 10 | 11 | export default class extends Controller { 12 | @service navigatorRouteResolver!: NavigatorRouteResolver; 13 | 14 | // eslint-disable-next-line ember/require-computed-property-dependencies 15 | mountedRouter = mount( 16 | // BEGIN-SNIPPET router-map 17 | switchRouter('auth', [ 18 | stackRouter('logged-out', [route('enter-email'), route('terms-of-service')]), 19 | stackRouter('logged-in', [ 20 | route('frame-root'), 21 | route('frame-tweet'), 22 | stackRouter('nested', [route('nested-a')], { headerMode: 'none' }), 23 | ]), 24 | route('no-header'), 25 | ]), 26 | // END-SNIPPET 27 | this.navigatorRouteResolver 28 | ); 29 | 30 | @action 31 | navigate(options: NavigateParams) { 32 | let normalizedOptions = Object.assign({}, options); 33 | 34 | if (options.key === 'GENERATE_UUID') { 35 | normalizedOptions.key = `uuid-${Math.floor(Math.random() * 10000000)}`; 36 | } 37 | 38 | this.mountedRouter.navigate(normalizedOptions); 39 | } 40 | 41 | links = [ 42 | { 43 | routeName: 'logged-out', 44 | variations: [{}], 45 | }, 46 | { 47 | routeName: 'enter-email', 48 | variations: [{}], 49 | }, 50 | { 51 | routeName: 'terms-of-service', 52 | variations: [{}], 53 | }, 54 | { 55 | routeName: 'logged-in', 56 | variations: [{}], 57 | }, 58 | { 59 | routeName: 'frame-root', 60 | variations: [{}, { key: 'a' }, { key: 'b' }, { key: 'c' }, { key: 'GENERATE_UUID' }], 61 | }, 62 | { 63 | routeName: 'frame-tweet', 64 | variations: [ 65 | { params: { tweet_id: '123' } }, 66 | { params: { tweet_id: '456' } }, 67 | { params: { tweet_id: '999' } }, 68 | { params: { tweet_id: '123' }, key: '123' }, 69 | { params: { tweet_id: '456' }, key: '456' }, 70 | { params: { tweet_id: '999' }, key: '999' }, 71 | { params: { tweet_id: '123' }, key: 'GENERATE_UUID' }, 72 | { params: { tweet_id: '456' }, key: 'GENERATE_UUID' }, 73 | { params: { tweet_id: '999' }, key: 'GENERATE_UUID' }, 74 | ], 75 | }, 76 | { 77 | routeName: 'nested-a', 78 | variations: [{}, { key: 'a' }, { key: 'b' }, { key: 'c' }, { key: 'GENERATE_UUID' }], 79 | }, 80 | { 81 | routeName: 'no-header', 82 | variations: [{}], 83 | }, 84 | ]; 85 | } 86 | -------------------------------------------------------------------------------- /addon/-private/actions/types.ts: -------------------------------------------------------------------------------- 1 | export const BACK = 'Navigation/BACK'; 2 | export const INIT = 'Navigation/INIT'; 3 | export const NAVIGATE = 'Navigation/NAVIGATE'; 4 | export const SET_PARAMS = 'Navigation/SET_PARAMS'; 5 | export const POP = 'Navigation/POP'; 6 | export const POP_TO_TOP = 'Navigation/POP_TO_TOP'; 7 | export const PUSH = 'Navigation/PUSH'; 8 | export const RESET = 'Navigation/RESET'; 9 | export const REPLACE = 'Navigation/REPLACE'; 10 | export const BATCH = 'Navigation/BATCH'; 11 | 12 | export type BackParams = { 13 | key?: string; 14 | }; 15 | 16 | export type BackAction = { 17 | type: typeof BACK; 18 | payload: BackParams; 19 | }; 20 | 21 | export type InitParams = { 22 | params?: Record; 23 | }; 24 | 25 | export type InitAction = { 26 | type: typeof INIT; 27 | payload: InitParams; 28 | }; 29 | 30 | export type NavigateParams = { 31 | routeName: string; 32 | params?: Record; 33 | action?: RouterActions; 34 | key?: string; 35 | }; 36 | 37 | export type NavigateAction = { 38 | type: typeof NAVIGATE; 39 | payload: NavigateParams; 40 | }; 41 | 42 | export type SetParamsParams = { 43 | key?: string; 44 | params?: Record; 45 | preserveFocus: boolean; 46 | }; 47 | 48 | export type SetParamsAction = { 49 | type: typeof SET_PARAMS; 50 | payload: SetParamsParams; 51 | }; 52 | 53 | export type PopParams = { 54 | n?: number; 55 | }; 56 | 57 | export type PopAction = { 58 | type: typeof POP; 59 | payload: PopParams; 60 | }; 61 | 62 | export type PopToTopAction = { 63 | type: typeof POP_TO_TOP; 64 | }; 65 | 66 | export type PushParams = { 67 | routeName: string; 68 | params?: Record; 69 | action?: RouterActions; 70 | }; 71 | 72 | export type PushAction = { 73 | type: typeof PUSH; 74 | payload: PushParams; 75 | }; 76 | 77 | export type ResetParams = { 78 | index: number; 79 | actions: RouterActions[]; 80 | key?: string | null; 81 | }; 82 | 83 | export type ResetAction = { 84 | type: typeof RESET; 85 | payload: ResetParams; 86 | }; 87 | 88 | export type ReplaceParams = { 89 | key?: string; 90 | newKey?: string; 91 | routeName?: string; 92 | params?: object; 93 | action?: RouterActions; 94 | }; 95 | 96 | export type ReplaceAction = { 97 | type: typeof REPLACE; 98 | payload: ReplaceParams; 99 | }; 100 | 101 | export type BatchParams = { 102 | actions: RouterActions[]; 103 | }; 104 | 105 | export type BatchAction = { 106 | type: typeof BATCH; 107 | payload: BatchParams; 108 | }; 109 | 110 | export type NavigationActions = 111 | | NavigateAction 112 | | BackAction 113 | | SetParamsAction 114 | | InitAction 115 | | BatchAction; 116 | export type StackActions = ResetAction | ReplaceAction | PushAction | PopAction | PopToTopAction; 117 | export type RouterActions = NavigationActions | StackActions; 118 | -------------------------------------------------------------------------------- /addon/-private/mounted-node.ts: -------------------------------------------------------------------------------- 1 | import { tracked } from '@glimmer/tracking'; 2 | 3 | import type MountedRouter from './mounted-router'; 4 | import type { Header } from './navigator-route'; 5 | import type NavigatorRoute from './navigator-route'; 6 | import type { RouteableState, RouterState } from './routeable'; 7 | 8 | export type MountedNodeSet = { [key: string]: MountedNode }; 9 | 10 | let ID = 0; 11 | 12 | /** 13 | * A MountedNode is an internal/private class that represents a node in the router tree that 14 | * has been fully initialized (similar to components in ember that have been fully rendered 15 | * into the DOM, or "mounted" components in React). 16 | * 17 | * Apps should not import, subclass, or interact with this class; instead, apps should 18 | * define subclasses of {NavigatorRoute}, which is the public API for customizing 19 | * behavior when the route is mounted. 20 | */ 21 | 22 | export class MountedNode { 23 | @tracked childNodes: MountedNodeSet; 24 | @tracked routeableState: RouteableState; 25 | route: NavigatorRoute; 26 | id: number; 27 | // header?: any; 28 | mountedRouter: MountedRouter; 29 | parentNode: MountedNode | null; 30 | 31 | constructor( 32 | mountedRouter: MountedRouter, 33 | parentNode: MountedNode | null, 34 | routeableState: RouteableState 35 | ) { 36 | // TODO: odd that we pass in routeableState but don't stash it? Maybe we should call update immediately? 37 | this.id = ID++; 38 | this.mountedRouter = mountedRouter; 39 | this.parentNode = parentNode; 40 | this.routeableState = routeableState; 41 | this.childNodes = {}; 42 | this.route = this.mountedRouter.createNavigatorRoute(this); 43 | this.mount(); 44 | } 45 | 46 | update(routeableState: RouteableState) { 47 | // TODO: is this check needed? when else would this change? 48 | if (this.routeableState === routeableState) { 49 | return; 50 | } 51 | 52 | this.route.update(routeableState); 53 | this.routeableState = routeableState; 54 | } 55 | 56 | mount() { 57 | this.route.mount(); 58 | } 59 | 60 | unmount() { 61 | this.route.unmount(); 62 | } 63 | 64 | resolve(name: string) { 65 | return this.mountedRouter.resolve(name); 66 | } 67 | 68 | get componentName() { 69 | return this.routeableState.componentName; 70 | } 71 | 72 | get routeName() { 73 | return this.routeableState.routeName; 74 | } 75 | 76 | get key() { 77 | return this.routeableState.key; 78 | } 79 | 80 | get params() { 81 | return this.routeableState.params; 82 | } 83 | 84 | get isRouter() { 85 | return !!(this.routeableState as RouterState).routes; 86 | } 87 | 88 | getHeaderConfig(): Header | null { 89 | let routerState = this.routeableState as RouterState; 90 | 91 | if (routerState.routes) { 92 | let key = routerState.routes[routerState.index].key; 93 | let child = this.childNodes[key]; 94 | 95 | return child?.getHeaderConfig(); 96 | } else { 97 | // this is leaf route, check the NavigatorRoute 98 | return this.route.header || null; 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/unit/routers/switch-router-test.ts: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | 3 | import { route, stackRouter, switchRouter } from 'ember-navigator'; 4 | import { _TESTING_ONLY_normalize_keys } from 'ember-navigator/-private/key-generator'; 5 | 6 | import { navigate } from './helpers'; 7 | 8 | import type { RouterState } from 'ember-navigator/-private/routeable'; 9 | 10 | module('Unit - SwitchRouter test', function (hooks) { 11 | hooks.beforeEach(() => _TESTING_ONLY_normalize_keys()); 12 | 13 | test('initial state', function (assert) { 14 | let router = switchRouter('root', [route('foo'), route('bar')]); 15 | let state = router.getInitialState(); 16 | 17 | assert.deepEqual(state, { 18 | componentName: 'ecr-switch', 19 | index: 0, 20 | key: 'SwitchRouterBase', 21 | params: {}, 22 | routeName: 'root', 23 | routes: [ 24 | { 25 | componentName: 'foo', 26 | key: 'foo', 27 | params: {}, 28 | routeName: 'foo', 29 | }, 30 | { 31 | componentName: 'bar', 32 | key: 'bar', 33 | params: {}, 34 | routeName: 'bar', 35 | }, 36 | ], 37 | }); 38 | }); 39 | 40 | test('navigating between shallow routes', function (assert) { 41 | let router = buildExampleRouter(); 42 | let initialState = router.getInitialState(); 43 | let state2 = navigate(router, initialState, 'b1'); 44 | 45 | assert.strictEqual(state2.index, 1); 46 | assert.deepEqual(state2.routes[1], { 47 | componentName: 'ecr-stack', 48 | headerComponentName: 'ecr-header', 49 | headerMode: 'float', 50 | index: 0, 51 | key: 'b', 52 | params: {}, 53 | routeName: 'b', 54 | routes: [ 55 | { 56 | componentName: 'b1', 57 | key: 'b1', 58 | params: {}, 59 | routeName: 'b1', 60 | }, 61 | ], 62 | } as RouterState); 63 | 64 | let state3 = navigate(router, initialState, 'b2'); 65 | let innerRoute = state3.routes[1] as RouterState; 66 | 67 | assert.strictEqual(innerRoute.index, 1); 68 | assert.deepEqual( 69 | innerRoute.routes.map((r) => r.routeName), 70 | ['b1', 'b2'] 71 | ); 72 | }); 73 | 74 | test("navigating to the parent route if a route you're in should be a no-op", function (assert) { 75 | let router = switchRouter('a', [switchRouter('b', [route('c')])]); 76 | 77 | let initialState = router.getInitialState(); 78 | let state2 = navigate(router, initialState, 'b'); 79 | 80 | assert.strictEqual(initialState, state2); 81 | }); 82 | 83 | test('navigating away', function (assert) { 84 | let router = switchRouter('root', [route('a'), route('b')]); 85 | 86 | let initialState = router.getInitialState(); 87 | let state2 = navigate(router, initialState, 'b'); 88 | 89 | assert.strictEqual(state2.index, 1); 90 | 91 | let state3 = navigate(router, state2, 'a'); 92 | 93 | assert.strictEqual(state3.index, 0); 94 | }); 95 | 96 | test('no-op navigation within active route results in same state object being returned', function (assert) { 97 | let router = buildExampleRouter(); 98 | let initialState = router.getInitialState(); 99 | let state2 = navigate(router, initialState, 'a1'); 100 | 101 | assert.strictEqual(initialState, state2); 102 | }); 103 | 104 | test('navigation within active route', function (assert) { 105 | let router = buildExampleRouter(); 106 | let initialState = router.getInitialState(); 107 | let state2 = navigate(router, initialState, 'a2'); 108 | 109 | assert.notEqual(state2, initialState); 110 | assert.strictEqual(state2.index, 0); 111 | 112 | let newNestedRoute = state2.routes[0] as RouterState; 113 | 114 | assert.strictEqual(newNestedRoute.index, 1); 115 | }); 116 | }); 117 | 118 | function buildExampleRouter() { 119 | return switchRouter('root', [ 120 | stackRouter('a', [route('a1'), route('a2')]), 121 | stackRouter('b', [route('b1'), route('b2')]), 122 | stackRouter('c', [route('c1'), route('c2')]), 123 | route('shallow'), 124 | ]); 125 | } 126 | -------------------------------------------------------------------------------- /addon/-private/navigator-route.ts: -------------------------------------------------------------------------------- 1 | import { notifyPropertyChange } from '@ember/object'; 2 | 3 | import type { NavigateParams, PopParams } from './actions/types'; 4 | import type { MountedNode } from './mounted-node'; 5 | import type { RouteableState } from './routeable'; 6 | 7 | export type Header = { 8 | title?: string; 9 | }; 10 | 11 | export type NavigatorRouteConstructorParams = [node: MountedNode]; 12 | 13 | /** 14 | * NavigatorRoute is part of the public API of ember-navigator; it is a class 15 | * that is meant to be subclassed with various lifecycle hooks that can be 16 | * overridden in the subclass. 17 | */ 18 | export default class NavigatorRoute { 19 | node: MountedNode; 20 | header?: Header; 21 | 22 | /** 23 | * Constructs a NavigatorRoute, which you can override in your NavigatorRoute subclasses 24 | * to load data or perform other operations when the route is mounted. 25 | * 26 | * @param params NavigatorRouteConstructorParams 27 | */ 28 | constructor(...params: NavigatorRouteConstructorParams) { 29 | this.node = params[0]; 30 | } 31 | 32 | static create(props: { node: MountedNode }) { 33 | let instance = new this(props.node); 34 | 35 | Object.assign(instance, props); 36 | 37 | return instance; 38 | } 39 | 40 | navigate(options: NavigateParams) { 41 | this.node.mountedRouter.navigate(options); 42 | } 43 | 44 | pop(options?: PopParams) { 45 | this.node.mountedRouter.pop(options); 46 | } 47 | 48 | update(_state: RouteableState) { 49 | // this is how we signal to components to re-render with the new state. 50 | notifyPropertyChange(this, 'node'); 51 | } 52 | 53 | /** 54 | * Returns the params hash passed to this route (mostly via the `navigate()` method) 55 | */ 56 | get params() { 57 | return this.node.params || {}; 58 | } 59 | 60 | /** 61 | * Returns the navigation key that uniquely identifies the route within a router tree; 62 | * this is a value that is either passed in as an option to `navigate()`, or is 63 | * auto-generated if no such value is passed to `navigate()` 64 | */ 65 | get key() { 66 | return this.node.key; 67 | } 68 | 69 | /** 70 | * Returns the name of this route as specified in the mapping DSL (e.g. `route('foo')`) 71 | */ 72 | get name() { 73 | return this.node.routeName; 74 | } 75 | 76 | /** 77 | * Returns the immediate parent route or router. For example, if this is the 3rd route 78 | * within a stack router, `parent()` will return the 2nd NavigatorRoute in the stack. 79 | */ 80 | get parent(): NavigatorRoute | null { 81 | const parentNode = this.node.parentNode; 82 | 83 | if (!parentNode) { 84 | return null; 85 | } 86 | 87 | return (parentNode as MountedNode).route; 88 | } 89 | 90 | /** 91 | * Returns the closest parent of the provided name. 92 | */ 93 | parentNamed(name: string): NavigatorRoute | null { 94 | // eslint-disable-next-line @typescript-eslint/no-this-alias 95 | let cur: NavigatorRoute | null = this; 96 | 97 | while (cur && cur.name !== name) { 98 | cur = cur.parent; 99 | } 100 | 101 | return cur; 102 | } 103 | 104 | /** 105 | * Returns the nearest parent router, e.g. the stack router that this route is mounted in. 106 | */ 107 | get parentRouter(): NavigatorRoute | null { 108 | // eslint-disable-next-line @typescript-eslint/no-this-alias 109 | let cur: NavigatorRoute | null = this; 110 | 111 | while (cur && !(cur.node as MountedNode).isRouter) { 112 | cur = cur.parent; 113 | } 114 | 115 | return cur; 116 | } 117 | 118 | /** 119 | * Returns the parent route, and null if there is no parent, or the parent is a router. 120 | */ 121 | get parentRoute(): NavigatorRoute | null { 122 | const parent = this.parent; 123 | 124 | if (!parent) { 125 | return null; 126 | } 127 | 128 | if ((parent.node as MountedNode).isRouter) { 129 | return null; 130 | } else { 131 | return parent; 132 | } 133 | } 134 | 135 | // Public overridable hooks: 136 | 137 | /** 138 | * `mount` is called after transitioning to a new route, or pushing a stack frame; 139 | * Within this hook, you can access `this.params` to access any params passed into 140 | * this route (such as model IDs or any other information) 141 | * 142 | * There is no difference between overriding this method vs just overriding the 143 | * NavigatorRoute constructor (and calling super()), but overriding the constructor 144 | * tends to be the happier path when working with TypeScript 145 | */ 146 | // eslint-disable-next-line @typescript-eslint/no-empty-function 147 | mount() {} 148 | 149 | /** 150 | * `unmount` is called after the route has been removed from the routing tree. 151 | */ 152 | // eslint-disable-next-line @typescript-eslint/no-empty-function 153 | unmount() {} 154 | } 155 | -------------------------------------------------------------------------------- /tests/unit/routers/tab-router-test.ts: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | 3 | import { route, stackRouter, tabRouter } from 'ember-navigator'; 4 | import { _TESTING_ONLY_normalize_keys } from 'ember-navigator/-private/key-generator'; 5 | 6 | import { navigate } from './helpers'; 7 | 8 | import type { RouterState } from 'ember-navigator/-private/routeable'; 9 | 10 | module('Unit - TabRouter test', function (hooks) { 11 | hooks.beforeEach(() => _TESTING_ONLY_normalize_keys()); 12 | 13 | test('initial state', function (assert) { 14 | let router = tabRouter('root', [route('foo'), route('bar')]); 15 | let state = router.getInitialState(); 16 | 17 | assert.deepEqual(state, { 18 | componentName: 'ecr-switch', 19 | index: 0, 20 | key: 'TabRouterBase', 21 | params: {}, 22 | routeName: 'root', 23 | routes: [ 24 | { 25 | componentName: 'foo', 26 | key: 'foo', 27 | params: {}, 28 | routeName: 'foo', 29 | }, 30 | { 31 | componentName: 'bar', 32 | key: 'bar', 33 | params: {}, 34 | routeName: 'bar', 35 | }, 36 | ], 37 | }); 38 | }); 39 | 40 | test('navigating between shallow routes', function (assert) { 41 | let router = buildExampleRouter(); 42 | let initialState = router.getInitialState(); 43 | let state2 = navigate(router, initialState, 'b1'); 44 | 45 | assert.strictEqual(state2.index, 1); 46 | assert.deepEqual(state2.routes[1], { 47 | componentName: 'ecr-stack', 48 | headerComponentName: 'ecr-header', 49 | headerMode: 'float', 50 | index: 0, 51 | key: 'b', 52 | params: {}, 53 | routeName: 'b', 54 | routes: [ 55 | { 56 | componentName: 'b1', 57 | key: 'b1', 58 | params: {}, 59 | routeName: 'b1', 60 | }, 61 | ], 62 | } as RouterState); 63 | 64 | let state3 = navigate(router, initialState, 'b2'); 65 | let innerRoute = state3.routes[1] as RouterState; 66 | 67 | assert.strictEqual(innerRoute.index, 1); 68 | assert.deepEqual( 69 | innerRoute.routes.map((r) => r.routeName), 70 | ['b1', 'b2'] 71 | ); 72 | }); 73 | 74 | test("navigating to the parent route if a route you're in should be a no-op", function (assert) { 75 | let router = tabRouter('a', [stackRouter('b', [route('c')])]); 76 | 77 | let initialState = router.getInitialState(); 78 | let state2 = navigate(router, initialState, 'b'); 79 | 80 | assert.strictEqual(initialState, state2); 81 | }); 82 | 83 | test('navigating away', function (assert) { 84 | let router = tabRouter('root', [route('a'), route('b')]); 85 | 86 | let initialState = router.getInitialState(); 87 | let state2 = navigate(router, initialState, 'b'); 88 | 89 | assert.strictEqual(state2.index, 1); 90 | 91 | let state3 = navigate(router, state2, 'a'); 92 | 93 | assert.strictEqual(state3.index, 0); 94 | }); 95 | 96 | test('no-op navigation within active route results in same state object being returned', function (assert) { 97 | let router = buildExampleRouter(); 98 | let initialState = router.getInitialState(); 99 | let state2 = navigate(router, initialState, 'a1'); 100 | 101 | assert.strictEqual(initialState, state2); 102 | }); 103 | 104 | test('navigation within active route', function (assert) { 105 | let router = buildExampleRouter(); 106 | let initialState = router.getInitialState(); 107 | let state2 = navigate(router, initialState, 'a2'); 108 | 109 | assert.notEqual(state2, initialState); 110 | assert.strictEqual(state2.index, 0); 111 | 112 | let newNestedRoute = state2.routes[0] as RouterState; 113 | 114 | assert.strictEqual(newNestedRoute.index, 1); 115 | }); 116 | 117 | test('navigation to another tab route preserves the state of each tab', function (assert) { 118 | let router = buildExampleRouter(); 119 | let initialState = router.getInitialState(); 120 | let state2 = navigate(router, initialState, 'a2'); 121 | let state3 = navigate(router, state2, 'b'); 122 | let state4 = navigate(router, state3, 'a'); 123 | 124 | assert.strictEqual(state4.index, 0, 'first tab should be active'); 125 | 126 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 127 | let tabRouterState = state4.routes[0] as any; 128 | 129 | assert.strictEqual(tabRouterState.index, 1, 'first tab should still be drilled down one level'); 130 | assert.strictEqual( 131 | tabRouterState.routes.length, 132 | 2, 133 | 'first tab should still be drilled down one level' 134 | ); 135 | assert.strictEqual( 136 | tabRouterState.routes[1].routeName, 137 | 'a2', 138 | 'first tab should still have a2 active' 139 | ); 140 | }); 141 | }); 142 | 143 | function buildExampleRouter() { 144 | return tabRouter('root', [ 145 | stackRouter('a', [route('a1'), route('a2')]), 146 | stackRouter('b', [route('b1'), route('b2')]), 147 | stackRouter('c', [route('c1'), route('c2')]), 148 | route('shallow'), 149 | ]); 150 | } 151 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-navigator", 3 | "version": "0.4.1", 4 | "description": "The default blueprint for ember-cli addons.", 5 | "keywords": [ 6 | "ember-addon" 7 | ], 8 | "license": "MIT", 9 | "author": "Alex Matchneer", 10 | "directories": { 11 | "doc": "doc", 12 | "test": "tests" 13 | }, 14 | "release-it": { 15 | "npm": { 16 | "skipChecks": true 17 | } 18 | }, 19 | "publishConfig": { 20 | "access": "public", 21 | "registry": "https://registry.npmjs.org" 22 | }, 23 | "repository": "https://github.com/machty/ember-navigator", 24 | "scripts": { 25 | "build": "NODE_OPTIONS=--openssl-legacy-provider ember build --environment=production", 26 | "lint": "npm-run-all --aggregate-output --continue-on-error --parallel \"lint:!(fix)\"", 27 | "lint:fix": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*:fix", 28 | "lint:hbs": "ember-template-lint .", 29 | "lint:hbs:fix": "ember-template-lint . --fix", 30 | "lint:js": "eslint . --cache", 31 | "lint:js:fix": "eslint . --fix", 32 | "prepack": "ember ts:precompile", 33 | "postpack": "ember ts:clean", 34 | "release": "release-it", 35 | "start": "ember server", 36 | "test": "npm-run-all lint test:*", 37 | "test:ember": "ember test", 38 | "test:ember-compatibility": "ember try:each" 39 | }, 40 | "dependencies": { 41 | "@glimmer/component": "^1.0.0", 42 | "@glimmer/tracking": "^1.0.4", 43 | "ember-cli-babel": "^7.26.6", 44 | "ember-cli-htmlbars": "^5.7.1", 45 | "ember-cli-typescript": "^5.0.0", 46 | "process-nextick-args": "^1.0.7", 47 | "util-deprecate": "^1.0.2" 48 | }, 49 | "devDependencies": { 50 | "@babel/eslint-parser": "^7.5.4", 51 | "@ember/test-helpers": "^2.4.0", 52 | "@nullvoxpopuli/eslint-configs": "https://github.com/machty/eslint-configs.git#babel-eslint", 53 | "@types/ember": "^3.1.1", 54 | "@types/ember-data": "^4.0.0", 55 | "@types/ember-data__adapter": "^4.0.0", 56 | "@types/ember-data__model": "^4.0.0", 57 | "@types/ember-data__serializer": "^4.0.0", 58 | "@types/ember-data__store": "^4.0.0", 59 | "@types/ember-qunit": "^5.0.0", 60 | "@types/ember-resolver": "^5.0.11", 61 | "@types/ember-test-helpers": "^1.0.5", 62 | "@types/ember-testing-helpers": "^0.0.3", 63 | "@types/ember__application": "^4.0.0", 64 | "@types/ember__array": "^4.0.1", 65 | "@types/ember__component": "^4.0.4", 66 | "@types/ember__controller": "^4.0.0", 67 | "@types/ember__debug": "^4.0.1", 68 | "@types/ember__engine": "^4.0.0", 69 | "@types/ember__error": "^4.0.0", 70 | "@types/ember__object": "^4.0.1", 71 | "@types/ember__polyfills": "^4.0.0", 72 | "@types/ember__routing": "^4.0.4", 73 | "@types/ember__runloop": "^4.0.0", 74 | "@types/ember__service": "^4.0.0", 75 | "@types/ember__string": "^3.0.9", 76 | "@types/ember__template": "^4.0.0", 77 | "@types/ember__test": "^4.0.0", 78 | "@types/ember__test-helpers": "^2.6.1", 79 | "@types/ember__utils": "^4.0.0", 80 | "@types/qunit": "^2.9.0", 81 | "@types/rsvp": "^4.0.3", 82 | "@typescript-eslint/eslint-plugin": "5.30.6", 83 | "@typescript-eslint/parser": "5.30.6", 84 | "babel-eslint": "10.1.0", 85 | "broccoli-asset-rev": "^2.4.5", 86 | "ember-ajax": "^5.1.1", 87 | "ember-auto-import": "^1.11.3", 88 | "ember-cli": "^3.16.10", 89 | "ember-cli-dependency-checker": "^2.0.0", 90 | "ember-cli-deploy": "^1.0.2", 91 | "ember-cli-deploy-build": "^1.1.1", 92 | "ember-cli-deploy-git": "^1.3.3", 93 | "ember-cli-deploy-git-ci": "^1.0.1", 94 | "ember-cli-inject-live-reload": "^1.4.1", 95 | "ember-cli-shims": "^1.2.0", 96 | "ember-cli-sri": "^2.1.1", 97 | "ember-cli-typescript-blueprints": "^3.0.0", 98 | "ember-code-snippet": "^3.0.0", 99 | "ember-disable-prototype-extensions": "^1.1.3", 100 | "ember-export-application-global": "^2.0.1", 101 | "ember-load-initializers": "^2.1.2", 102 | "ember-qunit": "^5.1.5", 103 | "ember-resolver": "^8.0.3", 104 | "ember-source": "^3.16.10", 105 | "ember-source-channel-url": "^3.0.0", 106 | "ember-template-lint": "^4.3.0", 107 | "ember-truth-helpers": "^3.0.0", 108 | "ember-try": "^1.4.0", 109 | "eslint": "7.32.0", 110 | "eslint-config-prettier": "8.5.0", 111 | "eslint-plugin-decorator-position": "5.0.0", 112 | "eslint-plugin-ember": "10.6.1", 113 | "eslint-plugin-import": "^2.26.0", 114 | "eslint-plugin-json": "^3.1.0", 115 | "eslint-plugin-node": "^11.1.0", 116 | "eslint-plugin-prettier": "4.2.1", 117 | "eslint-plugin-qunit": "7.3.1", 118 | "eslint-plugin-simple-import-sort": "^7.0.0", 119 | "loader.js": "^4.7.0", 120 | "npm-run-all": "^4.1.5", 121 | "prettier": "2.7.1", 122 | "qunit": "^2.13.0", 123 | "qunit-dom": "^2.0.0", 124 | "release-it": "^14.13.1", 125 | "testem": "^3.4.2", 126 | "typescript": "^4.6.3" 127 | }, 128 | "engines": { 129 | "node": ">= 12", 130 | "yarn": "^1.22.18" 131 | }, 132 | "ember-addon": { 133 | "configPath": "tests/dummy/config" 134 | }, 135 | "homepage": "https://github.com/machty/ember-navigator", 136 | "resolutions": { 137 | "@ember/test-helpers": "^2.4.0", 138 | "ember-cli-babel": "^7.26.6", 139 | "@babel/parser": "7.16.4", 140 | "minimist": "^1.2.6" 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /addon/-private/routers/switch-router.ts: -------------------------------------------------------------------------------- 1 | import { NAVIGATE } from '../actions/types'; 2 | import { MountedNode } from '../mounted-node'; 3 | import { BaseRouter, handledAction, unhandledAction } from './base-router'; 4 | 5 | import type { NavigateAction, RouterActions } from '../actions/types'; 6 | import type { MountedNodeSet } from '../mounted-node'; 7 | import type { 8 | InitialStateOptions, 9 | ReducerResult, 10 | RouteableReducer, 11 | RouteableState, 12 | RouterReducer, 13 | RouterState, 14 | } from '../routeable'; 15 | import type { BaseOptions } from './base-router'; 16 | 17 | export type SwitchOptions = BaseOptions; 18 | 19 | export class SwitchRouter extends BaseRouter implements RouterReducer { 20 | defaultKey = 'SwitchRouterBase'; 21 | 22 | dispatch(action: RouterActions, state: RouterState) { 23 | let activeRouteState = state.routes[state.index]; 24 | let nextRouteState = this.dispatchTo([activeRouteState], action); 25 | 26 | if (nextRouteState) { 27 | if (activeRouteState === nextRouteState) { 28 | // action was handled with no change, just return prior state 29 | return handledAction(state); 30 | } else { 31 | // action was handled and state changed; we're not switching between 32 | // routes, but merely updating the current one. 33 | let routes = [...state.routes]; 34 | 35 | routes[state.index] = nextRouteState; 36 | 37 | return handledAction({ 38 | ...state, 39 | routes, 40 | }); 41 | } 42 | } 43 | 44 | switch (action.type) { 45 | case NAVIGATE: 46 | return this.navigate(action, state); 47 | } 48 | 49 | return unhandledAction(); 50 | } 51 | 52 | navigate(action: NavigateAction, state: RouterState): ReducerResult { 53 | // TODO: params! 54 | 55 | let activeRouteState = state.routes[state.index]; 56 | let routeName = action.payload.routeName; 57 | 58 | if (activeRouteState.routeName === routeName) { 59 | return handledAction(state); 60 | } 61 | 62 | return this.navigateAway(action, state); 63 | } 64 | 65 | navigateAway(action: NavigateAction, state: RouterState): ReducerResult { 66 | // TODO: it seems wasteful to deeply recurse on every unknown route. 67 | // consider adding a cache, or building one at the beginning? 68 | for (let i = 0; i < this.children.length; ++i) { 69 | if (state.index === i) { 70 | // skip the active route, which we already checked. 71 | continue; 72 | } 73 | 74 | let routeable = this.children[i]; 75 | let initialChildRouteState = this.resetChildRoute(routeable); 76 | 77 | if (routeable.name === action.payload.routeName) { 78 | return this.switchToRoute(state, initialChildRouteState, i); 79 | } else if (routeable.isRouter) { 80 | let navigationResult = routeable.dispatch(action, initialChildRouteState); 81 | 82 | if (navigationResult.handled) { 83 | return this.switchToRoute(state, navigationResult.state, i); 84 | } 85 | } 86 | } 87 | 88 | return unhandledAction(); 89 | } 90 | 91 | switchToRoute(state: RouterState, childRouteState: RouteableState, i: number) { 92 | let routes = [...state.routes]; 93 | 94 | routes[state.index] = this.resetChildRoute(this.children[state.index]); 95 | routes[i] = childRouteState; 96 | 97 | return handledAction({ 98 | ...state, 99 | routes, 100 | index: i, 101 | }); 102 | } 103 | 104 | getInitialState(options: InitialStateOptions = {}): RouterState { 105 | let childRoutes = this.children.map((c) => this.resetChildRoute(c)); 106 | 107 | return { 108 | key: options.key || this.defaultKey, 109 | params: {}, 110 | routeName: this.name, 111 | componentName: 'ecr-switch', 112 | routes: childRoutes, 113 | index: 0, 114 | }; 115 | } 116 | 117 | resetChildRoute(routeable: RouteableReducer): RouteableState { 118 | return routeable.getInitialState({ key: routeable.name }); 119 | } 120 | 121 | // accept new router state and use it to update the mounted node, 122 | // calling various lifecycle hooks as you go 123 | reconcile(routerState: RouterState, mountedNode: MountedNode) { 124 | let currentChildNodes = mountedNode.childNodes; 125 | let nextChildNodes: MountedNodeSet = {}; 126 | 127 | let activeChildRouteState = routerState.routes[routerState.index]; 128 | let currentActiveNode = currentChildNodes[activeChildRouteState.key]; 129 | 130 | if (!currentActiveNode) { 131 | currentActiveNode = new MountedNode( 132 | mountedNode.mountedRouter, 133 | mountedNode, 134 | activeChildRouteState 135 | ); 136 | } 137 | 138 | let childRouteableReducer = this.childRouteables[activeChildRouteState.routeName]; 139 | 140 | childRouteableReducer.reconcile(activeChildRouteState, currentActiveNode); 141 | 142 | nextChildNodes[activeChildRouteState.key] = currentActiveNode; 143 | 144 | Object.keys(currentChildNodes).forEach((key) => { 145 | if (nextChildNodes[key]) { 146 | return; 147 | } 148 | 149 | currentChildNodes[key].unmount(); 150 | }); 151 | 152 | // TODO: this ceremony is duplicated with stack router. consolidate? move all this into mountedNode.update?? 153 | mountedNode.childNodes = nextChildNodes; 154 | mountedNode.update(routerState); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | # npm version tags 9 | - /^v\d+.\d+.\d+(?:-(?:alpha|beta|rc)\.\d+)?/ 10 | pull_request: 11 | schedule: 12 | - cron: "0 4 * * 6" # Saturdays at 4am 13 | 14 | jobs: 15 | lint: 16 | name: Lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | with: 21 | fetch-depth: 1 22 | 23 | - name: Set up Volta 24 | uses: volta-cli/action@v1 25 | 26 | - name: Get package manager's global cache path 27 | id: global-cache-dir-path 28 | run: echo "::set-output name=dir::$(yarn cache dir)" 29 | 30 | - name: Cache package manager's global cache and node_modules 31 | id: cache-dependencies 32 | uses: actions/cache@v2 33 | with: 34 | path: | 35 | ${{ steps.global-cache-dir-path.outputs.dir }} 36 | node_modules 37 | key: ${{ runner.os }}-${{ matrix.node-version }}-${{ 38 | hashFiles('**/yarn.lock' 39 | ) }} 40 | restore-keys: | 41 | ${{ runner.os }}-${{ matrix.node-version }}- 42 | 43 | - name: Install Dependencies 44 | run: yarn install --frozen-lockfile 45 | if: | 46 | steps.cache-dependencies.outputs.cache-hit != 'true' 47 | 48 | - name: Lint 49 | run: yarn lint 50 | 51 | - name: Test that ts precompile works 52 | run: './node_modules/.bin/ember ts:precompile' 53 | 54 | test: 55 | name: Tests 56 | runs-on: ${{ matrix.os }} 57 | needs: lint 58 | 59 | strategy: 60 | matrix: 61 | os: [ubuntu-latest] 62 | browser: [chrome, firefox] 63 | 64 | steps: 65 | - uses: actions/checkout@v2 66 | with: 67 | fetch-depth: 1 68 | 69 | - name: Set up Volta 70 | uses: volta-cli/action@v1 71 | 72 | - name: Get package manager's global cache path 73 | id: global-cache-dir-path 74 | run: echo "::set-output name=dir::$(yarn cache dir)" 75 | 76 | - name: Cache package manager's global cache and node_modules 77 | id: cache-dependencies 78 | uses: actions/cache@v2 79 | with: 80 | path: | 81 | ${{ steps.global-cache-dir-path.outputs.dir }} 82 | node_modules 83 | key: ${{ runner.os }}-${{ matrix.node-version }}-${{ 84 | hashFiles('**/yarn.lock' 85 | ) }} 86 | restore-keys: | 87 | ${{ runner.os }}-${{ matrix.node-version }}- 88 | 89 | - name: Install Dependencies 90 | run: yarn install --frozen-lockfile 91 | if: | 92 | steps.cache-dependencies.outputs.cache-hit != 'true' 93 | 94 | - name: Test 95 | run: yarn test:ember --launch ${{ matrix.browser }} 96 | 97 | floating-dependencies: 98 | name: Floating Dependencies 99 | runs-on: ${{ matrix.os }} 100 | needs: lint 101 | 102 | strategy: 103 | matrix: 104 | os: [ubuntu-latest] 105 | browser: [chrome, firefox] 106 | 107 | steps: 108 | - uses: actions/checkout@v2 109 | with: 110 | fetch-depth: 1 111 | 112 | - name: Set up Volta 113 | uses: volta-cli/action@v1 114 | 115 | - name: Get package manager's global cache path 116 | id: global-cache-dir-path 117 | run: echo "::set-output name=dir::$(yarn cache dir)" 118 | 119 | - name: Cache package manager's global cache and node_modules 120 | id: cache-dependencies 121 | uses: actions/cache@v2 122 | with: 123 | path: | 124 | ${{ steps.global-cache-dir-path.outputs.dir }} 125 | node_modules 126 | key: ${{ runner.os }}-${{ matrix.node-version }}-${{ 127 | hashFiles('**/yarn.lock' 128 | ) }} 129 | restore-keys: | 130 | ${{ runner.os }}-${{ matrix.node-version }}- 131 | 132 | - name: Install Dependencies 133 | run: yarn install --frozen-lockfile 134 | if: | 135 | steps.cache-dependencies.outputs.cache-hit != 'true' 136 | 137 | - name: Test 138 | run: yarn test:ember --launch ${{ matrix.browser }} 139 | 140 | try-scenarios: 141 | name: Tests - ${{ matrix.ember-try-scenario }} 142 | runs-on: ubuntu-latest 143 | continue-on-error: ${{ matrix.allow-failure }} 144 | needs: test 145 | timeout-minutes: 15 146 | 147 | strategy: 148 | fail-fast: true 149 | matrix: 150 | ember-try-scenario: 151 | - ember-lts-3.16 152 | - ember-lts-3.20 153 | - ember-lts-3.24 154 | - ember-lts-3.28 155 | # - ember-release 156 | # - ember-default 157 | # - typescript-3.9 158 | # - embroider-safe 159 | # - embroider-optimized 160 | allow-failure: [false] 161 | # include: 162 | # - ember-try-scenario: ember-beta 163 | # allow-failure: true 164 | # - ember-try-scenario: ember-canary 165 | # allow-failure: true 166 | 167 | steps: 168 | - uses: actions/checkout@v2 169 | with: 170 | fetch-depth: 1 171 | 172 | - name: Set up Volta 173 | uses: volta-cli/action@v1 174 | 175 | - name: Get package manager's global cache path 176 | id: global-cache-dir-path 177 | run: echo "::set-output name=dir::$(yarn cache dir)" 178 | 179 | - name: Cache package manager's global cache and node_modules 180 | id: cache-dependencies 181 | uses: actions/cache@v2 182 | with: 183 | path: | 184 | ${{ steps.global-cache-dir-path.outputs.dir }} 185 | node_modules 186 | key: ${{ runner.os }}-${{ matrix.node-version }}-${{ 187 | hashFiles('**/yarn.lock' 188 | ) }} 189 | restore-keys: | 190 | ${{ runner.os }}-${{ matrix.node-version }}- 191 | 192 | - name: Install Dependencies 193 | run: yarn install --frozen-lockfile 194 | if: | 195 | steps.cache-dependencies.outputs.cache-hit != 'true' 196 | 197 | - name: Test 198 | env: 199 | EMBER_TRY_SCENARIO: ${{ matrix.ember-try-scenario }} 200 | run: node_modules/.bin/ember try:one $EMBER_TRY_SCENARIO 201 | -------------------------------------------------------------------------------- /addon/-private/utils/state.ts: -------------------------------------------------------------------------------- 1 | import { assert } from '@ember/debug'; 2 | 3 | import type { RouterState, RouteState } from '../routeable'; 4 | 5 | /** 6 | * Utilities to perform atomic operation with navigate state and routes. 7 | * 8 | * ```javascript 9 | * const state1 = {key: 'screen 1'}; 10 | * const state2 = NavigationStateUtils.push(state1, {key: 'screen 2'}); 11 | * ``` 12 | */ 13 | const StateUtils = { 14 | /** 15 | * Gets a route by key. If the route isn't found, returns `null`. 16 | */ 17 | get(state: RouterState, key: string) { 18 | return state.routes.find((route) => route.key === key) || null; 19 | }, 20 | 21 | /** 22 | * Returns the first index at which a given route's key can be found in the 23 | * routes of the navigation state: RouterState, or -1 if it is not present. 24 | */ 25 | indexOf(state: RouterState, key: string) { 26 | return state.routes.findIndex((route) => route.key === key); 27 | }, 28 | 29 | /** 30 | * Returns `true` at which a given route's key can be found in the 31 | * routes of the navigation state. 32 | */ 33 | has(state: RouterState, key: string) { 34 | return !!state.routes.some((route) => route.key === key); 35 | }, 36 | 37 | /** 38 | * Pushes a new route into the navigation state. 39 | * Note that this moves the index to the position to where the last route in the 40 | * stack is at. 41 | */ 42 | push(state: RouterState, route: RouteState) { 43 | assert( 44 | `should not push route with duplicated key ${route.key}`, 45 | StateUtils.indexOf(state, route.key) === -1 46 | ); 47 | 48 | const routes = state.routes.slice(); 49 | 50 | routes.push(route); 51 | 52 | return { 53 | ...state, 54 | index: routes.length - 1, 55 | routes, 56 | }; 57 | }, 58 | 59 | /** 60 | * Pops out a route from the navigation state. 61 | * Note that this moves the index to the position to where the last route in the 62 | * stack is at. 63 | */ 64 | pop(state: RouterState) { 65 | if (state.index <= 0) { 66 | // [Note]: Over-popping does not throw error. Instead, it will be no-op. 67 | return state; 68 | } 69 | 70 | const routes = state.routes.slice(0, -1); 71 | 72 | return { 73 | ...state, 74 | index: routes.length - 1, 75 | routes, 76 | }; 77 | }, 78 | 79 | /** 80 | * Sets the focused route of the navigation state by index. 81 | */ 82 | jumpToIndex(state: RouterState, index: number) { 83 | if (index === state.index) { 84 | return state; 85 | } 86 | 87 | assert(`invalid index ${index} to jump to`, !!state.routes[index]); 88 | 89 | return { 90 | ...state, 91 | index, 92 | }; 93 | }, 94 | 95 | /** 96 | * Sets the focused route of the navigation state by key. 97 | */ 98 | jumpTo(state: RouterState, key: string) { 99 | const index = StateUtils.indexOf(state, key); 100 | 101 | return StateUtils.jumpToIndex(state, index); 102 | }, 103 | 104 | /** 105 | * Sets the focused route to the previous route. 106 | */ 107 | back(state: RouterState) { 108 | const index = state.index - 1; 109 | const route = state.routes[index]; 110 | 111 | return route ? StateUtils.jumpToIndex(state, index) : state; 112 | }, 113 | 114 | /** 115 | * Sets the focused route to the next route. 116 | */ 117 | forward(state: RouterState) { 118 | const index = state.index + 1; 119 | const route = state.routes[index]; 120 | 121 | return route ? StateUtils.jumpToIndex(state, index) : state; 122 | }, 123 | 124 | /** 125 | * Replace a route by a key. 126 | * Note that this moves the index to the position to where the new route in the 127 | * stack is at and updates the routes array accordingly. 128 | */ 129 | replaceAndPrune(state: RouterState, key: string, route: RouterState) { 130 | const index = StateUtils.indexOf(state, key); 131 | const replaced = StateUtils.replaceAtIndex(state, index, route); 132 | 133 | return { 134 | ...replaced, 135 | routes: replaced.routes.slice(0, index + 1), 136 | }; 137 | }, 138 | 139 | /** 140 | * Replace a route by a key. 141 | * Note that this moves the index to the position to where the new route in the 142 | * stack is at. Does not prune the routes. 143 | * If preserveIndex is true then replacing the route does not cause the index 144 | * to change to the index of that route. 145 | */ 146 | replaceAt(state: RouterState, key: string, route: RouteState, preserveIndex = false) { 147 | const index = StateUtils.indexOf(state, key); 148 | const nextIndex = preserveIndex ? state.index : index; 149 | let nextState = StateUtils.replaceAtIndex(state, index, route); 150 | 151 | nextState.index = nextIndex; 152 | 153 | return nextState; 154 | }, 155 | 156 | /** 157 | * Replace a route by a index. 158 | * Note that this moves the index to the position to where the new route in the 159 | * stack is at. 160 | */ 161 | replaceAtIndex(state: RouterState, index: number, route: RouteState) { 162 | assert(`invalid index ${index} for replacing route ${route.key}`, !!state.routes[index]); 163 | 164 | if (state.routes[index] === route && index === state.index) { 165 | return state; 166 | } 167 | 168 | const routes = state.routes.slice(); 169 | 170 | routes[index] = route; 171 | 172 | return { 173 | ...state, 174 | index, 175 | routes, 176 | }; 177 | }, 178 | 179 | /** 180 | * Resets all routes. 181 | * Note that this moves the index to the position to where the last route in the 182 | * stack is at if the param `index` isn't provided. 183 | */ 184 | reset(state: RouterState, routes: RouteState[], index: number) { 185 | assert('invalid routes to replace', routes.length && Array.isArray(routes)); 186 | 187 | const nextIndex = index === undefined ? routes.length - 1 : index; 188 | 189 | if (state.routes.length === routes.length && state.index === nextIndex) { 190 | const compare = (route: RouteState, ii: number) => routes[ii] === route; 191 | 192 | if (state.routes.every(compare)) { 193 | return state; 194 | } 195 | } 196 | 197 | assert(`invalid index ${nextIndex} to reset`, !!routes[nextIndex]); 198 | 199 | return { 200 | ...state, 201 | index: nextIndex, 202 | routes, 203 | }; 204 | }, 205 | }; 206 | 207 | export default StateUtils; 208 | -------------------------------------------------------------------------------- /tests/unit/mounted-router-test.ts: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | 3 | import { route, stackRouter, switchRouter } from 'ember-navigator'; 4 | import { NavigatorRoute } from 'ember-navigator'; 5 | import { _TESTING_ONLY_normalize_keys } from 'ember-navigator/-private/key-generator'; 6 | import MountedRouter from 'ember-navigator/-private/mounted-router'; 7 | 8 | import type { NavigatorRouteConstructorParams } from 'ember-navigator'; 9 | import type { Resolver } from 'ember-navigator/-private/routeable'; 10 | 11 | interface TestEvent { 12 | id: number; 13 | type: string; 14 | key: string; 15 | } 16 | 17 | function buildTestResolver() { 18 | let events: TestEvent[] = []; 19 | let delegateId = 0; 20 | 21 | class Route extends NavigatorRoute { 22 | id: number = delegateId++; 23 | 24 | constructor(...params: NavigatorRouteConstructorParams) { 25 | super(...params); 26 | events.push({ id: this.id, type: 'constructor', key: this.node.key }); 27 | } 28 | 29 | update() { 30 | events.push({ id: this.id, type: 'update', key: this.node.key }); 31 | } 32 | 33 | unmount() { 34 | events.push({ id: this.id, type: 'unmount', key: this.node.key }); 35 | } 36 | 37 | mount() { 38 | events.push({ id: this.id, type: 'mount', key: this.node.key }); 39 | } 40 | } 41 | 42 | class TestResolver implements Resolver { 43 | id: number; 44 | constructor() { 45 | this.id = 0; 46 | } 47 | 48 | resolve() { 49 | return Route; 50 | } 51 | } 52 | 53 | let resolver = new TestResolver(); 54 | 55 | return { resolver, events }; 56 | } 57 | 58 | module('Unit - MountedRouter test', function (hooks) { 59 | hooks.beforeEach(() => _TESTING_ONLY_normalize_keys()); 60 | 61 | test('switch: navigation enters/updates the new route and unmounts the old one', function (assert) { 62 | let router = switchRouter('root', [route('foo'), route('bar')]); 63 | let { resolver, events } = buildTestResolver(); 64 | let mountedRouter = new MountedRouter(router, resolver); 65 | 66 | events.length = 0; 67 | mountedRouter.navigate({ routeName: 'bar' }); 68 | assert.deepEqual(events, [ 69 | { 70 | id: 2, 71 | key: 'bar', 72 | type: 'constructor', 73 | }, 74 | { 75 | id: 2, 76 | key: 'bar', 77 | type: 'mount', 78 | }, 79 | { 80 | id: 1, 81 | key: 'foo', 82 | type: 'unmount', 83 | }, 84 | { 85 | id: 0, 86 | key: 'SwitchRouterBase', 87 | type: 'update', 88 | }, 89 | ]); 90 | }); 91 | 92 | test('no-op navigations result in zero changes/lifecycle events', function (assert) { 93 | let router = switchRouter('root', [route('foo'), route('bar')]); 94 | let { resolver, events } = buildTestResolver(); 95 | let mountedRouter = new MountedRouter(router, resolver); 96 | 97 | events.length = 0; 98 | mountedRouter.navigate({ routeName: 'foo' }); 99 | assert.deepEqual(events, []); 100 | }); 101 | 102 | test('stack: initial state', function (assert) { 103 | let router = stackRouter('root', [route('foo'), route('bar')]); 104 | let { resolver, events } = buildTestResolver(); 105 | 106 | new MountedRouter(router, resolver); 107 | assert.deepEqual(events, [ 108 | { 109 | id: 0, 110 | key: 'StackRouterRoot', 111 | type: 'constructor', 112 | }, 113 | { 114 | id: 0, 115 | key: 'StackRouterRoot', 116 | type: 'mount', 117 | }, 118 | { 119 | id: 1, 120 | key: 'foo', 121 | type: 'constructor', 122 | }, 123 | { 124 | id: 1, 125 | key: 'foo', 126 | type: 'mount', 127 | }, 128 | ]); 129 | }); 130 | 131 | test('stack: no-op', function (assert) { 132 | let router = stackRouter('root', [route('foo'), route('bar')]); 133 | let { resolver, events } = buildTestResolver(); 134 | let mountedRouter = new MountedRouter(router, resolver); 135 | 136 | events.length = 0; 137 | mountedRouter.navigate({ routeName: 'foo' }); 138 | assert.deepEqual(events, []); 139 | }); 140 | 141 | test('stack: basic nav', function (assert) { 142 | let router = stackRouter('root', [route('foo'), route('bar')]); 143 | let { resolver, events } = buildTestResolver(); 144 | let mountedRouter = new MountedRouter(router, resolver); 145 | 146 | events.length = 0; 147 | mountedRouter.navigate({ routeName: 'bar' }); 148 | assert.deepEqual(events, [ 149 | { 150 | id: 2, 151 | key: 'id-1', 152 | type: 'constructor', 153 | }, 154 | { 155 | id: 2, 156 | key: 'id-1', 157 | type: 'mount', 158 | }, 159 | { 160 | id: 0, 161 | key: 'StackRouterRoot', 162 | type: 'update', 163 | }, 164 | ]); 165 | 166 | const fooRoute = mountedRouter.rootNode.childNodes['foo'].route; 167 | 168 | assert.strictEqual(fooRoute.key, 'foo'); 169 | 170 | const barRoute = mountedRouter.rootNode.childNodes['id-1'].route; 171 | 172 | assert.strictEqual(barRoute.name, 'bar'); 173 | assert.strictEqual(barRoute.key, 'id-1'); 174 | assert.strictEqual(barRoute.parent, fooRoute); 175 | assert.strictEqual(barRoute.parent?.parent?.name, 'root'); 176 | 177 | assert.strictEqual(barRoute.parentNamed('bar')?.name, 'bar'); 178 | assert.strictEqual(barRoute.parentNamed('bar2'), null); 179 | 180 | assert.strictEqual(barRoute.parent?.parentRoute, null); 181 | 182 | assert.strictEqual(barRoute.parentRouter?.name, 'root'); 183 | assert.strictEqual(barRoute.parent?.parentRouter?.name, 'root'); 184 | }); 185 | 186 | test('stack: basic nav with params', function (assert) { 187 | let router = stackRouter('root', [route('foo'), route('bar')]); 188 | let { resolver, events } = buildTestResolver(); 189 | let mountedRouter = new MountedRouter(router, resolver); 190 | 191 | events.length = 0; 192 | mountedRouter.navigate({ routeName: 'bar', params: { bar_id: 123 } }); 193 | assert.deepEqual(events, [ 194 | { 195 | id: 2, 196 | key: 'id-1', 197 | type: 'constructor', 198 | }, 199 | { 200 | id: 2, 201 | key: 'id-1', 202 | type: 'mount', 203 | }, 204 | { 205 | id: 0, 206 | key: 'StackRouterRoot', 207 | type: 'update', 208 | }, 209 | ]); 210 | }); 211 | 212 | test('stack: popping', function (assert) { 213 | let router = stackRouter('root', [route('foo'), route('bar')]); 214 | let { resolver, events } = buildTestResolver(); 215 | let mountedRouter = new MountedRouter(router, resolver); 216 | 217 | mountedRouter.navigate({ routeName: 'bar' }); 218 | events.length = 0; 219 | mountedRouter.navigate({ routeName: 'foo' }); 220 | assert.deepEqual(events, [ 221 | { 222 | id: 2, 223 | key: 'id-1', 224 | type: 'unmount', 225 | }, 226 | { 227 | id: 0, 228 | key: 'StackRouterRoot', 229 | type: 'update', 230 | }, 231 | ]); 232 | }); 233 | }); 234 | -------------------------------------------------------------------------------- /tests/unit/routers/stack-router-test.ts: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | 3 | import { route, stackRouter } from 'ember-navigator'; 4 | import { _TESTING_ONLY_normalize_keys } from 'ember-navigator/-private/key-generator'; 5 | import { batch, navigate as makeNavigateAction, pop } from 'ember-navigator/actions'; 6 | 7 | import { handle, navigate } from './helpers'; 8 | 9 | import type { RouterState } from 'ember-navigator/-private/routeable'; 10 | 11 | module('Unit - StackRouter test', function (hooks) { 12 | hooks.beforeEach(() => _TESTING_ONLY_normalize_keys()); 13 | 14 | test('it provides an overridable componentName', function (assert) { 15 | let children = [route('foo')]; 16 | let stackRouter1 = stackRouter('root', children); 17 | let stackRouter2 = stackRouter('root', children, { componentName: 'x-foo' }); 18 | 19 | assert.strictEqual(stackRouter1.componentName, 'ecr-stack'); 20 | assert.strictEqual(stackRouter2.componentName, 'x-foo'); 21 | }); 22 | 23 | const DEFAULT_STATE = { 24 | componentName: 'ecr-stack', 25 | key: 'StackRouterRoot', 26 | params: {}, 27 | routeName: 'root', 28 | index: 0, 29 | headerComponentName: 'ecr-header', 30 | headerMode: 'float', 31 | routes: [ 32 | { 33 | key: 'foo', 34 | params: {}, 35 | routeName: 'foo', 36 | componentName: 'foo', 37 | }, 38 | ], 39 | }; 40 | 41 | test('it provides a default state', function (assert) { 42 | let children = [route('foo')]; 43 | let router = stackRouter('root', children); 44 | let state = router.getInitialState(); 45 | 46 | assert.deepEqual(state, DEFAULT_STATE); 47 | }); 48 | 49 | test('it supports navigating to deeply nested inactive routes', function (assert) { 50 | let router = stackRouter('root', [ 51 | route('a'), 52 | stackRouter('deeply', [stackRouter('nested', [route('b')])]), 53 | ]); 54 | let initialState = router.getInitialState(); 55 | let state = navigate(router, initialState, 'b'); 56 | 57 | assert.deepEqual((state.routes[1] as RouterState).routes, [ 58 | { 59 | componentName: 'ecr-stack', 60 | headerComponentName: 'ecr-header', 61 | headerMode: 'float', 62 | index: 0, 63 | key: 'nested', 64 | params: {}, 65 | routeName: 'nested', 66 | routes: [ 67 | { 68 | componentName: 'b', 69 | key: 'b', 70 | params: {}, 71 | routeName: 'b', 72 | }, 73 | ], 74 | } as RouterState, 75 | ]); 76 | }); 77 | 78 | test('it supports deeply nested sibling stack routers', function (assert) { 79 | let router = stackRouter('root', [ 80 | stackRouter('a', [route('aa')]), 81 | stackRouter('b', [route('bb')]), 82 | ]); 83 | let initialState = router.getInitialState(); 84 | let state = navigate(router, initialState, 'bb'); 85 | 86 | assert.strictEqual(state.index, 1); 87 | }); 88 | 89 | test('it supports nesting', function (assert) { 90 | let router = stackRouter('root', [stackRouter('nested', [route('foo')])]); 91 | 92 | let initialState = router.getInitialState(); 93 | 94 | assert.deepEqual(initialState, { 95 | componentName: 'ecr-stack', 96 | index: 0, 97 | key: 'StackRouterRoot', 98 | params: {}, 99 | routeName: 'root', 100 | headerComponentName: 'ecr-header', 101 | headerMode: 'float', 102 | routes: [ 103 | { 104 | componentName: 'ecr-stack', 105 | headerComponentName: 'ecr-header', 106 | headerMode: 'float', 107 | index: 0, 108 | key: 'nested', 109 | params: {}, 110 | routeName: 'nested', 111 | routes: [ 112 | { 113 | componentName: 'foo', 114 | key: 'foo', 115 | params: {}, 116 | routeName: 'foo', 117 | }, 118 | ], 119 | } as RouterState, 120 | ], 121 | }); 122 | 123 | let state2 = navigate(router, initialState, { routeName: 'foo', key: 'other' }); 124 | 125 | assert.deepEqual((state2.routes[0] as RouterState).routes, [ 126 | { 127 | componentName: 'foo', 128 | key: 'foo', 129 | params: {}, 130 | routeName: 'foo', 131 | }, 132 | { 133 | componentName: 'foo', 134 | key: 'other', 135 | params: {}, 136 | routeName: 'foo', 137 | }, 138 | ]); 139 | }); 140 | 141 | test('it supports navigation', function (assert) { 142 | let router = stackRouter('root', [route('foo'), route('bar')]); 143 | let initialState = router.getInitialState(); 144 | let state = navigate(router, initialState, { routeName: 'bar' }); 145 | 146 | assert.deepEqual(state, { 147 | componentName: 'ecr-stack', 148 | headerComponentName: 'ecr-header', 149 | headerMode: 'float', 150 | index: 1, 151 | key: 'StackRouterRoot', 152 | params: {}, 153 | routeName: 'root', 154 | routes: [ 155 | { 156 | componentName: 'foo', 157 | key: 'foo', 158 | params: {}, 159 | routeName: 'foo', 160 | }, 161 | { 162 | key: 'id-1', 163 | params: {}, 164 | routeName: 'bar', 165 | componentName: 'bar', 166 | }, 167 | ], 168 | }); 169 | 170 | // key-less navigation to a route that's already on the stack is a no-op 171 | let state2 = navigate(router, state, { routeName: 'bar' }); 172 | 173 | assert.strictEqual(state, state2); 174 | 175 | // providing a key causes a push 176 | let state3 = navigate(router, state2, { routeName: 'bar', key: 'lol' }); 177 | 178 | assert.strictEqual(state3.index, 2); 179 | assert.strictEqual(state3.routes[2].key, 'lol'); 180 | }); 181 | 182 | test('it supports popping the stack', function (assert) { 183 | let children = [route('foo'), route('bar')]; 184 | let router = stackRouter('root', children); 185 | let initialState = router.getInitialState(); 186 | 187 | assert.deepEqual(initialState, DEFAULT_STATE); 188 | 189 | let state2 = navigate(router, initialState, { routeName: 'bar' }); 190 | 191 | let state3 = handle(router, pop(), state2); 192 | 193 | assert.deepEqual(state3.routes, DEFAULT_STATE.routes); 194 | }); 195 | 196 | test('it supports letting the deepest stack pop the route', function (assert) { 197 | let router = stackRouter('root', [route('a'), stackRouter('nested', [route('b'), route('c')])]); 198 | let initialState = router.getInitialState(); 199 | let state2 = navigate(router, initialState, { routeName: 'nested' }); 200 | let state3 = navigate(router, state2, { routeName: 'c' }); 201 | let state4 = handle(router, pop(), state3); 202 | 203 | assert.strictEqual(state4.index, 1); 204 | }); 205 | 206 | test('it supports navigating with params', function (assert) { 207 | let router = stackRouter('root', [route('foo')]); 208 | let state = router.getInitialState(); 209 | let state2 = navigate(router, state, { routeName: 'foo', key: '1', params: { id: 4 } }); 210 | let state3 = navigate(router, state2, { routeName: 'foo', key: '2', params: { id: 5 } }); 211 | let state4 = navigate(router, state3, { routeName: 'foo', key: '3', params: { id: 6 } }); 212 | let allParams = state4.routes.map((r) => ({ params: r.params })); 213 | 214 | assert.deepEqual(allParams, [ 215 | { params: {} }, 216 | { params: { id: 4 } }, 217 | { params: { id: 5 } }, 218 | { params: { id: 6 } }, 219 | ]); 220 | }); 221 | 222 | test('it supports sending batch actions to support popTo + 1', function (assert) { 223 | let router = stackRouter('root', [ 224 | route('home'), 225 | route('other'), 226 | route('login-1'), 227 | route('login-2'), 228 | ]); 229 | 230 | let pushRoutes = batch({ 231 | actions: [ 232 | makeNavigateAction({ routeName: 'other' }), 233 | makeNavigateAction({ routeName: 'login-1' }), 234 | makeNavigateAction({ routeName: 'login-2' }), 235 | ], 236 | }); 237 | 238 | let popLoginFlow = batch({ 239 | actions: [makeNavigateAction({ routeName: 'login-1' }), pop()], 240 | }); 241 | 242 | let state = router.getInitialState(); 243 | let state2 = handle(router, pushRoutes, state); 244 | 245 | assert.deepEqual( 246 | state2.routes.map((r) => r.routeName), 247 | ['home', 'other', 'login-1', 'login-2'] 248 | ); 249 | 250 | let state3 = handle(router, popLoginFlow, state2); 251 | 252 | assert.deepEqual( 253 | state3.routes.map((r) => r.routeName), 254 | ['home', 'other'] 255 | ); 256 | }); 257 | }); 258 | -------------------------------------------------------------------------------- /addon/-private/routers/stack-router.ts: -------------------------------------------------------------------------------- 1 | import { BACK, BATCH, NAVIGATE, POP } from '../actions/types'; 2 | import { MountedNode } from '../mounted-node'; 3 | import StateUtils from '../utils/state'; 4 | import { BaseRouter, handledAction, unhandledAction } from './base-router'; 5 | 6 | import type { 7 | BackAction, 8 | BatchAction, 9 | NavigateAction, 10 | PopAction, 11 | RouterActions, 12 | } from '../actions/types'; 13 | import type { MountedNodeSet } from '../mounted-node'; 14 | import type { 15 | InitialStateOptions, 16 | ReducerResult, 17 | RouterReducer, 18 | RouterState, 19 | StackRouterState, 20 | } from '../routeable'; 21 | import type { BaseOptions } from './base-router'; 22 | 23 | export interface StackOptions extends BaseOptions { 24 | headerComponentName?: string; 25 | headerMode?: string; // TODO: type enum 26 | } 27 | 28 | export class StackRouter extends BaseRouter implements RouterReducer { 29 | dispatch(action: RouterActions, state: RouterState) { 30 | switch (action.type) { 31 | case NAVIGATE: 32 | return this.navigate(action, state); 33 | case BACK: 34 | return this.goBack(action, state); 35 | case POP: 36 | return this.popStack(action, state); 37 | case BATCH: 38 | return this.batch(action, state); 39 | } 40 | 41 | return unhandledAction(); 42 | } 43 | 44 | navigate(action: NavigateAction, state: RouterState): ReducerResult { 45 | return ( 46 | this.delegateToActiveChildRouters(action, state) || 47 | this.navigateToPreexisting(action, state) || 48 | this.navigateToNew(action, state) || 49 | unhandledAction() 50 | ); 51 | } 52 | 53 | delegateToActiveChildRouters(action: RouterActions, state: RouterState): ReducerResult | void { 54 | // Traverse routes from the top of the stack to the bottom, so the 55 | // active route has the first opportunity, then the one before it, etc. 56 | let reversedStates = state.routes.slice().reverse(); 57 | let nextRouteState = this.dispatchTo(reversedStates, action); 58 | 59 | if (!nextRouteState) { 60 | return; 61 | } 62 | 63 | const newState = StateUtils.replaceAndPrune(state, nextRouteState.key, nextRouteState); 64 | 65 | return handledAction(newState); 66 | } 67 | 68 | navigateToPreexisting(action: NavigateAction, state: RouterState): ReducerResult | void { 69 | let navParams = action.payload; 70 | 71 | if (!this.childRouteables[navParams.routeName]) { 72 | return; 73 | } 74 | 75 | const lastRouteIndex = state.routes.findIndex((r) => { 76 | if (navParams.key) { 77 | return r.key === navParams.key; 78 | } else { 79 | return r.routeName === navParams.routeName; 80 | } 81 | }); 82 | 83 | if (lastRouteIndex === -1) { 84 | return; 85 | } 86 | 87 | // If index is unchanged and params are not being set, leave state identity intact 88 | if (state.index === lastRouteIndex && !navParams.params) { 89 | return handledAction(state); 90 | } 91 | 92 | // Remove the now unused routes at the tail of the routes array 93 | const routes = state.routes.slice(0, lastRouteIndex + 1); 94 | 95 | // Apply params if provided, otherwise leave route identity intact 96 | if (navParams.params) { 97 | const route = state.routes[lastRouteIndex]; 98 | 99 | routes[lastRouteIndex] = { 100 | ...route, 101 | params: { 102 | ...route.params, 103 | ...navParams.params, 104 | }, 105 | }; 106 | } 107 | 108 | // Return state with new index 109 | return handledAction({ 110 | ...state, 111 | index: lastRouteIndex, 112 | routes, 113 | }); 114 | } 115 | 116 | navigateToNew(action: NavigateAction, state: RouterState): ReducerResult | void { 117 | let navParams = action.payload; 118 | 119 | // TODO: it seems wasteful to deeply recurse on every unknown route. 120 | // consider adding a cache, or building one at the beginning? 121 | for (let i = 0; i < this.children.length; ++i) { 122 | let routeable = this.children[i]; 123 | 124 | if (routeable.name === navParams.routeName) { 125 | let initialState = routeable.getInitialState({ 126 | key: navParams.key, 127 | params: navParams.params, 128 | }); 129 | 130 | return handledAction(StateUtils.push(state, initialState)); 131 | } else { 132 | let initialState = routeable.getInitialState(); 133 | // not a match, recurse 134 | 135 | let navigationResult = routeable.dispatch(action, initialState); 136 | 137 | if (navigationResult.handled) { 138 | let childRouteState = navigationResult.state; 139 | 140 | return handledAction(StateUtils.push(state, childRouteState)); 141 | } 142 | } 143 | } 144 | } 145 | 146 | goBack(action: BackAction, _state: RouterState): ReducerResult { 147 | let key = action.payload.key; 148 | 149 | if (key) { 150 | // If set, navigation will go back from the given key 151 | // const backRoute = state.routes.find(route => route.key === key); 152 | // backRouteIndex = backRoute ? state.routes.indexOf(backRoute) : -1; 153 | return notImplemented('goBack with key'); 154 | } else if (key === null) { 155 | // navigation will go back anywhere. 156 | return notImplemented('goBack with null key'); 157 | } else { 158 | // TODO: what happens here? 159 | return notImplemented('goBack with missing key'); 160 | } 161 | } 162 | 163 | popStack(action: PopAction, state: RouterState): ReducerResult { 164 | let result = this.delegateToActiveChildRouters(action, state); 165 | 166 | if (result) { 167 | return result; 168 | } 169 | 170 | // determine the index to go back *from*. In this case, n=1 means to go 171 | // back from state.index, as if it were a normal "BACK" action 172 | const n = action.payload.n || 1; 173 | const backRouteIndex = Math.max(1, state.index - n + 1); 174 | 175 | return backRouteIndex > 0 176 | ? handledAction({ 177 | ...state, 178 | routes: state.routes.slice(0, backRouteIndex), 179 | index: backRouteIndex - 1, 180 | }) 181 | : unhandledAction(); 182 | } 183 | 184 | batch(action: BatchAction, state: RouterState): ReducerResult { 185 | let newState = state; 186 | 187 | action.payload.actions.forEach((subaction) => { 188 | let result = this.dispatch(subaction, newState); 189 | 190 | if (result.handled) { 191 | newState = result.state; 192 | } 193 | // TODO: what if not handled? currently each batch will be treated as handled 194 | }); 195 | 196 | return handledAction(newState); 197 | } 198 | 199 | getInitialState(options: InitialStateOptions = {}): StackRouterState { 200 | const initialRouteName = this.routeNames[0]; 201 | let childRouteableState = this.childRouteables[initialRouteName].getInitialState({ 202 | key: initialRouteName, 203 | }); 204 | 205 | return { 206 | key: options.key || 'StackRouterRoot', 207 | index: 0, 208 | componentName: this.componentName, 209 | 210 | // TODO: in RN, the root stack navigator doesn't have params/routeName; are we doing it wrong? 211 | params: {}, 212 | routeName: this.name, 213 | headerComponentName: (this.options as StackOptions).headerComponentName || 'ecr-header', 214 | headerMode: (this.options as StackOptions).headerMode || 'float', 215 | routes: [childRouteableState], 216 | }; 217 | } 218 | 219 | reconcile(routerState: RouterState, mountedNode: MountedNode) { 220 | let currentChildNodes = mountedNode.childNodes; 221 | let nextChildNodes: MountedNodeSet = {}; 222 | 223 | let parent: MountedNode = mountedNode; 224 | 225 | routerState.routes.forEach((childRouteState) => { 226 | let childNode = currentChildNodes[childRouteState.key]; 227 | 228 | if (childNode && childNode.routeableState === childRouteState) { 229 | // child state hasn't changed in any way, don't recurse/update 230 | // TODO: the next two lines are duplicated below... how can we DRY/clean it 231 | nextChildNodes[childRouteState.key] = childNode; 232 | parent = childNode; 233 | 234 | return; 235 | } else if (!childNode) { 236 | childNode = new MountedNode(mountedNode.mountedRouter, parent, childRouteState); 237 | } 238 | 239 | let childRouteableReducer = this.childRouteables[childRouteState.routeName]; 240 | 241 | childRouteableReducer.reconcile(childRouteState, childNode); 242 | 243 | nextChildNodes[childRouteState.key] = childNode; 244 | parent = childNode; 245 | }); 246 | 247 | Object.keys(currentChildNodes).forEach((key) => { 248 | if (nextChildNodes[key]) { 249 | return; 250 | } 251 | 252 | currentChildNodes[key].unmount(); 253 | }); 254 | 255 | mountedNode.childNodes = nextChildNodes; 256 | mountedNode.update(routerState); 257 | } 258 | } 259 | 260 | function notImplemented(message: string) { 261 | console.error(`NOT IMPLEMENTED: ${message}`); 262 | 263 | return unhandledAction(); 264 | } 265 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ember-navigator ![ci badge](https://github.com/machty/ember-navigator/actions/workflows/ci.yml/badge.svg) 2 | 3 | 4 | A routing/navigation library for Ember.js suitable for mobile app UI flows, modeled after 5 | [React Navigation](https://reactnavigation.org/) and a few other mobile-centric navigation 6 | libraries that have popped up over the years. 7 | 8 | ## Status: Beta 9 | 10 | Ember Navigator is beta, under-documented, but used in production 11 | [FutureProof Retail](https://github.com/futureproofRetail/) 12 | and [Yapp](https://github.com/yapplabs) applications (and others) since 2020. 13 | 14 | ## Motivation 15 | 16 | Ember.js's Router is robust and battle-tested, but is not well-suited to common UI flows for mobile 17 | applications. For instance, it is a common mobile UI pattern to offer a tab bar of navigation buttons 18 | for different sections of the app, and for each section of the app to maintain/remember/restore 19 | its internal navigation state (which may be stack-based) when navigating between the tabs. This would 20 | be very difficult to model in the Ember.js Router, but is much easier with the primitives that 21 | Ember Navigator provides. 22 | 23 | ## Installation 24 | 25 | `ember install ember-navigator` 26 | 27 | ## Concepts 28 | 29 | - [Router "map"](#router-map) 30 | - [Router State](#router-state) 31 | - [Actions](#actions) 32 | - ["Mounting"](#-mounting-) 33 | - [Routes](#routes) 34 | - [URLs and ember-navigator](#urls-and-ember-navigator) 35 | 36 | ### Router "map" 37 | 38 | The router map should seem familiar from ember's router, with some differences. Here's an example of what the router 39 | map might looks like for an app similar to Twitter: 40 | 41 | ```js 42 | import { 43 | mount, 44 | route, 45 | stackRouter, 46 | tabRouter 47 | } from 'ember-navigator'; 48 | 49 | this.mountedRouter = mount( 50 | tabRouter('tabs', [ 51 | stackRouter('timelineTab', [ 52 | route('timeline'), 53 | route('tweet'), 54 | route('profile'), 55 | route('photos'), 56 | route('video'), 57 | // etc... 58 | ]), 59 | stackRouter('searchTab', [ 60 | route('trends'), 61 | tabRouter('searchResultsTabs', [ 62 | stackRouter('topTab', [ 63 | route('tweet'), 64 | route('profile'), 65 | // etc... 66 | ]), 67 | stackRouter('latestTab', [ 68 | route('timeline'), 69 | route('tweet'), 70 | // etc... 71 | ]), 72 | // etc... 73 | ]), 74 | route('group'), 75 | route('members'), 76 | // etc... 77 | ]), 78 | stackRouter('notificationsTab', [ 79 | route('notifications'), 80 | route('tweet'), 81 | route('profile'), 82 | // etc... 83 | ]), 84 | stackRouter('messagesTab', [ 85 | route('inbox'), 86 | route('thread'), 87 | route('tweet'), 88 | // etc... 89 | ]), 90 | ]) 91 | ); 92 | ``` 93 | 94 | You can see from the above example that different types of routers can have different logic about how they handle 95 | navigation and rendering. 96 | 97 | * A `TabRouter` has only one of its children rendered at a time, but remembers the state of each tab as the user switches between them. 98 | * A `StackRouter` starts off with the first declared child route as its only item and will push additional items onto its stack as the user drills down and pops items off the stack as the user taps back, for example. Only the top-most item of the stack is rendered. 99 | * A `SwitchRouter` (not shown in this example) has only one of its children rendered at a time, and resets the state of each child when switching between them. 100 | 101 | Besides these three Router implementations that are included in ember-navigator, you can write your own router classes too, either by subclassing one of these three, or subclassing the router base class that ember-navigator provides. 102 | 103 | One thing to note in the above example is that some routes are shown under more than one stackRoute. For example a profile screen in Twitter is available from many different contexts. You could even navigate to a profile screen and then another profile screen of another user on the same stack. This is an example of a feature of ember-navigator which is very difficult to achieve using the Ember router. 104 | 105 | [You may ask yourself][1], "where do I put this code?" There is not currently a prescriptive or opinionated answer to this question in ember-navigator. The mountedRouter property needs to be passed to a component for rendering eventually. You could do the router map definition and mounting in a service. The dummy app in this repository does it in the application controller. 106 | 107 | ### Router State 108 | 109 | Like "outlet" state in vanilla Ember (or redux reducer state), this is a structure of plain old Javascript objects and arrays 110 | that is built up by the various routers and passed to the various navigator components for rendering. 111 | 112 |
113 | Here is an annotated example of what a snapshot in time of router state might look like: 114 | 115 | ```js 116 | { 117 | // The routeName corresponds to the name given in the router map 118 | "routeName": "tabs", 119 | // The `index` designates which child route is active -- in this case, it is the first tab 120 | "index": 0, 121 | // The key property should uniquely identify this route and it's content. Routers may use 122 | // this information for navigation purposes. 123 | "key": "TabRouter", 124 | // The component name that will be used to render this node of the router 125 | "componentName": "ecr-switch", 126 | // The children of this node, i.e. the various tabs, in order 127 | "routes": [ 128 | { 129 | "key": "timelineTab", 130 | // The index at this level indicates that the second item of this stack route is active 131 | "index": 1, 132 | // The children of this node, i.e. the items in this stack 133 | "routes": [ 134 | { 135 | // Params are used by the route and component to fetch & render the appropriate content 136 | "params": { 137 | "timeline_id": "bf98e08e-d286-46c7-9faa-780e8ff69ce9" 138 | }, 139 | // Corresponds to the string provided in the router map 140 | "routeName": "timeline", 141 | "key": "timeline:bf98e08e-d286-46c7-9faa-780e8ff69ce9", 142 | "componentName": "timeline" 143 | }, 144 | // This is the active tab, so the item below represents the active route that should 145 | // currently be rendered to the screen. i.e. the user is looking at a tweet 146 | { 147 | "params": { 148 | "tweet_id": "f2ee81ef-3291-4397-877e-2a27a50a19bc" 149 | }, 150 | "routeName": "tweet", 151 | "key": "tweet:f2ee81ef-3291-4397-877e-2a27a50a19bc", 152 | "componentName": "tweet" 153 | } 154 | ], 155 | "componentName": "ecr-stack", 156 | "params": {}, 157 | "routeName": "timelineTab" 158 | }, 159 | { 160 | "key": "searchTab", 161 | "index": 0, 162 | "routes": [ 163 | { 164 | "params": {}, 165 | "routeName": "trends", 166 | "key": "trends", 167 | "componentName": "trends" 168 | } 169 | ], 170 | "componentName": "ecr-stack", 171 | "params": {}, 172 | "routeName": "searchTab", 173 | }, 174 | { 175 | "key": "notificationsTab", 176 | "index": 0, 177 | "routes": [ 178 | { 179 | "params": {}, 180 | "routeName": "notifications", 181 | "key": "notifications", 182 | "componentName": "notifications" 183 | } 184 | ], 185 | "componentName": "ecr-stack", 186 | "params": {}, 187 | "routeName": "notificationsTab", 188 | }, 189 | // Note that while the messages tab is not currently active and therefor is not rendered to 190 | // to the screen, it has two children (inbox > thread). When the user does switch to this 191 | // tab, she will be looking at the thread, 192 | { 193 | "key": "messagesTab", 194 | "index": 1, 195 | "routes": [ 196 | { 197 | "params": {}, 198 | "routeName": "inbox", 199 | "key": "inbox", 200 | "componentName": "inbox" 201 | }, 202 | { 203 | "params": { 204 | "thread_id": "31b489e4-9e91-43bc-a7dc-0060dd8434b1" 205 | }, 206 | "routeName": "thread", 207 | "key": "thread:31b489e4-9e91-43bc-a7dc-0060dd8434b1", 208 | "componentName": "thread" 209 | } 210 | ], 211 | "componentName": "ecr-stack", 212 | "params": {}, 213 | "routeName": "messagesTab" 214 | } 215 | ] 216 | } 217 | ``` 218 |
219 | 220 | 221 | ### Actions 222 | 223 | Actions like `navigate` and `pop` are delegated to the active routers and result in changes to the router state, which in turn 224 | results in re-rendering. 225 | 226 | Given the map example earlier in this README, the following call to `navigate` would push a new profile screen onto the current active stack. If the profile already exists in the active stack, it would instead pop items off to return to the profile. 227 | 228 | ```js 229 | this.mountedRouter.navigate({ 230 | routeName: 'profile', 231 | params: { profile_id: '42' }, 232 | key: 'profile:42' 233 | }); 234 | ``` 235 | 236 | This call to `pop` would remove the top item of the stack and make the item underneath it active. 237 | 238 | ```js 239 | this.mountedRouter.pop(); 240 | ``` 241 | 242 | The behavior described in response to `navigate` and `pop` is dependent on the *Router implementations, which are able to handle abstract actions and respond with updated state if they elect to handle the action. 243 | 244 | There are a number of action types defined in the ember-navigator codebase, but the built-in routers handle only a small subset of them. 245 | 246 | ### "Mounting" 247 | 248 | The process of instantiating the router map into an active router is done by passing the definition to "mount" and the result is 249 | an instance of the `MountedRouter` class. It in turn has a tree of `MountedNode` instances. A MountedNode is the "internal" 250 | stateful node that the routing API doesn't have access to. 251 | 252 | ### Routes 253 | 254 | The `MountedRouter` resolves route definitions to `NavigatorRoute` instances. This is a public API that has a reference to the underlying 255 | `MountedNode`. The `NavigatorRoute` instance is provided to the rendered component. ember-navigator provides a `NavigatorRoute` base class 256 | to extend your classes from. These `NavigatorRoute` instances are instantiated via the Ember container under the type `navigator-route`. 257 | So a "tweet" route would be resolved via the container as `navigator-route:tweet`, which Ember would look for, by default, in 258 | `app/navigator-routes/tweet.js`. If a named NavigatorRoute is not found, it will look up `navigator-route:basic`. ember-navgiator 259 | exports NavigatorRoute to this location, but you are encouraged to override it with your own implementation by creating a file at 260 | `app/navigator-routes/basic.js` in your app. 261 | 262 | ### URLs and ember-navigator 263 | 264 | There is no built-in support for URLs with ember-navigator. The arbitrary depth and items of stacks and the unrendered state of 265 | tabs makes URLs a complex challenge with a variety of viable solutions from a product and UX perspective. As a technical matter, 266 | it is possible to achieve deep linking and serialization of URLs by integrating ember-navigator with a wildcard route of the 267 | Ember router or with the location service directly. This is not a minor undertaking, though. 268 | 269 | ## Running the example app 270 | 271 | * `ember serve` 272 | * Visit your app at [http://localhost:4200](http://localhost:4200). 273 | 274 | ## Running Tests 275 | 276 | * `yarn test` (Runs `ember try:each` to test your addon against multiple Ember versions) 277 | * `ember test` 278 | * `ember test --server` 279 | 280 | ## Building 281 | 282 | * `ember build` 283 | 284 | For more information on using ember-cli, visit [https://ember-cli.com/](https://ember-cli.com/). 285 | 286 | [1]: https://youtu.be/5IsSpAOD6K8?t=48 287 | 288 | ## Releasing / Publishing to NPM 289 | 290 | ``` 291 | yarn release 292 | ``` 293 | --------------------------------------------------------------------------------