Compare commits

...

6 Commits

13 changed files with 348 additions and 28 deletions

View File

@@ -11,7 +11,11 @@ import CategoryChips from '#components/category-chips';
export default class AppHeaderComponent extends Component {
@service storage;
@tracked isUserMenuOpen = false;
@tracked hasQuery = false;
@tracked searchQuery = '';
get hasQuery() {
return !!this.searchQuery;
}
@action
toggleUserMenu() {
@@ -25,22 +29,21 @@ export default class AppHeaderComponent extends Component {
@action
handleQueryChange(query) {
this.hasQuery = !!query;
this.searchQuery = query;
}
@action
handleChipSelect() {
// When a chip is selected, we might want to ensure the search box is cleared visually,
// although the route transition will happen.
// The SearchBox component manages its own state, so we rely on the route transition.
// However, if we want to clear the search box input from here, we'd need to control it.
// For now, let's just let the route change happen.
handleChipSelect(category) {
this.searchQuery = category.label;
// The existing logic in CategoryChips triggers the route transition.
// This update simply fills the search box.
}
<template>
<header class="app-header">
<div class="header-left">
<SearchBox
@query={{this.searchQuery}}
@onToggleMenu={{@onToggleMenu}}
@onQueryChange={{this.handleQueryChange}}
/>

View File

@@ -146,7 +146,7 @@ export default class PlacesSidebar extends Component {
get isNearbySearch() {
const qp = this.router.currentRoute.queryParams;
return !qp.q && qp.lat && qp.lon;
return !qp.q && !qp.category && qp.lat && qp.lon;
}
<template>

View File

@@ -7,6 +7,7 @@ import { fn } from '@ember/helper';
import { task, timeout } from 'ember-concurrency';
import Icon from '#components/icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag';
import { POI_CATEGORIES } from '../utils/poi-categories';
import eq from 'ember-truth-helpers/helpers/eq';
export default class SearchBoxComponent extends Component {
@@ -15,34 +16,45 @@ export default class SearchBoxComponent extends Component {
@service mapUi;
@service map; // Assuming we might need map context, but mostly we use router
@tracked query = '';
@tracked _internalQuery = '';
@tracked results = [];
@tracked isFocused = false;
@tracked isLoading = false;
get query() {
return this.args.query ?? this._internalQuery;
}
set query(value) {
this._internalQuery = value;
}
get showPopover() {
return this.isFocused && this.results.length > 0;
}
@action
handleInput(event) {
this.query = event.target.value;
const value = event.target.value;
this.query = value;
if (this.args.onQueryChange) {
this.args.onQueryChange(this.query);
this.args.onQueryChange(value);
}
if (this.query.length < 2) {
if (value.length < 2) {
this.results = [];
return;
}
this.searchTask.perform();
this.searchTask.perform(value);
}
searchTask = task({ restartable: true }, async () => {
searchTask = task({ restartable: true }, async (term) => {
await timeout(300);
if (this.query.length < 2) return;
const query = typeof term === 'string' ? term : this.query;
if (query.length < 2) return;
this.isLoading = true;
try {
@@ -51,8 +63,20 @@ export default class SearchBoxComponent extends Component {
if (this.mapUi.currentCenter) {
({ lat, lon } = this.mapUi.currentCenter);
}
const results = await this.photon.search(this.query, lat, lon);
this.results = results;
// Filter categories
const q = query.toLowerCase();
const categoryMatches = POI_CATEGORIES.filter((c) =>
c.label.toLowerCase().includes(q)
).map((c) => ({
source: 'category',
title: c.label,
id: c.id,
icon: 'search',
}));
const results = await this.photon.search(query, lat, lon);
this.results = [...categoryMatches, ...results];
} catch (e) {
console.error('Search failed', e);
this.results = [];
@@ -84,7 +108,7 @@ export default class SearchBoxComponent extends Component {
event.preventDefault();
if (!this.query) return;
let queryParams = { q: this.query, selected: null };
let queryParams = { q: this.query, selected: null, category: null };
if (this.mapUi.currentCenter) {
const { lat, lon } = this.mapUi.currentCenter;
@@ -98,7 +122,37 @@ export default class SearchBoxComponent extends Component {
@action
selectResult(place) {
if (place.source === 'category') {
this.query = place.title;
if (this.args.onQueryChange) {
this.args.onQueryChange(place.title);
}
this.results = [];
let lat = null,
lon = null;
if (this.mapUi.currentCenter) {
({ lat, lon } = this.mapUi.currentCenter);
lat = lat?.toString();
lon = lon?.toString();
}
this.router.transitionTo('search', {
queryParams: {
q: place.title,
category: place.id,
selected: null,
lat: lat,
lon: lon,
},
});
return;
}
this.query = place.title;
if (this.args.onQueryChange) {
this.args.onQueryChange(place.title);
}
this.results = []; // Hide popover
// If it has an OSM ID, go to place details
@@ -116,6 +170,7 @@ export default class SearchBoxComponent extends Component {
lat: place.lat,
lon: place.lon,
selected: null,
category: null,
},
});
}
@@ -182,7 +237,11 @@ export default class SearchBoxComponent extends Component {
{{on "click" (fn this.selectResult result)}}
>
<div class="result-icon">
<Icon @name="map-pin" @size={{16}} @color="#666" />
<Icon
@name={{if result.icon result.icon "map-pin"}}
@size={{16}}
@color="#666"
/>
</div>
<div class="result-info">
<span class="result-title">{{result.title}}</span>

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

View File

@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-gEUnNw-L.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BOfcjRke.css">
<script type="module" crossorigin src="/assets/main-C4F17h3W.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CKp1bFPU.css">
</head>
<body>
</body>

View File

@@ -155,4 +155,67 @@ module('Acceptance | search', function (hooks) {
assert.dom('.places-list li').exists({ count: 1 });
assert.dom('.places-list li .place-name').hasText('My Secret Base');
});
test('visiting /search with category parameter performs category search', async function (assert) {
// Mock Osm Service
class MockOsmService extends Service {
async getCategoryPois(bounds, categoryId) {
if (categoryId === 'coffee') {
return [
{
title: 'Latte Art Cafe',
lat: 52.52,
lon: 13.405,
osmId: '101',
osmType: 'N',
description: 'Best Coffee',
},
];
}
return [];
}
}
this.owner.register('service:osm', MockOsmService);
// Mock Storage Service (empty)
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 (needed for bounds)
class MockMapService extends Service {
getBounds() {
return {
minLat: 52.5,
minLon: 13.4,
maxLat: 52.6,
maxLon: 13.5,
};
}
}
this.owner.register('service:map', MockMapService);
await visit('/search?category=coffee&lat=52.52&lon=13.405');
assert.strictEqual(
currentURL(),
'/search?category=coffee&lat=52.52&lon=13.405'
);
assert.dom('.places-list li').exists({ count: 1 });
assert.dom('.places-list li .place-name').hasText('Latte Art Cafe');
// Ensure it shows "Results" not "Nearby"
assert.dom('.sidebar-header h2').includesText('Results');
});
});

View File

@@ -1,13 +1,25 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render } from '@ember/test-helpers';
import { render, fillIn } from '@ember/test-helpers';
import AppHeader from 'marco/components/app-header';
import Service from '@ember/service';
module('Integration | Component | app-header', function (hooks) {
setupRenderingTest(hooks);
test('it renders the search box', async function (assert) {
this.noop = () => {};
class MockPhotonService extends Service {}
class MockRouterService extends Service {}
class MockMapUiService extends Service {}
class MockMapService extends Service {}
this.owner.register('service:photon', MockPhotonService);
this.owner.register('service:router', MockRouterService);
this.owner.register('service:map-ui', MockMapUiService);
this.owner.register('service:map', MockMapService);
await render(
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
);
@@ -16,4 +28,39 @@ module('Integration | Component | app-header', function (hooks) {
assert.dom('.search-box').exists('Search box is present in the header');
assert.dom('.menu-btn-integrated').exists('Menu button is integrated');
});
test('typing in search box toggles .searching class on header-center', async function (assert) {
this.noop = () => {};
class MockPhotonService extends Service {
search() {
return [];
}
}
class MockRouterService extends Service {}
class MockMapUiService extends Service {
setSearchBoxFocus() {}
currentCenter = null;
}
class MockMapService extends Service {}
this.owner.register('service:photon', MockPhotonService);
this.owner.register('service:router', MockRouterService);
this.owner.register('service:map-ui', MockMapUiService);
this.owner.register('service:map', MockMapService);
await render(
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
);
assert.dom('.header-center').doesNotHaveClass('searching');
await fillIn('.search-input', 'test');
assert.dom('.header-center').hasClass('searching');
await fillIn('.search-input', '');
assert.dom('.header-center').doesNotHaveClass('searching');
});
});

View File

@@ -0,0 +1,56 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import CategoryChips from 'marco/components/category-chips';
import Service from '@ember/service';
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
module('Integration | Component | category-chips', function (hooks) {
setupRenderingTest(hooks);
test('it renders the correct number of chips', async function (assert) {
class MockRouterService extends Service {}
class MockMapUiService extends Service {}
this.owner.register('service:router', MockRouterService);
this.owner.register('service:map-ui', MockMapUiService);
await render(<template><CategoryChips /></template>);
assert.dom('.category-chip').exists({ count: 5 });
// Check for some expected labels
assert.dom(this.element).includesText('Restaurants');
assert.dom(this.element).includesText('Coffee');
});
test('clicking a chip triggers the @onSelect action', async function (assert) {
let selectedCategory;
this.handleSelect = (category) => {
selectedCategory = category;
};
class MockRouterService extends Service {
transitionTo() {}
}
class MockMapUiService extends Service {}
this.owner.register('service:router', MockRouterService);
this.owner.register('service:map-ui', MockMapUiService);
await render(
<template><CategoryChips @onSelect={{this.handleSelect}} /></template>
);
// Find the chip for "Coffee"
const coffeeCategory = POI_CATEGORIES.find((c) => c.id === 'coffee');
const chip = Array.from(
this.element.querySelectorAll('.category-chip')
).find((el) => el.textContent.includes(coffeeCategory.label));
await click(chip);
assert.strictEqual(selectedCategory.id, 'coffee');
assert.strictEqual(selectedCategory.label, 'Coffee');
});
});

View File

@@ -100,7 +100,7 @@ module('Integration | Component | search-box', function (hooks) {
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
assert.verifySteps([
'transitionTo: search {"queryParams":{"q":"berlin","selected":null,"lat":"52.5200","lon":"13.4050"}}',
'transitionTo: search {"queryParams":{"q":"berlin","selected":null,"category":null,"lat":"52.5200","lon":"13.4050"}}',
]);
});
@@ -134,4 +134,96 @@ module('Integration | Component | search-box', function (hooks) {
assert.verifySteps(['search: cafe, 52.52, 13.405']);
});
test('it allows typing even when controlled by parent with a query argument', async function (assert) {
class MockPhotonService extends Service {
async search() {
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
this.query = '';
this.updateQuery = (val) => {
this.set('query', val);
};
this.noop = () => {};
await render(
<template>
<SearchBox
@query={{this.query}}
@onQueryChange={{this.updateQuery}}
@onToggleMenu={{this.noop}}
/>
</template>
);
// Initial state
assert.dom('.search-input').hasValue('');
// Simulate typing
await fillIn('.search-input', 't');
assert.dom('.search-input').hasValue('t', 'Input should show "t"');
await fillIn('.search-input', 'te');
assert.dom('.search-input').hasValue('te', 'Input should show "te"');
// Simulate external update (e.g. chip click)
this.set('query', 'restaurant');
// wait for re-render
await click('.search-input'); // just to trigger a change cycle or ensure stability
assert
.dom('.search-input')
.hasValue('restaurant', 'Input should update from external change');
});
test('it triggers category search with current location when clicking category result', async function (assert) {
// Mock MapUi Service
class MockMapUiService extends Service {
currentCenter = { lat: 51.5074, lon: -0.1278 };
setSearchBoxFocus() {}
}
this.owner.register('service:map-ui', MockMapUiService);
// Mock Photon Service
class MockPhotonService extends Service {
async search() {
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
// Mock Router Service
class MockRouterService extends Service {
transitionTo(routeName, options) {
assert.step(`transitionTo: ${routeName} ${JSON.stringify(options)}`);
}
}
this.owner.register('service:router', MockRouterService);
this.noop = () => {};
await render(
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
);
// Type "Resta" to trigger "Restaurants" category match
await fillIn('.search-input', 'Resta');
// Wait for debounce (300ms) + execution
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
await delay(400);
// The first result should be the category match
assert.dom('.search-result-item').exists({ count: 1 });
assert.dom('.result-title').hasText('Restaurants');
// Click the result
await click('.search-result-item');
// Assert transition with lat/lon from map center
assert.verifySteps([
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
]);
});
});