<template> <div id="feature-edit"> <h1> <span v-if="feature_type && currentRouteName === 'ajouter-signalement'"> Création d'un signalement <small>[{{ feature_type.title }}]</small> </span> <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 </span> </h1> <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" > <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="infoslist-title" class="infoslist" > <li v-for="info in form.title.infos" :key="info" > {{ info }} </li> </ul> <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 v-if="currentRouteName !== 'editer-attribut-signalement'" 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 v-if="currentRouteName !== 'editer-attribut-signalement'" 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'" > <p v-if="isOnline"> <button id="add-geo-image" type="button" class="ui compact button" @click="toggleGeoRefModal" > <i class="file image icon" aria-hidden="true" />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" aria-hidden="true" @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" aria-hidden="true" /> <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" aria-hidden="true" /> 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" aria-hidden="true" /> 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 :class="{ active: mapLoading }" class="ui inverted dimmer" > <div class="ui loader" /> </div> <div id="map" ref="map" > <SidebarLayers v-if="basemaps && map" /> <EditingToolbar v-if="isEditable" :map="map" /> </div> <div id="popup" class="ol-popup" > <a id="popup-closer" href="#" class="ol-popup-closer" /> <div id="popup-content" /> </div> </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="extraform" > <FeatureExtraForm :id="field.label" ref="extraForm" :field="field" class="field" /> {{ field.errors }} </div> <!-- Pièces jointes --> <div v-if="isOnline && currentRouteName !== 'editer-attribut-signalement'"> <div class="ui horizontal divider"> PIÈCES JOINTES </div> <div v-if="isOnline" 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" aria-hidden="true" /> Ajouter une pièce jointe </button> </div> <!-- Signalements liés --> <div v-if="isOnline && currentRouteName !== 'editer-attribut-signalement'"> <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" aria-hidden="true" /> Ajouter une liaison </button> </div> <div class="ui divider" /> <button id="save-changes" type="button" :class="['ui teal icon button', { loading: sendingFeature }]" @click="onSave" > <i class="white save icon" aria-hidden="true" /> Enregistrer les changements </button> </form> </div> </template> <script> import { mapState, mapGetters } from 'vuex'; import FeatureAttachmentForm from '@/components/Feature/FeatureAttachmentForm'; import FeatureLinkedForm from '@/components/Feature/FeatureLinkedForm'; import FeatureExtraForm from '@/components/Feature/Edit/FeatureExtraForm'; 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 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 { name: 'FeatureEdit', components: { FeatureAttachmentForm, FeatureLinkedForm, Dropdown, SidebarLayers, EditingToolbar, FeatureExtraForm, }, data() { return { map: null, mapLoading: false, sendingFeature: false, baseUrl: this.$store.state.configuration.BASE_URL, file: null, showGeoRef: false, showGeoPositionBtn: true, erreurGeolocalisationMessage: null, erreurUploadMessage: null, attachmentDataKey: 0, linkedDataKey: 0, form: { title: { errors: [], infos: [], id_for_label: 'name', field: { max_length: 128, }, 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: { ...mapState([ 'user', 'USER_LEVEL_PROJECTS', 'isOnline' ]), ...mapState('projects', [ 'project' ]), ...mapState('map', [ 'basemaps' ]), ...mapState('feature', [ 'attachmentFormset', 'checkedFeatures', 'currentFeature', 'extra_forms', 'features', 'linkedFormset', ]), ...mapState('feature-type', [ 'feature_types' ]), ...mapGetters([ 'permissions' ]), ...mapGetters('feature-type', [ 'feature_type' ]), field_title() { if (this.feature_type) { if (this.feature_type.title_optional) { return 'field'; } } return 'required field'; }, currentRouteName() { return this.$route.name; }, orderedCustomFields() { return [...this.extra_forms].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.user) { const isModerate = this.project.moderation; const userStatus = this.USER_LEVEL_PROJECTS[this.project.slug]; const isOwnFeature = this.currentFeature && this.currentFeature.properties && //* check if feature exist already this.currentFeature.properties.creator === this.user.id; //* si le contributeur est l'auteur du signalement return allowedStatus2change(this.user, isModerate, userStatus, isOwnFeature, this.currentRouteName); } return []; }, isEditable() { return this.basemaps && this.map && (this.feature_type && !this.feature_type.geom_type.includes('multi')); } }, watch: { 'form.title.value': function(newValue) { if (newValue && newValue.length === 128) { this.form.title.infos.push('Le nombre de caractères et limité à 128.'); } else { this.form.title.infos = []; } } }, 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.currentRouteName === 'editer-attribut-signalement') { this.$store.commit('feature/SET_CURRENT_FEATURE', []); } if (this.$route.params.slug_signal) { this.getFeatureAttachments(); this.getLinkedFeatures(); } }, mounted() { const promises = []; if (!this.project) { promises.push( 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(() => { if (this.currentRouteName !== 'editer-attribut-signalement') { this.initForm(); this.initMap(); this.onFeatureTypeLoaded(); // init map tools } 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 editionService.selectForDeletion = null; //* 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: { initForm() { if (this.currentRouteName.includes('editer')) { for (const key in this.currentFeature.properties) { if (key && this.form[key]) { if (key === 'status') { const value = this.currentFeature.properties[key]; this.form[key].value = statusChoices.find( (key) => key.value === value ); } else { this.form[key].value = this.currentFeature.properties[key]; } } } this.form.geom.value = this.currentFeature.geometry; this.updateStore(); } }, addPointToCoordinates(coordinates){ let json = { type: 'Feature', geometry: { type: 'Point', coordinates: coordinates, }, properties: {}, }; this.updateMap(json); this.updateGeomField(json.geometry); this.showGeoPositionBtn = false; this.erreurGeolocalisationMessage = ''; }, create_point_geoposition() { function success(position) { this.addPointToCoordinates([position.coords.longitude, position.coords.latitude]); } 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/`; const 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) { const regexp = /POINT\s\((.*)\s(.*)\)/; const arr = regexp.exec(response.data.geom); this.addPointToCoordinates([parseFloat(arr[1]), parseFloat(arr[2])]); // Set Attachment this.addAttachment({ title: 'Localisation', info: '', attachment_file: this.file.name, fileToImport: this.file, }); this.toggleGeoRefModal(); } }) .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"; } }); }, 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, ...linked }); 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.currentFeature ? this.currentFeature.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.extraForm) { for (const extraForm of this.$refs.extraForm) { if (extraForm.checkForm() === false) { isValid = false; } } } 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; }, onSave() { if (this.currentRouteName === 'editer-attribut-signalement') { this.postMultipleFeatures(); } else { this.postForm(); } }, async postForm(extraForms) { let response; let is_valid = this.checkFormGeom() && this.checkAddedForm(); if (!this.feature_type.title_optional) { is_valid = this.checkFormTitle() && is_valid; } 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.includes('editer') && 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.sendingFeature = true; 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; } }, async postMultipleFeatures() { this.$store.commit('DISPLAY_LOADER', 'Envoi des signalements en cours...'); 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) { // 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 response = await this.postForm(newXtraForms); responses.push(response); } } this.$store.commit('DISCARD_LOADER'); 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: { slug: this.$route.params.slug, }, }); }, //* ************* MAP *************** *// onFeatureTypeLoaded() { const geomType = this.feature_type.geom_type; editionService.addEditionControls(geomType, this.isEditable); editionService.draw.on('drawend', (evt) => { const feature = evt.feature; this.updateGeomField(new GeoJSON().writeGeometry(feature.getGeometry(),{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' })); if (this.feature_type.geomType === 'point') { this.showGeoPositionBtn = false; this.erreurGeolocalisationMessage = ''; } }); editionService.modify.on('modifyend', (evt) => { let feature = evt.features.getArray()[0]; this.updateGeomField(new GeoJSON().writeGeometry(feature.getGeometry(),{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' })); }); 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 (editionService.drawSource) { editionService.drawSource.clear(); } if (geomFeatureJSON) { let retour = new GeoJSON().readFeature(geomFeatureJSON,{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' }); editionService.startEditFeature(retour); } else { this.map.setView( this.$store.state.configuration.DEFAULT_MAP_VIEW.center, this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom ); } }, async updateGeomField(newGeom) { this.form.geom.value = newGeom; await this.updateStore(); }, initMap() { this.mapLoading = true; 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 = mapService.createMap(this.$refs.map, { mapDefaultViewCenter, mapDefaultViewZoom, maxZoom: this.project.map_max_zoom_level, interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true }, fullScreenControl: true }); const currentFeatureId = this.$route.params.slug_signal; 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.length > 0) { const allFeaturesExceptCurrent = features.filter( (feat) => feat.id !== currentFeatureId ); mapService.addFeatures({ features: allFeaturesExceptCurrent, featureTypes: this.feature_types, addToMap: true }); if (this.currentRouteName === 'editer-signalement') { editionService.setFeatureToEdit(this.currentFeature); this.updateMap(this.currentFeature); } } this.mapLoading = false; }) .catch((error) => { 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() { editionService.addSnapInteraction(this.map); }, disableSnap() { editionService.removeSnapInteraction(this.map); }, 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; } .extraform { margin-bottom: 1em; } </style>