Make collection list loading async
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
108
app/controllers/lists/list.js
Normal file
108
app/controllers/lists/list.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
|
||||||
|
|||||||
138
tests/acceptance/collections-test.js
Normal file
138
tests/acceptance/collections-test.js
Normal 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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user