Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b1dc75c
Feature/3686-draw-on-map (#3785)
Magnusrm Oct 14, 2025
834424e
Merge branch 'main' into feature/3686-draw-on-map
tina-ahm Oct 20, 2025
b703041
Feature/toolbar config (#3810)
tina-ahm Oct 27, 2025
a87803c
Merge branch 'main' into feature/3686-draw-on-map
tina-ahm Oct 27, 2025
efaa8e1
Merge remote-tracking branch 'origin/main' into feature/3686-draw-on-map
Magnusrm Nov 12, 2025
d0a0808
Merge remote-tracking branch 'origin/main' into feature/3686-draw-on-map
Magnusrm Nov 18, 2025
d966b70
Save-to-datamodel (#3855)
Magnusrm Nov 18, 2025
91f88d8
Feat/3686-draw-on-map/edit-geometries (#3865)
Magnusrm Nov 25, 2025
2c0faff
delete geos wip
Magnusrm Nov 27, 2025
65e2e3a
add WKT support, replace deprecated WKT package
Magnusrm Dec 12, 2025
65ca677
Merge remote-tracking branch 'origin/main' into feature/3686-draw-on-map
Magnusrm Dec 12, 2025
b44db26
update yarn.lock
Magnusrm Dec 12, 2025
0a4d69f
remove caret from react-ealfet-draw version
Magnusrm Dec 15, 2025
fa4cb60
fix duplicate geometries being rendered
Magnusrm Dec 16, 2025
8aa4108
Merge branch 'feature/3686-draw-on-map' into feature/3686-draw-on-map…
Magnusrm Dec 17, 2025
a6e22ec
debugging
Magnusrm Dec 17, 2025
243be89
fix editing bug
Magnusrm Dec 23, 2025
971580d
Merge remote-tracking branch 'origin/main' into feature/3686-draw-on-map
Magnusrm Dec 23, 2025
c0b5407
fix coderabbit comments
Magnusrm Jan 5, 2026
fdf69f0
Merge remote-tracking branch 'origin/main' into feature/3686-draw-on-map
Magnusrm Jan 5, 2026
2845583
Merge remote-tracking branch 'origin/main' into feature/3686-draw-on-map
Magnusrm Jan 19, 2026
a3025ee
add cypress tests for drawing geometries on map
Magnusrm Jan 21, 2026
67db162
use specific versioning
Magnusrm Jan 21, 2026
32aeff4
Merge remote-tracking branch 'origin/main' into feature/3686-draw-on-map
Magnusrm Jan 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion snapshots.js
Original file line number Diff line number Diff line change
Expand Up @@ -368,5 +368,5 @@ module.exports = {
}
}
},
"__version": "15.7.0"
"__version": "15.8.2"
}
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
15 changes: 11 additions & 4 deletions src/layout/Map/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -26,6 +28,8 @@ export function Map({ baseComponentId, className, readOnly, animate = true }: Ma
const map = useRef<LeafletMap | null>(null);
const isPdf = useIsPdf();
const { center, zoom, bounds } = useAutoViewport(baseComponentId, map, animate);
const { toolbar, dataModelBindings } = useItemWhenType(baseComponentId, 'Map');
const simpleBinding = dataModelBindings?.simpleBinding;

return (
<MapContainer
Expand All @@ -48,15 +52,18 @@ export function Map({ baseComponentId, className, readOnly, animate = true }: Ma
scrollWheelZoom={!readOnly}
attributionControl={false}
>
{toolbar !== undefined && <MapEditGeometries baseComponentId={baseComponentId} />}
<MapLayers baseComponentId={baseComponentId} />
<MapGeometries
baseComponentId={baseComponentId}
readOnly={readOnly}
/>
<MapSingleMarker
baseComponentId={baseComponentId}
readOnly={readOnly}
/>
{toolbar === undefined && simpleBinding && (
<MapSingleMarker
baseComponentId={baseComponentId}
readOnly={readOnly}
/>
)}
<AttributionControl prefix={false} />
</MapContainer>
);
Expand Down
50 changes: 50 additions & 0 deletions src/layout/Map/config.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
209 changes: 209 additions & 0 deletions src/layout/Map/features/geometries/editable/MapEditGeometries.tsx
Original file line number Diff line number Diff line change
@@ -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<L.FeatureGroup>(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,
});
}
});
Comment on lines +164 to +172
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the file and examine how initialGeometries is derived
head -200 src/layout/Map/features/geometries/editable/MapEditGeometries.tsx | cat -n

Repository: Altinn/app-frontend-react

Length of output: 8103


🏁 Script executed:

#!/bin/bash
# Search for useMapParsedGeometries and related hooks
rg -n "useMapParsedGeometries|initialGeometries" src/layout/Map/features/geometries/ -B 2 -A 5

Repository: Altinn/app-frontend-react

Length of output: 8415


🏁 Script executed:

#!/bin/bash
# Check line 38 and surrounding context to understand the filtering
sed -n '30,50p' src/layout/Map/features/geometries/editable/MapEditGeometries.tsx | cat -n

Repository: Altinn/app-frontend-react

Length of output: 1184


🏁 Script executed:

#!/bin/bash
# Check useMapRawGeometries to understand the data structure
rg -n -A 20 "export function useMapRawGeometries" src/layout/Map/features/geometries/fixed/hooks.ts

Repository: Altinn/app-frontend-react

Length of output: 1147


🏁 Script executed:

#!/bin/bash
# Check if Geometry type includes original index information
rg -n "interface Geometry\|type Geometry" src/layout/Map/features/geometries/ -A 10

Repository: Altinn/app-frontend-react

Length of output: 51


🏁 Script executed:

#!/bin/bash
# Find the Geometry type definition
rg -n "export.*type Geometry|export.*interface Geometry" src/layout/Map/features/geometries/ -B 2 -A 8

Repository: Altinn/app-frontend-react

Length of output: 51


🏁 Script executed:

#!/bin/bash
# Check the full parseGeometries function to see how Geometry objects are created
rg -n -A 30 "function parseGeometries" src/layout/Map/features/geometries/fixed/hooks.ts

Repository: Altinn/app-frontend-react

Length of output: 1323


🏁 Script executed:

#!/bin/bash
# Verify the issue: check if Geometry type is exported and see its definition
cat src/layout/Map/features/geometries/fixed/hooks.ts | head -90 | tail -30

Repository: Altinn/app-frontend-react

Length of output: 996


🏁 Script executed:

#!/bin/bash
# Check the RawGeometry and Geometry types more carefully
rg -n "RawGeometry|interface Geometry\|type Geometry" src/layout/Map/features/geometries/fixed/hooks.ts -B 1 -A 5

Repository: Altinn/app-frontend-react

Length of output: 1395


🏁 Script executed:

#!/bin/bash
# Check the types definition file to see Geometry and RawGeometry structures
cat src/layout/Map/types.ts | head -100

Repository: Altinn/app-frontend-react

Length of output: 332


Use altinnRowId for field lookup instead of filtered array index.

The code finds the geometry by altinnRowId but then uses the index from the filtered initialGeometries array to build the update path. Since initialGeometries filters by isEditable, the indices no longer match the original form data array. For example, if form data contains 4 geometries but only 3 are editable, the filtered array indices will be off by one or more, causing updates to the wrong record.

The onDeletedHandler (below) already uses the correct pattern with altinnRowId-based lookup. Apply the same approach to onEditedHandler to find the record in the unfiltered data source:

initialGeometries?.forEach((g, index) => {
  if (g.altinnRowId === altinnRowId) {
    const field = `${geometryBinding.field}[${index}].${geometryDataPath}`;

Instead, look up the original index from the complete geometry list or use setLeafValue with a reference that supports altinnRowId matching.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @src/layout/Map/features/geometries/editable/MapEditGeometries.tsx around
lines 165-173, The update logic currently uses the index from the filtered
initialGeometries array which misaligns with the original form data; change the
lookup so you find the geometry's index in the unfiltered source by matching
altinnRowId (e.g. findIndex on the complete geometries array used to build the
form data) and then build the field path using that original index (the same
pattern used in onDeletedHandler), then call setLeafValue with the corrected
reference (keep using geometryBinding.field and geometryDataPath but replace the
filtered index with the found originalIndex) so the correct record is updated.

});
};

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 (
<FeatureGroup ref={editRef}>
<EditControl
position='topright'
onCreated={onCreatedHandler}
onEdited={onEditedHandler}
onDeleted={onDeletedHandler}
draw={{
polyline: !!toolbar?.polyline,
polygon: !!toolbar?.polygon,
rectangle: !!toolbar?.rectangle,
circle: !!toolbar?.circle,
marker: !!toolbar?.marker,
circlemarker: false,
}}
/>
</FeatureGroup>
);
}
Loading
Loading