<template> <div v-frag> <div class="fourteen wide column"> <h1 v-if="feature && currentRouteName === 'editer-signalement'"> Mise à jour du signalement "{{ feature.title || feature.feature_id }}" </h1> <h1 v-else-if="feature_type && currentRouteName === 'ajouter-signalement'" > Création d'un signalement <small>[{{ feature_type.title }}]</small> </h1> <form id="form-feature-edit" action="" method="post" enctype="multipart/form-data" class="ui form" > <!-- Feature Fields --> <div class="two fields"> <div :class="field_title"> <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 :maxlength="form.title.field.max_length" :name="form.title.html_name" @blur="updateStore" > <ul id="errorlist-title" class="errorlist" > <li v-for="error in form.title.errors" :key="error" > {{ error }} </li> </ul> </div> <div class="required field"> <label :for="form.status.id_for_label">{{ form.status.label }}</label> <Dropdown :options="allowedStatusChoices" :selected="selected_status.name" :selection.sync="selected_status" /> </div> </div> <div class="field"> <label :for="form.description.id_for_label">{{ form.description.label }}</label> <textarea v-model="form.description.value" :name="form.description.html_name" rows="5" @blur="updateStore" /> </div> <!-- Geom Field --> <div class="field"> <label :for="form.geom.id_for_label">{{ form.geom.label }}</label> <!-- Import GeoImage --> <div v-if="feature_type && feature_type.geom_type === 'point'" v-frag > <p v-if="isOffline() !== true"> <button id="add-geo-image" type="button" class="ui compact button" @click="toggleGeoRefModal" > <i class="file image icon" />Importer une image géoréférencée </button> Vous pouvez utiliser une image géoréférencée pour localiser le signalement. </p> <div v-if="showGeoRef" class="ui dimmer modals page transition visible active" style="display: flex !important" > <div class="ui mini modal transition visible active" style="display: block !important" > <i class="close icon" @click="toggleGeoRefModal" /> <div class="content"> <h3>Importer une image géoréférencée</h3> <form id="form-geo-image" class="ui form" enctype="multipart/form-data" > <p> Attention, si vous avez déjà saisi une géométrie, celle issue de l'image importée l'écrasera. </p> <div class="field georef-btn"> <label>Image (png ou jpeg)</label> <label class="ui icon button" for="image_file" > <i class="file icon" /> <span class="label">{{ geoRefFileLabel }}</span> </label> <input id="image_file" ref="file" type="file" accept="image/jpeg, image/png" style="display: none" name="image_file" class="image_file" @change="handleFileUpload" > <ul v-if="erreurUploadMessage" class="errorlist" > <li> {{ erreurUploadMessage }} </li> </ul> </div> <button id="get-geom-from-image-file" type="button" :class="[ 'ui compact button', file && !erreurUploadMessage ? 'green' : 'disabled', { red: erreurUploadMessage }, ]" @click="georeferencement" > <i class="plus icon" /> Importer </button> </form> </div> </div> </div> <p v-if="showGeoPositionBtn"> <button id="create-point-geoposition" type="button" class="ui compact button" @click="create_point_geoposition" > <i class="ui map marker alternate icon" />Positionner le signalement à partir de votre géolocalisation </button> </p> <span v-if="erreurGeolocalisationMessage" id="erreur-geolocalisation" > <div class="ui negative message"> <div class="header"> Une erreur est survenue avec la fonctionnalité de géolocalisation </div> <p id="erreur-geolocalisation-message"> {{ erreurGeolocalisationMessage }} </p> </div> <br> </span> </div> <ul id="errorlist-geom" class="errorlist" > <li v-for="error in form.geom.errors" :key="error" > {{ error }} </li> </ul> <!-- Map --> <input :id="form.geom.id_for_label" v-model="form.geom.value" type="hidden" :name="form.geom.html_name" @blur="updateStore" > <div class="ui tab active map-container" data-tab="map" > <div id="map" ref="map" /> <SidebarLayers v-if="basemaps && map" /> </div> </div> <!-- Extra Fields --> <div class="ui horizontal divider"> DONNÉES MÉTIER </div> <div v-for="(field, index) in orderedCustomFields" :key="field.field_type + index" class="field" > <FeatureExtraForm :field="field" /> {{ field.errors }} </div> <!-- Pièces jointes --> <div v-if="isOffline() !== true"> <div class="ui horizontal divider"> PIÈCES JOINTES </div> <div v-if="isOffline() !== true" id="formsets-attachment" > <FeatureAttachmentForm v-for="attachForm in attachmentFormset" :key="attachForm.dataKey" ref="attachementForm" :attachment-form="attachForm" /> </div> <button id="add-attachment" type="button" class="ui compact basic button" @click="add_attachement_formset" > <i class="ui plus icon" />Ajouter une pièce jointe </button> </div> <!-- Signalements liés --> <div v-if="isOffline() !== true"> <div class="ui horizontal divider"> SIGNALEMENTS LIÉS </div> <div id="formsets-link"> <FeatureLinkedForm v-for="linkForm in linkedFormset" :key="linkForm.dataKey" ref="linkedForm" :linked-form="linkForm" /> </div> <button id="add-link" type="button" class="ui compact basic button" @click="add_linked_formset" > <i class="ui plus icon" />Ajouter une liaison </button> </div> <div class="ui divider" /> <button type="button" class="ui teal icon button" @click="postForm" > <i class="white save icon" /> Enregistrer les changements </button> </form> </div> </div> </template> <script> import frag from 'vue-frag'; import { mapState, mapGetters } from 'vuex'; import FeatureAttachmentForm from '@/components/feature/FeatureAttachmentForm'; import FeatureLinkedForm from '@/components/feature/FeatureLinkedForm'; import FeatureExtraForm from '@/components/feature/FeatureExtraForm'; import Dropdown from '@/components/Dropdown.vue'; import SidebarLayers from '@/components/map-layers/SidebarLayers'; import featureAPI from '@/services/feature-api'; import L from 'leaflet'; import 'leaflet-draw'; import { mapUtil } from '@/assets/js/map-util.js'; import { allowedStatus2change } from '@/utils'; import axios from '@/axios-client.js'; import flip from '@turf/flip'; export default { name: 'FeatureEdit', directives: { frag, }, components: { FeatureAttachmentForm, FeatureLinkedForm, Dropdown, SidebarLayers, FeatureExtraForm, }, data() { return { map: null, baseUrl: this.$store.state.configuration.BASE_URL, file: null, showGeoRef: false, showGeoPositionBtn: true, erreurGeolocalisationMessage: null, erreurUploadMessage: null, attachmentDataKey: 0, linkedDataKey: 0, form: { title: { errors: [], id_for_label: 'name', field: { max_length: 30, }, html_name: 'name', label: 'Nom', value: '', }, status: { id_for_label: 'status', html_name: 'status', label: 'Statut', value: { value: 'draft', name: 'Brouillon', }, }, description: { errors: [], id_for_label: 'description', html_name: 'description', label: 'Description', value: '', }, geom: { errors: [], label: 'Localisation', value: null, }, }, }; }, computed: { ...mapGetters(['permissions']), ...mapGetters('feature_type', ['feature_type']), ...mapState(['user', 'USER_LEVEL_PROJECTS']), ...mapState('projects', ['project']), ...mapState('map', ['basemaps']), ...mapState('feature', [ 'attachmentFormset', 'linkedFormset', 'features', 'extra_form', 'statusChoices', ]), field_title() { if (this.feature_type) { if (this.feature_type.title_optional) { return 'field'; } } return 'required field'; }, currentRouteName() { return this.$route.name; }, feature() { return this.$store.state.feature.current_feature; }, orderedCustomFields() { return [...this.extra_form].sort((a, b) => a.position - b.position); }, geoRefFileLabel() { if (this.file) { return this.file.name; } return 'Sélectionner une image ...'; }, selected_status: { get() { return this.form.status.value; }, set(newValue) { this.form.status.value = newValue; this.updateStore(); }, }, allowedStatusChoices() { if (this.project && this.feature && 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.statusChoices, isModerate, userStatus, isOwnFeature, this.currentRouteName); } return []; }, }, created() { this.$store.commit( 'feature_type/SET_CURRENT_FEATURE_TYPE_SLUG', this.$route.params.slug_type_signal ); //* empty previous feature data, not emptying by itself since it doesn't update by itself anymore if (this.currentRouteName === 'ajouter-signalement') { this.$store.commit('feature/SET_CURRENT_FEATURE', []); } if (this.$route.params.slug_signal) { this.getFeatureAttachments(); this.getLinkedFeatures(); } }, mounted() { let promises = [ this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug), this.$store.dispatch('projects/GET_PROJECT_INFO', this.$route.params.slug), ]; if (this.$route.params.slug_signal) { promises.push( this.$store.dispatch('feature/GET_PROJECT_FEATURE', { project_slug: this.$route.params.slug, feature_id: this.$route.params.slug_signal, }) ); } Promise.all(promises).then(() => { this.initForm(); this.initMap(); this.onFeatureTypeLoaded(); this.initExtraForms(this.feature); setTimeout( function () { mapUtil.addGeocoders(this.$store.state.configuration); }.bind(this), 1000 ); }); }, destroyed() { //* be sure that previous Formset have been cleared for creation this.$store.commit('feature/CLEAR_ATTACHMENT_FORM'); this.$store.commit('feature/CLEAR_LINKED_FORM'); this.$store.commit('feature/CLEAR_EXTRA_FORM'); }, methods: { isOffline() { return navigator.onLine == false; }, initForm() { if (this.currentRouteName === 'editer-signalement') { for (let key in this.feature) { if (key && this.form[key]) { if (key === 'status') { const value = this.feature[key]; this.form[key].value = this.statusChoices.find( (key) => key.value === value ); } else { this.form[key].value = this.feature[key]; } } } this.updateStore(); } }, create_point_geoposition() { function success(position) { const latitude = position.coords.latitude; const longitude = position.coords.longitude; var layer = L.circleMarker([latitude, longitude]); this.add_layer_call_back(layer); this.map.setView([latitude, longitude]); } function error(err) { this.erreurGeolocalisationMessage = err.message; if (err.message === 'User denied geolocation prompt') { this.erreurGeolocalisationMessage = "La géolocalisation a été désactivée par l'utilisateur"; } } this.erreurGeolocalisationMessage = null; if (!navigator.geolocation) { this.erreurGeolocalisationMessage = "La géolocalisation n'est pas supportée par votre navigateur."; } else { navigator.geolocation.getCurrentPosition( success.bind(this), error.bind(this) ); } }, toggleGeoRefModal() { if (this.showGeoRef) { //* when popup closes, empty form this.erreurUploadMessage = ''; this.file = null; } this.showGeoRef = !this.showGeoRef; }, handleFileUpload() { this.erreurUploadMessage = ''; this.file = this.$refs.file.files[0]; }, georeferencement() { const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}exif-geom-reader/`; let formData = new FormData(); formData.append('image_file', this.file); axios .post(url, formData, { headers: { 'Content-Type': 'multipart/form-data', }, }) .then((response) => { if (response.data.geom.indexOf('POINT') >= 0) { let regexp = /POINT\s\((.*)\s(.*)\)/; let arr = regexp.exec(response.data.geom); let json = { type: 'Feature', geometry: { type: 'Point', coordinates: [parseFloat(arr[1]), parseFloat(arr[2])], }, properties: {}, }; this.updateMap(json); this.updateGeomField(json); // Set Attachment this.addAttachment({ title: 'Localisation', info: '', id: 'loc', attachment_file: this.file.name, fileToImport: this.file, }); } }) .catch((error) => { console.error({ error }); if (error && error.response && error.response) { this.erreurUploadMessage = error.response.data.error; } else { this.erreurUploadMessage = "Une erreur est survenue pendant l'import de l'image géoréférencée"; } }); }, initExtraForms(feature) { function findCurrentValue(label) { const field = feature.feature_data.find((el) => el.label === label); return field ? field.value : null; } let extraForm = this.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, }; }); this.$store.commit('feature/SET_EXTRA_FORM', extraForm); }, add_attachement_formset() { this.$store.commit('feature/ADD_ATTACHMENT_FORM', { dataKey: this.attachmentDataKey, }); // * create an object with the counter in store this.attachmentDataKey += 1; // * increment counter for key in v-for }, 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, }); this.attachmentDataKey += 1; }, addExistingAttachementFormset(attachementFormset) { for (const attachment of attachementFormset) { this.addAttachment(attachment); } }, add_linked_formset() { this.$store.commit('feature/ADD_LINKED_FORM', { dataKey: this.linkedDataKey, }); // * create an object with the counter in store this.linkedDataKey += 1; // * increment counter for key in v-for }, addExistingLinkedFormset(linkedFormset) { for (const linked of linkedFormset) { this.$store.commit('feature/ADD_LINKED_FORM', { dataKey: this.linkedDataKey, relation_type: linked.relation_type, feature_to: linked.feature_to, }); this.linkedDataKey += 1; } }, updateStore() { this.$store.commit('feature/UPDATE_FORM', { title: this.form.title.value, status: this.form.status.value, description: this.form.description, geometry: this.form.geom.value, feature_id: this.feature ? this.feature.feature_id : '', }); }, checkFormTitle() { if (this.form.title.value) { this.form.title.errors = []; return true; } else if ( !this.form.title.errors.includes('Veuillez compléter ce champ.') ) { this.form.title.errors.push('Veuillez compléter ce champ.'); document .getElementById('errorlist-title') .scrollIntoView({ block: 'end', inline: 'nearest' }); } return false; }, checkFormGeom() { if (this.form.geom.value) { this.form.geom.errors = []; return true; } else if ( !this.form.geom.errors.includes('Valeur géométrique non valide.') ) { this.form.geom.errors.push('Valeur géométrique non valide.'); document .getElementById('errorlist-geom') .scrollIntoView({ block: 'end', inline: 'nearest' }); } return false; }, checkAddedForm() { let isValid = true; //* fallback if all customForms returned true if (this.$refs.attachementForm) { for (const attachementForm of this.$refs.attachementForm) { if (attachementForm.checkForm() === false) { isValid = false; } } } if (this.$refs.linkedForm) { for (const linkedForm of this.$refs.linkedForm) { if (linkedForm.checkForm() === false) { isValid = false; } } } return isValid; }, postForm() { let is_valid = true; if (!this.feature_type.title_optional) { is_valid = this.checkFormTitle() && this.checkFormGeom() && this.checkAddedForm(); } else { is_valid = this.checkFormGeom() && this.checkAddedForm(); } 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. if ( this.project.moderation && this.currentRouteName === 'editer-signalement' && this.form.status.value.value === 'published' && !this.permissions.is_project_administrator && !this.permissions.is_project_moderator ) { this.form.status.value = { name: 'Brouillon', value: 'draft' }; this.updateStore(); } this.$store.dispatch('feature/SEND_FEATURE', this.currentRouteName); } }, //* ************* MAP *************** *// onFeatureTypeLoaded() { var geomLeaflet = { point: 'circlemarker', linestring: 'polyline', polygon: 'polygon', }; var geomType = this.feature_type.geom_type; var drawConfig = { polygon: false, marker: false, polyline: false, rectangle: false, circle: false, circlemarker: false, }; drawConfig[geomLeaflet[geomType]] = true; L.drawLocal = { draw: { toolbar: { actions: { title: 'Annuler le dessin', text: 'Annuler', }, finish: { title: 'Terminer le dessin', text: 'Terminer', }, undo: { title: 'Supprimer le dernier point dessiné', text: 'Supprimer le dernier point', }, buttons: { polyline: 'Dessiner une polyligne', polygon: 'Dessiner un polygone', rectangle: 'Dessiner un rectangle', circle: 'Dessiner un cercle', marker: 'Dessiner une balise', circlemarker: 'Dessiner un point', }, }, handlers: { circle: { tooltip: { start: 'Cliquer et glisser pour dessiner le cercle.', }, radius: 'Rayon', }, circlemarker: { tooltip: { start: 'Cliquer sur la carte pour placer le point.', }, }, marker: { tooltip: { start: 'Cliquer sur la carte pour placer la balise.', }, }, polygon: { tooltip: { start: 'Cliquer pour commencer à dessiner.', cont: 'Cliquer pour continuer à dessiner.', end: 'Cliquer sur le premier point pour terminer le dessin.', }, }, polyline: { error: '<strong>Error:</strong> shape edges cannot cross!', tooltip: { start: 'Cliquer pour commencer à dessiner.', cont: 'Cliquer pour continuer à dessiner.', end: 'Cliquer sur le dernier point pour terminer le dessin.', }, }, rectangle: { tooltip: { start: 'Cliquer et glisser pour dessiner le rectangle.', }, }, simpleshape: { tooltip: { end: 'Relâcher la souris pour terminer de dessiner.', }, }, }, }, edit: { toolbar: { actions: { save: { title: 'Sauver les modifications', text: 'Sauver', }, cancel: { title: 'Annuler la modification, annule toutes les modifications', text: 'Annuler', }, clearAll: { title: "Effacer l'objet", text: 'Effacer', }, }, buttons: { edit: "Modifier l'objet", editDisabled: 'Aucun objet à modifier', remove: "Supprimer l'objet", removeDisabled: 'Aucun objet à supprimer', }, }, handlers: { edit: { tooltip: { text: "Faites glisser les marqueurs ou les balises pour modifier l'élément.", subtext: 'Cliquez sur Annuler pour annuler les modifications..', }, }, remove: { tooltip: { text: 'Cliquez sur un élément pour le supprimer.', }, }, }, }, }; this.drawnItems = new L.FeatureGroup(); this.map.addLayer(this.drawnItems); this.drawControlFull = new L.Control.Draw({ position: 'topright', edit: { featureGroup: this.drawnItems, }, draw: drawConfig, }); this.drawControlEditOnly = new L.Control.Draw({ position: 'topright', edit: { featureGroup: this.drawnItems, }, draw: false, }); if (this.currentRouteName === 'editer-signalement') { this.map.addControl(this.drawControlEditOnly); } else { this.map.addControl(this.drawControlFull); } this.changeMobileBtnOrder(); this.map.on( 'draw:created', function (e) { var layer = e.layer; this.add_layer_call_back(layer); }.bind(this) ); //var wellknown;// TODO Remplacer par autre chose this.map.on( 'draw:edited', function (e) { var layers = e.layers; let self = this; layers.eachLayer(function (layer) { self.updateGeomField(layer.toGeoJSON()); }); }.bind(this) ); this.map.on( 'draw:deleted', function () { this.drawControlEditOnly.remove(this.map); this.drawControlFull.addTo(this.map); this.updateGeomField(''); if (geomType === 'point') { this.showGeoPositionBtn = true; this.erreurGeolocalisationMessage = ''; } }.bind(this) ); }, updateMap(geomFeatureJSON) { if (this.drawnItems) this.drawnItems.clearLayers(); var geomType = this.feature_type.geom_type; if (geomFeatureJSON) { var geomJSON = flip(geomFeatureJSON.geometry); if (geomType === 'point') { L.circleMarker(geomJSON.coordinates).addTo(this.drawnItems); } else if (geomType === 'linestring') { L.polyline(geomJSON.coordinates).addTo(this.drawnItems); } else if (geomType === 'polygon') { L.polygon(geomJSON.coordinates).addTo(this.drawnItems); } this.map.fitBounds(this.drawnItems.getBounds(), { padding: [25, 25] }); } else { this.map.setView( this.$store.state.configuration.DEFAULT_MAP_VIEW.center, this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom ); } }, updateGeomField(newGeom) { this.form.geom.value = newGeom.geometry; this.updateStore(); }, initMap() { var mapDefaultViewCenter = this.$store.state.configuration.DEFAULT_MAP_VIEW.center; var mapDefaultViewZoom = this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom; // Create the map, then init the layers and features this.map = mapUtil.createMap(this.$refs.map, { mapDefaultViewCenter, mapDefaultViewZoom, }); const currentFeatureId = this.$route.params.slug_signal; setTimeout(() => { let project_id = this.$route.params.slug.split('-')[0]; const mvtUrl = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`; mapUtil.addVectorTileLayer( mvtUrl, this.$route.params.slug, this.$store.state.feature_type.feature_types ); }, 1000); 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`; axios .get(url) .then((response) => { const features = response.data.features; if (features) { const allFeaturesExceptCurrent = features.filter( (feat) => feat.id !== currentFeatureId ); mapUtil.addFeatures( allFeaturesExceptCurrent, {}, true, this.$store.state.feature_type.feature_types ); if (this.currentRouteName === 'editer-signalement') { const currentFeature = features.filter( (feat) => feat.id === currentFeatureId )[0]; this.updateMap(currentFeature); } } }) .catch((error) => { 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 mapUtil.updateOrder(event.detail.layers.slice().reverse()); }); }, add_layer_call_back(layer) { layer.addTo(this.drawnItems); this.drawControlFull.remove(this.map); this.drawControlEditOnly.addTo(this.map); //var wellknown;// TODO Remplacer par autre chose this.updateGeomField(layer.toGeoJSON()); if (this.feature_type.geomType === 'point') { this.showGeoPositionBtn = false; this.erreurGeolocalisationMessage = ''; } }, changeMobileBtnOrder() { //* move large toolbar for polygon creation, cutting map in the middle function changeDisplay() { let buttons = document.querySelector('.leaflet-draw-actions.leaflet-draw-actions-top.leaflet-draw-actions-bottom'); if (buttons && buttons.style) { buttons.style.display = 'flex'; buttons.style['flex-direction'] = 'column'; } } if (window.screen.availWidth < 767) { //* change button order all the time to keep homogeinity on mobile let wrapper = document.querySelector('.leaflet-top.leaflet-right'); if (wrapper) wrapper.appendChild(wrapper.children[0]); if (this.feature_type.geom_type === 'polygon') { //* if it's a polygon, change tools direction to vertical let polygonBtn = document.querySelector('.leaflet-draw-draw-polygon'); //* since elements are generated if (polygonBtn) polygonBtn.addEventListener('click', changeDisplay); //* it should be done at each click } } }, getFeatureAttachments() { featureAPI .getFeatureAttachments(this.$route.params.slug_signal) .then((data) => this.addExistingAttachementFormset(data)); }, getLinkedFeatures() { featureAPI .getFeatureLinks(this.$route.params.slug_signal) .then((data) => this.addExistingLinkedFormset(data)); }, }, }; </script> <style scoped> #map { height: 70vh; width: 100%; border: 1px solid grey; } #get-geom-from-image-file { margin-bottom: 5px; } .georef-btn { max-width: 400px; } @media only screen and (max-width: 767px) { #map { height: 80vh; } } /* // ! missing style in semantic.min.css */ .ui.right.floated.button { float: right; margin-right: 0; margin-left: 0.25em; } /* // ! margin écrasé par class last-child first-child */ .ui.segment { margin: 1rem 0 !important; } /* override to display buttons under the dimmer of modal */ .leaflet-top, .leaflet-bottom { z-index: 800; } </style>