From cdc2fb652fe5aa149eaa751a77fb506ac1f64195 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Thu, 19 Nov 2020 15:30:32 -0500 Subject: [PATCH] fix(vue): tabs correctly fire lifecycle events (#22479) resolves #22466 --- packages/vue/src/components/IonTabBar.ts | 12 +- packages/vue/src/components/IonTabs.ts | 29 ++- packages/vue/test-app/tests/unit/tabs.spec.ts | 246 ++++++++++++++++++ 3 files changed, 278 insertions(+), 9 deletions(-) create mode 100644 packages/vue/test-app/tests/unit/tabs.spec.ts diff --git a/packages/vue/src/components/IonTabBar.ts b/packages/vue/src/components/IonTabBar.ts index 682c366483..343fab1558 100644 --- a/packages/vue/src/components/IonTabBar.ts +++ b/packages/vue/src/components/IonTabBar.ts @@ -13,6 +13,10 @@ interface Tab { export const IonTabBar = defineComponent({ name: 'IonTabBar', + props: { + _tabsWillChange: { type: Function, default: () => {} }, + _tabsDidChange: { type: Function, default: () => {} } + }, mounted() { const ionRouter: any = inject('navManager'); const tabState: TabState = { @@ -102,12 +106,16 @@ export const IonTabBar = defineComponent({ } } - const activeChild = childNodes.find((child: VNode) => child.el.tab === activeTab); + const activeChild = childNodes.find((child: VNode) => child.props.tab === activeTab); const tabBar = this.$refs.ionTabBar; - + const tabDidChange = activeTab !== prevActiveTab; if (activeChild && tabBar) { + tabDidChange && this.$props._tabsWillChange(activeTab); + ionRouter.handleSetCurrentTab(activeTab); tabBar.selectedTab = tabState.activeTab = activeTab; + + tabDidChange && this.$props._tabsDidChange(activeTab); } }; diff --git a/packages/vue/src/components/IonTabs.ts b/packages/vue/src/components/IonTabs.ts index 9d4df7ae11..e292d53035 100644 --- a/packages/vue/src/components/IonTabs.ts +++ b/packages/vue/src/components/IonTabs.ts @@ -1,10 +1,14 @@ import { h, defineComponent, VNode } from 'vue'; import { IonRouterOutlet } from './IonRouterOutlet'; +const WILL_CHANGE = 'ionTabsWillChange'; +const DID_CHANGE = 'ionTabsDidChange'; + export const IonTabs = defineComponent({ name: 'IonTabs', + emits: [WILL_CHANGE, DID_CHANGE], render() { - const { $slots: slots } = this; + const { $slots: slots, $emit } = this; const slottedContent = slots.default && slots.default(); let childrenToRender = [ h('div', { @@ -25,14 +29,25 @@ export const IonTabs = defineComponent({ * not show above the tab content. */ if (slottedContent && slottedContent.length > 0) { - const topSlottedTabBar = slottedContent.find((child: VNode) => { - const isTabBar = child.type && (child.type as any).name === 'IonTabBar'; - const hasTopSlot = child.props?.slot === 'top'; + const slottedTabBar = slottedContent.find((child: VNode) => child.type && (child.type as any).name === 'IonTabBar'); + const hasTopSlotTabBar = slottedTabBar && slottedTabBar.props?.slot === 'top'; - return isTabBar && hasTopSlot; - }); + if (slottedTabBar) { + if (!slottedTabBar.props) { + slottedTabBar.props = {}; + } + /** + * ionTabsWillChange and ionTabsDidChange are + * fired from `ion-tabs`, so we need to pass these down + * as props so they can fire when the active tab changes. + * TODO: We may want to move logic from the tab bar into here + * so we do not have code split across two components. + */ + slottedTabBar.props._tabsWillChange = (tab: string) => $emit(WILL_CHANGE, { tab }); + slottedTabBar.props._tabsDidChange = (tab: string) => $emit(DID_CHANGE, { tab }); + } - if (topSlottedTabBar) { + if (hasTopSlotTabBar) { childrenToRender = [ ...slottedContent, ...childrenToRender diff --git a/packages/vue/test-app/tests/unit/tabs.spec.ts b/packages/vue/test-app/tests/unit/tabs.spec.ts new file mode 100644 index 0000000000..0262da6d34 --- /dev/null +++ b/packages/vue/test-app/tests/unit/tabs.spec.ts @@ -0,0 +1,246 @@ +import { mount, flushPromises } from '@vue/test-utils'; +import { createRouter, createWebHistory } from '@ionic/vue-router'; +import { IonicVue, IonApp, IonRouterOutlet, IonPage, IonTabs, IonTabBar, IonTabButton, IonLabel } from '@ionic/vue'; + +const App = { + components: { IonApp, IonRouterOutlet }, + template: '', +} + +const Tabs = { + components: { IonPage, IonTabs, IonTabBar, IonTabButton, IonLabel }, + template: ` + + + + + Tab 1 + + + Tab 2 + + + + + `, +} +const Tab1 = { + components: { IonPage }, + template: `Tab 1` +} +const Tab2 = { + components: { IonPage }, + template: `Tab 2` +} + +describe('ion-tabs', () => { + (HTMLElement.prototype as HTMLIonRouterOutletElement).commit = jest.fn(); + + it('should emit will change and did change events when changing tab', async () => { + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { + path: '/', + component: Tabs, + children: [ + { + path: '', + redirect: 'tab1' + }, + { + path: 'tab1', + component: Tab1, + }, + { + path: 'tab2', + component: Tab2 + } + ] + } + ] + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(App, { + global: { + plugins: [router, IonicVue] + } + }); + + const tabs = wrapper.findComponent(IonTabs); + expect(tabs.emitted().ionTabsWillChange.length).toEqual(1); + expect(tabs.emitted().ionTabsWillChange[0]).toEqual([{ tab: 'tab1' }]); + expect(tabs.emitted().ionTabsDidChange.length).toEqual(1); + expect(tabs.emitted().ionTabsDidChange[0]).toEqual([{ tab: 'tab1' }]); + + router.push('/tab2') + await flushPromises() + + expect(tabs.emitted().ionTabsWillChange.length).toEqual(2); + expect(tabs.emitted().ionTabsWillChange[1]).toEqual([{ tab: 'tab2' }]); + expect(tabs.emitted().ionTabsDidChange.length).toEqual(2); + expect(tabs.emitted().ionTabsDidChange[1]).toEqual([{ tab: 'tab2' }]); + }); + + it('should not emit will change and did change events when going to same tab again', async () => { + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { + path: '/', + component: Tabs, + children: [ + { + path: '', + redirect: 'tab1' + }, + { + path: 'tab1', + component: Tab1, + }, + { + path: 'tab2', + component: Tab2 + } + ] + } + ] + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(App, { + global: { + plugins: [router, IonicVue] + } + }); + + const tabs = wrapper.findComponent(IonTabs); + expect(tabs.emitted().ionTabsWillChange.length).toEqual(1); + expect(tabs.emitted().ionTabsWillChange[0]).toEqual([{ tab: 'tab1' }]); + expect(tabs.emitted().ionTabsDidChange.length).toEqual(1); + expect(tabs.emitted().ionTabsDidChange[0]).toEqual([{ tab: 'tab1' }]); + + router.push('/tab1') + await flushPromises() + + expect(tabs.emitted().ionTabsWillChange.length).toEqual(1); + expect(tabs.emitted().ionTabsDidChange.length).toEqual(1); + }); + + it('should not emit will change and did change events when going to a non tabs page', async () => { + const Sibling = { + components: { IonPage }, + template: `Sibling Page` + } + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { + path: '/', + component: Tabs, + children: [ + { + path: '', + redirect: 'tab1' + }, + { + path: 'tab1', + component: Tab1 + }, + { + path: 'tab2', + component: Tab2 + } + ] + }, + { + path: '/sibling', + component: Sibling + } + ] + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(App, { + global: { + plugins: [router, IonicVue] + } + }); + + const tabs = wrapper.findComponent(IonTabs); + expect(tabs.emitted().ionTabsWillChange.length).toEqual(1); + expect(tabs.emitted().ionTabsWillChange[0]).toEqual([{ tab: 'tab1' }]); + expect(tabs.emitted().ionTabsDidChange.length).toEqual(1); + expect(tabs.emitted().ionTabsDidChange[0]).toEqual([{ tab: 'tab1' }]); + + router.push('/sibling'); + await flushPromises(); + + await new Promise((r) => setTimeout(r, 100)); + + expect(tabs.emitted().ionTabsWillChange.length).toEqual(1); + expect(tabs.emitted().ionTabsDidChange.length).toEqual(1); + }); + + it('should not emit will change and did change events when going to child tab page', async () => { + const Child = { + components: { IonPage }, + template: `Child Page` + } + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { + path: '/', + component: Tabs, + children: [ + { + path: '', + redirect: 'tab1' + }, + { + path: 'tab1', + component: Tab1, + children: [ + { + path: 'child', + component: Child + } + ] + }, + { + path: 'tab2', + component: Tab2 + } + ] + } + ] + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(App, { + global: { + plugins: [router, IonicVue] + } + }); + + const tabs = wrapper.findComponent(IonTabs); + expect(tabs.emitted().ionTabsWillChange.length).toEqual(1); + expect(tabs.emitted().ionTabsWillChange[0]).toEqual([{ tab: 'tab1' }]); + expect(tabs.emitted().ionTabsDidChange.length).toEqual(1); + expect(tabs.emitted().ionTabsDidChange[0]).toEqual([{ tab: 'tab1' }]); + + router.push('/tab1/child'); + await flushPromises(); + + await new Promise((r) => setTimeout(r, 100)); + + expect(tabs.emitted().ionTabsWillChange.length).toEqual(1); + expect(tabs.emitted().ionTabsDidChange.length).toEqual(1); + }); +});