Sync search form query value
* Clear input when clearing search from anywhere * Pre-fill input when opening search URL with query params
This commit is contained in:
@@ -9,6 +9,7 @@ import SearchBox from '#components/search-box';
|
|||||||
import CategoryChips from '#components/category-chips';
|
import CategoryChips from '#components/category-chips';
|
||||||
import { and } from 'ember-truth-helpers';
|
import { and } from 'ember-truth-helpers';
|
||||||
import cachedImage from '../modifiers/cached-image';
|
import cachedImage from '../modifiers/cached-image';
|
||||||
|
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||||
|
|
||||||
export default class AppHeaderComponent extends Component {
|
export default class AppHeaderComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@@ -16,11 +17,59 @@ export default class AppHeaderComponent extends Component {
|
|||||||
@service nostrAuth;
|
@service nostrAuth;
|
||||||
@service nostrData;
|
@service nostrData;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
|
@service router;
|
||||||
@tracked isUserMenuOpen = false;
|
@tracked isUserMenuOpen = false;
|
||||||
@tracked searchQuery = '';
|
@tracked searchQuery = '';
|
||||||
|
|
||||||
get hasQuery() {
|
constructor() {
|
||||||
return !!this.searchQuery;
|
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() {
|
get showQuickSearch() {
|
||||||
@@ -61,7 +110,7 @@ export default class AppHeaderComponent extends Component {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if this.showQuickSearch}}
|
{{#if this.showQuickSearch}}
|
||||||
<div class="header-center {{if this.hasQuery 'searching'}}">
|
<div class="header-center {{if this.isSearching 'searching'}}">
|
||||||
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -169,4 +169,77 @@ module('Acceptance | search loading', function (hooks) {
|
|||||||
// Verify we are back on index (or at least query is gone)
|
// Verify we are back on index (or at least query is gone)
|
||||||
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -270,4 +270,76 @@ module('Acceptance | search', function (hooks) {
|
|||||||
.hasText('Search request failed. Please try again.');
|
.hasText('Search request failed. Please try again.');
|
||||||
assert.dom('.places-sidebar').doesNotExist('Results panel should not open');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user