Compare commits

..

11 Commits

Author SHA1 Message Date
60936ed2f5 1.21.1
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 56s
2026-04-27 21:51:55 +01:00
ca82a029bc Fix app menu section layout 2026-04-27 21:50:47 +01:00
0630aed73d Fix modals 2026-04-27 21:50:36 +01:00
f27a636529 1.21.0
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 58s
2026-04-27 21:26:23 +01:00
995ae95b09 Merge pull request 'Add full-size photo gallery' (#52) from feature/photo-gallery into master
All checks were successful
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 58s
Reviewed-on: #52
2026-04-27 20:24:12 +00:00
0fb320d996 Fix test and linter error
All checks were successful
CI / Lint (pull_request) Successful in 31s
CI / Test (pull_request) Successful in 57s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-27 21:20:39 +01:00
4f4ca827b1 Refactor carousel and gallery to share the carousel component
And make the gallery awesome
2026-04-27 21:12:46 +01:00
c1d3f25d50 WIP Add basic photo gallery 2026-04-27 16:45:49 +01:00
2087cfc4f7 Merge pull request 'Various search UI improvements' (#51) from ui/search into master
All checks were successful
CI / Lint (push) Successful in 32s
CI / Test (push) Successful in 1m0s
Reviewed-on: #51
2026-04-27 14:50:02 +00:00
8572032481 Eliminate race condition in tests
All checks were successful
CI / Lint (pull_request) Successful in 33s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 4s
2026-04-27 15:47:23 +01:00
b4c3f5c88d Improve map centering on mobile
Some checks failed
CI / Lint (pull_request) Successful in 33s
CI / Test (pull_request) Failing after 1m1s
2026-04-27 15:37:13 +01:00
19 changed files with 483 additions and 80 deletions

View File

@@ -15,7 +15,7 @@ export default class AppMenuSettingsApis extends Component {
<Icon @name="server" @size={{20}} />
<span>API Providers</span>
</summary>
<div class="details-content">
<div class="details-content form-layout">
<div class="form-group">
<label for="overpass-api">Overpass API Provider</label>
<select

View File

@@ -14,7 +14,7 @@ export default class AppMenuSettingsMapUi extends Component {
<Icon @name="map" @size={{20}} />
<span>Map & UI</span>
</summary>
<div class="details-content">
<div class="details-content form-layout">
<div class="form-group">
<label for="show-quick-search">Quick search buttons visible</label>
<select

View File

@@ -108,7 +108,7 @@ export default class AppMenuSettingsNostr extends Component {
<Icon @name="zap" @size={{20}} />
<span>Nostr</span>
</summary>
<div class="details-content">
<div class="details-content form-layout">
<div class="form-group">
<label for="nostr-photo-fallback-uploads">Upload photos to fallback
servers</label>

View File

@@ -782,14 +782,19 @@ export default class MapComponent extends Component {
// Check if mobile (width <= 768px matches CSS)
if (size[0] <= 768) {
// On mobile, the bottom 50% is covered by the sheet.
// We want the pin to be in the center of the TOP 50% (visible area).
// That means the pin should be at y = height * 0.25 (25% down from top).
// The map center is at y = height * 0.50.
// So the pin is "above" the center by 25% of the height in pixels.
// To put the pin there, the map center needs to be "below" the pin by that amount.
// We want the pin to be in the center of the TOP 50% (visible area), minus the header.
const height = size[1];
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center
const headerEl = document.querySelector('.app-header');
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
// Visible area is from headerHeight to height / 2 (bottom sheet covers bottom 50%)
const visibleCenterY = headerHeight + (height / 2 - headerHeight) / 2;
// The map center is at y = height * 0.50.
// So the pin is "above" the center by (height/2 - visibleCenterY) pixels.
// To put the pin there, the map center needs to be "below" the pin by that amount.
const offsetPixels = height / 2 - visibleCenterY; // Distance from desired pin pos to map center
const offsetMapUnits = offsetPixels * resolution;
// Shift center SOUTH (decrease Y).
@@ -849,6 +854,9 @@ export default class MapComponent extends Component {
let targetPixelY = pixel[1];
let needsPan = false;
const headerEl = document.querySelector('.app-header');
const headerHeight = headerEl ? headerEl.offsetHeight : 60;
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
if (size[0] <= 768) {
const height = size[1];
@@ -856,7 +864,7 @@ export default class MapComponent extends Component {
// If in bottom half
if (pixel[1] > splitPoint) {
targetPixelY = height * 0.25; // Target: Center of top half
targetPixelY = headerHeight + (height / 2 - headerHeight) / 2; // Target: Center of visible area above bottom sheet
needsPan = true;
}
}
@@ -877,11 +885,10 @@ export default class MapComponent extends Component {
// 3. Header Logic (Any screen size)
// Check if the (potentially new) target Y is under the header
const headerHeight = 60;
const minTopDistance = headerHeight + 20; // 80px
const minTopDistance = headerHeight + 20; // Provide some padding
if (targetPixelY < minTopDistance) {
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header
targetPixelY = minTopDistance + 30; // Move it clear of header
needsPan = true;
}

View File

@@ -1,16 +1,19 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { and, eq } from 'ember-truth-helpers';
import Blurhash from './blurhash';
import Icon from './icon';
import fadeInImage from '../modifiers/fade-in-image';
import { on } from '@ember/modifier';
import { modifier } from 'ember-modifier';
export default class PlacePhotosCarousel extends Component {
export default class PhotoCarousel extends Component {
@tracked canScrollLeft = false;
@tracked canScrollRight = false;
internalEventId = null;
carouselElement = null;
get photos() {
@@ -18,6 +21,7 @@ export default class PlacePhotosCarousel extends Component {
}
get showChevrons() {
// Only show chevrons if there's more than one photo
return this.photos.length > 1;
}
@@ -29,6 +33,10 @@ export default class PlacePhotosCarousel extends Component {
return !this.canScrollRight;
}
get variantClass() {
return this.args.variant || 'inline';
}
lastResetKey = null;
resetScrollPosition = modifier((element, [resetKey]) => {
@@ -41,16 +49,42 @@ export default class PlacePhotosCarousel extends Component {
scrollToNewPhoto = modifier((element, [eventId]) => {
if (eventId && eventId !== this.lastEventId) {
const isInitial = !this.lastEventId;
this.lastEventId = eventId;
// Allow DOM to update first since the photo was *just* added to the store
setTimeout(() => {
// Prevent feedback loop if this carousel initiated the change
if (this.internalEventId === eventId) {
return;
}
const scrollAction = () => {
const targetSlide = element.querySelector(
`[data-event-id="${eventId}"]`
);
if (targetSlide) {
element.scrollLeft = targetSlide.offsetLeft;
if (isInitial) {
const originalScrollBehavior = element.style.scrollBehavior;
element.style.scrollBehavior = 'auto';
element.scrollLeft = targetSlide.offsetLeft;
// Restore smooth scroll after the jump
setTimeout(() => {
element.style.scrollBehavior = originalScrollBehavior;
}, 50);
} else {
// Use native CSS smooth scrolling for subsequent clicks
element.scrollLeft = targetSlide.offsetLeft;
}
}
}, 100);
};
if (isInitial) {
// Execute immediately for the first render to prevent flash
scrollAction();
} else {
// Allow DOM to update first for subsequent clicks
setTimeout(scrollAction, 100);
}
}
});
@@ -68,10 +102,39 @@ export default class PlacePhotosCarousel extends Component {
resizeObserver.observe(element);
}
let intersectionObserver;
if (this.args.onVisiblePhotoChange && window.IntersectionObserver) {
// Set up intersection observer to track which photo is currently "most" visible
intersectionObserver = new IntersectionObserver(
(entries) => {
for (let entry of entries) {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
const eventId = entry.target.dataset.eventId;
this.internalEventId = eventId;
const photo = this.photos.find((p) => p.eventId === eventId);
if (photo) {
this.args.onVisiblePhotoChange(photo);
}
}
}
},
{
root: element,
threshold: 0.5,
}
);
const slides = element.querySelectorAll('.carousel-slide');
slides.forEach((slide) => intersectionObserver.observe(slide));
}
return () => {
if (resizeObserver) {
resizeObserver.unobserve(element);
}
if (intersectionObserver) {
intersectionObserver.disconnect();
}
};
});
@@ -103,23 +166,35 @@ export default class PlacePhotosCarousel extends Component {
});
}
@action
handlePhotoClick(photo) {
if (this.args.onPhotoClick) {
this.args.onPhotoClick(photo);
}
}
<template>
{{#if this.photos.length}}
<div class="place-photos-carousel-wrapper">
<div class="photo-carousel {{this.variantClass}}">
<div
class="place-photos-carousel-track"
class="photo-carousel-track"
{{this.setupCarousel}}
{{this.resetScrollPosition @resetKey}}
{{this.scrollToNewPhoto @scrollToEventId}}
{{on "scroll" this.updateScrollState}}
>
{{#each this.photos as |photo|}}
{{! template-lint-disable no-inline-styles }}
{{! template-lint-disable no-inline-styles no-invalid-interactive }}
<div
class="carousel-slide
{{if photo.isLandscape 'landscape' 'portrait'}}"
{{if photo.isLandscape 'landscape' 'portrait'}}
{{if
(and @scrollToEventId (eq photo.eventId @scrollToEventId))
'active'
}}"
style={{photo.style}}
data-event-id={{photo.eventId}}
{{on "click" (fn this.handlePhotoClick photo)}}
>
{{#if photo.blurhash}}
<Blurhash
@@ -169,7 +244,11 @@ export default class PlacePhotosCarousel extends Component {
disabled={{this.cannotScrollLeft}}
aria-label="Previous photo"
>
<Icon @name="chevron-left" @color="currentColor" />
<Icon
@name="chevron-left"
@color="currentColor"
@size={{if (eq @variant "gallery-main") 24 16}}
/>
</button>
<button
@@ -180,7 +259,11 @@ export default class PlacePhotosCarousel extends Component {
disabled={{this.cannotScrollRight}}
aria-label="Next photo"
>
<Icon @name="chevron-right" @color="currentColor" />
<Icon
@name="chevron-right"
@color="currentColor"
@size={{if (eq @variant "gallery-main") 24 16}}
/>
</button>
{{/if}}
</div>

View File

@@ -0,0 +1,85 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import Icon from './icon';
import PhotoCarousel from './photo-carousel';
export default class PhotoGallery extends Component {
@tracked currentPhoto = this.args.selectedPhoto || this.args.photos?.[0];
@action
handleClose() {
if (this.args.onClose) {
this.args.onClose();
}
}
@action
handleBackgroundClick(e) {
// Don't close if clicking on thumbnails, nav buttons, or the close button itself
if (
e.target.closest('.thumbnail-strip-container') ||
e.target.closest('.carousel-nav-btn') ||
e.target.closest('.close-btn')
) {
return;
}
this.handleClose();
}
@action
selectPhoto(photo) {
this.currentPhoto = photo;
}
@action
handleVisiblePhotoChange(photo) {
if (this.currentPhoto !== photo) {
this.currentPhoto = photo;
}
}
<template>
<div
class="photo-gallery-overlay"
role="dialog"
tabindex="-1"
{{on "click" this.handleBackgroundClick}}
>
{{! template-lint-disable no-invalid-interactive }}
<div class="photo-gallery-content">
<button
type="button"
class="close-btn btn-text"
{{on "click" this.handleClose}}
aria-label="Close gallery"
title="Close"
>
<Icon @name="x" @size={{24}} @color="white" />
</button>
<div class="main-photo-container">
<PhotoCarousel
@variant="gallery-main"
@photos={{@photos}}
@scrollToEventId={{this.currentPhoto.eventId}}
@onVisiblePhotoChange={{this.handleVisiblePhotoChange}}
@name={{@placeName}}
/>
</div>
<div class="thumbnail-strip-container">
<PhotoCarousel
@variant="gallery-thumbnails"
@photos={{@photos}}
@scrollToEventId={{this.currentPhoto.eventId}}
@onPhotoClick={{this.selectPhoto}}
@name={{@placeName}}
/>
</div>
</div>
</div>
</template>
}

View File

@@ -13,7 +13,8 @@ import PlaceListsManager from './place-lists-manager';
import PlacePhotoUpload from './place-photo-upload';
import NostrConnect from './nostr-connect';
import Modal from './modal';
import PlacePhotosCarousel from './place-photos-carousel';
import PhotoCarousel from './photo-carousel';
import PhotoGallery from './photo-gallery';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
@@ -24,10 +25,13 @@ export default class PlaceDetails extends Component {
@service nostrData;
@tracked isEditing = false;
@tracked showLists = false;
@tracked isPhotoUploadActive = false;
@tracked isConnectingNostr = false;
@tracked isGalleryOpen = false;
@tracked selectedGalleryPhoto = null;
@tracked isPhotoUploadModalOpen = false;
@tracked isNostrConnectModalOpen = false;
@tracked newlyUploadedPhotoId = null;
@tracked isPhotoUploadActive = false;
@action
handleUploadStateChange(isActive) {
@@ -362,6 +366,18 @@ export default class PlaceDetails extends Component {
return !!this.place.description;
}
@action
openGallery(photo) {
this.selectedGalleryPhoto = photo;
this.isGalleryOpen = true;
}
@action
closeGallery() {
this.isGalleryOpen = false;
this.selectedGalleryPhoto = null;
}
<template>
<div class="place-details">
{{#if this.isEditing}}
@@ -371,11 +387,13 @@ export default class PlaceDetails extends Component {
@onCancel={{this.cancelEditing}}
/>
{{else}}
<PlacePhotosCarousel
<PhotoCarousel
@variant="inline"
@photos={{this.photos}}
@name={{this.name}}
@resetKey={{this.place.osmId}}
@scrollToEventId={{this.newlyUploadedPhotoId}}
@onPhotoClick={{this.openGallery}}
/>
<h3>{{this.name}}</h3>
<p class="place-type">
@@ -609,5 +627,14 @@ export default class PlaceDetails extends Component {
<NostrConnect @onConnect={{this.onNostrConnected}} />
</Modal>
{{/if}}
{{#if this.isGalleryOpen}}
<PhotoGallery
@photos={{this.photos}}
@selectedPhoto={{this.selectedGalleryPhoto}}
@placeName={{this.name}}
@onClose={{this.closeGallery}}
/>
{{/if}}
</template>
}

View File

@@ -9,11 +9,28 @@ export default modifier((element, [url]) => {
let observer;
const hideBlurhash = () => {
const parent = element.parentElement;
const slide =
parent && parent.tagName === 'PICTURE' ? parent.parentElement : parent;
// Only hide the blurhash if we're in the gallery-main view.
// In the inline view, we want to keep the blurhash visible behind portrait photos
// to fill the 16:9 container gracefully.
if (slide && slide.closest('.photo-carousel.gallery-main')) {
const blur = slide.querySelector('.place-header-photo-blur');
if (blur) {
blur.style.opacity = '0';
}
}
};
const handleLoad = () => {
// Only apply the fade-in animation if it wasn't already loaded instantly
if (!element.classList.contains('loaded-instant')) {
element.classList.add('loaded');
}
hideBlurhash();
};
element.addEventListener('load', handleLoad);
@@ -33,6 +50,7 @@ export default modifier((element, [url]) => {
if (img.complete) {
// Already in browser cache, skip the animation
element.classList.add('loaded-instant');
hideBlurhash();
}
// If this image is inside a <picture> tag, we also need to swap <source> tags

View File

@@ -594,6 +594,9 @@ body {
padding: 0 1.4rem 1rem;
animation: details-slide-down 0.2s ease-out;
font-size: 0.9rem;
}
.sidebar-content details .details-content.form-layout {
display: flex;
flex-direction: column;
gap: 16px;
@@ -890,12 +893,15 @@ abbr[title] {
padding-bottom: 2rem;
}
.place-photos-carousel-wrapper {
.photo-carousel {
position: relative;
}
.photo-carousel.inline {
margin: -1rem -1rem 1rem;
}
.place-photos-carousel-track {
.photo-carousel-track {
display: flex;
overflow-x: auto;
scroll-behavior: smooth;
@@ -904,11 +910,12 @@ abbr[title] {
background-color: var(--hover-bg);
}
.place-photos-carousel-track::-webkit-scrollbar {
.photo-carousel-track::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
.carousel-slide {
cursor: pointer;
position: relative;
flex: 0 0 100%;
scroll-snap-align: start;
@@ -984,7 +991,7 @@ abbr[title] {
padding: 0;
}
.place-photos-carousel-wrapper:hover .carousel-nav-btn:not(.disabled) {
.photo-carousel:hover .carousel-nav-btn:not(.disabled) {
opacity: 1;
}
@@ -1006,40 +1013,40 @@ abbr[title] {
}
@media (width <= 768px) {
.place-photos-carousel-track {
.photo-carousel.inline .photo-carousel-track {
scroll-snap-type: none;
gap: 2px;
background-color: #fff;
}
.carousel-slide {
.photo-carousel.inline .carousel-slide {
flex: 0 0 auto;
height: 100px;
width: auto;
scroll-snap-align: none;
}
.carousel-slide.landscape {
.photo-carousel.inline .carousel-slide.landscape {
aspect-ratio: var(--slide-ratio, 16 / 9);
}
.carousel-slide.portrait {
.photo-carousel.inline .carousel-slide.portrait {
aspect-ratio: 1 / 1;
}
.carousel-placeholder {
.photo-carousel.inline .carousel-placeholder {
display: block;
background-color: var(--hover-bg);
flex: 1 1 0%;
min-width: 0;
}
.place-header-photo.landscape,
.place-header-photo.portrait {
.photo-carousel.inline .place-header-photo.landscape,
.photo-carousel.inline .place-header-photo.portrait {
object-fit: cover;
}
.carousel-nav-btn {
.photo-carousel.inline .carousel-nav-btn {
display: none;
}
}
@@ -1365,10 +1372,10 @@ span.icon {
@media (width <= 768px) {
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
/* Center Y = (height/2) / 2 = height/4 = 25% */
/* Center Y = (height/2) / 2 = height/4 = 25% + half header height */
.map-container.sidebar-open .map-crosshair {
left: 50%; /* Reset desktop shift */
top: 25%;
top: calc(25% + 30px); /* 30px approx half header height */
}
}
@@ -1866,3 +1873,149 @@ button.create-place {
.btn-link:hover {
text-decoration: underline;
}
/* Photo Gallery */
.photo-gallery-overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 90%);
z-index: 9999;
display: flex;
flex-direction: column;
}
.photo-gallery-overlay .photo-gallery-content {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.photo-gallery-overlay .close-btn {
position: absolute;
top: 0.5rem;
right: 1rem;
width: 48px;
height: 48px;
z-index: 10;
color: white;
background: transparent;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;
}
@media (width <= 768px) {
.photo-gallery-overlay .close-btn {
right: 0.5rem;
}
}
.photo-gallery-overlay .main-photo-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
overflow: hidden;
}
@media (width <= 768px) {
.photo-gallery-overlay .main-photo-container {
padding: 2rem 0;
}
}
.photo-gallery-overlay .main-photo-container img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* Photo Carousel: Gallery Main Variant */
.photo-carousel.gallery-main {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.photo-carousel.gallery-main .photo-carousel-track {
height: 100%;
background: transparent;
}
.photo-carousel.gallery-main .carousel-slide {
height: 100%;
flex: 0 0 100%;
aspect-ratio: auto;
cursor: default;
}
.photo-carousel.gallery-main .carousel-nav-btn {
width: 48px;
height: 48px;
}
.photo-carousel.gallery-main .place-header-photo-blur,
.photo-carousel.gallery-main .place-header-photo.landscape,
.photo-carousel.gallery-main .place-header-photo.portrait {
object-fit: contain;
}
@media (width <= 768px) {
.photo-carousel.gallery-main .carousel-nav-btn {
display: none;
}
}
/* Photo Carousel: Gallery Thumbnails Variant */
.photo-carousel.gallery-thumbnails {
width: 100%;
height: 100px;
background: rgb(0 0 0 / 50%);
padding-bottom: env(safe-area-inset-bottom, 0); /* Support mobile safe area */
}
.photo-carousel.gallery-thumbnails .photo-carousel-track {
height: 100%;
background: transparent;
gap: 4px;
scroll-snap-type: none;
padding: 0;
}
.photo-carousel.gallery-thumbnails .carousel-slide {
flex: 0 0 auto;
height: 100%;
width: auto;
scroll-snap-align: none;
opacity: 0.6;
transition: opacity 0.2s;
}
.photo-carousel.gallery-thumbnails .carousel-slide.landscape {
aspect-ratio: var(--slide-ratio, 16 / 9);
}
.photo-carousel.gallery-thumbnails .carousel-slide.portrait {
aspect-ratio: 1 / 1;
}
.photo-carousel.gallery-thumbnails .carousel-slide:hover,
.photo-carousel.gallery-thumbnails .carousel-slide.active {
opacity: 1;
}
.photo-carousel.gallery-thumbnails .place-header-photo.landscape,
.photo-carousel.gallery-thumbnails .place-header-photo.portrait {
object-fit: cover;
height: 100%;
}
.photo-carousel.gallery-thumbnails .carousel-nav-btn {
display: none;
}

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.20.5",
"version": "1.21.1",
"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

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-Dhq0XoTm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CHuW_yI-.css">
<script type="module" crossorigin src="/assets/main-DsygQlAh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-C_mgNoFX.css">
</head>
<body>
</body>

View File

@@ -1,17 +1,32 @@
import { module, test } from 'qunit';
import { visit, click, fillIn, currentURL } from '@ember/test-helpers';
import { visit, click, fillIn, currentURL, settled } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import { Promise } from 'rsvp';
let photonResolve;
let osmResolve;
class MockPhotonService extends Service {
cancelAll() {}
async search(query) {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 50));
if (query === 'slow') {
await new Promise((resolve) => setTimeout(resolve, 200));
// Return a promise that we can manually resolve in the test
// to avoid race conditions with native setTimeout
return new Promise((resolve) => {
photonResolve = () => {
resolve([
{
title: 'Test Place',
lat: 1,
lon: 1,
osmId: '123',
osmType: 'node',
},
]);
};
});
}
return [
{
@@ -29,9 +44,12 @@ class MockOsmService extends Service {
cancelAll() {}
async getCategoryPois(bounds, category) {
await new Promise((resolve) => setTimeout(resolve, 50));
if (category === 'slow_category') {
await new Promise((resolve) => setTimeout(resolve, 200));
return new Promise((resolve) => {
osmResolve = () => {
resolve([]);
};
});
}
return [];
}
@@ -44,6 +62,8 @@ module('Acceptance | search loading', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
photonResolve = null;
osmResolve = null;
this.owner.register('service:photon', MockPhotonService);
this.owner.register('service:osm', MockOsmService);
});
@@ -66,8 +86,12 @@ module('Acceptance | search loading', function (hooks) {
'Loading state is set for text search'
);
// Resolve the manual promise so the task can finish deterministically
photonResolve();
await searchPromise;
await new Promise((r) => setTimeout(r, 250));
await settled(); // Wait for ember-concurrency tasks to fully settle
assert.strictEqual(
mapUi.loadingState,
null,
@@ -84,8 +108,12 @@ module('Acceptance | search loading', function (hooks) {
'Loading state is set for category search'
);
// Resolve the manual promise
osmResolve();
await catPromise;
await new Promise((r) => setTimeout(r, 250));
await settled();
assert.strictEqual(
mapUi.loadingState,
null,
@@ -124,6 +152,7 @@ module('Acceptance | search loading', function (hooks) {
// 4. Click the clear button (should be visible since input has value)
await click('.search-clear-btn');
// Wait for the click and transition to settle
// Verify loading state is cleared immediately
assert.strictEqual(
@@ -132,6 +161,11 @@ module('Acceptance | search loading', function (hooks) {
'Loading state is cleared immediately after clicking clear'
);
// Clean up the dangling promise
if (photonResolve) {
photonResolve();
}
// Verify we are back on index (or at least query is gone)
assert.strictEqual(currentURL(), '/', 'Navigated to index');
});

View File

@@ -1,20 +1,20 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers';
import PlacePhotosCarousel from 'marco/components/place-photos-carousel';
import PhotoCarousel from 'marco/components/photo-carousel';
module('Integration | Component | place-photos-carousel', function (hooks) {
module('Integration | Component | photo-carousel', function (hooks) {
setupRenderingTest(hooks);
test('it renders gracefully with no photos', async function (assert) {
this.photos = [];
await render(
<template><PlacePhotosCarousel @photos={{this.photos}} /></template>
<template><PhotoCarousel @photos={{this.photos}} /></template>
);
assert
.dom('.place-photos-carousel-wrapper')
.dom('.photo-carousel')
.doesNotExist('it does not render the wrapper when there are no photos');
});
@@ -32,14 +32,12 @@ module('Integration | Component | place-photos-carousel', function (hooks) {
await render(
<template>
<div class="test-container">
<PlacePhotosCarousel @photos={{this.photos}} />
<PhotoCarousel @photos={{this.photos}} />
</div>
</template>
);
assert
.dom('.place-photos-carousel-wrapper')
.exists('it renders the wrapper');
assert.dom('.photo-carousel').exists('it renders the wrapper');
assert
.dom('.carousel-slide:not(.carousel-placeholder)')
.exists({ count: 1 }, 'it renders one real photo slide');
@@ -84,7 +82,7 @@ module('Integration | Component | place-photos-carousel', function (hooks) {
await render(
<template>
<div class="test-container">
<PlacePhotosCarousel @photos={{this.photos}} />
<PhotoCarousel @photos={{this.photos}} />
</div>
</template>
);