Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions map-view/src/api/__mocks__/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ export const mockMapConfig: MapConfig = {
name: "Basemap 1",
use_traffic_sign_icons: false,
clustered: false,
extra_feature_info: {},
},
{
identifier: "basemap-2",
name: "Basemap 2",
use_traffic_sign_icons: false,
clustered: false,
extra_feature_info: {},
},
],
},
Expand All @@ -29,13 +31,15 @@ export const mockMapConfig: MapConfig = {
app_name: "traffic_control",
use_traffic_sign_icons: false,
clustered: false,
extra_feature_info: {},
},
{
identifier: "overlay-2",
name: "Overlay 2",
app_name: "city_furniture",
use_traffic_sign_icons: false,
clustered: false,
extra_feature_info: {},
},
],
},
Expand Down
5 changes: 5 additions & 0 deletions map-view/src/common/MapUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ export function getFeatureLayerName(feature: Feature, overlayConfig: LayerConfig
return feature_layer ? feature_layer["name"] : "FeatureInfo title (missing)";
}

export function getFeatureLayerExtraInfoFields(feature: Feature, overlayConfig: LayerConfig) {
const feature_layer = getFeatureLayer(getFeatureType(feature), overlayConfig);
return feature_layer ? feature_layer["extra_feature_info"] : {};
}

function getFeatureLayer(featureType: string, overlayConfig: LayerConfig) {
return overlayConfig["layers"].find((l) => l.identifier === featureType);
}
Expand Down
115 changes: 75 additions & 40 deletions map-view/src/components/FeatureInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import NavigateBefore from "@mui/icons-material/NavigateBefore";
import NavigateNext from "@mui/icons-material/NavigateNext";
import React from "react";
import { APIBaseUrl } from "../consts";
import { Feature, MapConfig } from "../models";
import { Feature, FeatureProperties, MapConfig } from "../models";
import { withTranslation, WithTranslation } from "react-i18next";
import { getFeatureAppName, getFeatureLayerName } from "../common/MapUtils";
import { getFeatureAppName, getFeatureLayerExtraInfoFields, getFeatureLayerName } from "../common/MapUtils";

const styles = (theme: Theme) =>
createStyles({
Expand Down Expand Up @@ -97,11 +97,8 @@ class FeatureInfo extends React.Component<FeatureInfoProps, FeatureInfoState> {
this.runOnSelectFeature(featureIndex);
}

render() {
const { features, classes, onClose, t, mapConfig } = this.props;
const { featureIndex } = this.state;
const feature = features[featureIndex];

renderCommonFields(feature: Feature) {
const { t } = this.props;
const {
id,
value,
Expand All @@ -114,6 +111,73 @@ class FeatureInfo extends React.Component<FeatureInfoProps, FeatureInfoState> {
additional_information,
} = feature.getProperties();
const deviceTypeText = `${device_type_code} - ${device_type_description}${value ? ` (${value})` : ""}`;
const additionalInfoText = txt || additional_information;

return (
<>
<b>Id</b>: {id}
{device_type_code && (
<>
<br />
<b>{t("Device type")}</b>: {deviceTypeText}
</>
)}
{mount_type_description_fi && !device_type_code && (
<>
<br />
<b>{t("Mount type")}</b>: {mount_type_description_fi}
</>
)}
{direction && (
<>
<br />
<b>{t("Direction")}</b>: {direction}
</>
)}
{additionalInfoText && (
<>
<br />
<b>{t("Additional info")}</b>: {additionalInfoText}
</>
)}
{content_s && (
<>
<br />
<b>{t("Content Schema")}</b>: {JSON.stringify(content_s)}
</>
)}
{feature.getProperties().device_plan_id && (
<>
<br />
<b>{t("Distance to plan")}</b>: {this.state.realPlanDistance} m
</>
)}
</>
);
}

renderFeatureSpecificFields(feature: Feature, mapConfig: MapConfig) {
const extra_fields = getFeatureLayerExtraInfoFields(feature, mapConfig.overlayConfig);
if (extra_fields) {
return (
<>
{Object.entries(extra_fields).map(([key, value]) => (
<React.Fragment key={key}>
<br />
<b>{value}</b>: {JSON.stringify(feature.getProperties()[key as keyof FeatureProperties])}{" "}
</React.Fragment>
))}
</>
);
} else {
return <></>;
}
}

render() {
const { features, classes, onClose, t, mapConfig } = this.props;
const { featureIndex } = this.state;
const feature = features[featureIndex];

// Only run when distance is undefined (don't spam requests)
if (this.state.realPlanDistance === undefined) {
Expand All @@ -127,39 +191,10 @@ class FeatureInfo extends React.Component<FeatureInfoProps, FeatureInfoState> {
{this.getFeatureInfoTitle(feature)}
</Typography>
<Typography className={classes.content} variant="body1" component="p">
<b>Id</b>: {id}
{device_type_code && (
<>
<br />
<b>{t("Device type")}</b>: {deviceTypeText}
</>
)}
{mount_type_description_fi && !device_type_code && (
<>
<br />
<b>{t("Mount type")}</b>: {mount_type_description_fi}
</>
)}
{direction && (
<>
<br />
<b>{t("Direction")}</b>: {direction}
</>
)}
<br />
<b>{t("Additional info")}</b>: {txt || additional_information}
{content_s && (
<>
<br />
<b>{t("Content Schema")}</b>: {JSON.stringify(content_s)}
</>
)}
{feature.getProperties().device_plan_id && (
<>
<br />
<b>{t("Distance to plan")}</b>: {this.state.realPlanDistance} m
</>
)}
{this.renderCommonFields(feature)}
</Typography>
<Typography className={classes.content} variant="body1" component="p">
{this.renderFeatureSpecificFields(feature, mapConfig)}
</Typography>
</CardContent>
<CardActions>
Expand Down
1 change: 1 addition & 0 deletions map-view/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface Layer {
filter_fields?: string[];
use_traffic_sign_icons: boolean;
clustered: boolean;
extra_feature_info: Record<string, string>;
}

export interface LayerConfig {
Expand Down
19 changes: 19 additions & 0 deletions map/migrations/0010_layer_extra_feature_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 4.2.20 on 2025-06-02 13:27

from django.db import migrations, models
import map.validators


class Migration(migrations.Migration):

dependencies = [
('map', '0009_layer_name_sv'),
]

operations = [
migrations.AddField(
model_name='layer',
name='extra_feature_info',
field=models.JSONField(blank=True, help_text='Fields added here need to be included in the corresponding WFS Feature', null=True, validators=[map.validators.validate_layer_extra_feature_info], verbose_name='Extra Feature Info'),
),
]
13 changes: 13 additions & 0 deletions map/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from django.db import models, NotSupportedError
from django.utils.translation import gettext_lazy as _

from map.validators import validate_layer_extra_feature_info


class Layer(models.Model):
identifier = models.CharField(_("Identifier"), max_length=200)
Expand All @@ -15,6 +17,13 @@ class Layer(models.Model):
filter_fields = models.CharField(_("Filter fields"), max_length=200, blank=True)
use_traffic_sign_icons = models.BooleanField(_("Use Traffic Sign Icons"), default=False)
clustered = models.BooleanField(_("Clustered"), default=True)
extra_feature_info = models.JSONField(
_("Extra Feature Info"),
null=True,
blank=True,
help_text=_("Fields added here need to be included in the corresponding WFS Feature"),
validators=[validate_layer_extra_feature_info],
)

class Meta:
verbose_name = _("Layer")
Expand All @@ -24,6 +33,10 @@ class Meta:
def __str__(self):
return self.name_fi

def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)


class FeatureTypeEditMapping(models.Model):
"""Model for mapping feature type to another string that is used in map-view FeatureInfo component edit button.
Expand Down
52 changes: 47 additions & 5 deletions map/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json

import pytest
from django.conf import settings
from django.core.exceptions import ValidationError
from django.test import RequestFactory, TestCase
from django.urls import reverse

Expand Down Expand Up @@ -32,6 +34,7 @@ def test_return_success_for_staff_user(self):
class MapConfigTestCase(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.extra_feature_info = {"testfield": {"title_fi": "testi kenttä", "title_en": "test field"}}

def test_with_no_icon_draw_config(self):
"""Test that without any active IconDrawingConfig the default values are used."""
Expand Down Expand Up @@ -66,18 +69,22 @@ def test_with_icon_draw_config(self):
)

def test_layer_config_return_ok_en(self):
self._do_test_layer_config_ok("en")
self._do_test_layer_config_ok("en", self.extra_feature_info.get("testfield").get("title_en"))

def test_layer_config_return_ok_fi(self):
self._do_test_layer_config_ok("fi")
self._do_test_layer_config_ok("fi", self.extra_feature_info.get("testfield").get("title_fi"))

def test_layer_config_return_ok_sv(self):
self._do_test_layer_config_ok("sv")
self._do_test_layer_config_ok("sv", self.extra_feature_info.get("testfield").get("title_fi"))

def test_layer_config_return_ok_not_supported(self):
self._do_test_layer_config_ok("not_supported_lang", "fi")
self._do_test_layer_config_ok(
"not_supported_lang",
self.extra_feature_info.get("testfield").get("title_fi"),
"fi",
)

def _do_test_layer_config_ok(self, language_code, expected_language_code=None):
def _do_test_layer_config_ok(self, language_code, expected_testfield_value, expected_language_code=None):
expect_language_code = expected_language_code or language_code
Layer.objects.create(
identifier="basemap",
Expand All @@ -99,6 +106,7 @@ def _do_test_layer_config_ok(self, language_code, expected_language_code=None):
name_fi="Overlay 2 fi",
name_sv="Overlay 2 sv",
is_basemap=False,
extra_feature_info=self.extra_feature_info,
)
FeatureTypeEditMapping.objects.create(name="featurename", edit_name="edit_featurename")
request = self.factory.get(reverse("map-config"))
Expand All @@ -111,9 +119,43 @@ def _do_test_layer_config_ok(self, language_code, expected_language_code=None):
self.assertEqual(len(response_data["overlayConfig"]["layers"]), 2)
self.assertEqual(response_data["overlayConfig"]["layers"][0]["name"], f"Overlay 1 {expect_language_code}")
self.assertEqual(response_data["overlayConfig"]["layers"][1]["name"], f"Overlay 2 {expect_language_code}")
self.assertEqual(response_data["overlayConfig"]["layers"][0]["extra_feature_info"], {})
self.assertEqual(
response_data["overlayConfig"]["layers"][1]["extra_feature_info"]["testfield"],
expected_testfield_value,
)
self.assertEqual(
response_data["overviewConfig"]["imageUrl"],
f"{request.build_absolute_uri(settings.STATIC_URL)}traffic_control/png/map/cityinfra_overview_map-704x704.png",
)
self.assertEqual(response_data["overviewConfig"]["imageExtent"], [25490088.0, 6665065.0, 25512616, 6687593.0])
self.assertEqual(response_data["featureTypeEditNameMapping"], {"featurename": "edit_featurename"})


@pytest.mark.django_db
@pytest.mark.parametrize(
"extra_feature_info, exception_msg",
(
({"testfield": None}, "Extra Feature Info field data value cannot be empty for key: testfield"),
({"testfield": {"title_sv": None}}, "Extra field testfield does not have mandatory value title_fi"),
(
{"testfield": {"title_fi": None, "empty_list": [], "empty_dict": {}, "emptry_string": ""}},
"Extra Feature Info field data cannot be empty:",
),
),
)
def test_extra_feature_info_not_ok(extra_feature_info, exception_msg):
layer = Layer(
identifier="overlay-1",
name_en="Overlay 1 en",
name_fi="Overlay 1 fi",
name_sv="Overlay 1 sv",
is_basemap=False,
extra_feature_info=extra_feature_info,
)
with pytest.raises(ValidationError) as exc_info:
# full_clean has to be called so field validators are called.
layer.full_clean()
assert exc_info.value is not None
assert "extra_feature_info" in exc_info.value.error_dict
assert exception_msg in exc_info.value.error_dict.get("extra_feature_info", [])[0].messages[0]
15 changes: 15 additions & 0 deletions map/validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from django.core.exceptions import ValidationError


def validate_layer_extra_feature_info(value):
"""Validator to check Layer model's extra_feature_info field.
For each key there has to be title_fi in the payload dict.
"""
if value:
for k, v in value.items():
if v is None:
raise ValidationError(f"Extra Feature Info field data value cannot be empty for key: {k}")
if "title_fi" not in list(v.keys()):
raise ValidationError(f"Extra field {k} does not have mandatory value title_fi")
if not all(v.values()):
raise ValidationError(f"Extra Feature Info field data cannot be empty: {v}")
Loading
Loading