Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
59bc5ca046
|
|||
|
ef4bb8f51a
|
|||
|
f82a797720
|
|||
|
f9cb22ee0e
|
@@ -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}`;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,7 +39,7 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-DSyq2vVy.js"></script>
|
||||
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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"]']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user