Make collection list loading async

This commit is contained in:
2026-06-30 16:14:08 +02:00
parent 96a5a6ac34
commit f2e531c0f6
6 changed files with 333 additions and 157 deletions

View File

@@ -217,6 +217,11 @@ export default class PlacesSidebar extends Component {
@onToggleSave={{this.toggleSave}} @onToggleSave={{this.toggleSave}}
@onSave={{this.updateBookmark}} @onSave={{this.updateBookmark}}
/> />
{{else}}
{{#if @isLoading}}
<div class="sidebar-loading">
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
</div>
{{else}} {{else}}
{{#if @places}} {{#if @places}}
<ul class="places-list"> <ul class="places-list">
@@ -265,6 +270,7 @@ export default class PlacesSidebar extends Component {
Create new place Create new place
</button> </button>
{{/if}} {{/if}}
{{/if}}
</div> </div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,108 @@
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
export default class ListsListController extends Controller {
@service router;
@service mapUi;
@service storage;
@tracked model;
@tracked loadedPlaces = [];
get listId() {
return this.model?.list_id;
}
loadPlacesTask = task({ restartable: true }, async (listId) => {
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
try {
this.loadedPlaces = await this.storage.getPlacesInList(listId);
} catch (e) {
console.error('Failed to load places in list', listId, e);
this.loadedPlaces = [];
}
});
get scrollTop() {
return this.mapUi.getScrollPosition(`list-${this.listId}`);
}
get listColor() {
const list = this.storage.lists.find((l) => l.id === this.listId);
if (list && list.color) {
return list.color;
}
return getComputedStyle(document.documentElement)
.getPropertyValue('--default-list-color')
.trim();
}
get listTitle() {
const list = this.storage.lists.find((l) => l.id === this.listId);
return list ? list.title : 'Collections';
}
get places() {
const currentList = this.storage.lists.find((l) => l.id === this.listId);
const placeRefsIds = new Set(
currentList?.placeRefs?.map((ref) => ref.id) || []
);
// Filter live tracked savedPlaces that are in this list
const livePlaces = this.storage.savedPlaces.filter((p) =>
placeRefsIds.has(p.id)
);
const merged = [];
const seen = new Set();
// Process live state first to reflect deletions/edits immediately
livePlaces.forEach((p) => {
merged.push(p);
seen.add(p.id);
});
// Supplement with any background-fetched places that are still valid but not in live state yet
this.loadedPlaces.forEach((p) => {
if (placeRefsIds.has(p.id) && !seen.has(p.id)) {
merged.push(p);
seen.add(p.id);
}
});
return merged;
}
@action
selectPlace(place) {
if (place) {
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition(
`list-${this.listId}`,
sidebarContent.scrollTop
);
}
this.mapUi.returnToRoute = {
name: 'lists.list',
model: this.listId,
};
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', place);
}
}
@action
close() {
this.router.transitionTo('index');
}
@action
backToLists() {
this.router.transitionTo('lists.index');
}
}

View File

@@ -4,14 +4,23 @@ import { service } from '@ember/service';
export default class ListsListRoute extends Route { export default class ListsListRoute extends Route {
@service storage; @service storage;
async model(params) { model(params) {
const listId = params.list_id; // Resolve instantly so transition happens in 0ms!
try { return { list_id: params.list_id };
const places = await this.storage.getPlacesInList(listId); }
return { listId, places };
} catch (e) { setupController(controller, model) {
console.error('Failed to load places in list', listId, e); console.debug('DEBUG: setupController controller is:', controller);
return { listId, places: [] }; console.debug(
'DEBUG: controller.loadPlacesTask is:',
controller?.loadPlacesTask
);
controller.model = model;
super.setupController(controller, model);
if (controller && controller.loadPlacesTask) {
controller.loadPlacesTask.perform(model.list_id);
} else {
console.error('DEBUG: ERROR! controller.loadPlacesTask is undefined!');
} }
} }
} }

View File

@@ -2250,3 +2250,11 @@ button.create-place {
display: flex; display: flex;
align-items: center; align-items: center;
} }
/* Sidebar Loading State */
.sidebar-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 4rem 1rem;
}

View File

@@ -1,109 +1,16 @@
import Component from '@glimmer/component';
import PlacesSidebar from '#components/places-sidebar'; import PlacesSidebar from '#components/places-sidebar';
import { service } from '@ember/service';
import { action } from '@ember/object';
export default class ListsListTemplate extends Component { <template>
@service router; {{#if @controller.mapUi.isSidebarVisible}}
@service mapUi;
@service storage;
get listId() {
return this.args.model?.listId;
}
get scrollTop() {
return this.mapUi.getScrollPosition(`list-${this.listId}`);
}
get listColor() {
const list = this.storage.lists.find((l) => l.id === this.listId);
if (list && list.color) {
return list.color;
}
return getComputedStyle(document.documentElement)
.getPropertyValue('--default-list-color')
.trim();
}
get listTitle() {
const list = this.storage.lists.find((l) => l.id === this.listId);
return list ? list.title : 'Collections';
}
get places() {
const modelPlaces = this.args.model?.places || [];
const currentList = this.storage.lists.find((l) => l.id === this.listId);
const placeRefsIds = new Set(
currentList?.placeRefs?.map((ref) => ref.id) || []
);
// Filter live tracked savedPlaces that are in this list
const livePlaces = this.storage.savedPlaces.filter((p) =>
placeRefsIds.has(p.id)
);
const merged = [];
const seen = new Set();
// Process live state first to reflect deletions/edits immediately
livePlaces.forEach((p) => {
merged.push(p);
seen.add(p.id);
});
// Supplement with any model-fetched places that are still valid but not in live state yet
modelPlaces.forEach((p) => {
if (placeRefsIds.has(p.id) && !seen.has(p.id)) {
merged.push(p);
seen.add(p.id);
}
});
return merged;
}
@action
selectPlace(place) {
if (place) {
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition(
`list-${this.listId}`,
sidebarContent.scrollTop
);
}
this.mapUi.returnToRoute = {
name: 'lists.list',
model: this.listId,
};
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', place);
}
}
@action
close() {
this.router.transitionTo('index');
}
@action
backToLists() {
this.router.transitionTo('lists.index');
}
<template>
{{#if this.mapUi.isSidebarVisible}}
<PlacesSidebar <PlacesSidebar
@places={{this.places}} @places={{@controller.places}}
@title={{this.listTitle}} @title={{@controller.listTitle}}
@color={{this.listColor}} @color={{@controller.listColor}}
@scrollTop={{this.scrollTop}} @scrollTop={{@controller.scrollTop}}
@onSelect={{this.selectPlace}} @isLoading={{@controller.loadPlacesTask.isRunning}}
@onClose={{this.close}} @onSelect={{@controller.selectPlace}}
@onBack={{this.backToLists}} @onClose={{@controller.close}}
@onBack={{@controller.backToLists}}
/> />
{{/if}} {{/if}}
</template> </template>
}

View File

@@ -0,0 +1,138 @@
import { module, test } from 'qunit';
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
class MockOsmService extends Service {
async fetchOsmObject() {
return null;
}
}
class MockStorageService extends Service {
initialSyncDone = true;
savedPlaces = [
{
id: 'place-123',
title: 'Mountain Trail',
geohash: 'u33dc0',
osmTags: { name: 'Mountain Trail' },
},
];
lists = [
{
id: 'to-go',
title: 'Want to go',
color: '#2e9e4f',
placeRefs: [{ id: 'place-123', geohash: 'u33dc0' }],
},
{ id: 'to-do', title: 'To do', color: '#2a7fff', placeRefs: [] },
];
findPlaceById(id) {
if (id === 'place-123') {
return this.savedPlaces[0];
}
return null;
}
isPlaceSaved() {
return true;
}
loadPlacesInBounds() {
return [];
}
getPlacesInList(listId) {
if (listId === 'to-go') {
return Promise.resolve([this.savedPlaces[0]]);
}
return Promise.resolve([]);
}
rs = {
on: () => {},
};
}
module('Acceptance | collections navigation', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:osm', MockOsmService);
this.owner.register('service:storage', MockStorageService);
});
test('navigating through the collections menu hierarchy, viewing list places, and going back', async function (assert) {
// 1. Visit Home Map
await visit('/');
assert.strictEqual(currentURL(), '/');
// 2. Open the App Menu overlay
await click('.menu-btn-integrated');
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
assert
.dom('.app-menu')
.includesText('Collections', 'Menu contains Collections link');
// 3. Transition to Collections Index (List of lists)
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Collections"
assert.strictEqual(currentURL(), '/lists', 'Transitions to /lists index');
assert
.dom('.sidebar-header-text-centered')
.includesText('Collections', 'Header is centered and titled Collections');
assert
.dom('.lists-index-item')
.exists({ count: 2 }, 'Renders our 2 mocked list items');
// 4. Transition to a specific list (Want to go)
await click(document.querySelectorAll('.lists-index-item')[0]); // Click "Want to go"
assert.strictEqual(
currentURL(),
'/lists/to-go',
'Transitions instantly to /lists/to-go'
);
// 5. Verify background loading spinner shows up, then results populate
await waitFor('.places-list');
assert
.dom('.places-list .place-name')
.hasText('Mountain Trail', 'Renders the saved place from the list');
assert
.dom('.places-list .place-type')
.hasText('Saved place', 'Place type displays Saved place correctly');
// 6. Click back button in collection list header
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
// 7. Click back button in collections index header
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/menu', 'Goes back to main menu route');
// 8. Close sidebar
await click('.sidebar-header .close-btn');
assert.strictEqual(currentURL(), '/', 'Sidebar closed and returned home');
});
test('clicking a place inside a collection sets returnToRoute and returns gracefully on back click', async function (assert) {
await visit('/lists/to-go');
await waitFor('.places-list');
// Click on the place item to view details
await click('.place-item');
assert.ok(
currentURL().includes('/place/place-123'),
'Transitions to place details route'
);
// Click back from place details
await click('.back-btn');
assert.strictEqual(
currentURL(),
'/lists/to-go',
'Returns gracefully back to lists/to-go list view'
);
});
});