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 3404 additions and 4952 deletions
<template>
<div id="login-page">
<div class="row">
<div class="fourteen wide column">
<img
class="ui centered small image"
:src="appLogo"
alt="Logo de l'application"
>
<h2 class="ui center aligned icon header">
<div class="content">
{{ appName }}
<div class="sub header">
{{ appAbstract }}
</div>
</div>
</h2>
</div>
</div>
<div class="row">
<div
v-if="$route.name === 'login'"
class="six wide column"
>
<h3 class="ui horizontal divider header">
CONNEXION
</h3>
<div :class="['ui warning message', {'closed': !errors.global}]">
<div class="header">
Les informations d'identification sont incorrectes.
</div>
NB: Seuls les comptes actifs peuvent se connecter.
</div>
<form
class="ui form"
role="form"
type="post"
@submit.prevent="login"
>
<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="loginForm.username"
type="text"
name="username"
placeholder="Utilisateur"
>
</div>
</div>
<div class="six field">
<div class="ui left icon input">
<i
class="lock icon"
aria-hidden="true"
/>
<input
v-model="loginForm.password"
type="password"
name="password"
placeholder="Mot de passe"
>
</div>
</div>
<button
class="ui fluid large teal submit button"
type="submit"
>
Login
</button>
</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 {
logged: false,
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: {
...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);
}
},
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, '');
}
},
'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.$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.loginForm.username,
password: this.loginForm.password,
})
.then((status) => {
if (status === 200) {
this.errors.global = null;
} else if (status === 'error') {
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
<template>
<div v-frag>
<div class="fourteen wide column">
<h1>Mon compte</h1>
</div>
<div class="row">
<div class="five wide column">
<h4 class="ui horizontal divider header">PROFIL</h4>
<div class="ui divided list">
<div class="item">
<div class="right floated content">
<div class="description">
<span v-if="user.username">{{ user.username }} </span>
</div>
</div>
<div class="content">Nom d'utilisateur</div>
</div>
<div class="item">
<div class="right floated content">
<div class="description">
{{ userFullname }}
</div>
</div>
<div class="content">Nom complet</div>
</div>
<div class="item">
<div class="right floated content">
<div class="description">
{{ user.email }}
</div>
</div>
<div class="content">Adresse e-mail</div>
</div>
<div class="item">
<div class="right floated content">
<div class="description">
{{ user.is_superuser ? "Oui" : "Non" }}
</div>
</div>
<div class="content">Administrateur</div>
</div>
</div>
</div>
<div class="nine wide column">
<h4 class="ui horizontal divider header">MES PROJETS</h4>
<div class="ui divided items">
<div v-for="project in projects" :key="project.slug" class="item">
<!-- {% if permissions|lookup:project.slug %} -->
<div v-frag v-if="user_permissions[project.slug].can_view_project">
<div class="ui tiny image">
<img
v-if="project.thumbnail"
class="ui small image"
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
height="200"
/>
</div>
<div class="middle aligned content">
<router-link
:to="{
name: 'project_detail',
params: { slug: project.slug },
}"
class="header"
>{{ project.title }}</router-link
>
<div class="description">
<p>{{ project.description }}</p>
</div>
<div class="meta">
<span class="right floated"
>Projet {{ project.moderation ? "" : "non" }} modéré</span
>
<span
>Niveau d'autorisation requis :
{{ project.access_level_pub_feature }}</span
><br />
<span>
Mon niveau d'autorisation :
<span v-if="USER_LEVEL_PROJECTS && project">{{
USER_LEVEL_PROJECTS[project.slug]
}}</span>
<span v-if="user && user.is_administrator">{{
"+ Gestionnaire métier"
}}</span>
</span>
</div>
<div class="meta">
<span
class="right floated"
:data-tooltip="`Projet créé le ${project.created_on}`"
>
<i class="calendar icon"></i>&nbsp;{{ project.created_on }}
</span>
<span data-tooltip="Membres">
{{ project.nb_contributors }}&nbsp;<i class="user icon"></i>
</span>
<span data-tooltip="Signalements">
{{ project.nb_published_features }}&nbsp;<i
class="map marker icon"
></i>
</span>
<span data-tooltip="Commentaires">
{{ project.nb_published_features_comments }}&nbsp;<i
class="comment icon"
></i>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="fourteen wide column">
<div class="ui three stackable cards">
<div class="red card">
<div class="content">
<div class="center aligned header">
Mes dernières notifications reçues
</div>
<div class="center aligned description">
<div class="ui relaxed list">
<div v-for="item in events" :key="item.id" class="item">
<div class="content">
<div v-if="item.event_type === 'create'">
<a
v-if="item.object_type === 'feature'"
:href="item.related_feature.feature_url"
>
Signalement créé
</a>
<a
v-else-if="item.object_type === 'comment'"
:href="item.related_feature.feature_url"
>
Commentaire créé
</a>
<a
v-else-if="item.object_type === 'attachment'"
:href="item.related_feature.feature_url"
>
Pièce jointe ajoutée
</a>
<a
v-else-if="item.object_type === 'project'"
:href="item.project_url"
>
Projet créé
</a>
</div>
<div v-else-if="item.event_type === 'update'">
<a
v-if="item.object_type === 'feature'"
:href="item.related_feature.project_url"
>
Signalement mis à jour
</a>
<a
v-else-if="item.object_type === 'project'"
:href="item.project_url"
>à Projet mis à jour
</a>
</div>
<div v-else-if="item.event_type === 'delete'">
<span v-if="item.object_type === 'feature'">
Signalement supprimé({{ item.data.feature_title }})
</span>
<i v-else>Événement inconnu</i>
</div>
<div class="description">
<i
>[ {{ item.created_on }}
<span v-if="user.is_authenticated">
, par {{ item.display_user }}
</span>
]</i
>
</div>
</div>
</div>
<i v-if="!events || events.length === 0"
>Aucune notification pour le moment.</i
>
</div>
</div>
</div>
</div>
<div class="orange card">
<div class="content">
<div class="center aligned header">Mes derniers signalements</div>
<div class="center aligned description">
<div class="ui relaxed list">
<div v-for="item in features" :key="item.id" class="item">
<div class="content">
<div>
<a
v-if="item.related_feature"
:href="item.related_feature.feature_url"
>{{ item.related_feature.title }}</a
>
<span v-else>
{{ item.data.feature_title }} (supprimé)
</span>
</div>
<div class="description">
<i
>[ {{ item.created_on }}
<span v-if="user.is_authenticated">
, par {{ item.display_user }}
</span>
]</i
>
</div>
</div>
</div>
<i v-if="!features || features.length === 0"
>Aucun signalement pour le moment.</i
>
</div>
</div>
</div>
</div>
<div class="yellow card">
<div class="content">
<div class="center aligned header">Mes derniers commentaires</div>
<div class="center aligned description">
<div class="ui relaxed list">
<div v-for="item in comments" :key="item.id" class="item">
<div class="content">
<div>
<a :href="item.related_feature.feature_url"
>"{{ item.related_comment.comment }}"</a
>
</div>
<div class="description">
<i
>[ {{ item.created_on }}
<span v-if="user.is_authenticated">
, par {{ item.display_user }}
</span>
]</i
>
</div>
</div>
</div>
<i v-if="!comments || comments.length === 0"
>Aucun commentaire pour le moment.</i
>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import frag from "vue-frag";
import { mapState } from "vuex";
export default {
name: "My_account",
directives: {
frag,
},
data() {
return {
events: [],
features: [],
comments: [],
};
},
computed: {
// todo : filter projects to user
...mapState([
"user",
"projects",
"USER_LEVEL_PROJECTS",
"user_permissions",
]),
DJANGO_BASE_URL: function () {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
userFullname: function () {
if (this.user.first_name || this.user.last_name)
return this.user.first_name + " " + this.user.last_name;
return null;
},
},
methods: {
refreshId() {
return "?ver=" + Math.random();
},
setEvents(data){
this.events = data.events;
this.features = data.features;
this.comments = data.comments;
},
getEvents(){
this.$store
.dispatch("USER_EVENTS")
.then((data)=>{
this.setEvents(data)
})
}
},
created(){
this.getEvents();
}
};
</script>
\ No newline at end of file
...@@ -3,7 +3,9 @@ ...@@ -3,7 +3,9 @@
<h1>Not Found</h1> <h1>Not Found</h1>
<p> <p>
Oups, la page demandée n'a pas été trouvée. Essayez de retourner à la Oups, la page demandée n'a pas été trouvée. Essayez de retourner à la
<router-link to="/">page d'accueil</router-link> <router-link to="/">
page d'accueil
</router-link>
</p> </p>
</div> </div>
</template> </template>
<template>
<div id="project-features">
<div class="column">
<FeaturesListAndMapFilters
:show-map="showMap"
:features-count="featuresCountDisplay"
:pagination="pagination"
:all-selected="allSelected"
:edit-attributes-feature-type="editAttributesFeatureType"
@set-filter="setFilters"
@reset-pagination="resetPagination"
@fetch-features="fetchPagedFeatures"
@show-map="setShowMap"
@edit-status="modifyStatus"
@toggle-delete-modal="toggleDeleteModal"
/>
<div class="loader-container">
<div
:class="['ui tab active map-container', { 'visible': showMap }]"
data-tab="map"
>
<div
id="map"
ref="map"
>
<SidebarLayers
v-if="basemaps && map"
ref="sidebar"
/>
<Geolocation />
<Geocoder />
</div>
<div
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>
<!-- MODAL ALL DELETE FEATURE TYPE -->
<div
v-if="isDeleteModalOpen"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
:class="[
'ui mini modal',
{ 'active visible': isDeleteModalOpen },
]"
>
<i
class="close icon"
aria-hidden="true"
@click="isDeleteModalOpen = false"
/>
<div class="ui icon header">
<i
class="trash alternate icon"
aria-hidden="true"
/>
Êtes-vous sûr de vouloir effacer
<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
type="button"
class="ui red compact fluid button"
@click="deleteAllFeatureSelection"
>
Confirmer la suppression
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapActions, mapMutations } from 'vuex';
import mapService from '@/services/map-service';
import Geocoder from '@/components/Map/Geocoder';
import featureAPI from '@/services/feature-api';
import FeaturesListAndMapFilters from '@/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters';
import FeatureListTable from '@/components/Project/FeaturesListAndMap/FeatureListTable';
import SidebarLayers from '@/components/Map/SidebarLayers';
import Geolocation from '@/components/Map/Geolocation';
const initialPagination = {
currentPage: 1,
pagesize: 15,
start: 0,
end: 15,
};
export default {
name: 'FeaturesListAndMap',
components: {
FeaturesListAndMapFilters,
SidebarLayers,
Geocoder,
Geolocation,
FeatureListTable,
},
data() {
return {
allSelected: false,
editAttributesFeatureType: null,
currentLayer: null,
featuresCount: 0,
featuresWithGeomCount:0,
form: {
type: [],
status: [],
title: null,
},
isDeleteModalOpen: false,
loading: false,
lat: null,
lng: null,
map: null,
paginatedFeatures: [],
pagination: { ...initialPagination },
projectSlug: this.$route.params.slug,
queryparams: {},
showMap: true,
sort: {
column: 'updated_on',
ascending: true,
},
zoom: null,
};
},
computed: {
...mapState([
'isOnline'
]),
...mapState('projects', [
'project',
]),
...mapState('feature', [
'checkedFeatures',
'clickedFeatures',
]),
...mapState('feature-type', [
'feature_types',
]),
...mapState('map', [
'basemaps',
]),
API_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_API_BASE;
},
pageNumbers() {
return this.createPagesArray(this.featuresCount, this.pagination.pagesize);
},
featuresCountDisplay() {
return this.showMap ? this.featuresWithGeomCount : this.featuresCount;
}
},
watch: {
isOnline(newValue, oldValue) {
if (newValue != oldValue && !newValue) {
this.DISPLAY_MESSAGE({
comment: 'Les signalements du projet non mis en cache ne sont pas accessibles en mode déconnecté',
});
}
},
},
mounted() {
this.UPDATE_CHECKED_FEATURES([]); // empty for when turning back from edit attributes page
if (!this.project) {
// Chargements des features et infos projet en cas d'arrivée directe sur la page ou de refresh
Promise.all([
this.$store.dispatch('projects/GET_PROJECT', this.projectSlug),
this.$store.dispatch('projects/GET_PROJECT_INFO', this.projectSlug)
]).then(()=> this.initPage());
} else {
this.initPage();
}
},
destroyed() {
//* allow user to change page if ever stuck on loader
this.loading = false;
},
methods: {
...mapMutations([
'DISPLAY_MESSAGE',
]),
...mapActions('feature', [
'DELETE_FEATURE',
]),
...mapMutations('feature', [
'UPDATE_CHECKED_FEATURES'
]),
setShowMap(newValue) {
this.showMap = newValue;
// 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];
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.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) {
// 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,
});
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 {
// 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és avec succès.',
level: 'positive',
});
}
},
/**
* 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',
});
}
}
// 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();
},
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 || '';
this.lng = this.$route.query.lng || '';
var mapDefaultViewCenter =
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, {
zoom: this.zoom,
lat: this.lat,
lng: this.lng,
mapDefaultViewCenter,
mapDefaultViewZoom,
maxZoom: this.project.map_max_zoom_level,
interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true },
fullScreenControl: true,
geolocationControl: true,
});
this.$nextTick(() => {
const mvtUrl = `${this.API_BASE_URL}features.mvt`;
mapService.addVectorTileLayer({
url: mvtUrl,
project_slug: this.projectSlug,
featureTypes: this.feature_types,
formFilters: this.form,
queryParams: this.queryparams,
});
});
this.fetchPagedFeatures();
},
fetchBboxNfit(queryString) {
featureAPI
.getFeaturesBbox(this.projectSlug, queryString)
.then((bbox) => {
if (bbox) {
mapService.fitBounds(bbox);
}
});
},
//* Paginated Features for table *//
getFeatureTypeSlug(title) {
const featureType = this.feature_types.find((el) => el.title === title);
return featureType ? featureType.slug : null;
},
getAvalaibleField(orderField) {
let result = orderField;
if (orderField === 'display_creator') {
result = 'creator';
} else if (orderField === 'display_last_editor') {
result = 'last_editor';
}
return result;
},
/**
* 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;
// 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;
}
// 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;
}
// 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)}`;
},
/**
* 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;
}
// 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;
}
// If there are features, update the bounding box.
if (this.paginatedFeatures.length) {
this.fetchBboxNfit(queryString);
}
// Trigger actions on filter change.
this.onFilterChange();
// Hide the loading message.
this.loading = false;
});
},
//* Pagination for table *//
createPagesArray(featuresCount, pagesize) {
const totalPages = Math.ceil(
featuresCount / pagesize
);
return [...Array(totalPages).keys()].map((pageNumb) => {
++pageNumb;
return pageNumb;
});
},
handlePageChange(page) {
if (page === 'next') {
this.toNextPage();
} else if (page === 'previous') {
this.toPreviousPage();
} else if (typeof page === 'number') {
//* update limit and offset
this.toPage(page);
}
},
handleSortChange(sort) {
this.sort = sort;
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) {
const toAddOrRemove =
(pageNumber - this.pagination.currentPage) * this.pagination.pagesize;
this.pagination.start += toAddOrRemove;
this.pagination.end += toAddOrRemove;
this.pagination.currentPage = pageNumber;
this.fetchPagedFeatures();
},
toPreviousPage() {
if (this.pagination.currentPage !== 1) {
if (this.pagination.start > 0) {
this.pagination.start -= this.pagination.pagesize;
this.pagination.end -= this.pagination.pagesize;
this.pagination.currentPage -= 1;
}
this.fetchPagedFeatures();
}
},
toNextPage() {
if (this.pagination.currentPage !== this.pageNumbers.length) {
if (this.pagination.end < this.featuresCount) {
this.pagination.start += this.pagination.pagesize;
this.pagination.end += this.pagination.pagesize;
this.pagination.currentPage += 1;
}
this.fetchPagedFeatures();
}
},
},
};
</script>
<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%);
margin-left: 50%;
visibility: hidden;
position: absolute;
#map {
min-height: 0;
}
}
.map-container.visible {
visibility: visible;
position: relative;
width: 100%;
.sidebar-container {
left: -250px;
}
.sidebar-container.expanded {
left: 0;
}
#map {
width: 100%;
min-height: 310px;
height: calc(100vh - 310px);
border: 1px solid grey;
/* To not hide the filters */
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 {
margin: 1em auto 1em;
}
.map-container {
width: 100%;
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>
<template> <template>
<div class="fourteen wide column"> <div id="project-basemaps">
<div id="message_info" class="fullwidth"> <h1 class="ui header">
<div v-if="infoMessage.length > 0"> Administration des fonds cartographiques
<div </h1>
v-for="(message, index) of infoMessage"
:key="index"
:class="['ui message', message.success ? 'positive' : 'negative']"
style="text-align: left"
>
<div class="header">
<i class="info circle icon"></i> Informations
</div>
{{ message.comment }}
</div>
</div>
</div>
<h1 class="ui header">Administration des fonds cartographiques</h1>
<form <form
id="form-layers" id="form-layers"
action="."
method="post"
enctype="multipart/form-data"
class="ui form" class="ui form"
> >
<!-- {{ formset.management_form }} --> <!-- {{ formset.management_form }} -->
<div class="ui buttons"> <div class="ui buttons">
<a <button
class="ui compact small icon left floated button green" class="ui compact small icon left floated button green"
type="button"
data-variation="mini" data-variation="mini"
@click="addBasemap" @click="addBasemap"
> >
<i class="ui plus icon"></i> <i
<span>Créer un fond cartographique</span> class="ui plus icon"
</a> aria-hidden="true"
/>
<span>&nbsp;Créer un fond cartographique</span>
</button>
</div> </div>
<div v-if="basemaps" class="ui"> <div
<ProjectMappingBasemap v-if="basemaps"
class="ui margin-bottom margin-top"
>
<BasemapListItem
v-for="basemap in basemaps" v-for="basemap in basemaps"
:key="basemap.id" :key="basemap.id"
:basemap="basemap" :basemap="basemap"
...@@ -45,57 +36,72 @@ ...@@ -45,57 +36,72 @@
</div> </div>
<button <button
@click="saveChanges" v-if="basemaps && basemaps[0] && basemaps[0].title && basemaps[0].layers.length > 0"
type="button" type="button"
class="ui teal icon floated button" class="ui teal icon floated button"
@click="saveChanges"
> >
<i class="white save icon"></i> Enregistrer les changements <i
class="white save icon"
aria-hidden="true"
/>
Enregistrer les changements
</button> </button>
</form> </form>
</div> </div>
</template> </template>
<script> <script>
import Project_mapping_basemap from "@/components/project/project_mapping_basemap.vue"; import BasemapListItem from '@/components/Project/Basemaps/BasemapListItem.vue';
import { mapState, mapGetters } from "vuex"; import { mapState, mapGetters, mapMutations } from 'vuex';
export default { export default {
name: "Project_mapping", name: 'ProjectBasemaps',
components: { components: {
ProjectMappingBasemap: Project_mapping_basemap, BasemapListItem
}, },
data() { data() {
return { return {
infoMessage: [],
newBasemapIds: [], newBasemapIds: [],
}; };
}, },
computed: { computed: {
...mapState("map", ["basemaps"]), ...mapState('map', [
...mapGetters("map", ["basemapMaxId"]), 'basemaps'
]),
...mapGetters('map', [
'basemapMaxId'
]),
},
created() {
if (!this.$store.state.projects.project) {
this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug);
this.$store.dispatch('projects/GET_PROJECT_INFO', this.$route.params.slug);
}
}, },
methods: { methods: {
...mapMutations(['DISPLAY_MESSAGE']),
addBasemap() { addBasemap() {
this.newBasemapIds.push(this.basemapMaxId + 1); //* register new basemaps to seperate post and put this.newBasemapIds.push(this.basemapMaxId + 1); //* register new basemaps to seperate post and put
this.$store.commit("map/CREATE_BASEMAP", this.basemapMaxId + 1); this.$store.commit('map/CREATE_BASEMAP', this.basemapMaxId + 1);
}, },
checkTitles() { checkTitles() {
let isValid = true; let isValid = true;
this.basemaps.forEach((basemap) => { this.basemaps.forEach((basemap) => {
console.log(basemap); if (basemap.title === null || basemap.title === '') { //* check title when saving basemaps
if (basemap.title === null || basemap.title === "") { basemap.errors = 'Veuillez compléter ce champ.';
basemap.errors = "Veuillez compléter ce champ.";
isValid = false; isValid = false;
} else if (basemap.layers.length === 0) { } else if (basemap.layers.length === 0) {
basemap.errors = "Veuillez ajouter au moins un layer."; basemap.errors = 'Veuillez ajouter au moins un layer.';
isValid = false; isValid = false;
} else { } else {
basemap.errors = ""; basemap.errors = '';
} }
}); });
return isValid; return isValid;
...@@ -104,46 +110,30 @@ export default { ...@@ -104,46 +110,30 @@ export default {
saveChanges() { saveChanges() {
if (this.checkTitles()) { if (this.checkTitles()) {
this.$store this.$store
.dispatch("map/SAVE_BASEMAPS", this.newBasemapIds) .dispatch('map/SAVE_BASEMAPS', this.newBasemapIds)
.then((response) => { .then((response) => {
const errors = response.filter( const errors = response.filter(
(res) => (res) =>
res.status === 200 && res.status === 201 && res.status === 204 res.status !== 200 && res.status !== 201 && res.status !== 204
); );
if (errors.length === 0) { if (errors.length === 0) {
this.infoMessage.push({ this.DISPLAY_MESSAGE({
success: true, comment: 'Enregistrement effectué.',
comment: "Enregistrement effectué.", level: 'positive'
}); });
this.newBasemapIds = []; this.newBasemapIds = [];
} else { } else {
this.infoMessage.push({ this.DISPLAY_MESSAGE({
success: false, comment: 'L\'édition des fonds cartographiques a échoué.',
comment: "L'édition des fonds cartographiques a échoué. ", level: 'negative'
}); });
} }
document
.getElementById("message_info")
.scrollIntoView({ block: "end", inline: "nearest" });
setTimeout(
function () {
this.infoMessage = [];
}.bind(this),
5000
);
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.error(error);
}); });
} }
}, },
}, },
created() {
if (!this.$store.getters.project) {
this.$store.dispatch("GET_PROJECT_INFO", this.$route.params.slug);
}
},
}; };
</script> </script>
\ No newline at end of file
<template>
<div>
<div
v-if="permissions && permissions.can_view_project && project"
id="project-detail"
>
<ProjectHeader
:arrays-offline="arraysOffline"
@retrieveInfo="retrieveProjectInfo"
@updateLocalStorage="updateLocalStorage"
/>
<div class="ui grid stackable">
<div class="row">
<div class="eight wide column">
<ProjectFeatureTypes
:loading="featureTypesLoading"
:project="project"
@delete="toggleDeleteFeatureTypeModal"
@update="updateAfterImport"
/>
</div>
<div class="eight wide column block-map">
<div class="map-container">
<div
id="map"
ref="map"
/>
<div
:class="{ active: mapLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Chargement de la carte...
</div>
</div>
<SidebarLayers
v-if="basemaps && map && !projectInfoLoading"
ref="sidebar"
/>
<Geolocation />
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div>
<router-link
id="features-list"
:to="{
name: 'liste-signalements',
params: { slug: slug },
}"
custom
>
<div
class="ui button fluid teal"
>
<i class="ui icon arrow right" />
Voir tous les signalements
</div>
</router-link>
</div>
</div>
<div class="row">
<div class="sixteen wide column">
<div class="ui two stackable cards">
<ProjectLastFeatures
ref="lastFeatures"
/>
<ProjectLastComments
:loading="projectInfoLoading"
/>
</div>
</div>
</div>
<div class="row">
<div class="sixteen wide column">
<ProjectParameters
:project="project"
/>
</div>
</div>
</div>
</div>
<span v-else-if="!projectInfoLoading">
<i
class="icon exclamation triangle"
aria-hidden="true"
/>
<span>Vous ne disposez pas des droits nécessaires pour consulter ce
projet.</span>
</span>
<ProjectModal
:is-subscriber="is_suscriber"
:feature-type-to-delete="featureTypeToDelete"
@action="handleModalAction"
/>
</div>
</template>
<script>
import mapService from '@/services/map-service';
import { mapGetters, mapState, mapActions, mapMutations } from 'vuex';
import projectAPI from '@/services/project-api';
import featureTypeAPI from '@/services/featureType-api';
import featureAPI from '@/services/feature-api';
import ProjectHeader from '@/components/Project/Detail/ProjectHeader';
import ProjectFeatureTypes from '@/components/Project/Detail/ProjectFeatureTypes';
import ProjectLastFeatures from '@/components/Project/Detail/ProjectLastFeatures';
import ProjectLastComments from '@/components/Project/Detail/ProjectLastComments';
import ProjectParameters from '@/components/Project/Detail/ProjectParameters';
import ProjectModal from '@/components/Project/Detail/ProjectModal';
import SidebarLayers from '@/components/Map/SidebarLayers';
import Geolocation from '@/components/Map/Geolocation';
export default {
name: 'ProjectDetail',
components: {
ProjectHeader,
ProjectFeatureTypes,
ProjectLastFeatures,
ProjectLastComments,
ProjectParameters,
ProjectModal,
SidebarLayers,
Geolocation,
},
filters: {
setDate(value) {
const date = new Date(value);
const d = date.toLocaleDateString('fr', {
year: '2-digit',
month: 'numeric',
day: 'numeric',
});
return d;
},
},
props: {
message: {
type: Object,
default: () => {}
}
},
data() {
return {
infoMessage: '',
importMessage: null,
arraysOffline: [],
arraysOfflineErrors: [],
slug: this.$route.params.slug,
is_suscriber: false,
tempMessage: null,
projectInfoLoading: true,
featureTypesLoading: false,
featureTypeToDelete: null,
mapLoading: true,
};
},
computed: {
...mapGetters([
'permissions'
]),
...mapState('projects', [
'project'
]),
...mapState([
'configuration',
]),
...mapState('feature', [
'features'
]),
...mapState('feature-type', [
'feature_types'
]),
...mapState([
'user',
'user_permissions',
'reloadIntervalId',
]),
...mapState('map', [
'map',
'basemaps',
'availableLayers',
]),
API_BASE_URL() {
return this.configuration.VUE_APP_DJANGO_API_BASE;
},
},
created() {
if (this.user) {
projectAPI
.getProjectSubscription({
baseUrl: this.$store.state.configuration.VUE_APP_DJANGO_API_BASE,
projectSlug: this.$route.params.slug
})
.then((data) => (this.is_suscriber = data.is_suscriber));
}
},
mounted() {
this.retrieveProjectInfo();
if (this.message) {
this.DISPLAY_MESSAGE(this.message);
}
},
beforeDestroy() {
this.$store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
this.CLEAR_RELOAD_INTERVAL_ID();
this.CLOSE_PROJECT_MODAL();
},
methods: {
...mapMutations([
'CLEAR_RELOAD_INTERVAL_ID',
'DISPLAY_MESSAGE',
]),
...mapMutations('modals', [
'OPEN_PROJECT_MODAL',
'CLOSE_PROJECT_MODAL'
]),
...mapActions('projects', [
'GET_PROJECT_INFO',
'GET_PROJECT',
]),
...mapActions('map', [
'INITIATE_MAP'
]),
...mapActions('feature', [
'GET_PROJECT_FEATURES'
]),
...mapActions('feature-type', [
'GET_PROJECT_FEATURE_TYPES',
]),
retrieveProjectInfo() {
Promise.all([
this.GET_PROJECT(this.slug),
this.GET_PROJECT_INFO(this.slug)
])
.then(() => {
this.$nextTick(() => {
const map = mapService.getMap();
if (map) mapService.destroyMap();
this.initMap();
});
})
.catch((err) => {
console.error(err);
})
.finally(() => {
this.projectInfoLoading = false;
});
},
checkForOfflineFeature() {
let arraysOffline = [];
const localStorageArray = localStorage.getItem('geocontrib_offline');
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
this.arraysOffline = arraysOffline.filter(
(x) => x.project === this.slug
);
}
},
updateLocalStorage() {
let arraysOffline = [];
const localStorageArray = localStorage.getItem('geocontrib_offline');
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
}
const arraysOfflineOtherProject = arraysOffline.filter(
(x) => x.project !== this.slug
);
this.arraysOffline = [];
arraysOffline = arraysOfflineOtherProject.concat(
this.arraysOfflineErrors
);
localStorage.setItem('geocontrib_offline', JSON.stringify(arraysOffline));
},
subscribeProject() {
projectAPI
.subscribeProject({
baseUrl: this.$store.state.configuration.VUE_APP_DJANGO_API_BASE,
suscribe: !this.is_suscriber,
projectSlug: this.$route.params.slug,
})
.then((data) => {
this.is_suscriber = data.is_suscriber;
this.CLOSE_PROJECT_MODAL();
if (this.is_suscriber) {
this.DISPLAY_MESSAGE({
comment: 'Vous êtes maintenant abonné aux notifications de ce projet.', level: 'positive'
});
} else {
this.DISPLAY_MESSAGE({
comment: 'Vous ne recevrez plus les notifications de ce projet.', level: 'negative'
});
}
});
},
deleteProject() {
projectAPI.deleteProject(this.API_BASE_URL, this.slug)
.then((response) => {
if (response === 'success') {
this.$router.push('/');
this.DISPLAY_MESSAGE({
comment: `Le projet ${this.project.title} a bien été supprimé.`, level: 'positive'
});
} else {
this.DISPLAY_MESSAGE({
comment: `Une erreur est survenu lors de la suppression du projet ${this.project.title}.`,
level: 'negative'
});
}
});
},
deleteFeatureType() {
featureTypeAPI.deleteFeatureType(this.featureTypeToDelete.slug)
.then((response) => {
this.CLOSE_PROJECT_MODAL();
if (response === 'success') {
this.GET_PROJECT(this.slug);
this.retrieveProjectInfo();
this.DISPLAY_MESSAGE({
comment: `Le type de signalement ${this.featureTypeToDelete.title} a bien été supprimé.`,
level: 'positive',
});
} else {
this.DISPLAY_MESSAGE({
comment: `Une erreur est survenu lors de la suppression du type de signalement ${this.featureTypeToDelete.title}.`,
level: 'negative',
});
}
this.featureTypeToDelete = null;
})
.catch(() => {
this.DISPLAY_MESSAGE({
comment: `Une erreur est survenu lors de la suppression du type de signalement ${this.featureTypeToDelete.title}.`,
level: 'negative',
});
this.CLOSE_PROJECT_MODAL();
});
},
handleModalAction(e) {
switch (e) {
case 'subscribe':
this.subscribeProject();
break;
case 'deleteProject':
this.deleteProject();
break;
case 'deleteFeatureType':
this.deleteFeatureType();
break;
}
},
toggleDeleteFeatureTypeModal(featureType) {
this.featureTypeToDelete = featureType;
this.OPEN_PROJECT_MODAL('deleteFeatureType');
},
/**
* Initializes the map if the project is accessible and the user has view permissions.
* This method sets up the map, loads vector tile layers, and handles offline features.
*/
async initMap() {
// Check if the project is accessible and the user has view permissions
if (this.project && this.permissions.can_view_project) {
// Initialize the map using the provided element reference
await this.INITIATE_MAP({ el: this.$refs.map });
// Check for offline features
this.checkForOfflineFeature();
// Define the URL for vector tile layers
const mvtUrl = `${this.API_BASE_URL}features.mvt`;
// Define parameters for loading layers
const params = {
project_slug: this.slug,
featureTypes: this.feature_types,
queryParams: {
ordering: this.project.feature_browsing_default_sort,
filter: this.project.feature_browsing_default_filter,
},
};
// Add vector tile layers to the map
mapService.addVectorTileLayer({
url: mvtUrl,
...params
});
// Modify offline feature properties (setting color to 'red')
this.arraysOffline.forEach((x) => (x.geojson.properties.color = 'red'));
// Extract offline features from arraysOffline
const featuresOffline = this.arraysOffline.map((x) => x.geojson);
// Add offline features to the map if available
if (featuresOffline && featuresOffline.length > 0) {
mapService.addFeatures({
addToMap: true,
features: featuresOffline,
...params
});
}
// Get the bounding box of features and fit the map to it
featureAPI.getFeaturesBbox(this.slug).then((bbox) => {
if (bbox) {
mapService.fitBounds(bbox);
}
this.mapLoading = false; // Mark map loading as complete
});
}
},
updateAfterImport() {
// reload feature types
this.featureTypesLoading = true;
this.GET_PROJECT_FEATURE_TYPES(this.slug)
.then(() => {
this.featureTypesLoading = false;
});
// reload last features
this.$refs.lastFeatures.fetchLastFeatures();
// reload map
const map = mapService.getMap();
if (map) mapService.destroyMap();
this.mapLoading = true;
this.initMap();
},
},
};
</script>
<style lang="less" scoped>
.fullwidth {
width: 100%;
}
.block-map {
display: flex !important;
flex-direction: column;
.map-container {
position: relative;
height: 100%;
#map {
border: 1px solid grey;
}
}
.button {
margin-top: 0.5em;
}
}
div.geolocation-container {
/* each button have .5em space between, zoom buttons are 60px high and full screen button is 34px high */
top: calc(1.1em + 60px);
}
</style>
<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>
<div
class="
ui
small
button
circular
compact
absolute-right
icon
teal
"
data-tooltip="Consulter la documentation"
data-position="right center"
data-variation="mini"
@click="goToDocumentationFeature"
>
<i class="question icon" />
</div>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="feature_assignement"
v-model="form.feature_assignement"
class="hidden"
type="checkbox"
name="feature_assignement"
>
<label for="feature_assignement">Activation de l'assignation de signalements aux membres du projet</label>
</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&nbsp;de&nbsp;carte disponible à&nbsp;cette&nbsp;échelle
</div>
</div>
</div>
</div>
</div>
<div
v-if="filteredAttributes.length > 0"
class="ui horizontal divider"
>
ATTRIBUTS
</div>
<div class="fields grouped">
<ProjectAttributeForm
v-for="(attribute, index) in filteredAttributes"
: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: {
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: '' },
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,
feature_assignement: 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;
},
/**
* Filter out attribute of field type list without option
*/
filteredAttributes() {
return this.projectAttributes.filter(attr => attr.field_type === 'boolean' || attr.options);
},
},
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);
}
},
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() {
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 });
}
},
goToDocumentationFeature() {
window.open(this.$store.state.configuration.VUE_APP_URL_DOCUMENTATION_FEATURE);
}
},
};
</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>
<template> <template>
<div class="fourteen wide column"> <div id="project-members">
<h1 class="ui header">Gérer les membres</h1> <h1 class="ui header">
Gérer les membres
</h1>
<h4>Ajouter un membre</h4> <h4>Ajouter un membre</h4>
<div id="form-feature-edit" class="ui form" name="add-member"> <div
id="form-feature-edit"
class="ui form"
name="add-member"
>
<div class="two fields"> <div class="two fields">
<div class="field"> <div class="field">
<!-- <label :for="">{{ form.title.label }}</label> -->
<!-- <input type="text" v-model="newMember.name" /> -->
<Dropdown <Dropdown
:options="userOptions" :options="userOptions"
:selected="newMember.user.name" :selected="newMember.user.name"
...@@ -16,14 +20,19 @@ ...@@ -16,14 +20,19 @@
:search="true" :search="true"
:clearable="true" :clearable="true"
/> />
<ul id="errorlist" class="errorlist"> <ul
<li v-for="error in newMember.errors" :key="error"> id="errorlist"
class="errorlist"
>
<li
v-for="error in newMember.errors"
:key="error"
>
{{ error }} {{ error }}
</li> </li>
</ul> </ul>
</div> </div>
<div class="field"> <div class="field">
<!-- <label for="add-member"></label> -->
<Dropdown <Dropdown
:options="levelOptions" :options="levelOptions"
:selected="newMember.role.name" :selected="newMember.role.name"
...@@ -31,20 +40,32 @@ ...@@ -31,20 +40,32 @@
/> />
</div> </div>
</div> </div>
<button @click="addMember" type="button" class="ui teal icon button"> <button
<i class="white save icon"></i> type="button"
class="ui green icon button"
:disabled="!newMember.user.name"
@click="addMember"
>
<i
class="white add icon"
aria-hidden="true"
/>
<span class="padding-1">Ajouter</span> <span class="padding-1">Ajouter</span>
</button> </button>
</div> </div>
<!-- <input type="text" v-model="newMember.name" /> -->
<h4>Modifier le rôle d'un membre</h4> <h4>Modifier le rôle d'un membre</h4>
<div id="form-members" class="ui form"> <div
<table class="ui red table"> id="form-members"
class="ui form"
>
<table
class="ui red table"
aria-describedby="Table des membres du projet"
>
<thead> <thead>
<tr> <tr>
<th> <th scope="col">
Membre Membre
<i <i
:class="{ :class="{
...@@ -52,10 +73,11 @@ ...@@ -52,10 +73,11 @@
up: isSortedDesc('member'), up: isSortedDesc('member'),
}" }"
class="icon sort" class="icon sort"
aria-hidden="true"
@click="changeSort('member')" @click="changeSort('member')"
/> />
</th> </th>
<th> <th scope="col">
Niveau d'autorisation Niveau d'autorisation
<i <i
:class="{ :class="{
...@@ -63,17 +85,19 @@ ...@@ -63,17 +85,19 @@
up: isSortedDesc('role'), up: isSortedDesc('role'),
}" }"
class="icon sort" class="icon sort"
aria-hidden="true"
@click="changeSort('role')" @click="changeSort('role')"
/> />
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="member in projectMembers" :key="member.username"> <tr
v-for="member in projectMembers"
:key="member.username"
>
<td> <td>
{{ member.user.last_name }} {{ member.user.first_name }}<br /><i {{ member.user.last_name }} {{ member.user.first_name }}<br><em>{{ member.user.username }}</em>
>{{ member.user.username }}</i
>
</td> </td>
<td> <td>
<div class="required field online"> <div class="required field online">
...@@ -84,11 +108,15 @@ ...@@ -84,11 +108,15 @@
:search="true" :search="true"
/> />
<button <button
@click="removeMember(member)"
type="button" type="button"
class="ui icon button" class="ui icon button button-hover-red"
data-tooltip="Retirer ce membre"
@click="removeMember(member)"
> >
<i class="times icon"></i> <i
class="times icon"
aria-hidden="true"
/>
</button> </button>
</div> </div>
</td> </td>
...@@ -96,10 +124,17 @@ ...@@ -96,10 +124,17 @@
</tbody> </tbody>
</table> </table>
<div class="ui divider"></div> <div class="ui divider" />
<button @click="saveMembers" type="button" class="ui teal icon button"> <button
<i class="white save icon"></i> Enregistrer les changements type="button"
class="ui teal icon button"
@click="saveMembers"
>
<i
class="white save icon"
aria-hidden="true"
/>&nbsp;Enregistrer les changements
</button> </button>
</div> </div>
</div> </div>
...@@ -107,16 +142,15 @@ ...@@ -107,16 +142,15 @@
<script> <script>
import axios from '@/axios-client.js'; import axios from '@/axios-client.js';
import frag from "vue-frag";
import { mapGetters } from "vuex"; import { mapMutations, mapState } from 'vuex';
import Dropdown from "@/components/Dropdown.vue";
import Dropdown from '@/components/Dropdown.vue';
import { formatUserOption } from '@/utils';
export default { export default {
name: "Project_members", name: 'ProjectMembers',
directives: {
frag,
},
components: { components: {
Dropdown, Dropdown,
}, },
...@@ -125,62 +159,53 @@ export default { ...@@ -125,62 +159,53 @@ export default {
return { return {
projectUsers: [], projectUsers: [],
options: [ options: [
{ name: "Utilisateur connecté", value: "logged_user" }, { name: 'Utilisateur connecté', value: 'logged_user' },
{ name: "Contributeur", value: "contributor" }, { name: 'Contributeur', value: 'contributor' },
{ name: "Super Contributeur", value: "super_contributor" }, { name: 'Super Contributeur', value: 'super_contributor' },
{ name: "Modérateur", value: "moderator" }, { name: 'Modérateur', value: 'moderator' },
{ name: "Administrateur projet", value: "admin" }, { name: 'Administrateur projet', value: 'admin' },
], ],
newMember: { newMember: {
errors: [], errors: [],
user: { user: {
name: "", name: '',
value: "", value: '',
}, },
role: { role: {
name: "Contributeur", name: 'Contributeur',
value: "contributor", value: 'contributor',
}, },
}, },
sort: { sort: {
column: "", column: '',
ascending: true, ascending: true,
}, },
}; };
}, },
computed: { computed: {
...mapGetters(["project"]), ...mapState('projects', ['project']),
userOptions: function () { userOptions: function () {
return this.projectUsers return this.projectUsers
.filter((el) => el.userLevel.value === "logged_user") .filter((el) => el.userLevel.value === 'logged_user')
.map((el) => { .map((el) => formatUserOption(el.user)); // Format user data to fit dropdown option structure
let name = el.user.first_name || "";
if (el.user.last_name) {
name = name + " " + el.user.last_name;
}
return {
name: [name, el.user.username],
value: el.user.id,
};
});
}, },
levelOptions: function () { levelOptions: function () {
return this.options.filter( return this.options.filter(
(el) => (el) =>
(this.project.moderation ? el : el.value !== "moderator") && (this.project && this.project.moderation ? el : el.value !== 'moderator') &&
el.value !== "logged_user" el.value !== 'logged_user'
); );
}, },
projectMembers() { projectMembers() {
return this.projectUsers return this.projectUsers
.filter((el) => el.userLevel.value !== "logged_user") .filter((el) => el.userLevel.value !== 'logged_user')
.sort((a, b) => { .sort((a, b) => {
if (this.sort.column !== "") { if (this.sort.column !== '') {
if (this.sort.column === "member") { if (this.sort.column === 'member') {
const textA = a.user.username.toUpperCase(); const textA = a.user.username.toUpperCase();
const textB = b.user.username.toUpperCase(); const textB = b.user.username.toUpperCase();
if (this.sort.ascending) { if (this.sort.ascending) {
...@@ -188,7 +213,7 @@ export default { ...@@ -188,7 +213,7 @@ export default {
} else { } else {
return textA > textB ? -1 : textA < textB ? 1 : 0; return textA > textB ? -1 : textA < textB ? 1 : 0;
} }
} else if (this.sort.column === "role") { } else if (this.sort.column === 'role') {
const textA = a.userLevel.name.toUpperCase(); const textA = a.userLevel.name.toUpperCase();
const textB = b.userLevel.name.toUpperCase(); const textB = b.userLevel.name.toUpperCase();
if (this.sort.ascending) { if (this.sort.ascending) {
...@@ -204,11 +229,30 @@ export default { ...@@ -204,11 +229,30 @@ export default {
}, },
}, },
created() {
if (!this.project) {
this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug);
this.$store.dispatch('projects/GET_PROJECT_INFO', this.$route.params.slug);
}
this.populateMembers();
},
destroyed() {
//* allow user to change page if ever stuck on loader
this.DISCARD_LOADER();
},
methods: { methods: {
...mapMutations([
'DISPLAY_MESSAGE',
'DISPLAY_LOADER',
'DISCARD_LOADER'
]),
validateNewMember() { validateNewMember() {
this.newMember.errors = []; this.newMember.errors = [];
if (!this.newMember.user.value) { if (!this.newMember.user.value) {
this.newMember.errors.push("Veuillez compléter ce champ."); this.newMember.errors.push('Veuillez compléter ce champ.');
return false; return false;
} }
return true; return true;
...@@ -226,7 +270,7 @@ export default { ...@@ -226,7 +270,7 @@ export default {
if (this.validateNewMember()) { if (this.validateNewMember()) {
this.changeUserRole(this.newMember.user.value, this.newMember.role); this.changeUserRole(this.newMember.user.value, this.newMember.role);
//* empty add form //* empty add form
this.newMember.user = { value: "", name: "" }; this.newMember.user = { value: '', name: '' };
} }
}, },
...@@ -249,62 +293,74 @@ export default { ...@@ -249,62 +293,74 @@ export default {
removeMember(member) { removeMember(member) {
this.changeUserRole(member.user.id, { this.changeUserRole(member.user.id, {
name: "Utilisateur connecté", name: 'Utilisateur connecté',
value: "logged_user", value: 'logged_user',
}); });
}, },
/**
* Saves the updated members and their roles for a project.
* Displays a loader while the update is in progress and provides feedback upon completion or error.
*/
saveMembers() { saveMembers() {
// Display a loader to indicate that the update process is ongoing
this.DISPLAY_LOADER('Mise à jour des membres du projet en cours ...');
// Prepare the data to be sent in the API request
const data = this.projectUsers.map((member) => { const data = this.projectUsers.map((member) => {
return { return {
user: member.user, user: member.user,
level: { level: {
display: member.userLevel.name, display: member.userLevel.name, // Display name of the user level
codename: member.userLevel.value, codename: member.userLevel.value, // Codename of the user level
}, },
}; };
}); });
// Make an API request to update the project members
axios axios
.put( .put(
`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.project.slug}/utilisateurs/`, `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.project.slug}/utilisateurs/`,
data data
) )
.then((response) => { .then((response) => {
// Check if the response status is 200 (OK)
if (response.status === 200) { if (response.status === 200) {
this.$store.dispatch("GET_USER_LEVEL_PROJECTS"); //* update user status in top right menu // Dispatch an action to update the user status in the top right menu
this.$store.commit("DISPLAY_MESSAGE", "Permissions mises à jour"); this.$store.dispatch('GET_USER_LEVEL_PROJECTS');
// Display a positive message indicating success
this.DISPLAY_MESSAGE({ comment: 'Permissions mises à jour avec succès', level: 'positive' });
} else { } else {
this.$store.commit( // Display a generic error message if the response status is not 200
"DISPLAY_MESSAGE", this.DISPLAY_MESSAGE({
"Une erreur s'est produite pendant la mises à jour des permissions" comment: "Une erreur s'est produite pendant la mises à jour des permissions",
); level: 'negative'
});
} }
// Hide the loader regardless of the request result
this.DISCARD_LOADER();
}) })
.catch((error) => { .catch((error) => {
throw error; // Hide the loader if an error occurs
}); this.DISCARD_LOADER();
}, // Determine the error message to display
const errorMessage = error.response && error.response.data && error.response.data.error
fetchMembers() { ? error.response.data.error
// todo: move function to a service : "Une erreur s'est produite pendant la mises à jour des permissions";
return axios // Display the error message
.get( this.DISPLAY_MESSAGE({
`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/utilisateurs` comment: errorMessage,
) level: 'negative'
.then((response) => response.data) });
.catch((error) => { // Log the error to the console for debugging
throw error; console.error(error);
}); });
}, },
populateMembers() { populateMembers() {
this.$store.commit( this.DISPLAY_LOADER('Récupération des membres en cours...');
"DISPLAY_LOADER", this.$store.dispatch('projects/GET_PROJECT_USERS', this.$route.params.slug).then((members) => {
"Récupération des membres en cours..." this.DISCARD_LOADER();
);
this.fetchMembers().then((members) => {
this.$store.commit("DISCARD_LOADER");
this.projectUsers = members.map((el) => { this.projectUsers = members.map((el) => {
return { return {
userLevel: { name: el.level.display, value: el.level.codename }, userLevel: { name: el.level.display, value: el.level.codename },
...@@ -314,18 +370,6 @@ export default { ...@@ -314,18 +370,6 @@ export default {
}); });
}, },
}, },
created() {
if (!this.project) {
this.$store.dispatch("GET_PROJECT_INFO", this.$route.params.slug);
}
this.populateMembers();
},
destroyed() {
//* allow user to change page if ever stuck on loader
this.$store.commit("DISCARD_LOADER");
},
}; };
</script> </script>
......
<template>
<div id="projects">
<h2 class="ui horizontal divider header">
PROJETS
</h2>
<div class="flex">
<router-link
v-if="user && user.can_create_project && isOnline"
:to="{ name: 'project_create', params: { action: 'create' } }"
class="ui green basic button"
data-test="create-project"
>
<i
class="plus icon"
aria-hidden="true"
/>
Créer un nouveau projet
</router-link>
<router-link
v-if="user && user.can_create_project && isOnline"
:to="{
name: 'project_type_list',
}"
class="ui blue basic button"
data-test="to-project-models"
>
<i
class="copy icon"
aria-hidden="true"
/>
Accéder à la liste des modèles de projets
</router-link>
</div>
<!-- FILTRES DES PROJETS -->
<ProjectsMenu
:loading="loading"
@filter="setProjectsFilters"
@getData="getData"
@loading="setLoader"
/>
<div
v-if="configuration.DISPLAY_FORBIDDEN_PROJECTS"
id="forbidden-projects"
class="ui toggle checkbox margin-top"
>
<input
:checked="displayForbiddenProjects"
type="checkbox"
@input="toggleForbiddenProjects"
>
<label>
N'afficher que les projets disponibles à la consultation
</label>
</div>
<!-- LISTE DES PROJETS -->
<div
v-if="projects"
class="ui divided items dimmable dimmed"
data-test="project-list"
>
<div :class="['ui inverted dimmer', { active: loading }]">
<div class="ui loader" />
</div>
<ProjectsListItem
v-for="project in projects"
:key="project.slug"
:project="project"
/>
<span
v-if="!projects || projects.length === 0"
>
Vous n'avez accès à aucun projet.
</span>
<!-- PAGINATION -->
<Pagination
v-if="count"
:nb-pages="nbPages"
@page-update="changePage"
/>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions } from 'vuex';
import ProjectsMenu from '@/components/Projects/ProjectsMenu';
import ProjectsListItem from '@/components/Projects/ProjectsListItem';
import Pagination from '@/components/Pagination';
export default {
name: 'ProjectsList',
components: {
ProjectsMenu,
ProjectsListItem,
Pagination
},
data() {
return {
loading: false,
displayForbiddenProjects: false
};
},
computed: {
...mapState([
'configuration',
'user',
'isOnline',
]),
...mapState('projects', [
'projects',
'count',
'filters',
]),
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
nbPages() {
return Math.ceil(this.count / 10);
}
},
created() {
this.SET_CURRENT_PAGE(1);
// Empty stored text to search
this.SET_PROJECTS_SEARCH_STATE({ text: null });
// Empty stored project list
this.$store.commit('projects/SET_PROJECT', null);
// Init display of restricted access projects
this.displayForbiddenProjects = this.configuration.DISPLAY_FORBIDDEN_PROJECTS_DEFAULT;
this.setForbiddenProjectsFilter(true);
},
methods: {
...mapMutations('projects', [
'SET_CURRENT_PAGE',
'SET_PROJECTS_FILTER',
'SET_PROJECTS_SEARCH_STATE',
]),
...mapActions('projects', [
'GET_PROJECTS',
]),
getData(page) {
this.loading = true;
this.GET_PROJECTS({ page })
.then(() => {
this.loading = false;
})
.catch(() => {
this.loading = false;
});
},
setLoader(e) {
this.loading = e;
},
changePage(e) {
this.getData(e);
},
setProjectsFilters(e, noUpdate) {
this.SET_PROJECTS_FILTER(e);
// Reset the page number at filter change
this.SET_CURRENT_PAGE(1);
// Wait that all filters are set in store to fetch data when component is created
if (!noUpdate) {
this.getData();
}
},
toggleForbiddenProjects(e) {
this.displayForbiddenProjects = e.target.checked;
this.setForbiddenProjectsFilter();
},
setForbiddenProjectsFilter(noUpdate) {
this.setProjectsFilters({
filter: 'accessible',
value: this.displayForbiddenProjects ? 'true' : null
}, noUpdate);
},
}
};
</script>
<style lang="less" scoped>
#projects {
margin: 0 auto;
.dimmable {
.dimmer {
.loader {
top: 25%;
}
}
}
}
.flex {
display: flex;
justify-content: space-between;
}
#filters-divider {
padding-top: 0;
color: gray !important;
}
#forbidden-projects.checkbox {
font-size: 1.2em;
font-weight: 600;
label {
color: rgb(94, 94, 94);
}
input:checked ~ label::before {
background-color: var(--primary-color, #008c86) !important;
}
input:checked ~ label {
color: var(--primary-color, #008c86) !important;
}
}
</style>
\ No newline at end of file
<template>
<div id="projects-types">
<h3 class="ui header">
Créer un projet à partir d'un modèle disponible:
</h3>
<div class="ui divided items">
<div
v-for="project in project_types"
:key="project.slug"
class="item"
>
<div class="ui tiny image">
<img
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
alt="Image associé au projet"
>
</div>
<div class="middle aligned content">
<div class="description">
<router-link
:to="{
name: 'project_create_from',
params: {
slug: project.slug,
},
}"
>
{{ project.title }}
</router-link>
<p>{{ project.description }}</p>
<strong>Projet {{ project.moderation ? '' : 'non' }} modéré</strong>
</div>
<div class="meta">
<span data-tooltip="Date de création">
{{ project.created_on }}&nbsp;
<i
class="calendar icon"
aria-hidden="true"
/>
</span>
</div>
<div class="meta">
<span data-tooltip="Visibilité des signalement publiés">
{{ project.access_level_pub_feature }}&nbsp;<i
class="eye icon"
aria-hidden="true"
/>
</span>
<span data-tooltip="Visibilité des signalement archivés">
{{ project.access_level_arch_feature }}&nbsp;<i
class="archive icon"
aria-hidden="true"
/>
</span>
</div>
</div>
</div>
<span
v-if="!project_types || project_types.length === 0"
>Aucun projet type n'est défini.</span>
</div>
</div>
</template>
<script>
import projectAPI from '@/services/project-api';
export default {
name: 'ProjectTypeList',
data() {
return {
project_types: null,
};
},
computed: {
DJANGO_BASE_URL: function () {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
API_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_API_BASE;
},
},
mounted() {
projectAPI.getProjectTypes(this.API_BASE_URL)
.then((data) => {
if (data) {
this.project_types = data;
}
});
},
methods: {
refreshId() {
const crypto = window.crypto || window.msCrypto;
var array = new Uint32Array(1);
return '?ver=' + crypto.getRandomValues(array); // Compliant for security-sensitive use cases
},
},
};
</script>
<style lang="less" scoped>
#projects-types {
max-width: 800px !important;
}
</style>
\ No newline at end of file
<template>
<div v-frag>
<div v-frag v-if="feature">
<div class="row">
<div class="fourteen wide column">
<h1 class="ui header">
<div class="content">
{{ feature.title || feature.feature_id }}
<div class="ui icon right floated compact buttons">
<router-link
v-if="permissions && permissions.can_create_feature"
:to="{
name: 'ajouter-signalement',
params: {
slug_type_signal: $route.params.slug_type_signal,
},
}"
class="ui button button-hover-orange"
data-tooltip="Ajouter un signalement"
data-position="bottom left"
>
<i class="plus fitted icon"></i>
</router-link>
<router-link
v-if="
(permissions && permissions.can_update_feature) ||
isFeatureCreator ||
isModerator
"
:to="{
name: 'editer-signalement',
params: {
slug_signal: $route.params.slug_signal,
slug_type_signal: $route.params.slug_type_signal,
},
}"
class="ui button button-hover-orange"
>
<i class="inverted grey pencil alternate icon"></i>
</router-link>
<!-- (permissions && permissions.can_delete_feature) || -->
<a
v-if="
isFeatureCreator || permissions.is_project_super_contributor
"
@click="isCanceling = true"
id="feature-delete"
class="ui button button-hover-red"
>
<i class="inverted grey trash alternate icon"></i>
</a>
</div>
<div class="ui hidden divider"></div>
<div class="sub header prewrap">
{{ feature.description }}
</div>
</div>
</h1>
</div>
</div>
<div class="row">
<div class="seven wide column">
<table class="ui very basic table">
<tbody>
<div
v-frag
v-for="(field, index) in feature.feature_data"
:key="'field' + index"
>
<tr v-if="field">
<td>
<b>{{ field.label }}</b>
</td>
<td>
<b>
<i
v-if="field.field_type === 'boolean'"
:class="[
'icon',
field.value ? 'olive check' : 'grey times',
]"
></i>
<span v-else>
{{ field.value }}
</span>
</b>
</td>
</tr>
</div>
<tr>
<td>Auteur</td>
<td>{{ feature.display_creator }}</td>
</tr>
<tr>
<td>Statut</td>
<td>
<i v-if="feature.status" :class="['icon', statusIcon]"></i>
{{ statusLabel }}
</td>
</tr>
<tr>
<td>Date de création</td>
<td v-if="feature.created_on">
{{ feature.created_on | formatDate }}
</td>
</tr>
<tr>
<td>Date de dernière modification</td>
<td v-if="feature.updated_on">
{{ feature.updated_on | formatDate }}
</td>
</tr>
<tr>
<td>Date d'archivage automatique</td>
<td v-if="feature.archived_on">
{{ feature.archived_on }}
</td>
</tr>
<tr>
<td>Date de suppression automatique</td>
<td v-if="feature.deletion_on">
{{ feature.deletion_on }}
</td>
</tr>
</tbody>
</table>
<h3>Liaison entre signalements</h3>
<table class="ui very basic table">
<tbody>
<tr
v-for="(link, index) in linked_features"
:key="link.feature_to.title + index"
>
<td v-if="link.feature_to.feature_type_slug">
{{ link.relation_type_display }}
<a @click="pushNgo(link)">{{ link.feature_to.title }} </a>
({{ link.feature_to.display_creator }} -
{{ link.feature_to.created_on }})
</td>
</tr>
</tbody>
</table>
</div>
<div class="seven wide column">
<div id="map" ref="map"></div>
</div>
</div>
<div class="row">
<div class="seven wide column">
<h2 class="ui header">Pièces jointes</h2>
<div v-for="pj in attachments" :key="pj.id" class="ui divided items">
<div class="item">
<a
class="ui tiny image"
target="_blank"
:href="pj.attachment_file"
>
<img
:src="
pj.extension === '.pdf'
? require('@/assets/img/pdf.png')
: pj.attachment_file
"
/>
</a>
<div class="middle aligned content">
<a class="header" target="_blank" :href="pj.attachment_file">{{
pj.title
}}</a>
<div class="description">
{{ pj.info }}
</div>
</div>
</div>
</div>
<i v-if="attachments.length === 0"
>Aucune pièce jointe associée au signalement.</i
>
</div>
<div class="seven wide column">
<h2 class="ui header">Activité et commentaires</h2>
<div id="feed-event" class="ui feed">
<div v-frag v-for="(event, index) in events" :key="'event' + index">
<div v-frag v-if="event.event_type === 'create'">
<div v-if="event.object_type === 'feature'" class="event">
<div class="content">
<div class="summary">
<div class="date">
{{ event.created_on }}
</div>
Création du signalement
<span v-if="user">par {{ event.display_user }}</span>
</div>
</div>
</div>
<div v-else-if="event.object_type === 'comment'" class="event">
<div class="content">
<div class="summary">
<div class="date">
{{ event.created_on }}
</div>
Commentaire
<span v-if="user">par {{ event.display_user }}</span>
</div>
<div class="extra text">
{{ event.related_comment.comment }}
<div v-frag v-if="event.related_comment.attachment">
<br /><a
:href="
DJANGO_BASE_URL +
event.related_comment.attachment.url
"
target="_blank"
><i class="paperclip fitted icon"></i>
{{ event.related_comment.attachment.title }}</a
>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="event.event_type === 'update'" class="event">
<div class="content">
<div class="summary">
<div class="date">
{{ event.created_on }}
</div>
Signalement mis à jour
<span v-if="user">par {{ event.display_user }}</span>
</div>
</div>
</div>
</div>
</div>
<div
v-if="permissions && permissions.can_create_feature"
class="ui segment"
>
<form
id="form-comment"
class="ui form"
method="POST"
enctype="multipart/form-data"
>
<div class="required field">
<label :for="comment_form.comment.id_for_label"
>Ajouter un commentaire</label
>
{{ comment_form.comment.errors }}
<textarea
v-model="comment_form.comment.value"
:name="comment_form.comment.html_name"
rows="2"
></textarea>
</div>
<label>Pièce jointe (facultative)</label>
<div class="two fields">
<div class="field">
<label class="ui icon button" for="attachment_file">
<i class="paperclip icon"></i>
<span class="label">{{
comment_form.attachment_file.value
? comment_form.attachment_file.value
: "Sélectionner un fichier ..."
}}</span>
</label>
<input
type="file"
accept="application/pdf, image/jpeg, image/png"
style="display: none"
name="attachment_file"
id="attachment_file"
@change="onFileChange"
/>
</div>
<div class="field">
<input
v-model="comment_form.title.value"
type="text"
:name="comment_form.title.html_name"
:id="comment_form.title.id_for_label"
/>
{{ comment_form.title.errors }}
</div>
</div>
<ul v-if="comment_form.attachment_file.errors" class="errorlist">
<li>
{{ comment_form.attachment_file.errors }}
</li>
</ul>
<button
@click="postComment"
type="button"
class="ui compact green icon button"
>
<i class="plus icon"></i> Poster le commentaire
</button>
</form>
</div>
</div>
</div>
<div
v-if="isCanceling"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
:class="[
'ui mini modal subscription',
{ 'active visible': isCanceling },
]"
>
<i @click="isCanceling = false" class="close icon"></i>
<div class="ui icon header">
<i class="trash alternate icon"></i>
Supprimer le signalement
</div>
<div class="actions">
<form
action="{% url 'geocontrib:feature_delete' slug=feature.project.slug feature_type_slug=feature.feature_type.slug feature_id=feature.feature_id %}"
method="POST"
>
<input type="hidden" name="_method" value="delete" />
<button
@click="deleteFeature"
type="button"
class="ui red compact fluid button"
>
Confirmer la suppression
</button>
</form>
</div>
</div>
</div>
</div>
<div v-frag v-else>Pas de signalement correspondant trouvé</div>
</div>
</template>
<script>
import frag from "vue-frag";
import { mapGetters, mapState } from "vuex";
import { mapUtil } from "@/assets/js/map-util.js";
import featureAPI from "@/services/feature-api";
import axios from '@/axios-client.js';
// axios.defaults.headers.common['X-CSRFToken'] = (name => {
// var re = new RegExp(name + "=([^;]+)");
// var value = re.exec(document.cookie);
// return (value !== null) ? unescape(value[1]) : null;
// })('csrftoken');
export default {
name: "Feature_detail",
directives: {
frag,
},
data() {
return {
isCanceling: false,
attachments: [],
events: [],
comment_form: {
attachment_file: {
errors: null,
value: null,
file: null,
},
title: {
id_for_label: "title",
html_name: "title",
errors: null,
value: null,
},
comment: {
id_for_label: "add-comment",
html_name: "add-comment",
errors: null,
value: null,
},
non_field_errors: [],
},
};
},
computed: {
...mapState(["user", "USER_LEVEL_PROJECTS"]),
...mapGetters(["permissions", "project"]),
...mapState("feature", ["linked_features", "statusChoices"]),
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
feature() {
const result = this.$store.state.feature.features.find(
(el) => el.feature_id === this.$route.params.slug_signal
);
return result;
},
isFeatureCreator() {
if (this.feature && this.user) {
return this.feature.creator === this.user.id;
}
return false;
},
isModerator() {
return this.USER_LEVEL_PROJECTS &&
this.USER_LEVEL_PROJECTS[this.project.slug] === "Modérateur"
? true
: false;
},
statusIcon() {
switch (this.feature.status) {
case "archived":
return "grey archive";
case "pending":
return "teal hourglass outline";
case "published":
return "olive check";
case "draft":
return "orange pencil alternate";
default:
return "";
}
},
statusLabel() {
const status = this.statusChoices.find(
(el) => el.value === this.feature.status
);
return status ? status.name : "";
},
},
filters: {
formatDate(value) {
let date = new Date(value);
date = date.toLocaleString().replace(",", "");
return date.substr(0, date.length - 3); //* quick & dirty way to remove seconds from date
},
},
methods: {
pushNgo(link) {
this.$router.push({
name: "details-signalement",
params: {
slug_type_signal: link.feature_to.feature_type_slug,
slug_signal: link.feature_to.feature_id,
},
});
this.getFeatureEvents();
this.getFeatureAttachments();
this.getLinkedFeatures();
this.addFeatureToMap();
},
postComment() {
featureAPI
.postComment({
featureId: this.$route.params.slug_signal,
comment: this.comment_form.comment.value,
})
.then((response) => {
if (response && this.comment_form.attachment_file.file) {
featureAPI
.postCommentAttachment({
featureId: this.$route.params.slug_signal,
file: this.comment_form.attachment_file.file,
fileName: this.comment_form.title.file,
commentId: response.data.id,
title: response.data.comment,
})
.then(() => {
this.confirmComment();
});
} else {
this.confirmComment();
}
});
},
confirmComment() {
this.$store.commit("DISPLAY_MESSAGE", "Ajout du commentaire confirmé");
this.getFeatureEvents(); //* display new comment on the page
this.comment_form.attachment_file.file = null;
this.comment_form.attachment_file.value = null;
this.comment_form.title.file = null;
this.comment_form.title.value = null;
this.comment_form.comment.value = null;
},
validateImgFile(files, handleFile) {
let url = window.URL || window.webkitURL;
let image = new Image();
image.onload = function () {
handleFile(true);
};
image.onerror = function () {
handleFile(false);
};
image.src = url.createObjectURL(files);
URL.revokeObjectURL(image.src);
},
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) {
const period = files[0].name.lastIndexOf(".");
const fileName = files[0].name.substring(0, period);
const fileExtension = files[0].name.substring(period + 1);
const shortName = fileName.slice(0, 10) + "[...]." + fileExtension;
_this.comment_form.attachment_file.file = files[0]; //* store the file to post later
_this.comment_form.attachment_file.value = shortName; //* for display
_this.comment_form.title.value = shortName;
_this.comment_form.attachment_file.errors = null;
} else {
_this.comment_form.attachment_file.errors =
"Transférez une image valide. Le fichier que vous avez transféré n'est pas une image, ou il est corrompu.";
}
}
if (files.length) {
//* exception for pdf
if (files[0].type === "application/pdf") {
handleFile(true);
} else {
this.comment_form.attachment_file.errors = null;
//* check if file is an image and pass callback to handle file
this.validateImgFile(files[0], handleFile);
}
}
},
goBackToProject(message) {
this.$router.push({
name: "project_detail",
params: {
slug: this.$store.state.project_slug,
message,
},
});
},
deleteFeature() {
this.$store
.dispatch("feature/DELETE_FEATURE", this.feature.feature_id)
.then((response) => {
if (response.status === 204) {
this.$store.dispatch(
"feature/GET_PROJECT_FEATURES",
{
project_slug: this.$route.params.slug
}
);
this.goBackToProject();
}
});
},
initMap() {
var mapDefaultViewCenter =
this.$store.state.configuration.DEFAULT_MAP_VIEW.center;
var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
this.map = mapUtil.createMap(this.$refs.map, {
mapDefaultViewCenter,
mapDefaultViewZoom,
});
// Update link to feature list with map zoom and center
mapUtil.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);
}
});
});
layersToLoad.reverse();
}
mapUtil.addLayers(
layersToLoad,
this.$store.state.configuration.DEFAULT_BASE_MAP.SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP.OPTIONS
);
mapUtil.getMap().dragging.disable();
mapUtil.getMap().doubleClickZoom.disable();
mapUtil.getMap().scrollWheelZoom.disable();
this.addFeatureToMap();
},
addFeatureToMap() {
const currentFeatureId = this.$route.params.slug_signal;
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}features/${currentFeatureId}/?feature_type__slug=${this.$route.params.slug_type_signal}&output=geojson`;
axios
.get(url)
.then((response) => {
const feature = response.data;
if (feature) {
const currentFeature = [feature];
const featureGroup = mapUtil.addFeatures(
currentFeature,
{},
true,
this.$store.state.feature_type.feature_types
);
mapUtil
.getMap()
.fitBounds(featureGroup.getBounds(), { padding: [25, 25] });
}
})
.catch((error) => {
throw error;
});
},
getFeatureEvents() {
featureAPI
.getFeatureEvents(this.$route.params.slug_signal)
.then((data) => (this.events = data));
},
getFeatureAttachments() {
featureAPI
.getFeatureAttachments(this.$route.params.slug_signal)
.then((data) => (this.attachments = data));
},
getLinkedFeatures() {
featureAPI
.getFeatureLinks(this.$route.params.slug_signal)
.then((data) =>
this.$store.commit("feature/SET_LINKED_FEATURES", data)
);
},
},
created() {
this.$store.commit(
"feature_type/SET_CURRENT_FEATURE_TYPE_SLUG",
this.$route.params.slug_type_signal
);
this.getFeatureEvents();
this.getFeatureAttachments();
this.getLinkedFeatures();
},
mounted() {
this.$store.commit("DISPLAY_LOADER", "Recherche du signalement");
if (!this.project) {
this.$store
.dispatch("GET_PROJECT_INFO", this.$route.params.slug)
.then(() => {
this.$store.commit("DISCARD_LOADER");
this.initMap();
});
} else {
this.$store.commit("DISCARD_LOADER");
this.initMap();
}
},
beforeDestroy() {
this.$store.commit("CLEAR_MESSAGES");
},
};
</script>
<style>
#map {
width: 100%;
height: 100%;
min-height: 250px;
max-height: 70vh;
}
#feed-event .event {
margin-bottom: 1em;
}
#feed-event .event .date {
margin-right: 1em !important;
}
#feed-event .event .extra.text {
margin-left: 107px;
margin-top: 0;
}
.prewrap {
white-space: pre-wrap;
}
</style>
\ No newline at end of file
<template>
<div v-frag>
<script
type="application/javascript"
:src="
baseUrl +
'resources/leaflet-control-geocoder-1.13.0/Control.Geocoder.js'
"
></script>
<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
type="text"
required
:maxlength="form.title.field.max_length"
:name="form.title.html_name"
:id="form.title.id_for_label"
v-model="form.title.value"
@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
:name="form.description.html_name"
rows="5"
v-model="form.description.value"
@blur="updateStore"
></textarea>
</div>
<!-- Geom Field -->
<div class="field">
<label :for="form.geom.id_for_label">{{ form.geom.label }}</label>
<!-- Import GeoImage -->
<div v-frag v-if="feature_type && feature_type.geom_type === 'point'">
<p v-if="isOffline() !== true">
<button
@click="toggleGeoRefModal"
id="add-geo-image"
type="button"
class="ui compact button"
>
<i class="file image icon"></i>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"></i>
<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"></i>
<span class="label">{{ geoRefFileLabel }}</span>
</label>
<input
type="file"
accept="image/jpeg, image/png"
style="display: none"
ref="file"
v-on:change="handleFileUpload"
name="image_file"
class="image_file"
id="image_file"
/>
<ul v-if="erreurUploadMessage" class="errorlist">
<li>
{{ erreurUploadMessage }}
</li>
</ul>
</div>
<button
@click="georeferencement"
id="get-geom-from-image-file"
type="button"
:class="[
'ui compact button',
file && !erreurUploadMessage ? 'green' : 'disabled',
{ red: erreurUploadMessage },
]"
>
<i class="plus icon"></i>
Importer
</button>
</form>
</div>
</div>
</div>
<p v-if="showGeoPositionBtn">
<button
@click="create_point_geoposition"
id="create-point-geoposition"
type="button"
class="ui compact button"
>
<i class="ui map marker alternate icon"></i>Positionner le
signalement à partir de votre géolocalisation
</button>
</p>
<span
id="erreur-geolocalisation"
v-if="erreurGeolocalisationMessage"
>
<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
type="hidden"
:name="form.geom.html_name"
:id="form.geom.id_for_label"
v-model="form.geom.value"
@blur="updateStore"
/>
<div class="ui tab active map-container" data-tab="map">
<div id="map" ref="map"></div>
<!-- // todo: ajouter v-if -->
<!-- {% if serialized_base_maps|length > 0 %} {% include
"geocontrib/map-layers/sidebar-layers.html" with
basemaps=serialized_base_maps layers=serialized_layers
project=project.slug%} {% endif %} -->
<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="form in attachmentFormset"
:key="form.dataKey"
:attachmentForm="form"
ref="attachementForm"
/>
</div>
<button
@click="add_attachement_formset"
id="add-attachment"
type="button"
class="ui compact basic button button-hover-green"
>
<i class="ui plus icon"></i>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="form in linkedFormset"
:key="form.dataKey"
:linkedForm="form"
:features="features"
ref="linkedForm"
/>
</div>
<button
@click="add_linked_formset"
id="add-link"
type="button"
class="ui compact basic button button-hover-green"
>
<i class="ui plus icon"></i>Ajouter une liaison
</button>
</div>
<div class="ui divider"></div>
<button @click="postForm" type="button" class="ui teal icon button">
<i class="white save icon"></i> 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 axios from '@/axios-client.js';
import flip from "@turf/flip";
// axios.defaults.headers.common["X-CSRFToken"] = ((name) => {
// var re = new RegExp(name + "=([^;]+)");
// var value = re.exec(document.cookie);
// return value !== null ? unescape(value[1]) : null;
// })("csrftoken");
export default {
name: "Feature_edit",
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(["project", "permissions"]),
...mapGetters("feature_type", ["feature_type"]),
...mapState(["user", "USER_LEVEL_PROJECTS"]),
...mapState("map", ["basemaps"]),
...mapState("feature", [
"attachmentFormset",
"attachmentsToDelete",
"attachmentsToPut",
"linkedFormset",
"features",
"extra_form",
"linked_features",
"statusChoices",
]),
field_title() {
if (this.feature_type) {
if (this.feature_type.title_optional) {
return "field";
}
}
return "required field";
},
currentRouteName() {
return this.$route.name;
},
feature: function () {
return this.$store.state.feature.features.find(
(el) => el.feature_id === this.$route.params.slug_signal
);
},
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) {
const isModerate = this.project.moderation;
const userStatus = this.USER_LEVEL_PROJECTS[this.project.slug];
const isOwnFeature = this.feature
? this.feature.creator === this.user.id //* prevent undefined feature
: false; //* si le contributeur est l'auteur du signalement
if (
//* si admin, modérateur ou super contributeur, statuts toujours disponible: Brouillon, Publié, Archivé
userStatus === "Modérateur" ||
userStatus === "Administrateur projet" ||
(userStatus === "Super Contributeur" && !isModerate)
) {
return this.statusChoices.filter((el) => el.value !== "pending");
} else if (userStatus === "Super Contributeur" && isModerate) {
return this.statusChoices.filter(
(el) => el.value === "draft" || el.value === "pending"
);
} else if (userStatus === "Contributeur") {
//* cas particuliers du contributeur
if (
this.currentRouteName === "ajouter-signalement" ||
!isOwnFeature
) {
//* même cas à l'ajout d'une feature ou si feature n'a pas été créé par le contributeur
return isModerate
? this.statusChoices.filter(
(el) => el.value === "draft" || el.value === "pending"
)
: this.statusChoices.filter(
(el) => el.value === "draft" || el.value === "published"
);
} else {
//* à l'édition d'une feature et si le contributeur est l'auteur de la feature
return isModerate
? this.statusChoices.filter(
(el) => el.value !== "published" //* toutes sauf "Publié"
)
: this.statusChoices.filter(
(el) => el.value !== "pending" //* toutes sauf "En cours de publication"
);
}
}
}
return [];
},
},
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() {
console.log("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);
console.log(">> formData >> ", formData);
axios
.post(url, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((response) => {
console.log("SUCCESS!!", response.data);
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.log({ 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 ? 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) {
console.log(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.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) {
//this.updateGeomField(wellknown.stringify(layer.toGeoJSON()))
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();
console.log("update map");
var geomType = this.feature_type.geom_type;
if (geomFeatureJSON) {
var geomJSON = flip(geomFeatureJSON.geometry); //turf.flip(geomFeatureJSON)
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) => {
console.log(response.data.features);
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(wellknown.stringify(layer.toGeoJSON()))
this.updateGeomField(layer.toGeoJSON());
if (this.feature_type.geomType === "point") {
this.showGeoPositionBtn = false;
this.erreurGeolocalisationMessage = "";
}
},
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));
},
},
created() {
this.$store.commit(
"feature_type/SET_CURRENT_FEATURE_TYPE_SLUG",
this.$route.params.slug_type_signal
);
if (this.$route.params.slug_signal) {
this.getFeatureAttachments();
this.getLinkedFeatures();
}
},
mounted() {
this.$store
.dispatch("GET_PROJECT_INFO", this.$route.params.slug)
.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");
},
};
</script>
<style>
#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, je ne comprends pas comment... */
.ui.right.floated.button {
float: right;
margin-right: 0;
margin-left: 0.25em;
}
/* // ! margin écrasé par class last-child first-child, pas normal ... */
.ui.segment {
margin: 1rem 0 !important;
}
/* override to display buttons under the dimmer of modal */
.leaflet-top,
.leaflet-bottom {
z-index: 800;
}
</style>
<template>
<div class="fourteen wide column">
<script
type="application/javascript"
:src="
baseUrl +
'resources/leaflet-control-geocoder-1.13.0/Control.Geocoder.js'
"
></script>
<div id="feature-list-container" class="ui grid mobile-column">
<div class="four wide column mobile-fullwidth">
<h1>Signalements</h1>
</div>
<div class="twelve-wide column no-padding-mobile mobile-fullwidth">
<div class="ui large text loader">Chargement</div>
<div class="ui secondary menu no-margin">
<a
@click="showMap = true"
:class="['item no-margin', { active: showMap }]"
data-tab="map"
data-tooltip="Carte"
><i class="map fitted icon"></i
></a>
<a
@click="showMap = false"
:class="['item no-margin', { active: !showMap }]"
data-tab="list"
data-tooltip="Liste"
><i class="list fitted icon"></i
></a>
<div class="item">
<h4>
{{ filteredFeatures.length }} signalement{{
filteredFeatures.length > 1 ? "s" : ""
}}
</h4>
</div>
<div
v-if="
project &&
feature_types.length > 0 &&
permissions.can_create_feature
"
class="item right"
>
<div
@click="showAddFeature = !showAddFeature"
class="ui dropdown button compact button-hover-green"
data-tooltip="Ajouter un signalement"
data-position="bottom left"
>
<i class="plus fitted icon"></i>
<div
v-if="showAddFeature"
class="menu transition visible"
style="z-index: 9999"
>
<div class="header">Ajouter un signalement du type</div>
<div class="scrolling menu text-wrap">
<router-link
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: type.slug },
}"
v-for="(type, index) in feature_types"
:key="type.slug + index"
class="item"
>
{{ type.title }}
</router-link>
</div>
</div>
</div>
<div
v-if="checkedFeatures.length"
@click="modalAllDelete"
class="ui button compact button-hover-red margin-left-25"
data-tooltip="Effacer tous les types de signalements sélectionnés"
data-position="left center"
data-variation="mini"
>
<i class="grey trash fitted icon"></i>
</div>
</div>
</div>
</div>
</div>
<form id="form-filters" class="ui form grid" action="" method="get">
<div class="field wide four column no-margin-mobile">
<label>Type</label>
<Dropdown
:options="form.type.choices"
:selected="form.type.selected"
:selection.sync="form.type.selected"
:search="true"
:clearable="true"
/>
</div>
<div class="field wide four column no-padding-mobile no-margin-mobile">
<label>Statut</label>
<!-- //* giving an object mapped on key name -->
<Dropdown
:options="statusChoices"
:selected="form.status.selected.name"
:selection.sync="form.status.selected"
:search="true"
:clearable="true"
/>
</div>
<div class="field wide four column">
<label>Nom</label>
<div class="ui icon input">
<i class="search icon"></i>
<div class="ui action input">
<input
type="text"
name="title"
v-model="form.title"
@input="onFilterChange"
/>
<button
type="button"
class="ui teal icon button"
id="submit-search"
>
<i class="search icon"></i>
</button>
</div>
</div>
</div>
<!-- map params, updated on map move -->
<input type="hidden" name="zoom" v-model="zoom" />
<input type="hidden" name="lat" v-model="lat" />
<input type="hidden" name="lng" v-model="lng" />
</form>
<div v-show="showMap" class="ui tab active map-container" data-tab="map">
<div id="map" ref="map"></div>
<SidebarLayers v-if="baseMaps && map" />
</div>
<FeatureListTable
v-show="!showMap"
:filteredFeatures="filteredFeatures"
:checkedFeatures.sync="checkedFeatures"
/>
<!-- MODAL ALL DELETE FEATURE TYPE -->
<div
v-if="modalAllDeleteOpen"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
:class="[
'ui mini modal subscription',
{ 'active visible': modalAllDeleteOpen },
]"
>
<i @click="modalAllDeleteOpen = false" class="close icon"></i>
<div class="ui icon header">
<i class="trash alternate icon"></i>
Êtes-vous sûr de vouloir effacer
<span v-if="checkedFeatures.length === 1"> un signalement ? </span>
<span v-else> ces {{ checkedFeatures.length }} signalements ? </span>
</div>
<div class="actions">
<button
@click="deleteAllFeatureSelection"
type="button"
class="ui red compact fluid button no-margin"
>
Confirmer la suppression
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters, mapState } from "vuex";
import { mapUtil } from "@/assets/js/map-util.js";
import SidebarLayers from "@/components/map-layers/SidebarLayers";
import FeatureListTable from "@/components/feature/FeatureListTable";
import Dropdown from "@/components/Dropdown.vue";
import axios from '@/axios-client.js';
// axios.defaults.headers.common['X-CSRFToken'] = (name => {
// var re = new RegExp(name + "=([^;]+)");
// var value = re.exec(document.cookie);
// return (value !== null) ? unescape(value[1]) : null;
// })('csrftoken');
export default {
name: "Feature_list",
components: {
SidebarLayers,
Dropdown,
FeatureListTable,
},
data() {
return {
modalAllDeleteOpen: false,
form: {
type: {
selected: null,
choices: [],
},
status: {
selected: {
name: null,
value: null,
},
choices: [
{
name: "Brouillon",
value: "draft",
},
{
name: "En attente de publication",
value: "pending",
},
{
name: "Publié",
value: "published",
},
{
name: "Archivé",
value: "archived",
},
],
},
title: null,
},
geojsonFeatures: [],
filterStatus: null,
filterType: null,
baseUrl: this.$store.state.configuration.BASE_URL,
map: null,
zoom: null,
lat: null,
lng: null,
showMap: true,
showAddFeature: false,
};
},
computed: {
...mapGetters([
'project',
'permissions'
]),
...mapState("feature", [
'features',
'checkedFeatures'
]),
...mapState('feature_type', [
'feature_types'
]),
baseMaps() {
return this.$store.state.map.basemaps;
},
filteredFeatures() {
let results = this.geojsonFeatures;
if (this.form.type.selected) {
results = results.filter(
(el) => el.properties.feature_type.title === this.form.type.selected
);
}
if (this.form.status.selected.value) {
console.log("filter by" + this.form.status.selected.value);
results = results.filter(
(el) => el.properties.status.value === this.form.status.selected.value
);
}
if (this.form.title) {
results = results.filter((el) => {
if (el.properties.title) {
return el.properties.title
.toLowerCase()
.includes(this.form.title.toLowerCase());
} else
return el.id.toLowerCase().includes(this.form.title.toLowerCase());
});
}
return results;
},
statusChoices() {
//* if project is not moderate, remove pending status
return this.form.status.choices.filter((el) =>
this.project.moderation ? true : el.value !== "pending"
);
},
},
watch: {
filteredFeatures(newValue, oldValue) {
if (newValue && newValue !== oldValue) {
this.onFilterChange();
}
},
},
methods: {
modalAllDelete() {
this.modalAllDeleteOpen = !this.modalAllDeleteOpen;
},
deleteFeature(feature_id) {
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}features/${feature_id}/?project__slug=${this.project.slug}`;
axios
.delete(url, {})
.then(() => {
if (!this.modalAllDeleteOpen) {
this.$store
.dispatch("feature/GET_PROJECT_FEATURES",
{
project_slug: this.project.slug
}
)
.then(() => {
this.getNloadGeojsonFeatures();
this.checkedFeatures.splice(feature_id);
});
}
})
.catch(() => {
return false;
});
},
deleteAllFeatureSelection() {
let feature = {};
this.checkedFeatures.forEach((feature_id) => {
feature = { feature_id: feature_id };
this.deleteFeature(feature.feature_id);
});
this.modalAllDelete();
},
onFilterChange() {
if (this.featureGroup) {
const features = this.filteredFeatures;
this.featureGroup.clearLayers();
this.featureGroup = mapUtil.addFeatures(
features,
{},
true,
this.$store.state.feature_type.feature_types
);
mapUtil.getMap().invalidateSize();
mapUtil.getMap()._onResize(); // force refresh for vector tiles
if (this.featureGroup.getLayers().length > 0) {
mapUtil
.getMap()
.fitBounds(this.featureGroup.getBounds(), { padding: [25, 25] });
} else {
mapUtil.getMap().zoomOut(1);
}
}
},
initMap() {
console.log(this);
this.zoom = this.$route.query.zoom || "";
this.lat = this.$route.query.lat || "";
this.lng = this.$route.query.lng || "";
var mapDefaultViewCenter =
this.$store.state.configuration.DEFAULT_MAP_VIEW.center;
var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
this.map = mapUtil.createMap(this.$refs.map, {
zoom: this.zoom,
lat: this.lat,
lng: this.lng,
mapDefaultViewCenter,
mapDefaultViewZoom,
});
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());
});
// --------- End sidebar events ----------
this.getNloadGeojsonFeatures();
setTimeout(
function () {
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,
this.form
);
mapUtil.addGeocoders(this.$store.state.configuration);
}.bind(this),
1000
);
},
getNloadGeojsonFeatures() {
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/feature/?output=geojson`;
this.$store.commit(
"DISPLAY_LOADER",
"Récupération des signalements en cours..."
);
axios
.get(url)
.then((response) => {
if (response.status === 200 && response.data.features.length > 0) {
this.loadFeatures(response.data.features);
}
this.$store.commit("DISCARD_LOADER");
})
.catch((error) => {
this.$store.commit("DISCARD_LOADER");
throw error;
});
},
loadFeatures(features) {
this.geojsonFeatures = features;
const urlParams = new URLSearchParams(window.location.search);
const featureType = urlParams.get("feature_type");
const featureStatus = urlParams.get("status");
const featureTitle = urlParams.get("title");
this.featureGroup = mapUtil.addFeatures(
this.geojsonFeatures,
{
featureType,
featureStatus,
featureTitle,
},
true,
this.$store.state.feature_type.feature_types
);
// Fit the map to bound only if no initial zoom and center are defined
if (
(this.lat === "" || this.lng === "" || this.zoom === "") &&
this.geojsonFeatures.length > 0
) {
mapUtil
.getMap()
.fitBounds(this.featureGroup.getBounds(), { padding: [25, 25] });
}
this.form.type.choices = [
//* converting Set to an Array with spread "..."
...new Set(
this.geojsonFeatures.map((el) => el.properties.feature_type.title)
), //* use Set to eliminate duplicate values
];
},
},
created() {
if (!this.project) {
this.$store.dispatch("GET_PROJECT_INFO", this.$route.params.slug);
}
},
mounted() {
this.initMap();
},
destroyed() {
//* allow user to change page if ever stuck on loader
this.$store.commit("DISCARD_LOADER");
},
};
</script>
<style scoped>
#map {
width: 100%;
min-height: 300px;
height: calc(100vh - 300px);
border: 1px solid grey;
/* To not hide the filters */
z-index: 1;
}
.center {
text-align: center !important;
}
#feature-list-container {
justify-content: flex-start;
}
#feature-list-container .ui.menu:not(.vertical) .right.item {
padding-right: 0;
}
.map-container {
width: 80vw;
transform: translateX(-50%);
margin-left: 50%;
}
.margin-left-25 {
margin-left: 0.25em !important;
}
.no-margin {
margin: 0 !important;
}
.no-padding {
padding: 0 !important;
}
@media screen and (min-width: 767px) {
.twelve-wide {
width: 75% !important;
}
}
@media screen and (max-width: 767px) {
#feature-list-container > .mobile-fullwidth {
width: 100% !important;
}
.no-margin-mobile {
margin: 0 !important;
}
.no-padding-mobile {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.mobile-column {
flex-direction: column !important;
}
#form-filters > .field.column {
width: 100% !important;
}
.map-container {
width: 100%;
}
}
</style>
<template>
<div v-frag>
Erreur Réseau lors de l'envoi du signalement. Votre signalement devra être envoyé au serveur quand vous aurez de nouveau accès à internet.
Veuillez à ce moment là cliquer sur Envoyer sur la page principale du projet
<router-link
:to="{
name: 'project_detail',
params: { slug: $route.params.slug },
}"
class="header"
>Retour au projet</router-link
>
</div>
</template>
<script>
import frag from "vue-frag";
export default {
name: "Feature_offline",
directives: {
frag,
},
data() {
return {
};
},
computed: {
},
methods: {
}
};
</script>
<style>
</style>
\ No newline at end of file
<template>
<div v-if="structure" class="row">
<div class="five wide column">
<div class="ui attached secondary segment">
<h1 class="ui center aligned header">
<img
v-if="structure.geom_type === 'point'"
class="ui medium image"
src="@/assets/img/marker.png"
/>
<img
v-if="structure.geom_type === 'linestring'"
class="ui medium image"
src="@/assets/img/line.png"
/>
<img
v-if="structure.geom_type === 'polygon'"
class="ui medium image"
src="@/assets/img/polygon.png"
/>
{{ structure.title }}
</h1>
</div>
<div class="ui attached segment">
<div class="ui basic segment">
<div class="ui horizontal tiny statistic">
<div class="value">
{{ feature_type_features.length }}
</div>
<div class="label">
Signalement{{ features.length > 1 ? "s" : "" }}
</div>
</div>
<h3 class="ui header">Champs</h3>
<div class="ui divided list">
<div
v-for="(field, index) in orderedCustomFields"
:key="field.name + index"
class="item"
>
<div class="right floated content">
<div class="description">{{ field.field_type }}</div>
</div>
<div class="content">{{ field.label }} ({{ field.name }})</div>
</div>
</div>
</div>
</div>
<div class="ui bottom attached secondary segment">
<div v-if="permissions.can_create_feature" class="ui styled accordion">
<div
@click="toggleShowImport"
:class="['title', { active: showImport }]"
>
<i class="dropdown icon"></i>
Importer des signalements
</div>
<div :class="['content', { active: showImport }]">
<div id="form-import-features" class="ui form">
<div class="field">
<label class="ui icon button" for="json_file">
<i class="file icon"></i>
<span class="label">{{ fileToImport.name }}</span>
</label>
<input
@change="onFileChange"
type="file"
accept="application/json, .json, .geojson"
style="display: none"
name="json_file"
id="json_file"
/>
</div>
<ul v-if="importError" class="errorlist">
<li>
{{ importError }}
</li>
</ul>
<button
:disabled="fileToImport.size === 0"
@click="importGeoJson"
class="ui fluid teal icon button"
>
<i class="upload icon"></i> Lancer l'import
</button>
<ImportTask
v-if="importFeatureTypeData && importFeatureTypeData.length"
:data="importFeatureTypeData"
/>
</div>
</div>
</div>
<div class="ui styled accordion">
<div
@click="toggleShowImport"
:class="['title', { active: !showImport }]"
>
<i class="dropdown icon"></i>
Exporter les signalements
</div>
<div :class="['content', { active: !showImport }]">
<p>
Vous pouvez télécharger tous les signalements qui vous sont
accessibles.
</p>
<button
type="button"
class="ui fluid teal icon button"
@click="exportFeatures"
>
<i class="download icon"></i> Exporter
</button>
</div>
</div>
</div>
</div>
<div class="nine wide column">
<h3 class="ui header">Derniers signalements</h3>
<div
v-for="(feature, index) in lastFeatures"
:key="feature.feature_id + index"
class="ui small header"
>
<span v-if="feature.status === 'archived'" data-tooltip="Archivé">
<i class="grey archive icon"></i>
</span>
<span
v-else-if="feature.status === 'pending'"
data-tooltip="En attente de publication"
>
<i class="teal hourglass outline icon"></i>
</span>
<span v-else-if="feature.status === 'published'" data-tooltip="Publié">
<i class="olive check icon"></i>
</span>
<span v-else-if="feature.status === 'draft'" data-tooltip="Brouillon">
<i class="orange pencil alternate icon"></i>
</span>
<router-link
:to="{
name: 'details-signalement',
params: {
slug: project.slug,
slug_type_signal: feature.feature_type.slug,
slug_signal: feature.feature_id,
},
}"
>
{{ feature.title || feature.feature_id }}
</router-link>
<div class="sub header">
<div>
{{
feature.description
? feature.description.substring(0, 200)
: "Pas de description disponible"
}}
</div>
<div>
[ Créé le {{ feature.created_on | formatDate }}
<span v-if="$store.state.user">
par {{ feature.display_creator }}</span
>
]
</div>
</div>
</div>
<router-link
v-if="project"
:to="{ name: 'liste-signalements', params: { slug: project.slug } }"
class="ui right labeled icon button margin-25"
>
<i class="right arrow icon"></i>
Voir tous les signalements
</router-link>
<router-link
v-if="permissions.can_create_feature"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: structure.slug },
}"
class="ui icon button button-hover-green margin-25"
>
Ajouter un signalement
</router-link>
<br />
</div>
</div>
</template>
<script>
import { mapGetters, mapState } from "vuex";
import ImportTask from "@/components/ImportTask";
import featureAPI from "@/services/feature-api";
export default {
name: "Feature_type_detail",
components: {
ImportTask: ImportTask,
},
data() {
return {
importError: "",
fileToImport: {
name: "Sélectionner un fichier GeoJSON ...",
size: 0,
},
showImport: false,
};
},
filters: {
formatDate(value) {
let date = new Date(value);
date = date.toLocaleString().replace(",", " à");
return date.substr(0, date.length - 3); //* quick & dirty way to remove seconds from date
},
},
computed: {
...mapGetters(["project", "permissions"]),
...mapState("feature", ["features"]),
...mapState("feature_type", ["feature_types", "importFeatureTypeData"]),
structure: function () {
if (this.feature_types) {
return this.feature_types.find(
(el) => el.slug === this.$route.params.feature_type_slug
);
}
return null;
},
feature_type_features: function () {
return this.features.filter(
(el) => el.feature_type.slug === this.$route.params.feature_type_slug
);
},
lastFeatures: function () {
return this.feature_type_features.slice(0, 5);
},
orderedCustomFields() {
return [...this.structure.customfield_set].sort(
(a, b) => a.position - b.position
);
},
},
methods: {
toggleShowImport() {
this.showImport = !this.showImport;
if (this.showImport) {
this.$store.dispatch("feature_type/GET_IMPORTS", this.structure.slug);
}
},
transformProperties(prop) {
const type = typeof prop;
const date = new Date(prop);
if (type === "boolean") {
return "boolean";
} else if (Number.isSafeInteger(prop)) {
return "integer";
} else if (
type === "string" &&
date instanceof Date &&
!isNaN(date.valueOf())
) {
return "date";
} else if (type === "number" && !isNaN(parseFloat(prop))) {
return "decimal";
}
return "char"; //* string by default, most accepted type in database
},
checkJsonValidity(json) {
this.importError = "";
const fields = this.structure.customfield_set.map((el) => {
return {
name: el.name,
field_type: el.field_type,
options: el.options,
};
});
for (const feature of json.features) {
for (const { name, field_type, options } of fields) {
//* check if custom field is present
if (!(name in feature.properties)) {
return false;
}
const fieldInFeature = feature.properties[name];
const customType = this.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
if (field_type === "list") {
//*then check if the value is an available option
if (!options.includes(fieldInFeature)) {
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} ]`;
return false;
}
}
}
}
return true;
},
onFileChange(e) {
const files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
let reader = new FileReader();
reader.addEventListener("load", (e) => {
if (this.checkJsonValidity(JSON.parse(e.target.result))) {
this.fileToImport = files[0]; // todo : remove this value from state as it stored (first attempt didn't work)
this.$store.commit(
"feature_type/SET_FILE_TO_IMPORT",
this.fileToImport
);
}
});
reader.readAsText(files[0]);
},
importGeoJson() {
this.$store.dispatch("feature_type/SEND_FEATURES_FROM_GEOJSON", {
slug: this.$route.params.slug,
feature_type_slug: this.$route.params.feature_type_slug,
fileToImport: this.fileToImport,
});
},
exportFeatures() {
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/feature-type/${this.$route.params.feature_type_slug}/export/`;
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}.json`;
link.click();
URL.revokeObjectURL(link.href);
}
});
},
},
created() {
if (!this.project) {
this.$store.dispatch("GET_PROJECT_INFO", this.$route.params.slug);
}
this.$store.commit(
"feature_type/SET_CURRENT_FEATURE_TYPE_SLUG",
this.$route.params.slug_type_signal
);
},
};
</script>
<style scoped>
.margin-25 {
margin: 0 0.25em 0.25em 0 !important;
}
</style>
\ No newline at end of file
<template>
<div>
<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="cross icon"></i> {{ error }}</p>
</div>
</div>
<div class="fourteen wide column">
<form
id="form-symbology-edit"
action=""
method="post"
enctype="multipart/form-data"
class="ui form"
>
<h1 v-if="feature_type">
Éditer la symbologie du type de signalement "{{ feature_type.title }}" pour le
projet "{{ project.title }}"
</h1>
<SymbologySelector
v-if="feature_type"
:initColor="feature_type.color"
:geomType="feature_type.geom_type"
@set="setDefaultStyle"
/>
<div
v-if="
feature_type &&
feature_type.customfield_set.length > 0 &&
feature_type.customfield_set.some(el => el.field_type === 'list')
"
>
<div class="ui divider" />
<label
for="customfield-select"
id="customfield-select-label">
Personnaliser la symbologie d'une liste de valeurs:
</label>
<select
v-model="selectedCustomfield"
id="customfield-select"
class="ui dropdown"
>
<option
v-for="customfieldList of feature_type.customfield_set.filter(el => el.field_type === 'list')"
:key="customfieldList.name"
:value="customfieldList.name"
>
{{ customfieldList.label }}
</option>
</select>
</div>
<div v-if="selectedCustomfield">
<div
v-for="option of feature_type.customfield_set.find(el => el.name === selectedCustomfield).options"
:key="option"
>
<SymbologySelector
:title="option"
:initColor="feature_type.colors_style.value.colors[option].value"
:initIcon="feature_type.colors_style.value.icons[option]"
:geomType="feature_type.customfield_set.geomType"
@set="setColorsStyle"
/>
</div>
</div>
<div class="ui divider"></div>
<button
class="ui teal icon button margin-25"
type="button"
@click="sendFeatureSymbology"
>
<i class="white save icon"></i>
Sauvegarder la symbologie du type de signalement
</button>
</form>
</div>
</div>
</template>
<script>
import frag from 'vue-frag';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import SymbologySelector from '@/components/feature_type/SymbologySelector.vue';
export default {
name: 'FeatureTypeSymbology',
directives: {
frag,
},
components: {
SymbologySelector
},
data() {
return {
loading: false,
error: null,
selectedCustomfield: null,
form: {
color: '#000000',
icon: 'circle',
colors_style: {
fields: [],
value: {
colors: {},
icons: {},
custom_field_name: '',
},
},
}
}
},
computed: {
...mapGetters(['project']),
...mapState('feature_type', [
'customForms',
'colorsStyleList'
]),
...mapGetters('feature_type', [
'feature_type'
]),
},
watch: {
selectedCustomfield(newValue) {
this.form.colors_style.value.custom_field_name = newValue;
}
},
created() {
if (!this.project) {
this.GET_PROJECT_INFO(this.$route.params.slug);
}
this.SET_CURRENT_FEATURE_TYPE_SLUG(this.$route.params.slug_type_signal);
},
methods: {
...mapMutations('feature_type', [
'SET_CURRENT_FEATURE_TYPE_SLUG'
]),
...mapActions('feature_type', [
'SEND_FEATURE_SYMBOLOGY'
]),
...mapActions([
'GET_PROJECT_INFO'
]),
setDefaultStyle(e) {
const value = e.value;
this.form.color = value.color.value;
this.form.icon = value.icon;
},
setColorsStyle(e) {
const { name, value } = e;
this.form.colors_style.value.colors[name] = value.color;
this.form.colors_style.value.icons[name] = value.icon;
},
sendFeatureSymbology() {
this.loading = true;
this.SEND_FEATURE_SYMBOLOGY(this.form)
.then(() => {
this.loading = false;
})
.catch(() => {
this.loading = false;
})
}
}
}
</script>
<style lang="less" scoped>
h1 {
margin-top: 1em;
}
form {
text-align: left;
#customfield-select-label {
cursor: pointer;
font-weight: 600;
font-size: 1.1em;
}
#customfield-select {
width: 50% !important;
}
}
</style>
<template>
<div v-frag>
<div v-frag v-if="permissions && permissions.can_view_project && project">
<div id="message" class="fullwidth">
<div v-if="tempMessage" class="ui positive message">
<!-- <i class="close icon"></i> -->
<!-- <div class="header">You are eligible for a reward</div> -->
<p><i class="check icon"></i> {{ tempMessage }}</p>
</div>
</div>
<div id="message_info" class="fullwidth">
<div
v-if="infoMessage"
class="ui info message"
style="text-align: left"
>
<div class="header">
<i class="info circle icon"></i> Informations
</div>
<ul class="list">
{{
infoMessage
}}
</ul>
</div>
</div>
<div class="row">
<div class="four wide middle aligned column">
<img
class="ui small spaced image"
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
/>
<div class="ui hidden divider"></div>
<div class="ui basic teal label" data-tooltip="Membres">
<i class="user icon"></i>{{ project.nb_contributors }}
</div>
<div class="ui basic teal label" data-tooltip="Signalements">
<i class="map marker icon"></i>{{ project.nb_published_features }}
</div>
<div class="ui basic teal label" data-tooltip="Commentaires">
<i class="comment icon"></i
>{{ project.nb_published_features_comments }}
</div>
</div>
<div class="ten wide column">
<h1 class="ui header">
<div class="content">
{{ project.title }}
<div v-if="arraysOffline.length > 0">
{{ arraysOffline.length }} modifications en attente
<button
:disabled="isOffline()"
@click="sendOfflineFeatures()"
class="ui fluid teal icon button"
>
<i class="upload icon"></i> Envoyer au serveur
</button>
</div>
<div class="ui icon right floated compact buttons">
<a
v-if="
user &&
permissions &&
permissions.can_view_project &&
isOffline() !== true
"
id="subscribe-button"
class="ui button button-hover-green"
data-tooltip="S'abonner au projet"
data-position="top center"
data-variation="mini"
@click="isModalOpen = true"
>
<i class="inverted grey envelope icon"></i>
</a>
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
:to="{ name: 'project_edit', params: { slug: project.slug } }"
class="ui button button-hover-orange"
data-tooltip="Modifier le projet"
data-position="top center"
data-variation="mini"
>
<i class="inverted grey pencil alternate icon"></i>
</router-link>
</div>
<div class="ui hidden divider"></div>
<div class="sub header">
{{ project.description }}
</div>
</div>
</h1>
</div>
</div>
<div class="row">
<div class="seven wide column">
<h3 class="ui header">Types de signalements</h3>
<div class="ui middle aligned divided list">
<div
:class="{ active: featureTypeLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des types de signalements en cours...
</div>
</div>
<div
v-for="(type, index) in feature_types"
:key="type.title + '-' + index"
class="item"
>
<div class="middle aligned content">
<router-link
:to="{
name: 'details-type-signalement',
params: { feature_type_slug: type.slug },
}"
>
<img
v-if="type.geom_type === 'point'"
class="list-image-type"
src="@/assets/img/marker.png"
/>
<img
v-if="type.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
/>
<img
v-if="type.geom_type === 'polygon'"
class="list-image-type"
src="@/assets/img/polygon.png"
/>
{{ type.title }}
</router-link>
<!-- {% if project and feature_types and
permissions|lookup:'can_create_feature' %} -->
<!-- // ? should we get type.is_editable ? -->
<!-- v-if="
project &&
permissions.can_create_feature &&
type.is_editable
" -->
<router-link
v-if="
project && permissions && permissions.can_create_feature
"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-green
"
data-tooltip="Ajouter un signalement"
data-position="left center"
data-variation="mini"
>
<i class="ui plus icon"></i>
</router-link>
<router-link
:to="{
name: 'dupliquer-type-signalement',
params: { slug_type_signal: type.slug },
}"
v-if="
project &&
permissions &&
permissions.can_create_feature_type &&
isOffline() !== true
"
class="
ui
compact
small
icon
right
floated
button button-hover-green
"
data-tooltip="Dupliquer un type de signalement"
data-position="left center"
data-variation="mini"
>
<i class="inverted grey copy alternate icon"></i>
</router-link>
<router-link
:to="{
name: 'editer-type-signalement',
params: { slug_type_signal: type.slug },
}"
v-if="
project &&
type.is_editable &&
permissions &&
permissions.can_create_feature_type &&
isOffline() !== true
"
class="
ui
compact
small
icon
right
floated
button button-hover-green
"
data-tooltip="Éditer le type de signalement"
data-position="left center"
data-variation="mini"
>
<i class="inverted grey pencil alternate icon"></i>
</router-link>
<router-link
:to="{
name: 'editer-symbologie-signalement',
params: { slug_type_signal: type.slug },
}"
v-if="
project &&
type.is_editable &&
type.geom_type === 'point' &&
permissions &&
permissions.can_create_feature_type &&
isOffline() != true
"
class="
ui
compact
small
icon
right
floated
button button-hover-green
"
data-tooltip="Éditer la symbologie du type de signalement"
data-position="left center"
data-variation="mini"
>
<i class="inverted grey paint brush alternate icon"></i>
</router-link>
</div>
</div>
<div v-if="feature_types.length === 0">
<i> Le projet ne contient pas encore de type de signalements. </i>
</div>
</div>
<div class="nouveau-type-signalement">
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
:to="{
name: 'ajouter-type-signalement',
params: { slug: project.slug },
}"
class="ui compact basic button button-hover-green"
>
<i class="ui plus icon"></i>Créer un nouveau type de signalement
</router-link>
</div>
<div class="nouveau-type-signalement">
<a
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
class="
ui
compact
basic
button button-hover-green
important-flex
align-center
text-left
"
>
<i class="ui plus icon"></i>
<label class="ui" for="json_file">
<span class="label"
>Créer un nouveau type de signalement à partir d'un
GeoJSON</span
>
</label>
<input
type="file"
accept="application/json, .json, .geojson"
style="display: none"
name="json_file"
id="json_file"
@change="onFileChange"
/>
</a>
<br />
<div id="button-import" v-if="fileToImport.size > 0">
<button
:disabled="fileToImport.size === 0"
@click="toNewFeatureType"
class="ui fluid teal icon button"
>
<i class="upload icon"></i> Lancer l'import avec le fichier
{{ fileToImport.name }}
</button>
</div>
</div>
</div>
<div class="seven wide column">
<div id="map" ref="map"></div>
</div>
</div>
<div class="row">
<div class="fourteen wide column">
<div class="ui two stackable cards">
<div class="red card">
<div class="content">
<div class="center aligned header">Derniers signalements</div>
<div class="center aligned description">
<div
:class="{ active: featuresLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des signalements en cours...
</div>
</div>
<div class="ui relaxed list">
<div
v-for="(item, index) in last_features"
:key="item.title + index"
class="item"
>
<div class="content">
<div>
<router-link
:to="{
name: 'details-signalement',
params: {
slug: project.slug,
slug_type_signal: item.feature_type.slug,
slug_signal: item.feature_id,
},
}"
>{{ item.title || item.feature_id }}</router-link
>
</div>
<div class="description">
<i
>[{{ item.created_on | setDate
}}<span v-if="user && item.display_creator"
>, par {{ item.display_creator }}
</span>
]</i
>
</div>
</div>
</div>
<i v-if="last_features.length === 0"
>Aucun signalement pour le moment.</i
>
</div>
</div>
</div>
</div>
<div class="orange card">
<div class="content">
<div class="center aligned header">Derniers commentaires</div>
<div class="center aligned description">
<div class="ui relaxed list">
<div
v-for="(item, index) in last_comments"
:key="'comment ' + index"
class="item"
>
<div class="content">
<div>
<router-link :to="item.related_feature.feature_url"
>"{{ item.comment }}"</router-link
>
</div>
<div class="description">
<i
>[ {{ item.created_on
}}<span v-if="user && item.display_author"
>, par {{ item.display_author }}
</span>
]</i
>
</div>
</div>
</div>
<i v-if="!last_comments || last_comments.length === 0"
>Aucun commentaire pour le moment.</i
>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="fourteen wide column">
<div class="ui grey segment">
<h3 class="ui header">Paramètres du projet</h3>
<div class="ui five stackable cards">
<div class="card">
<div class="center aligned content">
<h4 class="ui center aligned icon header">
<i class="disabled grey archive icon"></i>
<div class="content">Délai avant archivage automatique</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.archive_feature }} jours
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey trash alternate icon"></i>
<div class="content">
Délai avant suppression automatique
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.delete_feature }} jours
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey eye icon"></i>
<div class="content">
Visibilité des signalements publiés
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.access_level_pub_feature }}
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey eye icon"></i>
<div class="content">
Visibilité des signalements archivés
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.access_level_arch_feature }}
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey cogs icon"></i>
<div class="content">Modération</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.moderation ? "Oui" : "Non" }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<span v-else>
<i class="icon exclamation triangle"></i>
<span
>Vous ne disposez pas des droits nécessaires pour consulter ce
projet.</span
>
</span>
<div
v-if="isModalOpen"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
:class="[
'ui mini modal subscription',
{ 'transition visible active': isModalOpen },
]"
>
<i @click="isModalOpen = false" class="close icon"></i>
<div class="ui icon header">
<i class="envelope icon"></i>
Notifications du projet
</div>
<div class="content">
<button
@click="subscribeProject"
:class="['ui compact fluid button', is_suscriber ? 'red' : 'green']"
>
{{
is_suscriber
? "Se désabonner de ce projet"
: "S'abonner à ce projet"
}}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import frag from "vue-frag";
import { mapUtil } from "@/assets/js/map-util.js";
import { mapGetters, mapState } from "vuex";
import projectAPI from "@/services/project-api";
import axios from '@/axios-client.js';
// axios.defaults.headers.common['X-CSRFToken'] = (name => {
// var re = new RegExp(name + "=([^;]+)");
// var value = re.exec(document.cookie);
// return (value !== null) ? unescape(value[1]) : null;
// })('csrftoken');
export default {
name: "Project_details",
props: ["message"],
directives: {
frag,
},
filters: {
setDate(value) {
const date = new Date(value);
const d = date.toLocaleDateString("fr", {
year: "2-digit",
month: "numeric",
day: "numeric",
});
return d;
},
},
data() {
return {
infoMessage: "",
arraysOffline: [],
geojsonImport: [],
fileToImport: { name: "", size: 0 },
slug: this.$route.params.slug,
isModalOpen: false,
is_suscriber: false,
tempMessage: null,
featureTypeLoading: true,
featuresLoading: true
};
},
computed: {
...mapGetters(["project", "permissions"]),
...mapState("feature_type", ["feature_types"]),
...mapState("feature", ["features"]),
...mapState(["last_comments", "user"]),
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
API_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_API_BASE;
},
last_features() {
// * limit to last five element of array (looks sorted chronologically, but not sure...)
return this.features.slice(-5);
},
},
methods: {
refreshId() {
return "?ver=" + Math.random();
},
isOffline() {
return navigator.onLine === false;
},
checkForOfflineFeature() {
let arraysOffline = [];
let localStorageArray = localStorage.getItem("geocontrib_offline");
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
this.arraysOffline = arraysOffline.filter(
(x) => x.project === this.project.slug
);
}
},
sendOfflineFeatures() {
var promises = [];
this.arraysOffline.forEach((feature, index, object) => {
console.log(feature);
if (feature.type === "post") {
promises.push(
axios
.post(`${this.API_BASE_URL}features/`, feature.geojson)
.then((response) => {
console.log(response);
if (response.status === 201 && response.data) {
object.splice(index, 1);
}
})
.catch((error) => {
console.log(error);
})
);
} else if (feature.type === "put") {
promises.push(
axios
.put(
`${this.API_BASE_URL}features/${feature.featureId}`,
feature.geojson
)
.then((response) => {
console.log(response);
if (response.status === 200 && response.data) {
object.splice(index, 1);
}
})
.catch((error) => {
console.log(error);
})
);
}
});
Promise.all(promises).then(() => {
this.updateLocalStorage();
window.location.reload();
});
},
updateLocalStorage() {
let arraysOffline = [];
let localStorageArray = localStorage.getItem("geocontrib_offline");
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
}
let arraysOfflineOtherProject = arraysOffline.filter(
(x) => x.project !== this.project.slug
);
arraysOffline = arraysOfflineOtherProject.concat(this.arraysOffline);
localStorage.setItem("geocontrib_offline", JSON.stringify(arraysOffline));
},
toNewFeatureType() {
this.$router.push({
name: "ajouter-type-signalement",
params: {
geojson: this.geojsonImport,
fileToImport: this.fileToImport,
},
});
},
onFileChange(e) {
var files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
this.fileToImport = files[0];
// TODO : VALIDATION IF FILE IS JSON
if (this.fileToImport.size > 0) {
const fr = new FileReader();
fr.onload = (e) => {
this.geojsonImport = JSON.parse(e.target.result);
};
fr.readAsText(this.fileToImport);
//* stock filename to import features afterward
this.$store.commit(
"feature_type/SET_FILE_TO_IMPORT",
this.fileToImport
);
}
},
subscribeProject() {
projectAPI
.subscribeProject({
suscribe: !this.is_suscriber,
projectSlug: this.$route.params.slug,
})
.then((data) => {
this.is_suscriber = data.is_suscriber;
this.isModalOpen = false;
if (this.is_suscriber)
this.infoMessage =
"Vous êtes maintenant abonné aux notifications de ce projet.";
else
this.infoMessage =
"Vous ne recevrez plus les notifications de ce projet.";
setTimeout(() => (this.infoMessage = ""), 3000);
});
},
initMap() {
if (this.project && this.permissions.can_view_project) {
this.$store.dispatch("map/INITIATE_MAP", this.$refs.map);
const url = `${this.API_BASE_URL}projects/${this.$route.params.slug}/feature/?output=geojson`;
this.checkForOfflineFeature();
let project_id = this.$route.params.slug.split("-")[0];
const mvtUrl = `${this.API_BASE_URL}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`;
mapUtil.addVectorTileLayer(
mvtUrl,
this.$route.params.slug,
this.$store.state.feature_type.feature_types
);
axios
.get(url)
.then((response) => {
let features = response.data.features;
this.arraysOffline.forEach(
(x) => (x.geojson.properties.color = "red")
);
features = response.data.features.concat(
this.arraysOffline.map((x) => x.geojson)
);
const featureGroup = mapUtil.addFeatures(
features,
{},
true,
this.$store.state.feature_type.feature_types
);
if (featureGroup && featureGroup.getLayers().length > 0) {
mapUtil
.getMap()
.fitBounds(featureGroup.getBounds(), { padding: [25, 25] });
this.$store.commit("map/SET_GEOJSON_FEATURES", features);
} else {
this.$store.commit("map/SET_GEOJSON_FEATURES", []);
}
})
.catch((error) => {
throw error;
});
}
},
},
created() {
if (this.user) {
projectAPI
.getProjectSubscription({ projectSlug: this.$route.params.slug })
.then((data) => (this.is_suscriber = data.is_suscriber));
}
this.$store.commit("feature_type/SET_FEATURE_TYPES", []); //* empty feature_types remaining from previous project
},
mounted() {
this.$store.dispatch('GET_PROJECT_INFO', this.slug)
.then(() => {
this.featureTypeLoading = false;
setTimeout(this.initMap, 1000);
});
this.$store.dispatch('feature/GET_PROJECT_FEATURES', {
project_slug: this.slug
})
.then(() => {
this.featuresLoading = false;
});
if (this.message) {
this.tempMessage = this.message;
document
.getElementById("message")
.scrollIntoView({ block: "end", inline: "nearest" });
setTimeout(() => (this.tempMessage = null), 5000); //* hide message after 5 seconds
}
},
};
</script>
<style>
@import "../../assets/resources/semantic-ui-2.4.2/semantic.min.css";
#map {
width: 100%;
height: 100%;
min-height: 250px;
}
.list-image-type {
margin-right: 5px;
height: 25px;
vertical-align: bottom;
}
/* // ! missing style in semantic.min.css, je ne comprends pas comment... */
.ui.right.floated.button {
float: right;
margin: 0 0 0 1em;
}
.nouveau-type-signalement {
padding-top: 1em;
}
#button-import {
padding-top: 0.5em;
}
.fullwidth {
width: 100%;
}
.important-flex {
display: flex !important;
}
.align-center {
align-items: center !important;
}
.text-left {
text-align: left !important;
}
</style>
\ No newline at end of file
<template>
<div class="fourteen wide column">
<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
type="text"
required
maxlength="128"
name="title"
id="title"
v-model="form.title"
/>
<ul id="errorlist-title" class="errorlist">
<li v-for="error in errors.title" :key="error">
{{ error }}
</li>
</ul>
</div>
<div class="field">
<label>Illustration du projet</label>
<img
class="ui small image"
id="form-input-file-logo"
:src="
thumbnailFileSrc
? thumbnailFileSrc
: DJANGO_BASE_URL + form.thumbnail
"
/>
<label class="ui icon button" for="thumbnail">
<i class="file icon"></i>
<span class="label">{{
form.thumbnail_name ? form.thumbnail_name : fileToImport.name
}}</span>
</label>
<input
@change="onFileChange"
class="file-selection"
type="file"
accept="image/jpeg, image/png"
style="display: none"
name="thumbnail"
id="thumbnail"
/>
<ul
v-if="errorThumbnail.length"
id="errorlist-thumbnail"
class="errorlist"
>
<li>
{{ errorThumbnail[0] }}
</li>
</ul>
</div>
</div>
<div class="field">
<label for="description">Description</label>
<textarea
v-model="form.description"
name="description"
rows="5"
></textarea>
<!-- {{ form.description.errors }} -->
</div>
<div class="ui horizontal divider">PARAMÈTRES</div>
<div class="four fields">
<div class="field">
<label for="archive_feature">Délai avant archivage</label>
<div class="ui right labeled input">
<input
type="number"
min="0"
oninput="validity.valid||(value=0);"
style="padding: 1px 2px"
name="archive_feature"
id="archive_feature"
@blur="checkEmpty"
v-model="form.archive_feature"
/>
<div class="ui label">jour(s)</div>
</div>
<ul
v-if="errors_archive_feature.length"
id="errorlist-achivage"
class="errorlist"
>
<li>
{{ errors_archive_feature[0] }}
</li>
</ul>
</div>
<div class="field">
<label for="delete_feature">Délai avant suppression</label>
<div class="ui right labeled input">
<input
type="number"
min="0"
oninput="validity.valid||(value=0);"
style="padding: 1px 2px"
name="delete_feature"
id="delete_feature"
@blur="checkEmpty"
v-model="form.delete_feature"
/>
<div class="ui label">jour(s)</div>
</div>
</div>
<div class="required field">
<label for="access_level_pub_feature"
>Visibilité des signalements publiés</label
>
<Dropdown
:options="levelPermissions"
: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 class="required field">
<label for="access_level_arch_feature">
Visibilité des signalements archivés
</label>
<Dropdown
:options="levelPermissions"
: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="field">
<div class="ui checkbox">
<input
type="checkbox"
v-model="form.moderation"
name="moderation"
id="moderation"
/>
<label for="moderation">Modération</label>
</div>
<!-- {{ form.moderation.errors }} -->
</div>
<div class="field">
<div class="ui checkbox">
<input
type="checkbox"
v-model="form.is_project_type"
name="is_project_type"
id="is_project_type"
/>
<label for="is_project_type">Est un projet type</label>
</div>
<!-- {{ form.is_project_type.errors }} -->
</div>
<div class="ui divider"></div>
<button @click="postForm" type="button" class="ui teal icon button">
<i class="white save icon"></i> Enregistrer les changements
</button>
</form>
</div>
</template>
<script>
import axios from '@/axios-client.js';
import Dropdown from "@/components/Dropdown.vue";
import { mapGetters } from "vuex";
// axios.defaults.headers.common["X-CSRFToken"] = ((name) => {
// var re = new RegExp(name + "=([^;]+)");
// var value = re.exec(document.cookie);
// return value !== null ? unescape(value[1]) : null;
// })("csrftoken");
export default {
name: "Project_edit",
components: {
Dropdown,
},
data() {
return {
loading: false,
action: "create",
levelPermissions: [
{ name: "Utilisateur anonyme", value: "anonymous" },
{ name: "Utilisateur connecté", value: "logged_user" },
{ name: "Contributeur", value: "contributor" },
],
fileToImport: {
name: "Sélectionner une image ...",
size: 0,
},
errors_archive_feature: [],
errors: {
title: [],
access_level_pub_feature: [],
access_level_arch_feature: [],
},
errorThumbnail: [],
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,
nb_features: 0,
nb_published_features: 0,
nb_comments: 0,
nb_published_features_comments: 0,
nb_contributors: 0,
is_project_type: false,
},
thumbnailFileSrc: "",
};
},
computed: {
...mapGetters(["project"]),
DJANGO_BASE_URL: function () {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
},
methods: {
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";
}
},
truncate(n, len) {
let 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) {
let url = window.URL || window.webkitURL;
let image = new Image();
image.onload = function () {
handleFile(true);
};
image.onerror = function () {
handleFile(false);
};
image.src = url.createObjectURL(files);
URL.revokeObjectURL(image.src);
},
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
let 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_PERMISSIONS"), //* refresh projects permissions
this.$store.dispatch("GET_ALL_PROJECTS"), //* & refresh project list
]).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) {
let 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 = {
title: this.form.title,
description: this.form.description,
access_level_arch_feature: this.form.access_level_arch_feature.value,
access_level_pub_feature: this.form.access_level_pub_feature.value,
archive_feature: this.form.archive_feature,
delete_feature: this.form.delete_feature,
is_project_type: this.form.is_project_type,
moderation: this.form.moderation,
};
let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/`;
if (this.action === "edit") {
await axios
.put((url += `${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 {
if (this.action === "create_from") {
url += `${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;
});
}
},
},
created() {
this.definePageType();
console.log(this.action);
if (this.action === "create") {
this.thumbnailFileSrc = require("@/assets/img/default.png");
} else if (this.action === "edit" || this.action === "create_from") {
if (!this.project) {
this.$store.dispatch("GET_PROJECT_INFO", this.$route.params.slug);
}
this.form = { ...this.project }; //* create a new object to avoid modifying original one
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 for dropdowns display (could be in a computed)
this.form.access_level_pub_feature = {
name: this.project.access_level_pub_feature,
value: this.levelPermissions.find(
(el) => el.name === this.project.access_level_pub_feature
).value,
};
this.form.access_level_arch_feature = {
name: this.project.access_level_arch_feature,
value: this.levelPermissions.find(
(el) => el.name === this.project.access_level_arch_feature
).value,
};
}
},
};
</script>
<style media="screen">
#form-input-file-logo {
margin-left: auto;
margin-right: auto;
}
.close.icon:hover {
cursor: pointer;
}
</style>
\ No newline at end of file
<template>
<div class="row">
<div class="seven wide column">
<h3 class="ui header">
Créer un projet à partir d'un modèle disponible:
</h3>
<div class="ui divided items">
<div v-for="project in project_types" :key="project.slug" class="item">
<div class="ui tiny image">
<img
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
/>
</div>
<div class="middle aligned content">
<div class="description">
<router-link
:to="{
name: 'project_create_from',
params: {
slug: project.slug,
},
}"
>{{ project.title }}</router-link
>
<p>{{ project.description }}</p>
<strong v-if="project.moderation">Projet modéré</strong>
<strong v-else>Projet non modéré</strong>
</div>
<div class="meta">
<span data-tooltip="Délai avant archivage">
{{ project.archive_feature }}&nbsp;<i class="box icon"></i>
</span>
<span data-tooltip="Délai avant suppression">
{{ project.archive_feature }}&nbsp;<i
class="trash alternate icon"
></i>
</span>
<span data-tooltip="Date de création">
{{ project.created_on }}&nbsp;<i class="calendar icon"></i>
</span>
</div>
<div class="meta">
<span data-tooltip="Visibilité des signalement publiés">
{{ project.access_level_pub_feature }}&nbsp;<i
class="eye icon"
></i>
</span>
<span data-tooltip="Visibilité des signalement archivés">
{{ project.access_level_arch_feature }}&nbsp;<i
class="archive icon"
></i>
</span>
</div>
</div>
</div>
<span v-if="!project_types || project_types.length === 0"
>Aucun projet type n'est défini.</span
>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from "vuex";
export default {
name: "Project_type_list",
computed: {
...mapGetters(["project_types"]),
DJANGO_BASE_URL: function () {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
},
methods: {
refreshId() {
return "?ver=" + Math.random();
},
},
};
</script>
\ No newline at end of file
<template>
<div>
<div class="row">
<div class="fourteen wide column">
<img
class="ui centered small image"
src="@/assets/img/logo-neogeo-circle.png"
/>
<h2 class="ui center aligned icon header">
<div class="content">
{{ APPLICATION_NAME }}
<div class="sub header">{{ APPLICATION_ABSTRACT }}</div>
</div>
</h2>
</div>
</div>
<div class="row">
<div class="six wide column">
<h3 class="ui horizontal divider header">CONNEXION</h3>
<div v-if="form.errors" class="ui warning message">
<div class="header">
Les informations d'identification sont incorrectes.
</div>
NB: Seuls les comptes actifs peuvent se connecter.
</div>
<form class="ui form" role="form" type="post" @submit.prevent="login">
<div class="ui stacked secondary segment">
<div class="six field required">
<div class="ui left icon input">
<i class="user icon"></i>
<input
v-model="username_value"
type="text"
name="username"
placeholder="Utilisateur"
/>
</div>
</div>
<div class="six field required">
<div class="ui left icon input">
<i class="lock icon"></i>
<input
v-model="password_value"
type="password"
name="password"
placeholder="Mot de passe"
/>
</div>
</div>
<button class="ui fluid large teal submit button" type="submit">
Login
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: "Login",
data() {
return {
username_value: null,
password_value: null,
logged: false,
form: {
errors: null,
},
};
},
computed: {
LOGO_PATH: function () {
return this.$store.state.configuration.VUE_APP_LOGO_PATH;
},
APPLICATION_NAME: function () {
return this.$store.state.configuration.VUE_APP_APPLICATION_NAME;
},
APPLICATION_ABSTRACT: function () {
return this.$store.state.configuration.VUE_APP_APPLICATION_ABSTRACT;
},
},
methods: {
login() {
this.$store
.dispatch("LOGIN", {
username: this.username_value,
password: this.password_value,
})
.then((status) => {
if (status === 200) {
this.form.errors = null;
} else if (status === "error") {
this.form.errors = status;
}
})
.catch();
},
},
};
</script>
\ No newline at end of file