Compare commits

..

6 Commits

Author SHA1 Message Date
b2220b8310 Close list dropdown when clicking outside of it
Some checks failed
CI / Lint (pull_request) Failing after 25s
CI / Test (pull_request) Successful in 34s
2026-03-13 13:40:28 +04:00
a8613ab81a Remove confirmation dialog when deleting place bookmarks 2026-03-13 13:27:01 +04:00
bcb9b20e85 WIP Add places to lists 2026-03-13 12:22:51 +04:00
466b1d5383 Comment dev config for remote access
All checks were successful
CI / Lint (push) Successful in 20s
CI / Test (push) Successful in 34s
2026-03-11 18:26:45 +04:00
ea7cb2f895 1.13.3
Some checks failed
CI / Lint (push) Failing after 18s
CI / Test (push) Successful in 29s
2026-03-11 18:19:15 +04:00
7e94f335ac Prevent zooming when selecting saved places 2026-03-11 18:16:24 +04:00
18 changed files with 650 additions and 66 deletions

View File

@@ -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
}); });
@@ -441,6 +464,7 @@ export default class MapComponent extends Component {
// Track the selected place from the UI Service (Router -> Map) // Track the selected place from the UI Service (Router -> Map)
updateSelectedPin = modifier(() => { updateSelectedPin = modifier(() => {
const selected = this.mapUi.selectedPlace; const selected = this.mapUi.selectedPlace;
const options = this.mapUi.selectionOptions || {};
if (!this.selectedPinOverlay || !this.selectedPinElement) return; if (!this.selectedPinOverlay || !this.selectedPinElement) return;
@@ -471,7 +495,12 @@ export default class MapComponent extends Component {
} }
} }
if (selected.bbox) { if (options.preventZoom) {
// If we are preventing zoom (e.g. user clicked a bookmark), we still need to center
// but without changing the zoom level.
// We use animateToSmartCenter without a second argument (zoom=null).
this.animateToSmartCenter(coords);
} else if (selected.bbox) {
this.zoomToBbox(selected.bbox); this.zoomToBbox(selected.bbox);
} else { } else {
this.handlePinVisibility(coords); this.handlePinVisibility(coords);
@@ -870,6 +899,7 @@ export default class MapComponent extends Component {
'Clicked bookmark while sidebar open (switching):', 'Clicked bookmark while sidebar open (switching):',
clickedBookmark clickedBookmark
); );
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', clickedBookmark); this.router.transitionTo('place', clickedBookmark);
return; return;
} }
@@ -884,6 +914,7 @@ export default class MapComponent extends Component {
// Normal behavior (sidebar is closed) // Normal behavior (sidebar is closed)
if (clickedBookmark) { if (clickedBookmark) {
console.debug('Clicked bookmark:', clickedBookmark); console.debug('Clicked bookmark:', clickedBookmark);
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', clickedBookmark); this.router.transitionTo('place', clickedBookmark);
return; return;
} }

View File

@@ -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,21 @@ export default class PlaceDetails extends Component {
this.isEditing = false; this.isEditing = false;
} }
@action
toggleLists(event) {
// Prevent this click from propagating to the document listener
// which handles the "click outside" logic.
if (event) {
event.stopPropagation();
}
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 +273,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

View File

@@ -0,0 +1,97 @@
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';
import onClickOutside from '../modifiers/on-click-outside';
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) {
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"
{{onClickOutside @onClose}}
>
<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>
}

View File

@@ -51,40 +51,39 @@ export default class PlacesSidebar extends Component {
if (!place) return; if (!place) return;
if (place.createdAt) { if (place.createdAt) {
if (confirm(`Delete "${place.title}"?`)) { // Direct delete without confirmation
try { try {
await this.storage.removePlace(place); await this.storage.removePlace(place);
console.debug('Place deleted:', place.title); console.debug('Place deleted:', place.title);
// Notify parent to refresh map bookmarks // Notify parent to refresh map bookmarks
if (this.args.onBookmarkChange) { if (this.args.onBookmarkChange) {
this.args.onBookmarkChange(); this.args.onBookmarkChange();
}
if (this.args.onUpdate) {
// Reconstruct the "original" place without ID/Geohash/CreatedAt
const freshPlace = {
...place,
id: undefined,
geohash: undefined,
createdAt: undefined,
};
this.args.onUpdate(freshPlace);
}
// Also fire onSelect if it exists (for list view)
if (this.args.onSelect) {
this.args.onSelect(null);
}
// Close sidebar after delete
if (this.args.onClose) {
this.args.onClose();
}
} catch (e) {
console.error('Failed to delete:', e);
alert('Failed to delete: ' + e.message);
} }
if (this.args.onUpdate) {
// Reconstruct the "original" place without ID/Geohash/CreatedAt
const freshPlace = {
...place,
id: undefined,
geohash: undefined,
createdAt: undefined,
};
this.args.onUpdate(freshPlace);
}
// Also fire onSelect if it exists (for list view)
if (this.args.onSelect) {
this.args.onSelect(null);
}
// Close sidebar after delete
if (this.args.onClose) {
this.args.onClose();
}
} catch (e) {
console.error('Failed to delete:', e);
alert('Failed to delete: ' + e.message);
} }
} else { } else {
// It's a fresh POI -> Save it // It's a fresh POI -> Save it

View File

@@ -0,0 +1,21 @@
import { modifier } from 'ember-modifier';
export default modifier((element, [callback]) => {
const handler = (event) => {
// Check if the click target is contained within the element
if (element && !element.contains(event.target)) {
callback(event);
}
};
// Delay attaching the listener to avoid catching the opening click
// (using a microtask or setTimeout 0)
const timer = setTimeout(() => {
document.addEventListener('click', handler);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handler);
};
});

View File

@@ -72,7 +72,9 @@ export default class PlaceRoute extends Route {
// Notify the Map UI to show the pin // Notify the Map UI to show the pin
if (model) { if (model) {
this.mapUi.selectPlace(model); const options = { preventZoom: this.mapUi.preventNextZoom };
this.mapUi.selectPlace(model, options);
this.mapUi.preventNextZoom = false;
} }
// Stop the pulse animation if it was running (e.g. redirected from search) // Stop the pulse animation if it was running (e.g. redirected from search)
this.mapUi.stopSearch(); this.mapUi.stopSearch();

View File

@@ -9,18 +9,24 @@ export default class MapUiService extends Service {
@tracked returnToSearch = false; @tracked returnToSearch = false;
@tracked currentCenter = null; @tracked currentCenter = null;
@tracked searchBoxHasFocus = false; @tracked searchBoxHasFocus = false;
@tracked selectionOptions = {};
@tracked preventNextZoom = false;
selectPlace(place) { selectPlace(place, options = {}) {
this.selectedPlace = place; this.selectedPlace = place;
this.selectionOptions = options;
} }
clearSelection() { clearSelection() {
this.selectedPlace = null; this.selectedPlace = null;
this.selectionOptions = {};
this.preventNextZoom = false;
} }
startSearch() { startSearch() {
this.isSearching = true; this.isSearching = true;
this.isCreating = false; this.isCreating = false;
this.preventNextZoom = false;
} }
stopSearch() { stopSearch() {

View File

@@ -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 = [];
} }

View File

@@ -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;
}

View 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,
};
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.13.2", "version": "1.13.3",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "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

View File

@@ -39,7 +39,7 @@
<meta name="msapplication-TileColor" content="#F6E9A6"> <meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png"> <meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-CHoCxxBl.js"></script> <script type="module" crossorigin src="/assets/main-gjk9d6Ld.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DAo4Q0R2.css"> <link rel="stylesheet" crossorigin href="/assets/main-DAo4Q0R2.css">
</head> </head>
<body> <body>

View File

@@ -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,148 @@ 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;
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.strictEqual(removedPlace.id, 'saved-id', 'removePlace was called');
});
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);
});
}); });

View 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');
});
});

View File

@@ -3,9 +3,9 @@ import { extensions, ember } from '@embroider/vite';
import { babel } from '@rollup/plugin-babel'; import { babel } from '@rollup/plugin-babel';
export default defineConfig({ export default defineConfig({
server: { // server: {
host: '0.0.0.0' // host: '0.0.0.0',
}, // },
plugins: [ plugins: [
ember(), ember(),
// extra plugins here // extra plugins here