diff --git a/src/router/index.js b/src/router/index.js index 764e0bf4c..a3f9847da 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,3 +1,4 @@ +import { IS_TESTING_WITH_CYPRESS } from '@/config' import appLoadingManagementRouterPlugin from '@/router/appLoadingManagement.routerPlugin' import legacyPermalinkManagementRouterPlugin from '@/router/legacyPermalinkManagement.routerPlugin' import storeSyncRouterPlugin from '@/router/storeSync/storeSync.routerPlugin' @@ -7,6 +8,8 @@ import LoadingView from '@/views/LoadingView.vue' import MapView from '@/views/MapView.vue' import { createRouter, createWebHashHistory } from 'vue-router' +const history = createWebHashHistory() + /** * The Vue Router for this app, see [Vue Router's doc on how to use * it]{@link https://router.vuejs.org/guide/} @@ -14,7 +17,7 @@ import { createRouter, createWebHashHistory } from 'vue-router' * @type {Router} */ const router = createRouter({ - history: createWebHashHistory(), + history, routes: [ { path: '/', @@ -42,4 +45,10 @@ appLoadingManagementRouterPlugin(router, store) legacyPermalinkManagementRouterPlugin(router, store) storeSyncRouterPlugin(router, store) +// exposing the router to Cypress, so that we may change URL param on the fly (without app reload), +// and this way test app reaction to URL changes +if (IS_TESTING_WITH_CYPRESS) { + window.vueRouterHistory = history + window.vueRouter = router +} export default router diff --git a/src/router/storeSync/CrossHairParamConfig.class.js b/src/router/storeSync/CrossHairParamConfig.class.js index 00d3cad09..2ba743330 100644 --- a/src/router/storeSync/CrossHairParamConfig.class.js +++ b/src/router/storeSync/CrossHairParamConfig.class.js @@ -4,6 +4,10 @@ import { round } from '@/utils/numberUtils' function dispatchCrossHairFromUrlIntoStore(store, urlParamValue) { const promisesForAllDispatch = [] + if (typeof urlParamValue !== 'string') { + promisesForAllDispatch.push(store.dispatch('setCrossHair', { crossHair: null })) + } + const parts = urlParamValue.split(',') if (parts.length === 1) { promisesForAllDispatch.push(store.dispatch('setCrossHair', { crossHair: urlParamValue })) diff --git a/src/router/storeSync/storeSync.routerPlugin.js b/src/router/storeSync/storeSync.routerPlugin.js index 3b8f82345..f45c54abd 100644 --- a/src/router/storeSync/storeSync.routerPlugin.js +++ b/src/router/storeSync/storeSync.routerPlugin.js @@ -1,5 +1,9 @@ +import { IS_TESTING_WITH_CYPRESS } from '@/config' import storeSyncConfig from '@/router/storeSync/storeSync.config' import log from '@/utils/logging' +import axios from 'axios' + +export const FAKE_URL_CALLED_AFTER_ROUTE_CHANGE = '/tell-cypress-route-has-changed' const watchedMutations = [ ...new Set( @@ -97,25 +101,25 @@ function urlQueryWatcher(store, to) { routeChangeIsTriggeredByThisModule = false return undefined } + const pendingStoreDispatch = [] let requireQueryUpdate = false - const newQuery = Object.assign({}, to.query) - // if the route change is not made by this module we need to check if a store change is needed + const newQuery = { ...to.query } + // if this module did not trigger the route change, we need to check if a store change is needed storeSyncConfig.forEach((paramConfig) => { const queryValue = paramConfig.readValueFromQuery(to.query) const storeValue = paramConfig.readValueFromStore(store) - const setValueInStore = (paramConfig, store, value) => { - // preventing store.subscribe above to change what is in the URL while dispatching this change - // if we don't ignore this next mutation, all other param than the one treated here could go back + const setValueInStore = async (paramConfig, store, value) => { + // Preventing store.subscribe above to change what is in the URL, while dispatching this change. + // If we don't ignore this next mutation, all other param than the one treated here could go back // to default/store value even though they could be defined differently in the URL. pendingMutationTriggeredByThisModule.push(paramConfig.mutationsToWatch) - paramConfig.populateStoreWithQueryValue(store, value).then(() => { - // removing mutation name from the pending ones - pendingMutationTriggeredByThisModule.splice( - pendingMutationTriggeredByThisModule.indexOf(paramConfig.mutationsToWatch), - 1 - ) - }) + await paramConfig.populateStoreWithQueryValue(store, value) + // removing mutation name from the pending ones + pendingMutationTriggeredByThisModule.splice( + pendingMutationTriggeredByThisModule.indexOf(paramConfig.mutationsToWatch), + 1 + ) } if (queryValue && queryValue !== storeValue) { @@ -126,7 +130,7 @@ function urlQueryWatcher(store, to) { 'to store with value', queryValue ) - setValueInStore(paramConfig, store, queryValue) + pendingStoreDispatch.push(setValueInStore(paramConfig, store, queryValue)) } else if (!queryValue && storeValue) { if (paramConfig.keepInUrlWhenDefault) { // if we don't have a query value but a store value update the url query with it @@ -145,16 +149,26 @@ function urlQueryWatcher(store, to) { paramConfig.urlParamName, 'has been removed from the URL, setting it to falsy value in the store' ) - setValueInStore( - paramConfig, - store, - paramConfig.valueType === Boolean ? false : null + pendingStoreDispatch.push( + setValueInStore( + paramConfig, + store, + paramConfig.valueType === Boolean ? false : null + ) ) delete newQuery[paramConfig.urlParamName] } requireQueryUpdate = true } }) + // Fake call to a URL so that Cypress can wait for route changes without waiting for arbitrary length of time + if (IS_TESTING_WITH_CYPRESS) { + Promise.all(pendingStoreDispatch).then(() => { + axios({ + url: FAKE_URL_CALLED_AFTER_ROUTE_CHANGE, + }) + }) + } if (requireQueryUpdate) { log.debug('Update URL query to', newQuery) // NOTE: this rewrite of query currently don't work when navigating manually got the `/#/` diff --git a/src/store/modules/position.store.js b/src/store/modules/position.store.js index 99f38c9be..66cd8283d 100644 --- a/src/store/modules/position.store.js +++ b/src/store/modules/position.store.js @@ -229,19 +229,24 @@ const actions = { }, increaseZoom: ({ dispatch, state }) => dispatch('setZoom', Number(state.zoom) + 1), decreaseZoom: ({ dispatch, state }) => dispatch('setZoom', Number(state.zoom) - 1), - /** @param {CrossHairs | String | null} crossHair */ + /** + * @param {CrossHairs | String | null} crossHair + * @param {Number[] | null} crossHairPosition + */ setCrossHair: ({ commit, state }, { crossHair, crossHairPosition }) => { if (crossHair === null) { - commit('setCrossHair', crossHair) + commit('setCrossHair', null) + commit('setCrossHairPosition', null) } else if (crossHair in CrossHairs) { commit('setCrossHair', CrossHairs[crossHair]) - } - // if a position is defined as param we use it - if (crossHairPosition) { - commit('setCrossHairPosition', crossHairPosition) - } else { - // if no position was given, we use the current center of the map as crosshair position - commit('setCrossHairPosition', state.center) + + // if a position is defined as param we use it + if (crossHairPosition) { + commit('setCrossHairPosition', crossHairPosition) + } else { + // if no position was given, we use the current center of the map as crosshair position + commit('setCrossHairPosition', state.center) + } } }, /** diff --git a/tests/e2e-cypress/integration/crosshair.cy.js b/tests/e2e-cypress/integration/crosshair.cy.js new file mode 100644 index 000000000..d9448c2a5 --- /dev/null +++ b/tests/e2e-cypress/integration/crosshair.cy.js @@ -0,0 +1,82 @@ +/// + +import { DEFAULT_PROJECTION } from '@/config' +import { CrossHairs } from '@/store/modules/position.store' + +describe('Testing the crosshair URL param', () => { + context('At app startup', () => { + it('does not add the crosshair by default', () => { + cy.goToMapView() + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.be.null + expect(positionStore.crossHairPosition).to.be.null + }) + }) + it('adds the crosshair at the center of the map if only the crosshair param is given', () => { + cy.goToMapView({ + crosshair: CrossHairs.point, + }) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.eq(CrossHairs.point) + expect(positionStore.crossHairPosition).to.eql(positionStore.center) + }) + }) + it('sets the crosshair at the given coordinate if provided in the URL (and not at map center)', () => { + const crossHairPosition = DEFAULT_PROJECTION.bounds.center.map((value) => value + 1000) + cy.goToMapView({ + crosshair: `${CrossHairs.bowl},${crossHairPosition.join(',')}`, + }) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.eq(CrossHairs.bowl) + expect(positionStore.crossHairPosition).to.eql(crossHairPosition) + }) + }) + }) + context('Changes of URL param value while the app has been loaded', () => { + it('Changes the crosshair types correctly if changed after app load', () => { + cy.goToMapView({ + crosshair: CrossHairs.point, + }) + cy.readStoreValue('state.position.crossHair').should('eq', CrossHairs.point) + cy.changeUrlParam('crosshair', CrossHairs.marker) + cy.readStoreValue('state.position.crossHair').should('eq', CrossHairs.marker) + }) + it('Changes the crosshair position if set after app reload', () => { + cy.goToMapView({ + crosshair: CrossHairs.cross, + }) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.eq(CrossHairs.cross) + expect(positionStore.crossHairPosition).to.eql(positionStore.center) + }) + + const newCrossHairPosition = DEFAULT_PROJECTION.bounds.center.map( + (value) => value - 12345 + ) + cy.changeUrlParam( + 'crosshair', + `${CrossHairs.cross},${newCrossHairPosition.join(',')}`, + // a change of the crosshair param with position triggers two dispatches: setCrossHair and setCrossHairPosition + 2 + ) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.eq(CrossHairs.cross) + expect(positionStore.crossHairPosition).to.eql(newCrossHairPosition) + }) + }) + it('removes the crosshair if the URL param is removed (or set to null)', () => { + cy.goToMapView({ + crosshair: CrossHairs.circle, + }) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.eq(CrossHairs.circle) + expect(positionStore.crossHairPosition).to.eql(positionStore.center) + }) + cy.changeUrlParam('crosshair', null, 2) + cy.readStoreValue('state.position').then((positionStore) => { + expect(positionStore.crossHair).to.be.null + expect(positionStore.crossHairPosition).to.be.null + }) + }) + }) +}) diff --git a/tests/e2e-cypress/support/commands.js b/tests/e2e-cypress/support/commands.js index affdb1460..14fda93ed 100644 --- a/tests/e2e-cypress/support/commands.js +++ b/tests/e2e-cypress/support/commands.js @@ -1,4 +1,5 @@ import { BREAKPOINT_TABLET } from '@/config' +import { FAKE_URL_CALLED_AFTER_ROUTE_CHANGE } from '@/router/storeSync/storeSync.routerPlugin' import { randomIntBetween } from '@/utils/numberUtils' import 'cypress-real-events' import 'cypress-wait-until' @@ -10,6 +11,12 @@ import { MapBrowserEvent } from 'ol' // https://on.cypress.io/custom-commands // *********************************************** +const addVueRouterIntercept = () => { + cy.intercept(FAKE_URL_CALLED_AFTER_ROUTE_CHANGE, { + statusCode: 200, + }).as('routeChange') +} + const addLayerTileFixture = () => { // catching WMTS type URLs in web mercator and lv95 cy.intercept(/1.0.0\/.*\/.*\/.*\/(21781|2056|3857|4326)\/\d+\/\d+\/\d+.jpe?g/, { @@ -86,6 +93,7 @@ const addGeoJsonIntercept = () => { export function getDefaultFixturesAndIntercepts() { return { + addVueRouterIntercept, addLayerTileFixture, addLayerFixtureAndIntercept, addTopicFixtureAndIntercept, @@ -193,6 +201,57 @@ Cypress.Commands.add( } ) +/** + * Changes a URL parameter without reloading the app. + * + * Help when you want to change a value in the URL but don't want the whole app be reloaded from + * scratch in the process. + * + * @param {string} urlParamName URL param name (present or not in the URL, will be added or + * overwritten) + * @param {string} urlParamValue The new URL param value we want to have + * @param {number} amountOfExpectedStoreDispatches The number of dispatches this change in the URL + * is going to trigger. This function will then wait for this amount of dispatch in the store + * before letting the test go further + */ +Cypress.Commands.add( + 'changeUrlParam', + (urlParamName, urlParamValue, amountOfExpectedStoreDispatches = 1) => { + cy.window() + .its('vueRouterHistory') + .then((vueRouterHistory) => { + // the router location will everything behind the hash sign, meaning /map?param1=...¶m2=... + const queryPart = vueRouterHistory.location.split('?')[1] + const query = new URLSearchParams(queryPart) + if (urlParamValue) { + query.set(urlParamName, urlParamValue) + } else { + query.delete(urlParamName) + } + + // We have to do the toString by hand, as if we use the standard toString all param value + // will be encoded. And so comas will be URL encoded instead of left untouched, meaning layers, camera and + // other params that use the coma to split values will not work. + const unencodedQuery = Array.from(query.entries()) + .map(([key, value]) => `${key}=${value}`) + .reduce((param1, param2) => `${param1}${param2}&`, '?') + // removing the trailing & resulting of the reduction above + .slice(0, -1) + // regenerating the complete router location + const newLocation = `${vueRouterHistory.location.split('?')[0]}${unencodedQuery}` + cy.log('router location changed from', vueRouterHistory.location, 'to', newLocation) + cy.window() + .its('vueRouter') + .then((vueRouter) => { + vueRouter.push(newLocation) + for (let i = 0; i < amountOfExpectedStoreDispatches; i++) { + cy.wait('@routeChange') + } + }) + }) + } +) + /** * Click on language command *