├── 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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
20 | {{#each this.links as |l|}}
21 |
22 | {{l.routeName}}
23 |
37 |
38 | {{/each}}
39 |
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 
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 |
--------------------------------------------------------------------------------