Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
07e8a8b
clean up comments and simplify early return logic
Magnusrm Feb 11, 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();
200 changes: 200 additions & 0 deletions src/layout/Map/features/geometries/editable/MapEditGeometries.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
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);
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

initialGeometries creates a new array reference every render, causing the effect to re-run continuously.

.filter() on Line 38 returns a new array on every render. Since this array is the sole dependency of the useEffect (Line 104), the effect fires every render — clearing and re-adding all layers each time. This causes unnecessary work and potential visual flicker.

Memoize the filtered result:

🐛 Proposed fix

Add useMemo to the import on Line 1, then:

-  const initialGeometries = useMapParsedGeometries(baseComponentId)?.filter((g) => g.isEditable);
+  const allParsedGeometries = useMapParsedGeometries(baseComponentId);
+  const initialGeometries = useMemo(
+    () => allParsedGeometries?.filter((g) => g.isEditable),
+    [allParsedGeometries],
+  );

Also applies to: 50-104

🤖 Prompt for AI Agents
In `@src/layout/Map/features/geometries/editable/MapEditGeometries.tsx` at line
38, The filtered geometry array is re-created each render because .filter()
returns a new reference; import useMemo and memoize the result: call const
parsed = useMapParsedGeometries(baseComponentId) and then wrap the filter in
useMemo (e.g. const initialGeometries = useMemo(() => parsed?.filter(g =>
g.isEditable), [parsed]) so the dependency for the useEffect is stable; apply
the same pattern to other similar spots between the initialGeometries
declaration and the effect (lines ~50-104) where .filter() or other array
transforms are used.


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,
properties: {
...feature.properties,
altinnRowId: item.altinnRowId,
},
};

// 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;

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,
},
};
Comment on lines +81 to +95
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Avoid as Feature type casts; narrow with a type guard instead.

Lines 83 and 85 use as Feature casts, which bypasses type safety. As per coding guidelines, "Avoid using any or type casting (as type) in TypeScript; instead, improve typing by removing such casts and anys to maintain proper type safety".

♻️ Suggested approach

Define a type guard function, e.g.:

function isGeoJsonFeature(data: unknown): data is Feature {
  return typeof data === 'object' && data !== null && 'type' in data && (data as Record<string, unknown>).type === 'Feature';
}

Then use it in place of the isFeature check and the casts will be unnecessary.

🤖 Prompt for AI Agents
In `@src/layout/Map/features/geometries/editable/MapEditGeometries.tsx` around
lines 81 - 95, The code uses unsafe casts "(geoData as Feature)" when building
newFeature; replace those casts by adding a type guard like
isGeoJsonFeature(data): data is Feature and use it to narrow geoData instead of
isFeature, then remove the "as Feature" casts in the newFeature creation (keep
the existing branches for Feature vs geometry), ensuring newFeature is
constructed from the narrowed geoData in the branch that the type guard confirms
is a Feature and from geoData as geometry in the other branch; update any
parameter typing for geoData if needed so the guard can operate.


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 || !geometryDataBinding || !isEditableBinding) {
return;
}

e.layers.eachLayer((layer) => {
// @ts-expect-error - Leaflet's typings don't guarantee feature or properties exist, but we ensure they do in onCreatedHandler
const editedGeo = layer.toGeoJSON();
const altinnRowId = editedGeo.properties?.altinnRowId;

let geoString = JSON.stringify(editedGeo);

if (geometryType == 'WKT') {
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 | 🟡 Minor

Use strict equality (===) instead of loose equality (==).

Line 151 uses == which can produce unexpected coercions.

-      if (geometryType == 'WKT') {
+      if (geometryType === 'WKT') {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (geometryType == 'WKT') {
if (geometryType === 'WKT') {
🤖 Prompt for AI Agents
In `@src/layout/Map/features/geometries/editable/MapEditGeometries.tsx` at line
151, Change the loose equality check to a strict one: in the MapEditGeometries
component replace the conditional that compares geometryType to 'WKT' (the
expression using geometryType == 'WKT') with a strict equality comparison
(geometryType === 'WKT') so the comparison avoids unintended type coercion.

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 - Leaflet's typings don't guarantee feature or properties exist, but we ensure they do in onCreatedHandler
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