From b00814a8a9cec466de73f9e41b8d98058b00abb6 Mon Sep 17 00:00:00 2001 From: Abban Dunne Date: Wed, 31 Jan 2024 09:03:07 +0100 Subject: [PATCH] Add router hijacking for donation and membership forms Also simplifies and adds tests to the membership form component Ticket: https://phabricator.wikimedia.org/T339109 --- src/components/pages/DonationForm.vue | 27 +++- src/components/pages/MembershipForm.vue | 109 ++++++--------- src/pages/donation_form.ts | 2 + src/pages/membership_application.ts | 2 + src/util/HistoryHijacker.ts | 33 +++++ .../pages/donation_form/DonationForm.spec.ts | 36 +++-- .../membership_form/MembershipForm.spec.ts | 127 ++++++++++++++++++ 7 files changed, 258 insertions(+), 78 deletions(-) create mode 100644 src/util/HistoryHijacker.ts create mode 100644 tests/unit/components/pages/membership_form/MembershipForm.spec.ts diff --git a/src/components/pages/DonationForm.vue b/src/components/pages/DonationForm.vue index 9971e1581..2a73f0b91 100644 --- a/src/components/pages/DonationForm.vue +++ b/src/components/pages/DonationForm.vue @@ -5,7 +5,7 @@ (); - const currentPageIndex = ref( props.startPageIndex ); watch( currentPageIndex, () => { window.scrollTo( 0, 0 ); } ); +props.historyHijacker.addHistoryCallback( ( e: PopStateEvent ) => { + // If the state is the address page then the user has hit the forward button after hitting back + currentPageIndex.value = e.state === AddressPageName ? 1 : 0; +} ); + +const goToAddressPage = () => { + currentPageIndex.value = 1; + props.historyHijacker.addPushState( AddressPageName ); +}; + +const goToPaymentPage = () => { + currentPageIndex.value = 0; + props.historyHijacker.back(); +}; + diff --git a/src/components/pages/MembershipForm.vue b/src/components/pages/MembershipForm.vue index ff52b239a..6a28ba81d 100644 --- a/src/components/pages/MembershipForm.vue +++ b/src/components/pages/MembershipForm.vue @@ -1,19 +1,35 @@ diff --git a/src/pages/donation_form.ts b/src/pages/donation_form.ts index 12a8e89e4..7ec1eaf01 100644 --- a/src/pages/donation_form.ts +++ b/src/pages/donation_form.ts @@ -23,6 +23,7 @@ import App from '@src/components/App.vue'; import DonationForm from '@src/components/pages/DonationForm.vue'; import { ApiCityAutocompleteResource } from '@src/util/CityAutocompleteResource'; import { createFeatureFetcher } from '@src/util/FeatureFetcher'; +import { WindowHistoryHijacker } from '@src/util/HistoryHijacker'; interface DonationFormModel { initialFormValues: any, @@ -81,6 +82,7 @@ dataPersister.initialize( persistenceItems ).then( () => { campaignValues: campaignParameters.getCampaignValues(), addressValidationPatterns: pageData.applicationVars.addressValidationPatterns, startPageIndex: paymentDataComplete ? 1 : 0, + historyHijacker: new WindowHistoryHijacker(), }, } ); app.provide( 'cityAutocompleteResource', new ApiCityAutocompleteResource() ); diff --git a/src/pages/membership_application.ts b/src/pages/membership_application.ts index cda357414..c5b20bf70 100644 --- a/src/pages/membership_application.ts +++ b/src/pages/membership_application.ts @@ -30,6 +30,7 @@ import { createFeatureFetcher } from '@src/util/FeatureFetcher'; import { bucketIdToCssClass } from '@src/util/bucket_id_to_css_class'; import CampaignParameters from '@src/util/CampaignParameters'; import { TrackingData } from '@src/view_models/TrackingData'; +import { WindowHistoryHijacker } from '@src/util/HistoryHijacker'; interface MembershipAmountModel { presetAmounts: Array, @@ -108,6 +109,7 @@ dataPersister.initialize( persistenceItems ).then( () => { dateOfBirthValidationPattern: pageData.applicationVars.dateOfBirthValidationPattern, trackingData: pageData.applicationVars.tracking, campaignValues: campaignParameters.getCampaignValues(), + historyHijacker: new WindowHistoryHijacker(), }, } ); app.provide( 'cityAutocompleteResource', new ApiCityAutocompleteResource() ); diff --git a/src/util/HistoryHijacker.ts b/src/util/HistoryHijacker.ts new file mode 100644 index 000000000..a99c3b26f --- /dev/null +++ b/src/util/HistoryHijacker.ts @@ -0,0 +1,33 @@ +export interface PopStateEvent { + state: string; +} + +export interface HistoryHijacker { + addHistoryCallback( callback: ( e: PopStateEvent ) => void ): void; + + addPushState( pageName: string ): void; + + back(): void; +} + +export class WindowHistoryHijacker implements HistoryHijacker { + private hasPushState: boolean = false; + + addHistoryCallback( callback: ( e: PopStateEvent ) => void ): void { + window.addEventListener( 'popstate', callback ); + } + + addPushState( pageName: string ) { + window.history.pushState( pageName, null, null ); + this.hasPushState = true; + } + + back(): void { + if ( !this.hasPushState ) { + return; + } + + this.hasPushState = false; + window.history.back(); + } +} diff --git a/tests/unit/components/pages/donation_form/DonationForm.spec.ts b/tests/unit/components/pages/donation_form/DonationForm.spec.ts index 4749a1c25..10d228b1e 100644 --- a/tests/unit/components/pages/donation_form/DonationForm.spec.ts +++ b/tests/unit/components/pages/donation_form/DonationForm.spec.ts @@ -3,22 +3,17 @@ import DonationForm from '@src/components/pages/DonationForm.vue'; import countries from '@src/../tests/data/countries'; import { AddressValidation } from '@src/view_models/Validation'; import { createFeatureToggle } from '@src/util/createFeatureToggle'; - -declare global { - namespace NodeJS { - interface Global { - window: Window; - } - } -} +import { HistoryHijacker } from '@src/util/HistoryHijacker'; const PaymentPage = { template: '
' }; const AddressPage = { template: '
' }; describe( 'DonationForm.vue', () => { + let historyHijacker: HistoryHijacker; beforeEach( () => { global.window.scrollTo = jest.fn(); + historyHijacker = { addHistoryCallback: jest.fn(), addPushState: jest.fn(), back: jest.fn() }; } ); const getWrapper = ( startPageIndex: number = 0 ): VueWrapper => { @@ -38,6 +33,7 @@ describe( 'DonationForm.vue', () => { salutations: [], addressValidationPatterns: {} as AddressValidation, startPageIndex, + historyHijacker, }, global: { stubs: { @@ -103,4 +99,28 @@ describe( 'DonationForm.vue', () => { expect( global.window.scrollTo ).toHaveBeenCalledTimes( 2 ); } ); + it( 'sets a history hijack callback when mounted', async () => { + getWrapper( 0 ); + + expect( historyHijacker.addHistoryCallback ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'adds history hijack push state when payment page is submitted', async () => { + const wrapper = getWrapper( 0 ); + + const paymentPage = wrapper.findComponent( PaymentPage ); + await paymentPage.vm.$emit( 'next-page' ); + + expect( historyHijacker.addPushState ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'calls history hijack back when donor clicks back link', async () => { + const wrapper = getWrapper( 1 ); + + const addressPage = wrapper.findComponent( AddressPage ); + await addressPage.vm.$emit( 'previous-page' ); + + expect( historyHijacker.back ).toHaveBeenCalledTimes( 1 ); + } ); + } ); diff --git a/tests/unit/components/pages/membership_form/MembershipForm.spec.ts b/tests/unit/components/pages/membership_form/MembershipForm.spec.ts new file mode 100644 index 000000000..d0fb0aeb9 --- /dev/null +++ b/tests/unit/components/pages/membership_form/MembershipForm.spec.ts @@ -0,0 +1,127 @@ +import { HistoryHijacker } from '@src/util/HistoryHijacker'; +import { mount, VueWrapper } from '@vue/test-utils'; +import MembershipForm from '@src/components/pages/MembershipForm.vue'; +import countries from '@test/data/countries'; +import { AddressValidation } from '@src/view_models/Validation'; + +const PaymentPage = { template: '
' }; +const AddressPage = { template: '
' }; + +describe( 'MembershipForm.vue', () => { + let historyHijacker: HistoryHijacker; + + beforeEach( () => { + global.window.scrollTo = jest.fn(); + historyHijacker = { addHistoryCallback: jest.fn(), addPushState: jest.fn(), back: jest.fn() }; + } ); + + const getWrapper = (): VueWrapper => { + return mount( MembershipForm, { + props: { + validateAddressUrl: '', + validateEmailUrl: '', + validateBankDataUrl: '', + validateLegacyBankDataUrl: '', + validateFeeUrl: '', + paymentAmounts: [ 5 ], + paymentIntervals: [ 0, 1, 3, 6, 12 ], + paymentTypes: [ 'BEZ', 'PPL', 'UEB', 'BTC' ], + countries, + salutations: [], + showMembershipTypeOption: false, + addressValidationPatterns: {} as AddressValidation, + dateOfBirthValidationPattern: '', + trackingData: { bannerImpressionCount: 0, impressionCount: 0 }, + campaignValues: { campaign: 'nicholas', keyword: 'cage' }, + historyHijacker, + }, + global: { + stubs: { + PaymentPage, + AddressPage, + }, + }, + } ); + }; + + it( 'displays Payment page on load ', () => { + const wrapper = getWrapper(); + + expect( wrapper.find( '.i-am-payment' ).exists() ).toBe( true ); + } ); + + it( 'loads Address page when next-page is triggered', async () => { + const wrapper = getWrapper(); + await wrapper.findComponent( PaymentPage ).vm.$emit( 'next-page' ); + + expect( wrapper.find( '.i-am-address-form' ).exists() ).toBe( true ); + } ); + + it( 'loads Payment component on the previous page', async () => { + const wrapper = getWrapper(); + + await wrapper.findComponent( PaymentPage ).vm.$emit( 'next-page' ); + await wrapper.findComponent( AddressPage ).vm.$emit( 'previous-page' ); + + expect( wrapper.find( '.i-am-payment' ).exists() ).toBe( true ); + } ); + + it( 'does not overshoot the first or last page when multiple page change events trigger', async () => { + const wrapper = getWrapper(); + + const paymentPage = wrapper.findComponent( PaymentPage ); + await paymentPage.vm.$emit( 'next-page' ); + await paymentPage.vm.$emit( 'next-page' ); + await paymentPage.vm.$emit( 'next-page' ); + + expect( wrapper.find( '.i-am-address-form' ).exists() ).toBe( true ); + + const addressPage = wrapper.findComponent( AddressPage ); + await addressPage.vm.$emit( 'previous-page' ); + await addressPage.vm.$emit( 'previous-page' ); + await addressPage.vm.$emit( 'previous-page' ); + + expect( wrapper.find( '.i-am-payment' ).exists() ).toBe( true ); + } ); + + it( 'scrolls to top of page only when page index changes', async () => { + const wrapper = getWrapper(); + + const paymentPage = wrapper.findComponent( PaymentPage ); + await paymentPage.vm.$emit( 'next-page' ); + await paymentPage.vm.$emit( 'next-page' ); + + const addressPage = wrapper.findComponent( AddressPage ); + await addressPage.vm.$emit( 'previous-page' ); + await addressPage.vm.$emit( 'previous-page' ); + + expect( global.window.scrollTo ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'sets a history hijack callback when mounted', async () => { + getWrapper(); + + expect( historyHijacker.addHistoryCallback ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'adds history hijack push state when payment page is submitted', async () => { + const wrapper = getWrapper(); + + const paymentPage = wrapper.findComponent( PaymentPage ); + await paymentPage.vm.$emit( 'next-page' ); + + expect( historyHijacker.addPushState ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'calls history hijack back when donor clicks back link', async () => { + const wrapper = getWrapper(); + + const paymentPage = wrapper.findComponent( PaymentPage ); + await paymentPage.vm.$emit( 'next-page' ); + + const addressPage = wrapper.findComponent( AddressPage ); + await addressPage.vm.$emit( 'previous-page' ); + + expect( historyHijacker.back ).toHaveBeenCalledTimes( 1 ); + } ); +} );