From 4c8f32fae99db4022aa9dc75187e2f161e8e678e Mon Sep 17 00:00:00 2001 From: James Manners Date: Thu, 12 Dec 2019 02:46:14 +1100 Subject: [PATCH] fix(tabs): preserve route navigation extras when changing tabs (#18493) fixes #18717 --- .../navigation/ion-router-outlet.ts | 18 ++- angular/src/directives/navigation/ion-tabs.ts | 51 +++++++-- .../directives/navigation/stack-controller.ts | 10 +- .../test/test-app/e2e/src/tabs.e2e-spec.ts | 106 +++++++++++++++++- .../app/tabs-tab1/tabs-tab1.component.html | 2 + 5 files changed, 174 insertions(+), 13 deletions(-) diff --git a/angular/src/directives/navigation/ion-router-outlet.ts b/angular/src/directives/navigation/ion-router-outlet.ts index 786e63b67f3..5d228d7e970 100644 --- a/angular/src/directives/navigation/ion-router-outlet.ts +++ b/angular/src/directives/navigation/ion-router-outlet.ts @@ -242,6 +242,22 @@ export class IonRouterOutlet implements OnDestroy, OnInit { return active ? active.url : undefined; } + /** + * Returns the RouteView of the active page of each stack. + * @internal + */ + getLastRouteView(stackId?: string): RouteView | undefined { + return this.stackCtrl.getLastUrl(stackId); + } + + /** + * Returns the root view in the tab stack. + * @internal + */ + getRootView(stackId?: string): RouteView | undefined { + return this.stackCtrl.getRootUrl(stackId); + } + /** * Returns the active stack ID. In the context of ion-tabs, it means the active tab. */ @@ -315,7 +331,7 @@ class OutletInjector implements Injector { private route: ActivatedRoute, private childContexts: ChildrenOutletContexts, private parent: Injector - ) {} + ) { } get(token: any, notFoundValue?: any): any { if (token === ActivatedRoute) { diff --git a/angular/src/directives/navigation/ion-tabs.ts b/angular/src/directives/navigation/ion-tabs.ts index c8ca3ce31d6..60aa2430f30 100644 --- a/angular/src/directives/navigation/ion-tabs.ts +++ b/angular/src/directives/navigation/ion-tabs.ts @@ -66,18 +66,53 @@ export class IonTabs { } } + /** + * When a tab button is clicked, there are several scenarios: + * 1. If the selected tab is currently active (the tab button has been clicked + * again), then it should go to the root view for that tab. + * + * a. Get the saved root view from the router outlet. If the saved root view + * matches the tabRootUrl, set the route view to this view including the + * navigation extras. + * b. If the saved root view from the router outlet does + * not match, navigate to the tabRootUrl. No navigation extras are + * included. + * + * 2. If the current tab tab is not currently selected, get the last route + * view from the router outlet. + * + * a. If the last route view exists, navigate to that view including any + * navigation extras + * b. If the last route view doesn't exist, then navigate + * to the default tabRootUrl + */ @HostListener('ionTabButtonClick', ['$event.detail.tab']) select(tab: string) { const alreadySelected = this.outlet.getActiveStackId() === tab; - const href = `${this.outlet.tabsPrefix}/${tab}`; - const url = alreadySelected - ? href - : this.outlet.getLastUrl(tab) || href; + const tabRootUrl = `${this.outlet.tabsPrefix}/${tab}`; + if (alreadySelected) { + const rootView = this.outlet.getRootView(tab); + const navigationExtras = rootView && tabRootUrl === rootView.url && rootView.savedExtras; + return this.navCtrl.navigateRoot(tabRootUrl, { + ...(navigationExtras), + animated: true, + animationDirection: 'back', + }); + } else { + const lastRoute = this.outlet.getLastRouteView(tab); + /** + * If there is a lastRoute, goto that, otherwise goto the fallback url of the + * selected tab + */ + const url = lastRoute && lastRoute.url || tabRootUrl; + const navigationExtras = lastRoute && lastRoute.savedExtras; - return this.navCtrl.navigateRoot(url, { - animated: true, - animationDirection: 'back' - }); + return this.navCtrl.navigateRoot(url, { + ...(navigationExtras), + animated: true, + animationDirection: 'back', + }); + } } getSelected(): string | undefined { diff --git a/angular/src/directives/navigation/stack-controller.ts b/angular/src/directives/navigation/stack-controller.ts index 982181633bf..3465bbe371b 100644 --- a/angular/src/directives/navigation/stack-controller.ts +++ b/angular/src/directives/navigation/stack-controller.ts @@ -70,7 +70,7 @@ export class StackController { if (router.getCurrentNavigation) { currentNavigation = router.getCurrentNavigation(); - // Angular < 7.2.0 + // Angular < 7.2.0 } else if ( router.navigations && router.navigations.value @@ -191,6 +191,14 @@ export class StackController { return views.length > 0 ? views[views.length - 1] : undefined; } + /** + * @internal + */ + getRootUrl(stackId?: string) { + const views = this.getStack(stackId); + return views.length > 0 ? views[0] : undefined; + } + getActiveStackId(): string | undefined { return this.activeView ? this.activeView.stackId : undefined; } diff --git a/angular/test/test-app/e2e/src/tabs.e2e-spec.ts b/angular/test/test-app/e2e/src/tabs.e2e-spec.ts index 8887bd52bcd..0f8d6063d6d 100644 --- a/angular/test/test-app/e2e/src/tabs.e2e-spec.ts +++ b/angular/test/test-app/e2e/src/tabs.e2e-spec.ts @@ -1,5 +1,5 @@ -import { browser, element, by, ElementFinder } from 'protractor'; -import { waitTime, testStack, handleErrorMessages } from './utils'; +import { browser, by, element, ElementFinder, ExpectedConditions } from 'protractor'; +import { handleErrorMessages, testStack, waitTime } from './utils'; describe('tabs', () => { afterEach(() => { @@ -131,6 +131,94 @@ describe('tabs', () => { await testTabTitle('Tab 3 - Page 1'); await testStack('ion-tabs ion-router-outlet', ['app-tabs-tab1', 'app-tabs-tab3']); }); + + it('should preserve navigation extras when switching tabs', async () => { + const expectUrlToContain = 'search=hello#fragment'; + let tab = await getSelectedTab() as ElementFinder; + await tab.$('#goto-nested-page1-with-query-params').click(); + await testTabTitle('Tab 1 - Page 2 (1)'); + await testUrlContains(expectUrlToContain); + + await element(by.css('#tab-button-contact')).click(); + await testTabTitle('Tab 2 - Page 1'); + + await element(by.css('#tab-button-account')).click(); + tab = await testTabTitle('Tab 1 - Page 2 (1)'); + await testUrlContains(expectUrlToContain); + }); + + it('should set root when clicking on an active tab to navigate to the root', async () => { + const expectNestedTabUrlToContain = 'search=hello#fragment'; + let tab = await getSelectedTab() as ElementFinder; + const initialUrl = await browser.getCurrentUrl(); + await tab.$('#goto-nested-page1-with-query-params').click(); + await testTabTitle('Tab 1 - Page 2 (1)'); + await testUrlContains(expectNestedTabUrlToContain); + + await element(by.css('#tab-button-account')).click(); + await testTabTitle('Tab 1 - Page 1'); + + await testUrlEquals(initialUrl); + }); + + }); + + describe('entry tab contains navigation extras', () => { + const expectNestedTabUrlToContain = 'search=hello#fragment'; + const rootUrlParams = 'test=123#rootFragment'; + const rootUrl = `/tabs/account?${rootUrlParams}`; + + beforeEach(async () => { + await browser.get(rootUrl); + await waitTime(30); + }); + + it('should preserve root url navigation extras when clicking on an active tab to navigate to the root', async () => { + await browser.get(rootUrl); + + let tab = await getSelectedTab() as ElementFinder; + await tab.$('#goto-nested-page1-with-query-params').click(); + await testTabTitle('Tab 1 - Page 2 (1)'); + await testUrlContains(expectNestedTabUrlToContain); + + await element(by.css('#tab-button-account')).click(); + await testTabTitle('Tab 1 - Page 1'); + + await testUrlContains(rootUrl); + }); + + it('should preserve root url navigation extras when changing tabs', async () => { + await browser.get(rootUrl); + + let tab = await getSelectedTab() as ElementFinder; + await element(by.css('#tab-button-contact')).click(); + tab = await testTabTitle('Tab 2 - Page 1'); + + await element(by.css('#tab-button-account')).click(); + await testTabTitle('Tab 1 - Page 1'); + + await testUrlContains(rootUrl); + }); + + it('should navigate deep then go home and preserve navigation extras', async () => { + let tab = await getSelectedTab(); + await tab.$('#goto-tab1-page2').click(); + tab = await testTabTitle('Tab 1 - Page 2 (1)'); + + await tab.$('#goto-next').click(); + tab = await testTabTitle('Tab 1 - Page 2 (2)'); + + await element(by.css('#tab-button-contact')).click(); + tab = await testTabTitle('Tab 2 - Page 1'); + + await element(by.css('#tab-button-account')).click(); + await testTabTitle('Tab 1 - Page 2 (2)'); + + await element(by.css('#tab-button-account')).click(); + await testTabTitle('Tab 1 - Page 1'); + + await testUrlContains(rootUrl); + }); }); describe('entry url - /tabs/account/nested/1', () => { @@ -159,7 +247,7 @@ describe('tabs', () => { await tab.$('#goto-next').click(); tab = await testTabTitle('Tab 1 - Page 2 (3)'); - await testStack('ion-tabs ion-router-outlet',[ + await testStack('ion-tabs ion-router-outlet', [ 'app-tabs-tab1-nested', 'app-tabs-tab1-nested', 'app-tabs-tab1-nested' @@ -226,6 +314,18 @@ async function testTabTitle(title: string) { return tab; } +async function testUrlContains(urlFragment: string) { + await browser.wait(ExpectedConditions.urlContains(urlFragment), + 5000, + `expected ${browser.getCurrentUrl()} to contain ${urlFragment}`); +} + +async function testUrlEquals(url: string) { + await browser.wait(ExpectedConditions.urlIs(url), + 5000, + `expected ${browser.getCurrentUrl()} to equal ${url}`); +} + async function getSelectedTab(): Promise { const tabs = element.all(by.css('ion-tabs ion-router-outlet > *:not(.ion-page-hidden)')); expect(await tabs.count()).toEqual(1); diff --git a/angular/test/test-app/src/app/tabs-tab1/tabs-tab1.component.html b/angular/test/test-app/src/app/tabs-tab1/tabs-tab1.component.html index 121cf556c70..fabccd1fcae 100644 --- a/angular/test/test-app/src/app/tabs-tab1/tabs-tab1.component.html +++ b/angular/test/test-app/src/app/tabs-tab1/tabs-tab1.component.html @@ -15,6 +15,8 @@

Welcome to Tab1

Go to Page 2 + Go to Page 2 with Query Params Go to Tab 3 - Page 2 Go to nested