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