Merge pull request 'Include saved places in search results' (#59) from feature/search_saved_places into master
All checks were successful
CI / Lint (push) Successful in 33s
CI / Test (push) Successful in 56s

Reviewed-on: #59
This commit was merged in pull request #59.
This commit is contained in:
2026-06-06 08:03:32 +00:00
3 changed files with 337 additions and 10 deletions

View File

@@ -13,6 +13,7 @@ import { eq, or } from 'ember-truth-helpers';
export default class SearchBoxComponent extends Component {
@service photon;
@service osm;
@service storage;
@service router;
@service mapUi;
@service map; // Assuming we might need map context, but mostly we use router
@@ -50,6 +51,29 @@ export default class SearchBoxComponent extends Component {
this.searchTask.perform(value);
}
formatSavedPlace(place) {
const listNames = (place._listIds || [])
.map((id) => this.storage.lists?.find((l) => l.id === id)?.title)
.filter(Boolean)
.join(', ');
const description = listNames
? `Saved place (${listNames})`
: 'Saved place';
return {
source: 'saved',
id: place.id,
title: place.title,
icon: 'bookmark',
description,
osmId: place.osmId,
osmType: place.osmType,
lat: place.lat,
lon: place.lon,
};
}
searchTask = task({ restartable: true }, async (term) => {
await timeout(300);
@@ -76,8 +100,29 @@ export default class SearchBoxComponent extends Component {
icon: 'search',
}));
// Filter saved places (minimum 3 characters)
let savedMatches = [];
if (q.length >= 3) {
savedMatches = this.storage.savedPlaces
.filter((p) => p.title && p.title.toLowerCase().includes(q))
.map((p) => this.formatSavedPlace(p));
}
const results = await this.photon.search(query, lat, lon);
this.results = [...categoryMatches, ...results];
// Deduplicate Photon results that are already in saved matches
const savedOsmIds = new Set(
savedMatches.map((s) => s.osmId).filter(Boolean)
);
const filteredPhotonResults = results.filter(
(r) => !savedOsmIds.has(r.osmId)
);
this.results = [
...categoryMatches,
...savedMatches,
...filteredPhotonResults,
];
} catch (e) {
console.error('Search failed', e);
this.results = [];
@@ -156,8 +201,12 @@ export default class SearchBoxComponent extends Component {
}
this.results = []; // Hide popover
// If it has an OSM ID, go to place details
if (place.osmId) {
// If it's a custom saved place without an OSM ID, go to place details via internal ID
if (place.source === 'saved' && place.id && !place.osmId) {
this.router.transitionTo('place', place.id);
}
// If it has an OSM ID, go to place details via OSM ID
else if (place.osmId) {
// Format: osm:node:123
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
const id = `osm:${place.osmType}:${place.osmId}`;

View File

@@ -78,14 +78,17 @@ export default class SearchController extends Controller {
// Search with Photon (using lat/lon for bias if available)
pois = await this.photon.search(params.q, lat, lon);
// Search local bookmarks by name
// Search local bookmarks by name (minimum 3 characters)
const queryLower = params.q.toLowerCase();
const localMatches = this.storage.savedPlaces.filter((p) => {
return (
p.title?.toLowerCase().includes(queryLower) ||
p.description?.toLowerCase().includes(queryLower)
);
});
let localMatches = [];
if (queryLower.length >= 3) {
localMatches = this.storage.savedPlaces.filter((p) => {
return (
p.title?.toLowerCase().includes(queryLower) ||
p.description?.toLowerCase().includes(queryLower)
);
});
}
// Merge local matches
localMatches.forEach((local) => {

View File

@@ -230,4 +230,279 @@ module('Integration | Component | search-box', function (hooks) {
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
]);
});
test('it includes, deduplicates, and prioritizes saved places in search results', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo(routeName, id) {
assert.step(`transitionTo: ${routeName} ["${id}"]`);
}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service
class MockStorageService extends Service {
lists = [{ id: 'favs', title: 'Favorites' }];
savedPlaces = [
{
title: 'Awesome Coffee',
lat: 52.5,
lon: 13.4,
osmId: '999',
osmType: 'node',
_listIds: ['favs'],
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search(query) {
if (query === 'coffee') {
return [
{
title: 'Awesome Coffee',
osmId: '999',
osmType: 'node',
description: 'Duplicate to be removed',
},
{
title: 'Other Coffee',
osmId: '888',
osmType: 'node',
description: 'A different coffee shop',
},
];
}
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type "coffee" to trigger matches in Category, Saved, and Photon
await fillIn('.search-input', 'coffee');
await waitFor('.search-results-popover', { timeout: 2000 });
const resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should be exactly 3 items:
// 1. Category (Coffee)
// 2. Saved (Awesome Coffee)
// 3. Photon (Other Coffee)
// (The Photon duplicate of "Awesome Coffee" is removed)
assert.strictEqual(resultItems.length, 3, 'Renders exactly 3 items');
// 1. Category
assert.ok(
resultItems[0].textContent.includes('Coffee'),
'First item is the category match'
);
assert
.dom(resultItems[0].querySelector('.result-icon svg'))
.hasClass('feather-search', 'Category uses search icon');
// 2. Saved Place
assert.ok(
resultItems[1].textContent.includes('Awesome Coffee'),
'Second item is the saved place match'
);
assert.ok(
resultItems[1].textContent.includes('Saved place'),
'Saved place has correct description text'
);
assert
.dom(resultItems[1].querySelector('.result-icon svg'))
.hasClass('feather-bookmark', 'Saved place uses bookmark icon');
// 3. Photon Match
assert.ok(
resultItems[2].textContent.includes('Other Coffee'),
'Third item is the unique photon result'
);
// Click the Saved Place
await click(resultItems[1]);
assert.verifySteps(['transitionTo: place ["osm:node:999"]']);
});
test('it requires 3 or more characters to match saved places', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo() {}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service
class MockStorageService extends Service {
lists = [{ id: 'favs', title: 'Favorites' }];
savedPlaces = [
{
title: 'Awesome Coffee',
lat: 52.5,
lon: 13.4,
osmId: '999',
osmType: 'node',
_listIds: ['favs'],
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search(query) {
if (query === 'aw' || query === 'awe') {
return [
{
title: 'Aww Some Place',
osmId: '111',
osmType: 'node',
},
];
}
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type "aw" (2 characters)
await fillIn('.search-input', 'aw');
await waitFor('.search-results-popover', { timeout: 2000 });
let resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should only show Photon match since 'aw' is < 3 characters
assert.strictEqual(
resultItems.length,
1,
'Renders exactly 1 item for 2 chars'
);
assert.ok(
resultItems[0].textContent.includes('Aww Some Place'),
'Shows photon match'
);
assert.notOk(
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
'Saved place is NOT shown for 2 char query'
);
// Type "awe" (3 characters)
await fillIn('.search-input', 'awe');
await waitFor('.search-results-popover', { timeout: 2000 });
resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Should now show Saved Place and Photon match
assert.strictEqual(
resultItems.length,
2,
'Renders exactly 2 items for 3 chars'
);
assert.ok(
resultItems.some((item) => item.textContent.includes('Awesome Coffee')),
'Saved place is now shown'
);
assert.ok(
resultItems.some((item) =>
item.textContent.includes('Saved place (Favorites)')
),
'List names are appended to the description'
);
});
test('it navigates to internal ID for custom saved places without an OSM ID', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 52.52, lon: 13.405 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo(routeName, id) {
assert.step(`transitionTo: ${routeName} ["${id}"]`);
}
}
this.owner.register('service:router', MockRouterService);
// Mock Storage Service (Custom Place)
class MockStorageService extends Service {
savedPlaces = [
{
id: 'custom-1234',
title: 'My Custom Home',
lat: 52.5,
lon: 13.4,
// Notice NO osmId or osmType
},
];
}
this.owner.register('service:storage', MockStorageService);
// Mock Photon Service
class MockPhotonService extends Service {
async search() {
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type 3 chars to trigger saved place match
await fillIn('.search-input', 'cus');
await waitFor('.search-results-popover', { timeout: 2000 });
const resultItems = Array.from(
this.element.querySelectorAll('.search-result-item')
);
// Ensure our custom place is rendered
const customResult = resultItems.find((item) =>
item.textContent.includes('My Custom Home')
);
assert.ok(customResult, 'Custom place is rendered');
// Click it
await click(customResult);
// Verify it navigated using the internal ID, NOT a search query
assert.verifySteps(['transitionTo: place ["custom-1234"]']);
});
});