diff --git a/core/api.txt b/core/api.txt index 9601ddbd7a..a8653d06ce 100644 --- a/core/api.txt +++ b/core/api.txt @@ -933,6 +933,8 @@ ion-ripple-effect,prop,type,"bounded" | "unbounded",'bounded',false,false ion-ripple-effect,method,addRipple,addRipple(x: number, y: number) => Promise<() => void> ion-route,none +ion-route,prop,beforeEnter,(() => boolean | NavigationHookOptions | Promise) | undefined,undefined,false,false +ion-route,prop,beforeLeave,(() => boolean | NavigationHookOptions | Promise) | undefined,undefined,false,false ion-route,prop,component,string,undefined,true,false ion-route,prop,componentProps,undefined | { [key: string]: any; },undefined,false,false ion-route,prop,url,string,'',false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 76dbc79699..9b5d9162ce 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -7,6 +7,7 @@ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; import { ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DatetimeOptions, DomRenderFn, FooterHeightFn, FrameworkDelegate, HeaderFn, HeaderHeightFn, InputChangeEventDetail, ItemHeightFn, ItemRenderFn, ItemReorderEventDetail, MenuChangeEventDetail, NavComponent, NavOptions, OverlayEventDetail, PickerButton, PickerColumn, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, ViewController } from "./interface"; import { IonicSafeString } from "./utils/sanitization"; +import { NavigationHookCallback } from "./components/route/route-interface"; import { SelectCompareFn } from "./components/select/select-interface"; export namespace Components { interface IonActionSheet { @@ -1849,6 +1850,14 @@ export namespace Components { "type": 'bounded' | 'unbounded'; } interface IonRoute { + /** + * A navigation hook that is fired when the route tries to enter. Returning `true` allows the navigation to proceed, while returning `false` causes it to be cancelled. Returning a `NavigationHookOptions` object causes the router to redirect to the path specified. + */ + "beforeEnter"?: NavigationHookCallback; + /** + * A navigation hook that is fired when the route tries to leave. Returning `true` allows the navigation to proceed, while returning `false` causes it to be cancelled. Returning a `NavigationHookOptions` object causes the router to redirect to the path specified. + */ + "beforeLeave"?: NavigationHookCallback; /** * Name of the component to load/select in the navigation outlet (`ion-tabs`, `ion-nav`) when the route matches. The value of this property is not always the tagname of the component to load, in `ion-tabs` it actually refers to the name of the `ion-tab` to select. */ @@ -1877,6 +1886,7 @@ export namespace Components { * Go back to previous page in the window.history. */ "back": () => Promise; + "canTransition": () => Promise; "navChanged": (direction: RouterDirection) => Promise; "printDebug": () => Promise; /** @@ -5095,6 +5105,14 @@ declare namespace LocalJSX { "type"?: 'bounded' | 'unbounded'; } interface IonRoute { + /** + * A navigation hook that is fired when the route tries to enter. Returning `true` allows the navigation to proceed, while returning `false` causes it to be cancelled. Returning a `NavigationHookOptions` object causes the router to redirect to the path specified. + */ + "beforeEnter"?: NavigationHookCallback; + /** + * A navigation hook that is fired when the route tries to leave. Returning `true` allows the navigation to proceed, while returning `false` causes it to be cancelled. Returning a `NavigationHookOptions` object causes the router to redirect to the path specified. + */ + "beforeLeave"?: NavigationHookCallback; /** * Name of the component to load/select in the navigation outlet (`ion-tabs`, `ion-nav`) when the route matches. The value of this property is not always the tagname of the component to load, in `ion-tabs` it actually refers to the name of the `ion-tab` to select. */ diff --git a/core/src/components/nav/nav.tsx b/core/src/components/nav/nav.tsx index ffa84f5f70..a637e997c8 100644 --- a/core/src/components/nav/nav.tsx +++ b/core/src/components/nav/nav.tsx @@ -513,7 +513,7 @@ export class Nav implements NavOutlet { // 7. _transitionStart(): called once the transition actually starts, it initializes the Animation underneath. // 8. _transitionFinish(): called once the transition finishes // 9. _cleanup(): syncs the navigation internal state with the DOM. For example it removes the pages from the DOM or hides/show them. - private queueTrns( + private async queueTrns( ti: TransitionInstruction, done: TransitionDoneFn | undefined ): Promise { @@ -527,6 +527,25 @@ export class Nav implements NavOutlet { }); ti.done = done; + /** + * If using router, check to see if navigation hooks + * will allow us to perform this transition. This + * is required in order for hooks to work with + * the ion-back-button or swipe to go back. + */ + if (ti.opts && ti.opts.updateURL !== false && this.useRouter) { + const router = document.querySelector('ion-router'); + if (router) { + const canTransition = await router.canTransition(); + if (canTransition === false) { + return Promise.resolve(false); + } else if (typeof canTransition === 'string') { + router.push(canTransition, ti.opts!.direction || 'back'); + return Promise.resolve(false); + } + } + } + // Normalize empty if (ti.insertViews && ti.insertViews.length === 0) { ti.insertViews = undefined; diff --git a/core/src/components/route/readme.md b/core/src/components/route/readme.md index 6ecd12070a..75725719ac 100644 --- a/core/src/components/route/readme.md +++ b/core/src/components/route/readme.md @@ -1,19 +1,233 @@ # ion-route -The route component takes a component and renders it when the Browser URl matches the url property. +The route component takes a component and renders it when the Browser URL matches the url property. > Note: this component should only be used with vanilla and Stencil JavaScript projects. For Angular projects, use [`ion-router-outlet`](../router-outlet) and the Angular router. +## Navigation Hooks + +Navigation hooks can be used to perform tasks or act as navigation guards. Hooks are used by providing functions to the `beforeEnter` and `beforeLeave` properties on each `ion-route`. Returning `true` allows navigation to proceed, while returning `false` causes it to be cancelled. Returning an object of type `NavigationHookOptions` allows you to redirect navigation to another page. + +## Interfaces + +```typescript +interface NavigationHookResult { + /** + * A valid path to redirect navigation to. + */ + redirect: string; +} +``` + + +## Usage + +### Javascript + +```html + + + + + + +``` + +```javascript +const dashboardPage = document.querySelector('ion-route[url="/dashboard"]'); +dashboardPage.beforeEnter = isLoggedInGuard; + +const newMessagePage = document.querySelector('ion-route[url="/dashboard"]'); +newMessagePage.beforeLeave = hasUnsavedDataGuard; + +const isLoggedInGuard = async () => { + const isLoggedIn = await UserData.isLoggedIn(); // Replace this with actual login validation + + if (isLoggedIn) { + return true; + } else { + return { redirect: '/login' }; // If a user is not logged in, they will be redirected to the /login page + } +} + +const hasUnsavedDataGuard = async () => { + const hasUnsavedData = await checkData(); // Replace this with actual validation + + if (hasUnsavedData) { + return await confirmDiscardChanges(); + } else { + return true; + } +} + +const confirmDiscardChanges = async () => { + const alert = document.createElement('ion-alert'); + alert.header = 'Discard Unsaved Changes?'; + alert.message = 'Are you sure you want to leave? Any unsaved changed will be lost.'; + alert.buttons = [ + { + text: 'Cancel', + role: 'Cancel', + }, + { + text: 'Discard', + role: 'destructive', + } + ]; + + document.body.appendChild(alert); + + await alert.present(); + + const { role } = await alert.onDidDismiss(); + + return (role === 'Cancel') ? false : true; +} +``` + + +### Stencil + +```typescript +import { Component, h } from '@stencil/core'; +import { alertController } from '@ionic/core'; + +@Component({ + tag: 'router-example', + styleUrl: 'router-example.css' +}) +export class RouterExample { + render() { + return ( + + + + + + + ) + } +} + +const isLoggedInGuard = async () => { + const isLoggedIn = await UserData.isLoggedIn(); // Replace this with actual login validation + + if (isLoggedIn) { + return true; + } else { + return { redirect: '/login' }; // If a user is not logged in, they will be redirected to the /login page + } +} + +const hasUnsavedDataGuard = async () => { + const hasUnsavedData = await checkData(); // Replace this with actual validation + + if (hasUnsavedData) { + return await confirmDiscardChanges(); + } else { + return true; + } +} + +const confirmDiscardChanges = async () => { + const alert = await alertController.create({ + header: 'Discard Unsaved Changes?', + message: 'Are you sure you want to leave? Any unsaved changed will be lost.', + buttons: [ + { + text: 'Cancel', + role: 'Cancel', + }, + { + text: 'Discard', + role: 'destructive', + } + ] + }); + + await alert.present(); + + const { role } = await alert.onDidDismiss(); + + return (role === 'Cancel') ? false : true; +} +``` + + +### Vue + +```html + + + +``` + + + ## Properties -| Property | Attribute | Description | Type | Default | -| ------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ----------- | -| `component` _(required)_ | `component` | Name of the component to load/select in the navigation outlet (`ion-tabs`, `ion-nav`) when the route matches. The value of this property is not always the tagname of the component to load, in `ion-tabs` it actually refers to the name of the `ion-tab` to select. | `string` | `undefined` | -| `componentProps` | -- | A key value `{ 'red': true, 'blue': 'white'}` containing props that should be passed to the defined component when rendered. | `undefined \| { [key: string]: any; }` | `undefined` | -| `url` | `url` | Relative path that needs to match in order for this route to apply. Accepts paths similar to expressjs so that you can define parameters in the url /foo/:bar where bar would be available in incoming props. | `string` | `''` | +| Property | Attribute | Description | Type | Default | +| ------------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------- | +| `beforeEnter` | -- | A navigation hook that is fired when the route tries to enter. Returning `true` allows the navigation to proceed, while returning `false` causes it to be cancelled. Returning a `NavigationHookOptions` object causes the router to redirect to the path specified. | `(() => boolean \| NavigationHookOptions \| Promise) \| undefined` | `undefined` | +| `beforeLeave` | -- | A navigation hook that is fired when the route tries to leave. Returning `true` allows the navigation to proceed, while returning `false` causes it to be cancelled. Returning a `NavigationHookOptions` object causes the router to redirect to the path specified. | `(() => boolean \| NavigationHookOptions \| Promise) \| undefined` | `undefined` | +| `component` _(required)_ | `component` | Name of the component to load/select in the navigation outlet (`ion-tabs`, `ion-nav`) when the route matches. The value of this property is not always the tagname of the component to load, in `ion-tabs` it actually refers to the name of the `ion-tab` to select. | `string` | `undefined` | +| `componentProps` | -- | A key value `{ 'red': true, 'blue': 'white'}` containing props that should be passed to the defined component when rendered. | `undefined \| { [key: string]: any; }` | `undefined` | +| `url` | `url` | Relative path that needs to match in order for this route to apply. Accepts paths similar to expressjs so that you can define parameters in the url /foo/:bar where bar would be available in incoming props. | `string` | `''` | ## Events diff --git a/core/src/components/route/route-interface.ts b/core/src/components/route/route-interface.ts new file mode 100644 index 0000000000..7f6b9f5d06 --- /dev/null +++ b/core/src/components/route/route-interface.ts @@ -0,0 +1,5 @@ +export type NavigationHookCallback = () => NavigationHookResult | Promise; +export type NavigationHookResult = boolean | NavigationHookOptions; +export interface NavigationHookOptions { + redirect: string; +} diff --git a/core/src/components/route/route.tsx b/core/src/components/route/route.tsx index 449a6c10f7..2ae15effb6 100644 --- a/core/src/components/route/route.tsx +++ b/core/src/components/route/route.tsx @@ -1,5 +1,7 @@ import { Component, ComponentInterface, Event, EventEmitter, Prop, Watch } from '@stencil/core'; +import { NavigationHookCallback } from './route-interface'; + @Component({ tag: 'ion-route' }) @@ -28,6 +30,22 @@ export class Route implements ComponentInterface { */ @Prop() componentProps?: {[key: string]: any}; + /** + * A navigation hook that is fired when the route tries to leave. + * Returning `true` allows the navigation to proceed, while returning + * `false` causes it to be cancelled. Returning a `NavigationHookOptions` + * object causes the router to redirect to the path specified. + */ + @Prop() beforeLeave?: NavigationHookCallback; + + /** + * A navigation hook that is fired when the route tries to enter. + * Returning `true` allows the navigation to proceed, while returning + * `false` causes it to be cancelled. Returning a `NavigationHookOptions` + * object causes the router to redirect to the path specified. + */ + @Prop() beforeEnter?: NavigationHookCallback; + /** * Used internally by `ion-router` to know when this route did change. */ diff --git a/core/src/components/route/usage/javascript.md b/core/src/components/route/usage/javascript.md new file mode 100644 index 0000000000..b5283442a9 --- /dev/null +++ b/core/src/components/route/usage/javascript.md @@ -0,0 +1,60 @@ +```html + + + + + + +``` + +```javascript +const dashboardPage = document.querySelector('ion-route[url="/dashboard"]'); +dashboardPage.beforeEnter = isLoggedInGuard; + +const newMessagePage = document.querySelector('ion-route[url="/dashboard"]'); +newMessagePage.beforeLeave = hasUnsavedDataGuard; + +const isLoggedInGuard = async () => { + const isLoggedIn = await UserData.isLoggedIn(); // Replace this with actual login validation + + if (isLoggedIn) { + return true; + } else { + return { redirect: '/login' }; // If a user is not logged in, they will be redirected to the /login page + } +} + +const hasUnsavedDataGuard = async () => { + const hasUnsavedData = await checkData(); // Replace this with actual validation + + if (hasUnsavedData) { + return await confirmDiscardChanges(); + } else { + return true; + } +} + +const confirmDiscardChanges = async () => { + const alert = document.createElement('ion-alert'); + alert.header = 'Discard Unsaved Changes?'; + alert.message = 'Are you sure you want to leave? Any unsaved changed will be lost.'; + alert.buttons = [ + { + text: 'Cancel', + role: 'Cancel', + }, + { + text: 'Discard', + role: 'destructive', + } + ]; + + document.body.appendChild(alert); + + await alert.present(); + + const { role } = await alert.onDidDismiss(); + + return (role === 'Cancel') ? false : true; +} +``` diff --git a/core/src/components/route/usage/stencil.md b/core/src/components/route/usage/stencil.md new file mode 100644 index 0000000000..28fac38c6c --- /dev/null +++ b/core/src/components/route/usage/stencil.md @@ -0,0 +1,64 @@ +```typescript +import { Component, h } from '@stencil/core'; +import { alertController } from '@ionic/core'; + +@Component({ + tag: 'router-example', + styleUrl: 'router-example.css' +}) +export class RouterExample { + render() { + return ( + + + + + + + ) + } +} + +const isLoggedInGuard = async () => { + const isLoggedIn = await UserData.isLoggedIn(); // Replace this with actual login validation + + if (isLoggedIn) { + return true; + } else { + return { redirect: '/login' }; // If a user is not logged in, they will be redirected to the /login page + } +} + +const hasUnsavedDataGuard = async () => { + const hasUnsavedData = await checkData(); // Replace this with actual validation + + if (hasUnsavedData) { + return await confirmDiscardChanges(); + } else { + return true; + } +} + +const confirmDiscardChanges = async () => { + const alert = await alertController.create({ + header: 'Discard Unsaved Changes?', + message: 'Are you sure you want to leave? Any unsaved changed will be lost.', + buttons: [ + { + text: 'Cancel', + role: 'Cancel', + }, + { + text: 'Discard', + role: 'destructive', + } + ] + }); + + await alert.present(); + + const { role } = await alert.onDidDismiss(); + + return (role === 'Cancel') ? false : true; +} +``` diff --git a/core/src/components/route/usage/vue.md b/core/src/components/route/usage/vue.md new file mode 100644 index 0000000000..a0f810dcb8 --- /dev/null +++ b/core/src/components/route/usage/vue.md @@ -0,0 +1,57 @@ +```html + + + +``` \ No newline at end of file diff --git a/core/src/components/router/router.tsx b/core/src/components/router/router.tsx index 4b186bff6c..7512b56821 100644 --- a/core/src/components/router/router.tsx +++ b/core/src/components/router/router.tsx @@ -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, diff --git a/core/src/components/router/test/guards/href.e2e.ts b/core/src/components/router/test/guards/href.e2e.ts new file mode 100644 index 0000000000..647ef20fc9 --- /dev/null +++ b/core/src/components/router/test/guards/href.e2e.ts @@ -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(); +} diff --git a/core/src/components/router/test/guards/index.html b/core/src/components/router/test/guards/index.html new file mode 100644 index 0000000000..810fff8f8e --- /dev/null +++ b/core/src/components/router/test/guards/index.html @@ -0,0 +1,194 @@ + + + + + + Navigation Guards + + + + + + + + + + + +
+ + + + beforeEnter Behavior + + + + + Allow Navigation + + + + + Block Navigation + + + + + Redirect + + + + +

+ + + + + beforeLeave Behavior + + + + + Allow Navigation + + + + + Block Navigation + + + + + Redirect + + + +
+ + + + + + + + + + + + + + + + diff --git a/core/src/components/router/test/guards/router-link.e2e.ts b/core/src/components/router/test/guards/router-link.e2e.ts new file mode 100644 index 0000000000..fbb022e9ef --- /dev/null +++ b/core/src/components/router/test/guards/router-link.e2e.ts @@ -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(); +} diff --git a/core/src/components/router/test/guards/router-push.e2e.ts b/core/src/components/router/test/guards/router-push.e2e.ts new file mode 100644 index 0000000000..2e65ba8374 --- /dev/null +++ b/core/src/components/router/test/guards/router-push.e2e.ts @@ -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(); +} diff --git a/core/src/components/router/utils/interface.ts b/core/src/components/router/utils/interface.ts index a9dfe81313..a57e68f7cf 100644 --- a/core/src/components/router/utils/interface.ts +++ b/core/src/components/router/utils/interface.ts @@ -1,4 +1,5 @@ import { AnimationBuilder, ComponentProps } from '../../../interface'; +import { NavigationHookCallback } from '../../route/route-interface'; export interface HTMLStencilElement extends HTMLElement { componentOnReady(): Promise; @@ -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 { diff --git a/core/src/components/router/utils/parser.ts b/core/src/components/router/utils/parser.ts index 9aa8e48329..12d6544478 100644 --- a/core/src/components/router/utils/parser.ts +++ b/core/src/components/router/utils/parser.ts @@ -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) {