Compare commits
7 Commits
981502e293
...
v1.25.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
cf80c1eea7
|
|||
|
126806fcd4
|
|||
|
f48045c35d
|
|||
|
989173a2e6
|
|||
|
62407f5fa4
|
|||
|
7f1c4b5f61
|
|||
|
59e334c499
|
@@ -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}}
|
||||||
|
|||||||
@@ -1088,6 +1088,7 @@ export default class MapComponent extends Component {
|
|||||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||||
this.mapUi.updateBounds(bbox);
|
this.mapUi.updateBounds(bbox);
|
||||||
await this.storage.loadPlacesInBounds(bbox);
|
await this.storage.loadPlacesInBounds(bbox);
|
||||||
|
if (this.isDestroying || this.isDestroyed) return;
|
||||||
this.nostrData.loadPlacesInBounds(bbox);
|
this.nostrData.loadPlacesInBounds(bbox);
|
||||||
this.loadBookmarks(this.storage.placesInView);
|
this.loadBookmarks(this.storage.placesInView);
|
||||||
|
|
||||||
@@ -1191,6 +1192,12 @@ export default class MapComponent extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.mapUi.searchResults && this.mapUi.searchResults.length > 0) {
|
||||||
|
console.debug('Clearing active search and markers on map click');
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Require Zoom >= 17 for generic map searches
|
// Require Zoom >= 17 for generic map searches
|
||||||
// This prevents accidental searches when interacting with the map at a high level
|
// This prevents accidental searches when interacting with the map at a high level
|
||||||
const currentZoom = this.mapInstance.getView().getZoom();
|
const currentZoom = this.mapInstance.getView().getZoom();
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ export default class SearchController extends Controller {
|
|||||||
|
|
||||||
// 2. If it's a back navigation to the exact same search, resolve instantly with no animation
|
// 2. If it's a back navigation to the exact same search, resolve instantly with no animation
|
||||||
if (isSameSearch && hasResults) {
|
if (isSameSearch && hasResults) {
|
||||||
this.mapUi.showSidebar();
|
if (this.mapUi.isSidebarVisible) {
|
||||||
|
this.mapUi.showSidebar();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1424,6 +1424,9 @@ button.create-place {
|
|||||||
border-top-left-radius: 16px;
|
border-top-left-radius: 16px;
|
||||||
border-top-right-radius: 16px;
|
border-top-right-radius: 16px;
|
||||||
inset: auto 0 0;
|
inset: auto 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-opening .sidebar {
|
||||||
animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1)
|
animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1)
|
||||||
forwards;
|
forwards;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ export default class ApplicationComponent extends Component {
|
|||||||
this.mapUi.hideSidebar();
|
this.mapUi.hideSidebar();
|
||||||
if (name === 'menu' || name.startsWith('lists')) {
|
if (name === 'menu' || name.startsWith('lists')) {
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
|
} else if (name === 'place') {
|
||||||
|
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||||
|
this.router.transitionTo('search', {
|
||||||
|
queryParams: this.mapUi.currentSearch,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ export default class PlaceTemplate extends Component {
|
|||||||
close() {
|
close() {
|
||||||
this.mapUi.clearSelection();
|
this.mapUi.clearSelection();
|
||||||
this.mapUi.hideSidebar();
|
this.mapUi.hideSidebar();
|
||||||
|
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||||
|
this.router.transitionTo('search', {
|
||||||
|
queryParams: this.mapUi.currentSearch,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.24.0",
|
"version": "1.25.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -39,8 +39,8 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
|
<script type="module" crossorigin src="/assets/main-BVNM87jL.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-BGF-Udec.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="modal-portal"></div>
|
<div id="modal-portal"></div>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ module('Acceptance | map search reset', function (hooks) {
|
|||||||
'Should have stayed on the search route with markers intact'
|
'Should have stayed on the search route with markers intact'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Second Click (Start new search)
|
// Second Click (Clear search and markers)
|
||||||
// Click slightly differently to ensure fresh event
|
// Click slightly differently to ensure fresh event
|
||||||
await triggerEvent(canvas, 'pointerdown', {
|
await triggerEvent(canvas, 'pointerdown', {
|
||||||
clientX: 250,
|
clientX: 250,
|
||||||
@@ -125,11 +125,6 @@ module('Acceptance | map search reset', function (hooks) {
|
|||||||
// 3. Wait for transition
|
// 3. Wait for transition
|
||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
|
||||||
const newUrl = currentURL();
|
assert.strictEqual(currentURL(), '/', 'Should have transitioned to index');
|
||||||
assert.notOk(
|
|
||||||
newUrl.includes('category=coffee'),
|
|
||||||
`New URL ${newUrl} should not contain category param`
|
|
||||||
);
|
|
||||||
assert.ok(newUrl.includes('/search'), 'Should be on search route');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
|
test('closing the sidebar transitions back to search route when opened from search results', async function (assert) {
|
||||||
const mapUi = this.owner.lookup('service:map-ui');
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
await visit('/search?lat=1&lon=1');
|
await visit('/search?lat=1&lon=1');
|
||||||
@@ -97,7 +97,22 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
await click('.close-btn');
|
await click('.close-btn');
|
||||||
|
|
||||||
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||||
assert.ok(currentURL().includes('/place/'), 'Remains on place route');
|
assert.strictEqual(
|
||||||
|
currentURL(),
|
||||||
|
'/search?lat=1&lon=1',
|
||||||
|
'Should transition back to search route'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing the sidebar when visiting a place directly transitions to index', async function (assert) {
|
||||||
|
await visit('/place/osm:node:123');
|
||||||
|
assert.ok(currentURL().includes('/place/'), 'Visited place directly');
|
||||||
|
|
||||||
|
// Click the Close (X) button
|
||||||
|
await click('.close-btn');
|
||||||
|
|
||||||
|
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
|
||||||
|
assert.strictEqual(currentURL(), '/', 'Should transition back to index');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigating directly to place and back closes sidebar', async function (assert) {
|
test('navigating directly to place and back closes sidebar', async function (assert) {
|
||||||
|
|||||||
@@ -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