<template> <div id="project-edit"> <div :class="{ active: loading }" class="ui inverted dimmer" > <div class="ui text loader"> Projet en cours de création. Vous allez être redirigé. </div> </div> <form id="form-project-edit" class="ui form" > <h1> <span v-if="action === 'edit'" >Édition du projet "{{ form.title }}"</span> <span v-else-if="action === 'create'">Création d'un projet</span> </h1> <div class="ui horizontal divider"> INFORMATIONS </div> <div class="two fields"> <div class="required field"> <label for="title">Titre</label> <input id="title" v-model="form.title" type="text" required maxlength="128" name="title" > <ul id="errorlist-title" class="errorlist" > <li v-for="error in errors.title" :key="error" > {{ error }} </li> </ul> </div> <div class="field file-logo"> <label>Illustration du projet</label> <img v-if="thumbnailFileSrc.length || form.thumbnail.length" id="form-input-file-logo" class="ui small image" :src=" thumbnailFileSrc ? thumbnailFileSrc : DJANGO_BASE_URL + form.thumbnail " alt="Thumbnail du projet" > <label class="ui icon button" for="thumbnail" > <i class="file icon" aria-hidden="true" /> <span class="label">{{ form.thumbnail_name ? form.thumbnail_name : fileToImport.name }}</span> </label> <input id="thumbnail" class="file-selection" type="file" accept="image/jpeg, image/png" style="display: none" name="thumbnail" @change="onFileChange" > <ul v-if="errorThumbnail.length" id="errorlist-thumbnail" class="errorlist" > <li> {{ errorThumbnail[0] }} </li> </ul> </div> </div> <div class="two fields"> <div class="field"> <label for="description">Description</label> <textarea id="editor" v-model="form.description" data-preview="#preview" name="description" rows="5" /> <!-- {{ form.description.errors }} --> </div> <div class="field"> <label for="preview">Aperçu</label> <div id="preview" class="description preview" name="preview" /> </div> </div> <div class="ui horizontal divider"> PARAMÈTRES </div> <div class="two fields"> <div id="published-visibility" class="required field" > <label for="access_level_pub_feature" >Visibilité des signalements publiés</label> <Dropdown :options="levelPermissionsPub" :selected="form.access_level_pub_feature.name" :selection.sync="form.access_level_pub_feature" /> <ul id="errorlist-access_level_pub_feature" class="errorlist" > <li v-for="error in errors.access_level_pub_feature" :key="error" > {{ error }} </li> </ul> </div> <div id="archived-visibility" class="required field" > <label for="access_level_arch_feature"> Visibilité des signalements archivés </label> <Dropdown :options="levelPermissionsArc" :selected="form.access_level_arch_feature.name" :selection.sync="form.access_level_arch_feature" /> <ul id="errorlist-access_level_arch_feature" class="errorlist" > <li v-for="error in errors.access_level_arch_feature" :key="error" > {{ error }} </li> </ul> </div> </div> <div class="two fields"> <div class="fields grouped checkboxes"> <div class="field"> <div class="ui checkbox"> <input id="moderation" v-model="form.moderation" class="hidden" type="checkbox" name="moderation" > <label for="moderation">Modération</label> </div> </div> <div class="field"> <div class="ui checkbox"> <input id="is_project_type" v-model="form.is_project_type" class="hidden" type="checkbox" name="is_project_type" > <label for="is_project_type">Est un projet type</label> </div> </div> <div class="field"> <div class="ui checkbox"> <input id="generate_share_link" v-model="form.generate_share_link" class="hidden" type="checkbox" name="generate_share_link" > <label for="generate_share_link">Génération d'un lien de partage externe</label> </div> </div> <div class="field"> <div class="ui checkbox"> <input id="fast_edition_mode" v-model="form.fast_edition_mode" class="hidden" type="checkbox" name="fast_edition_mode" > <label for="fast_edition_mode">Mode d'édition rapide de signalements</label> <a class=" ui small button circular compact absolute-right icon teal " data-tooltip="Consulter la documentation" data-position="right center" data-variation="mini" href="https://geocontrib.readthedocs.io/fr/latest/documentation_fonctionnelle/feature_editing/" target="_blank" rel="noopener" > <i class="question icon" /> </a> </div> </div> <div class="fields grouped"> <div class="field"> <label for="feature_browsing">Configuration du parcours de signalement</label> </div> <div id="feature_browsing_filter" class="field inline" > <label for="feature_browsing_default_filter">Filtrer sur</label> <Dropdown :options="featureBrowsingOptions.filter" :selected="form.feature_browsing_default_filter.name" :selection.sync="form.feature_browsing_default_filter" /> </div> <div id="feature_browsing_sort" class="field inline" > <label for="feature_browsing_default_sort">Trier par</label> <Dropdown :options="featureBrowsingOptions.sort" :selected="form.feature_browsing_default_sort.name" :selection.sync="form.feature_browsing_default_sort" /> </div> </div> </div> <div class="field"> <label>Niveau de zoom maximum de la carte</label> <div class="map-maxzoom-selector"> <div class="range-container"> <input v-model="form.map_max_zoom_level" type="range" min="0" max="22" step="1" @input="zoomMap" ><output class="range-output-bubble">{{ scalesTable[form.map_max_zoom_level] }}</output> </div> <div class="map-preview"> <label>Aperçu :</label> <div id="map" ref="map" /> <div class="no-preview"> pas de fond de carte disponible à cette échelle </div> </div> </div> </div> </div> <div class="ui horizontal divider"> ATTRIBUTS </div> <div class="fields grouped"> <ProjectAttributeForm v-for="(attribute, index) in projectAttributes" :key="index" :attribute="attribute" :form-project-attributes="form.project_attributes" @update:project_attributes="updateProjectAttributes($event)" /> </div> <div class="ui divider" /> <button id="send-project" type="button" class="ui teal icon button" @click="postForm" > <i class="white save icon" aria-hidden="true" /> Enregistrer les changements </button> </form> </div> </template> <script> import axios from '@/axios-client.js'; import Dropdown from '@/components/Dropdown'; import ProjectAttributeForm from '@/components/Project/Edition/ProjectAttributeForm'; import mapService from '@/services/map-service'; import TextareaMarkdown from 'textarea-markdown'; import { mapActions, mapState } from 'vuex'; export default { name: 'ProjectEdit', components: { Dropdown, ProjectAttributeForm }, data() { return { loading: false, action: 'create', fileToImport: { name: 'Sélectionner une image ...', size: 0, }, errors_archive_feature: [], errors: { title: [], access_level_pub_feature: [], access_level_arch_feature: [], }, errorThumbnail: [], featureBrowsingOptions: { filter: [{ name: 'Désactivé', value: '' }, { name: 'Type de signalement', value: 'feature_type_slug', }], sort: [{ name: 'Date de création', value: '-created_on', }, { name: 'Date de modification', value: '-updated_on' }], }, form: { title: '', slug: '', created_on: '', updated_on: '', description: '', moderation: false, thumbnail: '', // todo : utiliser l'image par défaut thumbnail_name: '', // todo: delete after getting image in jpg or png instead of data64 (require post to django) creator: null, access_level_pub_feature: { name: '', value: '' }, access_level_arch_feature: { name: '', value: '' }, archive_feature: 0, delete_feature: 0, map_max_zoom_level: 22, nb_features: 0, nb_published_features: 0, nb_comments: 0, nb_published_features_comments: 0, nb_contributors: 0, is_project_type: false, generate_share_link: false, fast_edition_mode: false, feature_browsing_default_filter: '', feature_browsing_default_sort: '-created_on', project_attributes: [], }, thumbnailFileSrc: '', scalesTable: [ '1:500 000 000', '1:250 000 000', '1:150 000 000', '1:70 000 000', '1:35 000 000', '1:15 000 000', '1:10 000 000', '1:4 000 000', '1:2 000 000', '1:1 000 000', '1:500 000', '1:250 000', '1:150 000', '1:70 000', '1:35 000', '1:15 000', '1:8 000', '1:4 000', '1:2 000', '1:1 000', '1:500', '1:250', '1:150', ] }; }, computed: { ...mapState([ 'levelsPermissions', 'projectAttributes' ]), ...mapState('projects', ['project']), DJANGO_BASE_URL: function () { return this.$store.state.configuration.VUE_APP_DJANGO_BASE; }, levelPermissionsArc(){ const levels = new Array(); if(this.levelsPermissions) { this.levelsPermissions.forEach((item) => { if (item.user_type_id !== 'super_contributor') { levels.push({ name: this.translateRoleToFrench(item.user_type_id), value: item.user_type_id, }); } if (!this.form.moderation && item.user_type_id === 'moderator') { levels.pop(); } }); } return levels; }, levelPermissionsPub(){ const levels = new Array(); if (this.levelsPermissions) { this.levelsPermissions.forEach((item) => { if ( item.user_type_id !== 'super_contributor' && item.user_type_id !== 'admin' && item.user_type_id !== 'moderator' ) { levels.push({ name: this.translateRoleToFrench(item.user_type_id), value: item.user_type_id, }); } }); } return levels; } }, watch: { 'form.moderation': function (newValue){ if(!newValue && this.form.access_level_arch_feature.value === 'moderator') { this.form.access_level_arch_feature = { name: '', value: '' }; } } }, mounted() { this.definePageType(); if (this.action === 'create') { this.thumbnailFileSrc = require('@/assets/img/default.png'); this.initPreviewMap(); } else if (this.action === 'edit' || this.action === 'create_from') { if (!this.project) { this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug) .then((projet) => { if (projet) { this.fillProjectForm(); } }); } else { this.fillProjectForm(); } } let textarea = document.querySelector('textarea'); new TextareaMarkdown(textarea); }, methods: { ...mapActions('map', [ 'INITIATE_MAP' ]), definePageType() { if (this.$router.history.current.name === 'project_create') { this.action = 'create'; } else if (this.$router.history.current.name === 'project_edit') { this.action = 'edit'; } else if (this.$router.history.current.name === 'project_create_from') { this.action = 'create_from'; } }, translateRoleToFrench(role){ switch (role) { case 'admin': return 'Administrateur projet'; case 'moderator': return 'Modérateur'; case 'contributor': return 'Contributeur'; case 'logged_user': return 'Utilisateur connecté'; case 'anonymous': return 'Utilisateur anonyme'; } }, truncate(n, len) { const ext = n.substring(n.lastIndexOf('.') + 1, n.length).toLowerCase(); let filename = n.replace('.' + ext, ''); if (filename.length <= len) { return n; } filename = filename.substr(0, len) + (n.length > len ? '[...]' : ''); return filename + '.' + ext; }, validateImgFile(files, handleFile) { const url = window.URL || window.webkitURL; const image = new Image(); image.onload = function () { handleFile(true); URL.revokeObjectURL(image.src); }; image.onerror = function () { handleFile(false); URL.revokeObjectURL(image.src); }; image.src = url.createObjectURL(files); }, onFileChange(e) { // * read image file const files = e.target.files || e.dataTransfer.files; const _this = this; //* 'this' is different in onload function function handleFile(isValid) { if (isValid) { _this.fileToImport = files[0]; //* store the file to post later const reader = new FileReader(); //* read the file to display in the page reader.onload = function (e) { _this.thumbnailFileSrc = e.target.result; }; reader.readAsDataURL(_this.fileToImport); _this.errorThumbnail = []; } else { _this.errorThumbnail.push( "Transférez une image valide. Le fichier que vous avez transféré n'est pas une image, ou il est corrompu." ); } } if (files.length) { //* check if file is an image and pass callback to handle file this.validateImgFile(files[0], handleFile); } }, checkEmpty() { //* forbid empty fields if (!this.form.archive_feature) { this.form.archive_feature = 0; } if (!this.form.delete_feature) { this.form.delete_feature = 0; } }, goBackNrefresh(slug) { Promise.all([ this.$store.dispatch('GET_USER_LEVEL_PROJECTS'), //* refresh projects user levels this.$store.dispatch('GET_USER_LEVEL_PERMISSIONS'), //* refresh projects permissions this.$store.dispatch('projects/GET_PROJECT', slug), //* refresh current project ]).then(() => // * go back to project list this.$router.push({ name: 'project_detail', params: { slug }, }) ); }, postProjectThumbnail(projectSlug) { //* send img to the backend when feature_type is created if (this.fileToImport) { const formData = new FormData(); formData.append('file', this.fileToImport); const url = this.$store.state.configuration.VUE_APP_DJANGO_API_BASE + 'projects/' + projectSlug + '/thumbnail/'; return axios .put(url, formData, { headers: { 'Content-Type': 'multipart/form-data', }, }) .then((response) => { if (response && response.status === 200) { this.goBackNrefresh(projectSlug); } }) .catch((error) => { let err_msg = "Transférez une image valide. Le fichier que vous avez transféré n'est pas une image, ou il est corrompu."; if (error.response.data[0]) { err_msg = error.response.data[0]; } this.errorThumbnail.push(err_msg); throw error; }); } }, checkForm() { if (this.form.archive_feature > this.form.delete_feature) { this.errors_archive_feature.push( "Le délais de suppression doit être supérieur au délais d'archivage." ); return false; } for (const key in this.errors) { if ((key === 'title' && this.form[key]) || this.form[key].value) { this.errors[key] = []; } else if (!this.errors[key].length) { this.errors[key].push( key === 'title' ? 'Veuillez compléter ce champ.' : 'Sélectionnez un choix valide. Ce choix ne fait pas partie de ceux disponibles.' ); document .getElementById(`errorlist-${key}`) .scrollIntoView({ block: 'end', inline: 'nearest' }); return false; } } return true; }, async postForm() { if (!this.checkForm()) { return; } const projectData = { ...this.form, access_level_arch_feature: this.form.access_level_arch_feature.value, access_level_pub_feature: this.form.access_level_pub_feature.value, feature_browsing_default_sort: this.form.feature_browsing_default_sort.value, feature_browsing_default_filter: this.form.feature_browsing_default_filter.value, }; if (this.action === 'edit') { await axios .put((`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/projects/${this.project.slug}/`), projectData) .then((response) => { if (response && response.status === 200) { //* send thumbnail after feature_type was updated if (this.fileToImport.size > 0) { this.postProjectThumbnail(this.project.slug); } else { this.goBackNrefresh(this.project.slug); } } }) .catch((error) => { if (error.response && error.response.data.title[0]) { this.errors.title.push(error.response.data.title[0]); } throw error; }); } else { let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/projects/`; if (this.action === 'create_from') { url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.project.slug}/duplicate/`; } this.loading = true; await axios .post(url, projectData) .then((response) => { if (response && response.status === 201 && response.data) { //* send thumbnail after feature_type was created if (this.fileToImport.size > 0) { this.postProjectThumbnail(response.data.slug); } else { this.goBackNrefresh(response.data.slug); } } this.loading = false; }) .catch((error) => { if (error.response && error.response.data.title[0]) { this.errors.title.push(error.response.data.title[0]); } this.loading = false; throw error; }); } }, fillProjectForm() { //* create a new object to avoid modifying original one this.form = { ...this.project }; //* if duplication of project, generate new name if (this.action === 'create_from') { this.form.title = this.project.title + ` (Copie-${new Date() .toLocaleString() .slice(0, -3) .replace(',', '')})`; this.form.is_project_type = false; } //* transform string values to objects used with dropdowns // fill dropdown current selection for archived feature viewing permission if (this.levelPermissionsArc) { const accessLevelArc = this.levelPermissionsArc.find( (el) => el.name === this.project.access_level_arch_feature ); if (accessLevelArc) { this.form.access_level_arch_feature = { name: this.project.access_level_arch_feature, value: accessLevelArc.value , }; } } // fill dropdown current selection for published feature viewing permission if (this.levelPermissionsPub) { const accessLevelPub = this.levelPermissionsPub.find( (el) => el.name === this.project.access_level_pub_feature ); if (accessLevelPub) { this.form.access_level_pub_feature = { name: this.project.access_level_pub_feature, value: accessLevelPub.value , }; } } // fill dropdown current selection for feature browsing default filtering const default_filter = this.featureBrowsingOptions.filter.find( (el) => el.value === this.project.feature_browsing_default_filter ); if (default_filter) { this.form.feature_browsing_default_filter = default_filter; } // fill dropdown current selection for feature browsing default sorting const default_sort = this.featureBrowsingOptions.sort.find( (el) => el.value === this.project.feature_browsing_default_sort ); if (default_sort) { this.form.feature_browsing_default_sort = default_sort; } this.initPreviewMap(); }, initPreviewMap () { const map = mapService.getMap(); if (map) mapService.destroyMap(); //On récupère le zoom maximum autorisé par la couche const maxZoomLayer = this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS.maxZoom; let activeZoom = maxZoomLayer; if(this.project && this.project.map_max_zoom_level < maxZoomLayer){ activeZoom = this.project.map_max_zoom_level; } this.INITIATE_MAP({ el: this.$refs.map, zoom: activeZoom, center: this.$store.state.configuration.MAP_PREVIEW_CENTER, maxZoom: 22, controls: [], zoomControl: false, //On désactive le zoom et le pan => gérer par le composant zoom max interactions: { dragPan: false, mouseWheelZoom: false } }); // add default basemap (in other maps the component SidebarLayer handles layers) mapService.addLayers( null, 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 ); //La tuile au dessus du zoom maximum n'existe pas //On attend un peu qu'elle se charge et on zoom si besoin setTimeout(() => { mapService.zoom(this.project ? this.project.map_max_zoom_level : 22); }, 500); }, zoomMap() { mapService.zoom(this.form.map_max_zoom_level); }, /** * Updates the value of a project attribute or adds a new attribute if it does not exist. * * This function looks for an attribute by its ID. If the attribute exists, its value is updated. * If the attribute does not exist, a new attribute object is added to the `project_attributes` array. * * @param {String} value - The new value to be assigned to the project attribute. * @param {Number} attributeId - The ID of the attribute to be updated or added. */ updateProjectAttributes({ value, attributeId }) { // Find the index of the attribute in the project_attributes array. const attributeIndex = this.form.project_attributes.findIndex(el => el.attribute_id === attributeId); if (attributeIndex !== -1) { // Directly update the attribute's value if it exists. this.form.project_attributes[attributeIndex].value = value; } else { // Add a new attribute object if it does not exist. this.form.project_attributes.push({ attribute_id: attributeId, value }); } } }, }; </script> <style media="screen" lang="less"> #form-input-file-logo { margin-left: auto; margin-right: auto; } .file-logo { min-height: calc(150px + 2.4285em); display: flex; flex-direction: column; justify-content: space-between; } .close.icon:hover { cursor: pointer; } textarea { height: 10em; } .description.preview { height: 10em; overflow: scroll; border: 1px solid rgba(34, 36, 38, .15); padding: .78571429em 1em; } .checkboxes { padding-left: .5em; .absolute-right.ui.compact.icon.button { position: absolute; right: -2.75em; top: calc(50% - 1em); padding: .4em; } } .map-maxzoom-selector { display: flex; flex-wrap: wrap; justify-content: space-between; input, output { height: fit-content; } output { white-space: nowrap; min-width: auto; } .range-container { margin-bottom: 2rem; } .map-preview { margin-top: -1rem; display: flex; position: relative; label { white-space: nowrap; font-size: .95em; margin-right: 1rem; } #map { min-height: 80px; height: 80px; width: 150px; max-width: 150px; z-index: 1; } .no-preview { position: absolute; top: 25%; left: 25%; text-align: center; font-size: .75em; color: #656565; } } } label[for=feature_browsing] { padding-left: 2em; } label[for=feature_browsing_default_filter], label[for=feature_browsing_default_sort] { min-width: 4em; } #feature_browsing_filter, #feature_browsing_sort { margin-left: 2.5rem; } @media only screen and (min-width: 1100px) { #feature_browsing_filter { margin-top: -2.25em; } #feature_browsing_filter, #feature_browsing_sort { float: right; } } </style>