From f2e531c0f66cd866b2715b173a3d30e98a97dc79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Tue, 30 Jun 2026 16:14:08 +0200 Subject: [PATCH] Make collection list loading async --- app/components/places-sidebar.gjs | 90 +++++++++-------- app/controllers/lists/list.js | 108 +++++++++++++++++++++ app/routes/lists/list.js | 25 +++-- app/styles/app.css | 8 ++ app/templates/lists/list.gjs | 121 +++-------------------- tests/acceptance/collections-test.js | 138 +++++++++++++++++++++++++++ 6 files changed, 333 insertions(+), 157 deletions(-) create mode 100644 app/controllers/lists/list.js create mode 100644 tests/acceptance/collections-test.js diff --git a/app/components/places-sidebar.gjs b/app/components/places-sidebar.gjs index 9d011b0..0011217 100644 --- a/app/components/places-sidebar.gjs +++ b/app/components/places-sidebar.gjs @@ -218,52 +218,58 @@ export default class PlacesSidebar extends Component { @onSave={{this.updateBookmark}} /> {{else}} - {{#if @places}} - + {{#if @isLoading}} + {{else}} - {{#if this.isNearbySearch}} -

No places found nearby.

+ {{#if @places}} + {{else}} -

No results found.

+ {{#if this.isNearbySearch}} +

No places found nearby.

+ {{else}} +

No results found.

+ {{/if}} {{/if}} - {{/if}} - + + {{/if}} {{/if}} diff --git a/app/controllers/lists/list.js b/app/controllers/lists/list.js new file mode 100644 index 0000000..9785c88 --- /dev/null +++ b/app/controllers/lists/list.js @@ -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'); + } +} diff --git a/app/routes/lists/list.js b/app/routes/lists/list.js index de9f6b6..23138d7 100644 --- a/app/routes/lists/list.js +++ b/app/routes/lists/list.js @@ -4,14 +4,23 @@ import { service } from '@ember/service'; export default class ListsListRoute extends Route { @service storage; - async model(params) { - const listId = params.list_id; - try { - const places = await this.storage.getPlacesInList(listId); - return { listId, places }; - } catch (e) { - console.error('Failed to load places in list', listId, e); - return { listId, places: [] }; + model(params) { + // Resolve instantly so transition happens in 0ms! + return { list_id: params.list_id }; + } + + setupController(controller, model) { + console.debug('DEBUG: setupController controller is:', controller); + 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!'); } } } diff --git a/app/styles/app.css b/app/styles/app.css index 64f71ac..db56cef 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -2250,3 +2250,11 @@ button.create-place { display: flex; align-items: center; } + +/* Sidebar Loading State */ +.sidebar-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 4rem 1rem; +} diff --git a/app/templates/lists/list.gjs b/app/templates/lists/list.gjs index 14b6070..33cce31 100644 --- a/app/templates/lists/list.gjs +++ b/app/templates/lists/list.gjs @@ -1,109 +1,16 @@ -import Component from '@glimmer/component'; import PlacesSidebar from '#components/places-sidebar'; -import { service } from '@ember/service'; -import { action } from '@ember/object'; -export default class ListsListTemplate extends Component { - @service router; - @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'); - } - - -} + diff --git a/tests/acceptance/collections-test.js b/tests/acceptance/collections-test.js new file mode 100644 index 0000000..ed85ae0 --- /dev/null +++ b/tests/acceptance/collections-test.js @@ -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' + ); + }); +});