diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49a821ef9..bbf28a5d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: # image: ghcr.io/mapforge-org/mapforge-ci-runner:latest services: mongodb: - image: mongo:8.2 + image: mongo:8.3 ports: - 27017:27017 # credentials are only needed when not running on ubuntu-latest VM diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7492fc7..2c5843544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## 2026-09 +* Option to organize geojson features into multiple layers * URL parameter `nocontrols=true` hides the map controls * Search can find map local geojson results * Color picker offers the site and map default colors diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c0f4a843d..d58972eaf 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -29,7 +29,7 @@ Before running the application, make sure MongoDB and Redis are running. For exa podman run -d --name mongo \ -v :/data/db:U,z \ -p 27017:27017 \ - mongo:8.2 + mongo:8.3 podman run -d --name redis \ -p 6379:6379 \ diff --git a/Gemfile b/Gemfile index 5fbf86aa3..1be75e626 100644 --- a/Gemfile +++ b/Gemfile @@ -103,7 +103,6 @@ group :test do gem "rspec" gem "rspec-rails" gem "rspec-wait" - gem "selenium-webdriver" gem "simplecov" gem "database_cleaner-mongoid" gem "mongoid-rspec" diff --git a/Gemfile.lock b/Gemfile.lock index c0ea674ff..3e3c189f9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -625,14 +625,7 @@ GEM simplecov (>= 0.22.0) tty-which (~> 0.5.0) virtus (~> 2.0) - rubyzip (3.7.0) securerandom (0.4.1) - selenium-webdriver (4.49.0) - base64 (~> 0.2) - logger (~> 1.4) - rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 4.0) - websocket (~> 1.0) sexp_processor (4.17.5) simplecov (1.2.0) singleton (0.3.0) @@ -694,7 +687,6 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket (1.2.11) websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) @@ -784,7 +776,6 @@ DEPENDENCIES rubocop-rubycw rubocop-thread_safety rubycritic - selenium-webdriver simplecov sprockets-rails standard diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index 325f90829..b479e88a3 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -122,6 +122,12 @@ } } +/* .form-control:focus paints a white background, which beats .btn-blue after a dropdown closes */ +.btn-blue.form-control:focus:not(:hover, .show) { + background-color: var(--color-mid-steel-blue); + border-color: var(--color-mid-steel-blue); +} + /* same blue as .btn-download in the share modal */ .btn-layer-actions { border-radius: 0.3rem; @@ -132,3 +138,11 @@ --bs-btn-hover-bg: var(--color-mid-blue-slate); --bs-btn-active-bg: var(--color-mid-blue-slate); } + +.btn-layer-menu { + --bs-btn-color: var(--color-mid-steel-blue); + --bs-btn-bg: transparent; + --bs-btn-border-color: var(--color-mid-steel-blue); + --bs-btn-hover-color: #fff; + --bs-btn-active-color: #fff; +} diff --git a/app/assets/stylesheets/modals.css b/app/assets/stylesheets/modals.css index 111c13765..750f32e2e 100644 --- a/app/assets/stylesheets/modals.css +++ b/app/assets/stylesheets/modals.css @@ -472,8 +472,9 @@ body.map-mode-rw .ro-only { font-weight: 600; } -/* dim layer name and content when hidden, but not the action buttons/dropdown */ -.layer-dimmed > h5 .link, +/* dim the whole hidden layer, but not the open menu */ +.layer-dimmed > h5 > :not(.layer-actions-dropdown), +.layer-dimmed .layer-actions-dropdown > button, .layer-dimmed > .layer-content { opacity: 0.5; } @@ -495,6 +496,24 @@ body.map-mode-rw .ro-only { text-overflow: ellipsis; } +@media (hover: hover) { + .layer-item-header .layer-active:hover { + opacity: 1 !important; + } +} + +.layer-name-input { + width: 12em; + max-width: 60%; + padding: 0 0.25rem; + font: inherit; +} + +.layer-item-header .text-nowrap { + display: inline-flex; + align-items: center; +} + .layer-item-header .btn-layer-actions { display: flex; align-items: center; @@ -558,6 +577,11 @@ body.map-mode-rw .ro-only { min-height: 1.5rem; } +/* the name of a geojson layer is a drop target, also when the layer is collapsed */ +.layer-item-header.drop-target { + background-color: var(--bs-tertiary-bg); +} + .layer-feature-item.sortable-chosen { background-color: var(--bs-tertiary-bg); } @@ -602,14 +626,6 @@ body.map-mode-rw .ro-only { /* Media queries at end to ensure they override defaults */ -@media (width > 574px) { - /* Keep layer action buttons on one line on desktop */ - .layer-item-header .text-nowrap { - display: inline-flex !important; - align-items: center; - } -} - @media (width <= 640px), (height < 410px) { .modal-pull-button { top: 0.7rem; diff --git a/app/channels/map_channel.rb b/app/channels/map_channel.rb index b24aa0dbc..cdcf7fbc1 100644 --- a/app/channels/map_channel.rb +++ b/app/channels/map_channel.rb @@ -62,7 +62,9 @@ def update_feature(data) def new_feature(data) Yabeda.websocket.messages_received.increment({ action: "new_feature", channel: "MapChannel" }) map = get_map_rw!(data["map_id"]) - @feature = map.layers.geojson.first.features.create!(feature_atts(data).merge({ id: data["id"] })) + layer = data["layer_id"] ? map.layers.geojson.find(data["layer_id"]) : map.layers.geojson.first + raise "Layer #{data["layer_id"]} not found on map #{data["map_id"]}" unless layer + @feature = layer.features.create!(feature_atts(data).merge({ id: data["id"] })) associate_image(data["properties"]) end @@ -70,7 +72,10 @@ def new_feature(data) def new_layer(data) Yabeda.websocket.messages_received.increment({ action: "new_layer", channel: "MapChannel" }) map = get_map_rw!(data["map_id"]) + # Layers have no position field, they sort by created_at + first_created_at = map.layers.first&.created_at if data["first"] layer = map.layers.new(layer_atts(data).merge({ id: data["id"] })) + layer.created_at = first_created_at - 1 if first_created_at documents = import_documents(layer, data.dig("geojson", "features") || []) Feature.collection.insert_many(documents) if documents.any? layer.features_count = documents.size @@ -91,8 +96,10 @@ def delete_layer(data) Yabeda.websocket.messages_received.increment({ action: "delete_layer", channel: "MapChannel" }) map = get_map_rw!(data["map_id"]) layer = map.layers.find(data["id"]) - # new_feature always writes into the first geojson layer - raise "Cannot delete the first geojson layer of map '#{map.public_id}'" if layer == map.layers.geojson.first + # new_feature needs a geojson layer to write into + if layer.type == "geojson" && map.layers.geojson.count == 1 + raise "Cannot delete the last geojson layer of map '#{map.public_id}'" + end layer.destroy end diff --git a/app/javascript/channels/map_channel.js b/app/javascript/channels/map_channel.js index 0b3bf78cc..c44e1beff 100644 --- a/app/javascript/channels/map_channel.js +++ b/app/javascript/channels/map_channel.js @@ -1,6 +1,7 @@ import consumer from 'channels/consumer' +import { initLayersModal } from 'maplibre/controls/shared' import { createLayerInstance } from 'maplibre/layers/factory' -import { destroyFeature, initializeLayerSources, initializeLayerStyles, layers, loadLayerDefinitions, upsert } from 'maplibre/layers/layers' +import { destroyFeature, getLayer, initializeLayerSources, initializeLayerStyles, layers, loadLayerDefinitions, upsert } from 'maplibre/layers/layers' import { initializeMaplibreProperties, loadedMapUpdatedAt, @@ -143,6 +144,7 @@ export function initializeSocket () { break case 'update_feature': upsert(data.feature, data.layer_id) + initLayersModal() break case 'fly_to': { // maplibre reads 'pitch' in options, so an undefined key would give it NaN @@ -154,6 +156,7 @@ export function initializeSocket () { } case 'delete_feature': destroyFeature(data.feature.id) + initLayersModal() break case 'update_map': window.gon.map_properties = data.map @@ -174,14 +177,14 @@ export function initializeSocket () { console.log('Layer updated on server, reloading layer styles', data.layer) layers[index].update(data.layer) layers[index].applyFeatureOrder(data.layer.feature_order) - initializeLayerStyles(data.layer.id) + initializeLayerStyles(data.layer.id).then(() => { initLayersModal() }) setLayerVisibility(layers[index].sourceId, data.layer.show !== false) } } else { const newLayer = createLayerInstance(data.layer) layers.push(newLayer) initializeLayerSources(data.layer.id) - initializeLayerStyles(data.layer.id) + initializeLayerStyles(data.layer.id).then(() => { initLayersModal() }) } break case 'delete_layer': @@ -191,6 +194,7 @@ export function initializeSocket () { layers.splice(delIndex, 1) // trigger a full map redraw setBackgroundMapLayer(mapProperties.base_map, true) + initLayersModal() } break case 'mouse': @@ -232,6 +236,8 @@ export function sendMessage (event, data) { const payload = { ...data } payload.map_id = window.gon.map_id payload.uuid = connectionUUID + // callers add the feature locally first, so its layer is known + if (event === 'new_feature') { payload.layer_id ||= getLayer(data.id, 'geojson')?.id } // dropping properties.id before sending to server if (payload.properties && payload.properties.id) { payload.properties = { ...payload.properties } diff --git a/app/javascript/controllers/map/context_menu_controller.js b/app/javascript/controllers/map/context_menu_controller.js index ba6b19e53..9673f4344 100644 --- a/app/javascript/controllers/map/context_menu_controller.js +++ b/app/javascript/controllers/map/context_menu_controller.js @@ -7,7 +7,7 @@ import { hideContextMenu } from 'maplibre/controls/context_menu' import { handleDelete } from 'maplibre/edit' import { updateElevation } from 'maplibre/routing/openrouteservice' import { getFeatureTypeName } from 'maplibre/feature' -import { addFeature, applyFeatureUpdate, getFeature, renderLayers } from 'maplibre/layers/layers' +import { addFeature, applyFeatureUpdate, getFeature, getLayer, renderLayers } from 'maplibre/layers/layers' import { addUndoState } from 'maplibre/undo' export default class extends Controller { @@ -49,7 +49,7 @@ export default class extends Controller { properties: JSON.parse(JSON.stringify(feature.properties || {})) } addUndoState('Feature added', secondFeature) - addFeature(secondFeature) + addFeature(secondFeature, getLayer(feature.id, 'geojson')?.id) sendMessage('new_feature', secondFeature) status(window.__('Line cut into 2 segments')) diff --git a/app/javascript/controllers/map/layers_controller.js b/app/javascript/controllers/map/layers_controller.js index 5326dffb0..649c0947f 100644 --- a/app/javascript/controllers/map/layers_controller.js +++ b/app/javascript/controllers/map/layers_controller.js @@ -9,10 +9,10 @@ import { updateElevation } from 'maplibre/routing/openrouteservice' import { confirmImageLocation, uploadImageToFeature } from 'maplibre/feature/image_upload' import { importFile } from 'maplibre/import/kml' import { createLayerInstance } from 'maplibre/layers/factory' -import { initializeLayerSources, initializeLayerStyles, layers, loadAllLayerData, loadLayerData, renderLayer, upsert } from 'maplibre/layers/layers' +import { activeLayer, initializeLayerSources, initializeLayerStyles, layers, loadAllLayerData, loadLayerData, renderLayer, setActiveLayer, upsert } from 'maplibre/layers/layers' import { queries } from 'maplibre/layers/overpass/queries' import { map, mapProperties, removeGeoJSONSource, setLayerVisibility, updateMapName } from 'maplibre/map' -import { addUndoState } from 'maplibre/undo' +import { addUndoState, layerPlacement, layerSettings } from 'maplibre/undo' // Browsers report an empty file.type for these when the platform mime database // does not know them, so the extension decides as well. @@ -175,8 +175,7 @@ export default class extends Controller { uploadImageToFeature(file, feature).then( () => { upsert(feature) - // redraw first geojson layer - renderLayer(layers.find(l => l.type === 'geojson').id) + renderLayer(activeLayer().id) sendMessage('new_feature', { ...feature }) status(window.__('Added image')) flyToFeature(feature) @@ -225,7 +224,7 @@ export default class extends Controller { const layerElement = event.target.closest('.layer-item') const layerId = layerElement.getAttribute('data-layer-id') const layer = layers.find(f => f.id === layerId) - addUndoState('Layer updated', { ...layer.toJSON(), geojson: layer.geojson }) + addUndoState('Layer updated', layerSettings(layer)) layer.query = layerElement.querySelector('.overpass-query').value layer.name = layerElement.querySelector('.overpass-name').value @@ -246,7 +245,7 @@ export default class extends Controller { const layerElement = event.target.closest('.layer-item') const layerId = layerElement.getAttribute('data-layer-id') const layer = layers.find(f => f.id === layerId) - addUndoState('Layer updated', { ...layer.toJSON(), geojson: layer.geojson }) + addUndoState('Layer updated', layerSettings(layer)) layer.query = layerElement.querySelector('.raster-url').value layer.name = layerElement.querySelector('.raster-name').value @@ -258,6 +257,37 @@ export default class extends Controller { initializeLayerStyles(layerId).then(() => { initLayersModal() }) } + renameLayer (event) { + event.preventDefault() + const layerElement = event.target.closest('.layer-item') + const layer = layers.find(f => f.id === layerElement.getAttribute('data-layer-id')) + const input = document.createElement('input') + input.type = 'text' + input.classList.add('form-control', 'd-inline-block', 'me-2', 'mapforge-font', 'layer-name-input') + input.placeholder = window.__('Layer elements') + input.value = layer.name || '' + let cancelled = false + // the header toggles the layer list on click, and Escape on window closes the modal + input.addEventListener('click', e => e.stopPropagation()) + input.addEventListener('keydown', e => { + e.stopPropagation() + if (e.key === 'Escape') { cancelled = true } + if (e.key === 'Enter' || e.key === 'Escape') { input.blur() } + }) + input.addEventListener('blur', () => { + const name = input.value.trim() + if (!cancelled && name && name !== layer.name) { + addUndoState('Layer updated', layerSettings(layer)) + layer.name = name + sendMessage('update_layer', layer.toJSON()) + } + initLayersModal() + }) + layerElement.querySelector('.layer-name').replaceWith(input) + input.focus() + input.select() + } + refreshLayer (event) { event.preventDefault() const layerId = event.target.closest('.layer-item').getAttribute('data-layer-id') @@ -301,28 +331,24 @@ export default class extends Controller { const layerId = layerElement.getAttribute('data-layer-id') const layer = layers.find(l => l.id === layerId) const wasVisible = layer.show !== false - if (window.gon.map_mode === "rw") { addUndoState('Layer updated', { ...layer.toJSON(), geojson: layer.geojson }) } + if (window.gon.map_mode === "rw") { addUndoState('Layer updated', layerSettings(layer)) } layer.show = !wasVisible setLayerVisibility(layer.sourceId, layer.show) - // update UI (both desktop and mobile visibility buttons) - layerElement.querySelectorAll('button.layer-visibility i, button.layer-visibility-mobile i').forEach(icon => { - if (layer.show) { - icon.classList.replace('bi-eye-slash', 'bi-eye') - } else { - icon.classList.replace('bi-eye', 'bi-eye-slash') - } - }) const visBtn = layerElement.querySelector('button.layer-visibility') - const visBtnMobile = layerElement.querySelector('button.layer-visibility-mobile') + const icon = visBtn.querySelector('i') + if (layer.show) { + icon.classList.replace('bi-eye-slash', 'bi-eye') + } else { + icon.classList.replace('bi-eye', 'bi-eye-slash') + } const newText = layer.show ? window.__('Hide layer') : window.__('Show layer') // Update tooltip title attributes visBtn.setAttribute('title', newText) visBtn.setAttribute('data-bs-original-title', newText) visBtn.setAttribute('aria-label', newText) - visBtnMobile.querySelector('.layer-visibility-text').textContent = newText // Update Bootstrap tooltip content if it exists if (typeof bootstrap !== 'undefined' && visBtn) { @@ -334,10 +360,10 @@ export default class extends Controller { // show/hide refresh and edit buttons based on visibility const hideAction = layer.show ? 'remove' : 'add' if (layer.type === 'overpass' || layer.type === 'wikipedia') { - layerElement.querySelectorAll('button.layer-refresh, button.layer-refresh-mobile').forEach(btn => btn.classList[hideAction]('hidden')) + layerElement.querySelector('button.layer-refresh').classList[hideAction]('hidden') } if ((layer.type === 'overpass' || layer.type === 'raster') && window.gon.map_mode === 'rw') { - layerElement.querySelectorAll('button.layer-edit, button.layer-edit-mobile').forEach(btn => btn.classList[hideAction]('hidden')) + layerElement.querySelector('button.layer-edit').classList[hideAction]('hidden') } // hide global "Load for this area" button if no visible overpass/wikipedia layers remain if (!layer.show) { @@ -352,12 +378,29 @@ export default class extends Controller { layerElement.classList.add('layer-dimmed') } - // when showing: initialize styles (and load data for overpass/wikipedia if needed) - if (layer.show) { initializeLayerStyles(layerId) } + // when showing: initialize styles (and load data for overpass/wikipedia if needed). + // A geojson layer keeps its style layers and data while hidden, so a full rebuild is only + // needed when they don't exist yet (hidden at page load, or replaced by setStyle). + if (layer.show && (layer.type !== 'geojson' || !layer.getStyleLayerIds().length)) { initializeLayerStyles(layerId) } // sync to server only in rw mode if (window.gon.map_mode === "rw") { sendMessage('update_layer', layer.toJSON()) } } + activateLayer (event) { + event.preventDefault() + event.stopPropagation() + const layer = layers.find(l => l.id === event.target.closest('.layer-item').getAttribute('data-layer-id')) + setActiveLayer(layer.id) + initLayersModal() + } + + createGeojsonLayer() { + const layerId = this.createLayer({ type: 'geojson', name: window.__('New layer'), + geojson: { type: 'FeatureCollection', features: [] } }, { first: true }) + setActiveLayer(layerId) + initLayersModal() + } + createWikipediaLayer() { this.createLayer({ type: 'wikipedia', name: 'Wikipedia' }) } @@ -416,7 +459,8 @@ export default class extends Controller { } // 'layer' is a layer definition: { type, name, query, geojson, heatmap, cluster, show } - createLayer(layer) { + // first: the layer becomes the first layer of the map, on the server and in the list + createLayer(layer, { first = false } = {}) { let layerId = functions.featureId() // must match server attribute order, for proper comparison in map_channel let layerData = { "id": layerId, "type": layer.type, "name": layer.name, @@ -433,13 +477,15 @@ export default class extends Controller { } const layerInstance = createLayerInstance(layerData) layerInstance.localData = true // renders from memory, see GeoJSONLayer.loadData - layers.push(layerInstance) + if (first) { layers.unshift(layerInstance) } else { layers.push(layerInstance) } addUndoState('Layer added', layerData) initLayersModal() initializeLayerSources(layerId) initializeLayerStyles(layerId) - sendMessage('new_layer', layerData) + // the flag stays out of layerData, which is the undo state and gets compared to server layers. + // Undo reads the placement from the list position instead, see layerPlacement. + sendMessage('new_layer', first ? { ...layerData, first: true } : layerData) return layerId } @@ -450,7 +496,7 @@ export default class extends Controller { const layerId = layerElement.getAttribute('data-layer-id') const layer = layers.find(f => f.id === layerId) - addUndoState('Layer deleted', { ...layer.toJSON(), geojson: layer.geojson }) + addUndoState('Layer deleted', { ...layer.toJSON(), geojson: layer.geojson }, true, layerPlacement(layer)) layer.cleanup() layers.splice(layers.indexOf(layer), 1) removeGeoJSONSource(layer.sourceId) diff --git a/app/javascript/maplibre/controls/shared.js b/app/javascript/maplibre/controls/shared.js index 1a390d764..ef462c841 100644 --- a/app/javascript/maplibre/controls/shared.js +++ b/app/javascript/maplibre/controls/shared.js @@ -9,7 +9,7 @@ import { initializeGeoLocateControl } from 'maplibre/controls/geolocate' import { initializeSearchControl } from 'maplibre/controls/search' import { draw, unselect } from 'maplibre/edit' import { featureIcon, getFeatureTypeName, resetHighlightedFeature } from 'maplibre/feature' -import { layers, moveFeature } from 'maplibre/layers/layers' +import { activeLayer, layers, moveFeature } from 'maplibre/layers/layers' import { map, mapProperties } from 'maplibre/map' export class ControlGroup { @@ -116,9 +116,9 @@ export class MapLayersControl { } else { resetControls() if (draw) { resetEditControls() } - initLayersModal() e.currentTarget.querySelector('button').classList.add('active') modal.classList.add('show') + initLayersModal() window.history.pushState({ modal: 'layers' }, '', `${window.location.pathname}#layers`) } } @@ -188,6 +188,35 @@ function updateFeatureCount (ul, layer) { layerElement.querySelector('.layer-empty-note')?.classList.toggle('hidden', count > 0) } +// Shared by all feature lists: after a drop into another list, the click lands in the target list +let draggingFeature = false + +// The fallback ghost ignores the pointer, so the element under it is the drop target. +// Only the name of another geojson layer counts. +function dropHeaderAt (event, fromLayerElement) { + const point = event?.changedTouches?.[0] || event + if (point?.clientX === undefined) { return null } + const header = document.elementFromPoint(point.clientX, point.clientY)?.closest('.layer-name')?.closest('.layer-item-header') + const layerElement = header?.closest('.layer-item[data-layer-type="geojson"]') + return layerElement && layerElement !== fromLayerElement ? header : null +} + +// Set from pointer moves, because Chrome keeps :hover frozen while a press that began on text is held +let dropHeader = null +function highlightDropHeader (header) { + if (header === dropHeader) { return } + dropHeader?.classList.remove('drop-target') + header?.classList.add('drop-target') + dropHeader = header +} + +function moveFeatureToLayer (feature, fromUl, fromLayer, toUl, toLayer) { + moveFeature(feature, toLayer.id) + sendMessage('update_feature', { ...feature, layer_id: toLayer.id }) + updateFeatureCount(fromUl, fromLayer) + updateFeatureCount(toUl, toLayer) +} + function renderLayerFeatures (layerElement, layer) { // a later initLayersModal() call detaches this element, then the build is obsolete if (!layerElement.isConnected) { return } @@ -226,34 +255,53 @@ function renderLayerFeatures (layerElement, layer) { }) if (window.gon.map_mode === 'rw' && layer.type === 'geojson') { ul.classList.add('feature-drop-list') - let dragging = false // Swallow the click that fires after a drop so reordering never selects a feature ul.addEventListener('click', e => { - if (dragging) { e.stopPropagation(); e.preventDefault() } + if (draggingFeature) { e.stopPropagation(); e.preventDefault() } }, true) + const onPointerMove = e => highlightDropHeader(dropHeaderAt(e, layerElement)) // Loaded lazily so the lib is never fetched in read-only mode, where features can't be reordered import('sortablejs').then(({ default: Sortable }) => { Sortable.create(ul, { - handle: '.feature-drag-handle', + // A finger on the whole line would block scrolling the list, so touch drags by the handle only + handle: window.matchMedia('(pointer: coarse)').matches ? '.feature-drag-handle' : undefined, + // below this many pixels of movement, a press stays a click that flies to the feature + fallbackTolerance: 3, group: 'features', animation: 150, // Use the JS fallback (pointer events) instead of native HTML5 DnD for // consistent touch + mouse behaviour and styling forceFallback: true, - onStart: () => { dragging = true }, + onStart: () => { + draggingFeature = true + document.addEventListener('pointermove', onPointerMove) + }, onEnd: evt => { - if (evt.from === evt.to && evt.oldIndex === evt.newIndex) { - setTimeout(() => { dragging = false }, 0) + document.removeEventListener('pointermove', onPointerMove) + highlightDropHeader(null) + // reset after the post-drop click has been processed + setTimeout(() => { draggingFeature = false }, 0) + const feature = layer.geojson.features.find(f => f.id === evt.item.dataset.featureId) + + // A drop on the name of a geojson layer, also a collapsed one + const header = dropHeaderAt(evt.originalEvent, layerElement) + const headerLayer = header && layers.find(l => l.id === header.closest('.layer-item').dataset.layerId) + if (headerLayer) { + const toUl = header.closest('.layer-item').querySelector('.layer-content ul') + // moveFeature puts it on top of the layer, which lists first + toUl.prepend(evt.item) + moveFeatureToLayer(feature, evt.from, layer, toUl, headerLayer) + // Without an order the server sorts by created_at, which a move does not change + const order = headerLayer.geojson.features.map(f => f.id) + // later layer updates send layer.toJSON(), which must not carry an old order + headerLayer.applyFeatureOrder(order) + sendMessage('update_layer', { id: headerLayer.id, feature_order: order }) return } + + if (evt.from === evt.to && evt.oldIndex === evt.newIndex) { return } const toLayer = layers.find(l => l.id === evt.to.closest('.layer-item').dataset.layerId) - if (evt.from !== evt.to) { - const feature = layer.geojson.features.find(f => f.id === evt.item.dataset.featureId) - moveFeature(feature, toLayer.id) - sendMessage('update_feature', { ...feature, layer_id: toLayer.id }) - updateFeatureCount(evt.from, layer) - updateFeatureCount(evt.to, toLayer) - } + if (evt.from !== evt.to) { moveFeatureToLayer(feature, evt.from, layer, evt.to, toLayer) } // The list is shown top-first, so reverse it back to draw order (bottom first) const orderedIds = Array.from(evt.to.querySelectorAll('li[data-feature-id]')) .map(li => li.getAttribute('data-feature-id')) @@ -261,8 +309,6 @@ function renderLayerFeatures (layerElement, layer) { toLayer.applyFeatureOrder(orderedIds) toLayer.render() sendMessage('update_layer', { id: toLayer.id, feature_order: orderedIds }) - // reset after the post-drop click has been processed - setTimeout(() => { dragging = false }, 0) } }) }) @@ -271,10 +317,19 @@ function renderLayerFeatures (layerElement, layer) { // create the list of layers + features export function initLayersModal () { + // opening the modal builds it, so a closed one needs no update + if (!document.querySelector('#layers-modal.show')) { return } functions.e('#layers', e => { + // a rebuild would drop the text of a rename or query edit in progress + if (e.querySelector('input:focus, textarea:focus')) { return } + const expanded = new Set(Array.from(e.querySelectorAll('.layer-content:not(.hidden)'), + c => c.closest('.layer-item').dataset.layerId)) + const scroller = e.closest('.inline-scroll') + const scrollTop = scroller.scrollTop dom.initTooltips(e) e.innerHTML = '' const template = document.querySelector('#layer-item-template') + const geojsonLayers = layers.filter(l => l.type === 'geojson') layers.forEach(layer => { let features = layer?.geojson?.features || [] const layerElement = template.cloneNode(true) @@ -317,65 +372,69 @@ export function initLayersModal () { e.appendChild(layerElement) // visibility toggle for all layers const visBtn = layerElement.querySelector('button.layer-visibility') - const visBtnMobile = layerElement.querySelector('button.layer-visibility-mobile') visBtn.classList.remove('hidden') - visBtnMobile.classList.remove('hidden') // Icon represents the STATE (eye = shown, eye-slash = hidden) if (layer.show === false) { visBtn.querySelector('i').classList.replace('bi-eye', 'bi-eye-slash') - visBtnMobile.querySelector('i').classList.replace('bi-eye', 'bi-eye-slash') visBtn.setAttribute('title', window.__('Show layer')) - visBtnMobile.querySelector('.layer-visibility-text').textContent = window.__('Show layer') layerElement.classList.add('layer-dimmed') } else { visBtn.querySelector('i').classList.replace('bi-eye-slash', 'bi-eye') - visBtnMobile.querySelector('i').classList.replace('bi-eye-slash', 'bi-eye') visBtn.setAttribute('title', window.__('Hide layer')) - visBtnMobile.querySelector('.layer-visibility-text').textContent = window.__('Hide layer') } - const isFirstGeojsonLayer = layer.type === 'geojson' && - layers.filter(l => l.type === 'geojson').indexOf(layer) === 0 + // With one geojson layer there is nothing to choose + if (layer.type === 'geojson' && window.gon.map_mode === 'rw' && geojsonLayers.length > 1) { + let pen = document.createElement('i') + if (layer === activeLayer()) { + pen.classList.add('bi', 'bi-pencil-fill', 'small', 'me-2') + pen.title = window.__('New features go to this layer') + } else { + const icon = pen + icon.classList.add('bi', 'bi-pencil', 'small') + pen = document.createElement('button') + pen.type = 'button' + pen.classList.add('btn', 'btn-link', 'p-0', 'me-2', 'text-reset', 'opacity-50', 'layer-active') + pen.title = window.__('Add new features to this layer') + pen.dataset.action = 'click->map--layers#activateLayer' + pen.appendChild(icon) + } + pen.dataset.toggle = 'tooltip' + pen.dataset.bsTrigger = 'hover' + head.parentNode.appendChild(pen) + } + if (layer.type === 'geojson' && window.gon.map_mode === 'rw') { + layerElement.querySelector('button.layer-rename').classList.remove('hidden') + } - // Show delete button for all layers except the first geojson layer - if ((layer.type !== 'geojson' || !isFirstGeojsonLayer) && window.gon.map_mode === "rw") { + // Show delete button for all layers except the last geojson layer + if ((layer.type !== 'geojson' || geojsonLayers.length > 1) && window.gon.map_mode === "rw") { layerElement.querySelector('button.layer-delete').classList.remove('hidden') - layerElement.querySelector('button.layer-delete-mobile').classList.remove('hidden') } // Show refresh button only for overpass and wikipedia layers that are visible if ((layer.type === 'overpass' || layer.type === 'wikipedia') && layer.show !== false) { layerElement.querySelector('button.layer-refresh').classList.remove('hidden') - layerElement.querySelector('button.layer-refresh-mobile').classList.remove('hidden') } if (layer.type === 'overpass') { if (window.gon.map_mode === "rw" && layer.show !== false) { layerElement.querySelector('button.layer-edit').classList.remove('hidden') - layerElement.querySelector('button.layer-edit-mobile').classList.remove('hidden') } } if (layer.type === 'raster') { if (window.gon.map_mode === "rw" && layer.show !== false) { layerElement.querySelector('button.layer-edit').classList.remove('hidden') - layerElement.querySelector('button.layer-edit-mobile').classList.remove('hidden') } } - // Simplify mobile UI when only visibility toggle is available const dropdown = layerElement.querySelector('.layer-actions-dropdown') - const visibleMobileActions = dropdown.querySelectorAll('.dropdown-item:not(.hidden)').length - if (visibleMobileActions <= 1) { - dropdown.classList.add('hidden') - const inlineButtons = layerElement.querySelector('.text-nowrap') - inlineButtons.classList.remove('d-none', 'd-sm-inline') - inlineButtons.classList.add('d-inline') - } + if (!dropdown.querySelector('.dropdown-item:not(.hidden)')) { dropdown.classList.add('hidden') } // build the feature lists after the modal painted, one task per layer setTimeout(() => renderLayerFeatures(layerElement, layer), 0) // expand layer items when there is only one layer - if (layers.length === 1) { - e.querySelector('.layer-content').classList.remove('hidden') + if (layers.length === 1 || expanded.has(layer.id)) { + layerElement.querySelector('.layer-content').classList.remove('hidden') layerElement.querySelector('.layer-item-header i').classList.remove('bi-caret-right-fill') layerElement.querySelector('.layer-item-header i').classList.add('bi-caret-down-fill') } @@ -388,6 +447,8 @@ export function initLayersModal () { layerElement.querySelector('.layer-content').appendChild(newNode) } }) + // queued after the feature list tasks, so the lists have their height again + setTimeout(() => { scroller.scrollTop = scrollTop }, 0) }) } diff --git a/app/javascript/maplibre/edit.js b/app/javascript/maplibre/edit.js index c80790605..27ee97f9d 100644 --- a/app/javascript/maplibre/edit.js +++ b/app/javascript/maplibre/edit.js @@ -18,7 +18,7 @@ import { map, mapProperties, onMapClickAfterLayers } from 'maplibre/map'; import { initDirections, resetDirections } from 'maplibre/routing/directions'; import { getRouteUpdate, updateElevation } from 'maplibre/routing/openrouteservice'; import { editStyles, initializeEditStyles } from 'maplibre/styles/edit_styles'; -import { addUndoState, redo, undo } from 'maplibre/undo'; +import { addUndoState, featurePlacement, redo, undo } from 'maplibre/undo'; export let draw // The route feature that directions currently edits. Only directions sets it. @@ -473,8 +473,8 @@ async function handleUpdate (e) { export function handleDelete (e) { selectedRoute = null const deletedFeature = e.features[0] // Assuming one feature is deleted at a time + addUndoState('Feature deleted', deletedFeature, true, featurePlacement(deletedFeature.id)) destroyFeature(deletedFeature.id) - addUndoState('Feature deleted', deletedFeature) resetDirections() resetControls() status(window.__('%{type} deleted').replace('%{type}', featureLabel(deletedFeature))) diff --git a/app/javascript/maplibre/layers/layers.js b/app/javascript/maplibre/layers/layers.js index a3c080de4..99b7c0e66 100644 --- a/app/javascript/maplibre/layers/layers.js +++ b/app/javascript/maplibre/layers/layers.js @@ -8,6 +8,7 @@ import { createLayerInstance } from 'maplibre/layers/factory' import { map, setLoadedMapUpdatedAt, sortLayers } from 'maplibre/map' export let layers // Layer instances: GeoJSONLayer, OverpassLayer, WikipediaLayer, BasemapLayer +let activeLayerId = null // geojson layer that receives new features, per browser session // Cached promise to ensure initializeLayers only runs once let initializePromise = null @@ -36,6 +37,7 @@ export function resetInitializationState() { layers.forEach(layer => layer.cleanup()) } resetLevels() + activeLayerId = null initializePromise = null layers = null setLoadedMapUpdatedAt(null) @@ -279,11 +281,22 @@ export function upsert (updatedFeature, layerId) { } } +// Falls back to the first geojson layer when none was picked or the picked one got deleted +export function activeLayer () { + return layers?.find(l => l.type === 'geojson' && l.id === activeLayerId) || + layers?.find(l => l.type === 'geojson') +} + +export function setActiveLayer (id) { + activeLayerId = id + const name = activeLayer()?.name || window.__('Layer elements') + status(window.__('New features go to layer %{name}').replace('%{name}', functions.escapeHtml(name))) +} + export function addFeature (feature, layerId) { feature.properties.id = feature.id - // A remote feature carries its layer id; local edits have none and go to the first geojson layer - const layer = layers?.find(l => l.type === 'geojson' && l.id === layerId) || - layers?.find(l => l.type === 'geojson') + // A remote feature carries its layer id; local edits have none and go to the active layer + const layer = layers?.find(l => l.type === 'geojson' && l.id === layerId) || activeLayer() if (!layer) { console.error('No geojson layer to add feature ' + feature.id + ' to') return diff --git a/app/javascript/maplibre/layers/overpass/overpass.js b/app/javascript/maplibre/layers/overpass/overpass.js index 979759e4b..86366690b 100644 --- a/app/javascript/maplibre/layers/overpass/overpass.js +++ b/app/javascript/maplibre/layers/overpass/overpass.js @@ -118,7 +118,7 @@ export class OverpassLayer extends Layer { console.error('Failed to fetch overpass for ' + this.id, this.layer.query, error.message) // return if layer is gone (likely page change) if (!layers || !layers.includes(this)) { return } - status(window.__('Failed to load layer %{name}').replace('%{name}', this.layer.name), 'error') + status(window.__('Failed to load layer %{name}').replace('%{name}', functions.escapeHtml(this.layer.name)), 'error') // Set empty geojson so layer can still render this.layer.geojson = { type: 'FeatureCollection', features: [] } this.render() diff --git a/app/javascript/maplibre/layers/wikipedia.js b/app/javascript/maplibre/layers/wikipedia.js index 32863ec6d..80d925e96 100644 --- a/app/javascript/maplibre/layers/wikipedia.js +++ b/app/javascript/maplibre/layers/wikipedia.js @@ -43,7 +43,7 @@ export class WikipediaLayer extends Layer { console.error('Failed to fetch wikipedia for ' + this.id, error) // return if layer is gone (likely page change) if (!layers || !layers.includes(this)) { return } - status(window.__('Failed to load layer %{name}').replace('%{name}', this.layer.name), 'error') + status(window.__('Failed to load layer %{name}').replace('%{name}', functions.escapeHtml(this.layer.name)), 'error') // Set empty geojson so layer can still render this.layer.geojson = { type: 'FeatureCollection', features: [] } this.render() diff --git a/app/javascript/maplibre/undo.js b/app/javascript/maplibre/undo.js index d8215ac27..4f6c50f26 100644 --- a/app/javascript/maplibre/undo.js +++ b/app/javascript/maplibre/undo.js @@ -3,7 +3,7 @@ import { status } from 'helpers/status' import { select, selectedRoute } from 'maplibre/edit' import { getFeatureTypeName } from 'maplibre/feature' import { showFeatureDetails } from 'maplibre/feature/details' -import { addFeature, destroyFeature, getFeature, layers, renderLayers } from 'maplibre/layers/layers' +import { addFeature, destroyFeature, getFeature, getLayer, layers, renderLayers } from 'maplibre/layers/layers' import { removeGeoJSONSource, setLayerVisibility } from 'maplibre/map' import { resetDirections } from 'maplibre/routing/directions' import { initLayersModal } from 'maplibre/controls/shared' @@ -31,9 +31,10 @@ export function clearUndoHistory() { } } -export function addUndoState(type, state, clearRedo = true) { +// meta holds entry data that is not part of the state, e.g. the placement of a removed layer +export function addUndoState(type, state, clearRedo = true, meta = {}) { // Deep clone to avoid mutation - undoStack.push({ type: type, state: JSON.parse(JSON.stringify(state)) }) + undoStack.push({ ...meta, type: type, state: JSON.parse(JSON.stringify(state)) }) // console.log('Updated undo stack', undoStack) showUndoButton() if (clearRedo) { @@ -43,9 +44,9 @@ export function addUndoState(type, state, clearRedo = true) { updateTooltips() } -function addRedoState(type, state) { +function addRedoState(type, state, meta = {}) { // Deep clone to avoid mutation - redoStack.push({ type: type, state: JSON.parse(JSON.stringify(state)) }) + redoStack.push({ ...meta, type: type, state: JSON.parse(JSON.stringify(state)) }) console.log('Updated redo stack', redoStack) showRedoButton() updateTooltips() @@ -102,7 +103,7 @@ export function undo() { // console.log('Undo state: ' + JSON.stringify(prevState)) const handler = undoHandlers[prevState.type] if (!handler) { console.warn('Cannot undo ', prevState); return } - handler(prevState) + if (handler(prevState) === false) { undoStack.push(prevState); return } status(window.__('Undone: %{type}').replace('%{type}', typeLabel(prevState))) renderLayers('geojson', true) keepSelection() @@ -117,7 +118,7 @@ export function redo() { // console.log('Next state: ' + JSON.stringify(nextState)) const handler = redoHandlers[nextState.type] if (!handler) { console.warn('Cannot redo ', nextState); return } - handler(nextState) + if (handler(nextState) === false) { redoStack.push(nextState); return } status(window.__('Redone: %{type}').replace('%{type}', typeLabel(nextState))) renderLayers('geojson', true) keepSelection() @@ -157,11 +158,16 @@ function redoFeatureUpdate(nextState) { } } +// A feature that comes back goes to its old layer, not to the active one +export function featurePlacement(featureId) { + return { layerId: getLayer(featureId, 'geojson')?.id } +} + function undoFeatureDelete(prevState) { let feature = getFeature(prevState.state.id, 'geojson') if (!feature) { addRedoState(prevState.type, prevState.state) - addFeature(prevState.state) + addFeature(prevState.state, prevState.layerId) sendMessage('new_feature', prevState.state) } else { console.warn('Feature with id ' + prevState.state.id + ' still present in layer geojson') @@ -171,7 +177,7 @@ function undoFeatureDelete(prevState) { function redoFeatureDelete(nextState) { let feature = getFeature(nextState.state.id, 'geojson') if (feature) { - addUndoState(nextState.type, feature, false) + addUndoState(nextState.type, feature, false, featurePlacement(feature.id)) destroyFeature(nextState.state.id) sendMessage('delete_feature', { id: nextState.state.id }) } else { @@ -182,7 +188,7 @@ function redoFeatureDelete(nextState) { function undoFeatureAdded(prevState) { let feature = getFeature(prevState.state.id, 'geojson') if (feature) { - addRedoState(prevState.type, feature, false) + addRedoState(prevState.type, feature, featurePlacement(feature.id)) destroyFeature(prevState.state.id) sendMessage('delete_feature', { id: prevState.state.id }) } else { @@ -194,7 +200,7 @@ function redoFeatureAdded(nextState) { let feature = getFeature(nextState.state.id, 'geojson') if (!feature) { addUndoState(nextState.type, nextState.state, false) - addFeature(nextState.state) + addFeature(nextState.state, nextState.layerId) sendMessage('new_feature', nextState.state) } else { console.warn('Feature with id ' + nextState.state.id + ' still present in layer geojson') @@ -204,7 +210,7 @@ function redoFeatureAdded(nextState) { function undoTrackAdded(prevState) { let feature = getFeature(prevState.state.id, 'geojson') if (feature) { - addRedoState(prevState.type, feature, false) + addRedoState(prevState.type, feature, featurePlacement(feature.id)) destroyFeature(prevState.state.id) resetDirections() sendMessage('delete_feature', { id: prevState.state.id }) @@ -221,11 +227,41 @@ function getFullLayerData(layer) { } } +// The state of a 'Layer updated' entry. Features and their order change without an undo entry, +// so an undo that restored them would drop later moves and additions. +export function layerSettings(layer) { + const { feature_order: _featureOrder, ...settings } = layer.toJSON() + return settings +} + +// The server can only place a new layer first or last, so that is all a removed layer remembers +export function layerPlacement(layer) { + return { first: layers.indexOf(layer) === 0 } +} + +// The server refuses to delete it, because new features need a geojson layer +function isLastGeojsonLayer(layer) { + if (layer.type !== 'geojson' || layers.filter(l => l.type === 'geojson').length > 1) { return false } + status(window.__('Cannot delete the last layer'), 'warning') + return true +} + +function restoreLayer(entry) { + const newLayer = createLayerInstance(entry.state) + newLayer.localData = true // renders from memory, see GeoJSONLayer.loadData + if (entry.first) { layers.unshift(newLayer) } else { layers.push(newLayer) } + initLayersModal() + initializeLayerSources(newLayer.id) + initializeLayerStyles(newLayer.id) + sendMessage('new_layer', entry.first ? { ...entry.state, first: true } : entry.state) +} + // Layer operations function undoLayerAdded(prevState) { const layer = layers.find(l => l.id === prevState.state.id) if (layer) { - addRedoState(prevState.type, getFullLayerData(layer)) + if (isLastGeojsonLayer(layer)) { return false } + addRedoState(prevState.type, getFullLayerData(layer), layerPlacement(layer)) layer.cleanup() layers.splice(layers.indexOf(layer), 1) removeGeoJSONSource(layer.sourceId) @@ -240,13 +276,7 @@ function redoLayerAdded(nextState) { const layer = layers.find(l => l.id === nextState.state.id) if (!layer) { addUndoState(nextState.type, nextState.state, false) - const newLayer = createLayerInstance(nextState.state) - newLayer.localData = true // renders from memory, see GeoJSONLayer.loadData - layers.push(newLayer) - initLayersModal() - initializeLayerSources(newLayer.id) - initializeLayerStyles(newLayer.id) - sendMessage('new_layer', nextState.state) + restoreLayer(nextState) } else { console.warn('Layer with id ' + nextState.state.id + ' already exists') } @@ -256,13 +286,7 @@ function undoLayerDeleted(prevState) { const layer = layers.find(l => l.id === prevState.state.id) if (!layer) { addRedoState(prevState.type, prevState.state) - const newLayer = createLayerInstance(prevState.state) - newLayer.localData = true // renders from memory, see GeoJSONLayer.loadData - layers.push(newLayer) - initLayersModal() - initializeLayerSources(newLayer.id) - initializeLayerStyles(newLayer.id) - sendMessage('new_layer', prevState.state) + restoreLayer(prevState) } else { console.warn('Layer with id ' + prevState.state.id + ' still exists') } @@ -271,7 +295,8 @@ function undoLayerDeleted(prevState) { function redoLayerDeleted(nextState) { const layer = layers.find(l => l.id === nextState.state.id) if (layer) { - addUndoState(nextState.type, getFullLayerData(layer), false) + if (isLastGeojsonLayer(layer)) { return false } + addUndoState(nextState.type, getFullLayerData(layer), false, layerPlacement(layer)) layer.cleanup() layers.splice(layers.indexOf(layer), 1) removeGeoJSONSource(layer.sourceId) @@ -285,7 +310,7 @@ function redoLayerDeleted(nextState) { function undoLayerUpdated(prevState) { const layer = layers.find(l => l.id === prevState.state.id) if (layer) { - addRedoState(prevState.type, getFullLayerData(layer)) + addRedoState(prevState.type, layerSettings(layer)) // Update layer properties using the layer's update method layer.update(prevState.state) setLayerVisibility(layer.sourceId, layer.show) @@ -299,7 +324,7 @@ function undoLayerUpdated(prevState) { function redoLayerUpdated(nextState) { const layer = layers.find(l => l.id === nextState.state.id) if (layer) { - addUndoState(nextState.type, getFullLayerData(layer), false) + addUndoState(nextState.type, layerSettings(layer), false) // Update layer properties using the layer's update method layer.update(nextState.state) setLayerVisibility(layer.sourceId, layer.show) diff --git a/app/views/maps/modals/_layers.haml b/app/views/maps/modals/_layers.haml index f9143b6e1..fc2c165dc 100644 --- a/app/views/maps/modals/_layers.haml +++ b/app/views/maps/modals/_layers.haml @@ -50,6 +50,15 @@ %span.d-sm-none= _("Add") %ul#query-dropdown.dropdown-menu{ "aria-labelledby": "newLayerDropdown" } + %li + %button.dropdown-item{ + type: "button", + data: { action: "click->map--layers#createGeojsonLayer" } + } + %i.bi.bi-plus-lg.me-2 + = _("New layer") + %li + %hr.dropdown-divider %li %button.dropdown-item{ type: "button", @@ -146,36 +155,27 @@ %span.w-100.link{ data: { action: "click->map--layers#toggleLayerList" }} %i.bi.bi-caret-right-fill %span.mapforge-font.layer-name.me-2= _("Layer name") - -# Desktop (and mobile when there is only one): inline buttons - %span.text-nowrap.d-none.d-sm-inline.me-2 + -# Frequent toggles inline, rare actions in the menu + %span.text-nowrap %button.hidden.ms-2.btn.btn-secondary.btn-layer-actions.layer-visibility{ data: { action: "click->map--layers#toggleLayerVisibility", "toggle": 'tooltip', "bs-trigger": 'hover' }, title: _("Hide layer") } %i.bi.bi-eye %button.hidden.btn.btn-secondary.btn-layer-actions.layer-refresh.ms-2{ data: { action: "click->map--layers#refreshLayer", "toggle": 'tooltip', "bs-trigger": 'hover' }, title: _("Refresh layer for current view") } %i.bi.bi-arrow-clockwise.reload-icon - %button.hidden.btn.btn-secondary.btn-layer-actions.layer-edit.ms-2{ data: { action: "click->map--layers#toggleEdit", "toggle": 'tooltip', "bs-trigger": 'hover' }, title: _("Edit query") } - %i.bi.bi-pencil-square - %button.hidden.btn.btn-orange.btn-layer-actions.layer-delete.ms-2{ data: { action: "click->map--layers#deleteLayer", "toggle": 'tooltip', "bs-trigger": 'hover' }, title: _("Delete Layer") } - %i.bi.bi-trash - -# Mobile: dropdown menu - .dropdown.d-sm-none.d-inline.layer-actions-dropdown + .dropdown.d-inline.layer-actions-dropdown -# fixed strategy: the menu escapes the scrollable modal body without turning off its overflow - %button.btn.btn-secondary.btn-layer-actions.ms-2.me-2{ type: "button", "data-bs-toggle": "dropdown", "aria-expanded": "false", "data-bs-popper-config": '{"strategy":"fixed"}' } + %button.btn.btn-layer-actions.btn-layer-menu.ms-2.me-2{ type: "button", "data-bs-toggle": "dropdown", "aria-expanded": "false", "data-bs-popper-config": '{"strategy":"fixed"}' } %i.bi.bi-three-dots-vertical %ul.dropdown-menu.dropdown-menu-end %li - %button.hidden.dropdown-item.layer-visibility-mobile{ type: "button", data: { action: "click->map--layers#toggleLayerVisibility" } } - %i.bi.bi-eye.me-2 - %span.layer-visibility-text= _("Hide layer") - %li - %button.hidden.dropdown-item.layer-refresh-mobile{ type: "button", data: { action: "click->map--layers#refreshLayer" } } - %i.bi.bi-arrow-clockwise.me-2 - = _("Refresh") + %button.hidden.dropdown-item.layer-rename{ type: "button", data: { action: "click->map--layers#renameLayer" } } + %i.bi.bi-input-cursor-text.me-2 + = _("Rename") %li - %button.hidden.dropdown-item.layer-edit-mobile{ type: "button", data: { action: "click->map--layers#toggleEdit" } } + %button.hidden.dropdown-item.layer-edit{ type: "button", data: { action: "click->map--layers#toggleEdit" } } %i.bi.bi-pencil-square.me-2 = _("Edit query") %li - %button.hidden.dropdown-item.text-danger.layer-delete-mobile{ type: "button", data: { action: "click->map--layers#deleteLayer" } } + %button.hidden.dropdown-item.text-danger.layer-delete{ type: "button", data: { action: "click->map--layers#deleteLayer" } } %i.bi.bi-trash.me-2 = _("Delete") diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e25a0dab7..ea2006668 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -75,7 +75,7 @@ services: - ./volumes:/volumes:z mongo: - image: mongo:8.2 + image: mongo:8.3 volumes: - ./volumes/mongodb:/data/db:z depends_on: diff --git a/locale/app.pot b/locale/app.pot index 8615262ae..126c01417 100644 --- a/locale/app.pot +++ b/locale/app.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: app 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-20 21:30+0200\n" -"PO-Revision-Date: 2026-09-20 21:30+0200\n" +"POT-Creation-Date: 2026-09-26 23:10+0200\n" +"PO-Revision-Date: 2026-09-26 23:10+0200\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" @@ -26,7 +26,7 @@ msgstr "" msgid "%{count} views" msgstr "" -#: ../app/javascript/maplibre/map.js:622 ../app/javascript/maplibre/undo.js:58 +#: ../app/javascript/maplibre/layers/layers.js:307 ../app/javascript/maplibre/undo.js:60 msgid "%{type} added" msgstr "" @@ -34,23 +34,23 @@ msgstr "" msgid "%{type} analysis" msgstr "" -#: ../app/javascript/controllers/feature/modal_controller.js:403 ../app/javascript/controllers/map/context_menu_controller.js:125 +#: ../app/javascript/controllers/feature/modal_controller.js:414 ../app/javascript/controllers/map/context_menu_controller.js:125 msgid "%{type} copied to clipboard" msgstr "" -#: ../app/javascript/maplibre/edit.js:401 ../app/javascript/maplibre/undo.js:59 +#: ../app/javascript/maplibre/edit.js:480 ../app/javascript/maplibre/undo.js:61 msgid "%{type} deleted" msgstr "" -#: ../app/javascript/maplibre/undo.js:57 +#: ../app/javascript/maplibre/undo.js:59 msgid "%{type} property update" msgstr "" -#: ../app/javascript/maplibre/undo.js:56 +#: ../app/javascript/maplibre/undo.js:58 msgid "%{type} update" msgstr "" -#: ../app/javascript/maplibre/map.js:650 +#: ../app/javascript/maplibre/layers/layers.js:335 msgid "%{type} updated" msgstr "" @@ -82,11 +82,11 @@ msgstr "" msgid "Add OpenStreetMap layers" msgstr "" -#: ../app/javascript/controllers/feature/modal_controller.js:305 ../app/javascript/controllers/map/settings_controller.js:209 +#: ../app/javascript/controllers/feature/modal_controller.js:316 ../app/javascript/controllers/map/settings_controller.js:209 msgid "Add a description text" msgstr "" -#: ../app/views/maps/modals/_settings.haml:26 ../app/views/maps/modals/feature/_edit_ui.haml:23 +#: ../app/views/maps/modals/_settings.haml:26 ../app/views/maps/modals/feature/_edit_ui.haml:40 msgid "Add description" msgstr "" @@ -94,15 +94,19 @@ msgstr "" msgid "Add layer" msgstr "" -#: ../app/views/frontpage/index.haml:94 +#: ../app/javascript/maplibre/controls/shared.js:397 +msgid "Add new features to this layer" +msgstr "" + +#: ../app/views/frontpage/index.haml:89 msgid "Add pictures, customize colors, symbols, labels" msgstr "" -#: ../app/views/frontpage/index.haml:92 +#: ../app/views/frontpage/index.haml:87 msgid "Add pictures, customize colors, symbols, labels, (3D) polygons, indoor maps and more" msgstr "" -#: ../app/views/frontpage/index.haml:84 +#: ../app/views/frontpage/index.haml:79 msgid "Add your own data:" msgstr "" @@ -110,11 +114,11 @@ msgstr "" msgid "Added feature %{index}/%{total}" msgstr "" -#: ../app/javascript/controllers/map/layers_controller.js:181 +#: ../app/javascript/controllers/map/layers_controller.js:180 msgid "Added image" msgstr "" -#: ../app/javascript/maplibre/layers/overpass/overpass.js:254 +#: ../app/javascript/maplibre/layers/osm_description.js:143 msgid "Address" msgstr "" @@ -158,15 +162,15 @@ msgstr "" msgid "Background map" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:100 +#: ../app/views/maps/modals/feature/_edit_ui.haml:117 msgid "Background:" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:35 +#: ../app/views/maps/modals/feature/_edit_ui.haml:52 msgid "Banner" msgstr "" -#: ../app/views/maps/modals/_layers.haml:134 +#: ../app/views/maps/modals/_layers.haml:143 msgid "Basemap Info (experimental)" msgstr "" @@ -174,11 +178,11 @@ msgstr "" msgid "Basement %{n}" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:213 +#: ../app/views/maps/modals/feature/_edit_ui.haml:230 msgid "Bicycle" msgstr "" -#: ../app/javascript/maplibre/edit.js:178 +#: ../app/javascript/maplibre/edit.js:47 msgid "Bicycle Mode: Click on the map to set waypoints, double click to finish" msgstr "" @@ -190,7 +194,7 @@ msgstr "" msgid "Bicycle Touring!" msgstr "" -#: ../app/views/maps/modals/_settings.haml:58 ../app/javascript/maplibre/feature.js:386 +#: ../app/views/maps/modals/_settings.haml:58 ../app/javascript/maplibre/feature.js:76 msgid "Bicycle route" msgstr "" @@ -198,7 +202,7 @@ msgstr "" msgid "Bicycle-focused map highlighting cycle routes, paths and bike infrastructure." msgstr "" -#: ../app/javascript/maplibre/feature.js:339 +#: ../app/javascript/maplibre/feature/edit_ui.js:79 msgid "Big dots" msgstr "" @@ -206,11 +210,11 @@ msgstr "" msgid "Bookmark the edit link of this map, or %{login_link} to add it to your account." msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:82 +#: ../app/views/maps/modals/feature/_edit_ui.haml:99 msgid "Border color:" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:69 +#: ../app/views/maps/modals/feature/_edit_ui.haml:86 msgid "Border width:" msgstr "" @@ -262,11 +266,15 @@ msgstr "" msgid "Can Edit" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:216 +#: ../app/javascript/maplibre/undo.js:245 +msgid "Cannot delete the last layer" +msgstr "" + +#: ../app/views/maps/modals/feature/_edit_ui.haml:233 msgid "Car" msgstr "" -#: ../app/views/maps/modals/_settings.haml:61 ../app/javascript/maplibre/feature.js:389 +#: ../app/views/maps/modals/_settings.haml:61 ../app/javascript/maplibre/feature.js:77 msgid "Car route" msgstr "" @@ -278,19 +286,19 @@ msgstr "" msgid "Changelog" msgstr "" -#: ../app/javascript/maplibre/feature.js:340 +#: ../app/javascript/maplibre/feature/edit_ui.js:80 msgid "Checker" msgstr "" -#: ../app/views/frontpage/index.haml:82 +#: ../app/views/frontpage/index.haml:98 msgid "Choose your base map: Streets, Satellite, Topographic, Biking" msgstr "" -#: ../app/views/frontpage/index.haml:80 +#: ../app/views/frontpage/index.haml:96 msgid "Choose your base map: Streets, Satellite, Topographic, Biking, Watercolor, 3D houses etc." msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:142 +#: ../app/views/maps/modals/feature/_edit_ui.haml:159 msgid "Circle" msgstr "" @@ -314,7 +322,7 @@ msgstr "" msgid "Click to copy html to embed in your website" msgstr "" -#: ../app/javascript/maplibre/feature.js:87 +#: ../app/javascript/maplibre/feature/details.js:31 msgid "Click to toggle format" msgstr "" @@ -326,11 +334,11 @@ msgstr "" msgid "Collaboration" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:84 +#: ../app/views/maps/modals/feature/_edit_ui.haml:101 msgid "Color" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:91 +#: ../app/views/maps/modals/feature/_edit_ui.haml:108 msgid "Color:" msgstr "" @@ -358,7 +366,7 @@ msgstr "" msgid "Connection error" msgstr "" -#: ../app/javascript/maplibre/map.js:503 +#: ../app/javascript/maplibre/styles/terrain.js:101 msgid "Contour lines added to map" msgstr "" @@ -370,7 +378,7 @@ msgstr "" msgid "Contributing" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:202 +#: ../app/views/maps/modals/feature/_edit_ui.haml:219 msgid "Convert to route" msgstr "" @@ -382,7 +390,7 @@ msgstr "" msgid "Converting track to %{profile} route..." msgstr "" -#: ../app/javascript/controllers/feature/modal_controller.js:410 ../app/javascript/controllers/map/settings_controller.js:57 +#: ../app/javascript/controllers/feature/modal_controller.js:421 ../app/javascript/controllers/map/settings_controller.js:57 msgid "Coordinates copied to clipboard" msgstr "" @@ -394,11 +402,11 @@ msgstr "" msgid "Copy %{type}" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:206 +#: ../app/views/maps/modals/feature/_edit_ui.haml:223 msgid "Copy and transform this line into a routed track with waypoints, elevation and surface info." msgstr "" -#: ../app/javascript/maplibre/feature.js:90 +#: ../app/javascript/maplibre/feature/details.js:34 msgid "Copy coordinates to clipboard" msgstr "" @@ -442,11 +450,11 @@ msgstr "" msgid "Cross platform" msgstr "" -#: ../app/javascript/maplibre/feature.js:334 +#: ../app/javascript/maplibre/feature/edit_ui.js:74 msgid "Crosshatch" msgstr "" -#: ../app/views/maps/modals/_layers.haml:96 +#: ../app/views/maps/modals/_layers.haml:105 msgid "Custom Overpass query" msgstr "" @@ -454,7 +462,7 @@ msgstr "" msgid "Custom artistic map style with a unique, hand-crafted color palette." msgstr "" -#: ../app/views/maps/modals/_layers.haml:59 +#: ../app/views/maps/modals/_layers.haml:68 msgid "Custom raster layer" msgstr "" @@ -470,15 +478,11 @@ msgstr "" msgid "Default view when opening this map." msgstr "" -#: ../app/views/maps/modals/_layers.haml:180 ../app/views/maps/modals/feature/_edit_ui.haml:239 ../app/javascript/maplibre/controls/context_menu.js:157 +#: ../app/views/maps/modals/_layers.haml:180 ../app/views/maps/modals/feature/_edit_ui.haml:256 ../app/javascript/maplibre/controls/context_menu.js:157 msgid "Delete" msgstr "" -#: ../app/views/maps/modals/_layers.haml:157 -msgid "Delete Layer" -msgstr "" - -#: ../app/views/maps/modals/feature/_edit_ui.haml:237 +#: ../app/views/maps/modals/feature/_edit_ui.haml:254 msgid "Delete feature" msgstr "" @@ -490,7 +494,7 @@ msgstr "" msgid "Delete midpoint" msgstr "" -#: ../app/javascript/maplibre/map.js:660 +#: ../app/javascript/maplibre/layers/layers.js:356 msgid "Deleting %{type}" msgstr "" @@ -506,7 +510,7 @@ msgstr "" msgid "Detailed vector map with landmarks, buildings and points of interest." msgstr "" -#: ../app/views/maps/modals/_feature.haml:20 ../app/javascript/maplibre/layers/overpass/overpass.js:255 +#: ../app/views/maps/modals/_feature.haml:20 ../app/javascript/maplibre/layers/osm_description.js:144 msgid "Details" msgstr "" @@ -526,7 +530,7 @@ msgstr "" msgid "Dismiss" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:194 +#: ../app/views/maps/modals/feature/_edit_ui.haml:211 msgid "Distance markers:" msgstr "" @@ -550,7 +554,7 @@ msgstr "" msgid "Don't show again for this map" msgstr "" -#: ../app/javascript/maplibre/feature.js:338 +#: ../app/javascript/maplibre/feature/edit_ui.js:78 msgid "Dots" msgstr "" @@ -574,20 +578,32 @@ msgstr "" msgid "Download map" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:8 +#: ../app/views/maps/modals/feature/_edit_ui.haml:25 msgid "Drag or add waypoints to reroute" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:5 +#: ../app/views/maps/modals/feature/_edit_ui.haml:20 ../app/views/maps/modals/feature/_edit_ui.haml:21 +msgid "Drag points to reshape" +msgstr "" + +#: ../app/views/maps/modals/feature/_edit_ui.haml:22 +msgid "Drag the line to move it" +msgstr "" + +#: ../app/views/maps/modals/feature/_edit_ui.haml:19 msgid "Drag the point to move it" msgstr "" -#: ../app/javascript/maplibre/map.js:133 -msgid "Drag to rotate map, click to reset north" +#: ../app/views/maps/modals/feature/_edit_ui.haml:23 +msgid "Drag the polygon to move it" +msgstr "" + +#: ../app/views/maps/modals/feature/_edit_ui.haml:24 +msgid "Drag the polygon to rotate it" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:6 ../app/views/maps/modals/feature/_edit_ui.haml:7 -msgid "Drag vertices to reshape" +#: ../app/javascript/maplibre/map.js:136 +msgid "Drag to rotate map, click to reset north" msgstr "" #: ../app/javascript/maplibre/controls/buttons/lines.js:74 @@ -614,11 +630,11 @@ msgstr "" msgid "Draw polygon (o)" msgstr "" -#: ../app/views/frontpage/index.haml:90 +#: ../app/views/frontpage/index.haml:85 msgid "Draw shapes and style them:" msgstr "" -#: ../app/views/frontpage/index.haml:97 +#: ../app/views/frontpage/index.haml:93 msgid "Dynamic layers: Use %{overpass_link} to add custom layers from OpenStreetMap data" msgstr "" @@ -626,7 +642,7 @@ msgstr "" msgid "Edit" msgstr "" -#: ../app/javascript/maplibre/layers/overpass/overpass.js:258 +#: ../app/javascript/maplibre/layers/osm_description.js:147 msgid "Edit in OSM" msgstr "" @@ -638,7 +654,7 @@ msgstr "" msgid "Edit mode" msgstr "" -#: ../app/views/maps/modals/_layers.haml:155 ../app/views/maps/modals/_layers.haml:176 +#: ../app/views/maps/modals/_layers.haml:176 msgid "Edit query" msgstr "" @@ -670,7 +686,7 @@ msgstr "" msgid "Enter name / Search queries" msgstr "" -#: ../app/javascript/maplibre/routing/openrouteservice.js:246 +#: ../app/javascript/maplibre/routing/openrouteservice.js:283 msgid "Error building route" msgstr "" @@ -682,15 +698,15 @@ msgstr "" msgid "Error detecting location: %{message}" msgstr "" -#: ../app/javascript/controllers/feature/edit_controller.js:81 ../app/javascript/controllers/feature/modal_controller.js:328 +#: ../app/javascript/controllers/feature/edit_controller.js:82 ../app/javascript/controllers/feature/modal_controller.js:339 msgid "Error updating %{type}" msgstr "" -#: ../app/javascript/controllers/feature/edit_controller.js:96 +#: ../app/javascript/controllers/feature/edit_controller.js:97 msgid "Error updating %{type} geometry" msgstr "" -#: ../app/javascript/maplibre/feature.js:506 +#: ../app/javascript/maplibre/feature/image_upload.js:16 msgid "Error uploading image" msgstr "" @@ -702,11 +718,11 @@ msgstr "" msgid "Failed to copy" msgstr "" -#: ../app/javascript/maplibre/layers/overpass/overpass.js:120 ../app/javascript/maplibre/layers/wikipedia.js:46 +#: ../app/javascript/maplibre/layers/overpass/overpass.js:121 ../app/javascript/maplibre/layers/wikipedia.js:46 msgid "Failed to load layer %{name}" msgstr "" -#: ../app/javascript/maplibre/feature.js:382 +#: ../app/javascript/maplibre/feature.js:73 msgid "Feature" msgstr "" @@ -726,15 +742,15 @@ msgstr "" msgid "File imported" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:106 +#: ../app/views/maps/modals/feature/_edit_ui.haml:123 msgid "Fill" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:102 +#: ../app/views/maps/modals/feature/_edit_ui.haml:119 msgid "Fill color" msgstr "" -#: ../app/javascript/maplibre/map.js:129 +#: ../app/javascript/maplibre/map.js:132 msgid "Find my location" msgstr "" @@ -762,7 +778,7 @@ msgstr "" msgid "Following position" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:210 +#: ../app/views/maps/modals/feature/_edit_ui.haml:227 msgid "Foot" msgstr "" @@ -786,7 +802,7 @@ msgstr "" msgid "Geometry" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:231 +#: ../app/views/maps/modals/feature/_edit_ui.haml:248 msgid "Geometry JSON" msgstr "" @@ -806,7 +822,7 @@ msgstr "" msgid "Gravel" msgstr "" -#: ../app/javascript/maplibre/feature.js:337 +#: ../app/javascript/maplibre/feature/edit_ui.js:77 msgid "Grid" msgstr "" @@ -814,15 +830,15 @@ msgstr "" msgid "Ground" msgstr "" -#: ../app/javascript/maplibre/feature.js:333 +#: ../app/javascript/maplibre/feature/edit_ui.js:73 msgid "Hatch" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:185 +#: ../app/views/maps/modals/feature/_edit_ui.haml:202 msgid "Height:" msgstr "" -#: ../app/views/maps/modals/_layers.haml:151 ../app/views/maps/modals/_layers.haml:168 ../app/javascript/controllers/map/layers_controller.js:319 ../app/javascript/maplibre/controls/shared.js:316 ../app/javascript/maplibre/controls/shared.js:317 +#: ../app/views/maps/modals/_layers.haml:160 ../app/javascript/controllers/map/layers_controller.js:346 ../app/javascript/maplibre/controls/shared.js:383 msgid "Hide layer" msgstr "" @@ -842,11 +858,11 @@ msgstr "" msgid "High-resolution satellite imagery without any labels or overlays." msgstr "" -#: ../app/javascript/maplibre/feature.js:392 +#: ../app/javascript/maplibre/feature.js:78 msgid "Hiking route" msgstr "" -#: ../app/javascript/maplibre/map.js:441 +#: ../app/javascript/maplibre/styles/terrain.js:40 msgid "Hillshade added to map" msgstr "" @@ -874,15 +890,15 @@ msgstr "" msgid "Ice" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:165 ../app/views/maps/modals/feature/_edit_ui.haml:170 +#: ../app/views/maps/modals/feature/_edit_ui.haml:182 ../app/views/maps/modals/feature/_edit_ui.haml:187 msgid "Icon" msgstr "" -#: ../app/views/maps/modals/_image_viewer.haml:2 ../app/views/maps/modals/feature/_edit_ui.haml:113 ../app/views/maps/modals/feature/_edit_ui.haml:177 +#: ../app/views/maps/modals/_image_viewer.haml:2 ../app/views/maps/modals/feature/_edit_ui.haml:130 ../app/views/maps/modals/feature/_edit_ui.haml:194 msgid "Image" msgstr "" -#: ../app/javascript/controllers/feature/edit_controller.js:407 ../app/javascript/controllers/feature/edit_controller.js:479 +#: ../app/javascript/controllers/feature/edit_controller.js:408 ../app/javascript/controllers/feature/edit_controller.js:480 msgid "Image exceeds 15MB" msgstr "" @@ -902,11 +918,11 @@ msgstr "" msgid "Import data (gpx, kml, image)" msgstr "" -#: ../app/views/frontpage/index.haml:88 +#: ../app/views/frontpage/index.haml:83 msgid "Import from GeoJSON, GPX and KML" msgstr "" -#: ../app/views/frontpage/index.haml:86 +#: ../app/views/frontpage/index.haml:81 msgid "Import your data from GeoJSON, GPX and KML" msgstr "" @@ -922,23 +938,23 @@ msgstr "" msgid "Keyboard Shortcuts" msgstr "" -#: ../app/javascript/maplibre/undo.js:62 +#: ../app/javascript/maplibre/undo.js:64 msgid "Layer added" msgstr "" -#: ../app/javascript/maplibre/undo.js:63 +#: ../app/javascript/maplibre/undo.js:65 msgid "Layer deleted" msgstr "" -#: ../app/javascript/maplibre/controls/shared.js:269 +#: ../app/javascript/controllers/map/layers_controller.js:267 ../app/javascript/maplibre/controls/shared.js:341 ../app/javascript/maplibre/layers/layers.js:292 msgid "Layer elements" msgstr "" -#: ../app/views/maps/modals/_layers.haml:148 ../app/views/maps/modals/_layers.haml:202 +#: ../app/views/maps/modals/_layers.haml:157 ../app/views/maps/modals/_layers.haml:202 msgid "Layer name" msgstr "" -#: ../app/javascript/maplibre/undo.js:64 +#: ../app/javascript/maplibre/undo.js:66 msgid "Layer updated" msgstr "" @@ -966,15 +982,15 @@ msgstr "" msgid "Light, minimal grayscale style ideal for data visualization overlays." msgstr "" -#: ../app/javascript/maplibre/feature.js:397 +#: ../app/javascript/maplibre/feature.js:83 msgid "Line" msgstr "" -#: ../app/javascript/maplibre/edit.js:196 +#: ../app/javascript/maplibre/edit.js:63 msgid "Line Mode: Click on the map to draw a line" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:81 +#: ../app/views/maps/modals/feature/_edit_ui.haml:98 msgid "Line color:" msgstr "" @@ -982,7 +998,7 @@ msgstr "" msgid "Line cut into 2 segments" msgstr "" -#: ../app/javascript/maplibre/feature.js:335 +#: ../app/javascript/maplibre/feature/edit_ui.js:75 msgid "Lines" msgstr "" @@ -994,11 +1010,11 @@ msgstr "" msgid "Load for this area" msgstr "" -#: ../app/javascript/maplibre/map.js:729 +#: ../app/javascript/maplibre/map.js:531 msgid "Loaded base map %{name}" msgstr "" -#: ../app/javascript/maplibre/feature.js:190 +#: ../app/javascript/maplibre/feature/details.js:132 msgid "Loading description..." msgstr "" @@ -1010,15 +1026,15 @@ msgstr "" msgid "Loading..." msgstr "" -#: ../app/javascript/maplibre/layers/overpass/overpass.js:254 +#: ../app/javascript/maplibre/layers/osm_description.js:143 msgid "Location" msgstr "" -#: ../app/javascript/maplibre/controls/shared.js:464 +#: ../app/javascript/maplibre/controls/shared.js:542 msgid "Location (https only)" msgstr "" -#: ../app/javascript/maplibre/map.js:130 +#: ../app/javascript/maplibre/map.js:133 msgid "Location not available" msgstr "" @@ -1034,7 +1050,7 @@ msgstr "" msgid "Logout" msgstr "" -#: ../app/views/maps/modals/_share.haml:82 ../app/javascript/maplibre/map.js:131 +#: ../app/views/maps/modals/_share.haml:82 ../app/javascript/maplibre/map.js:134 msgid "Map" msgstr "" @@ -1058,7 +1074,7 @@ msgstr "" msgid "Map layers" msgstr "" -#: ../app/javascript/maplibre/map.js:132 +#: ../app/javascript/maplibre/map.js:135 msgid "Map marker" msgstr "" @@ -1066,7 +1082,7 @@ msgstr "" msgid "Map owners have this map listed in their account with full permissions." msgstr "" -#: ../app/javascript/maplibre/map.js:79 +#: ../app/javascript/maplibre/map.js:78 msgid "Map properties updated" msgstr "" @@ -1102,11 +1118,11 @@ msgstr "" msgid "Mapforge: Create and share your own maps online" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:171 ../app/javascript/maplibre/feature.js:377 +#: ../app/views/maps/modals/feature/_edit_ui.haml:188 ../app/javascript/maplibre/feature/edit_ui.js:117 msgid "Max. 5MB" msgstr "" -#: ../app/javascript/maplibre/map.js:315 +#: ../app/javascript/maplibre/map.js:301 msgid "Maximum zoom level %{zoom} reached" msgstr "" @@ -1122,7 +1138,7 @@ msgstr "" msgid "Minimalist, neutral vector style with subtle colors and light labels." msgstr "" -#: ../app/javascript/maplibre/map.js:319 +#: ../app/javascript/maplibre/map.js:305 msgid "Minimum zoom level %{zoom} reached" msgstr "" @@ -1146,6 +1162,10 @@ msgstr "" msgid "Most viewed" msgstr "" +#: ../app/views/maps/modals/feature/_edit_ui.haml:10 +msgid "Move" +msgstr "" + #: ../app/javascript/maplibre/styles/basemaps.js:238 msgid "Muted Graybeard style with an added wintery, snow-covered terrain effect." msgstr "" @@ -1158,7 +1178,7 @@ msgstr "" msgid "Name:" msgstr "" -#: ../app/javascript/maplibre/feature.js:377 +#: ../app/javascript/maplibre/feature/edit_ui.js:117 msgid "Needs a polygon with four corners" msgstr "" @@ -1166,6 +1186,18 @@ msgstr "" msgid "Neutral MapTiler style designed as a clean backdrop for data visualization." msgstr "" +#: ../app/javascript/maplibre/layers/layers.js:293 +msgid "New features go to layer %{name}" +msgstr "" + +#: ../app/javascript/maplibre/controls/shared.js:390 +msgid "New features go to this layer" +msgstr "" + +#: ../app/views/maps/modals/_layers.haml:59 ../app/javascript/controllers/map/layers_controller.js:398 +msgid "New layer" +msgstr "" + #: ../app/views/maps/_list_filter.haml:1 msgid "Newest" msgstr "" @@ -1182,7 +1214,7 @@ msgstr "" msgid "No ads, no tracking, no lock-in" msgstr "" -#: ../app/javascript/maplibre/controls/shared.js:370 +#: ../app/javascript/maplibre/controls/shared.js:446 msgid "No elements in this layer" msgstr "" @@ -1194,15 +1226,15 @@ msgstr "" msgid "No signup needed. Every map has a view link and an edit link. Share them with anyone, list your map in the gallery, or keep it private. Embed it on other sites." msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:160 ../app/views/maps/modals/feature/_edit_ui.haml:164 +#: ../app/views/maps/modals/feature/_edit_ui.haml:177 ../app/views/maps/modals/feature/_edit_ui.haml:181 msgid "None" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:31 +#: ../app/views/maps/modals/feature/_edit_ui.haml:48 msgid "Not shown" msgstr "" -#: ../app/views/maps/modals/_layers.haml:140 +#: ../app/views/maps/modals/_layers.haml:149 msgid "OSM Info (experimental)" msgstr "" @@ -1218,11 +1250,11 @@ msgstr "" msgid "Only LineString tracks can be converted to routes" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:131 +#: ../app/views/maps/modals/feature/_edit_ui.haml:148 msgid "Opacity:" msgstr "" -#: ../app/views/frontpage/index.haml:132 ../app/views/frontpage/index.haml:132 +#: ../app/views/frontpage/index.haml:132 ../app/views/frontpage/index.haml:135 msgid "Open Source" msgstr "" @@ -1234,7 +1266,7 @@ msgstr "" msgid "OpenStreetMap data layers (Overpass)" msgstr "" -#: ../app/views/maps/modals/_layers.haml:126 +#: ../app/views/maps/modals/_layers.haml:135 msgid "OpenStreetMap indoor" msgstr "" @@ -1250,7 +1282,7 @@ msgstr "" msgid "Ownership link copied" msgstr "" -#: ../app/javascript/maplibre/edit.js:167 +#: ../app/javascript/maplibre/edit.js:39 msgid "Paint Mode: Click on the map to start drawing, release to finish" msgstr "" @@ -1262,7 +1294,7 @@ msgstr "" msgid "Paste %{type}" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:116 ../app/views/maps/modals/feature/_edit_ui.haml:121 +#: ../app/views/maps/modals/feature/_edit_ui.haml:133 ../app/views/maps/modals/feature/_edit_ui.haml:138 msgid "Pattern" msgstr "" @@ -1274,7 +1306,7 @@ msgstr "" msgid "Paving stones" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:146 +#: ../app/views/maps/modals/feature/_edit_ui.haml:163 msgid "Pin" msgstr "" @@ -1282,27 +1314,31 @@ msgstr "" msgid "Plan your ride and navigate with compass mode and elevation profile." msgstr "" +#: ../app/views/frontpage/index.haml:36 ../app/views/frontpage/index.haml:38 +msgid "Playground" +msgstr "" + #: ../app/views/maps/modals/_confirmation.haml:5 msgid "Please confirm" msgstr "" -#: ../app/views/maps/modals/_layers.haml:32 ../app/views/maps/modals/_settings.haml:73 ../app/views/maps/modals/feature/_edit_ui.haml:171 ../app/javascript/maplibre/feature.js:376 +#: ../app/views/maps/modals/_layers.haml:32 ../app/views/maps/modals/_settings.haml:73 ../app/views/maps/modals/feature/_edit_ui.haml:188 ../app/javascript/maplibre/feature/edit_ui.js:116 msgid "Please log in to upload images" msgstr "" -#: ../app/javascript/maplibre/feature.js:396 +#: ../app/javascript/maplibre/feature.js:82 msgid "Point" msgstr "" -#: ../app/javascript/maplibre/edit.js:189 +#: ../app/javascript/maplibre/edit.js:55 msgid "Point Mode: Click on the map to place a marker" msgstr "" -#: ../app/javascript/maplibre/feature.js:398 +#: ../app/javascript/maplibre/feature.js:84 msgid "Polygon" msgstr "" -#: ../app/javascript/maplibre/edit.js:192 +#: ../app/javascript/maplibre/edit.js:59 msgid "Polygon Mode: Click on the map to draw a polygon" msgstr "" @@ -1342,11 +1378,11 @@ msgstr "" msgid "Real-time: Map changes are synchronized to all users" msgstr "" -#: ../app/javascript/controllers/feature/edit_controller.js:67 ../app/javascript/controllers/map/context_menu_controller.js:132 +#: ../app/javascript/controllers/feature/edit_controller.js:68 ../app/javascript/controllers/map/context_menu_controller.js:132 msgid "Really delete this %{type}?" msgstr "" -#: ../app/javascript/controllers/map/layers_controller.js:448 +#: ../app/javascript/controllers/map/layers_controller.js:494 msgid "Really delete this layer?" msgstr "" @@ -1358,7 +1394,7 @@ msgstr "" msgid "Recently viewed Maps" msgstr "" -#: ../app/javascript/maplibre/undo.js:338 +#: ../app/javascript/maplibre/undo.js:364 msgid "Redo" msgstr "" @@ -1366,35 +1402,43 @@ msgstr "" msgid "Redo last change" msgstr "" -#: ../app/javascript/maplibre/undo.js:338 +#: ../app/javascript/maplibre/undo.js:364 msgid "Redo: %{type}" msgstr "" -#: ../app/javascript/maplibre/undo.js:120 +#: ../app/javascript/maplibre/undo.js:122 msgid "Redone: %{type}" msgstr "" -#: ../app/views/maps/modals/_layers.haml:172 -msgid "Refresh" +#: ../app/views/maps/modals/_layers.haml:162 +msgid "Refresh layer for current view" msgstr "" -#: ../app/views/maps/modals/_layers.haml:153 -msgid "Refresh layer for current view" +#: ../app/views/maps/modals/_layers.haml:172 +msgid "Rename" msgstr "" #: ../app/views/shared/_footer.html.haml:41 msgid "Report issue" msgstr "" +#: ../app/views/maps/modals/feature/_edit_ui.haml:6 +msgid "Reshape" +msgstr "" + #: ../app/javascript/maplibre/controls/context_menu.js:72 msgid "Reverse track" msgstr "" -#: ../app/javascript/maplibre/edit.js:172 +#: ../app/javascript/maplibre/edit.js:43 msgid "Road Mode: Click on the map to set waypoints, double click to finish" msgstr "" -#: ../app/javascript/maplibre/undo.js:65 +#: ../app/views/maps/modals/feature/_edit_ui.haml:14 +msgid "Rotate" +msgstr "" + +#: ../app/javascript/maplibre/undo.js:67 msgid "Route created from GPX" msgstr "" @@ -1414,7 +1458,7 @@ msgstr "" msgid "Routing segment %{index}/%{total}..." msgstr "" -#: ../app/views/frontpage/index.haml:98 +#: ../app/views/frontpage/index.haml:90 msgid "Routing: Plan routes for walking, bicycle and car, with elevation profile" msgstr "" @@ -1434,7 +1478,7 @@ msgstr "" msgid "Satellite imagery combined with street names and road labels overlay." msgstr "" -#: ../app/javascript/controllers/feature/modal_controller.js:334 +#: ../app/javascript/controllers/feature/modal_controller.js:345 msgid "Saving %{type}" msgstr "" @@ -1470,7 +1514,7 @@ msgstr "" msgid "Set current view as default" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:140 +#: ../app/views/maps/modals/feature/_edit_ui.haml:157 msgid "Shape:" msgstr "" @@ -1498,7 +1542,7 @@ msgstr "" msgid "Show in public gallery" msgstr "" -#: ../app/javascript/controllers/map/layers_controller.js:319 ../app/javascript/maplibre/controls/shared.js:310 ../app/javascript/maplibre/controls/shared.js:311 +#: ../app/javascript/controllers/map/layers_controller.js:346 ../app/javascript/maplibre/controls/shared.js:379 msgid "Show layer" msgstr "" @@ -1510,11 +1554,11 @@ msgstr "" msgid "Show navigation" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:29 +#: ../app/views/maps/modals/feature/_edit_ui.haml:46 msgid "Show on map:" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:18 +#: ../app/views/maps/modals/feature/_edit_ui.haml:35 msgid "Show title on map" msgstr "" @@ -1528,7 +1572,7 @@ msgstr[1] "" msgid "Single tracks or points of the map can get exported from their details view." msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:51 +#: ../app/views/maps/modals/feature/_edit_ui.haml:68 msgid "Size:" msgstr "" @@ -1544,7 +1588,7 @@ msgstr "" msgid "Snapping waypoints to road network..." msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:39 ../app/views/maps/modals/feature/_edit_ui.haml:150 +#: ../app/views/maps/modals/feature/_edit_ui.haml:56 ../app/views/maps/modals/feature/_edit_ui.haml:167 msgid "Square" msgstr "" @@ -1576,7 +1620,7 @@ msgstr "" msgid "Style Help" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:222 +#: ../app/views/maps/modals/feature/_edit_ui.haml:239 msgid "Style JSON" msgstr "" @@ -1588,7 +1632,7 @@ msgstr "" msgid "Surface" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:157 +#: ../app/views/maps/modals/feature/_edit_ui.haml:174 msgid "Symbol:" msgstr "" @@ -1596,11 +1640,11 @@ msgstr "" msgid "Terms of Service" msgstr "" -#: ../app/javascript/maplibre/map.js:424 +#: ../app/javascript/maplibre/styles/terrain.js:24 msgid "Terrain added to map" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:43 +#: ../app/views/maps/modals/feature/_edit_ui.haml:60 msgid "Text bubble" msgstr "" @@ -1624,7 +1668,7 @@ msgstr "" msgid "The file is not valid XML" msgstr "" -#: ../app/javascript/maplibre/feature.js:554 +#: ../app/javascript/maplibre/feature/image_upload.js:66 msgid "The image contains GPS coordinates (%{coordinates}).
Do you want to place the marker there?" msgstr "" @@ -1636,7 +1680,7 @@ msgstr "" msgid "The map starts centered on your content." msgstr "" -#: ../app/javascript/maplibre/feature.js:336 +#: ../app/javascript/maplibre/feature/edit_ui.js:76 msgid "Thick lines" msgstr "" @@ -1644,7 +1688,7 @@ msgstr "" msgid "This browser cannot read KMZ archives. Please unzip it and import the .kml" msgstr "" -#: ../app/javascript/controllers/feature/edit_controller.js:664 +#: ../app/javascript/controllers/feature/edit_controller.js:665 msgid "This feature no longer exists" msgstr "" @@ -1664,11 +1708,11 @@ msgstr "" msgid "Thunderforest Cycle map with elevation contours and cycling infrastructure." msgstr "" -#: ../app/views/maps/modals/_layers.haml:186 ../app/views/maps/modals/_layers.haml:203 ../app/views/maps/modals/feature/_edit_ui.haml:14 +#: ../app/views/maps/modals/_layers.haml:186 ../app/views/maps/modals/_layers.haml:203 ../app/views/maps/modals/feature/_edit_ui.haml:31 msgid "Title" msgstr "" -#: ../app/javascript/maplibre/map.js:128 +#: ../app/javascript/maplibre/map.js:131 msgid "Toggle attribution" msgstr "" @@ -1684,7 +1728,7 @@ msgstr "" msgid "Tour planning with Mapforge:" msgstr "" -#: ../app/javascript/maplibre/undo.js:60 +#: ../app/javascript/maplibre/undo.js:62 msgid "Track added" msgstr "" @@ -1696,7 +1740,7 @@ msgstr "" msgid "Track reversed" msgstr "" -#: ../app/javascript/maplibre/undo.js:61 +#: ../app/javascript/maplibre/undo.js:63 msgid "Track update" msgstr "" @@ -1712,15 +1756,11 @@ msgstr "" msgid "Tracking with µlogger" msgstr "" -#: ../app/views/frontpage/index.haml:36 ../app/views/frontpage/index.haml:38 -msgid "Tutorial map" -msgstr "" - #: ../app/views/docs/tutorials.haml:20 ../app/views/shared/_footer.html.haml:14 ../app/views/shared/_header.html.haml:22 ../app/views/shared/_header.html.haml:42 msgid "Tutorials" msgstr "" -#: ../app/javascript/maplibre/undo.js:331 +#: ../app/javascript/maplibre/undo.js:357 msgid "Undo" msgstr "" @@ -1728,11 +1768,11 @@ msgstr "" msgid "Undo last change" msgstr "" -#: ../app/javascript/maplibre/undo.js:331 +#: ../app/javascript/maplibre/undo.js:357 msgid "Undo: %{type}" msgstr "" -#: ../app/javascript/maplibre/undo.js:105 +#: ../app/javascript/maplibre/undo.js:107 msgid "Undone: %{type}" msgstr "" @@ -1788,7 +1828,7 @@ msgstr "" msgid "Visible section" msgstr "" -#: ../app/javascript/maplibre/edit.js:184 +#: ../app/javascript/maplibre/edit.js:51 msgid "Walk Mode: Click on the map to set waypoints, double click to finish" msgstr "" @@ -1808,11 +1848,11 @@ msgstr "" msgid "What do you want to do?" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:60 +#: ../app/views/maps/modals/feature/_edit_ui.haml:77 msgid "Width:" msgstr "" -#: ../app/views/maps/modals/_layers.haml:118 +#: ../app/views/maps/modals/_layers.haml:127 msgid "Wikipedia articles" msgstr "" @@ -1864,11 +1904,11 @@ msgstr "" msgid "Your maps (%{count})" msgstr "" -#: ../app/javascript/maplibre/map.js:134 +#: ../app/javascript/maplibre/map.js:137 msgid "Zoom in" msgstr "" -#: ../app/javascript/maplibre/map.js:135 +#: ../app/javascript/maplibre/map.js:138 msgid "Zoom out" msgstr "" @@ -2016,6 +2056,10 @@ msgstr "" msgid "play_area" msgstr "" +#: ../app/views/frontpage/index.haml:23 +msgid "playground" +msgstr "" + #: ../app/javascript/maplibre/controls/search.js:124 msgid "police_station" msgstr "" @@ -2064,11 +2108,11 @@ msgstr "" msgid "tram|streetcar" msgstr "" -#: ../app/views/maps/modals/feature/_edit_ui.haml:88 ../app/views/maps/modals/feature/_edit_ui.haml:95 +#: ../app/views/maps/modals/feature/_edit_ui.haml:105 ../app/views/maps/modals/feature/_edit_ui.haml:112 msgid "transparent" msgstr "" -#: ../app/views/frontpage/index.haml:23 ../app/views/maps/my.haml:24 +#: ../app/views/maps/my.haml:24 msgid "tutorial map" msgstr "" @@ -2092,54 +2136,54 @@ msgstr "" msgid "your maps" msgstr "" -#: ../app/views/maps/modals/_layers.haml:100 +#: ../app/views/maps/modals/_layers.haml:109 msgid "🍻 Breweries" msgstr "" -#: ../app/views/maps/modals/_layers.haml:102 +#: ../app/views/maps/modals/_layers.haml:111 msgid "🏊 Swimming pools" msgstr "" -#: ../app/views/maps/modals/_layers.haml:101 +#: ../app/views/maps/modals/_layers.haml:110 msgid "🏕️ Camping" msgstr "" -#: ../app/views/maps/modals/_layers.haml:102 +#: ../app/views/maps/modals/_layers.haml:111 msgid "🏪 Food Shops" msgstr "" -#: ../app/views/maps/modals/_layers.haml:101 +#: ../app/views/maps/modals/_layers.haml:110 msgid "👨🏻‍🚒 Hydranten" msgstr "" -#: ../app/views/maps/modals/_layers.haml:102 +#: ../app/views/maps/modals/_layers.haml:111 msgid "🚆 Trains" msgstr "" -#: ../app/views/maps/modals/_layers.haml:100 +#: ../app/views/maps/modals/_layers.haml:109 msgid "🚇 Subway" msgstr "" -#: ../app/views/maps/modals/_layers.haml:101 +#: ../app/views/maps/modals/_layers.haml:110 msgid "🚒 Feuerwehr" msgstr "" -#: ../app/views/maps/modals/_layers.haml:100 +#: ../app/views/maps/modals/_layers.haml:109 msgid "🚰 Drinking water" msgstr "" -#: ../app/views/maps/modals/_layers.haml:101 +#: ../app/views/maps/modals/_layers.haml:110 msgid "🚴 Bicycle routes" msgstr "" -#: ../app/views/maps/modals/_layers.haml:101 +#: ../app/views/maps/modals/_layers.haml:110 msgid "🚶🏾 Hiking routes" msgstr "" -#: ../app/views/maps/modals/_layers.haml:100 +#: ../app/views/maps/modals/_layers.haml:109 msgid "🚻 Public toilets" msgstr "" -#: ../app/views/maps/modals/_layers.haml:102 +#: ../app/views/maps/modals/_layers.haml:111 msgid "🛜 Wifi" msgstr "" diff --git a/locale/de/app.po b/locale/de/app.po index 157608045..d0ce24588 100644 --- a/locale/de/app.po +++ b/locale/de/app.po @@ -74,6 +74,9 @@ msgstr "Beschreibung hinzufügen" msgid "Add layer" msgstr "Ebene hinzufügen" +msgid "Add new features to this layer" +msgstr "Neue Elemente zu dieser Ebene hinzufügen" + msgid "Add pictures, customize colors, symbols, labels" msgstr "Füge Bilder hinzu, passe Farben, Symbole und Beschriftungen an" @@ -200,6 +203,9 @@ msgstr "Route wird berechnet..." msgid "Can Edit" msgstr "Bearbeitbar" +msgid "Cannot delete the last layer" +msgstr "Die letzte Ebene kann nicht gelöscht werden" + msgid "Car" msgstr "Auto" @@ -359,9 +365,6 @@ msgstr "Standardansicht beim Öffnen dieser Karte." msgid "Delete" msgstr "Löschen" -msgid "Delete Layer" -msgstr "Ebene löschen" - msgid "Delete feature" msgstr "Objekt löschen" @@ -437,15 +440,24 @@ msgstr "Karte herunterladen" msgid "Drag or add waypoints to reroute" msgstr "Wegpunkte ziehen oder hinzufügen, um die Route neu zu berechnen" +msgid "Drag points to reshape" +msgstr "Punkte ziehen, um die Form zu ändern" + +msgid "Drag the line to move it" +msgstr "Linie ziehen, um sie zu verschieben" + msgid "Drag the point to move it" msgstr "Punkt ziehen, um ihn zu verschieben" +msgid "Drag the polygon to move it" +msgstr "Polygon ziehen, um es zu verschieben" + +msgid "Drag the polygon to rotate it" +msgstr "Polygon ziehen, um es zu drehen" + msgid "Drag to rotate map, click to reset north" msgstr "Ziehen zum Drehen der Karte, klicken für Norden" -msgid "Drag vertices to reshape" -msgstr "Stützpunkte ziehen, um die Form zu ändern" - msgid "Draw freehand (f)" msgstr "Freihand zeichnen (f)" @@ -863,6 +875,9 @@ msgstr "Mehr Karten in der Galerie" msgid "Most viewed" msgstr "Meistgesehen" +msgid "Move" +msgstr "Verschieben" + msgid "Muted Graybeard style with an added wintery, snow-covered terrain effect." msgstr "Gedeckter Graybeard-Stil mit winterlichem, schneebedecktem Gelände." @@ -878,6 +893,15 @@ msgstr "Benötigt ein Polygon mit vier Ecken" msgid "Neutral MapTiler style designed as a clean backdrop for data visualization." msgstr "Neutraler MapTiler-Stil als klarer Hintergrund für Datenvisualisierungen." +msgid "New features go to layer %{name}" +msgstr "Neue Elemente kommen in die Ebene %{name}" + +msgid "New features go to this layer" +msgstr "Neue Elemente kommen in diese Ebene" + +msgid "New layer" +msgstr "Neue Ebene" + msgid "Newest" msgstr "Neueste" @@ -1037,21 +1061,27 @@ msgstr "Wiederherstellen: %{type}" msgid "Redone: %{type}" msgstr "Wiederhergestellt: %{type}" -msgid "Refresh" -msgstr "Aktualisieren" - msgid "Refresh layer for current view" msgstr "Ebene für aktuellen Kartenausschnitt neu laden" +msgid "Rename" +msgstr "Umbenennen" + msgid "Report issue" msgstr "Fehler melden" +msgid "Reshape" +msgstr "Form ändern" + msgid "Reverse track" msgstr "Track umkehren" msgid "Road Mode: Click on the map to set waypoints, double click to finish" msgstr "Straßen-Modus: Auf die Karte klicken, um Wegpunkte zu setzen, Doppelklick zum Beenden" +msgid "Rotate" +msgstr "Drehen" + msgid "Route created from GPX" msgstr "Route aus GPX erstellt" @@ -1291,9 +1321,6 @@ msgstr "Position wird verfolgt" msgid "Tracking with µlogger" msgstr "Aufzeichnen mit µlogger" -msgid "Tutorial map" -msgstr "Tutorial-Karte" - msgid "Tutorials" msgstr "Tutorials" diff --git a/spec/channels/map_channel_spec.rb b/spec/channels/map_channel_spec.rb index 3b824be37..e190b7a7c 100644 --- a/spec/channels/map_channel_spec.rb +++ b/spec/channels/map_channel_spec.rb @@ -93,7 +93,62 @@ end end + describe "#new_feature" do + let(:second) { create(:layer, map: map) } + + # returns the id of the added feature + def add(**atts) + id = BSON::ObjectId.new.to_s + perform :new_feature, id:, map_id: map.private_id, type: "Feature", properties: {}, + geometry: { "type" => "Point", "coordinates" => [ 8.1, 47.2 ] }, **atts + id + end + + before { subscribe(map_id: map.private_id) } + + it "adds the feature to the first geojson layer without a layer id" do + second + expect(Feature.find(add).layer).to eq layer + end + + it "adds the feature to the given layer" do + expect(Feature.find(add(layer_id: second.id.to_s)).layer).to eq second + end + + it "rejects a layer of another map" do + other_layer = create(:map).layers.first + expect { add(layer_id: other_layer.id.to_s) }.to raise_error(/not found/) + expect(other_layer.features.count).to eq 0 + end + end + + describe "#delete_layer" do + before { subscribe(map_id: map.private_id) } + + it "refuses to delete the last geojson layer" do + expect { perform :delete_layer, id: layer.id.to_s, map_id: map.private_id } + .to raise_error(/last geojson layer/) + expect(map.reload.layers.count).to eq 1 + end + + it "deletes the first geojson layer while another one exists" do + create(:layer, map:, type: "geojson") + perform :delete_layer, id: layer.id.to_s, map_id: map.private_id + + expect(Layer.find_by(id: layer.id)).to be_nil + end + end + describe "#new_layer" do + it "puts the layer first with the first flag" do + subscribe(map_id: map.private_id) + layer_id = BSON::ObjectId.new.to_s + perform :new_layer, id: layer_id, map_id: map.private_id, type: "geojson", first: true + + expect(map.reload.layers.first.id.to_s).to eq layer_id + expect(map.layers.geojson.first.id.to_s).to eq layer_id + end + it "skips invalid features and keeps the valid ones" do subscribe(map_id: map.private_id) layer_id = BSON::ObjectId.new.to_s diff --git a/spec/features/admin_spec.rb b/spec/features/admin_spec.rb index 3618ea5d8..6a33b091e 100644 --- a/spec/features/admin_spec.rb +++ b/spec/features/admin_spec.rb @@ -8,8 +8,7 @@ before do create_list(:map, 3) - allow_any_instance_of(ActionController::Base).to receive(:session) - .and_return({ user_id: admin.id }) + sign_in(admin) visit admin_path end diff --git a/spec/features/feature_details_spec.rb b/spec/features/feature_details_spec.rb index 5b40ace2e..4ffda216f 100644 --- a/spec/features/feature_details_spec.rb +++ b/spec/features/feature_details_spec.rb @@ -3,9 +3,10 @@ describe "Feature details" do let(:feature) { create(:feature, :polygon_middle, title: "Poly Title") } let(:map) { create(:map, features: [ feature ]) } + let(:path) { map.private_map_path } before do - visit map.private_map_path + visit path expect_map_loaded end @@ -58,10 +59,14 @@ end context "cycling through overlapping features" do - let(:polygon) { create(:feature, :polygon_middle, title: "Poly") } - let(:point1) { create(:feature, :point_middle, title: "Point 1") } - let(:point2) { create(:feature, :point_middle, title: "Point 2") } - let(:map) { create(:map, features: [ polygon, point1, point2, create(:feature, :point_middle, title: "Hidden", properties: { "marker-size" => "150", "onclick" => false }) ]) } + let(:map) do + create(:map, features: [ + create(:feature, :polygon_middle, title: "Poly"), + create(:feature, :point_middle, title: "Point 1"), + create(:feature, :point_middle, title: "Point 2"), + create(:feature, :point_middle, title: "Hidden", properties: { "marker-size" => "150", "onclick" => false }) + ]) + end it "cycles through all overlapping features on repeated clicks" do titles = [] @@ -74,10 +79,7 @@ end context "in view mode" do - before do - visit map.public_map_path - expect_map_loaded - end + let(:path) { map.public_map_path } it "skips features with onclick false" do titles = [] @@ -92,6 +94,8 @@ end end + # the export responses are covered by the request spec spec/requests/maps_controller_spec.rb, + # these links get the feature id and title from JavaScript context "export" do let(:feature) { create(:feature, :polygon_middle, title: "Poly Title") } let(:map) { create(:map, features: [ feature ]) } @@ -109,16 +113,6 @@ it "has share gpx link" do expect(page).to have_link("GPX", href: "/m/" + map.public_id + "/feature/" + feature.id + ".gpx" + "/Poly_Title") end - - it "can download feature gpx export" do - visit "/m/" + map.public_id + "/feature/" + feature.id + ".gpx" + "/Poly_Title" - file = wait_for_download("Poly Title.gpx", timeout: 10) - expect(File.read(file).scan(/ "*", "Content-Type" => "application/json" }, - status: 200, - body: ors_file - ) - - visit map.private_map_path - expect_map_loaded + stub_fixture(:post, /api\.heigit\.org\/openrouteservice\/v2\/directions\/foot-hiking/, "ors_foot.json") + stub_fixture(:post, /api\.heigit\.org\/openrouteservice\/v2\/snap\/foot-hiking/, "ors_snap.json") + # the elevation api has its own domain, because of CORS + stub_fixture(:post, /api\.openrouteservice\.org\/elevation\/line/, "ors_elevation.json") end context "with empty map" do + before do + visit map.private_map_path + expect_map_loaded + end + it "can create foot track" do find(".mapbox-gl-draw_line").click find(".mapbox-gl-draw_foot").click @@ -64,36 +62,6 @@ let(:map) { create(:map, features: [ gpx_feature ], center: [ 11.048, 49.472 ], zoom: 13) } before do - # Mock ORS Snap API - snap_file = File.read(Rails.root.join("spec", "fixtures", "files", "ors_snap.json")) - CapybaraMock.stub_request( - :post, /api\.heigit\.org\/openrouteservice\/v2\/snap\/foot-hiking/ - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*", "Content-Type" => "application/json" }, - status: 200, - body: snap_file - ) - - # Mock ORS Directions API (reuse existing fixture) - ors_file = File.read(Rails.root.join("spec", "fixtures", "files", "ors_foot.json")) - CapybaraMock.stub_request( - :post, /api\.heigit\.org\/openrouteservice\/v2\/directions\/foot-hiking/ - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*", "Content-Type" => "application/json" }, - status: 200, - body: ors_file - ) - - # Mock ORS Elevation API (uses different domain due to CORS) - elevation_file = File.read(Rails.root.join("spec", "fixtures", "files", "ors_elevation.json")) - CapybaraMock.stub_request( - :post, /api\.openrouteservice\.org\/elevation\/line/ - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*", "Content-Type" => "application/json" }, - status: 200, - body: elevation_file - ) - # Load map and wait for it to be ready visit map.private_map_path expect_map_loaded diff --git a/spec/features/feature_edit_point_spec.rb b/spec/features/feature_edit_point_spec.rb index 34f8f7f9f..48fe5e1f7 100644 --- a/spec/features/feature_edit_point_spec.rb +++ b/spec/features/feature_edit_point_spec.rb @@ -337,32 +337,6 @@ end end - context "cycling through overlapping features" do - let(:polygon) { create(:feature, :polygon_middle, title: "Poly") } - let(:point1) { create(:feature, :point_middle, title: "Point 1") } - let(:point2) { create(:feature, :point_middle, title: "Point 2") } - let(:map) { create(:map, features: [ polygon, point1, point2 ]) } - - it "cycles through all overlapping features on repeated clicks" do - titles = [] - 3.times do - click_center_of_screen - expect(page).to have_css("#feature-details-modal.show") - titles << find("#feature-title").text - end - expect(titles.uniq).to contain_exactly("Poly", "Point 1", "Point 2") - end - end - - context "with cursor sharing" do - let(:map) { create(:map, name: "Share Cursor", share_cursor: true) } - - it "sends cursor positions" do - hover_coord(70, 70) - expect(page).to have_css("#maplibre-map") - end - end - context "with lost websocket" do it "disables edit buttons" do ActionCable.server.connections.each(&:close) diff --git a/spec/features/feature_edit_spec.rb b/spec/features/feature_edit_spec.rb index ae794eb0d..b68489622 100644 --- a/spec/features/feature_edit_spec.rb +++ b/spec/features/feature_edit_spec.rb @@ -4,12 +4,6 @@ include_context "with an editable map and an elevation stub" context "with empty map" do - it "shows feature edit buttons" do - expect(page).to have_css(".mapbox-gl-draw_line") - expect(page).to have_css(".mapbox-gl-draw_polygon") - expect(page).to have_css(".mapbox-gl-draw_point") - end - context "when adding features" do it "adding a point to the map" do find(".mapbox-gl-draw_point").click diff --git a/spec/features/keyboard_shortcuts_spec.rb b/spec/features/keyboard_shortcuts_spec.rb index 201c5e51e..0c5238332 100644 --- a/spec/features/keyboard_shortcuts_spec.rb +++ b/spec/features/keyboard_shortcuts_spec.rb @@ -9,7 +9,7 @@ let(:map) { create(:map, features: [ point ]) } before do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + sign_in(user) visit map.private_map_path expect_map_loaded end diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb index b47c1ef54..7706ae1cb 100644 --- a/spec/features/login_spec.rb +++ b/spec/features/login_spec.rb @@ -6,7 +6,7 @@ let(:user) { create(:user) } before do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + sign_in(user) visit maps_path end diff --git a/spec/features/map_create_spec.rb b/spec/features/map_create_spec.rb deleted file mode 100644 index 0fc88bc66..000000000 --- a/spec/features/map_create_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -require "rails_helper" - -describe "Create map" do - let(:user) { create :user } - - before do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - visit root_path - end - - it "shows description" do - click_link "Create map" - expect_map_loaded - end -end diff --git a/spec/features/map_layers_spec.rb b/spec/features/map_layers_spec.rb index c18efa251..2c816c518 100644 --- a/spec/features/map_layers_spec.rb +++ b/spec/features/map_layers_spec.rb @@ -3,13 +3,6 @@ describe "Map layers" do include_context "with an editable map and an overpass stub" - context "with initial map rendering" do - it "shows map layers button" do - expect(page).to have_css("#maplibre-map") - expect(page).to have_css(".maplibregl-ctrl-layers") - end - end - context "feature listing" do before do feature @@ -23,6 +16,17 @@ expect(page).to have_text("Feature 1") end + it "updates the list on remote feature changes" do + layer_item = "#layer-list-#{map.layers.first.id}" + create(:feature, :point, title: "Feature 2", layer: map.layers.first) + expect(page).to have_css("#{layer_item} li", text: "Feature 2") + expect(page).to have_css("#{layer_item} .layer-feature-count", text: "(2)") + + feature.destroy + expect(page).to have_no_css("#{layer_item} li", text: "Feature 1") + expect(page).to have_css("#{layer_item} .layer-feature-count", text: "(1)") + end + it "flies to feature on click" do find("li[data-feature-id='#{feature.id}']").click # flyTo is finished when the feature details are shown @@ -33,17 +37,65 @@ end end + context "geojson layer" do + it "adds a new layer as first layer" do + find(".maplibregl-ctrl-layers").click + click_button "Add layer" + within("#query-dropdown") do + find("button.dropdown-item", text: "New layer").trigger("click") + end + wait_for { Layer.find_by(name: "New layer") }.not_to be_nil + expect(page).to have_text("New features go to layer New layer") + expect(map.reload.layers.first.name).to eq "New layer" + end + + context "with a second layer" do + let(:map) { create(:map, name: "Layers test").tap { |m| create(:layer, map: m, name: "Second") } } + let(:second) { map.layers.find_by(name: "Second") } + + it "adds new features to the active layer" do + find(".maplibregl-ctrl-layers").click + find("#layer-list-#{second.id} button.layer-active").click + expect(page).to have_text("New features go to layer Second") + + find(".maplibregl-ctrl-layers").click + find(".mapbox-gl-draw_point").click + click_coord("#maplibre-map", 50, 50) + wait_for { Feature.point.last&.layer }.to eq(second) + end + + it "can delete a geojson layer, but not the last one" do + first = map.layers.geojson.first + find(".maplibregl-ctrl-layers").click + open_layer_menu(first.id) + accept_alert do + find("#layer-list-#{first.id} .layer-delete").click + end + wait_for { Layer.find(first.id) }.to be_nil + expect(page).to have_no_css(".layer-delete", visible: true) + end + end + + it "can rename a geojson layer" do + layer = map.layers.geojson.first + find(".maplibregl-ctrl-layers").click + open_layer_menu(layer.id) + find("#layer-list-#{layer.id} .layer-rename").click + find("#layer-list-#{layer.id} .layer-name-input").send_keys("Trails", :enter) + wait_for { layer.reload.name }.to eq("Trails") + expect(page).to have_css(".layer-name", text: "Trails") + end + end + context "overpass layer" do + let(:map) { create(:map, name: "Layers test").tap { |m| m.layers << layer } } + let(:layer) { create(:layer, :overpass, name: "opass") } + before do - map.layers << layer - visit map.private_map_path - expect_map_loaded expect_overpass_loaded find(".maplibregl-ctrl-layers").click end - let(:layer) { create(:layer, :overpass, name: "opass") } - it "Shows overpass layer" do expect(page).to have_text("opass(1)") end @@ -59,6 +111,7 @@ it "can edit overpass layer" do expect(page).to have_text("opass") + open_layer_menu(layer.id) find(".layer-edit").click expect(page).to have_field("overpass-query", with: layer.query) fill_in "overpass-query", with: "nwr[highway=bus];out center 1;" @@ -68,8 +121,9 @@ it "can delete overpass layer" do expect(page).to have_text("opass") + open_layer_menu(layer.id) accept_alert do - find('.btn-layer-actions.layer-delete').click + find(".layer-delete").click end wait_for { Layer.find(layer.id) }.to be_nil end diff --git a/spec/features/map_layers_visibility_spec.rb b/spec/features/map_layers_visibility_spec.rb index bf6b5c037..28e2e39af 100644 --- a/spec/features/map_layers_visibility_spec.rb +++ b/spec/features/map_layers_visibility_spec.rb @@ -4,7 +4,11 @@ include_context "with an editable map and an overpass stub" context "overpass comment styling" do - subject(:map) { create(:map, name: "Styling test", center: [ 11.077, 49.447 ], zoom: 15) } + subject(:map) do + create(:map, name: "Styling test", center: [ 11.077, 49.447 ], zoom: 15).tap { |m| m.layers << layer } + end + + let(:overpass_fixture) { "overpass_line_polygon.json" } let(:layer) do create(:layer, :overpass, name: "styled", query: <<~QUERY) @@ -18,18 +22,6 @@ end before do - CapybaraMock.clear_stubs - overpass_file = File.read(Rails.root.join("spec", "fixtures", "files", "overpass_line_polygon.json")) - CapybaraMock.stub_request( - :post, "https://overpass-api.de/api/interpreter" - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*" }, - status: 200, - body: overpass_file - ) - map.layers << layer - visit map.private_map_path - expect_map_loaded expect_overpass_loaded end @@ -88,11 +80,10 @@ end end - context "layer visibility mobile dropdown", :phone do + context "layer visibility on phone", :phone do it "toggles layer visibility from show to hidden" do layer_id = map.layers.first.id - # When only visibility toggle is available, it shows inline instead of dropdown find(".maplibregl-ctrl-layers").click expect(page).to have_css("button.layer-visibility i.bi-eye") find("button.layer-visibility").click @@ -118,11 +109,10 @@ end context "layer visibility in readonly mode" do - it "does not sync visibility change to server" do - map.layers.first.update!(show: false) - visit map.public_map_path - expect_map_loaded + let(:map) { create(:map, name: "Layers test").tap { |m| m.layers.first.update!(show: false) } } + let(:map_path) { map.public_map_path } + it "does not sync visibility change to server" do find(".maplibregl-ctrl-layers").click expect(page).to have_css("button.layer-visibility i.bi-eye-slash") find("button.layer-visibility").click @@ -156,15 +146,13 @@ end context "overpass layer visibility" do + let(:map) { create(:map, name: "Layers test").tap { |m| m.layers << layer } } + let(:layer) { create(:layer, :overpass, name: "opass") } + before do - map.layers << layer - visit map.private_map_path - expect_map_loaded expect_overpass_loaded end - let(:layer) { create(:layer, :overpass, name: "opass") } - it "toggles overpass layer visibility via websocket" do layer.update!(show: false) expect_layer_visibility(layer.id, false, 'overpass') @@ -184,17 +172,9 @@ end context "wikipedia layer" do + # the layer requests the articles only once it is added, so the stub can follow the visit before do - wikipedia_file = File.read(Rails.root.join("spec", "fixtures", "files", "wikipedia.json")) - CapybaraMock.stub_request( - :get, /de\.wikipedia\.org\/w\/api\.php/ - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*" }, - status: 200, - body: wikipedia_file - ) - visit map.private_map_path - expect_map_loaded + stub_fixture(:get, /de\.wikipedia\.org\/w\/api\.php/, "wikipedia.json") end it "can add wikipedia layer" do @@ -208,15 +188,16 @@ end context "basemap change preserves layers" do + let(:map) do + create(:map, name: "Layers test").tap do |m| + create(:feature, :point, title: "Basemap Test Feature", layer: m.layers.first) + end + end + before do stub_const("Map::BASE_MAPS", [ "test", "test2" ] + Map::BASE_MAPS) - feature - visit map.private_map_path - expect_map_loaded end - let(:feature) { create(:feature, :point, title: "Basemap Test Feature", layer: map.layers.first) } - it "features remain visible after basemap change" do layer_id = map.layers.first.id expect_layer_visibility(layer_id, true) @@ -229,21 +210,17 @@ context "copy to my layer context menu" do # center matches the feature in spec/fixtures/files/overpass.json - subject(:map) { create(:map, name: 'Copy test', center: [ 12.3651437, 44.9165141 ], zoom: 15) } + subject(:map) do + create(:map, name: "Copy test", center: [ 12.3651437, 44.9165141 ], zoom: 15).tap { |m| m.layers << layer } + end let(:layer) { create(:layer, :overpass, name: "opass") } before do - map.layers << layer + expect_overpass_loaded end context "in read-write mode" do - before do - visit map.private_map_path - expect_map_loaded - expect_overpass_loaded - end - it "successfully copies overpass feature to user's geojson layer" do expect(Feature.count).to eq(0) @@ -266,11 +243,7 @@ end context "in read-only mode" do - before do - visit map.public_map_path - expect_map_loaded - expect_overpass_loaded - end + let(:map_path) { map.public_map_path } it "does not show copy to my layer option on overpass feature right-click" do center = center_of_screen @@ -283,12 +256,8 @@ # MapLibre drops features only when a new load succeeds, so a failed reload leaves them # on the map. The list must show the same. context "when the layer reload fails after a reconnect" do - let(:feature) { create(:feature, :point, title: "Kept Feature", layer: map.layers.first) } - - before do - feature - visit map.private_map_path - expect_map_loaded + let(:map) do + create(:map, name: "Layers test").tap { |m| create(:feature, :point, title: "Kept Feature", layer: m.layers.first) } end it "keeps the features in the layer list", :skip_console_errors do @@ -307,14 +276,19 @@ end context "reordering features" do - let!(:f1) { create(:feature, :point, title: "Reorder A", layer: map.layers.first) } - let!(:f2) { create(:feature, :point_middle, title: "Reorder B", layer: map.layers.first) } - let!(:f3) { create(:feature, :polygon_middle, title: "Reorder C", layer: map.layers.first) } + # the features exist before the visit, so they render server side in created_at order + let(:map) do + create(:map, name: "Layers test").tap do |m| + create(:feature, :point, title: "Reorder A", layer: m.layers.first) + create(:feature, :point_middle, title: "Reorder B", layer: m.layers.first) + create(:feature, :polygon_middle, title: "Reorder C", layer: m.layers.first) + end + end + let(:f1) { Feature.find_by("properties.title" => "Reorder A") } + let(:f2) { Feature.find_by("properties.title" => "Reorder B") } + let(:f3) { Feature.find_by("properties.title" => "Reorder C") } - # reload after features exist so they render server-side in created_at order before do - visit map.private_map_path - expect_map_loaded find(".maplibregl-ctrl-layers").click expect(page).to have_css("li[data-feature-id='#{f1.id}'] .feature-drag-handle") end @@ -339,16 +313,30 @@ def feature_ids = page.all("#layers li[data-feature-id]").map { |li| li["data-fe # dragging must not select a feature or open its details expect(page).not_to have_css("#feature-details-modal.show") end + + it "drags a feature by its name and still flies to it on click" do + drag_element("li[data-feature-id='#{f1.id}'] .feature-name", "li[data-feature-id='#{f3.id}']") + + expect(feature_ids.first).to eq f1.id.to_s + wait_for { map.layers.first.reload.feature_order.last }.to eq f1.id.to_s + + find("li[data-feature-id='#{f2.id}'] .feature-name").click + expect(page).to have_css("#feature-details-modal.show") + end end context "moving features across layers" do - let!(:feature) { create(:feature, :point, title: "Mover", layer: map.layers.first) } - let!(:target) { create(:layer, map: map, name: "Target layer") } - let!(:resident) { create(:feature, :point_middle, title: "Resident", layer: target) } + let(:map) do + create(:map, name: "Layers test").tap do |m| + create(:feature, :point, title: "Mover", layer: m.layers.first) + create(:feature, :point_middle, title: "Resident", layer: create(:layer, map: m, name: "Target layer")) + end + end + let(:feature) { Feature.find_by("properties.title" => "Mover") } + let(:target) { map.layers.find_by(name: "Target layer") } + let(:resident) { Feature.find_by("properties.title" => "Resident") } before do - visit map.private_map_path - expect_map_loaded find(".maplibregl-ctrl-layers").click find("#layer-list-#{target.id} .layer-name").click find("#layer-list-#{map.layers.first.id} .layer-name").click @@ -363,6 +351,38 @@ def feature_ids = page.all("#layers li[data-feature-id]").map { |li| li["data-fe expect(page).to have_css("#layer-list-#{target.id} .layer-feature-count", text: "(2)") wait_for { feature.reload.layer }.to eq target wait_for { target.reload.feature_order }.to include(feature.id.to_s) + expect(page).to have_css("#layers-modal.show") + end + + it "drops a feature onto the name of a collapsed layer" do + find("#layer-list-#{target.id} .layer-name").click + expect(page).to have_no_css("#layer-list-#{target.id} li[data-feature-id='#{resident.id}']", visible: true) + + drag_element("li[data-feature-id='#{feature.id}'] .feature-drag-handle", + "#layer-list-#{target.id} .layer-name") + + expect(page).to have_css("#layer-list-#{target.id} .layer-feature-count", text: "(2)") + wait_for { feature.reload.layer }.to eq target + wait_for { target.reload.feature_order.last }.to eq feature.id.to_s + expect(page).to have_css("#layers-modal.show") + end + + it "highlights the layer name under a feature dragged by its name" do + center = ->(selector) { + page.evaluate_script("(() => { const r = document.querySelector(#{selector.to_json}).getBoundingClientRect(); + return [r.x + r.width / 2, r.y + r.height / 2] })()") + } + from = center.call("li[data-feature-id='#{feature.id}'] .feature-name") + to = center.call("#layer-list-#{target.id} .layer-name") + mouse = page.driver.browser.mouse + mouse.move(x: from[0], y: from[1]) + mouse.down + mouse.move(x: to[0], y: to[1], steps: 12) + + expect(page).to have_css("#layer-list-#{target.id} .layer-item-header.drop-target") + mouse.up + expect(page).to have_no_css(".drop-target") + wait_for { feature.reload.layer }.to eq target end end end diff --git a/spec/features/map_settings_spec.rb b/spec/features/map_settings_spec.rb index 4237cd677..a59b1a21e 100644 --- a/spec/features/map_settings_spec.rb +++ b/spec/features/map_settings_spec.rb @@ -1,6 +1,6 @@ require "rails_helper" -describe "Map" do +describe "Map settings" do subject(:map) { create(:map, name: "Settings Test", center: nil, zoom: nil) } context "in rw mode" do @@ -10,11 +10,6 @@ expect_map_loaded end - it "shows map settings button" do - expect(page).to have_css("#maplibre-map") - expect(page).to have_css(".maplibregl-ctrl-map") - end - context "when using map settings modal" do it "basemap update gets saved" do find(".maplibregl-ctrl-map").click @@ -123,11 +118,6 @@ expect_map_loaded end - it "shows map settings button" do - expect(page).to have_css("#maplibre-map") - expect(page).to have_css(".maplibregl-ctrl-map") - end - context "when using map settings modal" do it "can change basemap locally" do find(".maplibregl-ctrl-map").click diff --git a/spec/features/map_share_spec.rb b/spec/features/map_share_spec.rb index d0105a85f..bbc71b193 100644 --- a/spec/features/map_share_spec.rb +++ b/spec/features/map_share_spec.rb @@ -1,12 +1,14 @@ require "rails_helper" -describe "Map" do +# The share links, the ownership link and the exports are server rendered, see the +# request spec spec/requests/maps_controller_spec.rb. This file keeps the parts that +# need a browser: the native share and the gallery toggle. +describe "Map share" do subject(:map) { create(:map, name: "Test Map", owners: [ user ]) } let(:user) { create(:user, name: "Test User", email: "test@mapforge.org") } - - context "share links" do + context "in rw mode" do before do visit map.private_map_path expect_map_loaded @@ -14,85 +16,13 @@ expect(page).to have_text("Share Map") end - it "has share public link" do - expect(page).to have_link("Share view link", href: "/m/" + subject.public_id) - end - - it "has share private link" do - expect(page).to have_link("Share edit link", href: "/m/" + subject.private_id) - end - - it "has share geojson link" do - expect(page).to have_link("GeoJSON", href: "/m/" + subject.public_id + ".geojson") - end - - it "has share gpx link" do - expect(page).to have_link("GPX", href: "/m/" + subject.public_id + ".gpx") - end - - it "has share map export link" do - expect(page).to have_link("Map export", href: "/m/" + subject.public_id + ".json?export=true") - end - it "native shares the view link when the icon inside it is clicked" do page.execute_script("navigator.share = (data) => { window.shared = data; return Promise.resolve() }") find("#share-view-link i").click expect(page.evaluate_script("window.shared.url")).to end_with("/m/" + subject.public_id) end - end - - context "export" do - subject(:map) { create(:map, owners: [ create(:user) ], features: features) } - - let(:features) { create_list(:feature, 2, :line_string) } - - it "can download geojson export" do - visit "/m/" + subject.public_id + ".geojson" - expect(page).to have_text(map.to_geojson.to_json) - end - - it "can download map export" do - visit "/m/" + subject.public_id + ".json" - expect(page).to have_text(map.to_json) - end - end - - context "export gpx" do - subject(:map) { create(:map, owners: [ create(:user) ], features: features) } - - let(:features) { - [ create(:feature, :line_string, coordinates: [ [ 11.041, 49.481 ], [ 11.056, 49.463 ] ]), - create(:feature, :line_string, coordinates: [ [ 11.056, 49.463 ], [ 11.061, 49.450 ] ]) ] - } - - before do - visit "/m/" + subject.public_id + ".gpx" - end - - it "exports gpx with one track per linestring" do - file = wait_for_download(subject.public_id + ".gpx", timeout: 10) - expect(File.read(file).scan(//i).size).to eq(2) - end - end - context "permissions in rw mode" do - before do - visit map.private_map_path - expect_map_loaded - find(".maplibregl-ctrl-share").click - expect(page).to have_text("Share Map") - end - - it "has no share ownership link" do - expect(page).not_to have_link("Share ownership link", href: "/m/" + map.private_id + "?join=true") - end - - it "can share map in gallery" do - find("#map-gallery-toggle").click - wait_for { map.reload.view_permission }.to eq("listed") - end - - it "can remove map from gallery" do + it "can add the map to the gallery and remove it again" do find("#map-gallery-toggle").click wait_for { map.reload.view_permission }.to eq("listed") @@ -100,18 +30,4 @@ wait_for { map.reload.view_permission }.to eq("link") end end - - context "permissions in owner mode" do - before do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - visit map.private_map_path - expect_map_loaded - find(".maplibregl-ctrl-share").click - expect(page).to have_text("Share Map") - end - - it "has share ownership link" do - expect(page).to have_link("Share ownership link", href: "/m/" + map.private_id + "?join=true") - end - end end diff --git a/spec/features/map_undo_spec.rb b/spec/features/map_undo_spec.rb index a1ebc89a9..bb71af339 100644 --- a/spec/features/map_undo_spec.rb +++ b/spec/features/map_undo_spec.rb @@ -1,23 +1,9 @@ require "rails_helper" describe "Map Undo/Redo" do - let(:user) { create(:user) } - let(:map) { create(:map, name: "Undo test") } + include_context "with an editable map and an overpass stub" - before do - overpass_file = File.read(Rails.root.join("spec", "fixtures", "files", "overpass.json")) - CapybaraMock.stub_request( - :post, "https://overpass-api.de/api/interpreter" - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*" }, - status: 200, - body: overpass_file - ) - - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - visit map.private_map_path - expect_map_loaded - end + let(:map) { create(:map, name: "Undo test") } it "can undo adding a point via undo button" do find(".mapbox-gl-draw_point").click @@ -46,6 +32,29 @@ wait_for { Feature.count }.to eq(1) end + context "with a feature and a second, active layer" do + let!(:polygon) { create(:feature, :polygon_middle, title: "Poly Title") } + let(:map) { create(:map, features: [ polygon ]).tap { |m| create(:layer, map: m, name: "Second") } } + + it "restores a deleted feature to its own layer" do + own_layer = polygon.reload.layer + find(".maplibregl-ctrl-layers").click + find("#layer-list-#{map.layers.find_by(name: 'Second').id} button.layer-active").click + expect(page).to have_text("New features go to layer Second") + find(".maplibregl-ctrl-layers").click + + click_coord("#maplibre-map", 512, 430) + accept_alert do + find("#edit-button-advanced").click + find("#edit-button-trash").click + end + wait_for { Feature.count }.to eq(0) + + find("button.maplibregl-ctrl-undo").click + wait_for { Feature.find_by(id: polygon.id)&.layer }.to eq(own_layer) + end + end + it "shows undo button after a change and hides it after undoing" do # undo button starts hidden expect(page).to have_css("button.maplibregl-ctrl-undo.hidden", visible: :all) @@ -111,29 +120,44 @@ expect(page).to have_text("🍻 Breweries") end - it "can undo deleting a layer" do - # Create a layer first - layer = create(:layer, name: "Test Layer", map: map) - visit map.private_map_path - expect_map_loaded - - # Open layers modal and verify layer exists + it "keeps a new layer first after undo and redo" do + initial_layer_count = map.layers.count find(".maplibregl-ctrl-layers").click - expect(page).to have_text("Test Layer") - - # Delete the layer - accept_alert do - find('.btn-layer-actions.layer-delete').click - end - wait_for { Layer.find_by(id: layer.id) }.to be_nil + click_button "Add layer" + within("#query-dropdown") { find("button.dropdown-item", text: "New layer").trigger("click") } + wait_for { map.reload.layers.first.name }.to eq("New layer") - # Undo the deletion find("button.maplibregl-ctrl-undo").click - expect(page).to have_text("Undo") + wait_for { map.reload.layers.count }.to eq(initial_layer_count) + find("button.maplibregl-ctrl-redo").click + wait_for { map.reload.layers.count }.to eq(initial_layer_count + 1) + expect(map.reload.layers.first.name).to eq("New layer") + end - # Layer should be restored - wait_for { map.reload.layers.find_by(name: "Test Layer") }.not_to be_nil - expect(page).to have_text("Test Layer") + context "with a second layer" do + let(:map) { create(:map, name: "Undo test").tap { |m| create(:layer, name: "Test Layer", map: m) } } + let(:layer) { map.layers.find_by(name: "Test Layer") } + + it "can undo deleting a layer" do + # Open layers modal and verify layer exists + find(".maplibregl-ctrl-layers").click + expect(page).to have_text("Test Layer") + + # Delete the layer + open_layer_menu(layer.id) + accept_alert do + find("#layer-list-#{layer.id} .layer-delete").click + end + wait_for { Layer.find_by(id: layer.id) }.to be_nil + + # Undo the deletion + find("button.maplibregl-ctrl-undo").click + expect(page).to have_text("Undo") + + # Layer should be restored + wait_for { map.reload.layers.find_by(name: "Test Layer") }.not_to be_nil + expect(page).to have_text("Test Layer") + end end end end diff --git a/spec/features/map_use_cases_spec.rb b/spec/features/map_use_cases_spec.rb index 03f57c331..a6800b5a8 100644 --- a/spec/features/map_use_cases_spec.rb +++ b/spec/features/map_use_cases_spec.rb @@ -19,8 +19,8 @@ expect(page).to have_no_css("#settings-modal.show") end - it "offers the import without images" do - expect(page).to have_text("Please log in to upload images") + # the accept value per login state is server rendered, see spec/requests/maps_controller_spec.rb + it "copies the accept value of the import tile to the file picker" do click_button "Import data (gpx, kml)" expect(page.evaluate_script("document.querySelector('#fileInput').accept")) .to eq ".gpx,.kml,.kmz,.geojson,.json" @@ -53,39 +53,4 @@ wait_for { map.reload.features.count }.to eq 2 end end - - context "with a named map in rw mode" do - subject(:map) { create(:map, name: "Use case test") } - - it "collapses the use cases" do - visit map.private_map_path - expect_map_loaded - find(".maplibregl-ctrl-map").click - expect(page).to have_css("details.feature-section-card:not([open])") - find("summary", text: "What do you want to do?").click - expect(page).to have_css("details.feature-section-card[open]") - end - end - - context "with a logged in user" do - let(:user) { create :user } - - it "offers the import options" do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - visit map.private_map_path - expect_map_loaded - expect(page).to have_no_text("Please log in to upload images") - click_button "Import data (gpx, kml, image)" - expect(page.evaluate_script("document.querySelector('#fileInput').accept")) - .to eq ".gpx,.kml,.kmz,.geojson,.json,image/*" - end - end - - context "in ro mode" do - it "does not render the use cases" do - visit map.public_map_path - expect_map_loaded - expect(page).to have_no_css(".welcome-tiles", visible: :all) - end - end end diff --git a/spec/features/map_view_spec.rb b/spec/features/map_view_spec.rb index a4b4d1026..775767312 100644 --- a/spec/features/map_view_spec.rb +++ b/spec/features/map_view_spec.rb @@ -3,9 +3,11 @@ describe "Map public view" do let(:map) { create(:map) } let(:path) { map.public_map_path } + let(:current_user) { nil } before do stub_const("Map::BASE_MAPS", [ "test", "test2" ] + Map::BASE_MAPS) + sign_in(current_user) if current_user visit path expect_map_loaded end @@ -233,7 +235,7 @@ # feature is created after loading the map, to make sure it's loaded via websocket it "receives new features via websocket channel" do create(:feature, :polygon_middle, layer: map.layers.first, title: "New Title") - sleep 1 + wait_for_feature("New Title") click_center_of_screen expect(page).to have_css("#feature-details-modal") expect(page).to have_text("New Title") @@ -258,12 +260,7 @@ context "as map owner / admin" do let(:map) { create(:map, owners: [ user ]) } let(:user) { create(:user) } - - before do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - visit path - expect_map_loaded - end + let(:current_user) { user } # the toggle labels are uppercased by CSS it "marks view mode as the active mode" do @@ -363,11 +360,6 @@ expect(page).to have_css("#layers-modal") end end - - it "has share edit link in the share modal" do - find(".maplibregl-ctrl-share").click - expect(page).to have_link("Share edit link", href: "/m/" + map.private_id) - end end context "with server gone" do @@ -440,10 +432,4 @@ expect(map_json_fetch_count).to eq(0) end end - - context "with other engines" do - it "deck.gl" do - visit deck_path(map.public_id) - end - end end diff --git a/spec/features/maps_list_spec.rb b/spec/features/maps_list_spec.rb index f49b4ef37..dc8d72dfa 100644 --- a/spec/features/maps_list_spec.rb +++ b/spec/features/maps_list_spec.rb @@ -3,7 +3,7 @@ # The filter results themselves are server rendered, see the request spec # spec/requests/maps_controller_spec.rb. This file only covers the parts that # need a browser: the live broadcast and the Stimulus filter controls. -describe "Map List" do +describe "Public map list" do let(:maps) { create_list(:map, 3, view_permission: "listed") } before do @@ -14,7 +14,7 @@ it "receives broadcasts for map changes" do # page is already loaded expect(page).to have_selector(:xpath, "//a[@href='/m/#{maps[0].public_id}']") - sleep(1) # make sure websocket is connected + expect(page).to have_css("turbo-cable-stream-source[connected]", visible: :all) new_map = create(:map, name: "broadcast", view_permission: "listed") expect(page).to have_selector(:xpath, "//a[@href='/m/#{new_map.public_id}/broadcast']") diff --git a/spec/features/maps_my_list_spec.rb b/spec/features/maps_my_list_spec.rb index 615bef1d3..5a400e3ee 100644 --- a/spec/features/maps_my_list_spec.rb +++ b/spec/features/maps_my_list_spec.rb @@ -2,11 +2,11 @@ # The list content and the filter are server rendered, see the request spec # spec/requests/maps_controller_spec.rb. Only the delete confirmation needs a browser. -describe "Map List" do +describe "My map list" do let(:user) { create(:user) } before do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + sign_in(user) create(:map, owners: [ user ]) visit my_path end diff --git a/spec/models/map_spec.rb b/spec/models/map_spec.rb index ca2b41ad7..63d6854c7 100644 --- a/spec/models/map_spec.rb +++ b/spec/models/map_spec.rb @@ -42,6 +42,16 @@ end end + describe "#to_gpx" do + it "exports one track per linestring" do + map = create(:map, features: [ + create(:feature, :line_string, coordinates: [ [ 11.041, 49.481 ], [ 11.056, 49.463 ] ]), + create(:feature, :line_string, coordinates: [ [ 11.056, 49.463 ], [ 11.061, 49.450 ] ]) + ]) + expect(map.to_gpx.to_s.scan(//i).size).to eq(2) + end + end + describe "#features_count" do it "sums up feature count of all layers" do expect(map.features_count).to eq(6) diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 3e5de1e94..c94e30fd7 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,4 +1,3 @@ -# This file is copied to spec/ when you run 'rails generate rspec:install' if ENV["COVERAGE"] == "true" require "simplecov" # CI splits the suite over jobs. A single job covers only its own groups, so the @@ -17,10 +16,8 @@ ENV["OPENROUTESERVICE_KEY"] ||= "test" require_relative "../config/environment" -# Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? require "rspec/rails" -# Add additional requires below this line. Rails is not loaded until this point! require "database_cleaner/mongoid" require "capybara-screenshot/rspec" @@ -33,26 +30,12 @@ # The Capybara server and the Chrome debug port both listen on localhost. WebMock.disable_net_connect!(allow_localhost: true) -# Requires supporting ruby files with custom matchers and macros, etc, in -# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are -# run as spec files by default. This means that files in spec/support that end -# in _spec.rb will both be required and run as specs, causing the specs to be -# run twice. It is recommended that you do not name files matching this glob to -# end with _spec.rb. You can configure this pattern with the --pattern -# option on the command line or in ~/.rspec, .rspec or `.rspec-local`. -# -# The following line is provided for convenience purposes. It has the downside -# of increasing the boot-up time by auto-requiring all files in the support -# directory. Alternatively, in the individual `*_spec.rb` files, manually -# require only the support files necessary. -# Rails.root.glob("spec/support/**/*.rb").sort.each { |f| require f } # raise on js console errors class JavaScriptError < StandardError; end RSpec.configure do |config| - # Remove this line to enable support for ActiveRecord config.use_active_record = false config.include Mongoid::Matchers, type: :model @@ -60,7 +43,6 @@ class JavaScriptError < StandardError; end config.before(:suite) do # Drop rack cache responses - require "fileutils" FileUtils.rm_rf(Dir["tmp/cache/rack"]) end @@ -76,69 +58,25 @@ class JavaScriptError < StandardError; end page.driver.browser.resize(width: 1024, height: 860) end - RSpec.configure do |config| - config.after(:each, type: :feature) do |spec| - # https://danielabaron.me/blog/capture-browser-console-logs-capybara-cuprite/ - logger = page.driver.browser.options.logger - browser_logs = logger.string - console_logs = browser_logs.lines.select { |line| line.include?("Runtime.consoleAPICalled") } - # puts console_logs.join("\n\n") - error_logs = console_logs.select { |line| line.include?('"type":"error"') } - - # Clear the logger buffer to prevent errors from carrying over to subsequent tests, - # also for :skip_console_errors specs - logger.truncate(0) - logger.rewind - - # Raise after clearing to ensure isolation even when test fails - if error_logs.present? && !spec.metadata[:skip_console_errors] - raise JavaScriptError, error_logs.join("\n\n") - end - - # levels = [ "SEVERE" ] - # # "maplibre-gl.js TypeError: Failed to fetch" seems to be caused by - # # the js file being cached already - # exclude = [ /TypeError: Failed to fetch/, - # /The user aborted a request/, - # /Failed to load resource/ ] - # errors = page.driver.browser.logs.get(:browser).to_a - # .select { |e| levels.include?(e.level) && e.message.present? } - # .reject { |e| exclude.any? { |ex| e.message =~ ex } } - # .map(&:message) - # if errors.present? - # raise JavaScriptError, errors.join("\n\n") - # end - # end - # if spec.metadata[:print_console_logs] - # logs = page.driver.browser.logs.get(:browser).to_a.map(&:message) - # puts logs.join("\n\n") + config.after(:each, type: :feature) do |spec| + # https://danielabaron.me/blog/capture-browser-console-logs-capybara-cuprite/ + logger = page.driver.browser.options.logger + console_logs = logger.string.lines.select { |line| line.include?("Runtime.consoleAPICalled") } + error_logs = console_logs.select { |line| line.include?('"type":"error"') } + + # Clear the logger buffer to prevent errors from carrying over to subsequent tests, + # also for :skip_console_errors specs + logger.truncate(0) + logger.rewind + + # Raise after clearing to ensure isolation even when test fails + if error_logs.present? && !spec.metadata[:skip_console_errors] + raise JavaScriptError, error_logs.join("\n\n") end end - # If you enable ActiveRecord support you should uncomment these lines, - # note if you'd prefer not to run each example within a transaction, you - # should set use_transactional_fixtures to false. - # - # config.fixture_path = Rails.root.join('spec/fixtures') - # config.use_transactional_fixtures = true - - # RSpec Rails can automatically mix in different behaviours to your tests - # based on their file location, for example enabling you to call `get` and - # `post` in specs under `spec/controllers`. - # - # You can disable this behaviour by removing the line below, and instead - # explicitly tag your specs with their type, e.g.: - # - # RSpec.describe UsersController, type: :controller do - # # ... - # end - # - # The different available types are documented in the features, such as in - # https://rspec.info/features/6-0/rspec-rails config.infer_spec_type_from_file_location! # Filter lines from Rails gems in backtraces. config.filter_rails_from_backtrace! - # arbitrary gems may also be filtered via: - # config.filter_gems_from_backtrace("gem name") end diff --git a/spec/requests/admin_controller_spec.rb b/spec/requests/admin_controller_spec.rb index 5241c338f..00882c30b 100644 --- a/spec/requests/admin_controller_spec.rb +++ b/spec/requests/admin_controller_spec.rb @@ -5,7 +5,7 @@ describe "#index" do before do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: admin.id }) + sign_in(admin) end it "lists the private link of every map" do @@ -31,7 +31,7 @@ it "redirects a logged in non-admin to the login page" do user = create(:user) - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + sign_in(user) get "/admin" expect(response).to redirect_to(login_path) end diff --git a/spec/requests/frontpage_controller_spec.rb b/spec/requests/frontpage_controller_spec.rb index 92ac84417..c04a8c32a 100644 --- a/spec/requests/frontpage_controller_spec.rb +++ b/spec/requests/frontpage_controller_spec.rb @@ -21,7 +21,7 @@ it "offers start a map, your maps and the playground to a logged in user" do user = create(:user) - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + sign_in(user) get "/" expect(response.body).to include(">Start a map") expect(response.body).to include(">Your maps") diff --git a/spec/requests/maps_controller_spec.rb b/spec/requests/maps_controller_spec.rb index 5e4acf5b2..ad9282ce7 100644 --- a/spec/requests/maps_controller_spec.rb +++ b/spec/requests/maps_controller_spec.rb @@ -19,6 +19,19 @@ expect { get map_json_path(id: map.public_id, export: true) } .to change { Yabeda.map_downloads.get(format: "mapforge").to_i }.by(1) end + + it "returns the map json" do + get map_json_path(id: map.public_id) + expect(JSON.parse(response.body)).to eq(JSON.parse(map.to_json)) + end + end + + describe "#show (geojson)" do + it "returns the map features" do + create(:feature, :line_string, layer: map.layers.first) + get map_geojson_path(id: map.public_id) + expect(JSON.parse(response.body)).to eq(JSON.parse(map.to_geojson.to_json)) + end end describe "#show (gpx)" do @@ -26,15 +39,123 @@ expect { get map_gpx_path(id: map.public_id) } .to change { Yabeda.map_downloads.get(format: "gpx").to_i }.by(1) end + + it "sends the gpx as a file named after the map" do + map = create(:map, name: "Test Map") + get map_gpx_path(id: map.public_id) + expect(response.headers["Content-Disposition"]).to include("attachment", 'filename="test-map.gpx"') + expect(response.body).to include(" { "longitude" => 11.0776, "latitude" => 49.4471, "accuracy_radius" => 20 }) stub_const("MAXMIND_DB", instance_double(MaxMindDB::Client, lookup: result)) @@ -364,7 +481,7 @@ def login end it "greets logged in users by first name" do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + sign_in(user) post tutorial_path labels = Map.tutorial.first.features.pluck(:properties).map { |p| p["label"] } expect(labels).to include("Welcome First to the Tutorial") @@ -379,14 +496,14 @@ def login end it "creates persistent tutorial map for each logged in user" do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + sign_in(user) post tutorial_path post tutorial_path expect(Map.tutorial.count).to eq 1 end it "counts only the creation, not the reuse" do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + sign_in(user) counter = -> { Yabeda.maps_created.get(kind: "tutorial", owner: "user", user: user.id.to_s).to_i } expect { post tutorial_path }.to change(&counter).by(1) expect { post tutorial_path }.not_to change(&counter) @@ -411,7 +528,7 @@ def login it "cannot be joined by a user" do get playground_path - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: create(:user).id }) + sign_in(create(:user)) get map_path(id: Map::PLAYGROUND_ID, join: true) expect(Map.find_by(private_id: Map::PLAYGROUND_ID).owners).to be_empty end @@ -450,7 +567,7 @@ def login describe "#map" do before do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) + sign_in(user) end let(:user) { create(:user) } diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 995873f48..befa08975 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -1,53 +1,5 @@ require "capybara/cuprite" -# Generates the arguments to register chrome as capybara driver -# -# @param [TrueClass|FalseClass, #headless] switch to run in headless mode -# @return [Hash] all keyword arguments needed by capybara to register the driver. -def chrome_driver_arguments(headless: false) - options = Selenium::WebDriver::Chrome::Options.new - options.args << "--window-size=1024,860" - options.args << "--lang=en_US" - options.args << "--enable-logging" - options.args << "--disable-blink-features=AutomationControlled" - options.args << "--disable-background-networking" - options.args << "--disable-default-apps" - options.args << "--disable-extensions" - options.args << "--disable-sync" - options.args << "--disable-translate" - options.args << "--metrics-recording-only" - options.args << "--no-first-run" - options.args << "--safebrowsing-disable-auto-update" - options.args << "--disable-client-side-phishing-detection" - options.args << "--disable-component-update" - options.args << "--disable-domain-reliability" - options.args << "--disable-features=NetworkService,NetworkServiceInProcess" - options.args << "--disable-popup-blocking" - options.args << "--disable-renderer-backgrounding" - options.args << "--ignore-certificate-errors" - options.args << "--no-default-browser-check" - - if defined?(Billy) - options.args << "--ignore-certificate-errors" - options.args << "--proxy-server=" + "#{Billy.proxy.host}:#{Billy.proxy.port}" if defined?(Billy) - end - - options.logging_prefs = { - browser: "ALL", # Capture all JavaScript errors - driver: "SEVERE" # Capture severe WebDriver errors - } - - if headless - options.args << "--headless" - options.args << "--no-sandbox" # http://chromedriver.chromium.org/help/chrome-doesn-t-start - options.args << "--disable-gpu" - end - - { - browser: :chrome, options:, timeout: 600 - } -end - # == Configure Capybara Capybara.configure do |config| config.default_max_wait_time = 30 @@ -61,15 +13,6 @@ def chrome_driver_arguments(headless: false) # == Register Capybara Drivers -# ++ Browser Driver -Capybara.register_driver :chrome do |app| - Capybara::Selenium::Driver.new(app, **chrome_driver_arguments(headless: false)) -end - -Capybara.register_driver :headless_chrome do |app| - Capybara::Selenium::Driver.new(app, **chrome_driver_arguments(headless: true)) -end - Capybara.register_driver(:cuprite) do |app| Capybara::Cuprite::Driver.new(app, window_size: [ 1024, 860 ], headless: "new", diff --git a/spec/support/map_helpers.rb b/spec/support/map_helpers.rb index bea06ca56..bbf23374f 100644 --- a/spec/support/map_helpers.rb +++ b/spec/support/map_helpers.rb @@ -56,3 +56,7 @@ def map_json_fetch_count "performance.getEntriesByType('resource').filter(e => /\\/m\\/[^\\/]+\\.json/.test(e.name)).length" ) end + +def open_layer_menu(layer_id) + find("#layer-list-#{layer_id} .layer-actions-dropdown > button").click +end diff --git a/spec/support/network_helpers.rb b/spec/support/network_helpers.rb index 7d4170ce3..356b6eb69 100644 --- a/spec/support/network_helpers.rb +++ b/spec/support/network_helpers.rb @@ -17,3 +17,11 @@ def wait_for_download(name, timeout: 10) end end end + +def stub_fixture(method, url, file) + CapybaraMock.stub_request(method, url).to_return( + headers: { "Access-Control-Allow-Origin" => "*", "Content-Type" => "application/json" }, + status: 200, + body: File.read(Rails.root.join("spec", "fixtures", "files", file)) + ) +end diff --git a/spec/support/session_helpers.rb b/spec/support/session_helpers.rb new file mode 100644 index 000000000..8707a69ca --- /dev/null +++ b/spec/support/session_helpers.rb @@ -0,0 +1,3 @@ +def sign_in(user) + allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) +end diff --git a/spec/support/shared_contexts.rb b/spec/support/shared_contexts.rb index 2c203b044..451fe0b15 100644 --- a/spec/support/shared_contexts.rb +++ b/spec/support/shared_contexts.rb @@ -3,17 +3,8 @@ let(:user) { create(:user) } before do - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - - elevation_file = File.read(Rails.root.join("spec", "fixtures", "files", "ors_elevation.json")) - CapybaraMock.stub_request( - :post, /api\.openrouteservice\.org\/elevation\/line/ - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*", "Content-Type" => "application/json" }, - status: 200, - body: elevation_file - ) - + sign_in(user) + stub_fixture(:post, /api\.openrouteservice\.org\/elevation\/line/, "ors_elevation.json") visit map.private_map_path expect_map_loaded end @@ -23,19 +14,14 @@ subject(:map) { create(:map, name: "Layers test") } let(:user) { create(:user) } + # a spec overrides these instead of a second visit, which would load the map twice + let(:overpass_fixture) { "overpass.json" } + let(:map_path) { map.private_map_path } before do - overpass_file = File.read(Rails.root.join("spec", "fixtures", "files", "overpass.json")) - CapybaraMock.stub_request( - :post, "https://overpass-api.de/api/interpreter" - ).to_return( - headers: { "Access-Control-Allow-Origin" => "*" }, - status: 200, - body: overpass_file - ) - - allow_any_instance_of(ApplicationController).to receive(:session).and_return({ user_id: user.id }) - visit map.private_map_path + stub_fixture(:post, "https://overpass-api.de/api/interpreter", overpass_fixture) + sign_in(user) + visit map_path expect_map_loaded end end