diff --git a/src/api/search.api.js b/src/api/search.api.js index c76cd8f15..2d67d665e 100644 --- a/src/api/search.api.js +++ b/src/api/search.api.js @@ -181,13 +181,14 @@ function parseLocationResult(result, outputProjection) { } } -async function searchLayers(queryString, lang, cancelToken) { +async function searchLayers(queryString, lang, cancelToken, limit = 0) { try { const layerResponse = await generateAxiosSearchRequest( queryString, lang, 'layers', - cancelToken.token + cancelToken.token, + { limit } ) // checking that there is something of interest to parse const resultWithAttrs = layerResponse?.data.results?.filter((result) => result.attrs) @@ -206,15 +207,17 @@ async function searchLayers(queryString, lang, cancelToken) { * @param queryString * @param lang * @param cancelToken + * @param limit * @returns {Promise} */ -async function searchLocation(outputProjection, queryString, lang, cancelToken) { +async function searchLocation(outputProjection, queryString, lang, cancelToken, limit = 0) { try { const locationResponse = await generateAxiosSearchRequest( queryString, lang, 'locations', - cancelToken.token + cancelToken.token, + { limit } ) // checking that there is something of interest to parse const resultWithAttrs = locationResponse?.data.results?.filter((result) => result.attrs) @@ -280,10 +283,17 @@ let cancelToken = null * @param {String} config.lang The lang ISO code in which the search must be conducted * @param {GeoAdminLayer[]} [config.layersToSearch=[]] List of searchable layers for which to fire * search requests. Default is `[]` + * @param {number} config.limit The maximum number of results to return * @returns {Promise} */ export default async function search(config) { - const { outputProjection = null, queryString = null, lang = null, layersToSearch = [] } = config + const { + outputProjection = null, + queryString = null, + lang = null, + layersToSearch = [], + limit = 0, + } = config if (!(outputProjection instanceof CoordinateSystem)) { const errorMessage = `A valid output projection is required to start a search request` log.error(errorMessage) @@ -307,10 +317,11 @@ export default async function search(config) { /** @type {Promise[]} */ const allRequests = [ - searchLayers(queryString, lang, cancelToken), - searchLocation(outputProjection, queryString, lang, cancelToken), + searchLayers(queryString, lang, cancelToken, limit), + searchLocation(outputProjection, queryString, lang, cancelToken, limit), ] + // TODO limit also in the local kml and gpx files ? if (layersToSearch.some((layer) => layer.searchable)) { allRequests.push( ...layersToSearch diff --git a/src/modules/menu/components/search/SearchBar.vue b/src/modules/menu/components/search/SearchBar.vue index a907f2898..8410e61a6 100644 --- a/src/modules/menu/components/search/SearchBar.vue +++ b/src/modules/menu/components/search/SearchBar.vue @@ -9,7 +9,7 @@ const dispatcher = { dispatcher: 'SearchBar' } const store = useStore() -const isPristine = ref(true) +const isPristine = ref(true) // if search bar is not yet modified by the user const showResults = ref(false) const searchInput = ref(null) const searchValue = ref('') @@ -20,16 +20,20 @@ const searchQuery = computed(() => store.state.search.query) const hasResults = computed(() => store.state.search.results.length > 0) const isPhoneMode = computed(() => store.getters.isPhoneMode) -watch(hasResults, (newValue) => { - // if an entry has been selected from the list, do not show the list again - // because the list has been hidden by onEntrySelected. Also if the search bar is pristine (not - // yet modified by the user) we don't want to show the result (e.g. at startup with a swisssearch - // query param) - if (!selectedEntry.value && !isPristine.value) { - log.debug(`Search has result changed to ${newValue}, change the show result to ${newValue}`) - showResults.value = newValue - } -}) +watch( + hasResults, + (newValue) => { + // if an entry has been selected from the list, do not show the list again + // because the list has been hidden by onEntrySelected. + if (!selectedEntry.value) { + log.debug( + `Search has result changed to ${newValue}, change the show result to ${newValue}` + ) + showResults.value = newValue + } + }, + { immediate: true } +) watch(showResults, (newValue) => { if (newValue && isPhoneMode.value && store.state.ui.showMenu) { diff --git a/src/router/storeSync/SearchParamConfig.class.js b/src/router/storeSync/SearchParamConfig.class.js index 47923fc50..1946ed1e6 100644 --- a/src/router/storeSync/SearchParamConfig.class.js +++ b/src/router/storeSync/SearchParamConfig.class.js @@ -2,6 +2,7 @@ import AbstractParamConfig, { STORE_DISPATCHER_ROUTER_PLUGIN, } from '@/router/storeSync/abstractParamConfig.class' +const URL_PARAM_NAME = 'swisssearch' /** * The goal is to stop centering on the search when sharing a position. When we share a position, * both the center and the crosshair are sets. @@ -15,16 +16,41 @@ function dispatchSearchFromUrl(to, store, urlParamValue) { query: urlParamValue, shouldCenter: !(to.query.crosshair && to.query.center), dispatcher: STORE_DISPATCHER_ROUTER_PLUGIN, + originUrlParam: true, }) } +/** + * This will remove the query param from the URL It is necessary to do this in vanilla JS because + * the router does not provide a way to remove a query without reloading the page which then removes + * the value from the store. + * + * @param {Object} key The key to remove from the URL + */ +function removeQueryParamFromHref(key) { + const [baseUrl, queryString] = window.location.href.split('?') + if (!queryString) { + return + } + + const params = new URLSearchParams(queryString) + if (!params.has(key)) { + return + } + params.delete(key) + + const newQueryString = params.toString() + const newUrl = newQueryString ? `${baseUrl}?${newQueryString}` : baseUrl + window.history.replaceState({}, document.title, newUrl) +} + export default class SearchParamConfig extends AbstractParamConfig { constructor() { super({ - urlParamName: 'swisssearch', - mutationsToWatch: ['setSearchQuery'], + urlParamName: URL_PARAM_NAME, + mutationsToWatch: [], setValuesInStore: dispatchSearchFromUrl, - extractValueFromStore: (store) => store.state.search.query, + afterSetValuesInStore: () => removeQueryParamFromHref(URL_PARAM_NAME), keepInUrlWhenDefault: false, valueType: String, defaultValue: '', diff --git a/src/router/storeSync/abstractParamConfig.class.js b/src/router/storeSync/abstractParamConfig.class.js index e9f4db7c0..7f1e01659 100644 --- a/src/router/storeSync/abstractParamConfig.class.js +++ b/src/router/storeSync/abstractParamConfig.class.js @@ -27,16 +27,19 @@ export default class AbstractParamConfig { * added to the URL even though its value is set to the default value of the param. * @param {NumberConstructor | StringConstructor | BooleanConstructor, ObjectConstructor} valueType * @param {Boolean | Number | String | null} defaultValue + * @param {Function} afterSetValuesInStore A function that will be called after the store values + * have been set */ constructor({ urlParamName, mutationsToWatch, setValuesInStore, - extractValueFromStore, + extractValueFromStore = (_) => '', keepInUrlWhenDefault = true, valueType = String, defaultValue = null, validateUrlInput = null, + afterSetValuesInStore = null, } = {}) { this.urlParamName = urlParamName this.mutationsToWatch = mutationsToWatch @@ -51,6 +54,7 @@ export default class AbstractParamConfig { this.defaultValue = false } this.validateUrlInput = validateUrlInput + this.afterSetValuesInStore = afterSetValuesInStore } /** @@ -201,4 +205,25 @@ export default class AbstractParamConfig { } }) } + + /** + * Triggers an action after the store has been populated with the query value. Returns a promise + * + * @returns {Promise} + */ + afterPopulateStore() { + return new Promise((resolve) => { + if (!this.afterSetValuesInStore) { + resolve() + } + const promiseAfterSetValuesInStore = this.afterSetValuesInStore() + if (promiseAfterSetValuesInStore) { + promiseAfterSetValuesInStore.then(() => { + resolve() + }) + } else { + resolve() + } + }) + } } diff --git a/src/router/storeSync/storeSync.routerPlugin.js b/src/router/storeSync/storeSync.routerPlugin.js index f8cad089a..39c6d11cf 100644 --- a/src/router/storeSync/storeSync.routerPlugin.js +++ b/src/router/storeSync/storeSync.routerPlugin.js @@ -122,6 +122,7 @@ function urlQueryWatcher(store, to, from) { const setValueInStore = async (paramConfig, store, value) => { await paramConfig.populateStoreWithQueryValue(to, store, value) + await paramConfig.afterPopulateStore() } if ( diff --git a/src/store/modules/search.store.js b/src/store/modules/search.store.js index 749b9b02c..efaee9dba 100644 --- a/src/store/modules/search.store.js +++ b/src/store/modules/search.store.js @@ -27,6 +27,19 @@ const state = { const getters = {} +function extractLimitNumber(query) { + const regex = / limit: \d+/ + const match = query.match(regex) + + if (match) { + return { + limit: parseInt(match[0].split(':')[1].trim()), + extractedQuery: query.replace(match[0], ''), + } + } + return { limit: 0, extractedQuery: query } +} + const actions = { /** * @param {vuex} vuex @@ -35,10 +48,12 @@ const actions = { */ setSearchQuery: async ( { commit, rootState, dispatch, getters }, - { query = '', shouldCenter = true, dispatcher } + { query = '', originUrlParam = false, shouldCenter = true, dispatcher } ) => { let results = [] commit('setSearchQuery', { query, dispatcher }) + const { limit, extractedQuery } = extractLimitNumber(query) + query = extractedQuery // only firing search if query is longer than or equal to 2 chars if (query.length >= 2) { const currentProjection = rootState.position.projection @@ -129,7 +144,14 @@ const actions = { queryString: query, lang: rootState.i18n.lang, layersToSearch: getters.visibleLayers, + limit, }) + if (originUrlParam && results.length === 1) { + dispatch('selectResultEntry', { + dispatcher: `${dispatcher}/setSearchQuery`, + entry: results[0], + }) + } } catch (error) { log.error(`Search failed`, error) } @@ -147,6 +169,7 @@ const actions = { * @param {SearchResult} entry */ selectResultEntry: ({ dispatch, getters, rootState }, { entry, dispatcher }) => { + console.log('selectResultEntry', entry) const dispatcherSelectResultEntry = `${dispatcher}/search.store/selectResultEntry` switch (entry.resultType) { case SearchResultTypes.LAYER: diff --git a/src/store/plugins/redo-search-when-needed.plugin.js b/src/store/plugins/redo-search-when-needed.plugin.js index 18835dce4..c70fc62b3 100644 --- a/src/store/plugins/redo-search-when-needed.plugin.js +++ b/src/store/plugins/redo-search-when-needed.plugin.js @@ -12,6 +12,7 @@ const redoSearchWhenNeeded = (store) => { query: store.state.search.query, // we don't center on the search query when redoing a search if there is a crosshair shouldCenter: store.state.position.crossHair === null, + originUrlParam: true, // necessary to select the first result if there is only one else it will not be because this redo search is done every time the page loaded }) } } diff --git a/tests/cypress/tests-e2e/legacyParamImport.cy.js b/tests/cypress/tests-e2e/legacyParamImport.cy.js index 98e4d6411..9cc30701b 100644 --- a/tests/cypress/tests-e2e/legacyParamImport.cy.js +++ b/tests/cypress/tests-e2e/legacyParamImport.cy.js @@ -249,6 +249,7 @@ describe('Test on legacy param import', () => { cy.intercept('**/rest/services/ech/SearchServer*?type=layers*', { body: { results: [] }, }).as('search-layers') + const coordinates = [2598633.75, 1200386.75] cy.intercept('**/rest/services/ech/SearchServer*?type=locations*', { body: { results: [ @@ -256,6 +257,10 @@ describe('Test on legacy param import', () => { attrs: { detail: '1530 payerne 5822 payerne ch vd', label: ' 1530 Payerne', + lat: 46.954559326171875, + lon: 7.420684814453125, + y: coordinates[0], + x: coordinates[1], }, }, ], @@ -267,10 +272,59 @@ describe('Test on legacy param import', () => { }, false ) - cy.readStoreValue('state.search.query').should('eq', '1530 Payerne') - cy.url().should('include', 'swisssearch=1530+Payerne') + cy.readStoreValue('state.search.query').should('eq', ' 1530 Payerne') + cy.url().should('not.contain', 'swisssearch') cy.get('[data-cy="searchbar"]').click() - cy.get('[data-cy="search-results-locations"]').should('be.visible') + const acceptableDelta = 0.25 + + // selects the result if it is only one + cy.readStoreValue('state.map.pinnedLocation').should((feature) => { + expect(feature).to.not.be.null + expect(feature).to.be.a('array').that.is.not.empty + expect(feature[0]).to.be.approximately(coordinates[0], acceptableDelta) + expect(feature[1]).to.be.approximately(coordinates[1], acceptableDelta) + }) + cy.get('[data-cy="search-results-locations"]').should('not.be.visible') + }) + it('limits the swisssearch with legacy parameter limit', () => { + cy.intercept('**/rest/services/ech/SearchServer*?type=layers*', { + body: { results: [] }, + }).as('search-layers') + const coordinates = [2598633.75, 1200386.75] + cy.intercept('**/rest/services/ech/SearchServer*?type=locations*', { + body: { + results: [ + { + attrs: { + detail: '1530 payerne 5822 payerne ch vd', + label: ' 1530 Payerne', + lat: 46.954559326171875, + lon: 7.420684814453125, + y: coordinates[0], + x: coordinates[1], + }, + }, + { + attrs: { + detail: '1530 payerne 5822 payerne ch vd 2', + label: ' 1530 Payerne 2', + lat: 46.954559326171875, + lon: 7.420684814453125, + y: coordinates[0], + x: coordinates[1], + }, + }, + ], + }, + }).as('search-locations') + cy.goToMapView( + { + swisssearch: '1530 Payerne limit: 2', + }, + false + ) + cy.readStoreValue('state.search.query').should('eq', '1530 Payerne limit: 2') + cy.url().should('not.contain', 'swisssearch') }) it('External WMS layer', () => { const layerName = 'OpenData-AV' diff --git a/tests/cypress/tests-e2e/search/search-results.cy.js b/tests/cypress/tests-e2e/search/search-results.cy.js index 12ea2502d..3d112e0dd 100644 --- a/tests/cypress/tests-e2e/search/search-results.cy.js +++ b/tests/cypress/tests-e2e/search/search-results.cy.js @@ -165,18 +165,14 @@ describe('Test the search bar result handling', () => { .invoke('text') .should('eq', expectedLocationLabel.replaceAll(/<\/?b>/g, '')) - cy.log('Checking that it adds the search query as swisssearch URL param') - cy.url().should('contain', 'swisssearch=test') + cy.log('Checking that it does not add the search query as swisssearch URL param') + cy.url().should('not.contain', 'swisssearch') cy.log( 'Checking that it reads the swisssearch URL param at startup and launch a search with its content' ) - cy.reload() - cy.waitMapIsReady() - cy.wait(['@search-locations', '@search-layers']) + cy.readStoreValue('state.search.query').should('eq', 'test') - cy.get('@locationSearchResults').should('not.be.visible') - cy.get(searchbarSelector).click() cy.get('@locationSearchResults').should('be.visible') cy.log('Checking that it displays layer results with info-buttons') @@ -379,5 +375,17 @@ describe('Test the search bar result handling', () => { cy.wait(['@search-locations', '@search-layers', '@search-layer-features']) cy.get('@layerFeatureSearchCategory').should('be.visible') + + cy.get(searchbarSelector).click() + cy.get('@locationSearchResults').should('not.be.visible') + + cy.log('Checking that the swisssearch url param is not present after reloading the page') + cy.reload() + cy.waitMapIsReady() + cy.wait(['@search-locations', '@search-layers']) + + cy.url().should('not.contain', 'swisssearch') + cy.readStoreValue('state.search.query').should('equal', '') + cy.get('@locationSearchResults').should('not.exist') }) })