diff --git a/package.json b/package.json index 733234cbd0..2c40662cc5 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@types/js-levenshtein": "1.1.3", "@types/json-schema": "7.0.15", "@types/leaflet": "1", + "@types/leaflet-draw": "1.0.13", "@types/marked": "6.0.0", "@types/mime": "4.0.0", "@types/node": "24.10.7", @@ -141,6 +142,7 @@ "@digdir/designsystemet-theme": "1.9.0", "@navikt/aksel-icons": "7.38.0", "@tanstack/react-query": "5.90.16", + "@terraformer/wkt": "2.2.1", "@types/cypress": "^1.1.6", "ajv": "8.17.1", "ajv-errors": "3.0.0", @@ -159,6 +161,7 @@ "immer": "11.1.3", "jsonpointer": "5.0.1", "leaflet": "1.9.4", + "leaflet-draw": "1.0.4", "lru-cache": "11.2.4", "marked": "17.0.1", "marked-mangle": "1.1.12", @@ -171,10 +174,10 @@ "react-dom": "19.2.3", "react-dropzone": "14.3.8", "react-leaflet": "5.0.0", + "react-leaflet-draw": "0.21.0", "react-number-format": "5.4.4", "react-router-dom": "6.30.3", "react-toastify": "11.0.5", - "terraformer-wkt-parser": "1.2.1", "typescript": "5.9.3", "typescript-eslint": "8.52.0", "uuid": "13.0.0", diff --git a/snapshots.js b/snapshots.js index b59de7dd68..cb3bd52da8 100644 --- a/snapshots.js +++ b/snapshots.js @@ -368,5 +368,5 @@ module.exports = { } } }, - "__version": "15.7.0" + "__version": "15.8.2" } diff --git a/src/index.tsx b/src/index.tsx index b9b3930c83..19a0d29f50 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -41,6 +41,7 @@ import { PartyPrefetcher } from 'src/queries/partyPrefetcher'; import * as queries from 'src/queries/queries'; import 'leaflet/dist/leaflet.css'; +import 'leaflet-draw/dist/leaflet.draw.css'; import 'react-toastify/dist/ReactToastify.css'; import 'src/index.css'; diff --git a/src/layout/Map/Map.tsx b/src/layout/Map/Map.tsx index 7b8d3ccb09..b679f0b93a 100644 --- a/src/layout/Map/Map.tsx +++ b/src/layout/Map/Map.tsx @@ -6,6 +6,7 @@ import cn from 'classnames'; import { type Map as LeafletMap } from 'leaflet'; import { useIsPdf } from 'src/hooks/useIsPdf'; +import { MapEditGeometries } from 'src/layout/Map/features/geometries/editable/MapEditGeometries'; import { useMapGeometryBounds } from 'src/layout/Map/features/geometries/fixed/hooks'; import { MapGeometries } from 'src/layout/Map/features/geometries/fixed/MapGeometries'; import { MapLayers } from 'src/layout/Map/features/layers/MapLayers'; @@ -14,6 +15,7 @@ import { MapSingleMarker } from 'src/layout/Map/features/singleMarker/MapSingleM import classes from 'src/layout/Map/MapComponent.module.css'; import { DefaultBoundsPadding, DefaultFlyToZoomLevel, getMapStartingView, isLocationValid } from 'src/layout/Map/utils'; import { useExternalItem } from 'src/utils/layout/hooks'; +import { useItemWhenType } from 'src/utils/layout/useNodeItem'; type MapProps = { baseComponentId: string; @@ -26,6 +28,8 @@ export function Map({ baseComponentId, className, readOnly, animate = true }: Ma const map = useRef(null); const isPdf = useIsPdf(); const { center, zoom, bounds } = useAutoViewport(baseComponentId, map, animate); + const { toolbar, dataModelBindings } = useItemWhenType(baseComponentId, 'Map'); + const simpleBinding = dataModelBindings?.simpleBinding; return ( + {toolbar !== undefined && } - + {toolbar === undefined && simpleBinding && ( + + )} ); diff --git a/src/layout/Map/config.ts b/src/layout/Map/config.ts index 4637e7c411..b5c79df2d4 100644 --- a/src/layout/Map/config.ts +++ b/src/layout/Map/config.ts @@ -1,4 +1,5 @@ import { CG } from 'src/codegen/CG'; +import { ExprVal } from 'src/features/expressions/types'; import { CompCategory } from 'src/layout/common'; export const Config = new CG.component({ @@ -39,6 +40,14 @@ export const Config = new CG.component({ .optional() .setDescription('Should point to a string (defaults to a "data" property on the geometries array objects)'), ), + new CG.prop( + 'geometryIsEditable', + new CG.dataModelBinding() + .optional() + .setDescription( + 'Should point to a boolean indicating if this geometry is editable. This has no default value, geometries will not be editable if this is not specified.', + ), + ), ).exportAs('IDataModelBindingsForMap'), ) .addProperty( @@ -164,6 +173,47 @@ export const Config = new CG.component({ new CG.enum('GeoJSON', 'WKT').optional({ default: 'GeoJSON' }).exportAs('IGeometryType'), ), ) + .addProperty( + new CG.prop( + 'toolbar', + new CG.obj( + new CG.prop( + 'polyline', + new CG.expr(ExprVal.Boolean) + .optional({ default: false }) + .setDescription('Expression or boolean allowing the user to draw lines on the map'), + ), + new CG.prop( + 'polygon', + new CG.expr(ExprVal.Boolean) + .optional({ default: false }) + .setDescription('Expression or boolean allowing the user to draw a polygon on the map'), + ), + new CG.prop( + 'rectangle', + new CG.expr(ExprVal.Boolean) + .optional({ default: false }) + .setDescription('Expression or boolean allowing the user to draw a rectangle on the map'), + ), + new CG.prop( + 'circle', + new CG.expr(ExprVal.Boolean) + .optional({ default: false }) + .setDescription('Expression or boolean allowing the user to draw a circle on the map'), + ), + new CG.prop( + 'marker', + new CG.expr(ExprVal.Boolean) + .optional({ default: false }) + .setDescription('Expression or boolean allowing the user to place multiple markers on the map'), + ), + ) + .optional() + .exportAs('Toolbar') + .setTitle('Toolbar') + .setDescription('Sets which geometries the user is allowed to draw'), + ), + ) .extends(CG.common('LabeledComponentProps')) .extendTextResources(CG.common('TRBLabel')) .addSummaryOverrides(); diff --git a/src/layout/Map/features/geometries/editable/MapEditGeometries.tsx b/src/layout/Map/features/geometries/editable/MapEditGeometries.tsx new file mode 100644 index 0000000000..a8f0476bf8 --- /dev/null +++ b/src/layout/Map/features/geometries/editable/MapEditGeometries.tsx @@ -0,0 +1,209 @@ +import React, { useEffect, useRef } from 'react'; +import { FeatureGroup } from 'react-leaflet'; +import { EditControl } from 'react-leaflet-draw'; + +import { geojsonToWKT } from '@terraformer/wkt'; +// Import GeoJSON type +import L from 'leaflet'; +import { v4 as uuidv4 } from 'uuid'; +import type { Feature } from 'geojson'; + +import { FD } from 'src/features/formData/FormDataWrite'; +import { ALTINN_ROW_ID } from 'src/features/formData/types'; +import { toRelativePath } from 'src/features/saveToGroup/useSaveToGroup'; +import { useLeafletDrawSpritesheetFix } from 'src/layout/Map/features/geometries/editable/useLeafletDrawSpritesheetFix'; +import { useMapParsedGeometries } from 'src/layout/Map/features/geometries/fixed/hooks'; +import { useDataModelBindingsFor } from 'src/utils/layout/hooks'; +import { useItemWhenType } from 'src/utils/layout/useNodeItem'; + +interface FeatureWithId extends Feature { + properties: { + altinnRowId?: string; + }; +} +interface MapEditGeometriesProps { + baseComponentId: string; +} + +export function MapEditGeometries({ baseComponentId }: MapEditGeometriesProps) { + const { geometryType } = useItemWhenType(baseComponentId, 'Map'); + + const editRef = useRef(null); + + const geometryBinding = useDataModelBindingsFor(baseComponentId, 'Map')?.geometries; + const geometryDataBinding = useDataModelBindingsFor(baseComponentId, 'Map')?.geometryData; + const isEditableBinding = useDataModelBindingsFor(baseComponentId, 'Map')?.geometryIsEditable; + const geometryDataFieldName = geometryDataBinding?.field.split('.').pop(); + const isEditableFieldName = isEditableBinding?.field.split('.').pop(); + const initialGeometries = useMapParsedGeometries(baseComponentId)?.filter((g) => g.isEditable); + + const geometryDataPath = toRelativePath(geometryBinding, geometryDataBinding); + + const appendToList = FD.useAppendToList(); + const setLeafValue = FD.useSetLeafValue(); + const removeFromList = FD.useRemoveFromListCallback(); + + const { toolbar } = useItemWhenType(baseComponentId, 'Map'); + + useLeafletDrawSpritesheetFix(); + + // Load initial data into the FeatureGroup on component mount + useEffect(() => { + const featureGroup = editRef.current; + if (featureGroup && initialGeometries) { + // Clear existing layers to prevent duplication if initialData changes + featureGroup.clearLayers(); + + initialGeometries.forEach((item) => { + if (item.data && item.data.type === 'FeatureCollection') { + item.data.features.forEach((feature: Feature) => { + // Attach the unique ID to the feature's properties + const newFeature: FeatureWithId = { + ...feature, // Copy type, geometry, etc. + properties: { + ...feature.properties, // Copy any existing properties + altinnRowId: item.altinnRowId, // Add our ID + }, + }; + + // Create a GeoJSON layer for the single feature and add it to the group + const leafletLayer = L.geoJSON(newFeature); + leafletLayer.eachLayer((layer) => { + featureGroup.addLayer(layer); + }); + }); + } else { + // Handle case where item.data is a single Feature / PolyLine / Polygon, etc. + const geoData = item.data; + + // 1. Check if it's already a Feature, otherwise wrap it in one + const isFeature = 'type' in geoData && geoData.type === 'Feature'; + + const newFeature: FeatureWithId = isFeature + ? { + ...(geoData as Feature), + properties: { + ...(geoData as Feature).properties, + altinnRowId: item.altinnRowId, + }, + } + : { + type: 'Feature', + geometry: geoData, + properties: { + altinnRowId: item.altinnRowId, + }, + }; + + const leafletLayer = L.geoJSON(newFeature); + leafletLayer.eachLayer((layer) => { + featureGroup.addLayer(layer); + }); + } + }); + } + }, [initialGeometries]); + + const onCreatedHandler = (e: L.DrawEvents.Created) => { + if (!geometryBinding || !geometryDataFieldName || !isEditableFieldName) { + return; + } + + const uuid = uuidv4(); + const layer = e.layer; + const geo = layer.toGeoJSON(); + + // Ensure the Leaflet layer object itself knows its ID for future edits + if (!layer.feature) { + layer.feature = { type: 'Feature', geometry: geo.geometry, properties: {} }; + } + layer.feature.properties = { + ...layer.feature.properties, + altinnRowId: uuid, + }; + + let geoString = JSON.stringify(geo); + if (geometryType === 'WKT') { + geoString = geojsonToWKT(geo.geometry); + } + + appendToList({ + reference: geometryBinding, + newValue: { + [ALTINN_ROW_ID]: uuid, + [geometryDataFieldName]: geoString, + [isEditableFieldName]: true, + }, + }); + }; + + const onEditedHandler = (e: L.DrawEvents.Edited) => { + if (!geometryBinding) { + return; + } + + if (!geometryDataFieldName) { + return; + } + + if (!geometryDataBinding) { + return; + } + + e.layers.eachLayer((layer) => { + // @ts-expect-error test + const editedGeo = layer.toGeoJSON(); + const altinnRowId = editedGeo.properties?.altinnRowId; + + let geoString = JSON.stringify(editedGeo); + + if (geometryType == 'WKT') { + geoString = geojsonToWKT(editedGeo.geometry); + } + + initialGeometries?.forEach((g, index) => { + if (g.altinnRowId === altinnRowId) { + const field = `${geometryBinding.field}[${index}].${geometryDataPath}`; + setLeafValue({ + reference: { dataType: geometryDataBinding?.dataType, field }, + newValue: geoString, + }); + } + }); + }); + }; + + const onDeletedHandler = (e: L.DrawEvents.Deleted) => { + if (!geometryBinding) { + return; + } + + e.layers.eachLayer((layer) => { + // @ts-expect-error test + const deletedGeo = layer.toGeoJSON(); + removeFromList({ + reference: geometryBinding, + callback: (item) => item[ALTINN_ROW_ID] === deletedGeo.properties?.altinnRowId, + }); + }); + }; + + return ( + + + + ); +} diff --git a/src/layout/Map/features/geometries/editable/useLeafletDrawSpritesheetFix.ts b/src/layout/Map/features/geometries/editable/useLeafletDrawSpritesheetFix.ts new file mode 100644 index 0000000000..c6ff8ceb0c --- /dev/null +++ b/src/layout/Map/features/geometries/editable/useLeafletDrawSpritesheetFix.ts @@ -0,0 +1,32 @@ +import { useEffect } from 'react'; + +import DrawSpriteSheetPng from 'leaflet-draw/dist/images/spritesheet.png'; +import DrawSpriteSheetPng2x from 'leaflet-draw/dist/images/spritesheet-2x.png'; + +const STYLE_ID = 'leaflet-draw-spritesheet-override'; + +/** + * Hook to fix leaflet-draw spritesheet paths by overriding the CSS with webpack-processed image URLs. + * This is needed because the default leaflet-draw.css references relative image paths that don't work + * after webpack processing. + */ +export function useLeafletDrawSpritesheetFix() { + useEffect(() => { + // Only inject the style once globally + if (document.getElementById(STYLE_ID)) { + return; + } + + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + .leaflet-draw-toolbar a { + background-image: url(${DrawSpriteSheetPng}) !important; + } + .leaflet-retina .leaflet-draw-toolbar a { + background-image: url(${DrawSpriteSheetPng2x}) !important; + } + `; + document.head.appendChild(style); + }, []); +} diff --git a/src/layout/Map/features/geometries/fixed/MapGeometries.tsx b/src/layout/Map/features/geometries/fixed/MapGeometries.tsx index ebffbfb3f5..f1a3f5fb17 100644 --- a/src/layout/Map/features/geometries/fixed/MapGeometries.tsx +++ b/src/layout/Map/features/geometries/fixed/MapGeometries.tsx @@ -7,6 +7,7 @@ import RetinaIcon from 'leaflet/dist/images/marker-icon-2x.png'; import IconShadow from 'leaflet/dist/images/marker-shadow.png'; import { useMapParsedGeometries } from 'src/layout/Map/features/geometries/fixed/hooks'; +import { useItemWhenType } from 'src/utils/layout/useNodeItem'; const markerIcon = icon({ iconUrl: Icon, @@ -22,11 +23,18 @@ type MapGeometriesProps = { }; export function MapGeometries({ baseComponentId, readOnly }: MapGeometriesProps) { - const geometries = useMapParsedGeometries(baseComponentId); + const { toolbar } = useItemWhenType(baseComponentId, 'Map'); + let geometries = useMapParsedGeometries(baseComponentId); + if (!geometries || geometries.length === 0) { return null; } + // if toolbar is defined, we want to render editable geometries separately + if (toolbar) { + geometries = geometries?.filter((g) => !g.isEditable); + } + return ( <> {geometries.map(({ altinnRowId, data, label }) => ( diff --git a/src/layout/Map/features/geometries/fixed/hooks.ts b/src/layout/Map/features/geometries/fixed/hooks.ts index 443630baca..1169a0b14e 100644 --- a/src/layout/Map/features/geometries/fixed/hooks.ts +++ b/src/layout/Map/features/geometries/fixed/hooks.ts @@ -1,8 +1,8 @@ import { useMemo } from 'react'; +import { wktToGeoJSON } from '@terraformer/wkt'; import dot from 'dot-object'; import { geoJson, LatLngBounds } from 'leaflet'; -import WKT from 'terraformer-wkt-parser'; import type { GeoJSON } from 'geojson'; import { FD } from 'src/features/formData/FormDataWrite'; @@ -23,6 +23,8 @@ export function useMapRawGeometries(baseComponentId: string): RawGeometry[] | un const labelPath = toRelativePath(dataModelBindings?.geometries, dataModelBindings?.geometryLabel) ?? 'label'; const dataPath = toRelativePath(dataModelBindings?.geometries, dataModelBindings?.geometryData) ?? 'data'; + const isEditablePath = + toRelativePath(dataModelBindings?.geometries, dataModelBindings?.geometryIsEditable) ?? 'isEditable'; return formData.map((item: unknown): RawGeometry => { if (!item || typeof item !== 'object' || !item[ALTINN_ROW_ID]) { @@ -35,9 +37,16 @@ export function useMapRawGeometries(baseComponentId: string): RawGeometry[] | un altinnRowId: item[ALTINN_ROW_ID], data: dot.pick(dataPath, item), label: dot.pick(labelPath, item), + isEditable: dot.pick(isEditablePath, item), }; }); - }, [dataModelBindings?.geometries, dataModelBindings?.geometryData, dataModelBindings?.geometryLabel, formData]); + }, [ + dataModelBindings?.geometries, + dataModelBindings?.geometryData, + dataModelBindings?.geometryLabel, + dataModelBindings?.geometryIsEditable, + formData, + ]); } export function useMapParsedGeometries(baseComponentId: string): Geometry[] | null { @@ -66,13 +75,13 @@ function parseGeometries(geometries: RawGeometry[] | undefined, geometryType?: I } const out: Geometry[] = []; - for (const { altinnRowId, data: rawData, label } of geometries) { + for (const { altinnRowId, data: rawData, label, isEditable } of geometries) { if (geometryType === 'WKT') { - const data = WKT.parse(rawData); - out.push({ altinnRowId, data, label }); + const data = wktToGeoJSON(rawData); + out.push({ altinnRowId, data, label, isEditable }); } else { const data = JSON.parse(rawData) as GeoJSON; - out.push({ altinnRowId, data, label }); + out.push({ altinnRowId, data, label, isEditable }); } } diff --git a/src/layout/Map/features/geometries/useValidateGeometriesBindings.ts b/src/layout/Map/features/geometries/useValidateGeometriesBindings.ts index 1e4f9d269b..b4761aad31 100644 --- a/src/layout/Map/features/geometries/useValidateGeometriesBindings.ts +++ b/src/layout/Map/features/geometries/useValidateGeometriesBindings.ts @@ -2,13 +2,15 @@ import { DataModels } from 'src/features/datamodel/DataModelsProvider'; import { lookupErrorAsText } from 'src/features/datamodel/lookupErrorAsText'; import { useLayoutLookups } from 'src/features/form/layout/LayoutsContext'; import { validateDataModelBindingsAny } from 'src/utils/layout/generator/validation/hooks'; +import { useExternalItem } from 'src/utils/layout/hooks'; import type { IDataModelReference } from 'src/layout/common.generated'; import type { IDataModelBindings } from 'src/layout/layout'; export function useValidateGeometriesBindings(baseComponentId: string, bindings: IDataModelBindings<'Map'>) { - const { geometries, geometryLabel, geometryData } = bindings ?? {}; + const { geometries, geometryLabel, geometryData, geometryIsEditable } = bindings ?? {}; const lookupBinding = DataModels.useLookupBinding(); const layoutLookups = useLayoutLookups(); + const toolbar = useExternalItem(baseComponentId, 'Map')?.toolbar; const errors: string[] = []; if (!geometries) { @@ -37,7 +39,7 @@ export function useValidateGeometriesBindings(baseComponentId: string, bindings: errors.push(`geometries binding must point to an array of objects`); } - const fieldsToValidate: { + let fieldsToValidate: { binding: IDataModelReference | undefined; name: string; expectedType: string; @@ -47,6 +49,13 @@ export function useValidateGeometriesBindings(baseComponentId: string, bindings: { binding: geometryData, name: 'geometryData', expectedType: 'string', defaultProperty: 'data' }, ]; + if (bindings?.geometries && !bindings?.simpleBinding && toolbar) { + fieldsToValidate = [ + ...fieldsToValidate, + { binding: geometryIsEditable, name: 'geometryIsEditable', expectedType: 'boolean' }, + ]; + } + for (const { binding, name, expectedType, defaultProperty } of fieldsToValidate) { const fieldPath = binding ? binding.field.replace(`${geometries.field}.`, '') : defaultProperty; diff --git a/src/layout/Map/index.tsx b/src/layout/Map/index.tsx index ff8b6339db..0e3df94773 100644 --- a/src/layout/Map/index.tsx +++ b/src/layout/Map/index.tsx @@ -10,6 +10,7 @@ import { MapComponentSummary } from 'src/layout/Map/MapComponentSummary'; import { MapSummary } from 'src/layout/Map/Summary2/MapSummary'; import { parseLocation } from 'src/layout/Map/utils'; import { validateDataModelBindingsAny } from 'src/utils/layout/generator/validation/hooks'; +import { useExternalItem } from 'src/utils/layout/hooks'; import { useNodeFormDataWhenType } from 'src/utils/layout/useNodeItem'; import type { PropsFromGenericComponent } from 'src/layout'; import type { IDataModelBindings } from 'src/layout/layout'; @@ -41,6 +42,21 @@ export class Map extends MapDef { const errors: string[] = []; const lookupBinding = DataModels.useLookupBinding(); const layoutLookups = useLayoutLookups(); + const toolbar = useExternalItem(baseComponentId, 'Map').toolbar; + + if (bindings?.simpleBinding && bindings?.geometryIsEditable) { + errors.push( + 'geometryIsEditable cannot be used with simpleBinding (markers will be added as geometry when geometryIsEditable is set)', + ); + } + + if (bindings?.geometryIsEditable && toolbar === undefined) { + errors.push('geometryIsEditable cannot be used without a defined toolbar'); + } + + if (!bindings?.geometryIsEditable && toolbar !== undefined) { + errors.push('toolbar cannot be used without setting geometryIsEditable in dataModelBindings'); + } const [simpleBindingErrors] = validateDataModelBindingsAny( baseComponentId, diff --git a/src/layout/Map/types.ts b/src/layout/Map/types.ts index 8c1febe078..c0d45b2b1a 100644 --- a/src/layout/Map/types.ts +++ b/src/layout/Map/types.ts @@ -4,10 +4,12 @@ export type RawGeometry = { altinnRowId: string; data: string; label?: string; + isEditable?: boolean; }; export type Geometry = { altinnRowId: string; data: GeoJSON; label?: string; + isEditable?: boolean; }; diff --git a/test/e2e/integration/component-library/map.ts b/test/e2e/integration/component-library/map.ts new file mode 100644 index 0000000000..0a07964ac9 --- /dev/null +++ b/test/e2e/integration/component-library/map.ts @@ -0,0 +1,50 @@ +import { AppFrontend } from 'test/e2e/pageobjects/app-frontend'; + +const appFrontend = new AppFrontend(); + +describe('Map component', () => { + it('Is able to draw new geometries on a map using the toolbar', () => { + cy.startAppInstance(appFrontend.apps.componentLibrary, { authenticationLevel: '2' }); + cy.gotoNavPage('Kart'); + + cy.intercept('GET', '**/**.png').as('tileRequest'); + + // Draw a polygon + cy.findByRole('link', { name: 'Draw a polygon' }).click(); + cy.get('#form-content-MapPage-MapComponent-Geometries').click(300, 300); + cy.get('#form-content-MapPage-MapComponent-Geometries').click(400, 200); + // wait for zoom to adjust (double clicking causes a zoom) + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(200); + cy.get('#form-content-MapPage-MapComponent-Geometries').click(400, 400); + // complete polygon by clicking last point twice + cy.get('#form-content-MapPage-MapComponent-Geometries').click(400, 400); + + cy.get('g>path').should('to.be.visible'); + }); + + it('Is able to draw new geometries and delete them on a map using the toolbar', () => { + cy.startAppInstance(appFrontend.apps.componentLibrary, { authenticationLevel: '2' }); + cy.gotoNavPage('Kart'); + + cy.intercept('GET', '**/**.png').as('tileRequest'); + + // Draw a polygon + cy.findByRole('link', { name: 'Draw a polygon' }).click(); + cy.get('#form-content-MapPage-MapComponent-Geometries').click(300, 300); + cy.get('#form-content-MapPage-MapComponent-Geometries').click(400, 200); + // wait for zoom to adjust (double clicking causes a zoom) + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(200); + cy.get('#form-content-MapPage-MapComponent-Geometries').click(400, 400); + // complete polygon by clicking last point twice + cy.get('#form-content-MapPage-MapComponent-Geometries').click(400, 400); + + cy.get('g>path').should('to.be.visible'); + + cy.findByRole('link', { name: 'Delete layers' }).click(); + cy.get('g>path').click('center'); + cy.findByRole('link', { name: 'Save' }).click(); + cy.get('g>path').should('not.exist'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 896e01c799..936e8f7c3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4134,6 +4134,13 @@ __metadata: languageName: node linkType: hard +"@terraformer/wkt@npm:2.2.1": + version: 2.2.1 + resolution: "@terraformer/wkt@npm:2.2.1" + checksum: 10c0/02a64b1a447ee5b602484501eada0871a6c2bad3bce650d1c86430c37fe42c8418be8c35eaef77df83545958a7a7a97fa97ac41f29e1c0e92a06c1db54cfe286 + languageName: node + linkType: hard + "@testing-library/cypress@npm:10.1.0": version: 10.1.0 resolution: "@testing-library/cypress@npm:10.1.0" @@ -4448,20 +4455,13 @@ __metadata: languageName: node linkType: hard -"@types/geojson@npm:*, @types/geojson@npm:^7946.0.0 || ^1.0.0": +"@types/geojson@npm:*": version: 7946.0.16 resolution: "@types/geojson@npm:7946.0.16" checksum: 10c0/1ff24a288bd5860b766b073ead337d31d73bdc715e5b50a2cee5cb0af57a1ed02cc04ef295f5fa68dc40fe3e4f104dd31282b2b818a5ba3231bc1001ba084e3c languageName: node linkType: hard -"@types/geojson@npm:^1.0.0": - version: 1.0.6 - resolution: "@types/geojson@npm:1.0.6" - checksum: 10c0/a8da051dda87226eb4a239a3238956ddb22806bd1faaed7d3ba12cbac031eae622aaec07b65c700093881956d47db1fdc2dd59674db0479c630a3095b3f21720 - languageName: node - linkType: hard - "@types/history@npm:^4.7.11": version: 4.7.11 resolution: "@types/history@npm:4.7.11" @@ -4552,7 +4552,16 @@ __metadata: languageName: node linkType: hard -"@types/leaflet@npm:1": +"@types/leaflet-draw@npm:1.0.13": + version: 1.0.13 + resolution: "@types/leaflet-draw@npm:1.0.13" + dependencies: + "@types/leaflet": "npm:^1.9" + checksum: 10c0/0b72a8afc38cd097fb024cd767dae1bd053c15987a7fa57ec7b13802adfa280a952665a0fd314d0a69686095b67f5eeb0e00c1f5dc02eb3507b6995ea5a3b024 + languageName: node + linkType: hard + +"@types/leaflet@npm:1, @types/leaflet@npm:^1.9": version: 1.9.21 resolution: "@types/leaflet@npm:1.9.21" dependencies: @@ -5683,6 +5692,7 @@ __metadata: "@pmmmwh/react-refresh-webpack-plugin": "npm:0.6.2" "@tanstack/react-query": "npm:5.90.16" "@tanstack/react-query-devtools": "npm:5.91.2" + "@terraformer/wkt": "npm:2.2.1" "@testing-library/cypress": "npm:10.1.0" "@testing-library/dom": "npm:10.4.1" "@testing-library/jest-dom": "npm:6.9.1" @@ -5694,6 +5704,7 @@ __metadata: "@types/js-levenshtein": "npm:1.1.3" "@types/json-schema": "npm:7.0.15" "@types/leaflet": "npm:1" + "@types/leaflet-draw": "npm:1.0.13" "@types/marked": "npm:6.0.0" "@types/mime": "npm:4.0.0" "@types/node": "npm:24.10.7" @@ -5767,6 +5778,7 @@ __metadata: jsdom: "patch:jsdom@npm%3A26.1.0#~/.yarn/patches/jsdom-npm-26.1.0-3857255f02.patch" jsonpointer: "npm:5.0.1" leaflet: "npm:1.9.4" + leaflet-draw: "npm:1.0.4" lint-staged: "npm:16.2.7" lru-cache: "npm:11.2.4" marked: "npm:17.0.1" @@ -5783,6 +5795,7 @@ __metadata: react-dom: "npm:19.2.3" react-dropzone: "npm:14.3.8" react-leaflet: "npm:5.0.0" + react-leaflet-draw: "npm:0.21.0" react-number-format: "npm:5.4.4" react-refresh: "npm:0.18.0" react-router-dom: "npm:6.30.3" @@ -5790,7 +5803,6 @@ __metadata: resize-observer-polyfill: "npm:1.5.1" source-map-loader: "npm:5.0.0" style-loader: "npm:4.0.0" - terraformer-wkt-parser: "npm:1.2.1" terser-webpack-plugin: "npm:5.3.16" tinybench: "npm:6.0.0" ts-jest: "npm:29.4.6" @@ -13091,6 +13103,13 @@ __metadata: languageName: node linkType: hard +"leaflet-draw@npm:1.0.4": + version: 1.0.4 + resolution: "leaflet-draw@npm:1.0.4" + checksum: 10c0/9e700b7e5e7d70c0f9d66efe2fec282f635eff63699e5f2f4eb733b140188058cc7cb2a5d3c6889c300958ec9e415fd5c0a02a08db0e1fc13a063d4710cff004 + languageName: node + linkType: hard + "leaflet@npm:1.9.4": version: 1.9.4 resolution: "leaflet@npm:1.9.4" @@ -13269,6 +13288,13 @@ __metadata: languageName: node linkType: hard +"lodash-es@npm:^4.17.15": + version: 4.17.21 + resolution: "lodash-es@npm:4.17.21" + checksum: 10c0/fb407355f7e6cd523a9383e76e6b455321f0f153a6c9625e21a8827d10c54c2a2341bd2ae8d034358b60e07325e1330c14c224ff582d04612a46a4f0479ff2f2 + languageName: node + linkType: hard + "lodash.camelcase@npm:^4.3.0": version: 4.3.0 resolution: "lodash.camelcase@npm:4.3.0" @@ -15411,6 +15437,22 @@ __metadata: languageName: node linkType: hard +"react-leaflet-draw@npm:0.21.0": + version: 0.21.0 + resolution: "react-leaflet-draw@npm:0.21.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + lodash-es: "npm:^4.17.15" + peerDependencies: + leaflet: ^1.8.0 + leaflet-draw: ^1.0.4 + prop-types: ^15.5.2 + react: ^19.1.0 + react-leaflet: ^5.0.0 + checksum: 10c0/98b37e322fd72f3f3ffc8b29c85bbf39ac49f1ce3f3155744c96804e782f580f24f8472da8c77f9b983e58ef6f49d520024d2125b7184614e34559007826a941 + languageName: node + linkType: hard + "react-leaflet@npm:5.0.0": version: 5.0.0 resolution: "react-leaflet@npm:5.0.0" @@ -17173,28 +17215,6 @@ __metadata: languageName: node linkType: hard -"terraformer-wkt-parser@npm:1.2.1": - version: 1.2.1 - resolution: "terraformer-wkt-parser@npm:1.2.1" - dependencies: - "@types/geojson": "npm:^1.0.0" - terraformer: "npm:~1.0.5" - checksum: 10c0/dd7c42fc04e1e3a3c288f19a93c54e8926ba42d62b0a168c6ebae814497a90d3d6d81e1b37bba09b226838152d67ee808f3105008c6076e0654c6d53b5ed9661 - languageName: node - linkType: hard - -"terraformer@npm:~1.0.5": - version: 1.0.12 - resolution: "terraformer@npm:1.0.12" - dependencies: - "@types/geojson": "npm:^7946.0.0 || ^1.0.0" - dependenciesMeta: - "@types/geojson": - optional: true - checksum: 10c0/bb6cb5aa853f5f8edf0a4805a03b7698fcf04f50c99c36f1ff3436a80b7e9225aaabe0c7f27dcdd255826fc03ad3da09cad30ebd80e13b7915a9642317c17234 - languageName: node - linkType: hard - "terser-webpack-plugin@npm:5.3.16, terser-webpack-plugin@npm:^5.3.16": version: 5.3.16 resolution: "terser-webpack-plugin@npm:5.3.16"