From: Tom Hughes Date: Fri, 2 May 2025 17:19:29 +0000 (+0100) Subject: Merge remote-tracking branch 'upstream/pull/5964' X-Git-Url: https://git.openstreetmap.org/rails.git/commitdiff_plain/refs/heads/master?hp=fb3e0977f830189af003f701bcf360dbaeea21df Merge remote-tracking branch 'upstream/pull/5964' --- diff --git a/app/abilities/api_ability.rb b/app/abilities/api_ability.rb index b0bd2578f..14d332ed0 100644 --- a/app/abilities/api_ability.rb +++ b/app/abilities/api_ability.rb @@ -34,7 +34,7 @@ class ApiAbility can :read, :active_user_blocks_list if scopes.include?("read_prefs") if user.terms_agreed? - can [:create, :update, :upload, :close], Changeset if scopes.include?("write_map") + can [:create, :update, :upload], Changeset if scopes.include?("write_map") can [:create, :destroy], ChangesetSubscription if scopes.include?("write_map") can :create, ChangesetComment if scopes.include?("write_changeset_comments") can [:create, :update, :destroy], [Node, Way, Relation] if scopes.include?("write_map") diff --git a/app/assets/javascripts/dashboard.js b/app/assets/javascripts/dashboard.js new file mode 100644 index 000000000..7719cac38 --- /dev/null +++ b/app/assets/javascripts/dashboard.js @@ -0,0 +1,33 @@ +//= require leaflet.locate + +$(function () { + const defaultHomeZoom = 12; + let map; + + if ($("#map").length) { + map = L.map("map", { + attributionControl: false, + zoomControl: false + }).addLayer(new L.OSM.Mapnik()); + + const position = $("html").attr("dir") === "rtl" ? "topleft" : "topright"; + + L.OSM.zoom({ position }).addTo(map); + + L.OSM.locate({ position }).addTo(map); + + if (OSM.home) { + map.setView([OSM.home.lat, OSM.home.lon], defaultHomeZoom); + } else { + map.setView([0, 0], 0); + } + + $("[data-user]").each(function () { + const user = $(this).data("user"); + if (user.lon && user.lat) { + L.marker([user.lat, user.lon], { icon: OSM.getMarker({ icon: user.icon }) }).addTo(map) + .bindPopup(user.description, { minWidth: 200 }); + } + }); + } +}); diff --git a/app/assets/javascripts/edit/id.js.erb b/app/assets/javascripts/edit/id.js.erb index fd7b3bd7c..c66335768 100644 --- a/app/assets/javascripts/edit/id.js.erb +++ b/app/assets/javascripts/edit/id.js.erb @@ -21,7 +21,7 @@ $(function () { } if (mapParams.object) { params.set("id", mapParams.object.type + "/" + mapParams.object.id); - if (hashArgs.center) ({ zoom, center: { lat, lng: lon } } = hashArgs); + if (hashArgs.center) ({ zoom, lat, lon } = hashArgs); } if (lat && lon) params.set("map", [zoom || 17, lat, lon].join("/")); diff --git a/app/assets/javascripts/index/directions-endpoint.js b/app/assets/javascripts/index/directions-endpoint.js index 97b841f14..785613b7a 100644 --- a/app/assets/javascripts/index/directions-endpoint.js +++ b/app/assets/javascripts/index/directions-endpoint.js @@ -97,7 +97,7 @@ OSM.DirectionsEndpoint = function Endpoint(map, input, marker, dragCallback, cha function getGeocode() { const viewbox = map.getBounds().toBBoxString(), // ,,, - geocodeUrl = OSM.NOMINATIM_URL + "search?" + new URLSearchParams({ q: endpoint.value, format: "json", viewbox }); + geocodeUrl = OSM.NOMINATIM_URL + "search?" + new URLSearchParams({ q: endpoint.value, format: "json", viewbox, limit: 1 }); endpoint.geocodeRequest = new AbortController(); fetch(geocodeUrl, { signal: endpoint.geocodeRequest.signal }) diff --git a/app/assets/javascripts/index/directions-route-output.js b/app/assets/javascripts/index/directions-route-output.js index 84bcc87a8..1335d5ec3 100644 --- a/app/assets/javascripts/index/directions-route-output.js +++ b/app/assets/javascripts/index/directions-route-output.js @@ -13,34 +13,49 @@ OSM.DirectionsRouteOutput = function (map) { weight: 12 }); + let distanceUnits = "km"; let downloadURL = null; - function formatTotalDistance(m) { - if (m < 1000) { - return OSM.i18n.t("javascripts.directions.distance_m", { distance: Math.round(m) }); - } else if (m < 10000) { - return OSM.i18n.t("javascripts.directions.distance_km", { distance: (m / 1000.0).toFixed(1) }); + function translateDistanceUnits(m) { + if (distanceUnits === "km") { + return [m, "m", m / 1000, "km"]; } else { - return OSM.i18n.t("javascripts.directions.distance_km", { distance: Math.round(m / 1000) }); + return [m / 0.3048, "ft", m / 1609.344, "mi"]; } } - function formatStepDistance(m) { - if (m < 5) { + function formatTotalDistance(minorValue, minorName, majorValue, majorName) { + const scope = "javascripts.directions.distance_in_units"; + + if (minorValue < 1000 || majorValue < 0.25) { + return OSM.i18n.t(minorName, { scope, distance: Math.round(minorValue) }); + } else if (majorValue < 10) { + return OSM.i18n.t(majorName, { scope, distance: majorValue.toFixed(1) }); + } else { + return OSM.i18n.t(majorName, { scope, distance: Math.round(majorValue) }); + } + } + + function formatStepDistance(minorValue, minorName, majorValue, majorName) { + const scope = "javascripts.directions.distance_in_units"; + + if (minorValue < 5) { return ""; - } else if (m < 200) { - return OSM.i18n.t("javascripts.directions.distance_m", { distance: String(Math.round(m / 10) * 10) }); - } else if (m < 1500) { - return OSM.i18n.t("javascripts.directions.distance_m", { distance: String(Math.round(m / 100) * 100) }); - } else if (m < 5000) { - return OSM.i18n.t("javascripts.directions.distance_km", { distance: String(Math.round(m / 100) / 10) }); + } else if (minorValue < 200) { + return OSM.i18n.t(minorName, { scope, distance: Math.round(minorValue / 10) * 10 }); + } else if (minorValue < 1500 || majorValue < 0.25) { + return OSM.i18n.t(minorName, { scope, distance: Math.round(minorValue / 100) * 100 }); + } else if (majorValue < 5) { + return OSM.i18n.t(majorName, { scope, distance: majorValue.toFixed(1) }); } else { - return OSM.i18n.t("javascripts.directions.distance_km", { distance: String(Math.round(m / 1000)) }); + return OSM.i18n.t(majorName, { scope, distance: Math.round(majorValue) }); } } - function formatHeight(m) { - return OSM.i18n.t("javascripts.directions.distance_m", { distance: Math.round(m) }); + function formatHeight(minorValue, minorName) { + const scope = "javascripts.directions.distance_in_units"; + + return OSM.i18n.t(minorName, { scope, distance: Math.round(minorValue) }); } function formatTime(s) { @@ -50,35 +65,25 @@ OSM.DirectionsRouteOutput = function (map) { return h + ":" + (m < 10 ? "0" : "") + m; } - const routeOutput = {}; - - routeOutput.write = function (content, route) { - polyline - .setLatLngs(route.line) - .addTo(map); - - const distanceText = $("

").append( - OSM.i18n.t("javascripts.directions.distance") + ": " + formatTotalDistance(route.distance) + ". " + - OSM.i18n.t("javascripts.directions.time") + ": " + formatTime(route.time) + "."); + function writeSummary(route) { + $("#directions_route_distance").val(formatTotalDistance(...translateDistanceUnits(route.distance))); + $("#directions_route_time").val(formatTime(route.time)); if (typeof route.ascend !== "undefined" && typeof route.descend !== "undefined") { - distanceText.append( - $("
"), - OSM.i18n.t("javascripts.directions.ascend") + ": " + formatHeight(route.ascend) + ". " + - OSM.i18n.t("javascripts.directions.descend") + ": " + formatHeight(route.descend) + "."); + $("#directions_route_ascend_descend").prop("hidden", false); + $("#directions_route_ascend").val(formatHeight(...translateDistanceUnits(route.ascend))); + $("#directions_route_descend").val(formatHeight(...translateDistanceUnits(route.descend))); + } else { + $("#directions_route_ascend_descend").prop("hidden", true); + $("#directions_route_ascend").val(""); + $("#directions_route_descend").val(""); } + } - const turnByTurnTable = $("") - .append($("")); - - content - .empty() - .append( - distanceText, - turnByTurnTable - ); + function writeSteps(route) { + $("#directions_route_steps").empty(); for (const [i, [direction, instruction, dist, lineseg]] of route.steps.entries()) { - const row = $("").appendTo(turnByTurnTable); + const row = $("").appendTo($("#directions_route_steps")); if (direction) { row.append(""); @@ -86,7 +91,7 @@ OSM.DirectionsRouteOutput = function (map) { row.append("
"); } row.append(`${i + 1}. ${instruction}`); - row.append("" + formatStepDistance(dist)); + row.append("" + formatStepDistance(...translateDistanceUnits(dist))); row.on("click", function () { popup @@ -105,22 +110,37 @@ OSM.DirectionsRouteOutput = function (map) { map.removeLayer(highlight); }); } + } + + const routeOutput = {}; + + routeOutput.write = function (route) { + polyline + .setLatLngs(route.line) + .addTo(map); + + writeSummary(route); + writeSteps(route); + + $("#directions_distance_units_km").off().on("change", () => { + distanceUnits = "km"; + writeSummary(route); + writeSteps(route); + }); + $("#directions_distance_units_mi").off().on("change", () => { + distanceUnits = "mi"; + writeSummary(route); + writeSteps(route); + }); const blob = new Blob([JSON.stringify(polyline.toGeoJSON())], { type: "application/json" }); URL.revokeObjectURL(downloadURL); downloadURL = URL.createObjectURL(blob); + $("#directions_route_download").prop("href", downloadURL); - content.append(`

${ - OSM.i18n.t("javascripts.directions.download") - }

`); - - content.append("

" + - OSM.i18n.t("javascripts.directions.instructions.courtesy", { - link: `${route.credit}` - }) + - "

"); + $("#directions_route_credit") + .text(route.credit) + .prop("href", route.creditlink); }; routeOutput.fit = function () { @@ -131,11 +151,18 @@ OSM.DirectionsRouteOutput = function (map) { return map.hasLayer(polyline); }; - routeOutput.remove = function (content) { - content.empty(); + routeOutput.remove = function () { map .removeLayer(popup) .removeLayer(polyline); + + $("#directions_distance_units_km").off(); + $("#directions_distance_units_mi").off(); + + $("#directions_route_steps").empty(); + + URL.revokeObjectURL(downloadURL); + $("#directions_route_download").prop("href", ""); }; return routeOutput; diff --git a/app/assets/javascripts/index/directions.js b/app/assets/javascripts/index/directions.js index 61c646c59..8fdc1b652 100644 --- a/app/assets/javascripts/index/directions.js +++ b/app/assets/javascripts/index/directions.js @@ -8,7 +8,7 @@ OSM.Directions = function (map) { let lastLocation = []; let chosenEngine; - let scheduledRouteArguments = null; + let sidebarReadyPromise = null; const routeOutput = OSM.DirectionsRouteOutput(map); @@ -88,15 +88,7 @@ OSM.Directions = function (map) { select.val(chosenEngine.provider); } - function getRoute(...routeArguments) { - if ($("#directions_content").length) { - getScheduledRoute(...routeArguments); - } else { - scheduledRouteArguments = routeArguments; - } - } - - function getScheduledRoute(fitRoute, reportErrors) { + function getRoute(fitRoute, reportErrors) { // Cancel any route that is already in progress if (controller) controller.abort(); @@ -110,30 +102,36 @@ OSM.Directions = function (map) { route: points.map(p => `${p.lat},${p.lng}`).join(";") })); - // copy loading item to sidebar and display it. we copy it, rather than - // just using it in-place and replacing it in case it has to be used - // again. - $("#directions_content").html($(".directions_form .loader_copy").html()); + $("#directions_loader").prop("hidden", false); + $("#directions_error").prop("hidden", true).empty(); + $("#directions_route").prop("hidden", true); map.setSidebarOverlaid(false); controller = new AbortController(); - chosenEngine.getRoute(points, controller.signal).then(function (route) { - routeOutput.write($("#directions_content"), route); + chosenEngine.getRoute(points, controller.signal).then(async function (route) { + await sidebarLoaded(); + $("#directions_route").prop("hidden", false); + routeOutput.write(route); if (fitRoute) { routeOutput.fit(); } - }).catch(function () { - routeOutput.remove($("#directions_content")); + }).catch(async function () { + await sidebarLoaded(); + routeOutput.remove(); if (reportErrors) { - $("#directions_content").html("
" + OSM.i18n.t("javascripts.directions.errors.no_route") + "
"); + $("#directions_error") + .prop("hidden", false) + .html("
" + OSM.i18n.t("javascripts.directions.errors.no_route") + "
"); } }).finally(function () { + $("#directions_loader").prop("hidden", true); controller = null; }); } function closeButtonListener(e) { e.stopPropagation(); - routeOutput.remove($("#directions_content")); + routeOutput.remove(); + sidebarReadyPromise = null; map.setSidebarOverlaid(true); // TODO: collapse width of sidebar back to previous } @@ -221,28 +219,23 @@ OSM.Directions = function (map) { const page = {}; - page.pushstate = page.popstate = function () { - page.load(); - - if ($("#directions_content").length) return; - - OSM.loadSidebarContent("/directions", () => { - if (scheduledRouteArguments) { - getScheduledRoute(...scheduledRouteArguments); - scheduledRouteArguments = null; - } - }); - - map.setSidebarOverlaid(!endpoints[0].latlng || !endpoints[1].latlng); - }; + function sidebarLoaded() { + if ($("#directions_route").length) { + sidebarReadyPromise = null; + return Promise.resolve(); + } + if (sidebarReadyPromise) return sidebarReadyPromise; + sidebarReadyPromise = new Promise(resolve => OSM.loadSidebarContent("/directions", resolve)); + return sidebarReadyPromise; + } - page.load = function () { + page.pushstate = page.popstate = page.load = function () { initializeFromParams(); $(".search_form").hide(); $(".directions_form").show(); - enableListeners(); + sidebarLoaded().then(enableListeners); map.setSidebarOverlaid(!endpoints[0].latlng || !endpoints[1].latlng); }; @@ -261,9 +254,9 @@ OSM.Directions = function (map) { endpoints[0].clearValue(); endpoints[1].clearValue(); - routeOutput.remove($("#directions_content")); + routeOutput.remove(); - scheduledRouteArguments = null; + sidebarReadyPromise = null; }; return page; diff --git a/app/assets/javascripts/index/history-changesets-layer.js b/app/assets/javascripts/index/history-changesets-layer.js index 51faffbf3..2f762e0d6 100644 --- a/app/assets/javascripts/index/history-changesets-layer.js +++ b/app/assets/javascripts/index/history-changesets-layer.js @@ -1,4 +1,19 @@ -OSM.HistoryChangesetsLayer = L.FeatureGroup.extend({ +OSM.HistoryChangesetBboxLayer = L.FeatureGroup.extend({ + getLayerId: function (layer) { + return layer.id; + }, + + addChangesetLayer: function (changeset) { + const style = this._getChangesetStyle(changeset); + const rectangle = L.rectangle(changeset.bounds, style); + rectangle.id = changeset.id; + return this.addLayer(rectangle); + }, + + updateChangesetLayerBounds: function (changeset) { + this.getLayer(changeset.id)?.setBounds(changeset.bounds); + }, + _getSidebarRelativeClassName: function ({ sidebarRelativePosition }) { if (sidebarRelativePosition > 0) { return "changeset-above-sidebar-viewport"; @@ -7,28 +22,67 @@ OSM.HistoryChangesetsLayer = L.FeatureGroup.extend({ } else { return "changeset-in-sidebar-viewport"; } - }, + } +}); - _getInteractiveStyle: function (changeset) { +OSM.HistoryChangesetBboxAreaLayer = OSM.HistoryChangesetBboxLayer.extend({ + _getChangesetStyle: function (changeset) { return { - weight: 2, - color: "var(--changeset-border-color)", + weight: 0, fillOpacity: 0, className: this._getSidebarRelativeClassName(changeset) }; - }, + } +}); - _getHighlightStyle: function (changeset) { +OSM.HistoryChangesetBboxOutlineLayer = OSM.HistoryChangesetBboxLayer.extend({ + _getChangesetStyle: function (changeset) { return { - interactive: false, weight: 4, + color: "var(--changeset-outline-color)", + fill: false, + className: this._getSidebarRelativeClassName(changeset) + }; + } +}); + +OSM.HistoryChangesetBboxBorderLayer = OSM.HistoryChangesetBboxLayer.extend({ + _getChangesetStyle: function (changeset) { + return { + weight: 2, color: "var(--changeset-border-color)", + fill: false, + className: this._getSidebarRelativeClassName(changeset) + }; + } +}); + +OSM.HistoryChangesetBboxHighlightBackLayer = OSM.HistoryChangesetBboxLayer.extend({ + _getChangesetStyle: function (changeset) { + return { + interactive: false, + weight: 6, + color: "var(--changeset-outline-color)", fillColor: "var(--changeset-fill-color)", fillOpacity: 0.3, className: this._getSidebarRelativeClassName(changeset) + " changeset-highlighted" }; - }, + } +}); + +OSM.HistoryChangesetBboxHighlightBorderLayer = OSM.HistoryChangesetBboxLayer.extend({ + _getChangesetStyle: function (changeset) { + return { + interactive: false, + weight: 4, + color: "var(--changeset-border-color)", + fill: false, + className: this._getSidebarRelativeClassName(changeset) + " changeset-highlighted" + }; + } +}); +OSM.HistoryChangesetsLayer = L.FeatureGroup.extend({ updateChangesets: function (map, changesets) { this._changesets = new Map(changesets.map(changeset => [changeset.id, changeset])); this.updateChangesetShapes(map); @@ -73,29 +127,50 @@ OSM.HistoryChangesetsLayer = L.FeatureGroup.extend({ changesetSouthWest.lng -= shiftInWorldCircumferences * 360; changesetNorthEast.lng -= shiftInWorldCircumferences * 360; - this._interactiveLayer.getLayer(changeset.id)?.setBounds(changeset.bounds); - this._highlightLayer.getLayer(changeset.id)?.setBounds(changeset.bounds); + for (const layer of this._bboxLayers) { + layer.updateChangesetLayerBounds(changeset); + } } } }, reorderChangesets: function () { const changesetEntries = [...this._changesets]; - changesetEntries.sort(([, a], [, b]) => { - const aInViewport = !a.sidebarRelativePosition; - const bInViewport = !b.sidebarRelativePosition; - if (aInViewport !== bInViewport) return aInViewport - bInViewport; - return b.bounds.getSize() - a.bounds.getSize(); - }); + changesetEntries.sort(([, a], [, b]) => b.bounds.getSize() - a.bounds.getSize()); this._changesets = new Map(changesetEntries); - this._interactiveLayer.clearLayers(); - this._highlightLayer.clearLayers(); + for (const layer of this._bboxLayers) { + layer.clearLayers(); + } + + for (const changeset of this._changesets.values()) { + if (changeset.sidebarRelativePosition !== 0) { + this._areaLayer.addChangesetLayer(changeset); + } + } + + for (const changeset of this._changesets.values()) { + if (changeset.sidebarRelativePosition === 0) { + this._areaLayer.addChangesetLayer(changeset); + } + } + + for (const changeset of this._changesets.values()) { + if (changeset.sidebarRelativePosition !== 0) { + this._borderLayer.addChangesetLayer(changeset); + } + } + + for (const changeset of this._changesets.values()) { + if (changeset.sidebarRelativePosition === 0) { + this._outlineLayer.addChangesetLayer(changeset); + } + } for (const changeset of this._changesets.values()) { - const rect = L.rectangle(changeset.bounds, this._getInteractiveStyle(changeset)); - rect.id = changeset.id; - rect.addTo(this._interactiveLayer); + if (changeset.sidebarRelativePosition === 0) { + this._borderLayer.addChangesetLayer(changeset); + } } }, @@ -103,14 +178,12 @@ OSM.HistoryChangesetsLayer = L.FeatureGroup.extend({ const changeset = this._changesets.get(id); if (!changeset) return; - let highlightRect = this._highlightLayer.getLayer(id); - if (!state && highlightRect) { - this._highlightLayer.removeLayer(highlightRect); - } - if (state && !highlightRect) { - highlightRect = L.rectangle(changeset.bounds, this._getHighlightStyle(changeset)); - highlightRect.id = id; - this._highlightLayer.addLayer(highlightRect); + if (state) { + this._highlightBackLayer.addChangesetLayer(changeset); + this._highlightBorderLayer.addChangesetLayer(changeset); + } else { + this._highlightBackLayer.removeLayer(id); + this._highlightBorderLayer.removeLayer(id); } }, @@ -124,9 +197,11 @@ OSM.HistoryChangesetsLayer = L.FeatureGroup.extend({ OSM.HistoryChangesetsLayer.addInitHook(function () { this._changesets = new Map; - this._interactiveLayer = L.featureGroup(); - this._highlightLayer = L.featureGroup(); - - this._interactiveLayer.getLayerId = this._highlightLayer.getLayerId = (layer) => layer.id; - this.addLayer(this._interactiveLayer).addLayer(this._highlightLayer); + this._bboxLayers = [ + this._areaLayer = new OSM.HistoryChangesetBboxAreaLayer().addTo(this), + this._outlineLayer = new OSM.HistoryChangesetBboxOutlineLayer().addTo(this), + this._borderLayer = new OSM.HistoryChangesetBboxBorderLayer().addTo(this), + this._highlightBackLayer = new OSM.HistoryChangesetBboxHighlightBackLayer().addTo(this), + this._highlightBorderLayer = new OSM.HistoryChangesetBboxHighlightBorderLayer().addTo(this) + ]; }); diff --git a/app/assets/javascripts/osm.js.erb b/app/assets/javascripts/osm.js.erb index c7887b63d..445ab4a08 100644 --- a/app/assets/javascripts/osm.js.erb +++ b/app/assets/javascripts/osm.js.erb @@ -89,9 +89,7 @@ OSM = { // Decide on a map starting position. Various ways of doing this. if (hash.center) { - mapParams.lon = hash.center.lng; - mapParams.lat = hash.center.lat; - mapParams.zoom = hash.zoom; + Object.assign(mapParams, hash); } else if (params.has("bbox")) { const [minlon, minlat, maxlon, maxlat] = params.get("bbox").split(","); mapParams.bounds = bboxToLatLngBounds({ minlon, minlat, maxlon, maxlat }); @@ -159,22 +157,20 @@ OSM = { }, parseHash: function (hash = location.hash) { - const args = {}; - const i = hash.indexOf("#"); - if (i < 0) { - return args; - } - - const hashParams = new URLSearchParams(hash.slice(i + 1)); + if (i < 0) return {}; - const map = (hashParams.get("map") || "").split("/"), + const hashParams = new URLSearchParams(hash.slice(i + 1)), + map = (hashParams.get("map") || "").split("/"), zoom = parseInt(map[0], 10), lat = parseFloat(map[1]), - lon = parseFloat(map[2]); + lon = parseFloat(map[2]), + args = {}; if (!isNaN(zoom) && !isNaN(lat) && !isNaN(lon)) { args.center = new L.LatLng(lat, lon); + args.lat = lat; + args.lon = lon; args.zoom = zoom; } diff --git a/app/assets/javascripts/user.js b/app/assets/javascripts/user.js index 028bf2074..7d2dc23d0 100644 --- a/app/assets/javascripts/user.js +++ b/app/assets/javascripts/user.js @@ -33,95 +33,85 @@ $(function () { map.setView([0, 0], 0); } - if ($("#map").hasClass("set_location")) { - marker = L.marker([0, 0], { - icon: OSM.getMarker({}), - keyboard: false, - interactive: false - }); - - if (OSM.home) { - marker.setLatLng([OSM.home.lat, OSM.home.lon]); - marker.addTo(map); - } + marker = L.marker([0, 0], { + icon: OSM.getMarker({}), + keyboard: false, + interactive: false + }); - map.on("click", function (e) { - if (!$("#updatehome").is(":checked")) return; + if (OSM.home) { + marker.setLatLng([OSM.home.lat, OSM.home.lon]); + marker.addTo(map); + } - const [lat, lon] = OSM.cropLocation(e.latlng, map.getZoom()); + map.on("click", function (e) { + if (!$("#updatehome").is(":checked")) return; - $("#home_lat").val(lat); - $("#home_lon").val(lon); + const [lat, lon] = OSM.cropLocation(e.latlng, map.getZoom()); - clearDeletedText(); - respondToHomeLatLonUpdate(); - }).on("moveend", function () { - const lat = $("#home_lat").val().trim(), - lon = $("#home_lon").val().trim(); - let location; + $("#home_lat").val(lat); + $("#home_lon").val(lon); - try { - if (lat && lon) { - location = L.latLng(lat, lon); - } - } catch (error) { - // keep location undefined + clearDeletedText(); + respondToHomeLatLonUpdate(); + }).on("moveend", function () { + const lat = $("#home_lat").val().trim(), + lon = $("#home_lon").val().trim(); + let location; + + try { + if (lat && lon) { + location = L.latLng(lat, lon); } + } catch (error) { + // keep location undefined + } - $("#home_show").prop("disabled", !location || isCloseEnoughToMapCenter(location)); - }); + $("#home_show").prop("disabled", !location || isCloseEnoughToMapCenter(location)); + }); - $("#home_lat, #home_lon").on("input", function () { - clearDeletedText(); - respondToHomeLatLonUpdate(); - }); + $("#home_lat, #home_lon").on("input", function () { + clearDeletedText(); + respondToHomeLatLonUpdate(); + }); - $("#home_location_name").on("input", function () { - homeLocationNameGeocoder.autofill = false; - clearDeletedText(); + $("#home_location_name").on("input", function () { + homeLocationNameGeocoder.autofill = false; + clearDeletedText(); - respondToHomeLatLonUpdate(false); - }); + respondToHomeLatLonUpdate(false); + }); - $("#home_show").click(function () { - const lat = $("#home_lat").val(), - lon = $("#home_lon").val(); + $("#home_show").click(function () { + const lat = $("#home_lat").val(), + lon = $("#home_lon").val(); - map.setView([lat, lon], defaultHomeZoom); - }); + map.setView([lat, lon], defaultHomeZoom); + }); - $("#home_delete").click(function () { - const lat = $("#home_lat").val(), - lon = $("#home_lon").val(), - locationName = $("#home_location_name").val(); + $("#home_delete").click(function () { + const lat = $("#home_lat").val(), + lon = $("#home_lon").val(), + locationName = $("#home_location_name").val(); - $("#home_lat, #home_lon, #home_location_name").val(""); - deleted_lat = lat; - deleted_lon = lon; - deleted_home_name = locationName; + $("#home_lat, #home_lon, #home_location_name").val(""); + deleted_lat = lat; + deleted_lon = lon; + deleted_home_name = locationName; - respondToHomeLatLonUpdate(false); - $("#home_undelete").trigger("focus"); - }); + respondToHomeLatLonUpdate(false); + $("#home_undelete").trigger("focus"); + }); - $("#home_undelete").click(function () { - $("#home_lat").val(deleted_lat); - $("#home_lon").val(deleted_lon); - $("#home_location_name").val(deleted_home_name); - clearDeletedText(); + $("#home_undelete").click(function () { + $("#home_lat").val(deleted_lat); + $("#home_lon").val(deleted_lon); + $("#home_location_name").val(deleted_home_name); + clearDeletedText(); - respondToHomeLatLonUpdate(false); - $("#home_delete").trigger("focus"); - }); - } else { - $("[data-user]").each(function () { - const user = $(this).data("user"); - if (user.lon && user.lat) { - L.marker([user.lat, user.lon], { icon: OSM.getMarker({ icon: user.icon }) }).addTo(map) - .bindPopup(user.description, { minWidth: 200 }); - } - }); - } + respondToHomeLatLonUpdate(false); + $("#home_delete").trigger("focus"); + }); } function respondToHomeLatLonUpdate(updateLocationName = true) { diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss index ec5b22396..dd1e3393c 100644 --- a/app/assets/stylesheets/common.scss +++ b/app/assets/stylesheets/common.scss @@ -606,6 +606,7 @@ tr.turn { .changeset-above-sidebar-viewport { --changeset-border-color: #CC7755; --changeset-fill-color: #888888; + --changeset-outline-color: #FFFFFF; } .changeset-in-sidebar-viewport { --changeset-border-color: #FF9500; @@ -613,10 +614,12 @@ tr.turn { --changeset-border-color: #FF6600; } --changeset-fill-color: #FFFFAF; + --changeset-outline-color: #FFFFFF; } .changeset-below-sidebar-viewport { --changeset-border-color: #8888AA; --changeset-fill-color: #888888; + --changeset-outline-color: #FFFFFF; } #sidebar .changesets { diff --git a/app/controllers/api/changesets/closes_controller.rb b/app/controllers/api/changesets/closes_controller.rb new file mode 100644 index 000000000..e3a6f9319 --- /dev/null +++ b/app/controllers/api/changesets/closes_controller.rb @@ -0,0 +1,31 @@ +module Api + module Changesets + class ClosesController < ApiController + before_action :check_api_writable + before_action :authorize + + authorize_resource :class => Changeset + + before_action :require_public_data + + # Helper methods for checking consistency + include ConsistencyValidations + + ## + # marks a changeset as closed. this may be called multiple times + # on the same changeset, so is idempotent. + def update + changeset = Changeset.find(params[:changeset_id]) + check_changeset_consistency(changeset, current_user) + + # to close the changeset, we'll just set its closed_at time to + # now. this might not be enough if there are concurrency issues, + # but we'll have to wait and see. + changeset.set_closed_time_now + + changeset.save! + head :ok + end + end + end +end diff --git a/app/controllers/api/changesets_controller.rb b/app/controllers/api/changesets_controller.rb index 891e2175b..1a60fa99b 100644 --- a/app/controllers/api/changesets_controller.rb +++ b/app/controllers/api/changesets_controller.rb @@ -6,12 +6,12 @@ module Api before_action :check_api_writable, :only => [:create, :update, :upload] before_action :setup_user_auth, :only => [:show] - before_action :authorize, :only => [:create, :update, :upload, :close] + before_action :authorize, :only => [:create, :update, :upload] authorize_resource - before_action :require_public_data, :only => [:create, :update, :upload, :close] - before_action :set_request_formats, :except => [:create, :close, :upload] + before_action :require_public_data, :only => [:create, :update, :upload] + before_action :set_request_formats, :except => [:create, :upload] skip_around_action :api_call_timeout, :only => [:upload] @@ -87,22 +87,6 @@ module Api render :plain => cs.id.to_s end - ## - # marks a changeset as closed. this may be called multiple times - # on the same changeset, so is idempotent. - def close - changeset = Changeset.find(params[:id]) - check_changeset_consistency(changeset, current_user) - - # to close the changeset, we'll just set its closed_at time to - # now. this might not be enough if there are concurrency issues, - # but we'll have to wait and see. - changeset.set_closed_time_now - - changeset.save! - head :ok - end - ## # Upload a diff in a single transaction. # diff --git a/app/views/dashboards/show.html.erb b/app/views/dashboards/show.html.erb index 78ff0560f..fe748e1a3 100644 --- a/app/views/dashboards/show.html.erb +++ b/app/views/dashboards/show.html.erb @@ -10,7 +10,7 @@ <% else %> <% content_for :head do %> - <%= javascript_include_tag "user" %> + <%= javascript_include_tag "dashboard" %> <% end %> <% user_data = { :lon => current_user.home_lon, diff --git a/app/views/directions/search.html.erb b/app/views/directions/search.html.erb index a7f83be3f..e64d3c23b 100644 --- a/app/views/directions/search.html.erb +++ b/app/views/directions/search.html.erb @@ -78,4 +78,48 @@ <%= render "sidebar_header", :title => t(".title") %> -
+ + + + + diff --git a/app/views/layouts/_search.html.erb b/app/views/layouts/_search.html.erb index 68c28c332..4d0456236 100644 --- a/app/views/layouts/_search.html.erb +++ b/app/views/layouts/_search.html.erb @@ -89,13 +89,5 @@ <%= submit_tag t("site.search.submit_text"), :class => "routing_go btn btn-primary py-1 px-2", :data => { :disable_with => false } %> - -
-
-
- <%= t("browse.start_rjs.loading") %> -
-
-
diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb index a7dd437e5..57e5acda7 100644 --- a/app/views/profiles/edit.html.erb +++ b/app/views/profiles/edit.html.erb @@ -57,7 +57,7 @@ checked <% end %> id="updatehome" /> - <%= tag.div "", :id => "map", :class => "content_map set_location border border-secondary-subtle rounded z-0" %> + <%= tag.div "", :id => "map", :class => "content_map border border-secondary-subtle rounded z-0" %> <%= f.primary t(".save") %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 6e8ee4762..1500b2e6a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1544,6 +1544,15 @@ en: directions: search: title: Directions + distance: "Distance" + time: "Time" + ascend: "Ascend" + descend: "Descend" + button_km: "km" + button_mi: "mi" + download: "Download route as GeoJSON" + filename: "route" + directions_courtesy_html: "Directions courtesy of %{link}" issues: index: title: Issues @@ -3298,11 +3307,11 @@ en: embed_html_disabled: HTML embedding is not available for this map layer edit_help: Move the map and zoom in on a location you want to edit, then click here. directions: - ascend: "Ascend" - descend: "Descend" - distance: "Distance" - distance_m: "%{distance}m" - distance_km: "%{distance}km" + distance_in_units: + m: "%{distance}m" + km: "%{distance}km" + ft: "%{distance}ft" + mi: "%{distance}mi" errors: no_route: "Couldn't find a route between those two places." no_place: "Sorry - couldn't locate '%{place}'." @@ -3361,7 +3370,6 @@ en: exit_roundabout: Exit the roundabout onto %{name} ferry_without_exit: Take the ferry %{name} unnamed: "unnamed road" - courtesy: "Directions courtesy of %{link}" exit_counts: first: "1st" second: "2nd" @@ -3373,9 +3381,6 @@ en: eighth: "8th" ninth: "9th" tenth: "10th" - time: "Time" - download: "Download route as GeoJSON" - filename: "route" query: node: Node way: Way diff --git a/config/routes.rb b/config/routes.rb index 93aa2f9fd..3aef845c4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,12 +18,12 @@ OpenStreetMap::Application.routes.draw do get "permissions" => "permissions#show" post "changeset/:id/upload" => "changesets#upload", :as => :changeset_upload, :id => /\d+/ - put "changeset/:id/close" => "changesets#close", :as => :changeset_close, :id => /\d+/ end namespace :api, :path => "api/0.6" do resources :changesets, :only => [:index, :create] resources :changesets, :path => "changeset", :id => /\d+/, :only => [:show, :update] do + resource :close, :module => :changesets, :only => :update resource :download, :module => :changesets, :only => :show resource :subscription, :controller => :changeset_subscriptions, :only => [:create, :destroy] resources :changeset_comments, :path => "comment", :only => :create diff --git a/test/controllers/api/changesets/closes_controller_test.rb b/test/controllers/api/changesets/closes_controller_test.rb new file mode 100644 index 000000000..ca735469a --- /dev/null +++ b/test/controllers/api/changesets/closes_controller_test.rb @@ -0,0 +1,130 @@ +require "test_helper" + +module Api + module Changesets + class ClosesControllerTest < ActionDispatch::IntegrationTest + ## + # test all routes which lead to this controller + def test_routes + assert_routing( + { :path => "/api/0.6/changeset/1/close", :method => :put }, + { :controller => "api/changesets/closes", :action => "update", :changeset_id => "1" } + ) + + assert_raises(ActionController::UrlGenerationError) do + put api_changeset_close_path(-132) + end + end + + def test_update_missing_changeset_when_unauthorized + put api_changeset_close_path(999111) + + assert_response :unauthorized + end + + def test_update_missing_changeset_by_regular_user + auth_header = bearer_authorization_header + + put api_changeset_close_path(999111), :headers => auth_header + + assert_response :not_found + end + + def test_update_when_unauthorized + changeset = create(:changeset) + + put api_changeset_close_path(changeset) + + assert_response :unauthorized + assert_predicate changeset.reload, :open? + end + + def test_update_by_private_user + user = create(:user, :data_public => false) + changeset = create(:changeset, :user => user) + auth_header = bearer_authorization_header user + + put api_changeset_close_path(changeset), :headers => auth_header + + assert_require_public_data + assert_predicate changeset.reload, :open? + end + + def test_update_by_changeset_non_creator + user = create(:user) + changeset = create(:changeset) + auth_header = bearer_authorization_header user + + put api_changeset_close_path(changeset), :headers => auth_header + + assert_response :conflict + assert_equal "The user doesn't own that changeset", @response.body + assert_predicate changeset.reload, :open? + end + + def test_update_without_required_scope + user = create(:user) + changeset = create(:changeset, :user => user) + auth_header = bearer_authorization_header user, :scopes => %w[read_prefs] + + put api_changeset_close_path(changeset), :headers => auth_header + + assert_response :forbidden + assert_predicate changeset.reload, :open? + end + + def test_update_by_changeset_creator_with_required_scope + user = create(:user) + changeset = create(:changeset, :user => user) + auth_header = bearer_authorization_header user, :scopes => %w[write_api] + + put api_changeset_close_path(changeset), :headers => auth_header + + assert_response :success + assert_not_predicate changeset.reload, :open? + end + + def test_update_twice + user = create(:user) + auth_header = bearer_authorization_header user + + freeze_time do + changeset = create(:changeset, :user => user) + + travel 30.minutes + put api_changeset_close_path(changeset), :headers => auth_header + + assert_response :success + changeset.reload + assert_not_predicate changeset, :open? + assert_equal 0.minutes.ago, changeset.closed_at + + travel 30.minutes + put api_changeset_close_path(changeset), :headers => auth_header + + assert_response :conflict + changeset.reload + assert_not_predicate changeset, :open? + assert_equal 30.minutes.ago, changeset.closed_at + end + end + + ## + # test that you can't close using another method + def test_update_method_invalid + user = create(:user) + changeset = create(:changeset, :user => user) + + auth_header = bearer_authorization_header user + + get api_changeset_close_path(changeset), :headers => auth_header + assert_response :not_found + assert_template "rescues/routing_error" + + post api_changeset_close_path(changeset), :headers => auth_header + assert_response :not_found + assert_template "rescues/routing_error" + end + end + end +end diff --git a/test/controllers/api/changesets_controller_test.rb b/test/controllers/api/changesets_controller_test.rb index c20b9bddc..05aa96b15 100644 --- a/test/controllers/api/changesets_controller_test.rb +++ b/test/controllers/api/changesets_controller_test.rb @@ -33,10 +33,6 @@ module Api { :path => "/api/0.6/changeset/1/upload", :method => :post }, { :controller => "api/changesets", :action => "upload", :id => "1" } ) - assert_routing( - { :path => "/api/0.6/changeset/1/close", :method => :put }, - { :controller => "api/changesets", :action => "close", :id => "1" } - ) assert_recognizes( { :controller => "api/changesets", :action => "create" }, @@ -651,89 +647,6 @@ module Api end end - ## - # test that the user who opened a change can close it - def test_close - private_user = create(:user, :data_public => false) - private_changeset = create(:changeset, :user => private_user) - user = create(:user) - changeset = create(:changeset, :user => user) - - ## Try without authentication - put changeset_close_path(changeset) - assert_response :unauthorized - - ## Try using the non-public user - auth_header = bearer_authorization_header private_user - put changeset_close_path(private_changeset), :headers => auth_header - assert_require_public_data - - ## The try with the public user - auth_header = bearer_authorization_header user - - cs_id = changeset.id - put changeset_close_path(cs_id), :headers => auth_header - assert_response :success - - # test that it really is closed now - cs = Changeset.find(changeset.id) - assert_not(cs.open?, - "changeset should be closed now (#{cs.closed_at} > #{Time.now.utc}.") - end - - ## - # test that a different user can't close another user's changeset - def test_close_invalid - user = create(:user) - changeset = create(:changeset) - - auth_header = bearer_authorization_header user - - put changeset_close_path(changeset), :headers => auth_header - assert_response :conflict - assert_equal "The user doesn't own that changeset", @response.body - end - - ## - # test that you can't close using another method - def test_close_method_invalid - user = create(:user) - changeset = create(:changeset, :user => user) - - auth_header = bearer_authorization_header user - - get changeset_close_path(changeset), :headers => auth_header - assert_response :not_found - assert_template "rescues/routing_error" - - post changeset_close_path(changeset), :headers => auth_header - assert_response :not_found - assert_template "rescues/routing_error" - end - - ## - # check that you can't close a changeset that isn't found - def test_close_not_found - cs_ids = [0, -132, "123"] - - # First try to do it with no auth - cs_ids.each do |id| - put changeset_close_path(id) - assert_response :unauthorized, "Shouldn't be able close the non-existant changeset #{id}, when not authorized" - rescue ActionController::UrlGenerationError => e - assert_match(/No route matches/, e.to_s) - end - - # Now try with auth - auth_header = bearer_authorization_header - cs_ids.each do |id| - put changeset_close_path(id), :headers => auth_header - assert_response :not_found, "The changeset #{id} doesn't exist, so can't be closed" - rescue ActionController::UrlGenerationError => e - assert_match(/No route matches/, e.to_s) - end - end - ## # upload something simple, but valid and check that it can # be read back ok diff --git a/test/system/dashboard_test.rb b/test/system/dashboard_test.rb index 34c348d84..9df3e208c 100644 --- a/test/system/dashboard_test.rb +++ b/test/system/dashboard_test.rb @@ -49,4 +49,25 @@ class DashboardSystemTest < ApplicationSystemTestCase assert_link "Unfollow", :below => followings_heading, :above => others_nearby_heading end end + + test "show map with home marker if home location is set" do + user = create(:user, :display_name => "Fred Tester", :home_lon => 1.1, :home_lat => 1.1) + sign_in_as(user) + + visit dashboard_path + + within "#map" do + assert_no_text "Your location" + assert_no_link "Fred Tester" + + find("img.leaflet-marker-icon").click + + assert_text "Your location" + assert_link "Fred Tester" + + click_on "Fred Tester" + end + + assert_current_path user_path(user) + end end