Compare commits
3 Commits
818ec35071
...
0ac6db65cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
0ac6db65cb
|
|||
|
86b20fd474
|
|||
|
8478e00253
|
@@ -5,6 +5,7 @@ import { on } from '@ember/modifier';
|
|||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||||
|
import { eq, and } from 'ember-truth-helpers';
|
||||||
|
|
||||||
export default class CategoryChipsComponent extends Component {
|
export default class CategoryChipsComponent extends Component {
|
||||||
@service router;
|
@service router;
|
||||||
@@ -41,6 +42,7 @@ export default class CategoryChipsComponent extends Component {
|
|||||||
class="category-chip"
|
class="category-chip"
|
||||||
{{on "click" (fn this.searchCategory category)}}
|
{{on "click" (fn this.searchCategory category)}}
|
||||||
aria-label={{category.label}}
|
aria-label={{category.label}}
|
||||||
|
disabled={{and (eq this.mapUi.loadingState.type "category") (eq this.mapUi.loadingState.value category.id)}}
|
||||||
>
|
>
|
||||||
<Icon @name={{category.icon}} @size={{16}} />
|
<Icon @name={{category.icon}} @size={{16}} />
|
||||||
<span>{{category.label}}</span>
|
<span>{{category.label}}</span>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import { task, timeout } from 'ember-concurrency';
|
|||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
import { eq, or } from 'ember-truth-helpers';
|
||||||
|
|
||||||
export default class SearchBoxComponent extends Component {
|
export default class SearchBoxComponent extends Component {
|
||||||
@service photon;
|
@service photon;
|
||||||
|
@service osm;
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service map; // Assuming we might need map context, but mostly we use router
|
@service map; // Assuming we might need map context, but mostly we use router
|
||||||
@@ -178,6 +179,11 @@ export default class SearchBoxComponent extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
clear() {
|
clear() {
|
||||||
|
this.searchTask.cancelAll();
|
||||||
|
this.mapUi.stopLoading();
|
||||||
|
this.osm.cancelAll();
|
||||||
|
this.photon.cancelAll();
|
||||||
|
|
||||||
this.query = '';
|
this.query = '';
|
||||||
this.results = [];
|
this.results = [];
|
||||||
if (this.args.onQueryChange) {
|
if (this.args.onQueryChange) {
|
||||||
@@ -211,7 +217,11 @@ export default class SearchBoxComponent extends Component {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<button type="submit" class="search-submit-btn" aria-label="Search">
|
<button type="submit" class="search-submit-btn" aria-label="Search">
|
||||||
<Icon @name="search" @size={{20}} @color="#5f6368" />
|
{{#if (or (eq this.mapUi.loadingState.type "text") (eq this.mapUi.loadingState.type "category"))}}
|
||||||
|
<Icon @name="loading-ring" @size={{20}} />
|
||||||
|
{{else}}
|
||||||
|
<Icon @name="search" @size={{20}} @color="#5f6368" />
|
||||||
|
{{/if}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{#if this.query}}
|
{{#if this.query}}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-ember-extension="1"><path d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 12 12;360 12 12" repeatCount="indefinite"/></path></svg>
|
||||||
|
After Width: | Height: | Size: 464 B |
+104
-82
@@ -22,97 +22,119 @@ export default class SearchRoute extends Route {
|
|||||||
const lat = params.lat ? parseFloat(params.lat) : null;
|
const lat = params.lat ? parseFloat(params.lat) : null;
|
||||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||||
let pois = [];
|
let pois = [];
|
||||||
|
let loadingType = null;
|
||||||
|
let loadingValue = null;
|
||||||
|
|
||||||
// Case 0: Category Search (category parameter present)
|
try {
|
||||||
if (params.category && lat && lon) {
|
// Case 0: Category Search (category parameter present)
|
||||||
// We need bounds. If we have active map state, use it.
|
if (params.category && lat && lon) {
|
||||||
let bounds = this.mapUi.currentBounds;
|
loadingType = 'category';
|
||||||
|
loadingValue = params.category;
|
||||||
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
|
|
||||||
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
// We need bounds. If we have active map state, use it.
|
||||||
// or just use a fixed box around the center.
|
let bounds = this.mapUi.currentBounds;
|
||||||
if (!bounds) {
|
|
||||||
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
|
||||||
// Let's take a safe box of ~1km radius.
|
|
||||||
const delta = 0.01;
|
|
||||||
bounds = {
|
|
||||||
minLat: lat - delta,
|
|
||||||
maxLat: lat + delta,
|
|
||||||
minLon: lon - delta,
|
|
||||||
maxLon: lon + delta,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pois = await this.osm.getCategoryPois(bounds, params.category, lat, lon);
|
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
||||||
|
// or just use a fixed box around the center.
|
||||||
// Sort by distance from center
|
if (!bounds) {
|
||||||
pois = pois
|
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
||||||
.map((p) => ({
|
// Let's take a safe box of ~1km radius.
|
||||||
...p,
|
const delta = 0.01;
|
||||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
bounds = {
|
||||||
}))
|
minLat: lat - delta,
|
||||||
.sort((a, b) => a._distance - b._distance);
|
maxLat: lat + delta,
|
||||||
}
|
minLon: lon - delta,
|
||||||
// Case 1: Text Search (q parameter present)
|
maxLon: lon + delta,
|
||||||
else if (params.q) {
|
};
|
||||||
// Search with Photon (using lat/lon for bias if available)
|
|
||||||
pois = await this.photon.search(params.q, lat, lon);
|
|
||||||
|
|
||||||
// Search local bookmarks by name
|
|
||||||
const queryLower = params.q.toLowerCase();
|
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
|
||||||
return (
|
|
||||||
p.title?.toLowerCase().includes(queryLower) ||
|
|
||||||
p.description?.toLowerCase().includes(queryLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge local matches
|
|
||||||
localMatches.forEach((local) => {
|
|
||||||
const exists = pois.find(
|
|
||||||
(poi) =>
|
|
||||||
(local.osmId && poi.osmId === local.osmId) ||
|
|
||||||
(poi.id && poi.id === local.id)
|
|
||||||
);
|
|
||||||
if (!exists) {
|
|
||||||
pois.push(local);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
// Case 2: Nearby Search (lat/lon present, no q)
|
|
||||||
else if (lat && lon) {
|
|
||||||
const searchRadius = 50; // Default radius
|
|
||||||
|
|
||||||
// Fetch POIs from Overpass
|
pois = await this.osm.getCategoryPois(
|
||||||
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
bounds,
|
||||||
|
params.category,
|
||||||
// Get cached/saved places in search radius
|
lat,
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
lon
|
||||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
|
||||||
return dist <= searchRadius;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge local matches
|
|
||||||
localMatches.forEach((local) => {
|
|
||||||
const exists = pois.find(
|
|
||||||
(poi) =>
|
|
||||||
(local.osmId && poi.osmId === local.osmId) ||
|
|
||||||
(poi.id && poi.id === local.id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!exists) {
|
// Sort by distance from center
|
||||||
pois.push(local);
|
pois = pois
|
||||||
}
|
.map((p) => ({
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by distance from click
|
|
||||||
pois = pois
|
|
||||||
.map((p) => {
|
|
||||||
return {
|
|
||||||
...p,
|
...p,
|
||||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
};
|
}))
|
||||||
})
|
.sort((a, b) => a._distance - b._distance);
|
||||||
.sort((a, b) => a._distance - b._distance);
|
}
|
||||||
|
// Case 1: Text Search (q parameter present)
|
||||||
|
else if (params.q) {
|
||||||
|
loadingType = 'text';
|
||||||
|
loadingValue = params.q;
|
||||||
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
|
|
||||||
|
// Search with Photon (using lat/lon for bias if available)
|
||||||
|
pois = await this.photon.search(params.q, lat, lon);
|
||||||
|
|
||||||
|
// Search local bookmarks by name
|
||||||
|
const queryLower = params.q.toLowerCase();
|
||||||
|
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
|
return (
|
||||||
|
p.title?.toLowerCase().includes(queryLower) ||
|
||||||
|
p.description?.toLowerCase().includes(queryLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge local matches
|
||||||
|
localMatches.forEach((local) => {
|
||||||
|
const exists = pois.find(
|
||||||
|
(poi) =>
|
||||||
|
(local.osmId && poi.osmId === local.osmId) ||
|
||||||
|
(poi.id && poi.id === local.id)
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
pois.push(local);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Case 2: Nearby Search (lat/lon present, no q)
|
||||||
|
else if (lat && lon) {
|
||||||
|
// Nearby search does NOT trigger loading state (pulse is used instead)
|
||||||
|
const searchRadius = 50; // Default radius
|
||||||
|
|
||||||
|
// Fetch POIs from Overpass
|
||||||
|
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||||
|
|
||||||
|
// Get cached/saved places in search radius
|
||||||
|
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
|
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||||
|
return dist <= searchRadius;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge local matches
|
||||||
|
localMatches.forEach((local) => {
|
||||||
|
const exists = pois.find(
|
||||||
|
(poi) =>
|
||||||
|
(local.osmId && poi.osmId === local.osmId) ||
|
||||||
|
(poi.id && poi.id === local.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
pois.push(local);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by distance from click
|
||||||
|
pois = pois
|
||||||
|
.map((p) => {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a._distance - b._distance);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (loadingType && loadingValue) {
|
||||||
|
this.mapUi.stopLoading(loadingType, loadingValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any of these are already bookmarked
|
// Check if any of these are already bookmarked
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default class MapUiService extends Service {
|
|||||||
@tracked preventNextZoom = false;
|
@tracked preventNextZoom = false;
|
||||||
@tracked searchResults = [];
|
@tracked searchResults = [];
|
||||||
@tracked currentSearch = null;
|
@tracked currentSearch = null;
|
||||||
|
@tracked loadingState = null;
|
||||||
|
|
||||||
selectPlace(place, options = {}) {
|
selectPlace(place, options = {}) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
@@ -70,4 +71,26 @@ export default class MapUiService extends Service {
|
|||||||
updateBounds(bounds) {
|
updateBounds(bounds) {
|
||||||
this.currentBounds = bounds;
|
this.currentBounds = bounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startLoading(type, value) {
|
||||||
|
this.loadingState = { type, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
stopLoading(type = null, value = null) {
|
||||||
|
// If no arguments provided, force stop (legacy/cleanup)
|
||||||
|
if (!type && !value) {
|
||||||
|
this.loadingState = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only clear if the current state matches the request
|
||||||
|
// This prevents a previous search from clearing the state of a new search
|
||||||
|
if (
|
||||||
|
this.loadingState &&
|
||||||
|
this.loadingState.type === type &&
|
||||||
|
this.loadingState.value === value
|
||||||
|
) {
|
||||||
|
this.loadingState = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ export default class OsmService extends Service {
|
|||||||
cachedResults = null;
|
cachedResults = null;
|
||||||
lastQueryKey = null;
|
lastQueryKey = null;
|
||||||
|
|
||||||
|
cancelAll() {
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
this.controller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getNearbyPois(lat, lon, radius = 50) {
|
async getNearbyPois(lat, lon, radius = 50) {
|
||||||
const queryKey = `${lat},${lon},${radius}`;
|
const queryKey = `${lat},${lon},${radius}`;
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -5,6 +5,15 @@ import { humanizeOsmTag } from '../utils/format-text';
|
|||||||
export default class PhotonService extends Service {
|
export default class PhotonService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
|
|
||||||
|
controller = null;
|
||||||
|
|
||||||
|
cancelAll() {
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
this.controller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get baseUrl() {
|
get baseUrl() {
|
||||||
return this.settings.photonApi;
|
return this.settings.photonApi;
|
||||||
}
|
}
|
||||||
@@ -12,6 +21,12 @@ export default class PhotonService extends Service {
|
|||||||
async search(query, lat, lon, limit = 10) {
|
async search(query, lat, lon, limit = 10) {
|
||||||
if (!query || query.length < 2) return [];
|
if (!query || query.length < 2) return [];
|
||||||
|
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
}
|
||||||
|
this.controller = new AbortController();
|
||||||
|
const signal = this.controller.signal;
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
q: query,
|
q: query,
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
@@ -25,7 +40,7 @@ export default class PhotonService extends Service {
|
|||||||
const url = `${this.baseUrl}?${params.toString()}`;
|
const url = `${this.baseUrl}?${params.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchWithRetry(url);
|
const res = await this.fetchWithRetry(url, { signal });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Photon request failed with status ${res.status}`);
|
throw new Error(`Photon request failed with status ${res.status}`);
|
||||||
}
|
}
|
||||||
@@ -35,6 +50,9 @@ export default class PhotonService extends Service {
|
|||||||
|
|
||||||
return data.features.map((f) => this.normalizeFeature(f));
|
return data.features.map((f) => this.normalizeFeature(f));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
console.error('Photon search error:', e);
|
console.error('Photon search error:', e);
|
||||||
// Return empty array on error so UI doesn't break
|
// Return empty array on error so UI doesn't break
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1315,3 +1315,9 @@ button.create-place {
|
|||||||
.category-chip:active {
|
.category-chip:active {
|
||||||
background: #eee;
|
background: #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-chip:disabled {
|
||||||
|
opacity: 0.75;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import user from 'feather-icons/dist/icons/user.svg?raw';
|
|||||||
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
||||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||||
|
import loadingRing from '../icons/270-ring.svg?raw';
|
||||||
|
|
||||||
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||||
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||||
@@ -104,6 +105,7 @@ import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/w
|
|||||||
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||||
|
|
||||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||||
|
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
activity,
|
activity,
|
||||||
@@ -209,8 +211,10 @@ const ICONS = {
|
|||||||
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
||||||
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||||
wikipedia,
|
wikipedia,
|
||||||
|
parking_p: parkingP,
|
||||||
x,
|
x,
|
||||||
zap,
|
zap,
|
||||||
|
'loading-ring': loadingRing,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FILLED_ICONS = [
|
const FILLED_ICONS = [
|
||||||
@@ -221,6 +225,7 @@ const FILLED_ICONS = [
|
|||||||
'shopping-basket',
|
'shopping-basket',
|
||||||
'camera',
|
'camera',
|
||||||
'person-sleeping-in-bed',
|
'person-sleeping-in-bed',
|
||||||
|
'loading-ring',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getIcon(name) {
|
export function getIcon(name) {
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ export const POI_ICON_RULES = [
|
|||||||
{ tags: { healthcare: 'dentist' }, icon: 'molar-tooth' },
|
{ tags: { healthcare: 'dentist' }, icon: 'molar-tooth' },
|
||||||
{ tags: { healthcare: true }, icon: 'greek-cross' },
|
{ tags: { healthcare: true }, icon: 'greek-cross' },
|
||||||
|
|
||||||
|
// Parking
|
||||||
|
{ tags: { amenity: 'parking' }, icon: 'parking_p' },
|
||||||
|
|
||||||
// Buildings
|
// Buildings
|
||||||
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
|
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
|
||||||
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
|
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import {
|
||||||
|
visit,
|
||||||
|
click,
|
||||||
|
fillIn,
|
||||||
|
triggerEvent,
|
||||||
|
currentURL,
|
||||||
|
} from '@ember/test-helpers';
|
||||||
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import { Promise } from 'rsvp';
|
||||||
|
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
cancelAll() {}
|
||||||
|
|
||||||
|
async search(query) {
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
if (query === 'slow') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Test Place',
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockOsmService extends Service {
|
||||||
|
cancelAll() {}
|
||||||
|
|
||||||
|
async getCategoryPois(bounds, category) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
if (category === 'slow_category') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async getNearbyPois() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module('Acceptance | search loading', function (hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search shows loading indicator but nearby search does not', async function (assert) {
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// 1. Text Search
|
||||||
|
// Start a search and check for loading state immediately
|
||||||
|
const searchPromise = visit('/search?q=slow');
|
||||||
|
|
||||||
|
// We can't easily check the DOM mid-transition in acceptance tests without complicated helpers,
|
||||||
|
// so we check the service state which drives the UI.
|
||||||
|
// Wait a tiny bit for the route to start processing
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
{ type: 'text', value: 'slow' },
|
||||||
|
'Loading state is set for text search'
|
||||||
|
);
|
||||||
|
|
||||||
|
await searchPromise;
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is cleared after text search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Category Search
|
||||||
|
const catPromise = visit('/search?category=slow_category&lat=1&lon=1');
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
{ type: 'category', value: 'slow_category' },
|
||||||
|
'Loading state is set for category search'
|
||||||
|
);
|
||||||
|
|
||||||
|
await catPromise;
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is cleared after category search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Nearby Search
|
||||||
|
await visit('/search?lat=1&lon=1');
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is NOT set for nearby search'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing search stops loading indicator', async function (assert) {
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// 1. Start from index
|
||||||
|
await visit('/');
|
||||||
|
|
||||||
|
// 2. Type "slow" to trigger autocomplete (which is async)
|
||||||
|
await fillIn('.search-input', 'slow');
|
||||||
|
|
||||||
|
// 3. Submit search to trigger route loading
|
||||||
|
click('.search-submit-btn'); // Intentionally no await to not block on transition
|
||||||
|
|
||||||
|
// Wait for loading state to activate
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
{ type: 'text', value: 'slow' },
|
||||||
|
'Loading state is set'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Click the clear button (should be visible since input has value)
|
||||||
|
await click('.search-clear-btn');
|
||||||
|
|
||||||
|
// Verify loading state is cleared immediately
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is cleared immediately after clicking clear'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify we are back on index (or at least query is gone)
|
||||||
|
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'marco/tests/helpers';
|
||||||
|
|
||||||
|
module('Unit | Service | map-ui', function (hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
|
||||||
|
test('it handles loading state correctly', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
assert.strictEqual(
|
||||||
|
service.loadingState,
|
||||||
|
null,
|
||||||
|
'loadingState starts as null'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start loading search A
|
||||||
|
service.startLoading('search', 'A');
|
||||||
|
assert.deepEqual(
|
||||||
|
service.loadingState,
|
||||||
|
{ type: 'search', value: 'A' },
|
||||||
|
'loadingState is set to search A'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop loading search A (successful case)
|
||||||
|
service.stopLoading('search', 'A');
|
||||||
|
assert.strictEqual(
|
||||||
|
service.loadingState,
|
||||||
|
null,
|
||||||
|
'loadingState is cleared when stopped with matching parameters'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles race condition: stopLoading only clears if parameters match', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// 1. Start loading search A
|
||||||
|
service.startLoading('search', 'A');
|
||||||
|
assert.deepEqual(service.loadingState, { type: 'search', value: 'A' });
|
||||||
|
|
||||||
|
// 2. Start loading search B (interruption)
|
||||||
|
// In a real app, search B would start before search A finishes.
|
||||||
|
service.startLoading('search', 'B');
|
||||||
|
assert.deepEqual(
|
||||||
|
service.loadingState,
|
||||||
|
{ type: 'search', value: 'B' },
|
||||||
|
'loadingState updates to search B'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Search A finishes and tries to stop loading
|
||||||
|
// The service should ignore this because current loading state is for B
|
||||||
|
service.stopLoading('search', 'A');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
service.loadingState,
|
||||||
|
{ type: 'search', value: 'B' },
|
||||||
|
'loadingState remains search B even after stopping search A'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Search B finishes
|
||||||
|
service.stopLoading('search', 'B');
|
||||||
|
assert.strictEqual(
|
||||||
|
service.loadingState,
|
||||||
|
null,
|
||||||
|
'loadingState is cleared when search B stops'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user