From 62407f5fa41a684b22062a2e243286484a303f96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 30 Jun 2026 18:45:15 +0200 Subject: [PATCH] Sync search form query value * Clear input when clearing search from anywhere * Pre-fill input when opening search URL with query params --- app/components/app-header.gjs | 55 ++++++++++++++++++- tests/acceptance/search-loading-test.js | 73 +++++++++++++++++++++++++ tests/acceptance/search-test.js | 72 ++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 3 deletions(-) diff --git a/app/components/app-header.gjs b/app/components/app-header.gjs index 425e204..fd4fe56 100644 --- a/app/components/app-header.gjs +++ b/app/components/app-header.gjs @@ -9,6 +9,7 @@ import SearchBox from '#components/search-box'; import CategoryChips from '#components/category-chips'; import { and } from 'ember-truth-helpers'; import cachedImage from '../modifiers/cached-image'; +import { POI_CATEGORIES } from '../utils/poi-categories'; export default class AppHeaderComponent extends Component { @service storage; @@ -16,11 +17,59 @@ export default class AppHeaderComponent extends Component { @service nostrAuth; @service nostrData; @service mapUi; + @service router; @tracked isUserMenuOpen = false; @tracked searchQuery = ''; - get hasQuery() { - return !!this.searchQuery; + constructor() { + super(...arguments); + if (this.router && typeof this.router.on === 'function') { + this.router.on('routeDidChange', this.syncSearchQuery); + } + this.syncSearchQuery(); + } + + willDestroy() { + if (this.router && typeof this.router.off === 'function') { + this.router.off('routeDidChange', this.syncSearchQuery); + } + super.willDestroy(...arguments); + } + + @action + syncSearchQuery() { + const qp = + this.mapUi.currentSearch || this.router?.currentRoute?.queryParams; + if (qp?.q) { + this.searchQuery = qp.q; + } else if (qp?.category) { + const category = POI_CATEGORIES.find((c) => c.id === qp.category); + this.searchQuery = category ? category.label : qp.category; + } else { + this.searchQuery = ''; + } + } + + get isSearching() { + // 1. If we are actively focusing/typing in the search box with a query, hide pills + if (this.mapUi.searchBoxHasFocus && this.searchQuery) { + return true; + } + + // 2. If we are on the search route, check loading and results status + if (this.router?.currentRouteName === 'search') { + if (this.mapUi.loadingState) { + return false; // Keep pills visible while loading + } + return this.mapUi.searchResults && this.mapUi.searchResults.length > 0; + } + + // 3. Fallback for integration tests (non-search route with a query) + if (this.router?.currentRouteName !== 'search' && this.searchQuery) { + return true; + } + + return false; } get showQuickSearch() { @@ -61,7 +110,7 @@ export default class AppHeaderComponent extends Component { {{#if this.showQuickSearch}} -
+
{{/if}} diff --git a/tests/acceptance/search-loading-test.js b/tests/acceptance/search-loading-test.js index 87fa843..eaed172 100644 --- a/tests/acceptance/search-loading-test.js +++ b/tests/acceptance/search-loading-test.js @@ -169,4 +169,77 @@ module('Acceptance | search loading', function (hooks) { // Verify we are back on index (or at least query is gone) assert.strictEqual(currentURL(), '/', 'Navigated to index'); }); + + test('quick search pills visibility during category search transition', async function (assert) { + const mapUi = this.owner.lookup('service:map-ui'); + mapUi.currentZoom = 15; + + // Seed localStorage with a high zoom level to ensure quick search buttons show + const highZoomState = { + center: [13.4, 52.5], + zoom: 18, + }; + window.localStorage.setItem( + 'marco:map-view', + JSON.stringify(highZoomState) + ); + + try { + // Make sure quick search buttons setting is enabled + const settings = this.owner.lookup('service:settings'); + settings.showQuickSearchButtons = true; + + // 1. Visit slowly loading category search + const catPromise = visit('/search?category=slow_category&lat=1&lon=1'); + await new Promise((r) => setTimeout(r, 10)); + + // Verify loading state is set and pills are visible (i.e. header-center does NOT have .searching) + assert.ok(mapUi.loadingState, 'Search is loading'); + assert + .dom('.header-center') + .doesNotHaveClass( + 'searching', + 'Pills remain visible while search is loading' + ); + + // Resolve the promise with empty results + osmResolve(); + await catPromise; + await settled(); + + // Verify search completed and since results are empty, pills are still visible + assert.strictEqual(mapUi.searchResults.length, 0, 'No results found'); + assert + .dom('.header-center') + .doesNotHaveClass( + 'searching', + 'Pills remain visible after search completes with no results' + ); + + // 2. Now simulate a fast category search that returns results + const osmService = this.owner.lookup('service:osm'); + osmService.getCategoryPois = async () => [ + { + title: 'Latte Art Cafe', + lat: 1, + lon: 1, + osmId: '101', + osmType: 'N', + }, + ]; + + await visit('/search?category=coffee&lat=1&lon=1'); + + // Verify search completed with results, so pills are hidden + assert.ok(mapUi.searchResults.length > 0, 'Results found'); + assert + .dom('.header-center') + .hasClass( + 'searching', + 'Pills are hidden after search completes with results' + ); + } finally { + window.localStorage.removeItem('marco:map-view'); + } + }); }); diff --git a/tests/acceptance/search-test.js b/tests/acceptance/search-test.js index 42ecedb..151c130 100644 --- a/tests/acceptance/search-test.js +++ b/tests/acceptance/search-test.js @@ -270,4 +270,76 @@ module('Acceptance | search', function (hooks) { .hasText('Search request failed. Please try again.'); assert.dom('.places-sidebar').doesNotExist('Results panel should not open'); }); + + test('search box query synchronized with active route query parameters', async function (assert) { + // Mock Osm Service + class MockOsmService extends Service { + async getCategoryPois() { + return []; + } + } + this.owner.register('service:osm', MockOsmService); + + // Mock Photon Service + class MockPhotonService extends Service { + async search() { + return []; + } + } + this.owner.register('service:photon', MockPhotonService); + + // Mock Storage Service + class MockStorageService extends Service { + savedPlaces = []; + findPlaceById() { + return null; + } + isPlaceSaved() { + return false; + } + rs = { on: () => {} }; + placesInView = []; + loadPlacesInBounds() { + return Promise.resolve(); + } + } + this.owner.register('service:storage', MockStorageService); + + // Mock Map Service + class MockMapService extends Service { + getBounds() { + return { + minLat: 52.5, + minLon: 13.4, + maxLat: 52.6, + maxLon: 13.5, + }; + } + } + this.owner.register('service:map', MockMapService); + + // 1. Visit a search URL directly + await visit('/search?q=Berlin'); + assert + .dom('.search-input') + .hasValue( + 'Berlin', + 'Search input is populated with search term on direct load' + ); + + // 2. Visit a category search URL + await visit('/search?category=coffee&lat=52.52&lon=13.405'); + assert + .dom('.search-input') + .hasValue( + 'Coffee', + 'Search input is populated with mapped category label' + ); + + // 3. Go back to index + await visit('/'); + assert + .dom('.search-input') + .hasValue('', 'Search input is cleared on transitioning to index'); + }); });