Merge pull request 'When search requests fail, show error in toast notifications instead of empty search results' (#38) from feature/failed_requests into master
Some checks failed
CI / Lint (push) Successful in 28s
CI / Test (push) Failing after 44s

Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
2026-03-27 11:12:58 +00:00
7 changed files with 131 additions and 3 deletions

14
app/components/toast.gjs Normal file
View File

@@ -0,0 +1,14 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
export default class ToastComponent extends Component {
@service toast;
<template>
{{#if this.toast.isVisible}}
<div class="toast-notification">
{{this.toast.message}}
</div>
{{/if}}
</template>
}

View File

@@ -9,6 +9,7 @@ export default class SearchRoute extends Route {
@service mapUi; @service mapUi;
@service storage; @service storage;
@service router; @service router;
@service toast;
queryParams = { queryParams = {
lat: { refreshModel: true }, lat: { refreshModel: true },
@@ -199,8 +200,12 @@ export default class SearchRoute extends Route {
} }
@action @action
error() { error(error, transition) {
this.mapUi.stopSearch(); this.mapUi.stopSearch();
return true; // Bubble error this.toast.show('Search request failed. Please try again.');
if (transition) {
transition.abort();
}
return false; // Prevent bubble and stop transition
} }
} }

View File

@@ -155,7 +155,7 @@ out center;
return []; return [];
} }
console.error('Category search failed', e); console.error('Category search failed', e);
return []; throw e;
} }
} }

21
app/services/toast.js Normal file
View File

@@ -0,0 +1,21 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class ToastService extends Service {
@tracked message = null;
@tracked isVisible = false;
timeoutId = null;
show(message, duration = 3000) {
this.message = message;
this.isVisible = true;
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.timeoutId = setTimeout(() => {
this.isVisible = false;
}, duration);
}
}

View File

@@ -1321,3 +1321,36 @@ button.create-place {
cursor: not-allowed; cursor: not-allowed;
pointer-events: none; pointer-events: none;
} }
/* Toast Notification */
.toast-notification {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
background-color: rgb(51 51 51 / 85%);
backdrop-filter: blur(4px);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 999px;
z-index: 9999;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
animation: fade-in-up 0.3s ease-out forwards;
text-align: center;
max-width: 90%;
font-size: 0.9rem;
font-weight: 500;
pointer-events: none;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translate(-50%, 1rem);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}

View File

@@ -3,6 +3,7 @@ import { pageTitle } from 'ember-page-title';
import Map from '#components/map'; import Map from '#components/map';
import AppHeader from '#components/app-header'; import AppHeader from '#components/app-header';
import AppMenu from '#components/app-menu/index'; import AppMenu from '#components/app-menu/index';
import Toast from '#components/toast';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
@@ -89,6 +90,8 @@ export default class ApplicationComponent extends Component {
<AppMenu @onClose={{this.closeAppMenu}} /> <AppMenu @onClose={{this.closeAppMenu}} />
{{/if}} {{/if}}
<Toast />
{{outlet}} {{outlet}}
</template> </template>
} }

View File

@@ -218,4 +218,56 @@ module('Acceptance | search', function (hooks) {
// Ensure it shows "Results" not "Nearby" // Ensure it shows "Results" not "Nearby"
assert.dom('.sidebar-header h2').includesText('Results'); assert.dom('.sidebar-header h2').includesText('Results');
}); });
test('search error handling prevents opening empty panel and shows toast', async function (assert) {
// Mock Osm Service to throw an error
class MockOsmService extends Service {
async getCategoryPois() {
throw new Error('Overpass request failed');
}
}
this.owner.register('service:osm', MockOsmService);
class MockStorageService extends Service {
savedPlaces = [];
findPlaceById() {
return null;
}
isPlaceSaved() {
return false;
}
rs = { on: () => {} };
placesInView = [];
loadPlacesInBounds() {
return Promise.resolve();
}
}
this.owner.register('service:storage', MockStorageService);
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('/');
try {
await visit('/search?category=coffee&lat=52.52&lon=13.405');
} catch {
// Aborted transition throws, which is expected
}
assert.dom('.toast-notification').exists('Toast should be visible');
assert
.dom('.toast-notification')
.hasText('Search request failed. Please try again.');
assert.dom('.places-sidebar').doesNotExist('Results panel should not open');
});
}); });