Include saved places in search results
This commit is contained in:
@@ -13,6 +13,7 @@ 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 osm;
|
||||||
|
@service storage;
|
||||||
@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
|
||||||
@@ -76,8 +77,39 @@ export default class SearchBoxComponent extends Component {
|
|||||||
icon: 'search',
|
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) => ({
|
||||||
|
source: 'saved',
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
icon: 'bookmark',
|
||||||
|
description: 'Saved place',
|
||||||
|
osmId: p.osmId,
|
||||||
|
osmType: p.osmType,
|
||||||
|
lat: p.lat,
|
||||||
|
lon: p.lon,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const results = await this.photon.search(query, lat, lon);
|
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) {
|
} catch (e) {
|
||||||
console.error('Search failed', e);
|
console.error('Search failed', e);
|
||||||
this.results = [];
|
this.results = [];
|
||||||
@@ -156,8 +188,12 @@ export default class SearchBoxComponent extends Component {
|
|||||||
}
|
}
|
||||||
this.results = []; // Hide popover
|
this.results = []; // Hide popover
|
||||||
|
|
||||||
// If it has an OSM ID, go to place details
|
// If it's a custom saved place without an OSM ID, go to place details via internal ID
|
||||||
if (place.osmId) {
|
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
|
// Format: osm:node:123
|
||||||
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
||||||
const id = `osm:${place.osmType}:${place.osmId}`;
|
const id = `osm:${place.osmType}:${place.osmId}`;
|
||||||
|
|||||||
@@ -78,14 +78,17 @@ export default class SearchController extends Controller {
|
|||||||
// Search with Photon (using lat/lon for bias if available)
|
// Search with Photon (using lat/lon for bias if available)
|
||||||
pois = await this.photon.search(params.q, lat, lon);
|
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 queryLower = params.q.toLowerCase();
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
let localMatches = [];
|
||||||
return (
|
if (queryLower.length >= 3) {
|
||||||
p.title?.toLowerCase().includes(queryLower) ||
|
localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
p.description?.toLowerCase().includes(queryLower)
|
return (
|
||||||
);
|
p.title?.toLowerCase().includes(queryLower) ||
|
||||||
});
|
p.description?.toLowerCase().includes(queryLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Merge local matches
|
// Merge local matches
|
||||||
localMatches.forEach((local) => {
|
localMatches.forEach((local) => {
|
||||||
|
|||||||
@@ -230,4 +230,269 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
|
'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 {
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
title: 'Awesome Coffee',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
title: 'Awesome Coffee',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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"]']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user