Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • geocontrib/geocontrib-frontend
  • ext_matthieu/geocontrib-frontend
  • fnecas/geocontrib-frontend
  • MatthieuE/geocontrib-frontend
4 results
Show changes
Showing
with 3789 additions and 1289 deletions
......@@ -10,14 +10,14 @@ const mapAPI = {
basemap['project'] = projectSlug;
if (newBasemapIds.includes(basemap.id)) {
return axios
.post(`${baseUrl}base-maps/`, basemap)
.post(`${baseUrl}v2/base-maps/`, basemap)
.then((response) => response)
.catch((error) => {
throw error;
});
} else {
return axios
.put(`${baseUrl}base-maps/${basemap.id}/`, basemap)
.put(`${baseUrl}v2/base-maps/${basemap.id}/`, basemap)
.then((response) => response)
.catch((error) => {
throw error;
......
import TileWMS from 'ol/source/TileWMS';
import { View, Map } from 'ol';
import { ScaleLine, Zoom, Attribution } from 'ol/control';
import { ScaleLine, Zoom, Attribution, FullScreen } from 'ol/control';
import TileLayer from 'ol/layer/Tile';
import { transform, transformExtent } from 'ol/proj.js';
import { transform, transformExtent, fromLonLat } from 'ol/proj';
import { defaults } from 'ol/interaction';
import XYZ from 'ol/source/XYZ';
import VectorTileLayer from 'ol/layer/VectorTile';
......@@ -10,20 +10,44 @@ import VectorTileSource from 'ol/source/VectorTile';
import { MVT, GeoJSON } from 'ol/format';
import { boundingExtent } from 'ol/extent';
import Overlay from 'ol/Overlay';
import {
Fill, Stroke, Style, Circle //RegularShape, Circle as CircleStyle, Text,Icon
} from 'ol/style';
import { Fill, Stroke, Style, Circle } from 'ol/style';
import { asArray } from 'ol/color';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import { fromLonLat } from 'ol/proj.js';
import OverlayPositioning from 'ol/OverlayPositioning';
import WMTSTileGrid from 'ol/tilegrid/WMTS';
import WMTS, { optionsFromCapabilities } from 'ol/source/WMTS';
import WMTSCapabilities from 'ol/format/WMTSCapabilities';
import Geolocation from 'ol/Geolocation.js';
import Feature from 'ol/Feature.js';
import Point from 'ol/geom/Point.js';
import { applyStyle } from 'ol-mapbox-style';
import { isEqual } from 'lodash';
import axios from '@/axios-client.js';
import router from '@/router';
import { statusChoices } from '@/utils';
let dictLayersToLeaflet = {};
import store from '@/store';
import { retrieveFeatureProperties } from '@/utils';
const parser = new WMTSCapabilities();
let dictLayersToMap = {};
let layersCount = 0;
const geolocationStyle = new Style({
image: new Circle({
radius: 6,
fill: new Fill({
color: '#3399CC',
}),
stroke: new Stroke({
color: '#fff',
width: 2,
}),
}),
});
const mapService = {
layers: [],
......@@ -36,6 +60,15 @@ const mapService = {
map: undefined,
queryParams: {},
geolocation: undefined, // for geolocation
geolocationSource: null, // for geolocation
positionFeature: null, // for geolocation
lastPosition: null, // for geolocation
getMap() {
return this.map;
......@@ -54,21 +87,25 @@ const mapService = {
maxZoom,
zoom,
zoomControl = true,
fullScreenControl = false,
geolocationControl = false,
interactions = { doubleClickZoom: false, mouseWheelZoom: false, dragPan: true },
controls = [
new Attribution({ collapsible: false }),
new ScaleLine({
units: 'metric',
}),
],
} = options;
if (el.innerHTML) {
el.innerHTML = '';
if (fullScreenControl) {
controls.push(new FullScreen({ tipLabel: 'Mode plein écran' }));
}
const mapOptions = {
layers: [],
target: el,
controls: [
new Attribution(),
new ScaleLine({
units: 'metric',
})],
controls,
interactions: defaults(interactions),
view: new View({
center: transform([ //* since 0 is considered false, check for number instead of just defined (though boolean will pass through)
......@@ -86,6 +123,10 @@ const mapService = {
if (zoomControl) {
this.map.addControl(new Zoom({ zoomInTipLabel: 'Zoomer', zoomOutTipLabel: 'Dézoomer' }));
}
if (geolocationControl) {
this.initGeolocation();
}
this.map.once('rendercomplete', () => {
this.map.updateSize();
});
......@@ -106,16 +147,47 @@ const mapService = {
overlay.setPosition(undefined);
closer.blur();
return false;
};
};
}
this.map.addOverlay(this.overlay);
this.map.on('click', this.onMapClick.bind(this));
// catch event from sidebarLayer to update layers order (since all maps use it now)
document.addEventListener('change-layers-order', (event) => {
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
this.updateOrder(event.detail.layers.slice().reverse());
});
return this.map;
},
addRouterToPopup(featureTypeSlug, featureId) {
addRouterToPopup({ featureId, featureTypeSlug, index }) {
const getFeaturePosition = async (searchParams) => {
const response = await axios.get(`${store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${router.history.current.params.slug}/feature/${featureId}/position-in-list/?${searchParams.toString()}`);
return response.data;
};
const goToBrowseFeatureDetail = async () => {
const currentQuery = { ...this.queryParams };
if (this.queryParams && this.queryParams.filter === 'feature_type_slug') { // when feature_type is the default filter of the project,
currentQuery['feature_type_slug'] = featureTypeSlug; // get its slug for the current feature
}
const searchParams = new URLSearchParams(currentQuery); // urlSearchParams allow to get rid of undefined values
if (!index >= 0) { // with mvt feature, there can't be an index
index = await getFeaturePosition(searchParams);
}
router.push({
name: 'details-signalement-filtre',
query: {
...Object.fromEntries(searchParams.entries()), // transform search params into object and spread it into query
offset: index
}
});
};
function goToFeatureDetail() {
router.push({
name: 'details-signalement',
......@@ -125,55 +197,61 @@ const mapService = {
},
});
}
function goToFeatureTypeDetail() {
const goToFeatureTypeDetail = () => {
router.push({
name: 'details-type-signalement',
params: {
feature_type_slug: featureTypeSlug,
},
});
}
document.getElementById('goToFeatureTypeDetail').onclick = goToFeatureTypeDetail;
document.getElementById('goToFeatureDetail').onclick = goToFeatureDetail;
};
const isFeatureBrowsing = (router.history.current.name === 'project_detail' || router.history.current.name === 'liste-signalements');
const featureEl = document.getElementById('goToFeatureDetail');
if (featureEl) featureEl.onclick = isFeatureBrowsing ? goToBrowseFeatureDetail : goToFeatureDetail;
const featureTypeEl = document.getElementById('goToFeatureTypeDetail');
if (featureTypeEl) featureTypeEl.onclick = goToFeatureTypeDetail;
},
onMapClick(event) {
async onMapClick (event) {
//* retrieve features under pointer
const features = this.map.getFeaturesAtPixel(event.pixel, {
layerFilter: (l) => l === this.mvtLayer || this.olLayer
layerFilter: (l) => l === this.mvtLayer || this.olLayer
});
//* prepare popup content
if (features && features.length > 0) {
if (features && features.length > 0 && this.content) {
const featureId = features[0].properties_ ? features[0].properties_.feature_id : features[0].id_;
const isEdited = router.history.current.name === 'editer-signalement' &&
router.history.current.params.slug_signal === featureId; //* avoid opening popup on feature currently edited
if (featureId && !isEdited) {
const popupContent = this._createContentPopup(features[0], this.featureTypes);
const popupContent = await this._createContentPopup(features[0]);
this.content.innerHTML = popupContent.html;
this.overlay.setPosition(event.coordinate);
this.addRouterToPopup(popupContent.feature_type.slug, popupContent.featureId);
this.addRouterToPopup({
featureId,
featureTypeSlug: popupContent.feature_type ? popupContent.feature_type.slug : '',
index: popupContent.index,
});
}
}
//const queryableLayerSelected = document.getElementById(`queryable-layers-selector-${this.wmsParams.basemapId}`).getElementsByClassName('selected')[0].textContent;
if (this.layers) {
} else if (this.layers) { // If no feature under the mouse pointer, attempt to find a query layer
const queryLayer = this.layers.find(x => x.query);
// pour compatibilité avec le proxy django
const proxyparams = [
'request',
'service',
'srs',
'version',
'bbox',
'height',
'width',
'layers',
'query_layers',
'info_format', 'x', 'y', 'i', 'j',
];
if (queryLayer) {
// pour compatibilité avec le proxy django
const proxyparams = [
'request',
'service',
'srs',
'version',
'bbox',
'height',
'width',
'layers',
'query_layers',
'info_format', 'x', 'y', 'i', 'j',
];
const url = this.getFeatureInfoUrl(event, queryLayer);
const urlInfos = url.split('?');
const urlInfos = url ? url.split('?') : [];
const urlParams = new URLSearchParams(urlInfos[1]);
const params = {};
Array.from(urlParams.keys()).forEach(param => {
......@@ -226,7 +304,7 @@ const mapService = {
},
getFeatureInfoUrl(event, layer) {
const olLayer = dictLayersToLeaflet[layer.id];
const olLayer = dictLayersToMap[layer.id];
const source = olLayer.getSource();
const viewResolution = this.map.getView().getResolution();
let url;
......@@ -240,7 +318,7 @@ const mapService = {
fitBounds(bounds) {
let ext = boundingExtent([[bounds[0][1], bounds[0][0]], [bounds[1][1], bounds[1][0]]]);
ext = transformExtent(ext, 'EPSG:4326', 'EPSG:3857');
this.map.getView().fit(ext, { padding: [25, 25, 25, 25] });
this.map.getView().fit(ext, { padding: [25, 25, 25, 25], maxZoom: 16 });
},
......@@ -250,56 +328,150 @@ const mapService = {
},
/**
* Add multiple layers to the map. If custom layers are defined, they will be added using `addConfigLayer`.
* If no custom layers are defined, a default basemap will be added based on the schema type (WMS, WMTS, or XYZ).
*
* @param {Array} layers - Array of layer configurations to be added.
* @param {string} serviceMap - URL or service for the map base layer.
* @param {Object} optionsMap - Options for the base layer (e.g., attribution, noWrap).
* @param {string} schemaType - Type of the base layer (either 'wms', 'wmts', or fallback to XYZ).
*
* @returns {void}
*/
addLayers: function (layers, serviceMap, optionsMap, schemaType) {
// Set the current layers to the provided layers array
this.layers = layers;
if (layers) { //* if admin has defined basemaps for this project
let count = 0;
// Check if custom layers are defined (admin-defined basemaps)
if (layers) {
// Reset the layer count for managing Z-index
layersCount = 0;
// Loop through each layer and add it using the addConfigLayer method
layers.forEach((layer) => {
if (layer) {
count +=1;
const options = layer.options;
if (options) {
options.noWrap = true;
options.opacity = layer.opacity;
if (layer.schema_type === 'wms') {
if (layer.queryable) {
options.title = layer.title;
dictLayersToLeaflet[layer.id] = this.addWMSLayer(layer.service, options);
} else {
dictLayersToLeaflet[layer.id] = this.addWMSLayer(layer.service, options);
}
} else if (layer.schema_type === 'tms') {
const layerTms = new TileLayer({
source: new XYZ({
attributions: options.attribution,
url: layer.service.replace('{s}', '{a-c}')
})
});
this.map.addLayer(layerTms);
dictLayersToLeaflet[layer.id] = layerTms;
}
}
dictLayersToLeaflet[layer.id].setZIndex(count);
if (!layer) {
console.error('Layer is missing in the provided layers array.');
} else {
this.addConfigLayer(layer);
}
});
} else { //* else when no basemaps defined
}
// If no custom layers are defined, fall back to the base map
else {
// Ensure that options for the base map are provided
if (!optionsMap) {
console.error('Options for the base map are missing.');
return;
}
// Set noWrap to true to prevent map wrapping around the globe
optionsMap.noWrap = true;
// Handle the base map based on the schema type (WMS, WMTS, or fallback)
if (schemaType === 'wms') {
this.addWMSLayer(serviceMap, optionsMap);
// Add WMS layer if the schema type is 'wms'
if (!serviceMap) {
console.error('Service URL is missing for WMS base layer.');
} else {
this.addWMSLayer(serviceMap, optionsMap);
}
} else if (schemaType === 'wmts') {
// Add WMTS layer if the schema type is 'wmts'
if (!serviceMap) {
console.error('Service URL is missing for WMTS base layer.');
} else {
this.addWMTSLayerFromCapabilities(serviceMap, optionsMap);
}
} else {
const layer = new TileLayer({
source: new XYZ({
attributions: optionsMap.attribution,
url: serviceMap.replace('{s}', '{a-c}')
})
});
this.map.addLayer(layer);
// Default to XYZ tile layer if the schema type is not WMS or WMTS
if (!serviceMap) {
console.error('Service URL is missing for XYZ base layer.');
} else {
const layer = new TileLayer({
source: new XYZ({
attributions: optionsMap.attribution, // Attribution for the layer
url: serviceMap.replace('{s}', '{a-c}') // Handle subdomains in the URL
})
});
this.map.addLayer(layer); // Add the layer to the map
}
}
}
},
/**
* Add a configuration layer (WMS, WMTS, or TMS) to the map based on the layer's schema type.
* The function handles multiple types of map layers and applies the necessary configurations.
*
* @param {Object} layer - The layer configuration object.
* @param {Object} layer.options - Options for the layer (e.g., opacity, noWrap).
* @param {string} layer.schema_type - Type of the layer ('wms', 'wmts', or 'tms').
* @param {string} layer.service - URL or service for the layer.
* @param {number} layer.opacity - Opacity of the layer.
*
* @returns {void}
*/
addConfigLayer: async function (layer) {
// Check if the layer object is provided
if (!layer) {
console.error('Layer object is missing');
return;
}
// Increment the layers count (to manage Z-index)
layersCount += 1;
// Extract options from the layer
const options = layer.options;
// Check if options are provided for the layer
if (!options) {
console.error(`Options are missing for layer: ${layer.id}`);
return;
}
// Set default layer options (noWrap and opacity)
options.noWrap = true; // Prevent wrapping of the layer around the globe
options['opacity'] = layer.opacity; // Set opacity based on the layer's configuration
// Handle WMS layers
if (layer.schema_type === 'wms') {
// Add title for queryable WMS layers
if (layer.queryable) options['title'] = layer.title;
dictLayersToMap[layer.id] = this.addWMSLayer(layer.service, options); // Add WMS layer
}
// Handle WMTS layers
else if (layer.schema_type === 'wmts') {
try {
const newLayer = await this.addWMTSLayerFromCapabilities(layer.service, options); // Add WMTS layer asynchronously
dictLayersToMap[layer.id] = newLayer;
} catch (error) {
console.error(`Error adding WMTS layer: ${layer.id}`, error);
}
}
// Handle TMS layers
else if (layer.schema_type === 'tms') {
try {
const newLayer = await this.addTMSLayer(layer.service, options); // Add TMS layer asynchronously
dictLayersToMap[layer.id] = newLayer;
} catch (error) {
console.error(`Error adding TMS layer: ${layer.id}`, error);
}
} else {
console.error(`Unsupported schema type: ${layer.schema_type}`);
}
// Set Z-index for the layer if it was successfully added to the map
if (dictLayersToMap[layer.id]) {
dictLayersToMap[layer.id].setZIndex(layersCount);
} else {
console.error(`Failed to add layer to map: ${layer.id}`);
}
},
addWMSLayer: function (url, options) {
options.VERSION = '1.1.1'; // pour compatibilité avec le proxy django
options.VERSION = options.version || '1.3.0'; // pour compatibilité avec le proxy django
const source = new TileWMS({
attributions: options.attribution,
url: url,
......@@ -308,23 +480,183 @@ const mapService = {
});
const layer = new TileLayer({
source: source
source: source,
opacity: parseFloat(options.opacity),
});
this.map.addLayer(layer);
return layer;
},
getWMTSLayerCapabilities: async function (url) {
// adapted from : https://openlayers.org/en/latest/examples/wmts-layer-from-capabilities.html
// get capabilities with request to the service
try {
const response = await fetch(url);
const text = await response.text();
const capabilities = parser.read(text);
return capabilities;
} catch (error) {
console.error(error);
}
},
addWMTSLayerFromCapabilities: async function (url, options) {
// adapted from : https://git.neogeo.fr/onegeo-suite/sites/onegeo-suite-site-maps-vuejs/-/blob/draft/src/services/MapService.ts
const wmtsCapabilities = await this.getWMTSLayerCapabilities(url);
const { layer, opacity, attributions, format, ignoreUrlInCapabiltiesResponse } = options;
let sourceOptions;
try {
if (format) {
sourceOptions = optionsFromCapabilities(wmtsCapabilities, { layer, format });
} else {
sourceOptions = optionsFromCapabilities(wmtsCapabilities, { layer });
}
}
catch (e) {
console.error(e);
if (e.message == 'projection is null') {
return 'Projection non reconnue';
}
else {
return 'Problème d\'analyse du getCapabilities';
}
}
if (ignoreUrlInCapabiltiesResponse) {
var searchMask = 'request(=|%3D)getCapabilities';
var regEx = new RegExp(searchMask, 'ig');
var replaceMask = '';
sourceOptions.urls[0] = url.replace(regEx, replaceMask);
}
sourceOptions.attributions = attributions;
sourceOptions.crossOrigin= 'anonymous';
if (layer === 'ORTHOIMAGERY.ORTHOPHOTOS') {
// un peu bourrin mais il semble y avoir qq chose de spécifique avec cette couche ORTHO
// https://geoservices.ign.fr/documentation/services/utilisation-web/affichage-wmts/openlayers-et-wmts
sourceOptions.tileGrid = new WMTSTileGrid({
origin: [-20037508,20037508],
resolutions: [
156543.03392804103,
78271.5169640205,
39135.75848201024,
19567.879241005125,
9783.939620502562,
4891.969810251281,
2445.9849051256406,
1222.9924525628203,
611.4962262814101,
305.74811314070485,
152.87405657035254,
76.43702828517625,
38.218514142588134,
19.109257071294063,
9.554628535647034,
4.777314267823517,
2.3886571339117584,
1.1943285669558792,
0.5971642834779396,
0.29858214173896974,
0.14929107086948493,
0.07464553543474241
],
matrixIds: ['0','1','2','3','4','5','6','7','8','9','10','11','12','13','14','15','16','17','18','19'],
});
}
const newLayer = new TileLayer({
opacity: parseFloat(opacity) || 1,
source: new WMTS(sourceOptions),
});
this.map.addLayer(newLayer);
return newLayer;
},
/**
* Add a TMS (Tile Map Service) layer to the map.
* If the URL includes `.pbf` (vector tiles), it will apply a style from the provided options.
* The layer will be added to the map, and the style will be applied asynchronously if needed.
*
* @param {string} url - The URL of the TMS layer service.
* @param {Object} options - Configuration options for the TMS layer, including opacity and style.
* @param {string} options.style - URL to the style JSON (required for vector tiles).
* @param {number} options.opacity - Opacity of the layer (optional).
*
* @returns {VectorTileLayer} - The TMS layer added to the map.
*/
async addTMSLayer(url, options) {
// Check if the URL is missing
if (!url) {
console.error('TMS layer service URL is missing');
// Check if the options object is missing
} else if (!options) {
console.error('TMS layer options object is missing');
} else {
let layerTms;
// Check if the URL refers to PBF (vector tiles)
if (url.includes('pbf')) {
// Ensure that a style is provided for vector tiles
if (!options.style) {
console.error('TMS layer from PBF requires a style in the options');
} else {
// Handle PBF vector tiles
layerTms = new VectorTileLayer({
source: new VectorTileSource({
format: new MVT(), // Format for vector tiles (Mapbox Vector Tiles)
url: url.replace('{s}', '{a-c}'), // Handle subdomain pattern in the URL if present
attributions: options.attribution,
})
});
try {
// Fetch the style JSON from the provided URL
const response = await fetch(options.style);
const json = await response.json();
// Apply the fetched style to the layer (asynchronous)
await applyStyle(layerTms, json);
} catch (error) {
// Handle any errors during the fetch process
console.error('Error loading the style JSON:', error);
}
}
} else {
// Handle PNG raster tiles
layerTms = new TileLayer({
source: new XYZ({
url: url.replace('{s}', '{a-c}'), // Use the PNG TMS URL pattern
attributions: options.attribution,
})
});
}
// Set the opacity for the layer (default to 1.0 if not specified)
layerTms.setOpacity(parseFloat(options.opacity || 1.0));
// Add the TMS layer to the map
this.map.addLayer(layerTms);
// Return the TMS layer for further manipulation if needed
return layerTms;
}
},
// Remove the base layers (not the features)
removeLayers: function () {
Object.values(dictLayersToLeaflet).forEach(element => {
Object.values(dictLayersToMap).forEach(element => {
this.map.removeLayer(element);
});
dictLayersToLeaflet = {};
dictLayersToMap = {};
},
updateOpacity(layerId, opacity) {
const layer = dictLayersToLeaflet[layerId];
layer.setOpacity(parseFloat(opacity));
const layer = dictLayersToMap[layerId];
if (layer) {
layer.setOpacity(parseFloat(opacity));
} else {
console.error(`Layer with id: ${layerId} couldn't be found for opacity update`);
}
},
updateOrder(layers) {
......@@ -340,38 +672,68 @@ const mapService = {
retrieveFeatureStyle: function (featureType, properties) {
const { colors_style, customfield_set } = featureType;
let { color, opacity } = featureType;
if (colors_style && colors_style.custom_field_name && customfield_set) {
const fieldType = customfield_set.find((el) => el.name === colors_style.custom_field_name).field_type;
const currentValue = properties[colors_style.custom_field_name];
switch (fieldType) {
case 'list' :
if (currentValue) {
color = colors_style.colors && colors_style.colors[currentValue];
opacity = colors_style.opacities && colors_style.opacities[currentValue];
const customField = customfield_set.find((el) => el.name === colors_style.custom_field_name);
if (customField) {
const fieldType = customField.field_type;
let currentValue = properties[colors_style.custom_field_name];
if (currentValue && typeof currentValue === 'string') currentValue = currentValue.trim(); // remove leading and trailing whitespaces
switch (fieldType) {
case 'list':
if (currentValue) {
color = colors_style.colors && colors_style.colors[currentValue];
opacity = colors_style.opacities && colors_style.opacities[currentValue];
}
break;
case 'char': //* if the custom field is supposed to be a string
//* check if its current value is empty or not, to select a color | https://redmine.neogeo.fr/issues/14048
color = colors_style.value.colors && colors_style.value.colors[currentValue ? 'Non vide' : 'Vide'];
opacity = colors_style.value.opacities && colors_style.value.opacities[currentValue ? 'Non vide' : 'Vide'];
break;
case 'boolean':
color = colors_style.value.colors && colors_style.value.colors[currentValue ? 'Coché' : 'Décoché'];
opacity = colors_style.value.opacities && colors_style.value.opacities[currentValue ? 'Coché' : 'Décoché'];
break;
}
break;
case 'char': //* if the custom field is supposed to be a string
//* check if its current value is empty or not, to select a color | https://redmine.neogeo.fr/issues/14048
color = colors_style.value.colors && colors_style.value.colors[currentValue ? 'Non vide' : 'Vide'];
opacity = colors_style.value.opacities && colors_style.value.opacities[currentValue ? 'Non vide' : 'Vide'];
break;
case 'boolean':
color = colors_style.value.colors && colors_style.value.colors[currentValue ? 'Coché' : 'Décoché'];
opacity = colors_style.value.opacities && colors_style.value.opacities[currentValue ? 'Coché' : 'Décoché'];
break;
}
}
return { color, opacity };
},
addVectorTileLayer: function (url, projectId, featureTypes, formFilters) {
addVectorTileLayer: function ({ url, project_slug, featureTypes, formFilters = {}, queryParams = {} }) {
const projectId = project_slug.split('-')[0];
const format_cfg = {/*featureClass: Feature*/ };
const mvt = new MVT(format_cfg);
function customLoader(tile, src) {
tile.setLoader(function(extent, resolution, projection) {
const token = () => {
const re = new RegExp('csrftoken=([^;]+)');
const value = re.exec(document.cookie);
return (value != null) ? unescape(value[1]) : null;
};
fetch(src, {
credentials: 'include',
headers: {
'X-CSRFToken': token()
},
}).then(function(response) {
response.arrayBuffer().then(function(data) {
const format = tile.getFormat(); // ol/format/MVT configured as source format
const features = format.readFeatures(data, {
extent: extent,
featureProjection: projection
});
tile.setFeatures(features);
});
});
});
}
const options = {
urls: [],
matrixSet: 'EPSG:3857'
matrixSet: 'EPSG:3857',
tileLoadFunction: customLoader,
};
options.format = mvt;
const layerSource = new VectorTileSource(options);
......@@ -384,97 +746,98 @@ const mapService = {
source: layerSource
});
this.featureTypes = featureTypes; // store featureTypes for popups
this.projectSlug = project_slug; // store projectSlug for popups
this.queryParams = queryParams; // store queryParams for popups
this.mvtLayer.setZIndex(30);
this.map.addLayer(this.mvtLayer);
window.layerMVT = this.mvtLayer;
},
/**
* Determines the style for a given feature based on its type and applicable filters.
*
* @param {Object} feature - The feature to style.
* @param {Array} featureTypes - An array of available feature types.
* @param {Object} formFilters - Filters applied through the form.
* @returns {ol.style.Style} - The OpenLayers style for the feature.
*/
getStyle: function (feature, featureTypes, formFilters) {
const properties = feature.getProperties();
let featureType;
// GeoJSON
if(properties && properties.feature_type){
// Determine the feature type. Differentiate between GeoJSON and MVT sources.
if (properties && properties.feature_type) {
// Handle GeoJSON feature type
featureType = featureTypes
.find((ft) => ft.slug === (properties.feature_type.slug || properties.feature_type));
} else { //MVT
} else {
// Handle MVT feature type
featureType = featureTypes.find((x) => x.slug.split('-')[0] === '' + properties.feature_type_id);
}
if (featureType) {
// Retrieve the style (color, opacity) for the feature.
const { color, opacity } = this.retrieveFeatureStyle(featureType, properties);
const colorValue =
color.value && color.value.length ?
color.value : typeof color === 'string' && color.length ?
color : '#000000';
let colorValue = '#000000'; // Default color
// Determine the color value based on the feature type.
if (color && color.value && color.value.length) {
colorValue = color.value;
} else if (typeof color === 'string' && color.length) {
colorValue = color;
}
// Convert the color value to RGBA and apply the opacity.
const rgbaColor = asArray(colorValue);
rgbaColor[3] = opacity || 0.5;//opacity
const defaultStyle = new Style(
{
image: new Circle({
fill: new Fill(
{
color: rgbaColor,
},
),
stroke: new Stroke(
{
color: colorValue,
width: 2,
},
),
radius: 5,
}),
stroke: new Stroke(
{
color: colorValue,
width: 2,
},
),
fill: new Fill(
{
color: rgbaColor,
},
),
},
);
const hiddenStyle = new Style(); // hide the feature to apply filters
// Filtre sur le feature type
if(formFilters){
if (formFilters.type && formFilters.type.selected) {
if (featureType.title !== formFilters.type.selected) {
return hiddenStyle;
}
rgbaColor[3] = opacity || 0.5; // Default opacity
// Define the default style for the feature.
const defaultStyle = new Style({
image: new Circle({
fill: new Fill({ color: rgbaColor }),
stroke: new Stroke({ color: colorValue, width: 2 }),
radius: 5,
}),
stroke: new Stroke({ color: colorValue, width: 2 }),
fill: new Fill({ color: rgbaColor }),
});
// Define a hidden style to apply when filters are active.
const hiddenStyle = new Style();
// Apply filters based on feature type, status, and title.
if (formFilters) {
if (formFilters.type && formFilters.type.length > 0 && !formFilters.type.includes(featureType.slug)) {
return hiddenStyle;
}
// Filtre sur le statut
if (formFilters.status && formFilters.status.selected.value) {
if (properties.status !== formFilters.status.selected.value) {
return hiddenStyle;
}
if (formFilters.status && formFilters.status.length > 0 && !formFilters.status.includes(properties.status)) {
return hiddenStyle;
}
// Filtre sur le titre
if (formFilters.title) {
if (!properties.title.toLowerCase().includes(formFilters.title.toLowerCase())) {
return hiddenStyle;
}
if (formFilters.title && !properties.title.toLowerCase().includes(formFilters.title.toLowerCase())) {
return hiddenStyle;
}
}
// Return the default style if no filters are applied or if the feature passes the filters.
return defaultStyle;
} else {
console.error('No corresponding featureType found.');
return;
return new Style();
}
},
addFeatures: function (features, filter, featureTypes, addToMap = true) {
addFeatures: function ({ features, filter = {}, featureTypes, addToMap = true, project_slug, queryParams = {} }) {
console.log('addToMap', addToMap);
const drawSource = new VectorSource();
let retour;
// TODO verifier utilité de cette boucle et remplacer par readFeatures plutot
let index = 0;
features.forEach((feature) => {
try {
if (feature.properties) {
feature.properties['index'] = index;
index += 1;
}
retour = new GeoJSON().readFeature(feature, { dataProjection: 'EPSG:4326', featureProjection: 'EPSG:3857' }, featureTypes);
drawSource.addFeature(retour);
} catch (err) {
......@@ -491,6 +854,8 @@ const mapService = {
this.olLayer = olLayer;
this.drawSource = drawSource;
this.featureTypes = featureTypes; // store featureTypes for popups
this.projectSlug = project_slug; // store projectSlug for popups
this.queryParams = queryParams; // store queryParams for popup routes
return drawSource;
},
......@@ -502,77 +867,55 @@ const mapService = {
this.map.on(eventName, callback);
},
_createContentPopup: function (feature, featureTypes) {
const formatDate = (current_datetime) => {
let formatted_date = current_datetime.getFullYear() + '-' + ('0' + (current_datetime.getMonth() + 1)).slice(-2) + '-' + ('0' + current_datetime.getDate()).slice(-2) + ' ' +
('0' + current_datetime.getHours()).slice(-2) + ':' + ('0' + current_datetime.getMinutes()).slice(-2);
return formatted_date;
};
let feature_type;
let status;
let date_maj;
let creator;
if (feature.getProperties) {
status = feature.getProperties().status;
date_maj = feature.getProperties().updated_on;
creator = feature.getProperties().creator;
if (featureTypes) {
feature_type = feature.getProperties().feature_type ||
featureTypes.find((x) => x.slug.split('-')[0] === '' + feature.getProperties().feature_type_id);
}
} else { //? TPD: I couldn't find when this code is used, is this still in use ?
status = feature.status;
if (status) status = status.name;
date_maj = feature.updated_on;
creator = feature.creator;
if (featureTypes) {
feature_type = featureTypes.find((x) => x.slug === feature.feature_type.slug);
}
}
if(date_maj && !isNaN(new Date(date_maj))) { //* check if it is already formatted
date_maj = formatDate(new Date(date_maj));
}
if (status) {
if (status.label) { //* when the label is already in the feature
status = status.label;
} else if (featureTypes) { //* if not, retrieve the name/label from the list
status = statusChoices.find((x) => x.value === status).name;
createCustomFiedsContent(featureType, feature) {
const { customfield_set } = featureType;
// generate html for each customField configured to be displayed
let rows = '';
for (const { label, name } of customfield_set) {
const value = feature.getProperties()[name];
// check if the value is not null nor undefined (to allow false value if boolean)
if (featureType.displayed_fields.includes(name) && value !== null && value !== undefined) {
rows += `<div class="customField-row">${label} : ${value}</div>`;
}
}
// wrap all rows into customFields container
return rows.length > 0 ?
`<div id="customFields">
<div class="ui divider"></div>
<h5>Champs personnalisés</h5>
${rows}
</div>` : '';
},
let author = '';
if (creator) {
author = creator.full_name
? `<div>
Auteur : ${creator.first_name} ${creator.last_name}
</div>`
: creator.username ? `<div>Auteur: ${creator.username}</div>` : '';
}
const title = feature.getProperties ? feature.getProperties().title : feature.title;
const html = `
<h4>
<a id="goToFeatureDetail" class="pointer">${title}</a>
_createContentPopup: async function (feature) {
const properties = await retrieveFeatureProperties(feature, this.featureTypes, this.projectSlug);
const { feature_type, index, status, updated_on, created_on, creator, display_last_editor } = properties; // index is used to retrieve feature by query when browsing features
const { displayed_fields } = feature_type;
// generate html for each native fields
const statusHtml = `<div>Statut : ${status}</div>`;
const featureTypeHtml = `<div>Type de signalement : ${feature_type ? '<a id="goToFeatureTypeDetail" class="pointer">' + feature_type.title + '</a>' : 'Type de signalement inconnu'}</div>`;
const updatedOnHtml = `<div>Dernière mise à jour : ${updated_on}</div>`;
const createdOnHtml = `<div>Date de création : ${created_on}</div>`;
const creatorHtml = creator ? `<div>Auteur : ${creator}</div>` : '';
const lastEditorHtml = display_last_editor ? `<div>Dernier éditeur : ${display_last_editor}</div>` : '';
// wrapping up finale html to fill popup, filtering native fields to display and adding filtered customFields
const html = `<h4>
<a id="goToFeatureDetail" class="pointer">${feature.getProperties ? feature.getProperties().title : feature.title}</a>
</h4>
<div>
Statut : ${status}
</div>
<div>
Type : ${ feature_type ? '<a id="goToFeatureTypeDetail" class="pointer">' + feature_type.title + '</a>' : 'Type de signalement inconnu' }
</div>
<div>
Dernière&nbsp;mise&nbsp;à&nbsp;jour&nbsp;:&nbsp;${date_maj}
</div>
${author}
`;
const featureId =
feature.getId() ?
feature.getId() :
feature.getProperties ?
feature.getProperties().feature_id :
feature.id;
return { html, feature_type, featureId };
<div class="fields">
${displayed_fields.includes('status') ? statusHtml : ''}
${displayed_fields.includes('feature_type') ? featureTypeHtml : ''}
${displayed_fields.includes('updated_on') ? updatedOnHtml : ''}
${displayed_fields.includes('created_on') ? createdOnHtml : ''}
${displayed_fields.includes('display_creator') ? creatorHtml : ''}
${displayed_fields.includes('display_last_editor') ? lastEditorHtml : ''}
${this.createCustomFiedsContent(feature_type, feature)}
</div>`;
return { html, feature_type, index };
},
zoom(zoomlevel) {
this.map.getView().setZoom(zoomlevel);
},
zoomTo(location, zoomlevel, lon, lat) {
......@@ -580,18 +923,94 @@ const mapService = {
location = [+lon, +lat];
}
this.map.getView().setCenter(transform(location, 'EPSG:4326', 'EPSG:3857'));
this.map.getView().setZoom(zoomlevel);
this.zoom(zoomlevel);
},
addOverlay(loc) {
animateTo(center, zoom) {
this.map.getView().animate({ center, zoom });
},
addOverlay(loc, zoom) {
const pos = fromLonLat(loc);
const marker = new Overlay({
position: pos,
positioning: OverlayPositioning.CENTER_CENTER,
position: pos,
positioning: 'center',
element: document.getElementById('marker'),
stopEvent: false
stopEvent: false,
});
this.map.addOverlay(marker);
this.animateTo(pos, zoom);
},
initGeolocation() {
this.geolocation = new Geolocation({
// enableHighAccuracy must be set to true to have the heading value.
trackingOptions: {
enableHighAccuracy: true,
},
projection: this.map.getView().getProjection(),
});
// handle this.geolocation error.
this.geolocation.on('error', (error) => {
console.error(error.message);
});
this.positionFeature = new Feature();
this.positionFeature.setStyle( geolocationStyle );
this.geolocation.on('change:position', () => {
const currentPosition = this.geolocation.getPosition();
if (!currentPosition || !isEqual(this.lastPosition, currentPosition)) {
console.log('current position: ', currentPosition); // keeping this console.log for debug purpose in case needed
}
this.lastPosition = currentPosition;
this.changeTrackerPosition();
});
this.geolocationSource = new VectorSource({
features: [this.positionFeature],
});
new VectorLayer({
map: this.map,
source: this.geolocationSource,
});
},
changeTrackerPosition() {
if (this.lastPosition) {
this.positionFeature.setGeometry(new Point(this.lastPosition));
this.animateTo(this.lastPosition, 16);
}
},
displayGeolocationPoint(isVisible) {
let features = this.geolocationSource.getFeatures();
if (!features) return;
const hiddenStyle = new Style(); // hide the feature
for (let i = 0; i < features.length; i++) {
features[i].setStyle(isVisible ? geolocationStyle : hiddenStyle);
}
},
toggleGeolocation(isTracking) {
if (this.geolocation) {
this.geolocation.setTracking(isTracking);
if (this.geolocationSource) {
this.displayGeolocationPoint(isTracking);
if (isTracking) {
this.changeTrackerPosition();
}
}
}
},
getMapCenter() {
const location = this.map.getView().getCenter();
if (location) {
return transform(location, 'EPSG:3857', 'EPSG:4326');
}
return null;
}
};
......
......@@ -4,7 +4,7 @@ const projectAPI = {
async getProject( baseUrl, projectSlug ) {
const response = await axios.get(
`${baseUrl}projects/${projectSlug}/`
`${baseUrl}v2/projects/${projectSlug}/`
);
if (
response.status === 200 &&
......@@ -45,8 +45,8 @@ const projectAPI = {
}
},
async getProjects({ baseUrl, filters, page, projectSlug, myaccount }) {
let url = `${baseUrl}projects/`;
async getProjects({ baseUrl, filters, page, projectSlug, myaccount, text }) {
let url = `${baseUrl}v2/projects/`;
if (projectSlug) {
url += `${projectSlug}/`;
}
......@@ -54,6 +54,10 @@ const projectAPI = {
if (myaccount) {
url += '&myaccount=true';
}
// Append search text if provided.
if (text) {
url += `&search=${encodeURIComponent(text)}`;
}
try {
if (Object.values(filters).some(el => el && el.length > 0)) {
for (const filter in filters) {
......@@ -72,9 +76,9 @@ const projectAPI = {
}
},
async getProjectTypes( baseUrl ) {
async getProjectUsers( baseUrl, projectSlug) {
const response = await axios.get(
`${baseUrl}project-types/`
`${baseUrl}projects/${projectSlug}/utilisateurs/`
);
if (
response.status === 200 &&
......@@ -86,9 +90,23 @@ const projectAPI = {
}
},
async getProjectTypes( baseUrl ) {
const response = await axios.get(
`${baseUrl}v2/projects/?is_project_type=true`
);
if (
response.status === 200 &&
response.data
) {
return response.data.results;
} else {
return null;
}
},
async deleteProject(baseUrl, projectSlug) {
const response = await axios.delete(
`${baseUrl}projects/${projectSlug}/`
`${baseUrl}v2/projects/${projectSlug}/`
);
if ( response.status === 204 ) {
return 'success';
......
import axios from '@/axios-client.js';
import store from '../store';
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const userAPI = {
async signup(data, url) {
try {
const response = await axios.post(url || `${baseUrl}v2/users/`, data);
return response; // Retourne directement la réponse si succès
} catch (err) {
console.error('Erreur lors de l\'inscription :', err.response || err);
return err.response || { status: 500, data: { detail: 'Erreur inconnue' } }; // 👈 Retourne la réponse d'erreur si disponible
}
},
};
export default userAPI;
......@@ -36,11 +36,14 @@ export default new Vuex.Store({
logged: false,
messageCount: 0,
messages: [],
projectAttributes: [],
reloadIntervalId: null,
staticPages: null,
user: false,
usersGroups: [],
USER_LEVEL_PROJECTS: null,
user_permissions: null,
userToken: null
},
mutations: {
......@@ -53,28 +56,30 @@ export default new Vuex.Store({
SET_CONFIG(state, payload) {
state.configuration = payload;
},
SET_USERS(state, payload) {
state.users = payload;
},
SET_COOKIE(state, cookie) {
state.cookie = cookie;
},
SET_STATIC_PAGES(state, staticPages) {
state.staticPages = staticPages;
},
SET_USERS_GROUPS(state, usersGroups) {
state.usersGroups = usersGroups;
},
SET_USER_LEVEL_PROJECTS(state, USER_LEVEL_PROJECTS) {
state.USER_LEVEL_PROJECTS = USER_LEVEL_PROJECTS;
},
SET_LOGGED(state, value) {
state.logged = value;
},
SET_USER_TOKEN(state, payload) {
state.userToken = payload;
},
SET_USER_PERMISSIONS(state, userPermissions) {
state.user_permissions = userPermissions;
},
SET_LEVELS_PERMISSIONS(state, levelsPermissions) {
state.levelsPermissions = levelsPermissions;
},
SET_PROJECT_ATTRIBUTES(state, userPermissions) {
state.projectAttributes = userPermissions;
},
DISPLAY_MESSAGE(state, message) {
message['counter'] = state.messageCount;
state.messageCount += 1;
......@@ -108,6 +113,10 @@ export default new Vuex.Store({
RESET_CANCELLABLE_SEARCH_REQUEST(state) {
state.cancellableSearchRequest = [];
},
REMOVE_LAST_CANCELLABLE_SEARCH_REQUEST(state) {
const updatedCancellableSearchRequest = state.cancellableSearchRequest.slice(0, -1);
state.cancellableSearchRequest = updatedCancellableSearchRequest;
},
SET_RELOAD_INTERVAL_ID(state, payload) {
state.reloadIntervalId = payload;
......@@ -120,8 +129,13 @@ export default new Vuex.Store({
getters: {
permissions: state => state.user_permissions && state.projects.project ?
state.user_permissions[state.projects.project.slug] :
noPermissions
state.user_permissions[state.projects.project.slug] : noPermissions,
usersGroupsOptions: state => state.usersGroups.map((group) => ({
name: group.display_name,
value: group.codename,
isGlobal: group.is_global,
})),
usersGroupsFeatureOptions: (state, getters) => getters.usersGroupsOptions.filter((group) => !group.isGlobal)
},
actions: {
......@@ -168,52 +182,145 @@ export default new Vuex.Store({
const slug = router.history.current.params.slug;
if (slug) {
router.push({ name: 'project_detail', params: { slug } });
router.push({ name: 'project_detail', params: { slug } }).catch(() => { // prevent redundant navigation error
console.error('Not critic: caught error from vue-router -> redundant navigation to same url.');
});
} else { //* not working at page load, routerHistory filled afterwards, could try history.back()
router.push(routerHistory[routerHistory.length - 1] || '/');
router.push(routerHistory[routerHistory.length - 1] || '/').catch(() => { // prevent redundant navigation error
console.error('Not critic: caught error from vue-router -> redundant navigation to same url.');
});
}
},
USER_INFO({ state, commit }) {
if (!state.user) {
axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}user_info/`)
/**
* Action to retrieve user information.
* - If a token is present in the URL, it indicates a Single Sign-On (SSO) attempt,
* in which case it logs out the user (if logged in) and connects via SSO with the token.
* - Otherwise, it fetches user information from the Django API endpoint:
* - If no user is logged in AND if the login should be done through SSO with a redirect,
* it navigates to the login platform. Afterward, the user will be redirected with the token and the original URL to open in Geocontrib.
* - Else if the response contains a login_url (to connect with OGS), the user is redirected to this url which will redirect the user
* to geocontrib after login, which will check again if the OGS session is activated
* - Otherwise, it displays a message that the user is not logged in but can still access the app as an anonymous user.
* A 'next' parameter is added to transfer to the OGS login page for redirection.
*/
async GET_USER_INFO({ state, commit, dispatch }) {
// Extract token from URL query parameters
const searchParams = new URLSearchParams(window.location.search);
const token = searchParams.get('token');
const url_redirect = searchParams.get('url_redirect');
const currentUrl = window.location.href;
// Check if token exists and SSO login URL is configured
if (token && state.configuration.VUE_APP_LOGIN_URL) {
// If user was previously connected through SSO, ensure they are logged out before reconnecting through SSO, in case user changed
await dispatch('LOGOUT');
dispatch('CONNECT_SSO_WITH_TOKEN', { token, url_redirect });
} else if (!state.user) {
// If user infos are not set, try to fetch them
return axios
.get(`${state.configuration.VUE_APP_DJANGO_API_BASE}user_info/`)
.then((response) => {
// Update the user state with received user data
if (response && response.status === 200) {
const user = response.data.user;
commit('SET_USER', user);
// Fetch user related data
dispatch('GET_USER_LEVEL_PERMISSIONS');
dispatch('GET_USER_LEVEL_PROJECTS');
dispatch('projects/GET_PROJECTS');
return;
}
})
.catch(() => {
//* if an url to redirect to an external authentification system is present, do not redirect to the login page
if (!state.configuration.VUE_APP_LOGIN_URL) {
const url = window.location.href;
if (url.includes('projet-partage')) {
const slug = url.split('projet-partage/')[1];
router.push({ name: 'login', params: { slug } });
} else {
router.push({ name: 'login' });
// If the instance is set to accept login with redirection
if (state.configuration.VUE_APP_SSO_LOGIN_URL_WITH_REDIRECT) {
commit('DISPLAY_MESSAGE', {
comment: 'Vous allez être redirigé vers la plateforme de connexion.'
});
// Call the SSO login plateform with url to redirect after login
window.open(`${state.configuration.VUE_APP_SSO_LOGIN_URL_WITH_REDIRECT}/?url_redirect=${encodeURIComponent(currentUrl)}`, '_self');
}
// If the user is not logged in, display an info message
commit('DISPLAY_MESSAGE', {
comment: `Vous n'êtes pas connecté actuellement.
Vous pouvez accéder à l'application en tant qu'utilisateur anonyme`
});
return;
});
}
},
/**
* Action to connect user through SSO with a token.
* If the app was opened with a token in the url, it attempts a login,
* if the login is succesfull, it set the user in the state
* and retrieve information that would have been retrieved in GET_USER_INFO when logged.
* If the url contained a url to redirect, it calls the router to open this page.
*/
async CONNECT_SSO_WITH_TOKEN({ state, commit, dispatch }, { token, url_redirect }) {
axios
.get(`${state.configuration.VUE_APP_DJANGO_API_BASE}login-token/?token=${token}`)
.then((response) => {
if (response && (response.status === 200 || response.status === 201)) {
const user = response.data;
commit('SET_USER', user);
dispatch('GET_USER_LEVEL_PROJECTS');
dispatch('GET_USER_LEVEL_PERMISSIONS');
commit('DISPLAY_MESSAGE', {
comment: `Vous êtes maintenant connecté ${user.first_name} ${user.last_name}`,
level: 'positive'
});
dispatch('projects/GET_PROJECTS');
if (url_redirect) {
// Prepare the url to redirect with vue-router that prefix the url with DOMAIN+BASE_URL
const substringToRemove = state.configuration.BASE_URL;
// Find the index of the string to remove
const index = url_redirect.indexOf(substringToRemove);
// If found, keep only the remaining part after the substring to remove
if (index !== -1) {
url_redirect = url_redirect.substring(index + substringToRemove.length);
}
// catch error from the router, because of second redirection to feature when call with a feature's id
router.push(url_redirect).catch((e) => e);
}
}
})
.catch((err) => {
console.error(err);
commit('DISPLAY_MESSAGE', {
comment: 'La connexion a échoué.',
level: 'negative'
});
});
},
async GET_USER_TOKEN({ state, commit }) {
const response = await axios.get(`${state.configuration.VUE_APP_DJANGO_API_BASE}get-token`);
if (
response.status === 200 &&
response.data
) {
commit('SET_USER_TOKEN', response.data);
}
},
LOGOUT({ commit, dispatch }) {
axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}logout/`)
LOGOUT({ state, commit, dispatch }) {
return axios
.get(`${state.configuration.VUE_APP_DJANGO_API_BASE}logout/`)
.then((response) => {
if (response && response.status === 200) {
commit('SET_USER', false);
commit('SET_USER_LEVEL_PROJECTS', null);
dispatch('GET_USER_LEVEL_PERMISSIONS');
if (router.history.current.name !== 'index' && !window.location.pathname.includes('projet-partage')) {
if (router.history.current.name !== 'index' &&
!window.location.pathname.includes('projet-partage') &&
!state.configuration.VUE_APP_LOGIN_URL
) {
router.push('/');
}
}
})
.catch((error) => {
throw error;
console.error(error);
});
},
......@@ -230,6 +337,19 @@ export default new Vuex.Store({
});
},
GET_USERS_GROUPS({ commit }) {
return axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}users-groups/`)
.then((response) => {
if (response && response.status === 200) {
commit('SET_USERS_GROUPS', response.data);
}
})
.catch((error) => {
throw error;
});
},
GET_USER_LEVEL_PROJECTS({ commit }) {
return axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}user-level-projects/`)
......@@ -257,7 +377,7 @@ export default new Vuex.Store({
},
GET_LEVELS_PERMISSIONS({ commit }) {
return axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}levels-permissions/`)
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/levels-permissions/`)
.then((response) => {
if (response && response.status === 200) {
commit('SET_LEVELS_PERMISSIONS', response.data);
......@@ -267,6 +387,26 @@ export default new Vuex.Store({
throw error;
});
},
GET_PROJECT_ATTRIBUTES({ commit }) {
return axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}project-attributes/`)
.then((response) => {
if (response && response.status === 200) {
commit('SET_PROJECT_ATTRIBUTES', response.data);
}
})
.catch((error) => {
throw error;
});
},
CANCEL_CURRENT_SEARCH_REQUEST({ state, commit }) {
if (state.cancellableSearchRequest.length > 0) {
const currentRequestCancelToken =
state.cancellableSearchRequest[state.cancellableSearchRequest.length - 1];
currentRequestCancelToken.cancel('Current search request was canceled');
commit('REMOVE_LAST_CANCELLABLE_SEARCH_REQUEST');
}
},
}
});
......@@ -9,14 +9,14 @@ const getColorsStyles = (customForms) => customForms
});
const pending2draftFeatures = (features) => {
const result = [];
for (const el of features) {
if (el.properties.status === 'pending') {
if (el.properties && el.properties.status === 'pending') {
el.properties.status = 'draft';
} else if (el.status === 'pending') {
el.status = 'draft';
}
result.push(el);
}
return result;
return features;
};
const feature_type = {
......@@ -97,10 +97,10 @@ const feature_type = {
actions: {
GET_PROJECT_FEATURE_TYPES({ commit }, project_slug) {
return axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${project_slug}/feature-types/`)
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/feature-types/?project__slug=${project_slug}`)
.then((response) => {
if (response.status === 200 && response.data) {
commit('SET_FEATURE_TYPES', response.data.feature_types);
commit('SET_FEATURE_TYPES', response.data);
return response;
}
})
......@@ -124,42 +124,37 @@ const feature_type = {
async GET_SELECTED_PRERECORDED_LIST_VALUES({ commit }, { name, pattern }) {
try {
const response = await axios.get(
`${this.state.configuration.VUE_APP_DJANGO_API_BASE}prerecorded-list-values/${name}/?pattern=${pattern}`
);
let url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}prerecorded-list-values/${name}/`;
if (pattern) {
url += `?pattern=${pattern}`;
}
const response = await axios.get(url);
if (response.status === 200) {
commit('SET_SELECTED_PRERECORDED_LIST_VALUES', {
name: name,
name,
values: response.data
});
}
} catch (err) {
console.error(err);
}
return;
},
async SEND_FEATURE_TYPE({ state, getters, rootState }, requestType) {
const data = {
title: state.form.title.value,
title_optional: state.form.title_optional.value,
enable_key_doc_notif: state.form.enable_key_doc_notif.value,
disable_notification: state.form.disable_notification.value,
geom_type: state.form.geom_type.value,
color: state.form.color.value,
colors_style: state.form.colors_style.value,
project: rootState.projects.project.slug,
customfield_set: state.customForms.map(el => {
return {
position: el.position,
is_mandatory: el.isMandatory,
label: el.label,
name: el.name,
field_type: el.field_type,
options: el.options,
};
}),
//'is_editable': true,
customfield_set: state.customForms
};
let url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}feature-types/`;
let url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/feature-types/`;
if (requestType === 'put') url += `${getters.feature_type.slug}/`;
return axios({
url,
......@@ -175,14 +170,14 @@ const feature_type = {
.catch((error) => error.response);
},
async SEND_FEATURE_SYMBOLOGY({ getters, rootState }, symbology) {
async SEND_FEATURE_DISPLAY_CONFIG({ getters, rootState }, displayConfig) {
const data = {
title: getters.feature_type.title,
project: rootState.projects.project.slug,
...symbology
...displayConfig
};
return axios
.put(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}feature-types/${getters.feature_type.slug}/`, data)
.put(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/feature-types/${getters.feature_type.slug}/`, data)
.then((response) => {
if (response) {
const feature_type_slug = response.data.slug;
......@@ -206,16 +201,15 @@ const feature_type = {
if (!name && state.fileToImport) {
name = state.fileToImport.name;
}
if (rootState.projects.project.moderation) {
if (state.fileToImport && state.fileToImport.size > 0) { //* if data in a binary file, read it as text
const textFile = await state.fileToImport.text();
geojson = JSON.parse(textFile);
}
const unmoderatedFeatures = pending2draftFeatures(geojson.features);
geojson= {
const unmoderatedFeatures = pending2draftFeatures(geojson.features || geojson);
geojson = geojson.features ? {
type: 'FeatureCollection', features: unmoderatedFeatures
};
} : unmoderatedFeatures;
}
const fileToImport = new File([JSON.stringify(geojson)], name, { type });
......@@ -223,7 +217,7 @@ const feature_type = {
formData.append('feature_type_slug', feature_type_slug);
const url =
this.state.configuration.VUE_APP_DJANGO_API_BASE +
'import-tasks/';
'v2/import-tasks/';
return axios
.post(url, formData, {
headers: {
......@@ -253,7 +247,7 @@ const feature_type = {
formData.append('csv_file', state.fileToImport);
formData.append('feature_type_slug', feature_type_slug);
const url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}import-tasks/`;
const url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/import-tasks/`;
return axios
.post(url, formData, {
......@@ -275,7 +269,7 @@ const feature_type = {
},
GET_IMPORTS({ state, commit, dispatch }, { project_slug, feature_type }) {
let url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}import-tasks/`;
let url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/import-tasks/`;
if (project_slug) {
url = url.concat('', `${url.includes('?') ? '&' : '?'}project_slug=${project_slug}`);
}
......@@ -297,15 +291,19 @@ const feature_type = {
}
}
}
if (diffStatus.length > 0) {
dispatch(
'feature/GET_PROJECT_FEATURES',
{
project_slug: project_slug,
feature_type__slug: feature_type,
},
{ root: true }
);
if (diffStatus.length > 0 && project_slug && feature_type) {
try {
dispatch(
'feature/GET_PROJECT_FEATURES',
{
project_slug: project_slug,
feature_type__slug: feature_type,
},
{ root: true }
);
} catch (err) {
console.error(err);
}
}
commit('SET_IMPORT_FEATURE_TYPES_DATA', response.data);
}
......
import axios from '@/axios-client.js';
import router from '../../router';
import { objIsEmpty, findXformValue, activateFieldsNforceValues } from'@/utils';
const feature = {
namespaced: true,
......@@ -34,9 +35,10 @@ const feature = {
},
INIT_FORM(state) {
state.form = {
title: state.currentFeature.title,
description: { value: state.currentFeature.description },
status: { value: state.currentFeature.status },
title: state.currentFeature.properties.title,
description: { value: state.currentFeature.properties.description },
status: { value: state.currentFeature.properties.status },
assigned_member: { value: state.currentFeature.properties.assigned_member },
};
},
UPDATE_FORM_FIELD(state, field) {
......@@ -47,10 +49,8 @@ const feature = {
}
},
UPDATE_EXTRA_FORM(state, extra_form) {
const index = state.extra_forms.findIndex(el => el.label === extra_form.label);
if (index !== -1) {
state.extra_forms[index] = extra_form;
}
const updatedExtraForms = state.extra_forms.map((field) => field.name === extra_form.name ? extra_form : field);
state.extra_forms = activateFieldsNforceValues(updatedExtraForms);
},
SET_EXTRA_FORMS(state, extra_forms) {
state.extra_forms = extra_forms;
......@@ -110,29 +110,21 @@ const feature = {
},
},
getters: {
},
actions: {
async GET_PROJECT_FEATURES({ commit, rootState }, {
async GET_PROJECT_FEATURES({ commit, dispatch, rootState }, {
project_slug,
feature_type__slug,
ordering,
search,
limit,
geojson = false
}) {
if (rootState.cancellableSearchRequest.length > 0) {
const currentRequestCancelToken =
rootState.cancellableSearchRequest[rootState.cancellableSearchRequest.length - 1];
currentRequestCancelToken.cancel();
}
}) {
dispatch('CANCEL_CURRENT_SEARCH_REQUEST', null, { root: true });
const cancelToken = axios.CancelToken.source();
commit('SET_CANCELLABLE_SEARCH_REQUEST', cancelToken, { root: true });
commit('SET_FEATURES', []);
commit('SET_FEATURES_COUNT', 0);
let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}projects/${project_slug}/feature/`;
let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/features/?project__slug=${project_slug}`;
if (feature_type__slug) {
url = url.concat('', `${url.includes('?') ? '&' : '?'}feature_type__slug=${feature_type__slug}`);
}
......@@ -162,25 +154,19 @@ const feature = {
if (error.message) {
console.error(error);
}
return error;
throw error; // 'throw' instead of 'return', in order to pass inside the 'catch' error instead of 'then', to avoid initiating map in another component after navigation
}
},
GET_PROJECT_FEATURE({ commit, rootState }, { project_slug, feature_id }) {
if (rootState.cancellableSearchRequest.length > 0) {
const currentRequestCancelToken =
rootState.cancellableSearchRequest[rootState.cancellableSearchRequest.length - 1];
currentRequestCancelToken.cancel();
}
GET_PROJECT_FEATURE({ commit, dispatch, rootState }, { project_slug, feature_id }) {
dispatch('CANCEL_CURRENT_SEARCH_REQUEST', null, { root: true });
const cancelToken = axios.CancelToken.source();
commit('SET_CANCELLABLE_SEARCH_REQUEST', cancelToken, { root: true });
const url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}projects/${project_slug}/feature/?id=${feature_id}`;
const url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/features/${feature_id}/?project__slug=${project_slug}`;
return axios
.get(url, { cancelToken: cancelToken.token })
.then((response) => {
if (response.status === 200 && response.data.features) {
const feature = response.data.features[0];
commit('SET_CURRENT_FEATURE', feature);
if (response.status === 200 && response.data) {
commit('SET_CURRENT_FEATURE', response.data);
}
return response;
})
......@@ -190,9 +176,24 @@ const feature = {
});
},
SEND_FEATURE({ state, rootState, commit, dispatch }, routeName) {
function redirect(featureId, featureName, response) {
if (routeName === 'editer-attribut-signalement') return response; // exit function to avoid conflict with next feature call to GET_PROJECT_FEATURE when modifying more than 2 features
/**
* Handles the entire lifecycle of a feature submission, from sending data to handling additional forms
* and managing redirections based on the operation performed (create or update).
* @param {Object} context - Vuex action context, including state and dispatch functions.
* @param {Object} payload - Contains parameters like routeName, query, and extraForms for form handling.
*/
SEND_FEATURE({ state, rootState, rootGetters, commit, dispatch }, { routeName, query, extraForms }) {
/**
* Handles redirection after a feature operation, updating URL queries or navigating to new routes.
* @param {string} featureName - The name of the feature being handled.
* @param {Object} response - The server response object.
* @return {Object} - Either the server response or a string to trigger page reload.
*/
function redirect(featureName, response) {
// when modifying more than 2 features, exit this function (to avoid conflict with next feature call to GET_PROJECT_FEATURE)
if (routeName === 'editer-attribut-signalement') return response;
let newQuery = { ...query }; // create a copy of query from the route to avoid redundant navigation error since the router object would be modified
// Display a success message in the UI.
commit(
'DISPLAY_MESSAGE',
{
......@@ -203,54 +204,93 @@ const feature = {
},
{ root: true },
);
dispatch(
'GET_PROJECT_FEATURE',
{
project_slug: rootState.projects.project.slug,
feature_id: featureId
})
.then(() => {
if (routeName.includes('details-signalement')) return response;
router.push({
name: 'details-signalement',
params: {
slug_type_signal: rootState['feature-type'].current_feature_type_slug,
slug_signal: featureId,
},
});
// Construct the query for navigation based on the current state and feature details.
const slug_type_signal = rootState['feature-type'].current_feature_type_slug;
const project = rootState.projects.project;
if (routeName === 'ajouter-signalement' && !query.ordering) {
newQuery = {
ordering: project.feature_browsing_default_sort,
offset: 0,// if feature was just created, in both ordering it would be the first in project features list
};
if (project.feature_browsing_default_filter === 'feature_type_slug') {
newQuery['feature_type_slug'] = slug_type_signal;
}
}
if (query && query.ordering === '-updated_on') { // if the list is ordered by update time
newQuery.offset = 0;// it would be in first position (else, if ordered by creation, the position won't change anyway)
}
// in fast edition avoid redundant navigation if query didn't change
if (routeName === 'details-signalement-filtre' && parseInt(query.offset) === parseInt(newQuery.offset)) {
return 'reloadPage';
}
// Perform the actual route navigation if needed.
if (!objIsEmpty(newQuery)) {
router.push({
name: 'details-signalement-filtre',
params: { slug_type_signal },
query: newQuery,
});
} else {
router.push({
name: 'details-signalement',
params: { slug_type_signal },
});
}
return response;
}
/**
* Manages the uploading of attachments and linked features after the main feature submission.
* @param {number} featureId - The ID of the feature to which attachments and linked features relate.
* @param {string} featureName - The name of the feature for messaging purposes.
* @param {Object} response - The server response from the main feature submission.
* @return {Object} - Redirect response or a string to trigger page reload.
*/
async function handleOtherForms(featureId, featureName, response) {
await dispatch('SEND_ATTACHMENTS', featureId);
await dispatch('PUT_LINKED_FEATURES', featureId);
return redirect(featureId, featureName, response);
return redirect(featureName, response);
}
/**
* Prepares a GeoJSON object from the current state and extra forms provided in the payload.
* @return {Object} - A GeoJSON object representing the feature with additional properties.
*/
function createGeojson() { //* prepare feature data to send
const extraFormObject = {}; //* prepare an object to be flatten in properties of geojson
for (const field of state.extra_forms) {
if (field.value !== null) extraFormObject[field.name] = field.value;
// use extraForms from argument if defined, overiding data from the store, in order to not use mutation (in case of multiple features)
for (const field of extraForms || state.extra_forms) {
// send extra form only if there is a value defined or if no value, if there was a value before, in order to avoid sending empty value when user didn't touch the extraform
if (field.value !== null ||
(state.currentFeature.properties && state.currentFeature.properties[field.name])) {
extraFormObject[field.name] = field.value;
}
}
return {
id: state.form.feature_id || state.currentFeature.feature_id,
let geojson = {
id: state.form.feature_id || state.currentFeature.id,
type: 'Feature',
geometry: state.form.geometry || state.form.geom ||
state.currentFeature.geometry || state.currentFeature.geom,
properties: {
title: state.form.title,
description: state.form.description.value,
status: state.form.status.value,
status: state.form.status.value.value || state.form.status.value,
project: rootState.projects.project.slug,
feature_type: rootState['feature-type'].current_feature_type_slug,
assigned_member: state.form.assigned_member.value,
...extraFormObject
}
};
// if not in the case of a non geographical feature type, add geometry to geojson, else send without geometry
if (rootGetters['feature-type/feature_type'].geom_type !== 'none') {
geojson['geometry'] = state.form.geometry || state.form.geom ||
state.currentFeature.geometry || state.currentFeature.properties.geom;
}
return geojson;
}
const geojson = createGeojson();
let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}features/`;
const geojson = createGeojson(); // Construct the GeoJSON from current state.
let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/features/`;
if (routeName !== 'ajouter-signalement') {
url += `${geojson.id}/?
feature_type__slug=${rootState['feature-type'].current_feature_type_slug}
......@@ -268,49 +308,55 @@ const feature = {
if ((response.status === 200 || response.status === 201) && response.data) {
const featureId = response.data.id;
const featureName = response.data.properties.title;
// Handle additional forms if needed.
if (state.attachmentFormset.length > 0 ||
state.linkedFormset.length > 0 ||
state.attachmentsToDelete.length > 0) {
state.linkedFormset.length > 0 ||
state.attachmentsToDelete.length > 0) {
return handleOtherForms(featureId, featureName, response);
} else {
return redirect(featureId, featureName, response);
return redirect(featureName, response);
}
}
})
.catch((error) => {
if (error.message === 'Network Error' || !rootState.isOnline) {
let arraysOffline = [];
const localStorageArray = localStorage.getItem('geocontrib_offline');
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
}
const updateMsg = {
project: rootState.projects.project.slug,
type: routeName === 'ajouter-signalement' ? 'post' : 'put',
featureId: geojson.id,
geojson: geojson
};
arraysOffline.push(updateMsg);
localStorage.setItem('geocontrib_offline', JSON.stringify(arraysOffline));
router.push({
name: 'offline-signalement',
params: {
slug_type_signal: rootState['feature-type'].current_feature_type_slug
},
});
}).catch((error) => {
// If offline, store the edited feature in localeStorage to send them when back online
if (error.message === 'Network Error' || !rootState.isOnline) {
let arraysOffline = [];
const localStorageArray = localStorage.getItem('geocontrib_offline');
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
}
else {
console.error('Error while sending feature', error);
throw error;
}
throw error;
});
const updateMsg = {
project: rootState.projects.project.slug,
type: routeName === 'ajouter-signalement' ? 'post' : 'put',
featureId: geojson.id,
geojson: geojson
};
arraysOffline.push(updateMsg);
localStorage.setItem('geocontrib_offline', JSON.stringify(arraysOffline));
router.push({
name: 'offline-signalement',
params: {
slug_type_signal: rootState['feature-type'].current_feature_type_slug
},
});
} else {
console.error('Error while sending feature', error);
throw error; // Re-throw the error for further handling.
}
throw error; // Ensure any error is thrown to be handled by calling code.
});
},
async SEND_ATTACHMENTS({ state, rootState, dispatch }, featureId) {
const DJANGO_API_BASE = rootState.configuration.VUE_APP_DJANGO_API_BASE;
function addFile(attachment, attchmtId) {
/**
* Adds a file to an existing attachment by uploading it to the server.
* @param {Object} attachment - The attachment object containing the file and other details.
* @param {number} attchmtId - The ID of the attachment to which the file is being added.
* @return {Promise<Object>} - The server's response to the file upload.
*/
function addFileToRequest(attachment, attchmtId) {
const formdata = new FormData();
formdata.append('file', attachment.fileToImport, attachment.fileToImport.name);
return axios
......@@ -324,10 +370,16 @@ const feature = {
});
}
/**
* Handles creating or updating an attachment, optionally uploading a file if included.
* @param {Object} attachment - The attachment data, including title, info, and optional file.
* @return {Promise<Object>} - The server response, either from creating/updating the attachment or from file upload.
*/
function putOrPostAttachement(attachment) {
const formdata = new FormData();
formdata.append('title', attachment.title);
formdata.append('info', attachment.info);
formdata.append('is_key_document', attachment.is_key_document);
let url = `${DJANGO_API_BASE}features/${featureId}/attachments/`;
if (attachment.id) {
......@@ -340,16 +392,21 @@ const feature = {
data: formdata
}).then((response) => {
if (response && (response.status === 200 || response.status === 201) && attachment.fileToImport) {
return addFile(attachment, response.data.id);
return addFileToRequest(attachment, response.data.id);
}
return response;
})
.catch((error) => {
console.error(error);
return error;
});
}).catch((error) => {
console.error(error);
return error;
});
}
/**
* Deletes attachments by dispatching a Vuex action.
* @param {number[]} attachmentsId - The IDs of the attachments to be deleted.
* @param {number} featureId - The ID of the feature related to the attachments.
* @return {Promise<Object>} - The server response to the deletion request.
*/
function deleteAttachement(attachmentsId, featureId) {
const payload = {
attachmentsId: attachmentsId,
......@@ -362,13 +419,11 @@ const feature = {
const promisesResult = await Promise.all([
...state.attachmentFormset.map((attachment) => putOrPostAttachement(attachment)),
...state.attachmentsToDelete.map((attachmentsId) => deleteAttachement(attachmentsId, featureId))
]
);
]);
state.attachmentsToDelete = [];
return promisesResult;
},
DELETE_ATTACHMENTS({ commit }, payload) {
const url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}features/${payload.featureId}/attachments/${payload.attachmentsId}/`;
return axios
......@@ -400,7 +455,7 @@ const feature = {
DELETE_FEATURE({ rootState }, payload) {
const { feature_id, noFeatureType } = payload;
let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}features/${feature_id}/?` +
let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/features/${feature_id}/?` +
`project__slug=${rootState.projects.project.slug}`;
if (!noFeatureType) {
url +=`&feature_type__slug=${rootState['feature-type'].current_feature_type_slug}`;
......@@ -412,28 +467,36 @@ const feature = {
return false;
});
},
/**
* Initializes extra forms based on the current feature type and its custom fields.
* This function retrieves custom fields for the current feature type, assigns values to them based on the current feature's properties,
* and commits them to the store to be displayed in the form.
*
* @param {Object} context - The Vuex action context, including state, rootGetters, and commit function.
*/
INIT_EXTRA_FORMS({ state, rootGetters, commit }) {
const feature = state.currentFeature;
function findCurrentValue(label) {
const field = feature.feature_data.find((el) => el.label === label);
return field ? field.value : null;
const feature = state.currentFeature; // Current feature being edited or viewed.
const featureType = rootGetters['feature-type/feature_type']; // Retrieves the feature type from root getters.
const customFields = featureType.customfield_set; // Custom fields defined for the feature type.
if (customFields) {
commit('SET_EXTRA_FORMS',
activateFieldsNforceValues( // A hypothetical function to activate fields and enforce their values.
customFields.map((field) => {
// Determines the initial value for the field
let value = feature.properties ? feature.properties[field.name] : findXformValue(feature, field);
// If the field is a boolean and the value is null, sets it to false
if (field.field_type === 'boolean' && value === null) {
value = false;
}
// Returns a new object with the updated value and the rest of the field's properties
return { ...field, value };
})
).sort((a, b) => a.position - b.position) // Sorts fields by their user-defined position.
);
}
//* retrieve 'name', 'options', 'position' from current feature_type data to display in the form
const extraForm = rootGetters['feature-type/feature_type'].customfield_set.map((field) => {
return {
...field,
//* add value field to extra forms from feature_type and existing values if feature is defined
value:
feature && feature.feature_data
? findCurrentValue(field.label)
: null,
};
});
commit('SET_EXTRA_FORMS', extraForm);
},
},
};
export default feature;
......@@ -83,7 +83,7 @@ const map = {
actions: {
GET_AVAILABLE_LAYERS({ commit }) {
return axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}layers/`)
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/layers/`)
.then((response) => (commit('SET_LAYERS', response.data)))
.catch((error) => {
throw error;
......@@ -92,7 +92,7 @@ const map = {
GET_BASEMAPS({ commit }, project_slug) {
return axios
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}base-maps/?project__slug=${project_slug}`)
.get(`${this.state.configuration.VUE_APP_DJANGO_API_BASE}v2/base-maps/?project__slug=${project_slug}`)
.then((response) => {
if (response.status === 200 && response.data) {
commit('SET_BASEMAPS', response.data);
......@@ -104,48 +104,47 @@ const map = {
});
},
INITIATE_MAP({ commit, rootState }, el) { //todo: since this function is not anymore called in different components, it would better to move it in project_details.vue
const mapDefaultViewCenter = [46, 2]; // defaultMapView.center;
const mapDefaultViewZoom = 5; // defaultMapView.zoom;
mapService.createMap(el, {
mapDefaultViewCenter: mapDefaultViewCenter,
mapDefaultViewZoom: mapDefaultViewZoom,
maxZoom: rootState.projects.project.map_max_zoom_level,
INITIATE_MAP({ commit, rootState }, options) {
var mapDefaultViewCenter =
this.state.configuration.DEFAULT_MAP_VIEW.center;
var mapDefaultViewZoom =
this.state.configuration.DEFAULT_MAP_VIEW.zoom;
mapService.createMap(options.el, {
mapDefaultViewZoom: options.zoom || mapDefaultViewZoom || 5,
mapDefaultViewCenter: options.center || mapDefaultViewCenter || [46.0, 2.0],
maxZoom: options.maxZoom || rootState.projects.project.map_max_zoom_level,
controls: options.controls,
zoomControl: options.zoomControl,
interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true , ...options.interactions },
geolocationControl: true,
});
const map = { ...mapService.getMap() };
commit('SET_MAP', map);
mapService.addLayers(
null,
this.state.configuration.DEFAULT_BASE_MAP_SERVICE,
this.state.configuration.DEFAULT_BASE_MAP_OPTIONS,
this.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE,
);
},
async SAVE_BASEMAPS({ state, rootState, dispatch }, newBasemapIds) {
//* send new basemaps synchronously to create their ids in the order they were created in the form
let promisesResult = [];
function postOrPut(basemapsToSend) {
if (basemapsToSend.length > 0) {
if (basemapsToSend.length > 0) { //* execute the function recursively as long as there is still a basemap to send
let basemap = basemapsToSend.shift(); //* remove and return first item in array
basemap['project'] = rootState.projects.project.slug;
let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}base-maps/`;
let url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/base-maps/`;
if (!newBasemapIds.includes(basemap.id)) url += `${basemap.id}/`;
promisesResult.push(
axios({
url,
method: newBasemapIds.includes(basemap.id) ? 'POST' : 'PUT',
data: basemap,
axios({
url,
method: newBasemapIds.includes(basemap.id) ? 'POST' : 'PUT',
data: basemap,
})
.then((response) => {
postOrPut(basemapsToSend);
promisesResult.push(response);
})
.then((response) => {
postOrPut(basemapsToSend);
return response;
})
.catch((error) => {
postOrPut(basemapsToSend);
return error;
})
);
.catch((error) => {
postOrPut(basemapsToSend);
promisesResult.push(error);
});
}
}
......@@ -154,16 +153,17 @@ const map = {
return dispatch('DELETE_BASEMAP', basemapId)
.then((response) => response);
}
//* save new or modifed basemap
postOrPut([...state.basemaps]);
//* delete basemaps
const deletedResult = await Promise.all(state.basemapsToDelete.map((basemapId) => deleteBMap(basemapId)));
state.basemapsToDelete = [];
//* return promises results
return [...promisesResult, ...deletedResult];
},
DELETE_BASEMAP({ commit }, basemapId) {
const url = `${this.state.configuration.VUE_APP_DJANGO_API_BASE}base-maps/` + basemapId;
DELETE_BASEMAP({ commit, rootState }, basemapId) {
const url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}v2/base-maps/${basemapId}/`;
return axios
.delete(url)
.then((response) => {
......
......@@ -8,6 +8,35 @@ const initialFilters = {
accessible: null
};
/**
* Constructs the URL for the search request, appending search text and any active filters.
*
* @param {Object} rootState - The root state to access global configuration settings.
* @param {Object} filters - The current state of filters applied to the search.
* @param {String} text - The current search text.
* @returns {String} The fully constructed URL for the search request.
*/
function constructSearchUrl({ baseUrl, filters, text, page }) {
let url = `${baseUrl}v2/projects/?`;
// Append page number if provided.
if (page) {
url += `page=${page}`;
}
// Append search text if provided.
if (text) {
url += `&search=${encodeURIComponent(text)}`;
}
// Append each active filter to the URL.
Object.entries(filters).forEach(([key, value]) => {
if (value) {
url += `&${key}=${encodeURIComponent(value)}`;
}
});
return url;
}
const projectsStore = {
namespaced: true,
......@@ -20,7 +49,7 @@ const projectsStore = {
last_comments: [],
projects: [],
project: null,
project_slug: null,
projectUsers: [],
searchProjectsFilter: null,
},
......@@ -47,6 +76,10 @@ const projectsStore = {
state.project = project;
},
SET_PROJECT_USERS(state, users) {
state.projectUsers = users;
},
SET_PROJECTS_FILTER(state, payload) {
state.filters[payload.filter] = payload.value;
},
......@@ -74,6 +107,7 @@ const projectsStore = {
page,
projectSlug,
myaccount,
text: state.searchProjectsFilter
});
commit('SET_PROJECTS', projects);
return;
......@@ -98,6 +132,13 @@ const projectsStore = {
return project;
},
async GET_PROJECT_USERS({ rootState, commit }, projectSlug) {
const baseUrl = rootState.configuration.VUE_APP_DJANGO_API_BASE;
const users = await projectAPI.getProjectUsers(baseUrl, projectSlug);
commit('SET_PROJECT_USERS', users);
return users;
},
async GET_PROJECT_INFO({ dispatch }, slug) {
const promises = [
dispatch('GET_PROJECT_LAST_MESSAGES', slug).then(response => response),
......@@ -123,47 +164,52 @@ const projectsStore = {
});
},
async HANDLE_PROJECTS_SEARCH_REQUEST({ state, rootState, commit }, text) {
/**
* Asynchronously handles the search request for projects, incorporating search text and applied filters.
* Cancels any ongoing search request to ensure that only the latest request is processed,
* which enhances the responsiveness of search functionality.
*
* @param {Object} context - Destructured to gain access to Vuex state, rootState, and commit function.
* @param {String} text - The search text used for filtering projects.
*/
async HANDLE_PROJECTS_SEARCH_REQUEST({ state, rootState, commit }, { page, text }) {
// Cancel any ongoing search request.
if (rootState.cancellableSearchRequest.length > 0) {
const currentRequestCancelToken =
rootState.cancellableSearchRequest[rootState.cancellableSearchRequest.length - 1];
currentRequestCancelToken.cancel();
}
// Prepare the cancel token for the new request and store it.
const cancelToken = axios.CancelToken.source();
commit('SET_CANCELLABLE_SEARCH_REQUEST', cancelToken, { root: true });
const url = `${rootState.configuration.VUE_APP_DJANGO_API_BASE}projects/?search=${text}`;
let filteredUrl;
if (Object.values(state.filters).some(el => el && el.length > 0)) {
filteredUrl = url;
for (const filter in state.filters) {
if (state.filters[filter]) {
filteredUrl = filteredUrl.concat('', `&${filter}=${state.filters[filter]}`);
}
}
}
// Construct the search URL with any applied filters.
const searchUrl = constructSearchUrl({
baseUrl: rootState.configuration.VUE_APP_DJANGO_API_BASE,
filters: state.filters,
text,
page
});
const response = await axios.get(
filteredUrl ? filteredUrl : url,
{
cancelToken: cancelToken.token,
}
);
if (response.status === 200) {
const projects = response.data;
if (projects) {
commit('SET_PROJECTS', projects);
try {
// Perform the search request.
const response = await axios.get(searchUrl, { cancelToken: cancelToken.token });
// Process successful response.
if (response.status === 200 && response.data) {
commit('SET_PROJECTS', response.data);
commit('SET_PROJECTS_SEARCH_STATE', {
isSearched: true,
text: text
});
}
} catch (error) {
// Handle potential errors, such as request cancellation.
console.error('Search request canceled or failed', error);
}
},
}
}
};
export default projectsStore;
import featureAPI from '@/services/feature-api';
import { isEqual, isNil } from 'lodash';
export function formatStringDate(stringDate) {
const date = new Date(stringDate);
const formatted_date = date.getFullYear() + '/' + ('0' + (date.getMonth() + 1)).slice(-2) + '/' + ('0' + date.getDate()).slice(-2) + ' ' +
('0' + date.getHours()).slice(-2) + ':' + ('0' + date.getMinutes()).slice(-2);
return formatted_date;
if (date instanceof Date && !isNaN(date.valueOf())) {
const formatted_date = date.getFullYear() + '/' + ('0' + (date.getMonth() + 1)).slice(-2) + '/' + ('0' + date.getDate()).slice(-2) + ' ' +
('0' + date.getHours()).slice(-2) + ':' + ('0' + date.getMinutes()).slice(-2);
return formatted_date;
}
return stringDate;
}
export const statusChoices = [
......@@ -39,6 +45,7 @@ export function allowedStatus2change(user, isModerate, userStatus, isOwnFeature,
} else if (userStatus === 'Contributeur') { //* cas particuliers du contributeur
if (currentRouteName === 'ajouter-signalement' || !isOwnFeature) {
//* même cas à l'ajout d'une feature ou si feature n'a pas été créé par le contributeur
//? Incohérence / Inutile ? Un contributeur ne peut pas modifier un signalement d'un autre utilisateur
return isModerate
? statusChoices.filter(
(el) => el.value === 'draft' || el.value === 'pending'
......@@ -60,27 +67,228 @@ export function allowedStatus2change(user, isModerate, userStatus, isOwnFeature,
return [];
}
/**
* Determines the type of a property based on its value.
*
* This function inspects a given property and returns a string indicating its type,
* such as 'boolean', 'integer', 'decimal', 'date', 'text', or 'char'.
* It uses various checks to determine the appropriate type for different value formats.
*
* @param {any} prop - The property value to be evaluated.
* @returns {string} The determined type of the property ('boolean', 'integer', 'decimal', 'date', 'text', or 'char').
*/
export function transformProperties(prop) {
const type = typeof prop;
const date = new Date(prop);
const regInteger = /^-*?\d+$/;
const regFloat = /^-*?\d*?\.\d+$/;
const regText = /[\r\n]/;
if (type === 'boolean' || (type === 'string' && (prop.toLowerCase() === 'true' || prop.toLowerCase() === 'False'))) {
const regInteger = /^-?\d+$/; // Regular expression to match integer numbers
const regFloat = /^-?\d*\.\d+$/; // Regular expression to match decimal numbers
const regText = /[\r\n]/; // Regular expression to detect multiline text (newlines)
const regDate = /^\d{4}-\d{2}-\d{2}$|^\d{2}\/\d{2}\/\d{4}$/; // Regular expression to match common date formats
// Check if the property is a boolean or a string that represents a boolean
if (type === 'boolean' || (type === 'string' && (prop.toLowerCase() === 'true' || prop.toLowerCase() === 'false'))) {
return 'boolean';
} else if (regInteger.test(prop) || Number.isSafeInteger(prop)) {
} else if (regInteger.test(prop) || (type === 'number' && Number.isSafeInteger(prop))) {
// Check if the property is an integer or a string that represents an integer
return 'integer';
} else if (
type === 'string' &&
['/', ':', '-'].some((el) => prop.includes(el)) && // check for chars found in datestring
date instanceof Date &&
!isNaN(date.valueOf())
) {
} else if (type === 'string' && regDate.test(prop.trim())) {
// More specific check for date strings using regular expressions
return 'date';
} else if (regFloat.test(prop) || type === 'number' && !isNaN(parseFloat(prop))) {
} else if (regFloat.test(prop) || (type === 'number' && !Number.isSafeInteger(prop))) {
// Check if the property is a decimal number or a string that represents a decimal
return 'decimal';
} else if (regText.test(prop)) {
} else if (regText.test(prop) || (type === 'string' && prop.length > 255)) {
// Check if the property contains newline characters or is a long text
return 'text';
}
return 'char'; //* string by default, most accepted type in database
// Default case for all other types: assume it is a short text or character field
return 'char';
}
export function objIsEmpty(obj) {
for(const prop in obj) {
if(Object.hasOwn(obj, prop)) {
return false;
}
}
return true;
}
export const reservedKeywords = [
// todo : add keywords for mapstyle (strokewidth...)
'id',
'title',
'description',
'status',
'created_on',
'updated_on',
'archived_on',
'deletion_on',
'feature_type',
'feature_id',
'display_creator',
'display_last_editor',
'project',
'creator',
'lat',
'lon'
];
export const customFieldTypeChoices = [
{ name: 'Booléen', value: 'boolean' },
{ name: 'Chaîne de caractères', value: 'char' },
{ name: 'Date', value: 'date' },
{ name: 'Liste de valeurs', value: 'list' },
{ name: 'Liste de valeurs pré-enregistrées', value: 'pre_recorded_list' },
{ name: 'Liste à choix multiples', value: 'multi_choices_list' },
{ name: 'Nombre entier', value: 'integer' },
{ name: 'Nombre décimal', value: 'decimal' },
{ name: 'Texte multiligne', value: 'text' },
{ name: 'Notification à un groupe', value: 'notif_group' },
];
export const featureNativeFields = [
{ name: 'status', label: 'Statut', field_type: 'Champ GéoContrib' },
{ name: 'feature_type', label: 'Type', field_type: 'Champ GéoContrib' },
{ name: 'updated_on', label: 'Dernière mise à jour', field_type: 'Champ GéoContrib' },
{ name: 'created_on', label: 'Date de création', field_type: 'Champ GéoContrib' },
{ name: 'display_creator', label: 'Auteur', field_type: 'Champ GéoContrib' },
{ name: 'display_last_editor', label: 'Dernier éditeur', field_type: 'Champ GéoContrib' },
];
export const formatDate = (current_datetime) => {
let formatted_date = current_datetime.getFullYear() + '-' + ('0' + (current_datetime.getMonth() + 1)).slice(-2) + '-' + ('0' + current_datetime.getDate()).slice(-2) + '&nbsp;' +
('0' + current_datetime.getHours()).slice(-2) + ':' + ('0' + current_datetime.getMinutes()).slice(-2);
return formatted_date;
};
export const retrieveFeatureProperties = async (feature, featureTypes, projectSlug) => {
const properties = feature.getProperties();
let { feature_type, status, updated_on, created_on, creator, display_last_editor, index } = properties;
if (creator) {
creator = creator.full_name ? `${creator.first_name} ${creator.last_name}` : creator.username;
} else if (properties.feature_id) {
//* if *** MVT *** feature, retrieve display_creator and display_last_editor by fetching the feature details from API
const fetchedFeature = await featureAPI.getProjectFeature(projectSlug, properties.feature_id);
if (fetchedFeature) {
creator = fetchedFeature.properties.display_creator;
display_last_editor = fetchedFeature.properties.display_last_editor;
feature_type = fetchedFeature.properties.feature_type;
}
}
if (featureTypes && feature_type) {
feature_type = featureTypes.find((el) => el.slug === (feature_type.slug || feature_type));
}
if (updated_on && !isNaN(new Date(updated_on))) { //* check if date is already formatted
updated_on = formatDate(new Date(updated_on));
}
if (created_on && !isNaN(new Date(created_on))) { //* check if date is already formatted
created_on = formatDate(new Date(created_on));
}
if (status) {
if (status.label) { //* when the label is already in the feature
status = status.label;
} else if (featureTypes) { //* if not, retrieve the name/label from the list
status = statusChoices.find((el) => el.value === status).name;
}
}
return { feature_type, status, updated_on, created_on, creator, display_last_editor, index };
};
export function findXformValue(feature, customField) {
if (!feature) return null;
if (feature.properties) {
return feature.properties[customField.name] || null;
} else if (feature.feature_data) {
const field = feature.feature_data.find((el) => el.label === customField.label);
return field ? field.value : null;
}
return null;
}
export function isXtraFormActive(extraForms, config) { // return true if no config or if the condition is fullfilled
if (config) { // if conditional field configuration is not null
// get name and value in condition
const { conditionField, conditionValue } = config;
// get the customForm which activates conditional field
const conditioningXForm = extraForms.find((xForm) => xForm.name === conditionField);
// check if the conditioning extraform value match the condition value
if (conditioningXForm) {
// if the values to compare are null or undefined the field can't be activated
if (isNil(conditioningXForm.value) || isNil(conditionValue)) {
return false;
} else if (Array.isArray(conditionValue) && Array.isArray(conditioningXForm.value)) { // case of multiple list or prerecorded values list
return conditioningXForm.value.some((value) => conditionValue.includes(value));
} else if (typeof conditioningXForm.value === 'object' && conditioningXForm.value.label) { // case of simple list
return conditioningXForm.value.label === conditionValue.label;
} else {
return conditioningXForm.value === conditionValue; // more simple case of other fields
}
}
}
return true;
}
export function checkDeactivatedValues(extraForms) {
// if changes occured, update extraForms array with freshly checked active customForms
let newExtraForms = extraForms.map((xForm) => { // we use 'deactivate' instead of 'activate' because at initialization this property cannot be evaluated ...
const isDeactivated = !isXtraFormActive(extraForms, xForm.conditional_field_config); // ... if the component is not created to set this property, thus no extra form would appear at all
// toggle value to null to deactivate other fields conditioned by it
if (isDeactivated) {
xForm['value'] = null;
}
return { ...xForm, ['isDeactivated']: isDeactivated };
});
return newExtraForms;
}
export function checkFieldForcedValue(field, extraForms) {
field['disabled'] = false; //* create a property disabled and (re)set to false by default
if (field.forced_value_config) {
//* loop over each forced value config for this extraForm
for (const config of field.forced_value_config) {
//* find the extraForm field conditioning the forced value
const conditioningField = extraForms.find((xtraForm) => xtraForm.name === config.conditionField);
//* if found check that its value match the condtionValue
if (conditioningField && isEqual(conditioningField.value, config.conditionValue)) {
//* set this value with the forced value and disable the form field
field.value = config.forcedValue;
field.disabled = true;
}
}
}
return field;
}
export function activateFieldsNforceValues(extraForms) {
for (const [index, field] of extraForms.entries()) {
const checkedField = checkFieldForcedValue(field, extraForms);
//* each time a value changes, call this function recursively, until there is no more change
if (checkedField.value !== field.value) {
extraForms[index] = checkedField; //* update the value in extraForms
activateFieldsNforceValues(extraForms); //* call the function with new extraForms
}
}
//* when no more changes detected in the loop, check for deactivated extraForms
extraForms = checkDeactivatedValues(extraForms);
//* return extraForms from the lastly called function
return extraForms;
}
export function formatUserOption(user) {
let name = user.first_name || '';
if (user.last_name) {
name = name + ' ' + user.last_name;
}
return {
name: [name, user.username],
value: user.id,
};
}
\ No newline at end of file
<template>
<div
v-if="(configuration.VUE_APP_SSO_LOGIN_URL_WITH_REDIRECT && !user) || !currentFeature"
class="no-access"
>
<h3>
🔒&nbsp;Vous n'avez pas accès à ce signalement
<span v-if="!user"> en tant qu'utilisateur anonyme&nbsp;🥸</span>
</h3>
<p v-if="!user">
Veuillez vous connectez afin de pouvoir visualiser le document
</p>
</div>
<div
v-else
:class="['preview', { is_pdf }]"
>
<embed
v-if="is_pdf"
:src="src"
type="application/pdf"
>
<div v-else>
<img
:src="src"
alt="Aperçu de l'image"
>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'AttachmentPreview',
computed: {
...mapState([
'configuration',
'user'
]),
...mapState('feature', [
'currentFeature'
]),
src() {
return this.$route.query.file;
},
is_pdf() {
return this.src && this.src.includes('pdf');
},
},
watch: {
user() {
/**
* Specific for platform with login by token
* When the user is setted, fetching again the feature with the cookies setted
* since automatic authentification can take time to return the response
* setting the cookies, while the app is loading already
*/
this.getFeature();
}
},
mounted() {
this.getFeature();
},
methods: {
getFeature() {
console.log('getFeature'); // Keeping for debugging after deployment
this.$store.dispatch('feature/GET_PROJECT_FEATURE', {
project_slug: this.$route.params.slug,
feature_id: this.$route.params.slug_signal,
});
}
}
};
</script>
<style scoped lang="less">
.no-access {
> h1 {
margin: 2em 0;
}
height: 60vh;
display: flex;
justify-content: center;
flex-direction: column;
> * {
text-align: center;
}
}
.preview {
width: 100vw;
&.is_pdf {
padding: 0;
@media screen and (min-width: 726px) {
height: calc(100vh - 70px - 1em);
margin: .5em auto;
box-shadow: 1px 2px 10px grey;
}
@media screen and (max-width: 725px) {
height: calc(100vh - 110px);
margin: 0 auto;
}
}
> * {
height: 100%;
width: 100%;
}
> div {
display: flex;
justify-content: center;
img {
max-width: 100%;
}
}
}
</style>
\ No newline at end of file
......@@ -10,9 +10,8 @@
v-if="project"
:features-count="featuresCount"
:slug-signal="slugSignal"
:feature-type="featureType"
:feature-type="feature_type"
:fast-edition-mode="project.fast_edition_mode"
:display-to-list-button="displayToListButton"
:is-feature-creator="isFeatureCreator"
:can-edit-feature="canEditFeature"
:can-delete-feature="canDeleteFeature"
......@@ -27,29 +26,39 @@
<FeatureTable
v-if="project"
ref="featureTable"
:feature-type="featureType"
:feature-type="feature_type"
:fast-edition-mode="project.fast_edition_mode"
:can-edit-feature="canEditFeature"
@tofeature="pushNgo"
/>
</div>
<div class="eight wide column">
<div
id="map"
ref="map"
/>
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
v-if="feature_type && feature_type.geom_type !== 'none'"
class="eight wide column"
>
<div class="map-container">
<div
id="popup-content"
/>
id="map"
ref="map"
>
<SidebarLayers
v-if="basemaps && map"
ref="sidebar"
/>
<Geolocation />
</div>
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div>
</div>
</div>
......@@ -62,6 +71,7 @@
<div class="eight wide column">
<FeatureComments
:events="events"
:enable-key-doc-notif="feature_type.enable_key_doc_notif"
@fetchEvents="getFeatureEvents"
/>
</div>
......@@ -122,22 +132,13 @@
/>
<div class="ui icon header">
<i
:class="[project.fast_edition_mode && hasUnsavedChange ? 'sign-out' : 'random', 'icon']"
class="sign-out icon"
aria-hidden="true"
/>
Abandonner {{
project.fast_edition_mode && hasUnsavedChange ?
'les modifications' :
'la vue signalement filtré'
}}
Abandonner les modifications
</div>
<div class="content">
{{
project.fast_edition_mode && hasUnsavedChange ?
'Les modifications apportées au signalement ne seront pas sauvegardées, continuer ?':
`Vous allez quittez la vue signalement filtré,
l\'ordre des signalements pourrait changer après édition d\'un signalement.`
}}
Les modifications apportées au signalement ne seront pas sauvegardées, continuer ?
</div>
<div class="actions">
<button
......@@ -145,6 +146,10 @@
class="ui green compact button"
@click="stayOnPage"
>
<i
class="close icon"
aria-hidden="true"
/>
Annuler
</button>
<button
......@@ -153,6 +158,10 @@
@click="leavePage"
>
Continuer
<i
class="arrow right icon"
aria-hidden="true"
/>
</button>
</div>
</div>
......@@ -166,6 +175,7 @@
</template>
<script>
import { isEqual } from 'lodash';
import { mapState, mapActions, mapMutations, mapGetters } from 'vuex';
import mapService from '@/services/map-service';
......@@ -176,6 +186,9 @@ import FeatureHeader from '@/components/Feature/Detail/FeatureHeader';
import FeatureTable from '@/components/Feature/Detail/FeatureTable';
import FeatureAttachements from '@/components/Feature/Detail/FeatureAttachements';
import FeatureComments from '@/components/Feature/Detail/FeatureComments';
import SidebarLayers from '@/components/Map/SidebarLayers';
import Geolocation from '@/components/Map/Geolocation';
import { buffer } from 'ol/extent';
export default {
......@@ -185,23 +198,13 @@ export default {
FeatureHeader,
FeatureTable,
FeatureAttachements,
FeatureComments
},
beforeRouteEnter (to, from, next) {
// if the user edited the feature, coming from filtered features details browsing,
// display a button to turn back to the list view, in order to start again from the list
// because order changes after edition, depending the sort criteria
// in beforeRouteEnter, the component is not mounted and this doesn't exist yet, thus we store the value in window object
window.displayToListButton = false; // reinitialisation of the value
if (from.query.offset !== undefined) { // if a queryset for filtered features is stored in the route from
window.displayToListButton = true; // toggle the value to display the button
}
next(); // continue page loading
FeatureComments,
SidebarLayers,
Geolocation,
},
beforeRouteUpdate (to, from, next) {
if (this.hasUnsavedChange) {
if (this.hasUnsavedChange && !this.isSavingChanges) {
this.confirmLeave(next); // prompt user that there is unsaved changes or that features order might change
} else {
next(); // continue navigation
......@@ -209,7 +212,7 @@ export default {
},
beforeRouteLeave (to, from, next) {
if (this.hasUnsavedChange || (from.query.offset >= 0 && to.name === 'editer-signalement')) {
if (this.hasUnsavedChange && !this.isSavingChanges) {
this.confirmLeave(next); // prompt user that there is unsaved changes or that features order might change
} else {
next(); // continue navigation
......@@ -233,12 +236,13 @@ export default {
},
},
events: [],
featureType: {},
featuresCount: null,
isDeleting: false,
isLeaving: false,
isSavingChanges: false,
map: null,
slug: this.$route.params.slug,
slugSignal: '',
displayToListButton: false,
};
},
......@@ -263,33 +267,57 @@ export default {
...mapGetters([
'permissions',
]),
...mapState('map', [
'basemaps',
]),
/**
* Checks if there are any unsaved changes in the form compared to the current feature's properties.
* This function is useful for prompting the user before they navigate away from a page with unsaved changes.
*
* @returns {boolean} - Returns true if there are unsaved changes; otherwise, returns false.
*/
hasUnsavedChange() {
if (this.project.fast_edition_mode && this.form && this.currentFeature) {
if (this.form.title !== this.currentFeature.title) return true;
if (this.form.description.value !== this.currentFeature.description) return true;
if (this.form.status.value !== this.currentFeature.status) return true;
// Ensure we are in edition mode and all required objects are present.
if (this.project && this.project.fast_edition_mode &&
this.form && this.currentFeature && this.currentFeature.properties) {
// Check for changes in title, description, and status.
if (this.form.title !== this.currentFeature.properties.title) return true;
if (this.form.description.value !== this.currentFeature.properties.description) return true;
if (this.form.status.value !== this.currentFeature.properties.status) return true;
if (this.form.assigned_member.value !== this.currentFeature.properties.assigned_member) return true;
// Iterate over extra forms to check for any changes.
for (const xForm of this.$store.state.feature.extra_forms) {
const originalField = this.currentFeature.feature_data.find(el => el.label === xForm.label);
if (originalField && xForm.value !== originalField.value) return true;
const originalValue = this.currentFeature.properties[xForm.name];
// Check if the form value has changed, considering edge cases for undefined, null, or empty values.
if (
!isEqual(xForm.value, originalValue) && // Check if values have changed.
!(!xForm.value && !originalValue) // Ensure both aren't undefined/null/empty, treating null as equivalent to false for unactivated conditionals or unset booleans.
) {
// Log the difference for debugging purposes.
console.log(`In custom form [${xForm.name}], the current form value [${xForm.value}] differs from original value [${originalValue}]`);
return true;
}
}
}
// If none of the above conditions are met, return false indicating no unsaved changes.
return false;
},
isFeatureCreator() {
if (this.currentFeature && this.user) {
return this.currentFeature.creator === this.user.id;
if (this.currentFeature && this.currentFeature.properties && this.user) {
return this.currentFeature.properties.creator === this.user.id ||
this.currentFeature.properties.creator.username === this.user.username;
}
return false;
},
isModerator() {
return this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.$route.params.slug] === 'Modérateur';
return this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.slug] === 'Modérateur';
},
isAdministrator() {
return this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.$route.params.slug] === 'Administrateur projet';
return this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.slug] === 'Administrateur projet';
},
canEditFeature() {
......@@ -305,25 +333,33 @@ export default {
this.isModerator ||
this.isAdministrator ||
this.user.is_superuser;
}
},
},
watch: {
/**
* To navigate back or forward to the previous or next URL, the query params in url are updated
* since the route doesn't change, mounted is not called, then the page isn't updated
* To reload page infos we need to call initPage() when query changes
*/
'$route.query'(newValue, oldValue) {
if (newValue !== oldValue) { //* Navigate back or forward to the previous or next URL
this.initPage(); //* doesn't update the page at query changes, thus it is done manually here
if (newValue !== oldValue) {
this.initPage();
}
},
},
created() {
this.$store.dispatch('GET_USERS_GROUPS'); // récupére les groupes d'utilisateurs pour extra_forms
},
mounted() {
this.initPage();
// when this is available, set the value with previously stored value in windows to pass it as a prop
this.displayToListButton = window.displayToListButton;
},
beforeDestroy() {
this.$store.commit('CLEAR_MESSAGES');
this.$store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
},
methods: {
......@@ -348,44 +384,53 @@ export default {
async initPage() {
await this.getPageInfo();
this.initMap();
if(this.feature_type && this.feature_type.geom_type === 'none') {
// setting map to null to ensure map would be created when navigating next to a geographical feature
this.map = null;
} else if (this.currentFeature) {
this.initMap();
}
},
async getPageInfo() {
if (this.$route.params.slug_signal && this.$route.params.slug_type_signal) { // if coming from the route with an id
this.SET_CURRENT_FEATURE_TYPE_SLUG(this.$route.params.slug_type_signal);
this.slugSignal = this.$route.params.slug_signal;
this.featureType = this.feature_type;
} //* else it would be retrieve after fetchFilteredFeature with offset
this.DISPLAY_LOADER('Recherche du signalement');
let promises = [];
//* Chargements des features et infos projet en cas d'arrivée directe sur la page ou de refresh
//* Récupération du projet, en cas d'arrivée directe sur la page ou de refresh
if (!this.project) {
promises.push(this.GET_PROJECT(this.slug));
}
//* Récupération des types de signalement, en cas de redirection page détails signalement avec id (projet déjà récupéré) ou cas précédent
if (!this.featureType || !this.basemaps) {
promises.push(
this.GET_PROJECT(this.$route.params.slug),
this.GET_PROJECT_INFO(this.$route.params.slug),
this.GET_PROJECT_INFO(this.slug),
);
}
//* changement de requête selon s'il y a un id ou un offset(dans le cas du parcours des signalements filtrés)
if (this.$route.query.offset >= 0) {
promises.push(this.fetchFilteredFeature());
} else if (!this.currentFeature || this.currentFeature.feature_id !== this.slugSignal) {
} else if (!this.currentFeature || this.currentFeature.id !== this.slugSignal) {
promises.push(
this.GET_PROJECT_FEATURE({
project_slug: this.$route.params.slug,
project_slug: this.slug,
feature_id: this.slugSignal,
})
);
}
await axios.all(promises);
this.getFeatureEvents();
this.getFeatureAttachments();
this.getLinkedFeatures();
this.DISCARD_LOADER();
if (this.project.fast_edition_mode) {
this.$store.commit('feature/INIT_FORM');
this.$store.dispatch('feature/INIT_EXTRA_FORMS');
}
if (this.currentFeature) {
this.getFeatureEvents();
this.getFeatureAttachments();
this.getLinkedFeatures();
if (this.project.fast_edition_mode) {
this.$store.commit('feature/INIT_FORM');
this.$store.dispatch('feature/INIT_EXTRA_FORMS');
}
}
},
confirmLeave(next) {
......@@ -402,58 +447,52 @@ export default {
this.next();
},
async reloadPage() {
await this.getPageInfo();
mapService.removeFeatures();
this.addFeatureToMap();
},
pushNgo(newEntry) {
this.$router.push(newEntry) //* update the params or queries in the route/url
.then(() => {
this.reloadPage();
})
.catch(() => true); //* catch error if navigation get aborted (in beforeRouteUpdate)
//* update the params or queries in the route/url
this.$router.push(newEntry)
//* catch error if navigation get aborted (in beforeRouteUpdate)
.catch(() => true);
},
goBackToProject(message) {
this.$router.push({
name: 'project_detail',
params: {
slug: this.$route.params.slug,
slug: this.slug,
message,
},
});
},
deleteFeature() {
this.isDeleting = false;
this.DISPLAY_LOADER('Suppression du signalement en cours...');
this.$store
.dispatch('feature/DELETE_FEATURE', { feature_id: this.currentFeature.feature_id })
.dispatch('feature/DELETE_FEATURE', { feature_id: this.currentFeature.id })
.then(async (response) => {
if (response.status === 204) {
await this.GET_PROJECT_FEATURES({
project_slug: this.$route.params.slug
});
this.goBackToProject();
this.DISCARD_LOADER();
if (response.status === 200) {
this.goBackToProject({ comment: 'Le signalement a bien été supprimé', level: 'positive' });
} else {
this.$store.commit('DISPLAY_MESSAGE', { comment: 'Une erreur est survenue pendant la suppression du signalement', level: 'negative' });
}
});
},
fetchFilteredFeature() {
const queryString = new URLSearchParams(this.$route.query);
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/feature-paginated/?limit=1&${queryString}`;
fetchFilteredFeature() { // TODO : if no query for sort, use project default ones
const queryString = new URLSearchParams({ ...this.$route.query });
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-paginated/?limit=1&${queryString}&output=geojson`;
return featureAPI.getPaginatedFeatures(url)
.then((data) => {
if (data && data.results && data.results[0]) {
if (data && data.results && data.results.features && data.results.features[0]) {
this.featuresCount = data.count;
this.previous = data.previous;
this.next = data.next;
this.SET_CURRENT_FEATURE(data.results[0]);
const { feature_id, feature_type } = data.results[0];
this.slugSignal = feature_id;
this.featureType = feature_type;
this.SET_CURRENT_FEATURE_TYPE_SLUG(feature_type.slug);
return { feature_id };
const currentFeature = data.results.features[0];
this.slugSignal = currentFeature.id;
this.SET_CURRENT_FEATURE(currentFeature);
this.SET_CURRENT_FEATURE_TYPE_SLUG(currentFeature.properties.feature_type.slug);
return { feature_id: currentFeature.id };
}
return;
});
......@@ -464,74 +503,33 @@ export default {
this.$store.state.configuration.DEFAULT_MAP_VIEW.center;
var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
this.map = mapService.createMap(this.$refs.map, {
mapDefaultViewCenter,
mapDefaultViewZoom,
maxZoom: this.project.map_max_zoom_level,
interactions : {
doubleClickZoom :false,
mouseWheelZoom: false,
dragPan: false
}
});
// Update link to feature list with map zoom and center
mapService.addMapEventListener('moveend', function () {
// update link to feature list with map zoom and center
/*var $featureListLink = $("#feature-list-link")
var baseUrl = $featureListLink.attr("href").split("?")[0]
$featureListLink.attr("href", baseUrl +`?zoom=${this.map.getZoom()}&lat=${this.map.getCenter().lat}&lng=${this.map.getCenter().lng}`)*/
});
// Load the layers.
// - if one basemap exists, we load the layers of the first one
// - if not, load the default map and service options
let layersToLoad = null;
var baseMaps = this.$store.state.map.basemaps;
var layers = this.$store.state.map.availableLayers;
if (baseMaps && baseMaps.length > 0) {
const basemapIndex = 0;
layersToLoad = baseMaps[basemapIndex].layers;
layersToLoad.forEach((layerToLoad) => {
layers.forEach((layer) => {
if (layer.id === layerToLoad.id) {
layerToLoad = Object.assign(layerToLoad, layer);
}
});
if (this.map) {
mapService.removeFeatures();
} else {
this.map = mapService.createMap(this.$refs.map, {
mapDefaultViewCenter,
mapDefaultViewZoom,
maxZoom: this.project.map_max_zoom_level,
interactions : {
doubleClickZoom :false,
mouseWheelZoom: true,
dragPan: true
},
fullScreenControl: true,
geolocationControl: true,
});
layersToLoad.reverse();
}
mapService.addLayers(
layersToLoad,
this.$store.state.configuration.DEFAULT_BASE_MAP_SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS,
this.$store.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE,
);
this.addFeatureToMap();
},
addFeatureToMap() {
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/feature/` +
`?feature_id=${this.slugSignal}&output=geojson`;
axios
.get(url)
.then((response) => {
if (response.data.features.length > 0) {
const featureGroup = mapService.addFeatures(
response.data.features,
{},
this.feature_types,
true
);
mapService.fitExtent(buffer(featureGroup.getExtent(),200));
}
})
.catch((error) => {
throw error;
});
const featureGroup = mapService.addFeatures({
project_slug: this.slug,
features: [this.currentFeature],
featureTypes: this.feature_types,
addToMap: true,
});
mapService.fitExtent(buffer(featureGroup.getExtent(),200));
},
getFeatureEvents() {
......@@ -570,12 +568,20 @@ export default {
let is_valid = true;
is_valid = this.checkAddedForm();
if (is_valid) {
this.$store.dispatch('feature/SEND_FEATURE', this.$route.name)
.then(() => {
this.getFeatureEvents();
mapService.removeFeatures();
this.addFeatureToMap();
});
this.isSavingChanges = true; // change the value to avoid confirmation popup after redirection with new query
this.$store.dispatch(
'feature/SEND_FEATURE',
{
routeName: this.$route.name,
query: this.$route.query
}
).then((response) => {
if (response === 'reloadPage') {
// when query doesn't change we need to reload the page infos with initPage(),
// since it would not be called from the watcher'$route.query' when the query does change
this.initPage();
}
});
}
}
},
......@@ -583,13 +589,38 @@ export default {
</script>
<style scoped>
.map-container {
height: 100%;
max-height: 70vh;
position: relative;
overflow: hidden;
background-color: #fff;
}
#map {
width: 100%;
height: 100%;
min-height: 250px;
max-height: 70vh;
border: 1px solid grey;
}
div.geolocation-container {
/* each button have (more or less depends on borders) .5em space between */
/* zoom buttons are 60px high, geolocation and full screen button is 34px high with borders */
top: calc(1.3em + 60px + 34px);
}
.prewrap {
white-space: pre-wrap;
}
.ui.active.dimmer {
position: fixed;
}
.ui.modal > .content {
text-align: center;
}
.ui.modal > .actions {
display: flex;
justify-content: space-evenly;
}
</style>
\ No newline at end of file
<template>
<div id="feature-edit">
<h1>
<span v-if="feature_type && currentRouteName === 'ajouter-signalement'">
<span v-if="feature_type && isCreation">
Création d'un signalement <small>[{{ feature_type.title }}]</small>
</span>
<span v-else-if="feature && currentRouteName === 'editer-signalement'">
Mise à jour du signalement "{{ feature.title || feature.feature_id }}"
<span v-else-if="currentFeature && currentRouteName === 'editer-signalement'">
Mise à jour du signalement "{{ currentFeature.properties ?
currentFeature.properties.title : currentFeature.id }}"
</span>
<span v-else-if="feature_type && currentRouteName === 'editer-attribut-signalement'">
Mise à jour des attributs de {{ checkedFeatures.length }} signalements
......@@ -14,23 +15,21 @@
<form
id="form-feature-edit"
action=""
method="post"
enctype="multipart/form-data"
class="ui form"
>
<!-- Feature Fields -->
<div
v-if="currentRouteName !== 'editer-attribut-signalement'"
class="two fields"
:class="[ project && project.feature_assignement ? 'three' : 'two', 'fields']"
>
<div :class="field_title">
<div :class="['field', {'required': !titleIsOptional}]">
<label :for="form.title.id_for_label">{{ form.title.label }}</label>
<input
:id="form.title.id_for_label"
v-model="form.title.value"
type="text"
required
:required="!titleIsOptional"
:maxlength="form.title.field.max_length"
:name="form.title.html_name"
@blur="updateStore"
......@@ -68,6 +67,16 @@
:selection.sync="selected_status"
/>
</div>
<div
v-if="project && project.feature_assignement"
class="field"
>
<label for="assigned_member">Membre assigné</label>
<ProjectMemberSelect
:selected-user-id="form.assigned_member.value"
@update:user="setMemberAssigned($event)"
/>
</div>
</div>
<div
......@@ -87,7 +96,8 @@
<!-- Geom Field -->
<div
v-if="currentRouteName !== 'editer-attribut-signalement'"
v-if="currentRouteName !== 'editer-attribut-signalement'
&& feature_type && feature_type.geom_type !== 'none'"
class="field"
>
<label :for="form.geom.id_for_label">{{ form.geom.label }}</label>
......@@ -249,7 +259,16 @@
<div
id="map"
ref="map"
/>
tabindex="0"
>
<SidebarLayers v-if="basemaps && map" />
<Geolocation />
<Geocoder />
<EditingToolbar
v-if="isEditable"
:map="map"
/>
</div>
<div
id="popup"
class="ol-popup"
......@@ -263,12 +282,6 @@
id="popup-content"
/>
</div>
<SidebarLayers v-if="basemaps && map" />
<EditingToolbar
v-if="isEditable"
:map="map"
/>
</div>
</div>
......@@ -277,10 +290,12 @@
DONNÉES MÉTIER
</div>
<div
v-for="(field, index) in orderedCustomFields"
:key="field.field_type + index"
v-for="field in extra_forms"
:key="field.name"
class="extraform"
>
<FeatureExtraForm
<ExtraForm
v-if="!field.isDeactivated"
:id="field.label"
ref="extraForm"
:field="field"
......@@ -303,6 +318,7 @@
:key="attachForm.dataKey"
ref="attachementForm"
:attachment-form="attachForm"
:enable-key-doc-notif="feature_type && feature_type.enable_key_doc_notif"
/>
</div>
<button
......@@ -364,20 +380,23 @@
<script>
import { mapState, mapGetters } from 'vuex';
import { GeoJSON } from 'ol/format';
import FeatureAttachmentForm from '@/components/Feature/FeatureAttachmentForm';
import FeatureLinkedForm from '@/components/Feature/FeatureLinkedForm';
import FeatureExtraForm from '@/components/Feature/Edit/FeatureExtraForm';
import ExtraForm from '@/components/ExtraForm';
import Dropdown from '@/components/Dropdown.vue';
import SidebarLayers from '@/components/Map/SidebarLayers';
import EditingToolbar from '@/components/Map/EditingToolbar';
import featureAPI from '@/services/feature-api';
import Geocoder from '@/components/Map/Geocoder';
import Geolocation from '@/components/Map/Geolocation';
import ProjectMemberSelect from '@/components/ProjectMemberSelect';
import featureAPI from '@/services/feature-api';
import mapService from '@/services/map-service';
import editionService from '@/services/edition-service';
import { statusChoices, allowedStatus2change } from '@/utils';
import axios from '@/axios-client.js';
import { GeoJSON } from 'ol/format';
export default {
......@@ -388,8 +407,11 @@ export default {
FeatureLinkedForm,
Dropdown,
SidebarLayers,
Geocoder,
Geolocation,
EditingToolbar,
FeatureExtraForm,
ExtraForm,
ProjectMemberSelect
},
data() {
......@@ -426,6 +448,9 @@ export default {
name: 'Brouillon',
},
},
assigned_member: {
value: null,
},
description: {
errors: [],
id_for_label: 'description',
......@@ -473,25 +498,16 @@ export default {
'feature_type'
]),
field_title() {
if (this.feature_type) {
if (this.feature_type.title_optional) {
return 'field';
}
}
return 'required field';
titleIsOptional() {
return this.feature_type && this.feature_type.title_optional;
},
currentRouteName() {
return this.$route.name;
},
feature() {
return this.$store.state.feature.currentFeature;
},
orderedCustomFields() {
return [...this.extra_forms].sort((a, b) => a.position - b.position);
isCreation() {
return this.currentRouteName === 'ajouter-signalement';
},
geoRefFileLabel() {
......@@ -512,12 +528,19 @@ export default {
},
},
isFeatureCreator() {
if (this.currentFeature && this.currentFeature.properties && this.user) {
return this.currentFeature.properties.creator === this.user.id ||
this.currentFeature.properties.creator.username === this.user.username;
}
return false;
},
allowedStatusChoices() {
if (this.project && this.feature && this.user) {
if (this.project && this.USER_LEVEL_PROJECTS && this.user) {
const isModerate = this.project.moderation;
const userStatus = this.USER_LEVEL_PROJECTS[this.project.slug];
const isOwnFeature = this.feature.creator === this.user.id; //* si le contributeur est l'auteur du signalement
return allowedStatus2change(this.user, isModerate, userStatus, isOwnFeature, this.currentRouteName);
return allowedStatus2change(this.user, isModerate, userStatus, this.isFeatureCreator, this.currentRouteName);
}
return [];
},
......@@ -529,7 +552,7 @@ export default {
watch: {
'form.title.value': function(newValue) {
if (newValue.length === 128) {
if (newValue && newValue.length === 128) {
this.form.title.infos.push('Le nombre de caractères et limité à 128.');
} else {
this.form.title.infos = [];
......@@ -538,6 +561,8 @@ export default {
},
created() {
this.$store.dispatch('GET_USERS_GROUPS'); // récupére les groupes d'utilisateurs pour extra_forms
this.$store.commit('feature/CLEAR_EXTRA_FORM');
this.$store.commit(
'feature-type/SET_CURRENT_FEATURE_TYPE_SLUG',
this.$route.params.slug_type_signal
......@@ -573,13 +598,21 @@ export default {
Promise.all(promises).then(() => {
if (this.currentRouteName !== 'editer-attribut-signalement') {
this.initForm();
this.initMap();
this.onFeatureTypeLoaded(); // init map tools
// if not in the case of a non geographical feature type, init map
if (this.feature_type.geom_type !== 'none') {
this.initMap();
this.initMapTools();
this.initDeleteFeatureOnKey();
}
}
this.$store.dispatch('feature/INIT_EXTRA_FORMS');
});
},
beforeDestroy() {
this.$store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
},
destroyed() {
editionService.removeActiveFeatures();
// emptying to enable adding event listener at feature edition straight after creation
......@@ -593,18 +626,19 @@ export default {
methods: {
initForm() {
if (this.currentRouteName.includes('editer')) {
for (const key in this.feature) {
for (const key in this.currentFeature.properties) {
if (key && this.form[key]) {
if (key === 'status') {
const value = this.feature[key];
const value = this.currentFeature.properties[key];
this.form[key].value = statusChoices.find(
(key) => key.value === value
);
} else {
this.form[key].value = this.feature[key];
this.form[key].value = this.currentFeature.properties[key];
}
}
}
this.form.geom.value = this.currentFeature.geometry;
this.updateStore();
}
},
......@@ -709,11 +743,7 @@ export default {
addAttachment(attachment) {
this.$store.commit('feature/ADD_ATTACHMENT_FORM', {
dataKey: this.attachmentDataKey,
title: attachment.title,
attachment_file: attachment.attachment_file,
info: attachment.info,
fileToImport: attachment.fileToImport,
id: attachment.id,
...attachment
});
this.attachmentDataKey += 1;
},
......@@ -742,12 +772,13 @@ export default {
},
updateStore() {
this.$store.commit('feature/UPDATE_FORM', {
return this.$store.commit('feature/UPDATE_FORM', {
title: this.form.title.value,
status: this.form.status.value,
description: this.form.description,
assigned_member: this.form.assigned_member,
geometry: this.form.geom.value,
feature_id: this.feature ? this.feature.feature_id : '',
feature_id: this.currentFeature ? this.currentFeature.id : '',
});
},
......@@ -815,13 +846,16 @@ export default {
}
},
async postForm() {
let is_valid = true;
async postForm(extraForms) {
let response;
is_valid =
this.checkFormGeom() &&
this.checkAddedForm();
if (!this.feature_type.title_optional) is_valid = this.checkFormTitle() && is_valid;
let is_valid = this.checkAddedForm();
// if not in the case of a non geographical feature type, check geometry's validity
if (this.feature_type && this.feature_type.geom_type !== 'none') {
is_valid = is_valid && this.checkFormGeom();
}
if (!this.feature_type.title_optional) {
is_valid = is_valid && this.checkFormTitle();
}
if (is_valid) {
//* in a moderate project, at edition of a published feature by someone else than admin or moderator, switch published status to draft.
......@@ -836,7 +870,14 @@ export default {
this.updateStore();
}
this.sendingFeature = true;
response = await this.$store.dispatch('feature/SEND_FEATURE', this.currentRouteName);
response = await this.$store.dispatch(
'feature/SEND_FEATURE',
{
routeName: this.currentRouteName,
query: this.$route.query,
extraForms// if multiple features, pass directly extraForms object to avoid mutating the store
}
);
this.sendingFeature = false;
return response;
}
......@@ -844,44 +885,42 @@ export default {
async postMultipleFeatures() {
this.$store.commit('DISPLAY_LOADER', 'Envoi des signalements en cours...');
const extraForms = [...this.extra_forms];// store extra forms for multiple features to not be overide by current feature
let results = [];
const responses = [];
// loop over each selected feature id
for (const featureId of this.checkedFeatures) {
// get other infos from this feature to feel the form
const response = await this.$store.dispatch('feature/GET_PROJECT_FEATURE', {
project_slug: this.$route.params.slug,
feature_id: featureId,
});
if (response.status === 200) {
this.initForm(); // fill title, status, description needed to send request
for (let xtraForm of extraForms) { // fill extra forms with features values, only if the value of the extra form for multiple features is null
if (xtraForm.value === null) { // if no value to overide in feature, keep the feature value
xtraForm['value'] = this.feature.feature_data.find((feat) => feat.label === xtraForm.label).value;
await this.$store.commit('feature/UPDATE_EXTRA_FORM', xtraForm);
// fill title, status & description in store, required to send feature update request
this.initForm();
// create a new object of custom form to send directly with the request, to avoid multiple asynchronous mutation in store
const newXtraForms = [];
// parse each current custom form values to update the new custom form for this feature
for (const extraForm of this.extra_forms) {
// copy current custom form to prevent modifying the original one
let newXtForm = { ...extraForm };
// if value wasn't changed in this page, get back previous value of the feature (rather than using feature orginal form, which is missing information to send in request)
if (newXtForm.value === null) {
newXtForm.value = this.currentFeature.properties[newXtForm.name];
}
newXtraForms.push(newXtForm);
}
const result = await this.postForm();
results.push(result);
const response = await this.postForm(newXtraForms);
responses.push(response);
}
}
this.$store.commit('DISCARD_LOADER');
const errors = results.filter((res) => res === undefined || res.status !== 200);
if (errors.length > 0) {
this.$store.commit(
'DISPLAY_MESSAGE',
{
comment: 'Des signalements n\'ont pas pu être mis à jour',
level: 'negative'
},
);
} else {
this.$store.commit(
'DISPLAY_MESSAGE',
{
comment: 'Les signalements ont été mis à jour',
level: 'positive'
},
);
}
const errors = responses.filter((res) => res === undefined || res.status !== 200).length > 0;
const message = {
comment: errors ? 'Des signalements n\'ont pas pu être mis à jour' : 'Les signalements ont été mis à jour',
level: errors ? 'negative' : 'positive'
};
this.$store.commit('DISPLAY_MESSAGE', message);
this.$router.push({
name: 'liste-signalements',
params: {
......@@ -892,9 +931,9 @@ export default {
//* ************* MAP *************** *//
onFeatureTypeLoaded() {
initMapTools() {
const geomType = this.feature_type.geom_type;
editionService.addEditionControls(geomType, this.isEditable);
editionService.addEditionControls(geomType);
editionService.draw.on('drawend', (evt) => {
const feature = evt.feature;
this.updateGeomField(new GeoJSON().writeGeometry(feature.getGeometry(),{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' }));
......@@ -928,7 +967,7 @@ export default {
}
if (geomFeatureJSON) {
let retour = new GeoJSON().readFeature(geomFeatureJSON,{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' });
editionService.startEditFeature(retour);
editionService.initFeatureToEdit(retour);
} else {
this.map.setView(
......@@ -950,35 +989,34 @@ export default {
var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
// Create the map, then init the layers and features
// Create the map, then init features
this.map = mapService.createMap(this.$refs.map, {
mapDefaultViewCenter,
mapDefaultViewZoom,
maxZoom: this.project.map_max_zoom_level,
interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true }
interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true },
fullScreenControl: true,
geolocationControl: true,
});
const currentFeatureId = this.$route.params.slug_signal;
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/feature/?feature_type__slug=${this.$route.params.slug_type_signal}&output=geojson`;
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/features/?feature_type__slug=${this.$route.params.slug_type_signal}&project__slug=${this.$route.params.slug}&output=geojson`;
axios
.get(url)
.then((response) => {
const features = response.data.features;
if (features) {
if (features.length > 0) {
const allFeaturesExceptCurrent = features.filter(
(feat) => feat.id !== currentFeatureId
);
mapService.addFeatures(
allFeaturesExceptCurrent,
{},
this.feature_types,
true
);
mapService.addFeatures({
addToMap: true,
project_slug: this.project.slug,
features: allFeaturesExceptCurrent,
featureTypes: this.feature_types,
});
if (this.currentRouteName === 'editer-signalement') {
const currentFeature = features.filter(
(feat) => feat.id === currentFeatureId
)[0];
editionService.setFeatureToEdit(currentFeature);
this.updateMap(currentFeature);
editionService.setFeatureToEdit(this.currentFeature);
this.updateMap(this.currentFeature);
}
}
this.mapLoading = false;
......@@ -987,12 +1025,6 @@ export default {
this.mapLoading = false;
throw error;
});
document.addEventListener('change-layers-order', (event) => {
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
mapService.updateOrder(event.detail.layers.slice().reverse());
});
},
enableSnap() {
......@@ -1014,6 +1046,25 @@ export default {
.getFeatureLinks(this.$route.params.slug_signal)
.then((data) => this.addExistingLinkedFormset(data));
},
/**
* Deletes the selected feature when the "Delete" or "Escape" key is pressed.
* The map element has been made focusable by adding tabindex=0.
*/
initDeleteFeatureOnKey() {
// Add an event listener for key presses
document.addEventListener('keydown', function(event) {
// Check if the element with the ID "map" has focus
if ((event.key === 'Delete' || event.key === 'Escape') && document.activeElement.id === 'map') {
// If the conditions are met, call the deleteSelectedFeature function
editionService.removeFeatureFromMap();
}
});
},
setMemberAssigned(e) {
this.form.assigned_member.value = e;
this.updateStore();
}
},
};
</script>
......@@ -1024,6 +1075,13 @@ export default {
width: 100%;
border: 1px solid grey;
}
div.geolocation-container {
/* each button have .5em space between, zoom buttons are 60px high and full screen button is 34px high */
top: calc(1.3em + 60px + 34px);
}
#get-geom-from-image-file {
margin-bottom: 5px;
}
......@@ -1051,4 +1109,7 @@ export default {
.leaflet-bottom {
z-index: 800;
}
.extraform {
margin-bottom: 1em;
}
</style>
<template>
<div
v-if="structure"
v-if="feature_type"
id="feature-type-detail"
>
<div class="ui stackable grid">
<div class="five wide column">
<div class="ui attached secondary segment">
<div
id="feature-type-title"
class="ui attached secondary segment"
>
<h1 class="ui center aligned header ellipsis">
<img
v-if="structure.geom_type === 'point'"
v-if="feature_type.geom_type === 'point'"
class="ui medium image"
alt="Géométrie point"
src="@/assets/img/marker.png"
>
<img
v-if="structure.geom_type === 'linestring'"
v-if="feature_type.geom_type === 'linestring'"
class="ui medium image"
alt="Géométrie ligne"
src="@/assets/img/line.png"
>
<img
v-if="structure.geom_type === 'polygon'"
v-if="feature_type.geom_type === 'polygon'"
class="ui medium image"
alt="Géométrie polygone"
src="@/assets/img/polygon.png"
>
<img
v-if="structure.geom_type === 'multipoint'"
v-if="feature_type.geom_type === 'multipoint'"
class="ui medium image"
alt="Géométrie point"
src="@/assets/img/multimarker.png"
>
<img
v-if="structure.geom_type === 'multilinestring'"
v-if="feature_type.geom_type === 'multilinestring'"
class="ui medium image"
alt="Géométrie ligne"
src="@/assets/img/multiline.png"
>
<img
v-if="structure.geom_type === 'multipolygon'"
v-if="feature_type.geom_type === 'multipolygon'"
class="ui medium image"
alt="Géométrie polygone"
src="@/assets/img/multipolygon.png"
>
{{ structure.title }}
<span
v-if="feature_type.geom_type === 'none'"
class="ui medium image"
title="Aucune géométrie"
>
<i class="ui icon big outline file" />
</span>
{{ feature_type.title }}
</h1>
</div>
<div class="ui attached segment">
......@@ -58,12 +68,12 @@
</div>
</div>
<div class="value">
{{ isOnline ? features_count : '?' }}
{{ isOnline ? featuresCount : '?' }}
</div>
<div
class="label"
>
Signalement{{ features.length > 1 || !isOnline ? "s" : "" }}
Signalement{{ featuresCount > 1 || !isOnline ? "s" : "" }}
</div>
</div>
......@@ -72,11 +82,11 @@
</h3>
<div class="ui divided list">
<div
v-for="(field, index) in orderedCustomFields"
v-for="(field, index) in feature_type.customfield_set"
:key="field.name + index"
class="item"
>
<div class="right floated content">
<div class="right floated content custom-field">
<div class="description">
{{ field.field_type }}
</div>
......@@ -90,7 +100,11 @@
</div>
<div class="ui bottom attached secondary segment">
<div class="ui styled accordion">
<div
v-if="user && permissions.can_create_feature"
class="ui styled accordion"
data-test="features-import"
>
<div
id="toggle-show-import"
:class="['title', { active: showImport && isOnline, nohover: !isOnline }]"
......@@ -123,8 +137,9 @@
@change="onGeojsonFileChange"
>
</div>
<div
v-if="structure.geom_type === 'point'"
v-if="feature_type.geom_type === 'point' || feature_type.geom_type === 'none'"
class="field"
>
<label
......@@ -189,13 +204,17 @@
Lancer l'import
</button>
<ImportTask
v-if="importFeatureTypeData && importFeatureTypeData.length"
:data="importFeatureTypeData"
:reloading="reloadingImport"
v-if="importsForFeatureType.length > 0"
ref="importTask"
:imports="importsForFeatureType"
@reloadFeatureType="reloadFeatureType"
/>
</div>
</div>
<div class="ui styled accordion">
<div
class="ui styled accordion"
data-test="features-export"
>
<div
:class="['title', { active: !showImport && isOnline, nohover: !isOnline }]"
@click="toggleShowImport"
......@@ -217,10 +236,10 @@
style="margin-bottom: 1em;"
>
<option value="GeoJSON">
GeoJSON
{{ feature_type.geom_type === 'none' ? 'JSON' : 'GeoJSON' }}
</option>
<option
v-if="structure.geom_type === 'point'"
v-if="feature_type.geom_type === 'point' || feature_type.geom_type === 'none'"
value="CSV"
>
CSV
......@@ -260,11 +279,12 @@
</div>
<div
v-if="
importFeatureTypeData &&
importFeatureTypeData.length &&
importFeatureTypeData.some((el) => el.status === 'pending')
importsForFeatureType &&
importsForFeatureType.length &&
importsForFeatureType.some((el) => el.status === 'pending')
"
class="ui message info"
data-test="wait-import-message"
>
<p>
Des signalements sont en cours d'import. Pour suivre le statut de
......@@ -284,6 +304,7 @@
v-for="(feature, index) in lastFeatures"
:key="feature.feature_id + index"
class="ui small header"
data-test="last-features"
>
<span
v-if="feature.status === 'archived'"
......@@ -321,18 +342,10 @@
aria-hidden="true"
/>
</span>
<router-link
:to="{
name: 'details-signalement',
params: {
slug,
slug_type_signal: $route.params.feature_type_slug,
slug_signal: feature.feature_id,
},
}"
>
{{ feature.title || feature.feature_id }}
</router-link>
<FeatureFetchOffsetRoute
:feature-id="feature.feature_id"
:properties="feature"
/>
<div class="sub header">
<div>
{{
......@@ -343,7 +356,7 @@
</div>
<div>
[ Créé le {{ feature.created_on | formatDate }}
<span v-if="$store.state.user">
<span v-if="user">
par {{ feature.display_creator }}</span>
]
</div>
......@@ -361,10 +374,10 @@
Voir tous les signalements
</router-link>
<router-link
v-if="permissions.can_create_feature && !structure.geom_type.includes('multi')"
v-if="permissions.can_create_feature && feature_type.geom_type && !feature_type.geom_type.includes('multi')"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: structure.slug },
params: { slug_type_signal: feature_type.slug },
}"
class="ui icon button button-hover-green margin-25"
>
......@@ -394,16 +407,27 @@ import { csv } from 'csvtojson';
import { mapActions, mapMutations, mapGetters, mapState } from 'vuex';
import { formatStringDate, transformProperties } from '@/utils';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
import ImportTask from '@/components/ImportTask';
import featureAPI from '@/services/feature-api';
import { fileConvertSizeToMo, determineDelimiter, parseCSV, checkLonLatValues } from '@/assets/js/utils';
import { fileConvertSizeToMo } from '@/assets/js/utils'; // TODO: refactor with above utils, those files are similar
const geojsonFileToImport = {
name: 'Sélectionner un fichier GeoJSON ...',
size: 0,
};
const csvFileToImport = {
name: 'Sélectionner un fichier CSV ...',
size: 0,
};
export default {
name: 'FeatureTypeDetail',
components: {
ImportTask: ImportTask,
FeatureFetchOffsetRoute,
ImportTask,
},
filters: {
......@@ -426,22 +450,18 @@ export default {
data() {
return {
importError: '',
geojsonFileToImport: {
name: 'Sélectionner un fichier GeoJSON ...',
size: 0,
},
csvFileToImport: {
name: 'Sélectionner un fichier CSV ...',
size: 0,
},
geojsonFileToImport,
csvFileToImport,
showImport: false,
slug: this.$route.params.slug,
featureTypeSlug: this.$route.params.feature_type_slug,
featuresLoading: true,
loadingImportFile: false,
waitMessage: false,
reloadingImport: false,
exportFormat: 'GeoJSON',
exportLoading: false
exportLoading: false,
lastFeatures: [],
featuresCount: 0,
};
},
......@@ -452,94 +472,41 @@ export default {
...mapGetters('projects', [
'project'
]),
...mapGetters('feature-type', [
'feature_type'
]),
...mapState([
'reloadIntervalId',
'configuration',
'isOnline',
'user',
]),
...mapState('projects', [
'project'
]),
...mapState('feature', [
'features',
'features_count'
]),
...mapState('feature-type', [
'feature_types',
'importFeatureTypeData'
'importFeatureTypeData',
'selectedPrerecordedListValues'
]),
importsForFeatureType() { // filter import task datas only for this feature type
if (this.importFeatureTypeData) {
return this.importFeatureTypeData.filter((el) => el.feature_type_title === this.featureTypeSlug);
}
return [];
},
CATALOG_NAME() {
return this.configuration.VUE_APP_CATALOG_NAME;
},
IDGO() {
return this.$store.state.configuration.VUE_APP_IDGO;
},
structure: function () {
if (Object.keys(this.feature_types).length) {
const st = this.feature_types.find(
(el) => el.slug === this.$route.params.feature_type_slug
);
if (st) {
return st;
}
}
return {};
},
feature_type_features: function () {
if (this.features.length) {
return this.features.filter(
(el) => el.feature_type.slug === this.$route.params.feature_type_slug
);
}
return {};
},
lastFeatures: function () {
if (this.feature_type_features.length) {
return this.feature_type_features.slice(0, 5);
}
return [];
},
orderedCustomFields() {
if (Object.keys(this.structure).length) {
return [...this.structure.customfield_set].sort(
(a, b) => a.position - b.position
);
}
return {};
},
},
watch: {
structure(newValue) {
if (newValue.slug) {
this.GET_IMPORTS({
feature_type: this.$route.params.feature_type_slug
});
}
},
importFeatureTypeData: {
deep: true,
handler(newValue, oldValue) {
if (newValue && newValue.some(el => el.status === 'pending')) {
setTimeout(() => {
this.reloadingImport = true;
this.GET_IMPORTS({
feature_type: this.$route.params.feature_type_slug
}).then(()=> {
setTimeout(() => {
this.reloadingImport = false;
}, 1000);
});
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL);
} else if (oldValue && oldValue.some(el => el.status === 'pending')) {
this.getLastFeatures();
}
}
},
feature_type(newValue) {
this.toggleJsonUploadOption(newValue);
}
},
created() {
......@@ -547,181 +514,399 @@ export default {
this.$store.dispatch('projects/GET_PROJECT', this.slug);
this.$store.dispatch('projects/GET_PROJECT_INFO', this.slug);
}
this.$store.commit('feature/SET_FEATURES', []); //* empty remaining features in case they were in geojson format and will be fetch anyway
this.getLastFeatures();
this.SET_CURRENT_FEATURE_TYPE_SLUG(
this.$route.params.feature_type_slug
this.featureTypeSlug
);
this.$store.dispatch('feature-type/GET_IMPORTS', {
project_slug: this.$route.params.slug,
feature_type: this.featureTypeSlug
});
this.getLastFeatures();
if (this.$route.params.type === 'external-geojson') {
this.showImport = true;
}
// empty prerecorded lists in case the list has been previously loaded with a limit in other component like ExtraForm
this.SET_PRERECORDED_LISTS([]);
// This function is also called by watcher at this stage, but to be safe in edge case
this.toggleJsonUploadOption(this.feature_type);
},
methods: {
...mapMutations([
'DISPLAY_MESSAGE',
]),
...mapMutations('feature-type', [
'SET_CURRENT_FEATURE_TYPE_SLUG',
'SET_FILE_TO_IMPORT',
'SET_PRERECORDED_LISTS'
]),
...mapActions('feature-type', [
'GET_PROJECT_FEATURE_TYPES',
'GET_SELECTED_PRERECORDED_LIST_VALUES',
'SEND_FEATURES_FROM_CSV',
'GET_IMPORTS'
]),
...mapActions('feature', [
'GET_PROJECT_FEATURES'
]),
getLastFeatures() {
let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-paginated/?feature_type_slug=${this.featureTypeSlug}&ordering=-created_on&limit=5&offset=0`;
featureAPI.getPaginatedFeatures(url)
.then((data) => {
if (data) {
this.lastFeatures = data.results;
this.featuresCount = data.count;
}
this.featuresLoading = false;
});
},
reloadFeatureType() {
this.GET_PROJECT_FEATURE_TYPES(this.slug);
this.getLastFeatures();
},
toggleShowImport() {
this.showImport = !this.showImport;
if (this.showImport) {
this.GET_IMPORTS({
feature_type: this.$route.params.feature_type_slug
});
},
/**
* In the case of a non geographical feature type, replace geoJSON by JSON in file to upload options
*
* @param {Object} featureType - The current featureType.
*/
toggleJsonUploadOption(featureType) {
if (featureType && featureType.geom_type === 'none') {
this.geojsonFileToImport = {
name: 'Sélectionner un fichier JSON ...',
size: 0,
};
}
},
checkJsonValidity(json) {
async checkPreRecordedValue(fieldValue, listName) {
const fieldLabel = fieldValue.label || fieldValue;
// encode special characters like apostrophe or white space
const encodedPattern = encodeURIComponent(fieldLabel);
// query existing prerecorded list values (with label to limit results in response, there could be many) and escape special characters, since single quote causes error in backend
await this.GET_SELECTED_PRERECORDED_LIST_VALUES({ name: listName, pattern: encodedPattern });
// check if the value exist in available prerecorded list values
return this.selectedPrerecordedListValues[listName].some((el) => el.label === fieldLabel);
},
/**
* Validates the imported data against the pre-determined field types.
*
* This function iterates over all imported features and checks if each property's value matches
* the expected type specified in the feature type schema. It accommodates specific type conversions,
* such as allowing numerical strings for 'char' or 'text' fields and converting string representations
* of booleans and lists as necessary.
*
* @param {Array} features - The array of imported features to validate.
* @returns {boolean} Returns true if all features pass the validation; otherwise, false with an error message.
*/
async isValidTypes(features) {
this.importError = '';
const fields = this.structure.customfield_set.map((el) => {
// Extract relevant field type information from the feature type schema
const fields = this.feature_type.customfield_set.map((el) => {
return {
name: el.name,
field_type: el.field_type,
options: el.options,
};
});
for (const feature of json.features) {
let count = 1;
for (const feature of features) {
this.$store.commit('DISPLAY_LOADER', `Vérification du signalement ${count} sur ${features.length}`);
for (const { name, field_type, options } of fields) {
const properties = feature.properties || feature;
if (name in properties) {
const fieldInFeature = properties[name];
let fieldInFeature = properties[name];
// Convert boolean strings from CSV to actual booleans
if (field_type === 'boolean') {
fieldInFeature = fieldInFeature === 'True' ? true : (fieldInFeature === 'False' ? false : fieldInFeature);
}
const customType = transformProperties(fieldInFeature);
//* if custom field value is not null, then check validity of field
if (fieldInFeature !== null) {
//* if field type is list, it's not possible to guess from value type
// Validate field only if it has a non-null, non-empty, defined value
if (fieldInFeature !== null && fieldInFeature !== '' && fieldInFeature !== undefined) {
// Handle 'list' type by checking if value is among the defined options
if (field_type === 'list') {
//*then check if the value is an available option
if (!options.includes(fieldInFeature)) {
this.importError = `Le fichier est invalide: la valeur [ ${fieldInFeature} ] n'est pas une option valide
pour le champ personnalisé "${name}".`;
this.importError = `Fichier invalide: La valeur "${fieldInFeature}" n'est pas une option valide dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
// Handle 'pre_recorded_list' by checking if the value matches pre-recorded options
} else if (field_type === 'pre_recorded_list') {
if (typeof fieldInFeature === 'string' && fieldInFeature.charAt(0) === '{') { // data from CSV come as string, if it doesn't start with bracket then it should not be converted to an object and stay as a string, since the structure has been simplified: https://redmine.neogeo.fr/issues/18740
try {
const jsonStr = fieldInFeature.replace(/['‘’"]\s*label\s*['‘’"]\s*:/g, '"label":')
.replace(/:\s*['‘’"](.+?)['‘’"]\s*(?=[,}])/g, ':"$1"');
fieldInFeature = JSON.parse(jsonStr);
} catch (e) {
console.error(e);
this.DISPLAY_MESSAGE({ comment: `La valeur "${fieldInFeature}" n'a pas pu être vérifiée dans le champ "${name}" du signalement "${properties.title}"` });
}
}
let fieldLabel = fieldInFeature.label || fieldInFeature;
const isPreRecordedValue = await this.checkPreRecordedValue(fieldLabel, options[0]);
if (!isPreRecordedValue) {
this.importError = `Fichier invalide: La valeur "${fieldLabel}" ne fait pas partie des valeurs pré-enregistrées dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
// Handle 'multi_choices_list' by checking if each value in the array is among the defined options
} else if (field_type === 'multi_choices_list') {
//*then check if the values in the value array are available options
//const unvalidValues = fieldInFeature.some((el) => !options.includes(el));
const unvalidValues = fieldInFeature.filter((el) => !options.includes(el));
console.log(customType, field_type, options, fieldInFeature, unvalidValues);
if (unvalidValues.length > 0) {
const plural = unvalidValues.length > 1;
this.importError = `Le fichier est invalide: ${plural ? 'les valeurs' : 'la valeur'} [ ${unvalidValues.join(', ')} ] ${plural ? 'ne sont pas des options valides' : 'n\'est pas une option valide'}
pour le champ personnalisé "${name}".`;
if (typeof fieldInFeature === 'string' && fieldInFeature.charAt(0) === '[') { // data from CSV come as string, if it doesn't start with bracket then there's no need to convert it to an array
try {
fieldInFeature = JSON.parse(fieldInFeature.replaceAll('\'', '"'));
} catch (e) {
console.error(e);
this.DISPLAY_MESSAGE({ comment: `La valeur "${fieldInFeature}" n'a pas pu être vérifiée dans le champ "${name}" du signalement "${properties.title}"` });
}
}
// Check that the value is an array before asserting its validity
if (Array.isArray(fieldInFeature)) {
const invalidValues = fieldInFeature.filter((el) => !options.includes(el));
if (invalidValues.length > 0) {
const plural = invalidValues.length > 1;
this.importError = `Fichier invalide: ${plural ? 'Les valeurs' : 'La valeur'} "${invalidValues.join(', ')}" ${plural ? 'ne sont pas des options valides' : 'n\'est pas une option valide'}
dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
} else {
this.importError = `Fichier invalide: La valeur "${fieldInFeature}" doit être un tableau dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
} else if (customType !== field_type) {
//* check if custom field value match
this.importError = `Le fichier est invalide: Un champ de type ${field_type} ne peut pas avoir la valeur [ ${fieldInFeature} ]`;
// Validate custom field value type
} else if (customType !== field_type &&
// at feature type at creation, in case the value was 0, since it can be either float or integer, by default we've set its type as a float
// when importing features, to avoid an error with different types, we bypass this check when the incoming feature value is a integer while the feature type says it should be a float
!(
// Allow integers where decimals are expected
(customType === 'integer' && field_type === 'decimal') ||
// Allow numbers formatted as strings when 'char' or 'text' type is expected
((customType === 'integer' || customType === 'float') && field_type === 'char' || field_type === 'text') ||
// Allow 'char' values where 'text' (multiline string) is expected
(customType === 'char' && field_type === 'text')
)
) {
this.importError = `Fichier invalide : Le type de champ "${field_type}" ne peut pas avoir la valeur "${fieldInFeature}" dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
}
}
}
count +=1;
}
this.$store.commit('DISCARD_LOADER');
return true;
},
/**
* Checks the validity of a CSV string. It ensures the CSV uses a recognized delimiter,
* contains 'lat' and 'lon' headers, and that these columns contain decimal values within valid ranges.
* Additionally, it verifies the consistency and presence of data in the CSV, and that the types of values are valid.
*
* @param {string} csvString - The CSV content in string format.
* @returns {boolean|Promise<boolean>} Returns a boolean or a Promise resolving to a boolean,
* indicating the validity of the CSV.
*/
async checkCsvValidity(csvString) {
this.importError = '';
// Find csvString delimiter
const commaDelimited = csvString.split('\n')[0].includes(',');
const semicolonDelimited = csvString.split('\n')[0].includes(';');
const delimiter = commaDelimited && !semicolonDelimited ? ',' : semicolonDelimited ? ';' : false;
if ((commaDelimited && semicolonDelimited) || !delimiter) {
// Determine the delimiter of the CSV
const delimiter = determineDelimiter(csvString);
if (!delimiter) {
this.importError = `Le fichier ${this.csvFileToImport.name} n'est pas formaté correctement`;
return false;
}
// Check if file contains 'lat' and 'long' fields
const headersLine =
csvString
.split('\n')[0]
.replace(/(\r\n|\n|\r)/gm, '')
.split(delimiter)
.filter(el => {
return el === 'lat' || el === 'lon';
});
if (headersLine.length !== 2) {
this.importError = 'Le fichier ne semble pas contenir de champs de coordonnées.';
// Parse the CSV string into rows
const rows = parseCSV(csvString, delimiter);
// Extract headers
const headers = rows.shift();
if (this.feature_type.geom_type !== 'none') {
// Check for required fields 'lat' and 'lon' in headers
if (!headers.includes('lat') || !headers.includes('lon')) {
this.importError = 'Les champs obligatoires "lat" et "lon" sont absents des headers.';
return false;
}
// Verify the presence and validity of coordinate values
const hasCoordValues = checkLonLatValues(headers, rows);
if (!hasCoordValues) {
this.importError = 'Les valeurs de "lon" et "lat" ne sont pas valides ou absentes.';
return false;
}
}
// Ensure there are data rows after the headers
if (rows.length === 0) {
this.importError = 'Aucune donnée trouvée après les en-têtes.';
return false;
}
const sampleLine =
csvString
.split('\n')[1]
.split(delimiter)
.map(el => {
return !isNaN(el) && el.indexOf('.') != -1;
})
.filter(Boolean);
if (sampleLine.length > 1 && headersLine.length === 2) {
const features = await csv().fromString(csvString);
return this.checkJsonValidity({ features });
} else {
// Ensure that each row has the same number of columns as the headers
if (rows.some(row => row.length !== headers.length)) {
this.importError = 'Incohérence dans le nombre de colonnes par ligne.';
return false;
}
// Convert the CSV string to a JSON object for further processing
const jsonFromCsv = await csv({ delimiter }).fromString(csvString);
// Validate the types of values in the JSON object
const validity = await this.isValidTypes(jsonFromCsv);
return validity;
},
onGeojsonFileChange(e) {
/**
* Handles the change event for GeoJSON file input. This function is triggered when a user selects a file.
* It reads the file, checks its validity if it's not too large, and updates the component state accordingly.
*
* @param {Event} e - The event triggered by file input change.
*/
async onGeojsonFileChange(e) {
// Start loading process
this.loadingImportFile = true;
// Clear any previously selected CSV file to avoid confusion
this.csvFileToImport = csvFileToImport;
// Retrieve the files from the event
const files = e.target.files || e.dataTransfer.files;
// If no file is selected, stop the loading process and return
if (!files.length) {
this.loadingImportFile = false;
return;
}
const reader = new FileReader();
reader.addEventListener('load', (e) => {
// bypass json check for files larger then 10 Mo
/**
* Asynchronously processes the content of the file.
* Checks the validity of the GeoJSON file if it's smaller than a certain size.
* Updates the state with the GeoJSON file if it's valid.
*
* @param {string} fileContent - The content of the file read by FileReader.
*/
const processFile = async (fileContent) => {
let jsonValidity;
// Check the file size and determine the GeoJSON validity
if (parseFloat(fileConvertSizeToMo(files[0].size)) <= 10) {
jsonValidity = this.checkJsonValidity(JSON.parse(e.target.result));
// If the file is smaller than 10 Mo, check its validity
try {
const json = JSON.parse(fileContent);
jsonValidity = await this.isValidTypes(json.features || json);
} catch (error) {
this.DISPLAY_MESSAGE({ comment: error, level: 'negative' });
jsonValidity = false;
}
} else {
// Assume validity for larger files
jsonValidity = true;
}
// If the GeoJSON is valid, update the component state with the file and set the file in store
if (jsonValidity) {
this.geojsonFileToImport = files[0]; // todo : remove this value from state as it stored (first attempt didn't work)
this.SET_FILE_TO_IMPORT(
this.geojsonFileToImport
);
this.geojsonFileToImport = files[0];
this.SET_FILE_TO_IMPORT(this.geojsonFileToImport);
} else {
// Clear any previously selected geojson file to disable import button
this.geojsonFileToImport = geojsonFileToImport;
this.toggleJsonUploadOption(this.feature_type);
}
// Stop the loading process
this.loadingImportFile = false;
});
};
// Setup the load event listener for FileReader
reader.addEventListener('load', (e) => processFile(e.target.result));
// Read the text from the selected file
reader.readAsText(files[0]);
},
onCsvFileChange(e) {
/**
* Handles the change event for CSV file input. This function is triggered when a user selects a file.
* It reads the file, checks its validity if it's not too large, and updates the component state accordingly.
*
* @param {Event} e - The event triggered by file input change.
*/
async onCsvFileChange(e) {
// Start loading process
this.loadingImportFile = true;
// Clear any previously selected geojson file to avoid confusion
this.geojsonFileToImport = geojsonFileToImport;
this.toggleJsonUploadOption(this.feature_type);
// Retrieve the files from the event
const files = e.target.files || e.dataTransfer.files;
// If no file is selected, stop the loading process and return
if (!files.length) {
this.loadingImportFile = false;
return;
}
// Create a new FileReader to read the selected file
const reader = new FileReader();
reader.addEventListener('load', (e) => {
// bypass csv check for files larger then 10 Mo
/**
* Asynchronously processes the content of the file.
* Checks the validity of the CSV file if it's smaller than a certain size.
* Updates the state with the CSV file if it's valid.
*
* @param {string} fileContent - The content of the file read by FileReader.
*/
const processFile = async (fileContent) => {
let csvValidity;
// Check the file size and determine the CSV validity
if (parseFloat(fileConvertSizeToMo(files[0].size)) <= 10) {
csvValidity = this.checkCsvValidity(e.target.result);
// If the file is smaller than 10 Mo, check its validity
csvValidity = await this.checkCsvValidity(fileContent);
} else {
// Assume validity for larger files
csvValidity = true;
}
// If the CSV is valid, update the component state with the file
if (csvValidity) {
this.csvFileToImport = files[0]; // todo : remove this value from state as it stored (first attempt didn't work)
this.SET_FILE_TO_IMPORT(
this.csvFileToImport
);
this.csvFileToImport = files[0]; // TODO: Remove this value from state as it is stored (first attempt didn't work)
this.SET_FILE_TO_IMPORT(this.csvFileToImport);
} else {
// Clear any previously selected geojson file to disable import button
this.csvFileToImport = csvFileToImport;
}
// Stop the loading process
this.loadingImportFile = false;
});
};
// Setup the load event listener for FileReader
reader.addEventListener('load', (e) => processFile(e.target.result));
// Read the text from the selected file
reader.readAsText(files[0]);
},
......@@ -729,7 +914,7 @@ export default {
this.waitMessage = true;
const payload = {
slug: this.slug,
feature_type_slug: this.$route.params.feature_type_slug,
feature_type_slug: this.featureTypeSlug,
};
if (this.$route.params.geojson) { //* import after redirection, for instance with data from catalog
payload['geojson'] = this.$route.params.geojson;
......@@ -742,6 +927,7 @@ export default {
this.$store.dispatch('feature-type/SEND_FEATURES_FROM_GEOJSON', payload)
.then(() => {
this.waitMessage = false;
this.$refs.importTask.fetchImports();
});
},
......@@ -749,11 +935,11 @@ export default {
this.waitMessage = true;
const payload = {
slug: this.slug,
feature_type_slug: this.$route.params.feature_type_slug,
feature_type_slug: this.featureTypeSlug,
};
if (this.$route.params.csv) { //* import after redirection, for instance with data from catalog
payload['csv'] = this.$route.params.csv;
} else if (this.csvFileToImport.size > 0) { //* import directly from geojson
} else if (this.csvFileToImport.size > 0) { //* import directly from csv file
payload['fileToImport'] = this.csvFileToImport;
} else {
this.importError = "La ressource n'a pas pu être récupéré.";
......@@ -762,20 +948,22 @@ export default {
this.SEND_FEATURES_FROM_CSV(payload)
.then(() => {
this.waitMessage = false;
this.$refs.importTask.fetchImports();
});
},
exportFeatures() {
this.exportLoading = true;
let exportFormat = this.feature_type.geom_type === 'none' && this.exportFormat === 'GeoJSON' ? 'json' : this.exportFormat.toLowerCase();
const url = `
${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-type/${this.$route.params.feature_type_slug}/export/?format_export=${this.exportFormat.toLowerCase()}
${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-type/${this.featureTypeSlug}/export/?format_export=${exportFormat}
`;
featureAPI.getFeaturesBlob(url)
.then((blob) => {
if (blob) {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${this.project.title}-${this.structure.title}.${this.exportFormat === 'GeoJSON' ? 'json' : 'csv'}`;
link.download = `${this.project.title}-${this.feature_type.title}.${exportFormat}`;
link.click();
setTimeout(function(){
URL.revokeObjectURL(link.href);
......@@ -787,24 +975,20 @@ export default {
this.exportLoading = false;
});
},
async getLastFeatures(){
const response = await
this.GET_PROJECT_FEATURES({
project_slug: this.slug,
feature_type__slug : this.$route.params.feature_type_slug,
ordering: '-created_on',
limit: '5'
});
if (response) {
this.featuresLoading = false;
}
},
},
};
</script>
<style scoped lang="less">
#feature-type-title i {
color: #000000;
margin: auto;
}
.custom-field.content {
overflow: hidden;
text-overflow: ellipsis;
}
.margin-25 {
margin: 0 0.25em 0.25em 0 !important;
}
......
<template>
<div>
<div id="displayCustomisation">
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui loader" />
</div>
<div
id="message"
class="fullwidth"
>
<div
v-if="error"
class="ui negative message"
>
<p>
<i
class="close icon"
aria-hidden="true"
/>
{{ error }}
</p>
</div>
<div
v-if="success"
class="ui positive message"
>
<i
class="close icon"
aria-hidden="true"
@click="success = null"
/>
<p>{{ success }}</p>
</div>
</div>
<h1 v-if="project && feature_type">
Éditer la symbologie du type de signalement "{{ feature_type.title }}" pour le
Modifier l'affichage sur la carte des signalements de type "{{ feature_type.title }}" pour le
projet "{{ project.title }}"
</h1>
<div class="fourteen wide column">
<section id="symbology">
<h3>Symbologie</h3>
<form
id="form-symbology-edit"
action=""
......@@ -48,17 +23,16 @@
>
<SymbologySelector
v-if="feature_type"
class="default"
id="default"
:init-color="feature_type.color"
:init-icon="feature_type.icon"
:init-opacity="feature_type.opacity"
:geom-type="feature_type.geom_type"
@set="setDefaultStyle"
/>
<div class="ui divider" />
<div
v-if="customizableFields.length > 0"
class="field"
class="fields inline"
>
<label
id="customfield-select-label"
......@@ -71,52 +45,137 @@
:options="customizableFields"
:selected="selectedCustomfield"
:selection.sync="selectedCustomfield"
:clearable="true"
/>
</div>
</div>
<div
v-if="selectedCustomfield"
id="customFieldSymbology"
class="field"
>
<div
<SymbologySelector
v-for="option of selectedFieldOptions"
:id="option"
:key="option"
:title="option"
:init-color="feature_type.colors_style.value ?
feature_type.colors_style.value.colors[option] ?
feature_type.colors_style.value.colors[option].value :
feature_type.colors_style.value.colors[option]
: null
"
:init-icon="feature_type.colors_style.value ?
feature_type.colors_style.value.icons[option] :
null
"
:init-opacity="getOpacity(feature_type, option)"
:geom-type="feature_type.geom_type"
@set="setColorsStyle"
/>
</div>
</form>
</section>
<div class="ui divider" />
<section
v-if="feature_type && feature_type.customfield_set"
id="popupDisplay"
>
<h3>Prévisualisation des champs personnalisés de l'info-bulle</h3>
<table
id="table-fields-to-display"
class="ui definition single line compact table"
aria-describedby="Liste des champs à afficher"
>
<thead>
<tr>
<th scope="col">
Prévisualisation du champ
</th>
<th scope="col">
Champ
</th>
<th scope="col">
Type
</th>
</tr>
</thead>
<tbody>
<tr
v-for="field in featureAnyFields"
:key="field.name"
:class="{ first_customfield: feature_type.customfield_set[0] &&
field.name === feature_type.customfield_set[0].name }"
>
<SymbologySelector
:id="option"
:title="option"
:init-color="feature_type.colors_style.value ?
feature_type.colors_style.value.colors[option] ?
feature_type.colors_style.value.colors[option].value :
feature_type.colors_style.value.colors[option]
: null
"
:init-icon="feature_type.colors_style.value ?
feature_type.colors_style.value.icons[option] :
null
"
:init-opacity="getOpacity(feature_type, option)"
:geom-type="feature_type.geom_type"
@set="setColorsStyle"
/>
<td
scope="row"
class="collapsing center aligned"
>
<div class="ui toggle checkbox">
<input
:checked="form.displayed_fields.includes(field.name)"
type="checkbox"
@input="toggleDisplay($event, field.name)"
>
<label />
</div>
</td>
<td scope="row">
{{ field.name }} ({{ field.label }})
</td>
<td scope="row">
{{ field.field_type || getCustomFieldType(field.field_type) }}
</td>
</tr>
</tbody>
</table>
</section>
<section id="notification">
<h3>Configuration de la notification d'abonnement</h3>
<div class="ui form">
<div class="field">
<div class="ui checkbox">
<input
id="enable_key_doc_notif"
v-model="form.enable_key_doc_notif"
class="hidden"
name="enable_key_doc_notif"
type="checkbox"
>
<label for="enable_key_doc_notif">Activer la notification de publication de pièces jointes</label>
</div>
<div class="ui divider" />
</div>
<button
id="save-symbology"
class="ui teal icon button margin-25"
type="button"
:disabled="!canSaveSymbology"
@click="sendFeatureSymbology"
>
<i
class="white save icon"
aria-hidden="true"
/>
Sauvegarder la symbologie du type de signalement
</button>
</form>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="disable_notification"
v-model="form.disable_notification"
class="hidden"
name="disable_notification"
type="checkbox"
>
<label for="disable_notification">Désactiver les notifications</label>
</div>
</div>
</div>
</section>
<button
id="save-display"
class="ui teal icon button margin-25"
type="button"
:disabled="!canSaveDisplayConfig"
@click="sendDisplayConfig"
>
<i
class="white save icon"
aria-hidden="true"
/>
Sauvegarder l'affichage du type de signalement
</button>
</div>
</template>
......@@ -125,12 +184,13 @@ import { isEqual } from 'lodash';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import { customFieldTypeChoices, featureNativeFields } from '@/utils';
import SymbologySelector from '@/components/FeatureType/SymbologySelector.vue';
import Dropdown from '@/components/Dropdown.vue';
export default {
name: 'FeatureTypeSymbology',
name: 'FeatureTypeDisplay',
components: {
SymbologySelector,
......@@ -140,8 +200,6 @@ export default {
data() {
return {
loading: false,
error: null,
success: null,
form: {
color: '#000000',
icon: 'circle',
......@@ -157,8 +215,11 @@ export default {
opacities: {},
}
},
displayed_fields: ['status', 'feature_type', 'updated_on'],
enable_key_doc_notif: false,
disable_notification: false,
},
canSaveSymbology: false
canSaveDisplayConfig: false
};
},
......@@ -177,7 +238,7 @@ export default {
if (this.feature_type) {
let options = this.feature_type.customfield_set.filter(el => el.field_type === 'list' || el.field_type === 'char' || el.field_type === 'boolean');
options = options.map((el) => {
return { name: [el.name, this.getFieldLabel(el.field_type)], value: el };
return { name: [el.name, this.getCustomFieldType(el.field_type)], value: el };
});
return options;
}
......@@ -186,12 +247,14 @@ export default {
selectedFieldOptions() {
if (this.selectedCustomfield) {
const customFieldSet = this.feature_type.customfield_set.find(el => el.name === this.selectedCustomfield);
if (customFieldSet.options.length > 0) {
return customFieldSet.options;
} else if (customFieldSet.field_type === 'char') {
return ['Vide', 'Non vide'];
} else if (customFieldSet.field_type === 'boolean') {
return ['Décoché', 'Coché'];
if (customFieldSet) {
if (customFieldSet.options && customFieldSet.options.length > 0) {
return customFieldSet.options;
} else if (customFieldSet.field_type === 'char') {
return ['Vide', 'Non vide'];
} else if (customFieldSet.field_type === 'boolean') {
return ['Décoché', 'Coché'];
}
}
}
return [];
......@@ -201,36 +264,39 @@ export default {
return this.form.colors_style.custom_field_name;
},
set(newValue) {
if (newValue && newValue.value) {
this.form.colors_style.custom_field_name = newValue.value.name;
if (newValue !== undefined) {
this.form.colors_style.custom_field_name = newValue.value ? newValue.value.name : null;
}
}
},
featureAnyFields() {
return [...featureNativeFields, ...this.feature_type.customfield_set];
}
},
watch: {
feature_type(newValue) {
// In which case the feature type would change while on this page ?
if (newValue) {
// Init form
this.form.color = JSON.parse(JSON.stringify(newValue.color));
this.form.icon = JSON.parse(JSON.stringify(newValue.icon));
this.form.colors_style = {
...this.form.colors_style,
...JSON.parse(JSON.stringify(newValue.colors_style))
};
this.initForm();
}
},
form: {
deep: true,
handler(newValue) {
// checks if they are changes to be saved to enable save button
if (isEqual(newValue, {
color: this.feature_type.color,
icon: this.feature_type.icon,
colors_style: this.feature_type.colors_style
opacity: this.feature_type.opacity,
colors_style: this.feature_type.colors_style,
displayed_fields: this.feature_type.displayed_fields,
enable_key_doc_notif: this.feature_type.enable_key_doc_notif,
disable_notification: this.feature_type.disable_notification
})) {
this.canSaveSymbology = false;
this.canSaveDisplayConfig = false;
} else {
this.canSaveSymbology = true;
this.canSaveDisplayConfig = true;
}
}
}
......@@ -249,6 +315,7 @@ export default {
this.GET_PROJECT_FEATURE_TYPES(this.$route.params.slug)
.then(() => {
this.initForm();
// TODO : Use the global loader and get rid of this redondant loader
this.loading = false;
})
.catch(() => {
......@@ -262,14 +329,14 @@ export default {
'SET_CURRENT_FEATURE_TYPE_SLUG'
]),
...mapActions('feature-type', [
'SEND_FEATURE_SYMBOLOGY',
'SEND_FEATURE_DISPLAY_CONFIG',
'GET_PROJECT_FEATURE_TYPES'
]),
...mapActions('projects', [
'GET_PROJECT',
'GET_PROJECT_INFO',
]),
initForm() {
this.form.color = JSON.parse(JSON.stringify(this.feature_type.color)); //? wouldn't be better to use lodash: https://medium.com/@pmzubar/why-json-parse-json-stringify-is-a-bad-practice-to-clone-an-object-in-javascript-b28ac5e36521
this.form.icon = JSON.parse(JSON.stringify(this.feature_type.icon)); //? since the library is already imported ?
......@@ -277,15 +344,22 @@ export default {
...this.form.colors_style,
...JSON.parse(JSON.stringify(this.feature_type.colors_style))
};
if (!this.form.colors_style.value['opacities']) { //* if the opacity values were never setted (would be better to find out why)
if (!this.form.colors_style.value['opacities']) { //* if the opacity values were never setted (but why would it happen, is it necessary ?)
this.form.colors_style.value['opacities'] = {};
}
if (this.feature_type.colors_style && Object.keys(this.feature_type.colors_style.colors).length > 0) {
this.selectedCustomfield =
this.feature_type.customfield_set.find(
el => el.name === this.feature_type.colors_style.custom_field_name
).name;
const coloredCustomField = this.feature_type.customfield_set.find(
el => el.name === this.feature_type.colors_style.custom_field_name
);
if (coloredCustomField) {
this.selectedCustomfield = coloredCustomField.name;
}
}
if (this.feature_type && this.feature_type.displayed_fields) {
this.form.displayed_fields = [...this.feature_type.displayed_fields];
}
this.form.enable_key_doc_notif = this.feature_type.enable_key_doc_notif;
this.form.disable_notification = this.feature_type.disable_notification;
},
setDefaultStyle(e) {
......@@ -306,30 +380,33 @@ export default {
this.form.colors_style.value.opacities[name] = opacity; //? why do we need to duplicate values ? for MVT ?
},
sendFeatureSymbology() {
toggleDisplay(evt, name) {
if (evt.target.checked) {
this.form.displayed_fields.push(name);
} else {
this.form.displayed_fields = this.form.displayed_fields.filter(el => el !== name);
}
},
sendDisplayConfig() {
this.loading = true;
this.SEND_FEATURE_SYMBOLOGY(this.form)
this.SEND_FEATURE_DISPLAY_CONFIG(this.form)
.then(() => {
this.GET_PROJECT_FEATURE_TYPES(this.$route.params.slug)
.then(() => {
this.loading = false;
this.success =
'La modification de la symbologie a été prise en compte. Vous allez être redirigé vers la page d\'accueil du projet.';
setTimeout(() => {
this.$router.push({
name: 'project_detail',
params: {
slug: this.$route.params.slug,
},
});
}, 1500);
})
.catch((err) => {
console.error(err);
});
this.loading = false;
this.$router.push({
name: 'project_detail',
params: {
slug: this.$route.params.slug,
message: { comment: `La modification de l'affichage du type de signalement "${this.feature_type.title}" a été prise en compte.`, level: 'positive' }
},
});
})
.catch((err) => {
console.error(err);
this.$store.commit('DISPLAY_MESSAGE', {
comment: `Une erreur est survenue pendant l'envoi des modifications de l'affichage du type de signalement "${this.feature_type.title}"`,
level: 'negative'
});
this.loading = false;
});
},
......@@ -341,37 +418,63 @@ export default {
return null;
},
getFieldLabel(fieldType) {
switch (fieldType) {
case 'list':
return'Liste de valeurs';
case 'char':
return 'Chaîne de caractères';
case 'boolean':
return 'Booléen';
}
getCustomFieldType(fieldType) {
return customFieldTypeChoices.find(el => el.value === fieldType).name;
}
}
};
</script>
<style lang="less" scoped>
h1 {
margin-top: 1em;
#displayCustomisation {
h1 {
margin-top: 1em;
}
form {
text-align: left;
margin-left: 1em;
#customfield-select-label {
font-weight: 600;
font-size: 1.1em;
}
#custom_types-dropdown {
margin: 1em;
&& > .dropdown {
width: 50%;
}
}
}
}
form {
text-align: left;
#customfield-select-label {
font-weight: 600;
font-size: 1.1em;
}
#custom_types-dropdown > .dropdown {
width: 50%;
section {
padding: 1.5em 0;
// shrink toggle background width and height
.ui.toggle.checkbox .box::after, .ui.toggle.checkbox label::after {
height: 15px;
width: 15px;
}
.ui.toggle.checkbox .box, .ui.toggle.checkbox label {
padding-left: 2.5rem;
}
// reduce toggle button width and height
.ui.toggle.checkbox .box::before, .ui.toggle.checkbox label::before {
height: 15px;
width: 35px;
}
// adjust toggled button placement
.ui.toggle.checkbox input:checked ~ .box::after, .ui.toggle.checkbox input:checked ~ label::after {
left: 20px;
}
.ui.toggle.checkbox .box, .ui.toggle.checkbox label, .ui.toggle.checkbox {
min-height: 15px;
}
table {
border-collapse: collapse;
}
tr.first_customfield td {
border-top-width: 4px !important;
}
}
</style>
......@@ -10,9 +10,6 @@
<form
v-if="project"
id="form-type-edit"
action=""
method="post"
enctype="multipart/form-data"
class="ui form"
>
<h1 v-if="action === 'create'">
......@@ -79,6 +76,30 @@
<label :for="form.title_optional.html_name">{{ form.title_optional.label }}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
:id="form.enable_key_doc_notif.html_name"
v-model="form.enable_key_doc_notif.value"
class="hidden"
:name="form.enable_key_doc_notif.html_name"
type="checkbox"
>
<label :for="form.enable_key_doc_notif.html_name">{{ form.enable_key_doc_notif.label }}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
:id="form.disable_notification.html_name"
v-model="form.disable_notification.value"
class="hidden"
:name="form.disable_notification.html_name"
type="checkbox"
>
<label :for="form.disable_notification.html_name">{{ form.disable_notification.label }}</label>
</div>
</div>
<div id="formsets">
<FeatureTypeCustomForm
......@@ -108,7 +129,7 @@
<div class="ui divider" />
<button
id="send-feature_type"
class="ui teal icon button margin-25"
:class="['ui teal icon button margin-25', { disabled: loading }]"
type="button"
@click="sendFeatureType"
>
......@@ -120,8 +141,8 @@
signalement
</button>
<button
v-if="geojson || csv"
class="ui teal icon button margin-25"
v-if="geojson || csv || json"
:class="['ui teal icon button margin-25', { disabled: loading }]"
type="button"
@click="postFeatureTypeThenFeatures"
>
......@@ -129,7 +150,7 @@
class="white save icon"
aria-hidden="true"
/>
Créer et importer le(s) signalement(s) du {{ geojson ? 'geojson' : 'csv' }}
Créer et importer le(s) signalement(s) du {{ geojson ? 'geojson' : csv ? 'csv' : 'json' }}
</button>
</form>
</div>
......@@ -142,7 +163,7 @@ import { mapGetters, mapState, mapMutations, mapActions } from 'vuex';
import Dropdown from '@/components/Dropdown.vue';
import FeatureTypeCustomForm from '@/components/FeatureType/FeatureTypeCustomForm.vue';
import { transformProperties } from'@/utils';
import { transformProperties, reservedKeywords } from'@/utils';
export default {
name: 'FeatureTypeEdit',
......@@ -161,6 +182,10 @@ export default {
type: Array,
default: null,
},
json: {
type: Array,
default: null,
},
},
data() {
......@@ -173,6 +198,7 @@ export default {
{ value: 'linestring', name: 'Ligne' },
{ value: 'point', name: 'Point' },
{ value: 'polygon', name: 'Polygone' },
{ value: 'none', name: 'Aucune' },
],
form: {
colors_style: {
......@@ -196,7 +222,7 @@ export default {
id_for_label: 'title',
label: 'Titre',
field: {
max_length: 128, // ! Vérifier la valeur dans django
max_length: 128,
},
html_name: 'title',
value: null,
......@@ -208,36 +234,31 @@ export default {
label: 'Titre du signalement optionnel',
value: false,
},
enable_key_doc_notif: {
errors: null,
id_for_label: 'enable_key_doc_notif',
html_name: 'enable_key_doc_notif',
label: 'Activer la notification de publication de pièces jointes',
value: false,
},
disable_notification: {
errors: null,
id_for_label: 'disable_notification',
html_name: 'disable_notification',
label: 'Désactiver les notifications',
value: false,
},
geom_type: {
id_for_label: 'geom_type',
label: 'Type de géométrie',
field: {
max_length: 128, // ! Vérifier la valeur dans django
max_length: 128,
},
html_name: 'geom_type',
value: 'point',
},
},
slug: this.$route.params.slug,
reservedKeywords: [
// todo : add keywords for mapstyle (strokewidth...)
'id',
'title',
'description',
'status',
'created_on',
'updated_on',
'archived_on',
'deletion_on',
'feature_type',
'feature_id',
'display_creator',
'display_last_editor',
'project',
'creator',
'lat',
'lon'
],
};
},
......@@ -312,14 +333,16 @@ export default {
},
customForms(newValue, oldValue) {
if (newValue !== oldValue) {
const name = this.form.colors_style.value.custom_field_name;
const customField = this.customForms.find((el) => el.name === name);
if (!customField || customField.length === 0) {
//* if the customForm corresponding doesn't exist reset colors_style values
this.form.colors_style.value = {
colors: {},
custom_field_name: '',
};
// Retrieve custom_field_name; returns undefined if colors_style.value is null/undefined
const customFieldName = this.form.colors_style.value?.custom_field_name;
// Determine if a custom field with the given name exists in customForms
// 'some' returns true if any element matches the condition
const customFieldExists = customFieldName && this.customForms.some(el => el.name === customFieldName);
// Reset colors_style if no corresponding custom field is found
if (!customFieldExists) {
this.form.colors_style.value = { colors: {}, custom_field_name: '' };
}
}
},
......@@ -353,12 +376,6 @@ export default {
//* when creation from a geojson
if (this.geojson) {
this.importGeoJsonFeatureType();
if (this.fileToImport && this.fileToImport.name) {
this.form.title.value = // * use the filename as title by default
this.fileToImport.name.split('.')[0];
} else { //* case when the geojson comes from datasud catalog
this.form.title.value = this.geojson.name;// * use the typename as title by default
}
//* add multiple geometries options available only for geojson (therefore when importing from catalog also)
this.geomTypeChoices.push(
{ value: 'multilinestring', name: 'Multiligne' },
......@@ -368,12 +385,9 @@ export default {
}
if (this.csv) {
this.importCSVFeatureType();
if (this.fileToImport && this.fileToImport.name) {
this.form.title.value = // * use the filename as title by default
this.fileToImport.name.split('.')[0];
} else { //* case when the geojson comes from datasud catalog
this.form.title.value = this.csv.name;// * use the typename as title by default
}
}
if (this.json) {
this.importJsonFeatureType();
}
},
beforeDestroy() {
......@@ -440,7 +454,7 @@ export default {
}
}
//! add custom fields using ONLY this function, incrementing dataKey for Vue to correctly update components
formData.customfield_set.forEach((el) => this.addCustomForm(el));
[...formData.customfield_set].forEach((el) => this.addCustomForm(el));
this.updateStore();
},
......@@ -478,6 +492,8 @@ export default {
color: this.form.color,
title: this.form.title,
title_optional: this.form.title_optional,
enable_key_doc_notif: this.form.enable_key_doc_notif,
disable_notification: this.form.disable_notification,
geom_type: this.form.geom_type,
colors_style: this.form.colors_style,
});
......@@ -528,9 +544,9 @@ export default {
.then((response) => {
const { status, data } = response;
if (status === 200) {
this.goBackToProject('Le type de signalement a été mis à jour');
this.goBackToProject({ comment: 'Le type de signalement a été mis à jour', level: 'positive' });
} else if (status === 201) {
this.goBackToProject('Le nouveau type de signalement a été créé');
this.goBackToProject({ comment: 'Le nouveau type de signalement a été créé', level: 'positive' });
} else {
let comment = 'Une erreur est survenue lors de l\'import du type de signalement';
if (data.customfield_set) {
......@@ -553,11 +569,14 @@ export default {
this.SEND_FEATURES_FROM_GEOJSON({
slug: this.slug,
feature_type_slug,
geojson: this.geojson
geojson: this.geojson || this.json
})
.then((response) => {
if (response && response.status === 200) {
this.goBackToProject();
this.goBackToProject({
comment: 'Le nouveau type de signalement a été créé. L\'import des signalements est en cours',
level: 'positive'
});
} else {
this.DISPLAY_MESSAGE({
comment: `Une erreur est survenue lors de l'import de signalements.\n ${ response.data.detail }`,
......@@ -570,6 +589,7 @@ export default {
this.loading = false;
});
},
postCSVFeatures(feature_type_slug) {
this.$store
.dispatch('feature-type/SEND_FEATURES_FROM_CSV', {
......@@ -579,7 +599,10 @@ export default {
})
.then((response) => {
if (response && response.status === 200) {
this.goBackToProject();
this.goBackToProject({
comment: 'Le nouveau type de signalement a été créé. Import des signalements est en cours',
level: 'positive'
});
} else {
this.DISPLAY_MESSAGE({
comment: `Une erreur est survenue lors de l'import de signalements.\n ${ response.data.detail }`,
......@@ -601,10 +624,9 @@ export default {
.dispatch('feature-type/SEND_FEATURE_TYPE', requestType)
.then(({ feature_type_slug }) => {
if (feature_type_slug) {
if (this.geojson) {
if (this.geojson || this.json) {
this.postGeojsonFeatures(feature_type_slug);
}
else if (this.csv) {
} else if (this.csv) {
this.postCSVFeatures(feature_type_slug);
}
} else {
......@@ -625,40 +647,119 @@ export default {
});
},
buildCustomForm(properties) {
for (const [key, val] of Object.entries(properties)) {
//* check that the property is not a keyword from the backend or map style
// todo: add map style keywords
if (!this.reservedKeywords.includes(key)) {
const customForm = {
label: { value: key || '' },
name: { value: key || '' },
position: this.dataKey, // * use dataKey already incremented by addCustomForm
field_type: { value: transformProperties(val) }, // * guessed from the type
options: { value: [] }, // * not available in export
};
this.addCustomForm(customForm);
/**
* Builds custom form fields based on the properties of data entries.
*
* This function iterates through a subset of data entries (such as rows from a CSV, JSON objects, or GeoJSON features)
* to determine the most appropriate type for each field. It tracks confirmed types to avoid redundant checks and
* stops processing a field once its type is definitively determined. If a field is initially detected as a 'char',
* it remains as 'char' unless a multiline text ('text') is detected later. The function prioritizes the detection
* of definitive types (like 'text', 'boolean', 'integer') and updates the form with the confirmed types.
*
* @param {Array} propertiesList - An array of data entries, where each entry is an object representing a set of properties.
*/
buildCustomForm(propertiesList) {
const confirmedTypes = {}; // Store confirmed types for each field
const detectedAsChar = {}; // Track fields initially detected as 'char'
// Iterate over each row or feature in the subset
propertiesList.forEach((properties) => {
for (const [key, val] of Object.entries(properties)) {
if (!reservedKeywords.includes(key)) {
// If the type for this field has already been confirmed as something other than 'char', skip it
if (confirmedTypes[key] && confirmedTypes[key] !== 'char') {
continue;
}
// Determine the type of the current value
const detectedType = transformProperties(val);
if (detectedType === 'text') {
// Once 'text' (multiline) is detected, confirm it immediately
confirmedTypes[key] = 'text';
} else if (!confirmedTypes[key] && detectedType !== 'char') {
// If a type is detected that is not 'char' and not yet confirmed, confirm it
confirmedTypes[key] = detectedType;
} else if (!confirmedTypes[key]) {
// If this field hasn't been confirmed yet, initialize it as 'char'
confirmedTypes[key] = 'char';
detectedAsChar[key] = true;
} else if (detectedAsChar[key] && detectedType !== 'char') {
// If a field was initially detected as 'char' but now has a different type, update it
confirmedTypes[key] = detectedType;
delete detectedAsChar[key]; // Remove from 'char' tracking once updated
}
}
}
});
// Build custom forms using the confirmed types
for (const [key, confirmedType] of Object.entries(confirmedTypes)) {
const customForm = {
label: { value: key || '' },
name: { value: key || '' },
position: this.dataKey, // use dataKey already incremented by addCustomForm
field_type: { value: confirmedType }, // use the confirmed type
options: { value: [] }, // not available in export
};
this.addCustomForm(customForm);
}
},
setTitleFromFile() {
if (this.fileToImport && this.fileToImport.name) {
this.form.title.value = // * use the filename as title by default
this.fileToImport.name.split('.')[0];
} else { //* case when the data comes from datasud catalog
// * use the typename as title by default
this.form.title.value = this.geojson.name || this.csv.name || this.json.name;
}
},
importGeoJsonFeatureType() {
if (this.geojson.features && this.geojson.features.length) {
//* in order to get feature_type properties, the first feature is enough
const { properties, geometry } = this.geojson.features[0];
this.form.title.value = properties.feature_type;
const { geometry } = this.geojson.features[0];
this.form.geom_type.value = geometry.type.toLowerCase();
this.updateStore(); //* register title & geom_type in store
this.buildCustomForm(properties);
this.updateStore(); // register geom_type in store
// Use a subset of the first N features to build the form
const subsetFeatures = this.geojson.features.slice(0, 200); // Adjust '200' based on performance needs
const propertiesList = subsetFeatures.map(feature => feature.properties);
this.buildCustomForm(propertiesList);
}
this.setTitleFromFile();
},
importCSVFeatureType() {
if (this.csv.length) {
this.updateStore(); //* register title & geom_type in store
this.buildCustomForm(this.csv[0]);
this.updateStore(); // register title in store
// Use a subset of the first N rows to build the form
const subsetCSV = this.csv.slice(0, 200); // Adjust '200' based on performance needs
this.buildCustomForm(subsetCSV);
// Check for geom data
if (!('lat' in this.csv[0]) || !('lon' in this.csv[0])) {
this.form.geom_type.value = 'none';
}
}
this.setTitleFromFile();
},
importJsonFeatureType() {
if (this.json.length) {
this.form.geom_type.value = 'none'; // JSON are non-geom features
this.updateStore(); // register title in store
// Use a subset of the first N objects to build the form
const subsetJson = this.json.slice(0, 200); // Adjust '200' based on performance needs
this.buildCustomForm(subsetJson);
}
this.setTitleFromFile();
},
},
};
</script>
......
......@@ -16,7 +16,7 @@
import { mapState } from 'vuex';
export default {
name: 'Default',
name: 'Help',
computed: {
...mapState(['staticPages']),
......
......@@ -34,7 +34,7 @@
import { mapState } from 'vuex';
export default {
name: 'WithRightMenu',
name: 'Mentions',
data() {
return {
......
......@@ -4,29 +4,29 @@
<div class="fourteen wide column">
<img
class="ui centered small image"
:src="logo"
:src="appLogo"
alt="Logo de l'application"
>
<h2 class="ui center aligned icon header">
<div class="content">
{{ APPLICATION_NAME }}
{{ appName }}
<div class="sub header">
{{ APPLICATION_ABSTRACT }}
{{ appAbstract }}
</div>
</div>
</h2>
</div>
</div>
<div class="row">
<div class="six wide column">
<div
v-if="$route.name === 'login'"
class="six wide column"
>
<h3 class="ui horizontal divider header">
CONNEXION
</h3>
<div
v-if="form.errors"
class="ui warning message"
>
<div :class="['ui warning message', {'closed': !errors.global}]">
<div class="header">
Les informations d'identification sont incorrectes.
</div>
......@@ -39,29 +39,29 @@
type="post"
@submit.prevent="login"
>
<div class="ui stacked secondary segment">
<div class="six field required">
<div class="ui secondary segment">
<div class="six field">
<div class="ui left icon input">
<i
class="user icon"
aria-hidden="true"
/>
<input
v-model="username_value"
v-model="loginForm.username"
type="text"
name="username"
placeholder="Utilisateur"
>
</div>
</div>
<div class="six field required">
<div class="six field">
<div class="ui left icon input">
<i
class="lock icon"
aria-hidden="true"
/>
<input
v-model="password_value"
v-model="loginForm.password"
type="password"
name="password"
placeholder="Mot de passe"
......@@ -77,70 +77,467 @@
</div>
</form>
</div>
<div
v-else-if="$route.name === 'signup'"
class="six wide column"
>
<h3 class="ui horizontal divider header">
INSCRIPTION
</h3>
<div :class="['ui warning message', {'closed': !error}]">
{{ error }}
</div>
<form
class="ui form"
role="form"
type="post"
@submit.prevent="signup"
>
<div class="ui secondary segment">
<div class="six field">
<div class="ui left icon input">
<i
class="user outline icon"
aria-hidden="true"
/>
<input
v-model="signupForm.first_name"
type="text"
name="first_name"
placeholder="Prénom"
required
>
</div>
</div>
<div class="six field">
<div class="ui left icon input">
<i
class="id card icon"
aria-hidden="true"
/>
<input
v-model="signupForm.last_name"
type="text"
name="last_name"
placeholder="Nom"
required
>
</div>
</div>
<div class="six field">
<div class="ui left icon input">
<i
class="envelope icon"
aria-hidden="true"
/>
<input
v-model="signupForm.email"
type="email"
name="email"
placeholder="Adresse courriel"
required
>
</div>
</div>
<div class="six field">
<div class="ui left icon input">
<i
class="user icon"
aria-hidden="true"
/>
<input
v-model="signupForm.username"
type="text"
name="username"
placeholder="Utilisateur"
disabled
>
</div>
</div>
<div :class="['six field', {'error': errors.passwd}]">
<div class="ui action left icon input">
<i
class="lock icon"
aria-hidden="true"
/>
<input
v-model="signupForm.password"
:type="showPwd ? 'text' : 'password'"
name="password"
placeholder="Mot de passe"
required
@blur="isValidPwd"
>
<button
class="ui icon button"
@click="showPwd = !showPwd"
>
<i :class="[showPwd ? 'eye slash' : 'eye', 'icon']" />
</button>
</div>
</div>
<div :class="['six field', {'error': errors.comments}]">
<div class="ui left icon input">
<i
class="pencil icon"
aria-hidden="true"
/>
<input
v-model="signupForm.comments"
type="text"
name="comments"
:placeholder="commentsFieldLabel || `Commentaires`"
:required="commentsFieldRequired"
>
</div>
</div>
<div
v-if="usersGroupsOptions.length > 0"
class="six field"
>
<div class="ui divider" />
<Multiselect
v-model="usersGroupsSelections"
:options="usersGroupsOptions"
:multiple="true"
track-by="value"
label="name"
select-label=""
selected-label=""
deselect-label=""
:searchable="false"
:placeholder="'Sélectionez un ou plusieurs groupe de la liste ...'"
/>
<p v-if="adminMail">
Si le groupe d'utilisateurs recherché n'apparaît pas, vous pouvez demander à
<a :href="'mailto:'+adminMail">{{ adminMail }}</a> de le créer
</p>
</div>
<button
:class="['ui fluid large teal submit button']"
type="submit"
>
Valider
</button>
</div>
</form>
</div>
<div
v-else-if="$route.name === 'sso-signup-success'"
class="six wide column"
>
<h3 class="ui horizontal divider header">
INSCRIPTION RÉUSSIE
</h3>
<h4 class="ui center aligned icon header">
<div class="content">
<p
v-if="username"
class="sub header"
>
Le compte pour le nom d'utilisateur <strong>{{ username }}</strong> a été créé
</p>
<p>
Un e-mail de confirmation vient d'être envoyé à l'adresse indiquée.
</p>
<p class="sub header">
Merci de bien vouloir suivre les instructions données afin de finaliser la création de votre compte.
</p>
</div>
</h4>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapMutations } from 'vuex';
import Multiselect from 'vue-multiselect';
import userAPI from '../services/user-api';
export default {
name: 'Login',
components: {
Multiselect
},
props: {
username: {
type: String,
default: null
}
},
data() {
return {
username_value: null,
password_value: null,
logged: false,
form: {
errors: null,
loginForm: {
username: null,
password: null,
},
signupForm: {
username: null,
password: null,
first_name: null,
last_name: null,
email: null,
comments: null,
usersgroups: [],
},
errors: {
global: null,
passwd: null,
comments: null,
},
showPwd: false,
};
},
computed: {
logo() {
return this.$store.state.configuration.VUE_APP_LOGO_PATH;
...mapState({
appLogo: state => state.configuration.VUE_APP_LOGO_PATH,
appName: state => state.configuration.VUE_APP_APPLICATION_NAME,
appAbstract: state => state.configuration.VUE_APP_APPLICATION_ABSTRACT,
adminMail: state => state.configuration.VUE_APP_ADMIN_MAIL,
ssoSignupUrl: state => state.configuration.VUE_APP_SSO_SIGNUP_URL,
commentsFieldLabel: state => state.configuration.VUE_APP_SIGNUP_COMMENTS_FIELD_LABEL,
commentsFieldRequired: state => state.configuration.VUE_APP_SIGNUP_COMMENTS_FIELD_REQUIRED,
}),
...mapGetters(['usersGroupsOptions']),
usersGroupsSelections: {
get() {
return this.usersGroupsOptions.filter((el) => this.signupForm.usersgroups?.includes(el.value));
},
set(newValue) {
this.signupForm.usersgroups = newValue.map(el => el.value);
}
},
APPLICATION_NAME() {
return this.$store.state.configuration.VUE_APP_APPLICATION_NAME;
error() {
return this.errors.global || this.errors.passwd || this.errors.comments;
}
},
watch: {
'signupForm.first_name': function (newValue, oldValue) {
if (newValue !== oldValue) {
this.signupForm.username = `${newValue.charAt(0)}${this.signupForm.last_name}`.toLowerCase().replace(/\s/g, '');
}
},
'signupForm.last_name': function (newValue, oldValue) {
if (newValue !== oldValue) {
this.signupForm.username = `${this.signupForm.first_name.charAt(0)}${newValue}`.toLowerCase().replace(/\s/g, '');
}
},
APPLICATION_ABSTRACT() {
return this.$store.state.configuration.VUE_APP_APPLICATION_ABSTRACT;
'signupForm.password': function (newValue, oldValue) {
if (newValue.length >= 8) {
if (newValue !== oldValue) {
this.isValidPwd();
}
} else {
this.errors.passwd = null;
}
},
username(newValue, oldValue) {
if (newValue !== oldValue) {
this.loginForm.username = newValue;
}
}
},
created() {
if (this.$route.name === 'signup') {
this.$store.dispatch('GET_USERS_GROUPS'); // récupére les groupes d'utilisateurs pour extra_forms
}
},
mounted() {
if (this.$store.state.user) {
this.$store.commit(
'DISPLAY_MESSAGE',
{ comment: 'Vous êtes déjà connecté, vous allez être redirigé vers la page précédente.' }
);
setTimeout(() => this.$store.dispatch('REDIRECT_AFTER_LOGIN'), 3100);
if (this.$route.name === 'login') {
if (this.$store.state.user) {
this.DISPLAY_MESSAGE({ header: 'Vous êtes déjà connecté', comment: 'Vous allez être redirigé vers la page précédente.' });
setTimeout(() => this.$store.dispatch('REDIRECT_AFTER_LOGIN'), 3100);
}
}
},
methods: {
...mapMutations(['DISPLAY_MESSAGE']),
login() {
this.$store
.dispatch('LOGIN', {
username: this.username_value,
password: this.password_value,
username: this.loginForm.username,
password: this.loginForm.password,
})
.then((status) => {
if (status === 200) {
this.form.errors = null;
this.errors.global = null;
} else if (status === 'error') {
this.form.errors = status;
this.errors.global = status;
}
})
.catch();
},
async signup() {
if (this.hasUnvalidFields()) return;
// Étape 1 : Création de l'utilisateur auprès du service d'authentification SSO si nécessaire
if (this.ssoSignupUrl) {
const ssoResponse = await userAPI.signup({
...this.signupForm,
// Ajout du label personnalisé pour affichage plus précis dans admin OGS
comments: `{"${this.commentsFieldLabel}":"${this.signupForm.comments}"}`,
// Pour permettre la visualisation dans OGS Maps, l'utilisateur doit être ajouté à un groupe OGS, mis en dur pour aller vite pour l'instant
usergroup_roles:[{ organisation: { id: 1 } }]
}, this.ssoSignupUrl);
if (ssoResponse.status !== 201) {
if (ssoResponse.status === 400) {
this.errors.global = 'Un compte associé à ce courriel existe déjà';
} else {
this.errors.global = `Erreur lors de l'inscription: ${ssoResponse.data?.detail || 'Problème inconnu'}`;
}
return; // Stoppe la fonction si l'inscription SSO échoue
} else {
this.signupForm.username = ssoResponse.data.username;
this.signupForm.first_name = ssoResponse.data.first_name;
this.signupForm.last_name = ssoResponse.data.last_name;
}
}
// Étape 2 : Création de l'utilisateur dans Geocontrib
const response = await userAPI.signup(this.signupForm);
if (response.status !== 201) {
const errorMessage = response.data
? Object.values(response.data)?.[0]?.[0] || 'Problème inconnu'
: 'Problème inconnu';
this.errors.global = `Erreur lors de l'inscription: ${errorMessage}`;
return;
}
this.DISPLAY_MESSAGE({ header: 'Inscription réussie !', comment: `Bienvenue sur la plateforme ${this.signupForm.username}.`, level: 'positive' });
if (this.ssoSignupUrl) {
setTimeout(() => {
this.$router.push({ name: 'sso-signup-success', params: { username: this.signupForm.username } });
}, 3100);
} else {
setTimeout(() => {
this.$router.push({ name: 'login', params: { username: this.signupForm.username } });
}, 3100);
}
},
isValidPwd() {
const regPwd = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d/$&+,:;=?#|'<>.^*()%!-]{8,}$/;
if (!regPwd.test(this.signupForm.password)) {
this.errors.passwd = `Le mot de passe doit comporter au moins 8 caractères, dont 1 majuscule, 1 minuscule et 1 chiffre.
Vous pouvez utiliser les caractères spéciaux suivants : /$ & + , : ; = ? # | ' < > . ^ * ( ) % ! -.`;
return false;
}
this.errors.passwd = null;
return true;
},
hasUnvalidFields() {
const { last_name, email, first_name, comments } = this.signupForm;
if (this.commentsFieldRequired && !comments) {
this.errors.comments = `Le champ ${ this.commentsFieldLabel || 'Commentaires'} est requis`;
return true;
} else {
this.errors.comments = null;
}
if (email && last_name && first_name) {
this.errors.global = null;
} else {
this.errors.global = 'Certains champs requis ne sont pas renseignés';
return true;
}
return !this.isValidPwd();
}
},
};
</script>
<style lang="less" scoped>
#login-page {
max-width: 500px;
min-width: 200px;
margin: 3em auto;
.ui.message {
min-height: 0px;
&.closed {
overflow: hidden;
opacity: 0;
padding: 0;
max-height: 0px;
}
}
input[required] {
background-image: linear-gradient(45deg, transparent, transparent 50%, rgb(209, 0, 0) 50%, rgb(209, 0, 0) 100%);
background-position: top right;
background-size: .5em .5em;
background-repeat: no-repeat;
}
}
p {
margin: 1em 0 !important;
}
</style>
<style>
.multiselect__placeholder {
position: absolute;
width: calc(100% - 48px);
overflow: hidden;
text-overflow: ellipsis;
}
.multiselect__tags {
position: relative;
}
/* keep font-weight from overide of semantic classes */
.multiselect__placeholder,
.multiselect__content,
.multiselect__tags {
font-weight: initial !important;
}
/* keep placeholder eigth */
.multiselect .multiselect__placeholder {
margin-bottom: 9px !important;
padding-top: 1px;
}
/* keep placeholder height when opening dropdown without selection */
input.multiselect__input {
padding: 3px 0 0 0 !important;
}
/* keep placeholder height when opening dropdown with already a value selected */
.multiselect__tags .multiselect__single {
padding: 1px 0 0 0 !important;
margin-bottom: 9px;
}
</style>
\ No newline at end of file
......@@ -3,8 +3,9 @@
<div class="column">
<FeaturesListAndMapFilters
:show-map="showMap"
:features-count="featuresCount"
:features-count="featuresCountDisplay"
:pagination="pagination"
:all-selected="allSelected"
:edit-attributes-feature-type="editAttributesFeatureType"
@set-filter="setFilters"
@reset-pagination="resetPagination"
......@@ -14,46 +15,62 @@
@toggle-delete-modal="toggleDeleteModal"
/>
<div
:class="['ui tab active map-container', { 'visible': showMap }]"
data-tab="map"
>
<div class="loader-container">
<div
id="map"
ref="map"
/>
<SidebarLayers
v-if="basemaps && map"
ref="sidebar"
/>
<Geocoder />
<div
id="popup"
class="ol-popup"
:class="['ui tab active map-container', { 'visible': showMap }]"
data-tab="map"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="map"
ref="map"
>
<SidebarLayers
v-if="basemaps && map"
ref="sidebar"
/>
<Geolocation />
<Geocoder />
</div>
<div
id="popup-content"
/>
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div>
<FeatureListTable
v-show="!showMap"
:paginated-features="paginatedFeatures"
:page-numbers="pageNumbers"
:all-selected="allSelected"
:checked-features.sync="checkedFeatures"
:features-count="featuresCount"
:pagination="pagination"
:sort="sort"
:edit-attributes-feature-type.sync="editAttributesFeatureType"
:queryparams="queryparams"
@update:page="handlePageChange"
@update:sort="handleSortChange"
@update:allSelected="handleAllSelectedChange"
/>
<Transition name="fadeIn">
<div
v-if="loading"
class="ui inverted dimmer active"
>
<div class="ui text loader">
Récupération des signalements en cours...
</div>
</div>
</Transition>
</div>
<FeatureListTable
v-show="!showMap"
:paginated-features="paginatedFeatures"
:page-numbers="pageNumbers"
:checked-features.sync="checkedFeatures"
:features-count="featuresCount"
:pagination="pagination"
:sort="sort"
:edit-attributes-feature-type.sync="editAttributesFeatureType"
:queryparams="queryparams"
@update:page="handlePageChange"
@update:sort="handleSortChange"
/>
<!-- MODAL ALL DELETE FEATURE TYPE -->
......@@ -79,8 +96,11 @@
aria-hidden="true"
/>
Êtes-vous sûr de vouloir effacer
<span v-if="checkedFeatures.length === 1"> un signalement ? </span>
<span v-else> ces {{ checkedFeatures.length }} signalements ? </span>
<span v-if="checkedFeatures.length === 1"> un signalement&nbsp;?</span>
<span v-else-if="checkedFeatures.length > 1">ces {{ checkedFeatures.length }} signalements&nbsp;?</span>
<span v-else>tous les signalements sélectionnés&nbsp;?<br>
<small>Seuls ceux que vous êtes autorisé à supprimer seront réellement effacés.</small>
</span>
</div>
<div class="actions">
<button
......@@ -105,8 +125,9 @@ import Geocoder from '@/components/Map/Geocoder';
import featureAPI from '@/services/feature-api';
import FeaturesListAndMapFilters from '@/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters';
import SidebarLayers from '@/components/Map/SidebarLayers';
import FeatureListTable from '@/components/Project/FeaturesListAndMap/FeatureListTable';
import SidebarLayers from '@/components/Map/SidebarLayers';
import Geolocation from '@/components/Map/Geolocation';
const initialPagination = {
currentPage: 1,
......@@ -122,24 +143,24 @@ export default {
FeaturesListAndMapFilters,
SidebarLayers,
Geocoder,
Geolocation,
FeatureListTable,
},
data() {
return {
allSelected: false,
editAttributesFeatureType: null,
currentLayer: null,
featuresCount: 0,
featuresWithGeomCount:0,
form: {
type: {
selected: '',
},
status: {
selected: '',
},
type: [],
status: [],
title: null,
},
isDeleteModalOpen: false,
loading: false,
lat: null,
lng: null,
map: null,
......@@ -181,6 +202,10 @@ export default {
pageNumbers() {
return this.createPagesArray(this.featuresCount, this.pagination.pagesize);
},
featuresCountDisplay() {
return this.showMap ? this.featuresWithGeomCount : this.featuresCount;
}
},
......@@ -201,16 +226,15 @@ export default {
Promise.all([
this.$store.dispatch('projects/GET_PROJECT', this.projectSlug),
this.$store.dispatch('projects/GET_PROJECT_INFO', this.projectSlug)
])
.then(()=> this.initMap());
]).then(()=> this.initPage());
} else {
this.initMap();
this.initPage();
}
},
destroyed() {
//* allow user to change page if ever stuck on loader
this.$store.commit('DISCARD_LOADER');
this.loading = false;
},
methods: {
......@@ -227,92 +251,226 @@ export default {
setShowMap(newValue) {
this.showMap = newValue;
//* expanded sidebar is visible under the list, even when the map is closed (position:absolute), solved by closing it whin switching to list
// expanded sidebar is visible under the list, even when the map is closed (position:absolute), solved by closing it when switching to list
if (newValue === false && this.$refs.sidebar) this.$refs.sidebar.toggleSidebar(false);
},
resetPagination() {
this.pagination = { ...initialPagination };
},
/**
* Updates the filters based on the provided key-value pair.
*
* @param {Object} e - The key-value pair representing the filter to update.
*/
setFilters(e) {
const filter = Object.keys(e)[0];
const value = Object.values(e)[0];
if (filter === 'title') {
this.form[filter] = value;
} else {
this.form[filter].selected = value;
let value = Object.values(e)[0];
if (value && Array.isArray(value)) {
value = value.map(el => el.value);
}
this.form[filter] = value;
},
toggleDeleteModal() {
this.isDeleteModalOpen = !this.isDeleteModalOpen;
},
/**
* Modifie le statut des objets sélectionnés.
*
* Cette méthode prend en charge deux cas :
* 1. Si tous les objets sont sélectionnés (`allSelected`), une requête unique en mode "bulk update" est envoyée
* au backend pour modifier le statut de tous les objets correspondant aux critères.
* 2. Si des objets spécifiques sont sélectionnés (`checkedFeatures`), ils sont traités un par un de manière
* récursive. Chaque objet modifié est retiré de la liste des objets sélectionnés.
*
* En cas d'erreur (réseau ou backend), un message d'erreur est affiché, et les données sont rafraîchies.
* Si tous les objets sont modifiés avec succès, un message de confirmation est affiché.
*
* @param {string} newStatus - Le nouveau statut à appliquer aux objets sélectionnés.
* @returns {Promise<void>} - Une promesse qui se résout lorsque tous les objets ont été traités.
*/
async modifyStatus(newStatus) {
if (this.checkedFeatures.length > 0) {
const feature_id = this.checkedFeatures[0];
const feature = this.clickedFeatures.find((el) => el.feature_id === feature_id);
if (this.allSelected) {
// Cas : Modification en masse de tous les objets
try {
// Update additional query parameters based on the current filter states.
this.updateQueryParams();
const queryString = new URLSearchParams(this.queryparams).toString();
const response = await featureAPI.projectFeatureBulkUpdateStatus(this.projectSlug, queryString, newStatus);
if (response && response.data) {
// Affiche un message basé sur la réponse du backend
this.DISPLAY_MESSAGE({
comment: response.data.message,
level: response.data.level,
});
}
} catch (error) {
// Gère les erreurs de type Axios (400, 500, etc.)
if (error.response && error.response.data) {
this.DISPLAY_MESSAGE({
comment: error.response.data.error || 'Une erreur est survenue.',
level: 'negative',
});
} else {
// Gère les erreurs réseau ou autres
this.DISPLAY_MESSAGE({
comment: 'Impossible de communiquer avec le serveur.',
level: 'negative',
});
}
}
// Rafraîchit les données après un traitement global
this.resetPagination();
this.fetchPagedFeatures();
} else if (this.checkedFeatures.length > 0) {
// Cas : Traitement des objets un par un
const feature_id = this.checkedFeatures[0]; // Récupère l'ID du premier objet sélectionné
const feature = this.clickedFeatures.find((el) => el.feature_id === feature_id); // Trouve l'objet complet
if (feature) {
featureAPI.updateFeature({
// Envoie une requête pour modifier le statut d'un objet spécifique
const response = await featureAPI.updateFeature({
feature_id,
feature_type__slug: feature.feature_type,
project__slug: this.projectSlug,
newStatus
}).then((response) => {
if (response && response.data && response.status === 200) {
const newCheckedFeatures = [...this.checkedFeatures];
newCheckedFeatures.splice(this.checkedFeatures.indexOf(response.data.id), 1);
this.UPDATE_CHECKED_FEATURES(newCheckedFeatures);
this.modifyStatus(newStatus);
} else {
this.DISPLAY_MESSAGE({
comment: `Le signalement ${feature.title} n'a pas pu être modifié`,
level: 'negative'
});
this.fetchPagedFeatures();
}
newStatus,
});
if (response && response.data && response.status === 200) {
// Supprime l'objet traité de la liste des objets sélectionnés
const newCheckedFeatures = [...this.checkedFeatures];
newCheckedFeatures.splice(this.checkedFeatures.indexOf(response.data.id), 1);
this.UPDATE_CHECKED_FEATURES(newCheckedFeatures);
// Rappel récursif pour traiter l'objet suivant
this.modifyStatus(newStatus);
} else {
// Affiche un message d'erreur si la modification échoue
this.DISPLAY_MESSAGE({
comment: `Le signalement ${feature.title} n'a pas pu être modifié.`,
level: 'negative',
});
// Rafraîchit les données en cas d'erreur
this.fetchPagedFeatures();
}
}
} else {
this.fetchPagedFeatures();
// Cas : Tous les objets ont été traités après le traitement récursif
this.fetchPagedFeatures(); // Rafraîchit les données pour afficher les mises à jour
this.DISPLAY_MESSAGE({
comment: 'Tous les signalements ont été modifié avec succès.',
level: 'positive'
comment: 'Tous les signalements ont été modifiés avec succès.',
level: 'positive',
});
}
},
deleteAllFeatureSelection() {
const initialFeaturesCount = this.featuresCount;
const initialCurrentPage = this.pagination.currentPage;
const promises = this.checkedFeatures.map(
(feature_id) => this.DELETE_FEATURE({ feature_id, noFeatureType: true })
);
Promise.all(promises).then((response) => {
const deletedFeaturesCount = response.reduce((acc, curr) => curr.status === 204 ? acc += 1 : acc, 0);
const newFeaturesCount = initialFeaturesCount - deletedFeaturesCount;
const newPagesArray = this.createPagesArray(newFeaturesCount, this.pagination.pagesize);
const newLastPageNum = newPagesArray[newPagesArray.length - 1];
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', []);
if (initialCurrentPage > newLastPageNum) { //* if page doesn't exist anymore
this.toPage(newLastPageNum); //* go to new last page
} else {
this.fetchPagedFeatures();
/**
* Supprime tous les objets sélectionnés.
*
* Cette méthode prend en charge deux cas :
* 1. Si tous les objets sont sélectionnés (`allSelected`), une requête unique en mode "bulk delete" est envoyée
* au backend pour supprimer tous les objets correspondant aux critères. La liste des résultats est ensuite rafraichie.
* 2. Si des objets spécifiques sont sélectionnés (`checkedFeatures`), ils sont traités un par un de manière
* récursive. Cette méthode utilise `Promise.all` pour envoyer les requêtes de suppression en parallèle
* pour tous les objets dans la liste `checkedFeatures`. Après suppression, elle met à jour la pagination
* et rafraîchit les objets affichés pour refléter les changements.
*
* En cas d'erreur (réseau ou backend), un message d'erreur est affiché, et les données sont rafraîchies.
* Si tous les objets sont supprimé avec succès, un message de confirmation est affiché.
*
* @returns {Promise<void>} - Une promesse qui se résout lorsque tous les objets ont été traités.
*/
async deleteAllFeatureSelection() {
if (this.allSelected) {
// Cas : Suppression en masse de tous les objets
try {
// Update additional query parameters based on the current filter states.
this.updateQueryParams();
const queryString = new URLSearchParams(this.queryparams).toString();
const response = await featureAPI.projectFeatureBulkDelete(this.projectSlug, queryString);
if (response && response.data) {
// Affiche un message basé sur la réponse du backend
this.DISPLAY_MESSAGE({
comment: response.data.message,
level: response.data.level,
});
}
} catch (error) {
// Gère les erreurs de type Axios (400, 500, etc.)
if (error.response && error.response.data) {
this.DISPLAY_MESSAGE({
comment: error.response.data.error || 'Une erreur est survenue.',
level: 'negative',
});
} else {
// Gère les erreurs réseau ou autres
this.DISPLAY_MESSAGE({
comment: 'Impossible de communiquer avec le serveur.',
level: 'negative',
});
}
}
})
.catch((err) => console.error(err));
// Rafraîchit les données après un traitement global
this.resetPagination();
this.fetchPagedFeatures();
} else {
// Sauvegarde le nombre total d'objets
const initialFeaturesCount = this.featuresCount;
// Sauvegarde la page actuelle
const initialCurrentPage = this.pagination.currentPage;
// Crée une liste de promesses pour supprimer chaque objet sélectionné
const promises = this.checkedFeatures.map((feature_id) =>
this.DELETE_FEATURE({ feature_id, noFeatureType: true })
);
// Exécute toutes les suppressions en parallèle
Promise.all(promises)
.then((response) => {
// Compte le nombre d'objets supprimés avec succès
const deletedFeaturesCount = response.reduce(
(acc, curr) => (curr.status === 204 ? acc + 1 : acc),
0
);
// Calcule le nouveau total d'objets
const newFeaturesCount = initialFeaturesCount - deletedFeaturesCount;
// Recalcule les pages
const newPagesArray = this.createPagesArray(newFeaturesCount, this.pagination.pagesize);
// Dernière page valide
const newLastPageNum = newPagesArray[newPagesArray.length - 1];
// Réinitialise la sélection
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', []);
if (initialCurrentPage > newLastPageNum) {
// Navigue à la dernière page valide si la page actuelle n'existe plus
this.toPage(newLastPageNum);
} else {
// Rafraîchit les objets affichés
this.fetchPagedFeatures();
}
})
// Gère les erreurs éventuelles
.catch((err) => console.error(err));
}
// Ferme la modale de confirmation de suppression
this.toggleDeleteModal();
},
modifyFeaturesAttributes() {
console.log('modifyFeaturesAttributes');
},
onFilterChange() {
if (mapService.getMap() && mapService.mvtLayer) {
mapService.mvtLayer.changed();
}
},
initPage() {
this.sort = {
column: this.project.feature_browsing_default_sort.replace('-', ''),
ascending: this.project.feature_browsing_default_sort.includes('-')
};
this.initMap();
},
initMap() {
this.zoom = this.$route.query.zoom || '';
this.lat = this.$route.query.lat || '';
......@@ -330,35 +488,28 @@ export default {
mapDefaultViewCenter,
mapDefaultViewZoom,
maxZoom: this.project.map_max_zoom_level,
interactions : { doubleClickZoom :false,mouseWheelZoom:true,dragPan:true }
interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true },
fullScreenControl: true,
geolocationControl: true,
});
//this.fetchBboxNfit(); cette methode est appelée a nouveau par la suite donc pas utile ici
document.addEventListener('change-layers-order', (event) => {
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
mapService.updateOrder(event.detail.layers.slice().reverse());
});
// --------- End sidebar events ----------
setTimeout(() => {
const project_id = this.projectSlug.split('-')[0];
this.$nextTick(() => {
const mvtUrl = `${this.API_BASE_URL}features.mvt`;
mapService.addVectorTileLayer(
mvtUrl,
project_id,
this.feature_types,
this.form
);
}, 1000);
mapService.addVectorTileLayer({
url: mvtUrl,
project_slug: this.projectSlug,
featureTypes: this.feature_types,
formFilters: this.form,
queryParams: this.queryparams,
});
});
this.fetchPagedFeatures();
},
fetchBboxNfit(queryParams) {
fetchBboxNfit(queryString) {
featureAPI
.getFeaturesBbox(this.projectSlug, queryParams)
.getFeaturesBbox(this.projectSlug, queryString)
.then((bbox) => {
if (bbox) {
mapService.fitBounds(bbox);
......@@ -382,66 +533,74 @@ export default {
return result;
},
buildQueryString() {
let queryString = '';
const typeFilter = this.getFeatureTypeSlug(this.form.type.selected);
const statusFilter = this.form.status.selected.value;
/**
* Updates the query parameters based on the current state of the pagination and form filters.
* This function sets various parameters like offset, feature_type_slug, status__value, title,
* and ordering to be used in an API request and to filter hidden features on mvt tiles.
*/
updateQueryParams() {
// empty queryparams to remove params when removed from the form
this.queryparams = {};
// Update the 'offset' parameter based on the current pagination start value.
this.queryparams['offset'] = this.pagination.start;
if (typeFilter) {
this.queryparams['feature_type_slug'] = typeFilter;
queryString += `&feature_type_slug=${typeFilter}`;
// Set 'feature_type_slug' if a type is selected in the form.
if (this.form.type.length > 0) {
this.queryparams['feature_type_slug'] = this.form.type;
}
if (statusFilter) {
this.queryparams['status__value'] = statusFilter;
queryString += `&status__value=${statusFilter}`;
// Set 'status__value' if a status is selected in the form.
if (this.form.status.length > 0) {
this.queryparams['status__value'] = this.form.status;
}
// Set 'title' if a title is entered in the form.
if (this.form.title) {
this.queryparams['title'] = this.form.title;
queryString += `&title=${this.form.title}`;
}
if (this.sort.column) {
let ordering = `${this.sort.ascending ? '-' : ''}${this.getAvalaibleField(this.sort.column)}`;
this.queryparams['ordering'] = ordering;
queryString += `&ordering=${ordering}`;
}
return queryString;
// Update the 'ordering' parameter based on the current sorting state.
// Prepends a '-' for descending order if sort.ascending is false.
this.queryparams['ordering'] = `${this.sort.ascending ? '-' : ''}${this.getAvalaibleField(this.sort.column)}`;
},
fetchPagedFeatures(newUrl) {
if (!navigator.onLine) {
/**
* Fetches paginated feature data from the API.
* This function is called to retrieve a specific page of features based on the current pagination settings and any applied filters.
* If the application is offline, it displays a message and does not proceed with the API call.
*/
fetchPagedFeatures() {
// Check if the application is online; if not, display a message and return.
if (!this.isOnline) {
this.DISPLAY_MESSAGE({
comment: 'Les signalements du projet non mis en cache ne sont pas accessibles en mode déconnecté',
});
return;
}
let url = `${this.API_BASE_URL}projects/${this.projectSlug}/feature-paginated/?limit=${this.pagination.pagesize}&offset=${this.pagination.start}`;
//* if receiving next & previous url (// todo : might be not used anymore, to check)
if (newUrl && typeof newUrl === 'string') {
//newUrl = newUrl.replace("8000", "8010"); //* for dev uncomment when using proxy link
url = newUrl;
}
const queryString = this.buildQueryString();
url += queryString;
this.$store.commit(
'DISPLAY_LOADER',
'Récupération des signalements en cours...'
);
// Display a loading message.
this.loading = true;
// Update additional query parameters based on the current filter states.
this.updateQueryParams();
const queryString = new URLSearchParams(this.queryparams).toString();
// Construct the base URL with query parameters.
const url = `${this.API_BASE_URL}projects/${this.projectSlug}/feature-paginated/?limit=${this.pagination.pagesize}&${queryString}`;
// Make an API call to get the paginated features.
featureAPI.getPaginatedFeatures(url)
.then((data) => {
if (data) {
// Update the component state with the data received from the API.
this.featuresCount = data.count;
this.featuresWithGeomCount = data.geom_count;
this.previous = data.previous;
this.next = data.next;
this.paginatedFeatures = data.results;
}
//* bbox needs to be updated with the same filters
// If there are features, update the bounding box.
if (this.paginatedFeatures.length) {
this.fetchBboxNfit(queryString);
}
this.onFilterChange(); //* use paginated event to watch change in filters and modify features on map
this.$store.commit('DISCARD_LOADER');
// Trigger actions on filter change.
this.onFilterChange();
// Hide the loading message.
this.loading = false;
});
},
......@@ -470,10 +629,15 @@ export default {
handleSortChange(sort) {
this.sort = sort;
this.fetchPagedFeatures({
filterType: undefined,
filterValue: undefined,
});
this.fetchPagedFeatures();
},
handleAllSelectedChange(isChecked) {
this.allSelected = isChecked;
// Si des sélections existent, tout déselectionner
if (this.checkedFeatures.length > 0) {
this.UPDATE_CHECKED_FEATURES([]);
}
},
toPage(pageNumber) {
......@@ -512,6 +676,14 @@ export default {
<style lang="less" scoped>
.loader-container {
position: relative;
min-height: 250px; // keep a the spinner above result and below table header
z-index: 1;
.ui.inverted.dimmer.active {
opacity: .6;
}
}
.map-container {
width: 80vw;
transform: translateX(-50%);
......@@ -544,6 +716,11 @@ export default {
z-index: 1;
}
}
div.geolocation-container {
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(1.3em + 60px + 34px);
}
@media screen and (max-width: 767px) {
#project-features {
......@@ -554,5 +731,37 @@ export default {
position: relative;
}
}
.fadeIn-enter-active {
animation: fadeIn .5s;
}
.fadeIn-leave-active {
animation: fadeIn .5s reverse;
}
.transition.fade.in {
-webkit-animation-name: fadeIn;
animation-name: fadeIn
}
@-webkit-keyframes fadeIn {
0% {
opacity: 0
}
100% {
opacity: .9
}
}
@keyframes fadeIn {
0% {
opacity: 0
}
100% {
opacity: .9
}
}
</style>