diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 2336c2f3f6..6c8ed8cbcc 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -2558,7 +2558,7 @@ { "type": "event", "label": "On marker click", - "key": "onMarkerClick", + "key": "onClickMarker", "context": [ { "label": "Clicked marker", @@ -2566,58 +2566,66 @@ } ] }, + { + "type": "boolean", + "label": "Enable creating markers", + "key": "creationEnabled", + "defaultValue": false + }, { "type": "event", - "label": "On map click", - "key": "onMapClick", + "label": "On create marker", + "key": "onCreateMarker", + "dependsOn": "creationEnabled", "context": [ { - "label": "Clicked latitude", + "label": "New marker latitude", "key": "lat" }, { - "label": "Clicked longitude", + "label": "New marker longitude", "key": "lng" } ] }, { "type": "boolean", - "label": "Enable Fullscreen", + "label": "Enable fullscreen", "key": "fullScreenEnabled", "defaultValue": true }, { "type": "boolean", - "label": "Enable Location", + "label": "Enable location", "key": "locationEnabled", "defaultValue": true }, { "type": "boolean", - "label": "Enable Zoom", + "label": "Enable zoom", "key": "zoomEnabled", "defaultValue": true }, - { - "type": "number", - "label": "Zoom Level (0-100)", - "key": "zoomLevel", - "defaultValue": 72, - "max": 100, - "min": 0 - }, { "type": "text", "label": "Tile URL", "key": "tileURL", "defaultValue": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" }, + { "type": "text", - "label": "Default Location", + "label": "Default Location (when empty)", "key": "defaultLocation", - "defaultValue": "51.5072,-0.1276" + "placeholder": "51.5072,-0.1276" + }, + { + "type": "number", + "label": "Default Location Zoom Level (0-100)", + "key": "zoomLevel", + "placeholder": 50, + "max": 100, + "min": 0 }, { "type": "text", diff --git a/packages/client/src/components/app/embedded-map/EmbeddedMap.svelte b/packages/client/src/components/app/embedded-map/EmbeddedMap.svelte index d71873c0f3..b7eb516aa8 100644 --- a/packages/client/src/components/app/embedded-map/EmbeddedMap.svelte +++ b/packages/client/src/components/app/embedded-map/EmbeddedMap.svelte @@ -2,8 +2,8 @@ import L from "leaflet" import sanitizeHtml from "sanitize-html" import "leaflet/dist/leaflet.css" - import { Helpers } from "@budibase/bbui" - import { getContext } from "svelte" + import { Helpers, Button } from "@budibase/bbui" + import { onMount, getContext } from "svelte" import { FullScreenControl, LocationControl, @@ -24,91 +24,16 @@ export let defaultLocation export let tileURL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" export let mapAttribution - export let onMarkerClick - export let onMapClick + export let creationEnabled = false + export let onClickMarker + export let onCreateMarker const { styleable, notificationStore } = getContext("sdk") const component = getContext("component") const embeddedMapId = `${Helpers.uuid()}-wrapper` - let cachedDeviceCoordinates - const fallbackCoordinates = [51.5072, -0.1276] //London - - let mapInstance - let mapMarkerGroup = new L.FeatureGroup() - let mapMarkers = [] - let loaded = false - - let minZoomLevel = 0 - let maxZoomLevel = 18 - let adjustedZoomLevel = !Number.isInteger(zoomLevel) - ? 72 - : Math.round(zoomLevel * (maxZoomLevel / 100)) - - $: zoomControlUpdated(mapInstance, zoomEnabled) - $: locationControlUpdated(mapInstance, locationEnabled) - $: fullScreenControlUpdated(mapInstance, fullScreenEnabled) - $: updateMapDimensions( - mapInstance, - $component.styles.normal.width, - $component.styles.normal.height - ) - $: addMapMarkers( - mapInstance, - dataProvider?.rows, - latitudeKey, - longitudeKey, - titleKey, - onMarkerClick - ) - $: if (!loaded && typeof mapInstance === "object" && mapMarkers.length > 0) { - loaded = true - mapInstance.setZoom(0) - mapInstance.fitBounds(mapMarkerGroup.getBounds(), { - paddingTopLeft: [0, 24], - }) - } - - const updateMapDimensions = mapInstance => { - if (typeof mapInstance !== "object") { - return - } - mapInstance.invalidateSize() - } - - let isValidLatitude = value => { - return !isNaN(value) && value > -90 && value < 90 - } - - let isValidLongitude = value => { - return !isNaN(value) && value > -180 && value < 180 - } - - const parseDefaultLocation = defaultLocation => { - if (typeof defaultLocation !== "string") { - return fallbackCoordinates - } - let defaultLocationParts = defaultLocation.split(",") - if (defaultLocationParts.length !== 2) { - return fallbackCoordinates - } - - let parsedDefaultLatitude = parseFloat(defaultLocationParts[0].trim()) - let parsedDefaultLongitude = parseFloat(defaultLocationParts[1].trim()) - - return isValidLatitude(parsedDefaultLatitude) === true && - isValidLongitude(parsedDefaultLongitude) === true - ? [parsedDefaultLatitude, parsedDefaultLongitude] - : fallbackCoordinates - } - - $: defaultCoordinates = - mapMarkers.length > 0 - ? parseDefaultLocation(defaultLocation) - : fallbackCoordinates - // Map Button Controls - let locationControl = new LocationControl({ + const locationControl = new LocationControl({ position: "bottomright", onLocationFail: err => { if (err.code === GeolocationPositionError.PERMISSION_DENIED) { @@ -134,13 +59,135 @@ } }, }) - let fullScreenControl = new FullScreenControl({ + const fullScreenControl = new FullScreenControl({ position: "topright", }) - let zoomControl = L.control.zoom({ + const zoomControl = L.control.zoom({ position: "bottomright", }) + // Map and marker configuration + const defaultMarkerOptions = { + html: + '
', + className: "embedded-map-marker", + iconSize: [26, 26], + iconAnchor: [13, 26], + popupAnchor: [0, -13], + } + const mapMarkerOptions = { + icon: L.divIcon(defaultMarkerOptions), + draggable: false, + alt: "Location Marker", + } + const candidateMarkerOptions = { + icon: L.divIcon({ + ...defaultMarkerOptions, + className: "embedded-map-marker--candidate", + }), + draggable: false, + alt: "Location Marker", + } + const mapOptions = { + fullScreen: false, + zoomControl: false, + scrollWheelZoom: zoomEnabled, + minZoomLevel, + maxZoomLevel, + } + const fallbackCoordinates = [51.5072, -0.1276] //London + + let mapInstance + let mapMarkerGroup = new L.FeatureGroup() + let candidateMarkerGroup = new L.FeatureGroup() + let candidateMarkerPosition + let mounted = false + let initialMarkerZoomCompleted = false + let minZoomLevel = 0 + let maxZoomLevel = 18 + let cachedDeviceCoordinates + + $: validRows = getValidRows(dataProvider?.rows, latitudeKey, longitudeKey) + $: safeZoomLevel = parseZoomLevel(zoomLevel) + $: defaultCoordinates = parseDefaultLocation(defaultLocation) + $: initMap(tileURL, mapAttribution, safeZoomLevel) + $: zoomControlUpdated(mapInstance, zoomEnabled) + $: locationControlUpdated(mapInstance, locationEnabled) + $: fullScreenControlUpdated(mapInstance, fullScreenEnabled) + $: width = $component.styles.normal.width + $: height = $component.styles.normal.height + $: width, height, mapInstance?.invalidateSize() + $: defaultCoordinates, resetView() + $: addMapMarkers( + mapInstance, + validRows, + latitudeKey, + longitudeKey, + titleKey, + onClickMarker + ) + + const isValidLatitude = value => { + return !isNaN(value) && value > -90 && value < 90 + } + + const isValidLongitude = value => { + return !isNaN(value) && value > -180 && value < 180 + } + + const getValidRows = (rows, latKey, lngKey) => { + if (!rows?.length || !latKey || !lngKey) { + return [] + } + return rows.filter(row => { + return isValidLatitude(row[latKey]) && isValidLongitude(row[lngKey]) + }) + } + + const parseZoomLevel = zoomLevel => { + let zoom = zoomLevel + if (zoom == null || isNaN(zoom)) { + zoom = 50 + } else { + zoom = parseFloat(zoom) + zoom = Math.max(0, Math.min(100, zoom)) + } + return Math.round((zoom * maxZoomLevel) / 100) + } + + const parseDefaultLocation = defaultLocation => { + if (typeof defaultLocation !== "string") { + return fallbackCoordinates + } + let defaultLocationParts = defaultLocation.split(",") + if (defaultLocationParts.length !== 2) { + return fallbackCoordinates + } + + let parsedDefaultLatitude = parseFloat(defaultLocationParts[0].trim()) + let parsedDefaultLongitude = parseFloat(defaultLocationParts[1].trim()) + + return isValidLatitude(parsedDefaultLatitude) === true && + isValidLongitude(parsedDefaultLongitude) === true + ? [parsedDefaultLatitude, parsedDefaultLongitude] + : fallbackCoordinates + } + + const resetView = () => { + if (!mapInstance) { + return + } + if (mapMarkerGroup.getLayers().length) { + mapInstance.setZoom(0) + mapInstance.fitBounds(mapMarkerGroup.getBounds(), { + paddingTopLeft: [0, 24], + }) + } else { + mapInstance.setView(defaultCoordinates, safeZoomLevel) + } + } + const locationControlUpdated = (mapInstance, locationEnabled) => { if (typeof mapInstance !== "object") { return @@ -176,51 +223,21 @@ } } - //Map icon and marker configuration - const mapIconMarkup = - '' - const mapIcon = L.divIcon({ - html: mapIconMarkup, - className: "embedded-map-marker", - iconSize: [26, 26], - iconAnchor: [13, 26], - popupAnchor: [0, -13], - }) - const mapMarkerOptions = { - icon: mapIcon, - draggable: false, - alt: "Location Marker", - } - let mapOptions = { - fullScreen: false, - zoomControl: false, - scrollWheelZoom: zoomEnabled, - minZoomLevel, - maxZoomLevel, - } - const addMapMarkers = ( mapInstance, - rows, + validRows, latKey, lngKey, titleKey, onClick ) => { - if (typeof mapInstance !== "object" || !rows || !latKey || !lngKey) { + if (!mapInstance || !validRows?.length) { return } mapMarkerGroup.clearLayers() - - const validRows = rows.filter(row => { - return isValidLatitude(row[latKey]) && isValidLongitude(row[lngKey]) - }) - validRows.forEach(row => { let markerCoords = [row[latKey], row[lngKey]] - let marker = L.marker(markerCoords, mapMarkerOptions).addTo(mapInstance) let markerContent = generateMarkerPopupContent( row[latKey], @@ -242,57 +259,91 @@ }) }) } - - mapMarkers = [...mapMarkers, marker] }) + + // Zoom to markers if this is the first time + if (!initialMarkerZoomCompleted) { + resetView() + initialMarkerZoomCompleted = true + } } const generateMarkerPopupContent = (latitude, longitude, text) => { return text || latitude + "," + longitude } - const initMap = () => { - const initCoords = defaultCoordinates - + const initMap = (tileURL, attribution, zoom) => { + if (!mounted) { + return + } + if (mapInstance) { + mapInstance.remove() + } mapInstance = L.map(embeddedMapId, mapOptions) mapMarkerGroup.addTo(mapInstance) + candidateMarkerGroup.addTo(mapInstance) - const cleanAttribution = sanitizeHtml(mapAttribution, { + // Add attribution + const cleanAttribution = sanitizeHtml(attribution, { allowedTags: ["a"], allowedAttributes: { a: ["href", "target"], }, }) - L.tileLayer(tileURL, { attribution: "© " + cleanAttribution, - zoom: adjustedZoomLevel, + zoom, }).addTo(mapInstance) - //Initialise the map - mapInstance.setView(initCoords, adjustedZoomLevel) - // Add click handler - mapInstance?.on("click", e => { - console.log("map clicked!") - if (onMapClick) { - onMapClick({ - lat: e.latlng.lat, - lng: e.latlng.lng, - }) - } - }) + mapInstance.on("click", handleMapClick) + + // Reset view + resetView() } - const mapAction = () => { - initMap() - return { - destroy() { - mapInstance.remove() - mapInstance = undefined - }, + const handleMapClick = e => { + if (!creationEnabled) { + return + } + candidateMarkerGroup.clearLayers() + candidateMarkerPosition = [e.latlng.lat, e.latlng.lng] + let candidateMarker = L.marker( + candidateMarkerPosition, + candidateMarkerOptions + ) + candidateMarker + .bindTooltip("New marker", { + permanent: true, + direction: "top", + offset: [0, -25], + }) + .addTo(candidateMarkerGroup) + .on("click", clearCandidateMarker) + } + + const createMarker = async () => { + if (!onCreateMarker) { + return + } + const res = await onCreateMarker({ + lat: candidateMarkerPosition[0], + lng: candidateMarkerPosition[1], + }) + if (res !== false) { + clearCandidateMarker() } } + + const clearCandidateMarker = () => { + candidateMarkerGroup.clearLayers() + candidateMarkerPosition = null + } + + onMount(() => { + mounted = true + initMap(tileURL, mapAttribution, safeZoomLevel) + }) diff --git a/packages/client/src/stores/confirmation.js b/packages/client/src/stores/confirmation.js index 497b021b04..bb9a54386f 100644 --- a/packages/client/src/stores/confirmation.js +++ b/packages/client/src/stores/confirmation.js @@ -4,30 +4,36 @@ const initialState = { showConfirmation: false, title: null, text: null, - callback: null, + onConfirm: null, + onCancel: null, } const createConfirmationStore = () => { const store = writable(initialState) - const showConfirmation = (title, text, callback) => { + const showConfirmation = (title, text, onConfirm, onCancel) => { store.set({ showConfirmation: true, title, text, - callback, + onConfirm, + onCancel, }) } const confirm = async () => { const state = get(store) - if (!state.showConfirmation || !state.callback) { + if (!state.showConfirmation || !state.onConfirm) { return } store.set(initialState) - await state.callback() + await state.onConfirm() } const cancel = () => { + const state = get(store) store.set(initialState) + if (state.onCancel) { + state.onCancel() + } } return { diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js index d880455af0..d9d4dced71 100644 --- a/packages/client/src/utils/buttonActions.js +++ b/packages/client/src/utils/buttonActions.js @@ -331,33 +331,36 @@ export const enrichButtonActions = (actions, context) => { // If this action is confirmable, show confirmation and await a // callback to execute further actions if (action.parameters?.confirm) { - const defaultText = confirmTextMap[action["##eventHandlerType"]] - const confirmText = action.parameters?.confirmText || defaultText - confirmationStore.actions.showConfirmation( - action["##eventHandlerType"], - confirmText, - async () => { - // When confirmed, execute this action immediately, - // then execute the rest of the actions in the chain - const result = await callback() - if (result !== false) { - // Generate a new total context to pass into the next enrichment - buttonContext.push(result) - const newContext = { ...context, actions: buttonContext } + return new Promise(resolve => { + const defaultText = confirmTextMap[action["##eventHandlerType"]] + const confirmText = action.parameters?.confirmText || defaultText + confirmationStore.actions.showConfirmation( + action["##eventHandlerType"], + confirmText, + async () => { + // When confirmed, execute this action immediately, + // then execute the rest of the actions in the chain + const result = await callback() + if (result !== false) { + // Generate a new total context to pass into the next enrichment + buttonContext.push(result) + const newContext = { ...context, actions: buttonContext } - // Enrich and call the next button action - const next = enrichButtonActions( - actions.slice(i + 1), - newContext - ) - await next() + // Enrich and call the next button action + const next = enrichButtonActions( + actions.slice(i + 1), + newContext + ) + resolve(await next()) + } else { + resolve(false) + } + }, + () => { + resolve(false) } - } - ) - - // Stop enriching actions when encountering a confirmable action, - // as the callback continues the action chain - return + ) + }) } // For non-confirmable actions, execute the handler immediately