WIP Add places to lists
This commit is contained in:
@@ -60,9 +60,27 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
// Create a vector source and layer for bookmarks
|
// Create a vector source and layer for bookmarks
|
||||||
this.bookmarkSource = new VectorSource();
|
this.bookmarkSource = new VectorSource();
|
||||||
const bookmarkLayer = new VectorLayer({
|
|
||||||
source: this.bookmarkSource,
|
const bookmarkStyleFunction = (feature) => {
|
||||||
style: [
|
const originalPlace = feature.get('originalPlace');
|
||||||
|
let color = '#ffcc33'; // Default Yellow
|
||||||
|
|
||||||
|
if (
|
||||||
|
originalPlace &&
|
||||||
|
originalPlace._listIds &&
|
||||||
|
originalPlace._listIds.length > 0
|
||||||
|
) {
|
||||||
|
// Find the first list color
|
||||||
|
// We need access to storage.lists.
|
||||||
|
// Since this is inside setupMap, 'this' refers to the component instance.
|
||||||
|
const firstListId = originalPlace._listIds[0];
|
||||||
|
const list = this.storage.lists.find((l) => l.id === firstListId);
|
||||||
|
if (list && list.color) {
|
||||||
|
color = list.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
new Style({
|
new Style({
|
||||||
image: new Circle({
|
image: new Circle({
|
||||||
radius: 10,
|
radius: 10,
|
||||||
@@ -73,14 +91,19 @@ export default class MapComponent extends Component {
|
|||||||
new Style({
|
new Style({
|
||||||
image: new Circle({
|
image: new Circle({
|
||||||
radius: 9,
|
radius: 9,
|
||||||
fill: new Fill({ color: '#ffcc33' }), // Gold/Yellow
|
fill: new Fill({ color: color }),
|
||||||
stroke: new Stroke({
|
stroke: new Stroke({
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
width: 2,
|
width: 2,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookmarkLayer = new VectorLayer({
|
||||||
|
source: this.bookmarkSource,
|
||||||
|
style: bookmarkStyleFunction,
|
||||||
zIndex: 10, // Ensure it sits above the map tiles
|
zIndex: 10, // Ensure it sits above the map tiles
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,31 @@ import { on } from '@ember/modifier';
|
|||||||
import { htmlSafe } from '@ember/template';
|
import { htmlSafe } from '@ember/template';
|
||||||
import { humanizeOsmTag } from '../utils/format-text';
|
import { humanizeOsmTag } from '../utils/format-text';
|
||||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
|
import { mapToStorageSchema } from '../utils/place-mapping';
|
||||||
import { getSocialInfo } from '../utils/social-links';
|
import { getSocialInfo } from '../utils/social-links';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import PlaceEditForm from './place-edit-form';
|
import PlaceEditForm from './place-edit-form';
|
||||||
|
import PlaceListsManager from './place-lists-manager';
|
||||||
|
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
|
|
||||||
export default class PlaceDetails extends Component {
|
export default class PlaceDetails extends Component {
|
||||||
@tracked isEditing = false;
|
@tracked isEditing = false;
|
||||||
|
@tracked showLists = false;
|
||||||
|
|
||||||
get place() {
|
get place() {
|
||||||
return this.args.place || {};
|
return this.args.place || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get saveablePlace() {
|
||||||
|
if (this.place.createdAt) {
|
||||||
|
return this.place;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapToStorageSchema(this.place);
|
||||||
|
}
|
||||||
|
|
||||||
get tags() {
|
get tags() {
|
||||||
return this.place.osmTags || {};
|
return this.place.osmTags || {};
|
||||||
}
|
}
|
||||||
@@ -37,6 +48,16 @@ export default class PlaceDetails extends Component {
|
|||||||
this.isEditing = false;
|
this.isEditing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleLists() {
|
||||||
|
this.showLists = !this.showLists;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeLists() {
|
||||||
|
this.showLists = false;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async saveChanges(changes) {
|
async saveChanges(changes) {
|
||||||
if (this.args.onSave) {
|
if (this.args.onSave) {
|
||||||
@@ -247,21 +268,30 @@ export default class PlaceDetails extends Component {
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button
|
<div class="save-button-wrapper">
|
||||||
type="button"
|
<button
|
||||||
class={{if
|
type="button"
|
||||||
this.place.createdAt
|
class={{if
|
||||||
"btn btn-secondary"
|
this.place.createdAt
|
||||||
"btn btn-outline"
|
"btn btn-secondary"
|
||||||
}}
|
"btn btn-outline"
|
||||||
{{on "click" (fn @onToggleSave this.place)}}
|
}}
|
||||||
>
|
{{on "click" this.toggleLists}}
|
||||||
<Icon
|
>
|
||||||
@name="bookmark"
|
<Icon
|
||||||
@color={{if this.place.createdAt "currentColor" "#007bff"}}
|
@name="bookmark"
|
||||||
/>
|
@color={{if this.place.createdAt "currentColor" "#007bff"}}
|
||||||
{{if this.place.createdAt "Saved" "Save"}}
|
/>
|
||||||
</button>
|
{{if this.place.createdAt "Saved" "Save"}}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{#if this.showLists}}
|
||||||
|
<PlaceListsManager
|
||||||
|
@place={{this.saveablePlace}}
|
||||||
|
@onClose={{this.closeLists}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
{{#if this.place.createdAt}}
|
{{#if this.place.createdAt}}
|
||||||
<button
|
<button
|
||||||
|
|||||||
95
app/components/place-lists-manager.gjs
Normal file
95
app/components/place-lists-manager.gjs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import Icon from './icon';
|
||||||
|
|
||||||
|
export default class PlaceListsManager extends Component {
|
||||||
|
@service storage;
|
||||||
|
|
||||||
|
get isSaved() {
|
||||||
|
return !!this.args.place.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get placeListIds() {
|
||||||
|
return this.args.place._listIds || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
isInList(list) {
|
||||||
|
if (!this.placeListIds) return false;
|
||||||
|
return this.placeListIds.includes(list.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async toggleSaved() {
|
||||||
|
if (this.isSaved) {
|
||||||
|
if (confirm(`Remove "${this.args.place.title}" from saved places?`)) {
|
||||||
|
await this.storage.removePlace(this.args.place);
|
||||||
|
if (this.args.onClose) this.args.onClose();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.storage.storePlace(this.args.place);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async toggleList(list) {
|
||||||
|
const isMember = this.placeListIds.includes(list.id);
|
||||||
|
const shouldAdd = !isMember;
|
||||||
|
|
||||||
|
if (shouldAdd && !this.isSaved) {
|
||||||
|
// Auto-save if adding to list
|
||||||
|
await this.storage.storePlace(this.args.place);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Toggle membership
|
||||||
|
// We must pass the SAVED place (with ID) to the toggle function
|
||||||
|
// If we just saved it above, the args.place might still be the old object reference unless storage updates it in-place?
|
||||||
|
// StorageService.storePlace returns the new object.
|
||||||
|
// But togglePlaceList handles saving internally if ID is missing.
|
||||||
|
|
||||||
|
// Let's rely on storage.togglePlaceList to handle the "save if needed" part.
|
||||||
|
await this.storage.togglePlaceList(this.args.place, list.id, shouldAdd);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Failed to update list: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="place-lists-manager">
|
||||||
|
<div class="list-item master-toggle">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={{this.isSaved}}
|
||||||
|
{{on "change" this.toggleSaved}}
|
||||||
|
/>
|
||||||
|
<span class="list-name">Saved</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="lists-container">
|
||||||
|
{{#each this.storage.lists as |list|}}
|
||||||
|
<div class="list-item">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={{this.isInList list}}
|
||||||
|
{{on "change" (fn this.toggleList list)}}
|
||||||
|
disabled={{unless this.isSaved true}}
|
||||||
|
/>
|
||||||
|
<span class="list-color" style="background-color: {{list.color}}"></span>
|
||||||
|
<span class="list-name">{{list.title}}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export default class StorageService extends Service {
|
|||||||
@tracked savedPlaces = [];
|
@tracked savedPlaces = [];
|
||||||
@tracked loadedPrefixes = [];
|
@tracked loadedPrefixes = [];
|
||||||
@tracked currentBbox = null;
|
@tracked currentBbox = null;
|
||||||
|
@tracked lists = [];
|
||||||
@tracked version = 0; // Shared version tracker for bookmarks
|
@tracked version = 0; // Shared version tracker for bookmarks
|
||||||
@tracked initialSyncDone = false;
|
@tracked initialSyncDone = false;
|
||||||
@tracked connected = false;
|
@tracked connected = false;
|
||||||
@@ -46,6 +47,7 @@ export default class StorageService extends Service {
|
|||||||
this.rs.on('connected', () => {
|
this.rs.on('connected', () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.userAddress = this.rs.remote.userAddress;
|
this.userAddress = this.rs.remote.userAddress;
|
||||||
|
this.loadLists();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rs.on('disconnected', () => {
|
this.rs.on('disconnected', () => {
|
||||||
@@ -54,6 +56,7 @@ export default class StorageService extends Service {
|
|||||||
this.placesInView = [];
|
this.placesInView = [];
|
||||||
this.savedPlaces = [];
|
this.savedPlaces = [];
|
||||||
this.loadedPrefixes = [];
|
this.loadedPrefixes = [];
|
||||||
|
this.lists = [];
|
||||||
this.initialSyncDone = false;
|
this.initialSyncDone = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,13 +64,18 @@ export default class StorageService extends Service {
|
|||||||
// console.debug('[rs] sync done:', result);
|
// console.debug('[rs] sync done:', result);
|
||||||
if (!this.initialSyncDone) {
|
if (!this.initialSyncDone) {
|
||||||
this.initialSyncDone = true;
|
this.initialSyncDone = true;
|
||||||
|
this.loadLists();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rs.scope('/places/').on('change', (event) => {
|
this.rs.scope('/places/').on('change', (event) => {
|
||||||
// console.debug(event);
|
// console.debug(event);
|
||||||
this.handlePlaceChange(event);
|
if (event.relativePath.startsWith('_lists/')) {
|
||||||
debounceTask(this, 'reloadCurrentView', 200);
|
this.loadLists();
|
||||||
|
} else {
|
||||||
|
this.handlePlaceChange(event);
|
||||||
|
debounceTask(this, 'reloadCurrentView', 200);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +128,88 @@ export default class StorageService extends Service {
|
|||||||
this.loadAllPlaces(required);
|
this.loadAllPlaces(required);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadLists() {
|
||||||
|
try {
|
||||||
|
if (!this.places.lists) return; // Wait for module init
|
||||||
|
|
||||||
|
// Ensure defaults exist first
|
||||||
|
await this.places.lists.initDefaults();
|
||||||
|
|
||||||
|
const lists = await this.places.lists.getAll();
|
||||||
|
this.lists = lists || [];
|
||||||
|
this.refreshPlaceListAssociations();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load lists:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPlaceListAssociations() {
|
||||||
|
// 1. Build an index of PlaceID -> ListID[]
|
||||||
|
const placeToListMap = new Map();
|
||||||
|
|
||||||
|
this.lists.forEach((list) => {
|
||||||
|
if (list.placeRefs && Array.isArray(list.placeRefs)) {
|
||||||
|
list.placeRefs.forEach((ref) => {
|
||||||
|
if (!ref.id) return;
|
||||||
|
if (!placeToListMap.has(ref.id)) {
|
||||||
|
placeToListMap.set(ref.id, []);
|
||||||
|
}
|
||||||
|
placeToListMap.get(ref.id).push(list.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Helper to attach lists to a place object
|
||||||
|
const attachLists = (place) => {
|
||||||
|
const listIds = placeToListMap.get(place.id) || [];
|
||||||
|
// Assign directly to object property (non-tracked mutation is fine as we trigger updates below)
|
||||||
|
place._listIds = listIds;
|
||||||
|
return place;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Update savedPlaces
|
||||||
|
this.savedPlaces = this.savedPlaces.map((p) => attachLists({ ...p }));
|
||||||
|
|
||||||
|
// 4. Update placesInView
|
||||||
|
this.placesInView = this.placesInView.map((p) => attachLists({ ...p }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async togglePlaceList(place, listId, shouldBeInList) {
|
||||||
|
if (!place) return;
|
||||||
|
|
||||||
|
// Ensure place is saved first if it's new
|
||||||
|
let savedPlace = place;
|
||||||
|
if (!place.id || !place.geohash) {
|
||||||
|
if (shouldBeInList) {
|
||||||
|
// If adding to a list, we must save the place first
|
||||||
|
savedPlace = await this.storePlace(place);
|
||||||
|
} else {
|
||||||
|
return; // Can't remove an unsaved place from a list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (shouldBeInList) {
|
||||||
|
await this.places.lists.addPlace(
|
||||||
|
listId,
|
||||||
|
savedPlace.id,
|
||||||
|
savedPlace.geohash
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.places.lists.removePlace(listId, savedPlace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload lists to reflect changes
|
||||||
|
await this.loadLists();
|
||||||
|
|
||||||
|
// Return the updated place
|
||||||
|
return this.findPlaceById(savedPlace.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to toggle place in list:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadPlacesInBounds(bbox) {
|
async loadPlacesInBounds(bbox) {
|
||||||
// 1. Calculate required prefixes
|
// 1. Calculate required prefixes
|
||||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||||
@@ -173,6 +263,8 @@ export default class StorageService extends Service {
|
|||||||
// Full reload
|
// Full reload
|
||||||
this.placesInView = places;
|
this.placesInView = places;
|
||||||
}
|
}
|
||||||
|
// Refresh list associations
|
||||||
|
this.refreshPlaceListAssociations();
|
||||||
} else {
|
} else {
|
||||||
if (!prefixes) this.placesInView = [];
|
if (!prefixes) this.placesInView = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -981,3 +981,63 @@ button.create-place {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Place Lists Manager */
|
||||||
|
.save-button-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
|
width: 220px;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .list-item {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .list-item:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager input[type="checkbox"] {
|
||||||
|
accent-color: #007bff;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .list-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #eee;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|||||||
15
app/utils/place-mapping.js
Normal file
15
app/utils/place-mapping.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { getLocalizedName } from './osm';
|
||||||
|
|
||||||
|
export function mapToStorageSchema(place) {
|
||||||
|
return {
|
||||||
|
title: place.title || getLocalizedName(place.osmTags, 'Untitled Place'),
|
||||||
|
lat: place.lat,
|
||||||
|
lon: place.lon,
|
||||||
|
tags: [],
|
||||||
|
url: place.osmTags?.website,
|
||||||
|
osmId: String(place.osmId || place.id),
|
||||||
|
osmType: place.osmType,
|
||||||
|
osmTags: place.osmTags || {},
|
||||||
|
description: place.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,35 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
import { render } from '@ember/test-helpers';
|
import { render, click } from '@ember/test-helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
import PlaceDetails from 'marco/components/place-details';
|
import PlaceDetails from 'marco/components/place-details';
|
||||||
|
|
||||||
module('Integration | Component | place-details', function (hooks) {
|
module('Integration | Component | place-details', function (hooks) {
|
||||||
setupRenderingTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
class StorageService extends Service {
|
||||||
|
lists = [
|
||||||
|
{ id: 'to-go', title: 'Want to go', color: '#ff00ff' },
|
||||||
|
{ id: 'to-do', title: 'To do', color: '#008000' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async storePlace(place) {
|
||||||
|
return { ...place, id: '123', createdAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePlace() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async togglePlaceList() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:storage', StorageService);
|
||||||
|
});
|
||||||
|
|
||||||
test('it formats coordinates correctly', async function (assert) {
|
test('it formats coordinates correctly', async function (assert) {
|
||||||
const place = {
|
const place = {
|
||||||
title: 'Test Place',
|
title: 'Test Place',
|
||||||
@@ -34,4 +58,160 @@ module('Integration | Component | place-details', function (hooks) {
|
|||||||
assert.dom('.place-details h3').hasText('Place without Coords');
|
assert.dom('.place-details h3').hasText('Place without Coords');
|
||||||
assert.dom('.meta-info a[href*="geo:"]').doesNotExist();
|
assert.dom('.meta-info a[href*="geo:"]').doesNotExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it reveals the list manager when save is clicked', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Cool Cafe',
|
||||||
|
lat: 10,
|
||||||
|
lon: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Manager is initially hidden
|
||||||
|
assert.dom('.place-lists-manager').doesNotExist();
|
||||||
|
|
||||||
|
// Find the Save button
|
||||||
|
// It's the first button in .actions
|
||||||
|
const saveBtn = this.element.querySelector('.actions button');
|
||||||
|
await click(saveBtn);
|
||||||
|
|
||||||
|
// Manager should be visible now
|
||||||
|
assert.dom('.place-lists-manager').exists();
|
||||||
|
|
||||||
|
// Check for default lists from mock service
|
||||||
|
assert.dom('.place-lists-manager').includesText('Want to go');
|
||||||
|
assert.dom('.place-lists-manager').includesText('To do');
|
||||||
|
assert.dom('.place-lists-manager').includesText('Saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles saving a new place via master toggle', async function (assert) {
|
||||||
|
let storedPlace = null;
|
||||||
|
|
||||||
|
// Override mock service specifically for this test to spy on storePlace
|
||||||
|
class MockStorage extends Service {
|
||||||
|
lists = [];
|
||||||
|
async storePlace(place) {
|
||||||
|
storedPlace = place;
|
||||||
|
return { ...place, id: 'new-id', createdAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorage);
|
||||||
|
|
||||||
|
const place = {
|
||||||
|
title: 'New Spot',
|
||||||
|
lat: 20,
|
||||||
|
lon: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Open manager
|
||||||
|
await click('.actions button');
|
||||||
|
|
||||||
|
// Find master "Saved" toggle
|
||||||
|
const masterToggle = this.element.querySelector(
|
||||||
|
'.place-lists-manager .master-toggle input'
|
||||||
|
);
|
||||||
|
|
||||||
|
// It should be unchecked initially for a new place
|
||||||
|
assert.dom(masterToggle).isNotChecked();
|
||||||
|
|
||||||
|
// Click it to save
|
||||||
|
await click(masterToggle);
|
||||||
|
|
||||||
|
// Verify storePlace was called
|
||||||
|
assert.ok(storedPlace, 'storePlace was called');
|
||||||
|
assert.strictEqual(storedPlace.title, 'New Spot');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles removing a saved place via master toggle', async function (assert) {
|
||||||
|
let removedPlace = null;
|
||||||
|
let confirmCalled = false;
|
||||||
|
|
||||||
|
// Mock confirm
|
||||||
|
const originalConfirm = window.confirm;
|
||||||
|
window.confirm = () => {
|
||||||
|
confirmCalled = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MockStorage extends Service {
|
||||||
|
lists = [];
|
||||||
|
async removePlace(place) {
|
||||||
|
removedPlace = place;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorage);
|
||||||
|
|
||||||
|
const place = {
|
||||||
|
id: 'saved-id',
|
||||||
|
title: 'Saved Spot',
|
||||||
|
lat: 30,
|
||||||
|
lon: 30,
|
||||||
|
createdAt: '2023-01-01', // Marks it as saved
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Open manager
|
||||||
|
await click('.actions button');
|
||||||
|
|
||||||
|
// Find master "Saved" toggle
|
||||||
|
const masterToggle = this.element.querySelector(
|
||||||
|
'.place-lists-manager .master-toggle input'
|
||||||
|
);
|
||||||
|
|
||||||
|
// It should be checked initially for a saved place
|
||||||
|
assert.dom(masterToggle).isChecked();
|
||||||
|
|
||||||
|
// Click it to remove
|
||||||
|
await click(masterToggle);
|
||||||
|
|
||||||
|
assert.ok(confirmCalled, 'confirm dialog was shown');
|
||||||
|
assert.strictEqual(removedPlace.id, 'saved-id', 'removePlace was called');
|
||||||
|
|
||||||
|
// Restore confirm
|
||||||
|
window.confirm = originalConfirm;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it adds place to a list', async function (assert) {
|
||||||
|
let listId = null;
|
||||||
|
let placeArg = null;
|
||||||
|
let shouldAdd = null;
|
||||||
|
|
||||||
|
class MockStorage extends Service {
|
||||||
|
lists = [{ id: 'favs', title: 'Favorites', color: 'red' }];
|
||||||
|
async togglePlaceList(place, id, add) {
|
||||||
|
placeArg = place;
|
||||||
|
listId = id;
|
||||||
|
shouldAdd = add;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorage);
|
||||||
|
|
||||||
|
// Provide a place that is already saved
|
||||||
|
const place = {
|
||||||
|
id: 'p1',
|
||||||
|
title: 'My Spot',
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
_listIds: [], // Not in any list yet
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Open manager
|
||||||
|
await click('.actions button');
|
||||||
|
|
||||||
|
// Find the checkbox for "Favorites"
|
||||||
|
const checkbox = this.element.querySelectorAll(
|
||||||
|
'.place-lists-manager input[type="checkbox"]'
|
||||||
|
)[1]; // Index 1 because 0 is master toggle
|
||||||
|
|
||||||
|
await click(checkbox);
|
||||||
|
|
||||||
|
assert.strictEqual(listId, 'favs');
|
||||||
|
assert.strictEqual(placeArg.id, 'p1');
|
||||||
|
assert.true(shouldAdd);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
58
tests/unit/utils/place-mapping-test.js
Normal file
58
tests/unit/utils/place-mapping-test.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { mapToStorageSchema } from 'marco/utils/place-mapping';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
|
||||||
|
module('Unit | Utility | place-mapping', function () {
|
||||||
|
test('it maps a raw place object to the storage schema', function (assert) {
|
||||||
|
const rawPlace = {
|
||||||
|
osmId: 12345,
|
||||||
|
osmType: 'node',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: {
|
||||||
|
name: 'Test Place',
|
||||||
|
website: 'https://example.com',
|
||||||
|
},
|
||||||
|
description: 'A test description',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mapToStorageSchema(rawPlace);
|
||||||
|
|
||||||
|
assert.strictEqual(result.title, 'Test Place');
|
||||||
|
assert.strictEqual(result.lat, 52.52);
|
||||||
|
assert.strictEqual(result.lon, 13.405);
|
||||||
|
assert.strictEqual(result.osmId, '12345');
|
||||||
|
assert.strictEqual(result.osmType, 'node');
|
||||||
|
assert.strictEqual(result.url, 'https://example.com');
|
||||||
|
assert.strictEqual(result.description, 'A test description');
|
||||||
|
assert.deepEqual(result.osmTags, rawPlace.osmTags);
|
||||||
|
assert.deepEqual(result.tags, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it prioritizes place.title over osmTags.name', function (assert) {
|
||||||
|
const rawPlace = {
|
||||||
|
osmId: 123,
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
title: 'Custom Title',
|
||||||
|
osmTags: {
|
||||||
|
name: 'OSM Name',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mapToStorageSchema(rawPlace);
|
||||||
|
assert.strictEqual(result.title, 'Custom Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles fallback title correctly when no name is present', function (assert) {
|
||||||
|
const rawPlace = {
|
||||||
|
id: 987,
|
||||||
|
lat: 10,
|
||||||
|
lon: 20,
|
||||||
|
osmTags: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mapToStorageSchema(rawPlace);
|
||||||
|
assert.strictEqual(result.title, 'Untitled Place');
|
||||||
|
assert.strictEqual(result.osmId, '987');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user