Add privacy-first request location picker

This commit is contained in:
SimpleTest 2026-07-22 14:49:42 +03:00
parent 3d320ce182
commit b0abe5248c
18 changed files with 5846 additions and 2496 deletions

View File

@ -308,3 +308,255 @@ html {
transform: translateY(-2px);
box-shadow: 0 18px 48px rgb(23 37 31 / 0.10);
}
.request-location-workspace {
padding: 1.25rem;
border: 1px solid var(--color-base-300);
border-radius: 1.25rem;
background: var(--color-base-100);
}
.request-location-modes {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: hidden;
border: 1px solid var(--color-base-300);
border-radius: 0.75rem;
}
.request-location-mode {
position: relative;
display: flex;
min-height: 4.25rem;
cursor: pointer;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-inline-start: 1px solid var(--color-base-300);
background: var(--color-base-100);
transition: color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
}
.request-location-mode:first-child {
border-inline-start: 0;
}
.request-location-mode input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.request-location-mode span {
display: grid;
gap: 0.1rem;
}
.request-location-mode strong,
.request-location-mode small {
display: block;
}
.request-location-mode strong {
font-size: 0.9rem;
}
.request-location-mode small {
color: color-mix(in oklab, var(--color-base-content) 58%, transparent);
font-size: 0.68rem;
line-height: 1.25;
}
.request-location-mode:has(input:checked) {
z-index: 1;
background: color-mix(in oklab, var(--color-success) 9%, var(--color-base-100));
color: var(--color-success);
box-shadow: inset 0 0 0 1.5px var(--color-success);
}
.request-location-mode:has(input:focus-visible) {
outline: 3px solid color-mix(in oklab, var(--color-success) 38%, transparent);
outline-offset: -3px;
}
.request-location-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(14rem, 17rem);
gap: 1rem;
}
.request-location-map-shell {
position: relative;
min-width: 0;
}
.request-location-map {
min-height: 31rem;
border-radius: 0.9rem;
}
.request-location-map[data-map-unavailable="true"] {
display: grid;
place-items: center;
padding: 2rem;
text-align: center;
color: color-mix(in oklab, var(--color-base-content) 65%, transparent);
}
.request-location-hidden-state {
display: none;
min-height: 16rem;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
border: 1px dashed var(--color-base-300);
border-radius: 0.9rem;
background: var(--color-base-200);
}
.request-location-controls {
padding: 1rem;
border: 1px solid var(--color-base-300);
border-radius: 0.9rem;
background: var(--color-base-100);
}
.request-location-radius label {
position: relative;
cursor: pointer;
}
.request-location-radius input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.request-location-radius span {
display: grid;
min-height: 2.5rem;
place-items: center;
padding: 0.5rem;
border: 1px solid var(--color-base-300);
border-radius: 0.5rem;
font-size: 0.82rem;
font-weight: 700;
}
.request-location-radius label:has(input:checked) span {
border-color: var(--color-success);
background: color-mix(in oklab, var(--color-success) 9%, var(--color-base-100));
color: var(--color-success);
}
.request-location-radius label:has(input:focus-visible) span {
outline: 3px solid color-mix(in oklab, var(--color-success) 38%, transparent);
outline-offset: 2px;
}
.request-location-privacy,
.request-location-exact-warning,
.request-location-after-match {
display: flex;
gap: 0.75rem;
padding: 0.875rem;
border: 1px solid var(--color-base-300);
border-radius: 0.75rem;
font-size: 0.75rem;
line-height: 1.35;
}
.request-location-privacy {
border-color: color-mix(in oklab, var(--color-success) 36%, var(--color-base-300));
background: color-mix(in oklab, var(--color-success) 8%, var(--color-base-100));
color: color-mix(in oklab, var(--color-success) 85%, var(--color-base-content));
}
.request-location-exact-warning {
border-color: color-mix(in oklab, var(--color-warning) 42%, var(--color-base-300));
background: color-mix(in oklab, var(--color-warning) 8%, var(--color-base-100));
}
.request-location-privacy p,
.request-location-exact-warning p,
.request-location-after-match p {
margin-top: 0.2rem;
color: color-mix(in oklab, var(--color-base-content) 65%, transparent);
}
.request-location-exact-warning {
display: none;
}
.request-location-manual {
padding-top: 0.75rem;
border-top: 1px solid var(--color-base-300);
}
.request-location-manual summary {
cursor: pointer;
font-size: 0.85rem;
font-weight: 700;
}
.request-location-workspace[data-location-mode="exact_public"] .request-location-radius,
.request-location-workspace[data-location-mode="exact_public"] .request-location-privacy {
display: none;
}
.request-location-workspace[data-location-mode="exact_public"] .request-location-exact-warning {
display: flex;
}
.request-location-workspace[data-location-mode="hidden"] .request-location-grid {
grid-template-columns: 1fr;
}
.request-location-workspace[data-location-mode="hidden"] .request-location-map,
.request-location-workspace[data-location-mode="hidden"] .request-location-controls,
.request-location-workspace[data-location-mode="hidden"] .request-location-manual {
display: none;
}
.request-location-workspace[data-location-mode="hidden"] .request-location-hidden-state {
display: flex;
}
@media (max-width: 63.999rem) {
.request-location-grid {
grid-template-columns: 1fr;
}
.request-location-map {
min-height: 26rem;
}
}
@media (max-width: 47.999rem) {
.request-location-workspace {
padding: 1rem;
}
.request-location-modes {
grid-template-columns: 1fr;
}
.request-location-mode {
min-height: 3.75rem;
justify-content: flex-start;
border-inline-start: 0;
border-top: 1px solid var(--color-base-300);
}
.request-location-mode:first-child {
border-top: 0;
}
.request-location-map {
min-height: 22rem;
}
}

View File

@ -29,11 +29,66 @@ const markerPoints = element => {
}
}
const emptyFeatureCollection = () => ({type: "FeatureCollection", features: []})
const areaPolygon = ({longitude, latitude, radius_meters: radiusMeters}) => {
const earthRadiusMeters = 6371008.8
const angularDistance = radiusMeters / earthRadiusMeters
const latitudeRadians = latitude * Math.PI / 180
const longitudeRadians = longitude * Math.PI / 180
const coordinates = []
for (let step = 0; step <= 64; step += 1) {
const bearing = step / 64 * Math.PI * 2
const pointLatitude = Math.asin(
Math.sin(latitudeRadians) * Math.cos(angularDistance) +
Math.cos(latitudeRadians) * Math.sin(angularDistance) * Math.cos(bearing)
)
const pointLongitude = longitudeRadians + Math.atan2(
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latitudeRadians),
Math.cos(angularDistance) - Math.sin(latitudeRadians) * Math.sin(pointLatitude)
)
coordinates.push([pointLongitude * 180 / Math.PI, pointLatitude * 180 / Math.PI])
}
return {
type: "Feature",
properties: {},
geometry: {type: "Polygon", coordinates: [coordinates]}
}
}
const areaFeatureCollection = points => ({
type: "FeatureCollection",
features: points
.filter(point => !point.exact && Number.isFinite(point.radius_meters))
.map(areaPolygon)
})
const extendBoundsWithFeature = (bounds, feature) => {
feature.geometry.coordinates[0].forEach(coordinate => bounds.extend(coordinate))
}
const privacySafeAreaCenter = (latitude, longitude, radiusMeters) => {
const latitudeStep = radiusMeters * Math.SQRT2 / 111320
const longitudeScale = Math.max(Math.cos(latitude * Math.PI / 180), 0.2)
const longitudeStep = latitudeStep / longitudeScale
return {
latitude: Math.round(latitude / latitudeStep) * latitudeStep,
longitude: Math.round(longitude / longitudeStep) * longitudeStep
}
}
const createAidMap = element => {
const state = {
element,
map: null,
markers: []
markers: [],
areaSourceId: `${element.id || "aid-map"}-areas`,
areaFillId: `${element.id || "aid-map"}-area-fill`,
areaLineId: `${element.id || "aid-map"}-area-line`
}
state.markLoading = () => {
@ -106,7 +161,36 @@ const createAidMap = element => {
state.markers = []
const points = markerPoints(state.element)
points.forEach(point => {
const exactPoints = points.filter(point => point.exact || !Number.isFinite(point.radius_meters))
const areaFeatures = areaFeatureCollection(points)
if (state.map.isStyleLoaded()) {
const source = state.map.getSource(state.areaSourceId)
if (source) {
source.setData(areaFeatures)
} else {
state.map.addSource(state.areaSourceId, {type: "geojson", data: areaFeatures})
state.map.addLayer({
id: state.areaFillId,
type: "fill",
source: state.areaSourceId,
paint: {"fill-color": "#278467", "fill-opacity": 0.18}
})
state.map.addLayer({
id: state.areaLineId,
type: "line",
source: state.areaSourceId,
paint: {
"line-color": "#16725b",
"line-width": 2,
"line-dasharray": [2, 2]
}
})
}
}
exactPoints.forEach(point => {
const popupContent = document.createElement("div")
const title = document.createElement("strong")
title.textContent = point.title || ""
@ -121,12 +205,11 @@ const createAidMap = element => {
state.markers.push(marker)
})
if (points.length === 1) {
state.map.flyTo({center: [points[0].longitude, points[0].latitude], zoom: 12})
} else if (points.length > 1) {
if (points.length > 0) {
const bounds = new maplibregl.LngLatBounds()
points.forEach(point => bounds.extend([point.longitude, point.latitude]))
state.map.fitBounds(bounds, {padding: 52, maxZoom: 13})
exactPoints.forEach(point => bounds.extend([point.longitude, point.latitude]))
areaFeatures.features.forEach(feature => extendBoundsWithFeature(bounds, feature))
state.map.fitBounds(bounds, {padding: 52, maxZoom: 13, duration: 0})
}
}
@ -161,15 +244,20 @@ const createAidMap = element => {
new maplibregl.NavigationControl({showCompass: !demoMap}),
demoMap ? "bottom-right" : "top-right"
)
state.onLoad = () => {
state.markReady()
state.renderMarkers()
}
state.map.on("dataloading", state.markLoading)
state.map.on("load", state.markReady)
state.map.on("load", state.onLoad)
state.map.on("idle", state.markReady)
state.renderMarkers()
}
state.destroy = () => {
state.map?.off("dataloading", state.markLoading)
state.map?.off("load", state.markReady)
state.map?.off("load", state.onLoad)
state.map?.off("idle", state.markReady)
state.map?.remove()
}
@ -479,6 +567,320 @@ export const Hooks = {
}
},
RequestLocationPicker: {
mounted() {
this.map = null
this.marker = null
this.mapLoaded = false
this.areaSourceId = `${this.el.id}-selection-area`
this.areaFillId = `${this.el.id}-selection-fill`
this.areaLineId = `${this.el.id}-selection-line`
this.cacheElements = () => {
this.mapElement = this.el.querySelector(this.el.dataset.mapTarget)
this.latitudeInput = this.el.querySelector(this.el.dataset.latitudeTarget)
this.longitudeInput = this.el.querySelector(this.el.dataset.longitudeTarget)
this.statusElement = this.el.querySelector(this.el.dataset.statusTarget)
}
this.mode = () =>
this.el.querySelector("[data-location-mode-input]:checked")?.value ||
this.el.dataset.locationMode ||
"approximate_public"
this.radius = () => {
const value = Number(
this.el.querySelector("[data-location-radius-input]:checked")?.value || 1000
)
return [500, 1000, 2000].includes(value) ? value : 1000
}
this.coordinates = () => {
const latitudeValue = String(this.latitudeInput?.value || "").trim()
const longitudeValue = String(this.longitudeInput?.value || "").trim()
if (latitudeValue === "" || longitudeValue === "") return null
const latitude = Number(latitudeValue)
const longitude = Number(longitudeValue)
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) return null
return {latitude, longitude}
}
this.setStatus = message => {
if (this.statusElement) this.statusElement.textContent = message
}
this.setSelectionStatus = () => {
if (!this.coordinates()) {
this.setStatus(this.el.dataset.mapReadyLabel)
return
}
this.setStatus(
this.mode() === "approximate_public"
? this.el.dataset.areaSelectedLabel
: this.el.dataset.pointSelectedLabel
)
}
this.focusSelection = () => {
const coordinates = this.coordinates()
if (!coordinates || !this.map) return
this.map.flyTo({
center: [coordinates.longitude, coordinates.latitude],
zoom: this.mode() === "exact_public" ? 15 : 13
})
}
this.notifyCoordinatesChanged = () => {
this.longitudeInput?.dispatchEvent(new Event("input", {bubbles: true}))
}
this.setCoordinates = ({latitude, longitude}, {notify = true, center = false} = {}) => {
if (!this.latitudeInput || !this.longitudeInput) return
this.latitudeInput.value = latitude.toFixed(6)
this.longitudeInput.value = longitude.toFixed(6)
this.renderSelection()
if (center && this.map) {
this.map.flyTo({center: [longitude, latitude], zoom: this.mode() === "exact_public" ? 15 : 13})
}
if (notify) this.notifyCoordinatesChanged()
}
this.clearCoordinates = ({notify = false} = {}) => {
if (this.latitudeInput) this.latitudeInput.value = ""
if (this.longitudeInput) this.longitudeInput.value = ""
this.renderSelection()
if (notify) this.notifyCoordinatesChanged()
}
this.ensureAreaLayers = () => {
if (!this.mapLoaded || this.map.getSource(this.areaSourceId)) return
this.map.addSource(this.areaSourceId, {type: "geojson", data: emptyFeatureCollection()})
this.map.addLayer({
id: this.areaFillId,
type: "fill",
source: this.areaSourceId,
paint: {"fill-color": "#278467", "fill-opacity": 0.2}
})
this.map.addLayer({
id: this.areaLineId,
type: "line",
source: this.areaSourceId,
paint: {
"line-color": "#16725b",
"line-width": 2,
"line-dasharray": [2, 2]
}
})
}
this.setAreaData = data => {
if (!this.mapLoaded) return
this.ensureAreaLayers()
this.map.getSource(this.areaSourceId)?.setData(data)
}
this.removeMarker = () => {
this.marker?.remove()
this.marker = null
}
this.renderSelection = () => {
const mode = this.mode()
const coordinates = this.coordinates()
this.el.dataset.locationMode = mode
if (!this.mapLoaded) return
if (mode === "hidden") {
this.removeMarker()
this.setAreaData(emptyFeatureCollection())
return
}
if (!coordinates) {
this.removeMarker()
this.setAreaData(emptyFeatureCollection())
return
}
if (mode === "approximate_public") {
this.removeMarker()
this.setAreaData(areaFeatureCollection([{
...coordinates,
exact: false,
radius_meters: this.radius()
}]))
return
}
this.setAreaData(emptyFeatureCollection())
if (!this.marker) {
this.marker = new maplibregl.Marker({color: "#d6573b", draggable: true})
.setLngLat([coordinates.longitude, coordinates.latitude])
.addTo(this.map)
this.marker.on("dragend", () => {
const point = this.marker.getLngLat()
this.setCoordinates({latitude: point.lat, longitude: point.lng})
this.setStatus(this.el.dataset.pointSelectedLabel)
})
} else {
this.marker.setLngLat([coordinates.longitude, coordinates.latitude])
}
}
this.syncMode = () => {
const mode = this.mode()
this.el.dataset.locationMode = mode
if (mode === "hidden") {
this.clearCoordinates()
this.setStatus(this.el.dataset.hiddenLabel)
} else {
this.renderSelection()
this.setSelectionStatus()
window.requestAnimationFrame(() => this.map?.resize())
}
}
this.onChange = event => {
if (event.target.matches("[data-location-mode-input]")) {
this.syncMode()
return
}
if (event.target.matches("[data-location-radius-input]")) {
this.renderSelection()
this.setSelectionStatus()
return
}
if (event.target.matches("[data-coordinate-input]")) {
this.renderSelection()
if (this.coordinates()) {
this.focusSelection()
this.setSelectionStatus()
}
}
}
this.onClick = event => {
const button = event.target.closest("[data-use-current-location]")
if (!button) return
if (!navigator.geolocation) {
this.setStatus(this.el.dataset.locationUnavailableLabel)
return
}
button.disabled = true
navigator.geolocation.getCurrentPosition(
position => {
const raw = {
latitude: position.coords.latitude,
longitude: position.coords.longitude
}
const selected = this.mode() === "approximate_public"
? privacySafeAreaCenter(raw.latitude, raw.longitude, this.radius())
: raw
this.setCoordinates(selected, {center: true})
this.setStatus(
this.mode() === "approximate_public"
? this.el.dataset.areaSelectedLabel
: this.el.dataset.pointSelectedLabel
)
button.disabled = false
},
() => {
this.setStatus(this.el.dataset.locationDeniedLabel)
button.disabled = false
},
{enableHighAccuracy: true, timeout: 10000}
)
}
this.cacheElements()
this.el.addEventListener("change", this.onChange)
this.el.addEventListener("click", this.onClick)
if (!this.mapElement || !supportsMapCanvas()) {
if (this.mapElement) {
this.mapElement.dataset.mapUnavailable = "true"
this.mapElement.textContent = this.mapElement.dataset.mapUnavailableLabel
}
this.syncMode()
return
}
const initialCoordinates = this.coordinates()
this.map = new maplibregl.Map({
container: this.mapElement,
style: defaultStyle,
center: initialCoordinates
? [initialCoordinates.longitude, initialCoordinates.latitude]
: [30.5234, 50.4501],
zoom: initialCoordinates ? 13 : 5,
attributionControl: true,
dragRotate: false,
pitchWithRotate: false
})
this.map.touchZoomRotate.disableRotation()
this.map.addControl(new maplibregl.NavigationControl({showCompass: false}), "top-left")
this.map.on("load", () => {
this.mapLoaded = true
this.mapElement.dataset.mapReady = "true"
this.ensureAreaLayers()
this.renderSelection()
})
this.map.on("click", event => {
if (this.mode() === "hidden") return
this.setCoordinates(
{latitude: event.lngLat.lat, longitude: event.lngLat.lng},
{center: true}
)
this.setStatus(
this.mode() === "approximate_public"
? this.el.dataset.areaSelectedLabel
: this.el.dataset.pointSelectedLabel
)
})
this.syncMode()
},
updated() {
this.cacheElements()
this.syncMode()
},
destroyed() {
this.el.removeEventListener("change", this.onChange)
this.el.removeEventListener("click", this.onClick)
this.removeMarker()
this.map?.remove()
}
},
LocationPicker: {
mounted() {
this.el.addEventListener("click", () => {

35
design-qa.md Normal file
View File

@ -0,0 +1,35 @@
# Request location picker — design QA
## Reference and implementation
- Target image: `/home/simple/.codex/generated_images/019f725d-87b5-79e1-8a9f-66e6eaffb35a/exec-d7ad900f-ed0f-4909-8a64-43bfc32dce7e.png`
- Desktop implementation: `/d/my_projects/who_need_help/output/playwright/location-picker-approximate-final.png`
- Mobile implementation: `/d/my_projects/who_need_help/output/playwright/location-picker-mobile-focused.png`
- Side-by-side comparison: `/d/my_projects/who_need_help/output/playwright/location-picker-comparison.png`
- Viewports checked: desktop `1673×1288`, mobile `390×844`
## Comparison
The implementation preserves the selected direction: a public landmark field, three clearly separated privacy modes, a large interactive map, radius choices for an approximate area, privacy guidance, and an optional manual-coordinate fallback. It adapts the reference into the existing Who Need Help visual system and request form rather than reproducing it as a disconnected screen.
The local end-to-end environment intentionally uses a deterministic solid raster tile. Map controls, marker/circle geometry, dragging, clicking, geolocation handling, and responsive layout are still exercised; the application continues to obtain its real raster tile URL from runtime configuration.
## Findings
- P0: none.
- P1: none.
- P2: none.
- P3: the mobile screenshot shows only the location section because the full request form is vertically scrollable; no horizontal overflow was observed.
## Interaction checks
- Approximate mode stores and displays a bounded `500 m`, `1 km`, or `2 km` public area.
- Browser geolocation is privacy-snapped before it reaches the form in approximate mode.
- Exact mode displays a draggable marker and makes the public disclosure explicit.
- No-map mode clears coordinates and hides the map.
- A participant can later share live location privately after matching, independently of the public request location.
- The manual coordinate fallback remains open while both values are entered.
## Result
Passed. No P0–P2 design discrepancy remains in the checked desktop and mobile states.

View File

@ -7,6 +7,7 @@ import {
projectText,
registerAndConfirm,
selectOptionContaining,
setRequestLocation,
waitForLiveViewConnected,
} from "./helpers";
@ -53,13 +54,14 @@ test("active chat and browser tracking recover after the serving BEAM node resta
.getByLabel("What help do you need?")
.fill("A run-scoped reserved medicine pickup for failover verification.");
await requester.page.getByLabel("Urgency").selectOption("now");
await requester.page
.getByLabel("Request expires (UTC)")
.fill(new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString().slice(0, 16));
await requester.page.getByLabel("Approximate area").fill("Failover district");
await requester.page.getByLabel("Location visibility").selectOption("approximate_public");
await requester.page.getByLabel("Latitude").fill("50.4501");
await requester.page.getByLabel("Longitude").fill("30.5234");
await requester.page.getByRole("button", { name: "In 3 hours" }).click();
await setRequestLocation(requester.page, {
label: "Failover district",
mode: "approximate_public",
latitude: "50.4501",
longitude: "30.5234",
radiusMeters: 1000,
});
await requester.page.locator("#request-form input[type=checkbox]").check();
await requester.page.getByRole("button", { name: "Publish request" }).click();
await expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/);

View File

@ -118,6 +118,49 @@ export async function gotoLiveView(page: Page, url: string): Promise<void> {
await waitForLiveViewConnected(page);
}
export async function setRequestLocation(
page: Page,
options: {
label: string;
mode: "approximate_public" | "exact_public" | "hidden";
latitude?: string;
longitude?: string;
radiusMeters?: 500 | 1000 | 2000;
},
): Promise<void> {
const workspace = page.locator("#request-location-workspace");
await workspace.getByLabel("Area or public landmark").fill(options.label);
await workspace
.locator(
`input[name="help_request[location_visibility]"][value="${options.mode}"]`,
)
.check();
if (options.mode === "hidden") {
await expect(workspace.locator("#request-latitude")).toHaveValue("");
await expect(workspace.locator("#request-longitude")).toHaveValue("");
return;
}
if (options.mode === "approximate_public") {
await workspace
.locator(
`input[name="help_request[location_radius_meters]"][value="${options.radiusMeters ?? 1000}"]`,
)
.check();
}
const manualCoordinates = workspace.locator("details.request-location-manual");
if (!(await manualCoordinates.getAttribute("open"))) {
await manualCoordinates.locator("summary").click();
}
await workspace.getByLabel("Latitude").fill(options.latitude ?? "50.4501");
await workspace.getByLabel("Longitude").fill(options.longitude ?? "30.5234");
}
export async function waitForMapReady(page: Page): Promise<void> {
const map = page.locator(".aid-map");
@ -324,7 +367,7 @@ export async function registerAndConfirm(
await form
.getByRole("button", { name: "Create account and email me a link" })
.click();
await expect(page).toHaveURL(/\/users\/log-in$/);
await expect(page).toHaveURL(/\/users\/check-email\?flow=register$/);
await expect(
page
.getByRole("alert")

View File

@ -9,6 +9,7 @@ import {
projectText,
registerAndConfirm,
selectOptionContaining,
setRequestLocation,
waitForMapReady,
} from "./helpers";
@ -43,14 +44,14 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
.getByLabel("What help do you need?")
.fill("Please collect the legal medicine that is already reserved.");
await requester.page.getByLabel("Urgency").selectOption("now");
const expiresAt = new Date(Date.now() + 3 * 60 * 60 * 1000)
.toISOString()
.slice(0, 16);
await requester.page.getByLabel("Request expires (UTC)").fill(expiresAt);
await requester.page.getByLabel("Approximate area").fill("E2E central district");
await requester.page.getByLabel("Location visibility").selectOption("approximate_public");
await requester.page.getByLabel("Latitude").fill("50.4501");
await requester.page.getByLabel("Longitude").fill("30.5234");
await requester.page.getByRole("button", { name: "In 3 hours" }).click();
await setRequestLocation(requester.page, {
label: "E2E central district",
mode: "approximate_public",
latitude: "50.4501",
longitude: "30.5234",
radiusMeters: 1000,
});
await requester.page.locator("#request-form input[type=checkbox]").check();
await requester.page.getByRole("button", { name: "Publish request" }).click();
await expect(requester.page).toHaveURL(/\/requests\/[0-9a-f-]+$/);
@ -154,13 +155,11 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
.getByLabel("What help do you need?")
.fill("The motorcycle is secured and needs help with a broken chain.");
await requester.page.getByLabel("Urgency").selectOption("today");
await requester.page
.getByLabel("Request expires (UTC)")
.fill(new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString().slice(0, 16));
await requester.page.getByLabel("Approximate area").fill("E2E secured roadside area");
await requester.page.getByLabel("Location visibility").selectOption("hidden");
await requester.page.getByLabel("Latitude").fill("50.4501");
await requester.page.getByLabel("Longitude").fill("30.5234");
await requester.page.getByRole("button", { name: "In 3 hours" }).click();
await setRequestLocation(requester.page, {
label: "E2E secured roadside area",
mode: "hidden",
});
await requester.page.locator("#request-form input[type=checkbox]").last().check();
await requester.page.getByRole("button", { name: "Publish request" }).click();
await expect(requester.page.getByRole("heading", { name: roadsideTitle })).toBeVisible();
@ -173,7 +172,7 @@ test("two users complete medicine tracking, handover, realtime chat, and blind r
);
await clickUntilVisible(
helper.page.getByRole("button", { name: "Withdraw from this request" }),
helper.page.getByText("You withdrew from this request."),
helper.page.getByText("This request was cancelled."),
);
await expect(requester.page.getByText("Cancelled", { exact: true })).toBeVisible();

View File

@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
import {
captureBrowserFailures,
gotoLiveView,
projectEmail,
registerAndConfirm,
} from "./helpers";
test("request location picker separates approximate, exact, and hidden disclosure", async ({
browser,
request,
}, testInfo) => {
const user = await registerAndConfirm(
browser,
request,
projectEmail("location-picker", testInfo.project.name),
"Location Picker",
);
const assertBrowserClean = captureBrowserFailures(user.page);
const origin = new URL(process.env.BASE_URL!).origin;
await user.context.grantPermissions(["geolocation"], { origin });
await user.context.setGeolocation({
latitude: 50.450123,
longitude: 30.523456,
accuracy: 5,
});
await gotoLiveView(user.page, "/requests/new");
const workspace = user.page.locator("#request-location-workspace");
const latitude = workspace.locator("#request-latitude");
const longitude = workspace.locator("#request-longitude");
await workspace.getByRole("button", { name: "Use my location" }).click();
await expect(workspace.locator("#request-location-status")).toHaveText(
"Approximate area selected. Your exact position is not stored.",
);
await expect(latitude).not.toHaveValue("50.450123");
await expect(longitude).not.toHaveValue("30.523456");
await workspace
.locator('input[name="help_request[location_visibility]"][value="exact_public"]')
.check();
await expect(workspace.getByText("This point is public")).toBeVisible();
await workspace.getByRole("button", { name: "Use my location" }).click();
await expect(workspace.locator("#request-location-status")).toHaveText(
"Exact public point selected.",
);
await expect(latitude).toHaveValue("50.450123");
await expect(longitude).toHaveValue("30.523456");
await workspace
.locator('input[name="help_request[location_visibility]"][value="hidden"]')
.check();
await expect(workspace.getByRole("heading", { name: "No coordinates will be stored" }))
.toBeVisible();
await expect(latitude).toHaveValue("");
await expect(longitude).toHaveValue("");
assertBrowserClean();
await user.context.close();
});

View File

@ -508,13 +508,20 @@ defmodule WhoNeedHelp.Help do
if owner or
(matched_participant and request.location_visibility == :exact_for_active_match) do
%Geo.Point{coordinates: {lng, lat}} = request.location
%{latitude: lat, longitude: lng, exact: true}
exact_request_coordinates(request)
else
HelpRequest.public_coordinates(request)
end
end
defp exact_request_coordinates(%HelpRequest{
location: %Geo.Point{coordinates: {lng, lat}}
}) do
%{latitude: lat, longitude: lng, exact: true}
end
defp exact_request_coordinates(%HelpRequest{location: nil}), do: nil
def handover_code(request_id) do
secret = Application.fetch_env!(:who_need_help, :handover_secret)
digest = :crypto.mac(:hmac, :sha256, secret, request_id)

View File

@ -12,6 +12,7 @@ defmodule WhoNeedHelp.Help.HelpRequest do
field :structured_data, :map, default: %{}
field :location_label, :string
field :location, Geo.PostGIS.Geometry
field :location_radius_meters, :integer, default: 1_000
field :status, Ecto.Enum,
values: [:open, :matched, :in_progress, :completed, :cancelled, :expired],
@ -44,6 +45,7 @@ defmodule WhoNeedHelp.Help.HelpRequest do
:pickup_instructions,
:structured_data,
:location_label,
:location_radius_meters,
:urgency,
:location_visibility,
:expires_at,
@ -55,7 +57,6 @@ defmodule WhoNeedHelp.Help.HelpRequest do
:title,
:description,
:location_label,
:location,
:urgency,
:location_visibility,
:expires_at,
@ -65,6 +66,7 @@ defmodule WhoNeedHelp.Help.HelpRequest do
|> validate_length(:description, min: 10, max: 2_000)
|> validate_length(:pickup_instructions, max: 1_000)
|> validate_length(:location_label, max: 255)
|> validate_location()
|> validate_acceptance(:safety_confirmed,
message: "confirm the safety guidance before publishing"
)
@ -78,6 +80,23 @@ defmodule WhoNeedHelp.Help.HelpRequest do
end
defp put_location(changeset, attrs) do
case get_field(changeset, :location_visibility) do
:hidden ->
changeset
|> put_change(:location, nil)
|> put_change(:location_radius_meters, nil)
:exact_public ->
changeset
|> put_location_point(attrs)
|> put_change(:location_radius_meters, nil)
_approximate_or_match_only ->
put_location_point(changeset, attrs)
end
end
defp put_location_point(changeset, attrs) do
lat = attrs["latitude"] || attrs[:latitude]
lng = attrs["longitude"] || attrs[:longitude]
@ -90,6 +109,22 @@ defmodule WhoNeedHelp.Help.HelpRequest do
end
end
defp validate_location(changeset) do
case get_field(changeset, :location_visibility) do
:hidden ->
changeset
visibility when visibility in [:approximate_public, :exact_for_active_match] ->
changeset
|> validate_required([:location])
|> validate_required([:location_radius_meters])
|> validate_inclusion(:location_radius_meters, allowed_location_radii())
_exact_or_invalid ->
validate_required(changeset, [:location])
end
end
defp parse_coordinate(value) when is_float(value), do: {:ok, value}
defp parse_coordinate(value) when is_integer(value), do: {:ok, value * 1.0}
@ -110,11 +145,35 @@ defmodule WhoNeedHelp.Help.HelpRequest do
end)
end
def public_coordinates(%__MODULE__{location: %Geo.Point{coordinates: {lng, lat}}} = request) do
case request.location_visibility do
:hidden -> nil
:exact_public -> %{latitude: lat, longitude: lng, exact: true}
_ -> %{latitude: Float.round(lat, 2), longitude: Float.round(lng, 2), exact: false}
end
def allowed_location_radii, do: [500, 1_000, 2_000]
def public_coordinates(%__MODULE__{location_visibility: :hidden}), do: nil
def public_coordinates(%__MODULE__{location: nil}), do: nil
def public_coordinates(%__MODULE__{
location_visibility: :exact_public,
location: %Geo.Point{coordinates: {lng, lat}}
}) do
%{latitude: lat, longitude: lng, exact: true}
end
def public_coordinates(%__MODULE__{
location_visibility: :approximate_public,
location: %Geo.Point{coordinates: {lng, lat}},
location_radius_meters: radius
}) do
%{latitude: lat, longitude: lng, exact: false, radius_meters: radius || 1_000}
end
def public_coordinates(%__MODULE__{
location: %Geo.Point{coordinates: {lng, lat}},
location_radius_meters: radius
}) do
%{
latitude: Float.round(lat, 2),
longitude: Float.round(lng, 2),
exact: false,
radius_meters: radius || 1_000
}
end
end

View File

@ -9,13 +9,16 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
@impl true
def mount(_params, _session, socket) do
expires = DateTime.utc_now(:second) |> DateTime.add(3, :hour)
default_location_visibility = socket.assigns.current_scope.user.location_visibility
default_location_visibility =
default_location_visibility(socket.assigns.current_scope.user.location_visibility)
changeset =
Help.change_request(%HelpRequest{}, %{
"expires_at" => expires,
"urgency" => "now",
"location_visibility" => to_string(default_location_visibility)
"location_visibility" => to_string(default_location_visibility),
"location_radius_meters" => "1000"
})
{:ok,
@ -92,7 +95,9 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|> Enum.map(fn
{:location, _error} ->
{:location,
gettext("Choose a location using the button or enter both latitude and longitude.")}
gettext(
"Choose an area or exact point on the map, use your location, or select no map location."
)}
{:expires_at, _error} ->
{:expires_at, gettext("Choose an expiry time in the future.")}
@ -109,6 +114,31 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
|> Calendar.strftime("%Y-%m-%dT%H:%M")
end
defp default_location_visibility(visibility)
when visibility in [:exact_public, :hidden],
do: visibility
defp default_location_visibility(_visibility), do: :approximate_public
defp location_mode(form) do
case to_string(form[:location_visibility].value || "approximate_public") do
"exact_public" -> "exact_public"
"hidden" -> "hidden"
_approximate_or_legacy -> "approximate_public"
end
end
defp location_radius(form) do
value = form[:location_radius_meters].value || 1_000
case to_string(value) do
radius when radius in ["500", "1000", "2000"] -> radius
_invalid_or_missing -> "1000"
end
end
defp coordinate_value(form, key), do: Map.get(form.params, key, "")
defp assign_category(socket, params) do
category =
Enum.find(socket.assigns.categories, &(&1.id == params["category_id"]))
@ -342,55 +372,217 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
]}
/>
</div>
<.input
field={@form[:location_label]}
label={gettext("Approximate area")}
placeholder={gettext("Podil, near the central pharmacy")}
/>
<.input
field={@form[:location_visibility]}
type="select"
label={gettext("Location visibility")}
options={[
{gettext("Approximate publicly (recommended)"), "approximate_public"},
{gettext("Hidden"), "hidden"},
{gettext("Exact only for active match"), "exact_for_active_match"},
{gettext("Exact publicly — explicit consent"), "exact_public"}
]}
/>
<div class="rounded-2xl bg-base-200 p-5">
<h2 class="font-bold">{gettext("Location coordinates (required)")}</h2>
<button
type="button"
phx-hook="LocationPicker"
id="location-picker"
data-latitude-target="#request-latitude"
data-longitude-target="#request-longitude"
class="btn btn-outline btn-sm mt-3"
>
{gettext("Use my current foreground location")}
</button>
<p class="mt-2 text-xs text-base-content/60">
{gettext(
"Use the button or enter both coordinates. Public map precision follows the visibility choice above."
)}
</p>
<div class="mt-3 grid grid-cols-2 gap-3">
<section
id="request-location-workspace"
phx-hook="RequestLocationPicker"
data-location-mode={location_mode(@form)}
data-map-target="#request-location-map"
data-latitude-target="#request-latitude"
data-longitude-target="#request-longitude"
data-status-target="#request-location-status"
data-map-ready-label={gettext("Choose a place on the map or use your current location.")}
data-area-selected-label={
gettext("Approximate area selected. Your exact position is not stored.")
}
data-point-selected-label={gettext("Exact public point selected.")}
data-hidden-label={gettext("No map coordinates will be stored.")}
data-location-unavailable-label={gettext("Location is unavailable in this browser.")}
data-location-denied-label={gettext("Location permission was denied.")}
class="request-location-workspace"
>
<div>
<h2 class="text-xl font-black">{gettext("Where is help needed?")}</h2>
<.input
field={@form[:latitude]}
id="request-latitude"
type="number"
step="any"
label={gettext("Latitude")}
/>
<.input
field={@form[:longitude]}
id="request-longitude"
type="number"
step="any"
label={gettext("Longitude")}
field={@form[:location_label]}
label={gettext("Area or public landmark")}
placeholder={gettext("Podil, near the central pharmacy")}
/>
<p class="mt-1 text-sm text-base-content/60">
{gettext("This description is visible to everyone. Do not enter a private address.")}
</p>
</div>
<fieldset class="mt-5">
<legend class="mb-2 font-bold">{gettext("What should people see?")}</legend>
<div class="request-location-modes">
<label class="request-location-mode">
<input
type="radio"
name="help_request[location_visibility]"
value="approximate_public"
checked={location_mode(@form) == "approximate_public"}
data-location-mode-input
/>
<.icon name="hero-map" class="size-5" />
<span>
<strong>{gettext("Approximate area")}</strong>
<small>{gettext("Recommended for privacy")}</small>
</span>
</label>
<label class="request-location-mode">
<input
type="radio"
name="help_request[location_visibility]"
value="exact_public"
checked={location_mode(@form) == "exact_public"}
data-location-mode-input
/>
<.icon name="hero-map-pin" class="size-5" />
<span>
<strong>{gettext("Exact point")}</strong>
<small>{gettext("Visible before matching")}</small>
</span>
</label>
<label class="request-location-mode">
<input
type="radio"
name="help_request[location_visibility]"
value="hidden"
checked={location_mode(@form) == "hidden"}
data-location-mode-input
/>
<.icon name="hero-eye-slash" class="size-5" />
<span>
<strong>{gettext("No map location")}</strong>
<small>{gettext("Only the public area description")}</small>
</span>
</label>
</div>
</fieldset>
<div class="request-location-grid mt-4">
<div class="request-location-map-shell">
<div
id="request-location-map"
phx-update="ignore"
data-map-unavailable-label={
gettext("The map is unavailable. You can use the manual coordinate fallback.")
}
class="aid-map request-location-map"
/>
<div class="request-location-hidden-state">
<.icon name="hero-eye-slash" class="size-8" />
<div>
<h3 class="font-bold">{gettext("No coordinates will be stored")}</h3>
<p class="mt-1 text-sm text-base-content/60">
{gettext(
"Helpers will see only the area or landmark written above. You can share a live location later after matching."
)}
</p>
</div>
</div>
</div>
<aside class="request-location-controls">
<button
type="button"
data-use-current-location
class="btn btn-outline btn-success w-full"
>
<.icon name="hero-paper-airplane" class="size-5" />
{gettext("Use my location")}
</button>
<fieldset class="request-location-radius mt-4">
<legend class="mb-2 text-sm font-bold">{gettext("Area radius")}</legend>
<div class="grid grid-cols-3 gap-2 lg:grid-cols-1">
<label :for={
{label, value} <- [{"500 m", "500"}, {"1 km", "1000"}, {"2 km", "2000"}]
}>
<input
type="radio"
name="help_request[location_radius_meters]"
value={value}
checked={location_radius(@form) == value}
data-location-radius-input
/>
<span>{label}</span>
</label>
</div>
</fieldset>
<div class="request-location-privacy mt-4" data-approximate-guidance>
<.icon name="hero-shield-check" class="size-6 shrink-0" />
<div>
<strong>{gettext("Your exact position is not shown")}</strong>
<p>{gettext("Move the map area so it covers a useful public meeting place.")}</p>
</div>
</div>
<div class="request-location-exact-warning mt-4" data-exact-guidance>
<.icon name="hero-eye" class="size-6 shrink-0" />
<div>
<strong>{gettext("This point is public")}</strong>
<p>{gettext("Use a safe public meeting point instead of a private address.")}</p>
</div>
</div>
<div class="request-location-after-match mt-4">
<.icon name="hero-lock-closed" class="size-6 shrink-0" />
<div>
<strong>{gettext("After a helper accepts")}</strong>
<p>
{gettext(
"You can separately share live location with the matched person and stop sharing at any time."
)}
</p>
</div>
</div>
</aside>
</div>
<p
id="request-location-status"
role="status"
aria-live="polite"
class="mt-3 min-h-5 text-sm font-medium text-base-content/65"
>
{gettext("Choose a place on the map or use your current location.")}
</p>
<details
id="request-location-manual"
phx-update="ignore"
class="request-location-manual mt-3"
>
<summary>{gettext("Enter coordinates manually")}</summary>
<p class="mt-2 text-xs text-base-content/60">
{gettext(
"For an approximate area, these coordinates are the area center—not your exact position."
)}
</p>
<div class="mt-3 grid grid-cols-2 gap-3">
<label class="fieldset">
<span class="label">{gettext("Latitude")}</span>
<input
id="request-latitude"
name="help_request[latitude]"
type="number"
step="any"
min="-90"
max="90"
value={coordinate_value(@form, "latitude")}
class="input w-full"
data-coordinate-input
/>
</label>
<label class="fieldset">
<span class="label">{gettext("Longitude")}</span>
<input
id="request-longitude"
name="help_request[longitude]"
type="number"
step="any"
min="-180"
max="180"
value={coordinate_value(@form, "longitude")}
class="input w-full"
data-coordinate-input
/>
</label>
</div>
</details>
<p
:for={{:location, message} <- @submission_errors}
id="request-location-error"
@ -399,7 +591,7 @@ defmodule WhoNeedHelpWeb.RequestLive.New do
<.icon name="hero-exclamation-circle" class="size-5" />
{message}
</p>
</div>
</section>
<div class="rounded-2xl border border-base-300 p-4 text-sm">
<.input
field={@form[:safety_confirmed]}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,70 @@
defmodule WhoNeedHelp.Repo.Migrations.AddRequestLocationArea do
use Ecto.Migration
def up do
alter table(:help_requests) do
add :location_radius_meters, :integer, default: 1_000
modify :location, :geometry, null: true
end
execute """
UPDATE help_requests
SET location = NULL,
location_radius_meters = NULL
WHERE location_visibility = 'hidden'
"""
execute """
UPDATE help_requests
SET location_radius_meters = NULL
WHERE location_visibility = 'exact_public'
"""
execute """
UPDATE help_requests
SET location = ST_SetSRID(
ST_MakePoint(
round(ST_X(location)::numeric, 2)::double precision,
round(ST_Y(location)::numeric, 2)::double precision
),
4326
),
location_radius_meters = 1000
WHERE location_visibility = 'approximate_public'
AND location IS NOT NULL
"""
create constraint(:help_requests, :help_requests_location_radius_allowed,
check:
"location_radius_meters IS NULL OR location_radius_meters IN (500, 1000, 2000)"
)
create constraint(:help_requests, :help_requests_location_mode_valid,
check: """
(location_visibility = 'hidden' AND location IS NULL AND location_radius_meters IS NULL)
OR
(location_visibility = 'exact_public' AND location IS NOT NULL AND location_radius_meters IS NULL)
OR
(location_visibility IN ('approximate_public', 'exact_for_active_match')
AND location IS NOT NULL
AND location_radius_meters IN (500, 1000, 2000))
"""
)
end
def down do
drop constraint(:help_requests, :help_requests_location_mode_valid)
drop constraint(:help_requests, :help_requests_location_radius_allowed)
execute """
UPDATE help_requests
SET location = ST_SetSRID(ST_MakePoint(0, 0), 4326)
WHERE location IS NULL
"""
alter table(:help_requests) do
modify :location, :geometry, null: false
remove :location_radius_meters
end
end
end

View File

@ -23,6 +23,12 @@ mkdir -p "$output_dir"
export TRAEFIK_PROJECT_CONSTRAINT="$project"
export TRAEFIK_APP_NAME="who-need-help-e2e-$run_id"
export TRAEFIK_DOCKER_NETWORK="${project}_ingress"
# Compose interpolates required base-image variables before applying the E2E
# override. Keep those names local to the isolated runner; the application
# services themselves are still replaced with `who-need-help:e2e` below.
export APP_IMAGE="who-need-help:e2e"
export SOCKET_PROXY_IMAGE="who-need-help:socket-proxy-e2e"
export POSTGIS_IMAGE="who-need-help:postgis-e2e"
export E2E_OUTPUT_DIR="$output_dir"
export E2E_UID
E2E_UID=$(id -u)

View File

@ -405,9 +405,32 @@ defmodule WhoNeedHelp.TrustSafetyTest do
Help.create_request(context.helper_scope, context.attrs)
end
test "approximate requests expose only their bounded area geometry", context do
attrs = Map.put(context.attrs, "location_radius_meters", "500")
{:ok, request} = Help.create_request(context.requester_scope, attrs)
assert %{
latitude: 50.4501,
longitude: 30.5234,
exact: false,
radius_meters: 500
} = WhoNeedHelp.Help.HelpRequest.public_coordinates(request)
invalid_attrs = Map.put(context.attrs, "location_radius_meters", "5000")
assert {:error, changeset} = Help.create_request(context.requester_scope, invalid_attrs)
assert "is invalid" in errors_on(changeset).location_radius_meters
end
test "hidden request coordinates remain hidden from an accepted helper", context do
hidden_attrs = Map.put(context.attrs, "location_visibility", "hidden")
hidden_attrs =
context.attrs
|> Map.put("location_visibility", "hidden")
|> Map.drop(["latitude", "longitude"])
{:ok, request} = Help.create_request(context.requester_scope, hidden_attrs)
assert is_nil(request.location)
assert is_nil(request.location_radius_meters)
{:ok, _assignment} = Help.accept_request(context.helper_scope, request.id)
request = Help.get_request!(request.id)
outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
@ -415,9 +438,7 @@ defmodule WhoNeedHelp.TrustSafetyTest do
assert is_nil(WhoNeedHelp.Help.HelpRequest.public_coordinates(request))
assert is_nil(Help.request_coordinates(outsider_scope, request))
assert is_nil(Help.request_coordinates(context.helper_scope, request))
assert %{latitude: 50.4501, longitude: 30.5234, exact: true} =
Help.request_coordinates(context.requester_scope, request)
assert is_nil(Help.request_coordinates(context.requester_scope, request))
end
test "blocking an accepted helper revokes request, exact location, and tracking", context do

View File

@ -316,7 +316,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
refute html =~ "controlled substance"
end
test "new request form uses the profile location visibility default", %{
test "new request form normalizes legacy match-only visibility to a private area", %{
conn: conn,
user: user
} do
@ -326,10 +326,40 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
{:ok, view, _html} = live(conn, ~p"/requests/new")
assert has_element?(view, "#request-location-workspace[phx-hook='RequestLocationPicker']")
assert has_element?(
view,
"#help_request_location_visibility option[value='exact_for_active_match'][selected]"
"input[name='help_request[location_visibility]'][value='approximate_public'][checked]"
)
refute has_element?(
view,
"input[name='help_request[location_visibility]'][value='exact_for_active_match']"
)
end
test "new request form offers map privacy modes and bounded area radii", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/requests/new")
assert has_element?(view, "#request-location-map[phx-update='ignore']")
for mode <- ["approximate_public", "exact_public", "hidden"] do
assert has_element?(
view,
"input[name='help_request[location_visibility]'][value='#{mode}']"
)
end
for radius <- ["500", "1000", "2000"] do
assert has_element?(
view,
"input[name='help_request[location_radius_meters]'][value='#{radius}']"
)
end
assert has_element?(view, "#request-latitude[data-coordinate-input]")
assert has_element?(view, "#request-longitude[data-coordinate-input]")
end
test "new request form uses separate local date and time controls", %{conn: conn} do
@ -365,7 +395,10 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
html = render_submit(view, "save", %{"help_request" => params})
assert html =~ "Request was not published"
assert html =~ "Choose a location using the button or enter both latitude and longitude."
assert html =~
"Choose an area or exact point on the map, use your location, or select no map location."
assert html =~ "Choose an expiry time in the future."
assert has_element?(view, "#request-location-error")
assert has_element?(view, "#request-submit-errors[role='alert']")