feat(router): add navigation hooks (#21709)

This commit is contained in:
Liam DeBeasi
2020-07-17 11:08:16 -04:00
committed by GitHub
parent fa93dffdb4
commit 77464ef21a
16 changed files with 1109 additions and 13 deletions

View File

@ -70,9 +70,17 @@ export class Router implements ComponentInterface {
}
@Listen('popstate', { target: 'window' })
protected onPopState() {
protected async onPopState() {
const direction = this.historyDirection();
const path = this.getPath();
let path = this.getPath();
const canProceed = await this.runGuards(path);
if (canProceed !== true) {
if (typeof canProceed === 'object') {
path = parsePath(canProceed.redirect);
}
return false;
}
console.debug('[ion-router] URL changed -> update nav', path, direction);
return this.writeNavStateRoot(path, direction);
}
@ -85,6 +93,21 @@ export class Router implements ComponentInterface {
});
}
/** @internal */
@Method()
async canTransition() {
const canProceed = await this.runGuards();
if (canProceed !== true) {
if (typeof canProceed === 'object') {
return canProceed.redirect;
} else {
return false;
}
}
return true;
}
/**
* Navigate to the specified URL.
*
@ -92,14 +115,25 @@ export class Router implements ComponentInterface {
* @param direction The direction of the animation. Defaults to `"forward"`.
*/
@Method()
push(url: string, direction: RouterDirection = 'forward', animation?: AnimationBuilder) {
async push(url: string, direction: RouterDirection = 'forward', animation?: AnimationBuilder) {
if (url.startsWith('.')) {
url = (new URL(url, window.location.href)).pathname;
}
console.debug('[ion-router] URL pushed -> updating nav', url, direction);
const path = parsePath(url);
const queryString = url.split('?')[1];
let path = parsePath(url);
let queryString = url.split('?')[1];
const canProceed = await this.runGuards(path);
if (canProceed !== true) {
if (typeof canProceed === 'object') {
path = parsePath(canProceed.redirect);
queryString = canProceed.redirect.split('?')[1];
} else {
return false;
}
}
this.setPath(path, direction, queryString);
return this.writeNavStateRoot(path, direction, animation);
}
@ -191,6 +225,7 @@ export class Router implements ComponentInterface {
// lookup redirect rule
const redirects = readRedirects(this.el);
const redirect = routeRedirect(path, redirects);
let redirectFrom: string[] | null = null;
if (redirect) {
this.setPath(redirect.to!, direction);
@ -237,6 +272,25 @@ export class Router implements ComponentInterface {
}
return resolve;
}
private async runGuards(to: string[] | null = this.getPath(), from: string[] | null = parsePath(this.previousPath)) {
if (!to || !from) { return true; }
const routes = readRoutes(this.el);
const toChain = routerPathToChain(to, routes);
const fromChain = routerPathToChain(from, routes);
const beforeEnterHook = toChain && toChain[toChain.length - 1].beforeEnter;
const beforeLeaveHook = fromChain && fromChain[fromChain.length - 1].beforeLeave;
const canLeave = beforeLeaveHook ? await beforeLeaveHook() : true;
if (canLeave === false || typeof canLeave === 'object') { return canLeave; }
const canEnter = beforeEnterHook ? await beforeEnterHook() : true;
if (canEnter === false || typeof canEnter === 'object') { return canEnter; }
return true;
}
private async writeNavState(
node: HTMLElement | undefined, chain: RouteChain, direction: RouterDirection,

View File

@ -0,0 +1,128 @@
import { newE2EPage } from '@stencil/core/testing';
test('router: guards - href - allow/allow', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 1: beforeEnter: allow, beforeLeave: allow
await setBeforeEnterHook(page, 'allow');
const href = await page.$('#href');
await href.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/home');
});
test('router: guards - href - block/allow', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 2: beforeEnter: block, beforeLeave: allow
await setBeforeEnterHook(page, 'block');
const href = await page.$('#href');
await href.click();
await page.waitForChanges();
await checkUrl(page, '#/home');
});
test('router: guards - href - redirect/allow', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 3: beforeEnter: redirect, beforeLeave: allow
await setBeforeEnterHook(page, 'redirect');
const href = await page.$('#href');
await href.click();
await page.waitForChanges();
await checkUrl(page, '#/test');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/home');
});
test('router: guards - href - allow/block', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 4: beforeEnter: allow, beforeLeave: block
await setBeforeLeaveHook(page, 'block');
const href = await page.$('#href');
await href.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
});
// TODO this is an actual bug in the code.
test('router: guards - href - allow/redirect', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 5: beforeEnter: allow, beforeLeave: redirect
await setBeforeLeaveHook(page, 'redirect');
const href = await page.$('#href');
await href.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/test');
});
const checkUrl = async (page, url: string) => {
const getUrl = await page.url();
expect(getUrl).toContain(url);
}
const setBeforeEnterHook = async (page, type: string) => {
const button = await page.$(`ion-radio-group#beforeEnter ion-radio[value=${type}]`);
await button.click();
}
const setBeforeLeaveHook = async (page, type: string) => {
const button = await page.$(`ion-radio-group#beforeLeave ion-radio[value=${type}]`);
await button.click();
}

View File

@ -0,0 +1,194 @@
<!DOCTYPE html>
<html dir="ltr">
<head>
<meta charset="UTF-8">
<title>Navigation Guards</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet">
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet">
<style>
.toolbar {
position: fixed;
top: 20px;
right: 20px;
z-index: 100;
width: 200px;
background: white;
box-shadow: 0px 1px 10px rgba(0,0,0,0.2);
}
</style>
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script> <script>
class HomePage extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-title>Home Page</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-list>
<ion-button id="router-push">router.push</ion-button><br>
<ion-router-link href="/child">
<ion-button id="router-link">ion-router-link</ion-button><br>
</ion-router-link>
<ion-button href="/child" id="href">href</ion-button>
</ion-list>
</ion-content>`;
const childButton = this.querySelector('#router-push');
childButton.addEventListener('click', () => {
const r = document.querySelector('ion-router');
r.push('/child');
});
}
}
class ChildPage extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-buttons>
<ion-back-button default-href="/test"></ion-back-button>
</ion-buttons>
<ion-title>Child Page</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
</ion-content>`;
}
}
class TestPage extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-buttons>
<ion-back-button></ion-back-button>
</ion-buttons>
<ion-title>Test Page</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
</ion-content>`;
}
}
customElements.define('home-page', HomePage);
customElements.define('child-page', ChildPage);
customElements.define('test-page', TestPage);
</script>
</head>
<body>
<div class="toolbar">
<ion-radio-group id="beforeEnter" value="allow">
<ion-list-header>
<ion-label>
beforeEnter Behavior
</ion-label>
</ion-list-header>
<ion-item>
<ion-label>Allow Navigation</ion-label>
<ion-radio value="allow"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Block Navigation</ion-label>
<ion-radio value="block"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Redirect</ion-label>
<ion-radio value="redirect"></ion-radio>
</ion-item>
</ion-radio-group>
<br><br>
<ion-radio-group id="beforeLeave" value="allow">
<ion-list-header>
<ion-label>
beforeLeave Behavior
</ion-label>
</ion-list-header>
<ion-item>
<ion-label>Allow Navigation</ion-label>
<ion-radio value="allow"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Block Navigation</ion-label>
<ion-radio value="block"></ion-radio>
</ion-item>
<ion-item>
<ion-label>Redirect</ion-label>
<ion-radio value="redirect"></ion-radio>
</ion-item>
</ion-radio-group>
</div>
<ion-app>
<ion-router>
<ion-route-redirect from="/" to="/home"></ion-route-redirect>
<ion-route url="/home" component="home-page"></ion-route>
<ion-route url="/test" component="test-page"></ion-route>
<ion-route url="/child" component="child-page"></ion-route>
</ion-router>
<ion-nav></ion-nav>
<script>
const beforeEnterGroup = document.querySelector('ion-radio-group#beforeEnter');
const beforeLeaveGroup = document.querySelector('ion-radio-group#beforeLeave');
beforeEnterGroup.addEventListener('ionChange', (ev) => {
switch (ev.detail.value) {
case "redirect":
page.beforeEnter = redirect;
break;
case "block":
page.beforeEnter = block;
break;
default:
page.beforeEnter = allow;
break;
}
});
beforeLeaveGroup.addEventListener('ionChange', (ev) => {
switch (ev.detail.value) {
case "redirect":
page.beforeLeave = redirect;
break;
case "block":
page.beforeLeave = block;
break;
default:
page.beforeLeave = allow;
break;
}
});
const redirect = (to = '/test') => { return { redirect: to }};
const block = () => false;
const allow = () => true;
const page = document.querySelector('ion-route[component="child-page"]');
page.beforeEnter = allow;
page.beforeLeave = allow;
</script>
</ion-app>
</body>
</html>

View File

@ -0,0 +1,128 @@
import { newE2EPage } from '@stencil/core/testing';
test('router: guards - router-link - allow/allow', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 1: beforeEnter: allow, beforeLeave: allow
await setBeforeEnterHook(page, 'allow');
const routerLink = await page.$('#router-link');
await routerLink.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/home');
});
test('router: guards - router-link - block/allow', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 2: beforeEnter: block, beforeLeave: allow
await setBeforeEnterHook(page, 'block');
const routerLink = await page.$('#router-link');
await routerLink.click();
await page.waitForChanges();
await checkUrl(page, '#/home');
});
test('router: guards - router-link - redirect/allow', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 3: beforeEnter: redirect, beforeLeave: allow
await setBeforeEnterHook(page, 'redirect');
const routerLink = await page.$('#router-link');
await routerLink.click();
await page.waitForChanges();
await checkUrl(page, '#/test');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/home');
});
test('router: guards - router-link - allow/block', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 4: beforeEnter: allow, beforeLeave: block
await setBeforeLeaveHook(page, 'block');
const routerLink = await page.$('#router-link');
await routerLink.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
});
// TODO this is an actual bug in the code.
test('router: guards - router-link - allow/redirect', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 5: beforeEnter: allow, beforeLeave: redirect
await setBeforeLeaveHook(page, 'redirect');
const routerLink = await page.$('#router-link');
await routerLink.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/test');
});
const checkUrl = async (page, url: string) => {
const getUrl = await page.url();
expect(getUrl).toContain(url);
}
const setBeforeEnterHook = async (page, type: string) => {
const button = await page.$(`ion-radio-group#beforeEnter ion-radio[value=${type}]`);
await button.click();
}
const setBeforeLeaveHook = async (page, type: string) => {
const button = await page.$(`ion-radio-group#beforeLeave ion-radio[value=${type}]`);
await button.click();
}

View File

@ -0,0 +1,128 @@
import { newE2EPage } from '@stencil/core/testing';
test('router: guards - router.push - allow/allow', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 1: beforeEnter: allow, beforeLeave: allow
await setBeforeEnterHook(page, 'allow');
const routerPush = await page.$('#router-push');
await routerPush.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/home');
});
test('router: guards - router.push - block/allow', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 2: beforeEnter: block, beforeLeave: allow
await setBeforeEnterHook(page, 'block');
const routerPush = await page.$('#router-push');
await routerPush.click();
await page.waitForChanges();
await checkUrl(page, '#/home');
});
test('router: guards - router.push - redirect/allow', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 3: beforeEnter: redirect, beforeLeave: allow
await setBeforeEnterHook(page, 'redirect');
const routerPush = await page.$('#router-push');
await routerPush.click();
await page.waitForChanges();
await checkUrl(page, '#/test');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/home');
});
test('router: guards - router.push - allow/block', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 4: beforeEnter: allow, beforeLeave: block
await setBeforeLeaveHook(page, 'block');
const routerPush = await page.$('#router-push');
await routerPush.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
});
// TODO this is an actual bug in the code.
test('router: guards - router.push - allow/redirect', async () => {
const page = await newE2EPage({
url: '/src/components/router/test/guards?ionic:_testing=true'
});
// Test 5: beforeEnter: allow, beforeLeave: redirect
await setBeforeLeaveHook(page, 'redirect');
const routerPush = await page.$('#router-push');
await routerPush.click();
await page.waitForChanges();
await checkUrl(page, '#/child');
const backButton = await page.$('ion-back-button');
await backButton.click();
await page.waitForChanges();
await checkUrl(page, '#/test');
});
const checkUrl = async (page, url: string) => {
const getUrl = await page.url();
expect(getUrl).toContain(url);
}
const setBeforeEnterHook = async (page, type: string) => {
const button = await page.$(`ion-radio-group#beforeEnter ion-radio[value=${type}]`);
await button.click();
}
const setBeforeLeaveHook = async (page, type: string) => {
const button = await page.$(`ion-radio-group#beforeLeave ion-radio[value=${type}]`);
await button.click();
}

View File

@ -1,4 +1,5 @@
import { AnimationBuilder, ComponentProps } from '../../../interface';
import { NavigationHookCallback } from '../../route/route-interface';
export interface HTMLStencilElement extends HTMLElement {
componentOnReady(): Promise<this>;
@ -36,6 +37,8 @@ export interface RouteEntry {
id: string;
path: string[];
params: {[key: string]: any} | undefined;
beforeLeave?: NavigationHookCallback;
beforeEnter?: NavigationHookCallback;
}
export interface RouteNode extends RouteEntry {

View File

@ -29,6 +29,8 @@ export const readRouteNodes = (root: Element, node = root): RouteTree => {
path: parsePath(readProp(el, 'url')),
id: component.toLowerCase(),
params: el.componentProps,
beforeLeave: el.beforeLeave,
beforeEnter: el.beforeEnter,
children: readRouteNodes(root, el)
};
});
@ -57,7 +59,9 @@ const flattenNode = (chain: RouteChain, routes: RouteChain[], node: RouteNode) =
s.push({
id: node.id,
path: node.path,
params: node.params
params: node.params,
beforeLeave: node.beforeLeave,
beforeEnter: node.beforeEnter
});
if (node.children.length === 0) {