Compare commits
6 Commits
b39d92b7c4
...
7e98b6796c
| Author | SHA1 | Date | |
|---|---|---|---|
|
7e98b6796c
|
|||
|
8e9beb16de
|
|||
|
b083c1d001
|
|||
|
4008a8c883
|
|||
|
eb7cff7ff5
|
|||
|
db6478e353
|
@@ -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}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
2
release/assets/main-C4F17h3W.js
Normal file
2
release/assets/main-C4F17h3W.js
Normal file
File diff suppressed because one or more lines are too long
1
release/assets/main-CKp1bFPU.css
Normal file
1
release/assets/main-CKp1bFPU.css
Normal file
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,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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
56
tests/integration/components/category-chips-test.gjs
Normal file
56
tests/integration/components/category-chips-test.gjs
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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"}}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user