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 4826 additions and 0 deletions
import axios from 'axios';
axios.defaults.withCredentials = true;
// Add a request interceptor
axios.interceptors.request.use(function (config) {
config.headers['X-CSRFToken'] = (name => {
const re = new RegExp(name + '=([^;]+)');
const value = re.exec(document.cookie);
return (value != null) ? unescape(value[1]) : null;
})('csrftoken');
return config;
}, function (error) {
return Promise.reject(error);
});
// Add a response interceptor
axios.interceptors.response.use(function (response) {
response.headers['X-CSRFToken'] = (name => {
const re = new RegExp(name + '=([^;]+)');
const value = re.exec(document.cookie);
return (value != null) ? unescape(value[1]) : null;
})('csrftoken');
return response;
}, function (error) {
return Promise.reject(error);
});
export default axios;
<template>
<div
id="user-activity"
class="ui stackable cards"
>
<!-- EVENTS -->
<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', { 'ellipsis nowrap': item.related_feature.title }]">
{{ getNotificationName(item.event_type, item.object_type) }}
<div
v-if="item.object_type === 'project'"
>
<router-link
v-if="item.project_title"
:to="{
name: 'project_detail',
params: { slug: item.project_slug },
}"
>
{{ item.project_title }}
</router-link>
<span
v-else
class="meta"
><del>{{ item.project_slug }}</del>&nbsp;(supprimé)</span>
</div>
<div v-else>
<FeatureFetchOffsetRoute
v-if="item.related_feature.deletion_on === 'None'"
:feature-id="item.feature_id"
:properties="{
feature_type: {
slug: item.feature_type_slug
},
title: item.related_feature.title,
...item
}"
/>
<span
v-else
class="meta"
><del>{{ item.data.feature_title || item.feature_id }}</del>&nbsp;(supprimé)</span>
</div>
<div class="description">
<em>[ {{ item.created_on }}
<span v-if="user">
, par {{ item.display_user }}
</span>
]</em>
</div>
</div>
</div>
<em
v-if="!events || events.length === 0"
>Aucune notification pour le moment.</em>
</div>
</div>
</div>
</div>
<!-- FEATURES -->
<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>
<FeatureFetchOffsetRoute
v-if="item.related_feature.deletion_on === 'None'"
:feature-id="item.feature_id"
:properties="{
feature_type: {
slug: item.feature_type_slug
},
title: item.related_feature.title,
...item
}"
/>
<span
v-else
class="meta"
>
<del>{{ item.data.feature_title || item.feature_id }}</del>&nbsp;(supprimé)
</span>
</div>
<div class="description">
<em>[ {{ item.created_on }}
<span v-if="user">
, par {{ item.display_user }}
</span>
]</em>
</div>
</div>
</div>
<em
v-if="!features || features.length === 0"
>Aucun signalement pour le moment.</em>
</div>
</div>
</div>
</div>
<!-- COMMENTS -->
<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>
<FeatureFetchOffsetRoute
v-if="item.related_feature.deletion_on === 'None'"
:feature-id="item.feature_id"
:properties="{
feature_type: {
slug: item.feature_type_slug
},
title: quoteComment(item.data.comment),
...item
}"
/>
<span
v-else
class="meta"
>
<del>{{ item.data.comment }}</del>&nbsp;(supprimé)
</span>
</div>
<div class="description">
<em>[ {{ item.created_on }}
<span v-if="user">
, par {{ item.display_user }}
</span>
]</em>
</div>
</div>
</div>
<em
v-if="!comments || comments.length === 0"
>Aucun commentaire pour le moment.</em>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import miscAPI from '@/services/misc-api';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'UserActivity',
components: {
FeatureFetchOffsetRoute,
},
data() {
return {
events: [],
features: [],
comments: [],
};
},
computed: {
...mapState([
'user',
]),
isSharedProject() {
return this.$route.path.includes('projet-partage');
},
},
created(){
this.getEvents();
// unset project to avoid interfering with generating query in feature links
this.$store.commit('projects/SET_PROJECT', null);
},
methods: {
getEvents(){
miscAPI.getUserEvents(this.$route.params.slug)
.then((data)=>{
this.events = data.events;
this.features = data.features;
this.comments = data.comments;
});
},
getNotificationName(eventType, objectType) {
if (eventType === 'create') {
if (objectType === 'feature') {
return 'Signalement créé';
} else if (objectType === 'comment') {
return 'Commentaire créé';
} else if (objectType === 'attachment') {
return 'Pièce jointe ajoutée';
} else if (objectType === 'project') {
return 'Projet créé';
}
} else if (eventType === 'update') {
if (objectType === 'feature') {
return 'Signalement mis à jour';
} else if (objectType === 'project') {
return 'Projet mis à jour';
}
} else if (eventType === 'delete') {
if (objectType === 'feature') {
return 'Signalement supprimé';
} else if (objectType === 'project') {
return 'Projet mis à jour';
} else {
return 'Événement inconnu';
}
}
},
quoteComment(comment) {
return `"${comment}"`;
},
}
};
</script>
<style scoped lang="less">
#user-activity {
flex-flow: column;
margin: 1em 0;
.card {
margin: .875em 0;
}
}
</style>
<template>
<div>
<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
v-if="qrcode"
class="qrcode"
>
<img
:src="qrcode"
alt="qrcode"
>
<p>
Ce QR code vous permet de vous connecter à l'application mobile GéoContrib (bientôt disponible)
</p>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import QRCode from 'qrcode';
export default {
name: 'UserProfile',
data() {
return {
qrcode: null
};
},
computed: {
...mapState([
'configuration',
'user',
'userToken'
]),
userFullname() {
if (this.user.first_name || this.user.last_name) {
return `${this.user.first_name} ${this.user.last_name}`;
}
return null;
},
},
created() {
this.GET_USER_TOKEN()
.then(async () => {
try {
const qrcodeData = {
url: `${this.configuration.VUE_APP_DJANGO_BASE}/geocontrib/`,
token: this.userToken
};
this.qrcode = await QRCode.toDataURL(JSON.stringify(qrcodeData));
} catch (err) {
console.error(err);
}
})
.catch((err) => {
console.error(err);
});
},
methods: {
...mapActions([
'GET_USER_TOKEN'
])
}
};
</script>
<style scoped lang="less">
.qrcode {
img {
display: block;
margin: auto;
width: 12rem;
}
p {
font-size: 0.8rem;
font-style: italic;
text-align: center;
}
}
</style>
<template>
<div>
<h4 class="ui horizontal divider header">
MES PROJETS
</h4>
<div class="ui divided items">
<div
:class="['ui inverted dimmer', { active: projectsLoading }]"
>
<div class="ui text loader">
Récupération des projets en cours...
</div>
</div>
<div
v-for="project in projectsArray"
:key="project.slug"
class="item"
>
<div
v-if="user_permissions[project.slug].can_view_project"
class="item-content-wrapper"
>
<div class="ui tiny image">
<img
v-if="project.thumbnail"
class="ui small image"
alt="Thumbnail projet"
: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 top">
<span
class="right floated"
>
<strong>Projet {{ project.moderation ? "" : "non" }} modéré</strong>
</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"
aria-hidden="true"
/>
&nbsp;{{ project.created_on }}
</span>
<span data-tooltip="Membres">
{{ project.nb_contributors }}&nbsp;
<i
class="user icon"
aria-hidden="true"
/>
</span>
<span data-tooltip="Signalements publiés">
{{ project.nb_published_features }}&nbsp;
<i
class="map marker icon"
aria-hidden="true"
/>
</span>
<span data-tooltip="Commentaires">
{{ project.nb_published_features_comments }}&nbsp;
<i
class="comment icon"
aria-hidden="true"
/>
</span>
</div>
</div>
</div>
</div>
<!-- PAGINATION -->
<Pagination
v-if="count"
:nb-pages="Math.ceil(count/10)"
@page-update="changePage"
/>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions } from 'vuex';
import Pagination from '@/components/Pagination.vue';
export default {
name: 'UserProjectList',
components: {
Pagination,
},
data() {
return {
projectsLoading: true,
};
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS',
'user_permissions',
]),
// todo : filter projects to user
...mapState('projects', [
'projects',
'count',
]),
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
isSharedProject() {
return this.$route.path.includes('projet-partage');
},
availableProjects() {
if (this.isSharedProject) {
return this.projects.filter((el) => el.slug === this.$route.params.slug);
}
return this.projects;
},
projectsArray() { //* if only one project, only project object is returned
return Array.isArray(this.projects) ? this.projects : [this.projects];
}
},
created(){
this.SET_PROJECTS([]); //* empty previous project to avoid undefined user_permissions[project.slug]
this.getData();
},
methods: {
...mapMutations('projects', [
'SET_CURRENT_PAGE',
'SET_PROJECTS',
]),
...mapActions('projects', [
'GET_PROJECTS',
]),
refreshId() {
const crypto = window.crypto || window.msCrypto;
var array = new Uint32Array(1);
return '?ver=' + crypto.getRandomValues(array); // Compliant for security-sensitive use cases
},
getData(page) {
this.loading = true;
this.GET_PROJECTS({ ismyaccount: true, projectSlug: this.$route.params.slug, page })
.then(() => this.projectsLoading = false)
.catch(() => this.projectsLoading = false);
},
changePage(e) {
this.getData(e);
},
}
};
</script>
<style lang="less" scoped>
.ui.divided.items {
.item {
.item-content-wrapper {
width: 100%;
margin: 0;
padding: 1em 0;
display: flex;
.middle.aligned.content {
.header {
font-size: 1.28571429em;
font-weight: 600;
color: rgb(31, 31, 31)
}
}
}
}
> .item:nth-child(2) {
border: none !important;
}
}
.description {
p {
text-align: justify;
}
}
@media only screen and (min-width: 767px) {
.item-content-wrapper {
align-items: flex-start;
.middle.aligned.content {
width: 100%;
padding: 0 0 0 1.5em;
.meta.top {
span {
line-height: 1.2em;
}
}
}
}
}
@media only screen and (max-width: 767px) {
.item-content-wrapper {
flex-direction: column;
align-items: center;
.middle.aligned.content {
width: 80%;
padding: 1.5em 0 0;
.meta.top {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
.right.floated {
float: none !important;
margin-left: 0 !important;
margin-bottom: 0.5em;
}
span {
margin: 0.15em 0;
}
}
}
}
}
</style>
<template>
<div id="app-footer">
<div class="ui compact text menu">
<router-link
:to="{name: 'mentions'}"
class="item"
>
Mentions légales
</router-link>
<router-link
:to="{name: 'aide'}"
class="item"
>
Aide
</router-link>
<p class="item">
Version {{ PACKAGE_VERSION }}
</p>
</div>
</div>
</template>
<script>
export default {
name: 'AppFooter',
computed: {
PACKAGE_VERSION: () => process.env.PACKAGE_VERSION || '0',
}
};
</script>
<template>
<div
id="app-header"
:class="$route.name"
>
<div class="menu container">
<div class="ui inverted icon menu">
<router-link
:is="isSharedProject ? 'span' : 'router-link'"
:to="isSharedProject ? '' : '/'"
:class="['header item', {disable: isSharedProject}]"
>
<img
class="ui right spaced image"
alt="Logo de l'application"
:src="logo"
>
<span class="desktop">
{{ APPLICATION_NAME }}
</span>
</router-link>
<div
v-if="width <= 560 || (width > 560 && project)"
id="menu-dropdown"
:class="['ui dropdown item', { 'active visible': menuIsOpen }]"
@click="menuIsOpen = !menuIsOpen"
>
<div
v-if="!isOnline"
class="crossed-out mobile"
>
<i
class="wifi icon"
aria-hidden="true"
/>
</div>
<span class="expand-center">
<span v-if="project"> Projet : {{ project.title }} </span>
</span>
<i
class="dropdown icon"
aria-hidden="true"
/>
<div
:class="[
'menu dropdown-list transition',
{ 'visible': menuIsOpen },
]"
style="z-index: 401"
>
<router-link
v-if="project"
:to="{
name: 'project_detail',
params: { slug: project.slug },
}"
class="item"
>
<i
class="home icon"
aria-hidden="true"
/>Accueil
</router-link>
<router-link
v-if="project"
:to="{
name: 'liste-signalements',
params: { slug: project.slug },
}"
class="item"
>
<i
class="list icon"
aria-hidden="true"
/>Liste & Carte
</router-link>
<router-link
v-if="
project && isOnline && hasAdminRights"
:to="{
name: 'project_mapping',
params: { slug: project.slug },
}"
class="item"
>
<i
class="map icon"
aria-hidden="true"
/>Fonds cartographiques
</router-link>
<router-link
v-if="
project && isOnline && hasAdminRights"
:to="{
name: 'project_members',
params: { slug: project.slug },
}"
class="item"
>
<i
class="users icon"
aria-hidden="true"
/>Membres
</router-link>
<div class="mobile">
<router-link
:is="isOnline ? 'router-link' : 'span'"
v-if="user"
:to="{
name: 'my_account',
params: { slug: isSharedProject && $route.params.slug ? $route.params.slug : null }
}"
class="item"
>
{{ userFullname || user.username || "Utilisateur inconnu" }}
</router-link>
<div
v-if="USER_LEVEL_PROJECTS && project"
class="item ui label vertical no-hover"
>
<!-- super user rights are higher than others -->
{{ user && user.is_superuser ? 'Administrateur' : USER_LEVEL_PROJECTS[project.slug] }}
<br>
</div>
<div
v-if="user && user.is_administrator"
class="item ui label vertical no-hover"
>
Gestionnaire métier
</div>
<div
v-if="!DISABLE_LOGIN_BUTTON"
>
<a
v-if="user"
class="item"
@click="logout"
><i
class="ui logout icon"
aria-hidden="true"
/>
</a>
<router-link
v-else-if="!user && !SSO_LOGIN_URL"
:to="{ name : 'login' }"
class="item"
>
Se connecter
</router-link>
<a
v-else
class="item"
:href="SSO_LOGIN_URL"
target="_self"
>Se connecter</a>
</div>
</div>
</div>
</div>
<div class="desktop flex push-right-desktop item title abstract">
<span>
{{ APPLICATION_ABSTRACT }}
</span>
</div>
<div class="desktop flex push-right-desktop">
<div
v-if="!isOnline"
class="item network-icon"
>
<span
data-tooltip="Vous êtes hors-ligne,
vos changements pourront être envoyés au serveur au retour de la connexion"
data-position="bottom right"
>
<div class="crossed-out">
<i
class="wifi icon"
aria-hidden="true"
/>
</div>
</span>
</div>
<router-link
:is="isOnline ? 'router-link' : 'span'"
v-if="user"
:to="{
name: 'my_account',
params: { slug: isSharedProject && $route.params.slug ? $route.params.slug : null }
}"
class="item"
>
{{ userFullname || user.username || "Utilisateur inconnu" }}
</router-link>
<div
v-if="USER_LEVEL_PROJECTS && project"
class="item ui label vertical no-hover"
>
<!-- super user rights are higher than others -->
{{ user && user.is_superuser ? 'Administrateur' : USER_LEVEL_PROJECTS[project.slug] }}
<br>
</div>
<div
v-if="user && user.is_administrator"
class="item ui label vertical no-hover"
>
Gestionnaire métier
</div>
<div
v-if="!DISABLE_LOGIN_BUTTON"
>
<a
v-if="user"
class="item log-item"
@click="logout"
><i
class="ui logout icon"
aria-hidden="true"
/>
</a>
<router-link
v-else-if="!user && !SSO_LOGIN_URL"
:to="{ name : 'login' }"
class="item log-item"
>
Se Connecter
</router-link>
<a
v-else
class="item log-item"
:href="SSO_LOGIN_URL"
target="_self"
>Se connecter</a>
</div>
</div>
</div>
<MessageInfoList />
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import MessageInfoList from '@/components/MessageInfoList';
export default {
name: 'AppHeader',
components: {
MessageInfoList
},
data() {
return {
menuIsOpen: false,
width: window.innerWidth > 0 ? window.innerWidth : screen.width,
};
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS',
'configuration',
'loader',
'isOnline'
]),
...mapState('projects', [
'projects',
'project',
]),
APPLICATION_NAME() {
return this.configuration.VUE_APP_APPLICATION_NAME;
},
APPLICATION_ABSTRACT() {
return this.configuration.VUE_APP_APPLICATION_ABSTRACT;
},
DISABLE_LOGIN_BUTTON() {
return this.configuration.VUE_APP_DISABLE_LOGIN_BUTTON;
},
SSO_LOGIN_URL() {
if (this.configuration.VUE_APP_LOGIN_URL) {
// add a next parameter with the pathname as expected by OGS to redirect after login
return `${this.configuration.VUE_APP_LOGIN_URL}/?next=${encodeURIComponent(window.location.pathname)}`;
}
return null;
},
logo() {
return this.configuration.VUE_APP_LOGO_PATH;
},
userFullname() {
if (this.user.first_name || this.user.last_name) {
return `${this.user.first_name} ${this.user.last_name}`;
}
return null;
},
isAdmin() {
return this.USER_LEVEL_PROJECTS &&
this.USER_LEVEL_PROJECTS[this.project.slug] === 'Administrateur projet'
? true
: false;
},
hasAdminRights() {
return this.user && (this.user.is_administrator || this.user.is_superuser) || this.isAdmin;
},
isSharedProject() {
return this.$route.path.includes('projet-partage');
}
},
created() {
window.addEventListener('mousedown', this.clickOutsideMenu);
},
beforeDestroy() {
window.removeEventListener('mousedown', this.clickOutsideMenu);
},
methods: {
logout() {
this.$store.dispatch('LOGOUT');
},
clickOutsideMenu(e) {
if (e.target.closest && !e.target.closest('#menu-dropdown')) {
this.menuIsOpen = false;
}
},
}
};
</script>
<style lang="less" scoped>
.menu.container .header {
padding-top: 5px !important;
padding-bottom: 5px !important;
&> img {
max-height: 30px;
}
}
.vertical {
flex-direction: column;
justify-content: center;
}
.flex {
display: flex;
}
/* keep above loader */
#menu-dropdown {
z-index: 1001;
}
.expand-center {
width: 100%;
text-align: center;
}
.network-icon {
padding: .5rem !important;
}
.crossed-out {
position: relative;
padding: .2em;
&::before {
content: "";
position: absolute;
top: 45%;
left: -8%;
width: 100%;
border-top: 2px solid #ee2e24;
transform: rotate(45deg);
box-shadow: 0px 0px 0px 1px #373636;
border-radius: 3px;
}
}
@media screen and (max-width: 1110px) {
.abstract{
display: none !important;
}
}
@media screen and (min-width: 726px) {
.mobile {
display: none !important;
}
#app-header {
min-width: 560px;
}
.menu.container {
width: auto !important;
}
.push-right-desktop {
margin-left: auto;
}
}
@media screen and (max-width: 725px) {
.desktop {
display: none !important;
}
div.dropdown-list {
width: 100vw;
}
.menu.container .header {
//width: 70px;
width: 100%;
}
#app-header:not(.index) {
/* make the logo disappear on scroll */
position: sticky;
top: -90px;
height: 80px;
.menu.container {
/* make the logo disappear on scroll */
height: 30px;
position: sticky;
top: 0;
}
}
.menu.container .header > img {
margin: 0;
margin: auto;
max-width: 100%;
}
#menu-dropdown {
width: 100%;
}
#menu-dropdown > span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
.menu.container {
position: relative;
}
.disable:hover {
cursor: default !important;
background-color: #373636 !important;
}
/* keep above map controls or buttons */
#app-header {
z-index: 9999;
.menu.container .ui.inverted.icon.menu { /* avoid adding space when messages are displayed */
margin: 0;
display: flex;
flex-wrap: wrap;
}
}
.ui.menu .ui.dropdown .menu {
.item.no-hover:hover {
cursor: auto !important;
background: white !important;
}
}
/* copy style to apply inside nested div */
.ui.menu .ui.dropdown .menu .item {
margin: 0;
text-align: left;
font-size: 1em !important;
padding: 0.78571429em 1.14285714em !important;
background: 0 0 !important;
color: #252525 !important;
text-transform: none !important;
font-weight: 400 !important;
box-shadow: none !important;
transition: none !important;
}
.item.title::before {
background: none !important;
}
.log-item {
height: 100% !important;
}
</style>
\ No newline at end of file
<template>
<div
:id="`custom-dropdown${identifier}`"
:class="[
'ui search selection dropdown',
{ 'active visible': isOpen },
{ disabled },
]"
@click="toggleDropdown"
>
<input
v-if="search"
ref="input"
v-model="input"
class="search"
autocomplete="off"
tabindex="0"
:placeholder="placehold"
@input="isOpen = true"
@keyup.enter="select(0)"
@keyup.esc="toggleDropdown(false)"
>
<div
v-if="!input"
class="default text"
>
<div v-if="Array.isArray(selected)">
<span v-if="selected[0]"> {{ selected[0] }} - </span>
<span class="italic">{{ selected[1] }}</span>
</div>
<div v-else>
{{ selectedDisplay }}
</div>
</div>
<i
:class="['dropdown icon', { clear: clearable && selected }]"
aria-hidden="true"
@click="clear"
/>
<div :class="['menu', { 'visible transition': isOpen }]">
<div
v-for="(option, index) in filteredOptions || ['No results found.']"
:id="option.name && Array.isArray(option.name) ? option.name[0] : option.name || option"
:key="option + index"
:class="[
filteredOptions ? 'item' : 'message',
{ 'active selected': option.name === selected || option.id === selected },
]"
@click="select(index)"
>
<div v-if="option.name && Array.isArray(option.name)">
<span v-if="option.name[0]"> {{ option.name[0] }} - </span>
<span class="italic">{{ option.name[1] }}</span>
</div>
<span v-else-if="option.name">
{{ option.name }}
</span>
<span v-else>
{{ option }}
</span>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Dropdown',
props: {
clearable: {
type: Boolean,
default: null,
},
disabled: {
type: Boolean,
default: null,
},
options: {
type: Array,
default: null,
},
placeholder: {
type: String,
default: null,
},
selected: {
type: [String, Array],
default: null,
},
search: {
type: Boolean,
default: null,
},
},
data() {
return {
isOpen: false,
input: '',
identifier: 0,
};
},
computed: {
filteredOptions: function () {
let options = this.options;
if (this.search && this.input !== '') {
options = this.options.filter(this.matchInput);
}
return options.length > 0 ? options : null;
},
placehold() {
return this.input ? '' : this.placeholder;
},
selectedDisplay() { // for project attributes, option are object and selected is an id
if (this.options[0] && this.options[0].name) {
const option = this.options.find(opt => opt.id === this.selected);
if (option) return option.name;
}
return this.selected;
}
},
created() {
const crypto = window.crypto || window.msCrypto;
const array = new Uint32Array(1);
this.identifier = Math.floor(crypto.getRandomValues(array) * 10000);
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
beforeDestroy() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
},
methods: {
toggleDropdown(val) {
if (this.isOpen) { //* if dropdown is open :
this.input = ''; // * -> clear input field when closing dropdown
} else if (this.search) { //* if dropdown is closed is a search dropdown:
this.$refs.input.focus({ //* -> focus on input field
preventScroll: true,
});
} else if (this.clearable && val.target && this.selected) {
this.clear(); //* clear selected and input
}
this.isOpen = typeof val === 'boolean' ? val : !this.isOpen;
},
select(index) {
// * toggle dropdown is called several time, timeout delay this function to be the last
setTimeout(() => {
this.isOpen = false;
}, 0);
if (this.filteredOptions) {
this.$emit('update:selection', this.filteredOptions[index]);
}
this.input = '';
},
matchInput(el) {
let match;
if (el.name && Array.isArray(el.name)) {
match =
el.name[0].toLowerCase().includes(this.input.toLowerCase()) ||
el.name[1].toLowerCase().includes(this.input.toLowerCase());
} else {
match = el.name
? el.name.toLowerCase().includes(this.input.toLowerCase())
: el.toLowerCase().includes(this.input.toLowerCase());
}
return match;
},
clear() {
if (this.clearable && this.selected) {
this.input = '';
this.$emit('update:selection', '');
if (this.isOpen) {
this.toggleDropdown(false);
}
}
},
clickOutsideDropdown(e) {
if (!e.target.closest(`#custom-dropdown${this.identifier}`) && this.isOpen) {
this.toggleDropdown(false);
}
},
},
};
</script>
<style scoped>
.ui.selection.dropdown .menu > .item {
white-space: nowrap;
}
.italic {
font-style: italic;
}
</style>
<template>
<div
:class="['field', { 'disabled': field.disabled }]"
:data-field_type="field.field_type"
data-test="extra-form"
name="extra-form"
>
<div
v-if="field && field.field_type === 'boolean'"
:class="['ui checkbox', { 'disabled': field.disabled }]"
>
<!-- JSON.parse is used in case of receiving a string 'true' or 'false'-->
<input
:id="field.name"
type="checkbox"
:checked="JSON.parse(field.value)"
:name="field.name"
@change="updateStore_extra_form"
>
<label :for="field.name">
{{ displayLabels ? field.label : '' }}
</label>
</div>
<template v-else>
<label
v-if="displayLabels"
:for="field.name"
:class="{ required: field.is_mandatory }"
>
{{ field.label }}
</label>
<input
v-if="field && field.field_type === 'char'"
:id="field.name"
:value="field.value"
type="text"
:name="field.name"
:required="field.is_mandatory"
@blur="updateStore_extra_form"
>
<textarea
v-else-if="field && field.field_type === 'text'"
:value="field.value"
:name="field.name"
:required="field.is_mandatory"
rows="3"
@blur="updateStore_extra_form"
/>
<input
v-else-if="field && field.field_type === 'integer'"
:id="field.name"
:value="field.value"
type="number"
:name="field.name"
:required="field.is_mandatory"
@change="updateStore_extra_form"
>
<input
v-else-if="field && field.field_type === 'decimal'"
:id="field.name"
:value="field.value"
type="number"
step=".01"
:name="field.name"
:required="field.is_mandatory"
@change="updateStore_extra_form"
>
<input
v-else-if="field && field.field_type === 'date'"
:id="field.name"
:value="field.value"
type="date"
:name="field.name"
:required="field.is_mandatory"
@change="updateStore_extra_form"
>
<Dropdown
v-else-if="field && (field.field_type === 'list' || field.field_type === 'notif_group')"
:options="field.field_type === 'notif_group' ? usersGroupsFeatureOptions : field.options"
:selected="selected_extra_form_list"
:selection.sync="selected_extra_form_list"
:required="field.is_mandatory"
:search="true"
:clearable="true"
/>
<div
v-else-if="field && field.field_type === 'multi_choices_list'"
class="checkbox_list"
>
<div
v-for="option in field.options"
:key="option.id || option"
class="ui checkbox"
>
<input
:id="option.id || option"
type="checkbox"
:checked="field.value && field.value.includes(option.id || option)"
:name="option.id || option"
@change="selectMultipleCheckbox"
>
<label :for="option.id || option">
{{ option.name || option }}
</label>
</div>
</div>
<Multiselect
v-else-if="field && field.field_type === 'pre_recorded_list'"
v-model="selectedPrerecordedValue"
:options="selectedPrerecordedListValues[field.options[0]] || []"
:options-limit="10"
:allow-empty="!field.is_mandatory"
track-by="label"
label="label"
:reset-after="false"
select-label=""
selected-label=""
deselect-label=""
:searchable="true"
:placeholder="'Recherchez une valeur de la liste pré-définie ...'"
:show-no-results="true"
:loading="loadingPrerecordedListValues"
:clear-on-select="false"
:preserve-search="false"
@search-change="search"
@select="selectPrerecordedValue"
>
<template slot="clear">
<div
v-if="selectedPrerecordedValue"
class="multiselect__clear"
@click.prevent.stop="clearPrerecordedValue"
>
<i
class="close icon"
aria-hidden="true"
/>
</div>
</template>
<span slot="noResult">
Aucun résultat.
</span>
<span slot="noOptions">
Saisissez les premiers caractères ...
</span>
</Multiselect>
</template>
</div>
</template>
<script>
import { mapState, mapActions, mapMutations, mapGetters } from 'vuex';
import Multiselect from 'vue-multiselect';
import Dropdown from '@/components/Dropdown.vue';
export default {
name: 'ExtraForm',
components: {
Dropdown,
Multiselect
},
props: {
field: {
type: Object,
default: null,
},
useValueOnly: {
type: Boolean,
default: false,
}
},
data() {
return {
error: null,
prerecordedListSearchQuery: null,
loadingPrerecordedListValues: false,
selectedPrerecordedValue: null,
selectedMultipleCheckbox: [],
};
},
computed: {
...mapState('feature-type', [
'selectedPrerecordedListValues'
]),
...mapState('feature', [
'extra_forms',
]),
...mapGetters(['usersGroupsFeatureOptions']),
selected_extra_form_list: {
get() {
if (this.field.field_type === 'notif_group') {
const usersGroup = this.usersGroupsFeatureOptions.find((group) => group.value === this.field.value);
return usersGroup ? usersGroup.name : '';
}
return this.field.value || '';
},
set(newValue) {
//* set the value selected in the dropdown
if (this.useValueOnly) {
this.$emit('update:value', newValue.id || newValue);
} else {
const newExtraForm = this.field;
newExtraForm['value'] = this.field.field_type === 'notif_group' ? newValue.value : newValue;
this.$store.commit('feature/UPDATE_EXTRA_FORM', newExtraForm);
}
},
},
displayLabels() {
return this.$route.name === 'editer-signalement' || this.$route.name === 'ajouter-signalement' || this.$route.name === 'editer-attribut-signalement';
},
},
watch: {
/**
* Watches for changes in the 'field.value' and updates the form state accordingly.
* This watcher handles specific field types, ensuring their values are correctly initialized
* and updated in scenarios like fast edition mode where certain values might not be auto-refreshed.
*
* @param {*} newValue - The new value of the field.
* @param {*} oldValue - The previous value of the field before the change.
*/
'field.value': function(newValue, oldValue) {
// Check if the field object exists.
if (this.field) {
// Handle pre-recorded list fields specifically.
if (this.field.field_type === 'pre_recorded_list') {
// Update the form value if both new and old values are defined and different,
// or if either value is undefined, indicating a change.
if ((newValue && oldValue && (newValue.label !== oldValue.label || newValue !== oldValue))
|| !newValue || !oldValue) {
this.initPrerecordedXform(); // Reinitialize the field to reflect the updated value.
}
} else if (this.field.field_type === 'multi_choices_list') {
// For multi-choice lists, reinitialize the field if the array values have changed.
// This is crucial in fast edition modes to prevent overriding the current value with a stale value.
this.initMultipleCheckboxXform();
}
// Reset any error states for the field.
this.error = null;
}
},
prerecordedListSearchQuery(newValue) {
this.loadingPrerecordedListValues = true;
this.GET_SELECTED_PRERECORDED_LIST_VALUES({
name: this.field.options[0],
pattern: newValue
})
.then(() => {
this.loadingPrerecordedListValues = false;
})
.catch(() => {
this.loadingPrerecordedListValues = false;
});
}
},
created() {
if (this.field) {
if (this.field.field_type === 'pre_recorded_list') {
this.initPrerecordedXform();
} else if (this.field.field_type === 'multi_choices_list') {
this.initMultipleCheckboxXform();
}
}
},
mounted() {
// autoset field to false if is a boolean, since user doesn't need to select it, when false value is expected
if (this.field.field_type === 'boolean' && (this.field.value === undefined || this.field.value === null)) {
this.updateStore_extra_form(false);
}
},
methods: {
...mapActions('feature-type', [
'GET_SELECTED_PRERECORDED_LIST_VALUES'
]),
...mapMutations('feature', [
'UPDATE_EXTRA_FORM',
'SET_EXTRA_FORMS',
]),
initMultipleCheckboxXform() {
this.selectedMultipleCheckbox = typeof this.field.value === 'string' ? this.field.value.split(',') : this.field.value || [];
},
initPrerecordedXform() {
const { options, value } = this.field;
this.loadingPrerecordedListValues = true;
this.GET_SELECTED_PRERECORDED_LIST_VALUES({
name: options[0],
pattern: ''
})
.then(() => {
this.loadingPrerecordedListValues = false;
})
.catch(() => {
this.loadingPrerecordedListValues = false;
});
if (value) {
this.selectedPrerecordedValue = { label: value.label ? value.label : value };
} else {
this.selectedPrerecordedValue = null;
}
},
/**
* Updates the Vuex store or component state with the new value for a form field.
* This function handles different types of form fields including boolean, multi-choice lists, and others.
*
* @param {Event|*} val - The new value or an event object.
*/
updateStore_extra_form(val) {
// Check if the field object is defined.
if (this.field) {
let newValue;
// If the function is triggered by an event from a template input.
if (val && val.target) {
// For boolean fields (like checkboxes), use the 'checked' property.
if (this.field.field_type === 'boolean') {
newValue = val.target.checked;
} else {
// For other input types, use the 'value' property.
newValue = val.target.value;
}
} else if (this.field.field_type === 'multi_choices_list') {
// For multi-choice lists, the value is stored in component state.
newValue = this.selectedMultipleCheckbox;
} else {
// If the function is called directly with a value (not from an event).
newValue = val;
}
// Set the new value for the field.
if (this.useValueOnly) {
// If the component is used to update directly a returned value, emit an event with the new value.
this.$emit('update:value', newValue);
} else {
// Otherwise, update the Vuex store with the new value for the extra form field.
this.UPDATE_EXTRA_FORM({ ...this.field, value: newValue });
}
}
},
checkForm() {
let isValid = true;
if (this.field && this.field.is_mandatory && !this.field.value) {
isValid = false;
this.error = 'Ce champ est obligatoire';
} else {
this.error = null;
}
return isValid;
},
search(text) {
this.prerecordedListSearchQuery = text;
},
selectPrerecordedValue(e) {
this.selectedPrerecordedValue = e;
this.prerecordedListSearchQuery = null;
this.updateStore_extra_form({ target: { value: this.selectedPrerecordedValue.label } });
},
clearPrerecordedValue() {
this.selectedPrerecordedValue = null;
this.prerecordedListSearchQuery = null;
this.updateStore_extra_form({ target: { value: null } });
},
/**
* Handles the selection and deselection of checkboxes in a form.
* This function updates an array to track the selected checkboxes by their names.
* It's typically called on the change event of each checkbox.
*
* @param {Event} e - The event object from the checkbox input.
*/
selectMultipleCheckbox(e) {
// Destructure the 'checked' status and 'name' of the checkbox from the event target.
const { checked, name } = e.target;
// If the checkbox is checked, add its name to the array of selected checkboxes.
// Cloning the array to allow unsaved changes detection (it wasn't working with Array.push)
if (checked) {
this.selectedMultipleCheckbox = [...this.selectedMultipleCheckbox, name];
} else {
// If the checkbox is unchecked, remove its name from the array.
this.selectedMultipleCheckbox = this.selectedMultipleCheckbox.filter((el) => el !== name);
}
// Call a method to update the Vuex store or component state with the latest selection.
this.updateStore_extra_form();
},
},
};
</script>
<style lang="less" scoped>
label.required:after {
content: ' *';
color: rgb(209, 0, 0);
}
.checkbox_list {
display: flex;
flex-direction: column;
.ui.checkbox {
margin: .25rem 0;
font-weight: normal;
}
}
</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>
<template>
<div>
<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
"
alt="Pièce jointe au signalement"
>
</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>
<em v-if="attachments.length === 0">
Aucune pièce jointe associée au signalement.
</em>
</div>
</template>
<script>
export default {
name: 'FeatureAttachements',
props: {
attachments: {
type: Array,
default: () => {
return [];
}
}
}
};
</script>
<template>
<div>
<h2 class="ui header">
Activité et commentaires
</h2>
<div
id="feed-event"
class="ui feed"
>
<div
v-for="(event, index) in events"
:key="'event' + index"
>
<div
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-if="event.related_comment.attachment"
>
<br>
<a
:href="
DJANGO_BASE_URL +
event.related_comment.attachment.url
"
target="_blank"
>
<i
class="paperclip fitted icon"
aria-hidden="true"
/>
{{ 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 && isOnline"
class="ui segment"
>
<form
id="form-comment"
class="ui form"
>
<div class="required field">
<label
:for="comment_form.comment.id_for_label"
>Ajouter un commentaire</label>
<ul
v-if="comment_form.comment.errors"
class="errorlist"
>
<li>
{{ comment_form.comment.errors }}
</li>
</ul>
<textarea
v-model="comment_form.comment.value"
:name="comment_form.comment.html_name"
rows="2"
/>
</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"
aria-hidden="true"
/>
<span class="label">{{
comment_form.attachment_file.value
? comment_form.attachment_file.value
: "Sélectionner un fichier ..."
}}</span>
</label>
<input
id="attachment_file"
type="file"
accept="application/pdf, image/jpeg, image/png"
style="display: none"
name="attachment_file"
@change="onFileChange"
>
</div>
<div class="field">
<input
id="title"
v-model="comment_form.attachment_file.title"
type="text"
name="title"
>
{{ comment_form.attachment_file.errors }}
</div>
</div>
<div
v-if="enableKeyDocNotif"
class="field"
>
<div class="ui checkbox">
<input
id="is_key_document"
v-model="comment_form.attachment_file.isKeyDocument"
class="hidden"
name="is_key_document"
type="checkbox"
>
<label for="is_key_document">Envoyer une notification de publication aux abonnés du projet</label>
</div>
</div>
<ul
v-if="comment_form.attachment_file.errors"
class="errorlist"
>
<li>
{{ comment_form.attachment_file.errors }}
</li>
</ul>
<button
type="button"
class="ui compact green icon button"
@click="postComment"
>
<i
class="plus icon"
aria-hidden="true"
/> Poster le commentaire
</button>
</form>
</div>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import featureAPI from '@/services/feature-api';
export default {
name: 'FeatureComments',
props: {
events: {
type: Array,
default: () => {
return [];
}
},
enableKeyDocNotif: {
type: Boolean,
default: false,
}
},
data() {
return {
comment_form: {
attachment_file: {
errors: null,
title: '',
file: null,
isKeyDocument: false
},
comment: {
id_for_label: 'add-comment',
html_name: 'add-comment',
errors: '',
value: null,
},
},
};
},
computed: {
...mapState([
'user',
'isOnline',
]),
...mapGetters([
'permissions',
]),
...mapState('feature', [
'currentFeature',
]),
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
},
methods: {
validateForm() {
this.comment_form.comment.errors = '';
if (!this.comment_form.comment.value) {
this.comment_form.comment.errors = 'Le commentaire ne peut pas être vide';
return false;
}
return true;
},
postComment() {
if (this.validateForm()) {
featureAPI
.postComment({
featureId: this.currentFeature.feature_id || this.currentFeature.id,
comment: this.comment_form.comment.value,
})
.then((response) => {
if (response && this.comment_form.attachment_file.file) {
featureAPI
.postCommentAttachment({
featureId: this.currentFeature.feature_id || this.currentFeature.id,
file: this.comment_form.attachment_file.file,
fileName: this.comment_form.attachment_file.fileName,
title: this.comment_form.attachment_file.title,
isKeyDocument: this.comment_form.attachment_file.isKeyDocument,
commentId: response.data.id,
})
.then(() => {
// Reset isKeyDocument to default
this.comment_form.attachment_file.isKeyDocument = false;
this.confirmComment();
});
} else {
this.confirmComment();
}
});
}
},
confirmComment() {
this.$store.commit('DISPLAY_MESSAGE', { comment: 'Ajout du commentaire confirmé', level: 'positive' });
this.$emit('fetchEvents'); //* display new comment on the page
this.comment_form.attachment_file.file = null; //* empty all inputs
this.comment_form.attachment_file.fileName = '';
this.comment_form.attachment_file.title = '';
this.comment_form.comment.value = null;
},
onFileChange(e) {
// * read image file
const files = e.target.files || e.dataTransfer.files;
const handleFile = (isValid) => {
if (isValid) {
this.comment_form.attachment_file.file = files[0]; //* store the file to post afterwards
let title = files[0].name;
this.comment_form.attachment_file.fileName = title; //* name of the file
const fileExtension = title.substring(title.lastIndexOf('.') + 1);
if ((title.length - fileExtension.length) > 11) {
title = `${title.slice(0, 10)}[...].${fileExtension}`;
}
this.comment_form.attachment_file.title = title; //* title for display
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);
}
}
},
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);
},
}
};
</script>
<style lang="less" scoped>
.event {
margin-bottom: 1em;
.content {
display: block;
flex: 1 1 auto;
align-self: stretch;
text-align: left;
word-wrap: break-word;
.summary {
margin: 0;
font-size: 1em;
font-weight: 700;
color: #252525;
.date {
display: inline-block;
float: none;
font-weight: 400;
font-size: .85714286em;
font-style: normal;
margin: 0 1em 0 .5em;
padding: 0;
color: rgba(0,0,0,.4);
}
}
.extra.text {
margin-left: 107px;
margin-top: 0;
}
}
}
</style>
\ No newline at end of file
<template>
<div>
<h1 class="ui header">
<div class="content">
<div class="two-block">
<div
v-if="fastEditionMode && form && canEditFeature"
class="form ui half-block"
>
<input
id="feature_detail_title_input"
:value="form.title"
type="text"
required
maxlength="128"
name="title"
@blur="updateTitle"
>
</div>
<div
v-else
class="ellipsis"
>
{{ currentFeature.properties ?
currentFeature.properties.title : currentFeature.id }}
</div>
<div
id="feature-actions"
class="ui icon compact buttons"
>
<div
v-if="queryparams"
class="fast_browsing"
>
<div>
<div>
<strong>
Tri en cours:
</strong>
<span>
par&nbsp;{{ currentSort }}
</span>
</div>
<div>
<strong>
Filtre en cours:
</strong>
<span>
{{ currentFilters }}
</span>
</div>
</div>
<span
class="feature-count"
>
{{ parseInt($route.query.offset) + 1 }} sur {{ featuresCount }}
</span>
<button
id="previous-feature"
:class="['ui button button-hover-green tiny-margin', { disabled: queryparams.previous < 0 }]"
data-tooltip="Voir le précédent signalement"
data-position="bottom center"
@click="toFeature('previous')"
>
<i
class="angle left fitted icon"
aria-hidden="true"
/>
</button>
<button
id="next-feature"
:class="[
'ui button button-hover-green tiny-margin',
{ disabled: queryparams.next >= featuresCount }
]"
data-tooltip="Voir le prochain signalement"
data-position="bottom center"
@click="toFeature('next')"
>
<i
class="angle right fitted icon"
aria-hidden="true"
/>
</button>
</div>
<div>
<button
v-if="fastEditionMode && canEditFeature"
id="save-fast-edit"
:class="['ui button button-hover-orange tiny-margin', { disabled: false }]"
data-tooltip="Enregistrer les modifications"
data-position="bottom center"
@click="fastEditFeature"
>
<i
class="save fitted icon"
aria-hidden="true"
/>
</button>
<router-link
v-if="permissions && permissions.can_create_feature
&& (featureType && !featureType.geom_type.includes('multi'))"
id="add-feature"
:to="{
name: 'ajouter-signalement',
params: {
slug_type_signal: $route.params.slug_type_signal || featureType ? featureType.slug : '',
},
}"
class="ui button button-hover-green tiny-margin"
data-tooltip="Ajouter un signalement"
data-position="bottom center"
>
<i
class="plus icon"
aria-hidden="true"
/>
</router-link>
<router-link
v-if="slugSignal && canEditFeature"
id="edit-feature"
:to="{
name: 'editer-signalement',
params: {
slug_signal: slugSignal,
slug_type_signal: $route.params.slug_type_signal || featureType ? featureType.slug : '',
},
query: $route.query
}"
class="ui button button-hover-orange tiny-margin"
data-tooltip="Éditer le signalement"
data-position="bottom center"
>
<i
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<a
v-if="canDeleteFeature && isOnline"
id="currentFeature-delete"
class="ui button button-hover-red tiny-margin"
data-tooltip="Supprimer le signalement"
data-position="bottom right"
@click="$emit('setIsDeleting')"
>
<i
class="inverted grey trash alternate icon"
aria-hidden="true"
/>
</a>
</div>
</div>
</div>
<!-- <div class="ui hidden divider" /> -->
<div class="sub header prewrap">
<span
v-if="fastEditionMode && canEditFeature && form"
class="form ui half-block"
>
<textarea
:value="form.description.value"
name="description"
rows="5"
@blur="updateDescription"
/>
</span>
<span v-else-if="currentFeature && currentFeature.properties">
{{ currentFeature.properties.description }}
</span>
</div>
</div>
</h1>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
export default {
name: 'FeatureHeader',
props: {
featuresCount : {
type: Number,
default: null,
},
slugSignal: {
type: String,
default: '',
},
featureType: {
type: Object,
default: () => {},
},
fastEditionMode: {
type: Boolean,
default: false,
},
isFeatureCreator: {
type: Boolean,
default: false,
},
canEditFeature: {
type: Boolean,
default: false,
},
canDeleteFeature: {
type: Boolean,
default: false,
},
},
computed: {
...mapState([
'user',
'isOnline',
]),
...mapState('feature', [
'currentFeature',
'form',
]),
...mapState('projects', [
'project'
]),
...mapGetters([
'permissions',
]),
queryparams() {
return this.$route.query.offset >= 0 ? {
previous: parseInt(this.$route.query.offset) - 1,
next: parseInt(this.$route.query.offset) + 1
} : null;
},
currentSort() {
const sort = this.$route.query.ordering;
if (sort) {
if (sort.includes('status')) {
return 'statut';
} else if (sort.includes('feature_type')) {
return 'type de signalement';
} else if (sort.includes('title')) {
return 'nom';
} else if (sort.includes('updated_on')) {
return 'date de modification';
} else if (sort.includes('creator')) {
return 'auteur';
} else if (sort.includes('last_editor')) {
return 'dernier éditeur';
}
}
return 'date de création';
},
currentFilters() {
let filters = [];
if (this.$route.query.feature_type_slug) filters.push('type de signalement');
if (this.$route.query.status__value) filters.push('statut');
if (this.$route.query.title) filters.push('titre');
if (filters.length > 0) {
return `par ${filters.join(', ')}`;
} else {
return 'désactivé';
}
}
},
methods: {
toFeature(direction) {
this.$emit('tofeature', {
name: 'details-signalement-filtre',
params: {
slug_type_signal: this.currentFeature.properties.feature_type.slug,
},
query: {
...this.$route.query,
offset: this.queryparams[direction]
}
});
},
updateTitle(e) {
this.$store.commit('feature/UPDATE_FORM_FIELD', { name: 'title', value: e.target.value });
},
updateDescription(e) {
this.$store.commit('feature/UPDATE_FORM_FIELD', { name: 'description', value: e.target.value });
},
fastEditFeature() {
this.$emit('fastEditFeature');
}
}
};
</script>
<style lang="less">
#feature_detail_title_input {
font-weight: bold;
font-size: 2em;
padding: .25em;
}
.two-block {
display: flex;
justify-content: space-between;
margin-bottom: .5em;
}
#feature-actions {
flex-direction: column;
> div {
line-height: initial;
&:last-of-type {
text-align: right;
}
}
> .fast_browsing {
display: flex;
align-items: center;
margin-bottom: .25rem;
span {
margin-left: .1em;
}
span, div {
font-size: 1rem;
color: #666666;
margin-right: 1rem;
font-weight: normal;
opacity: 1 !important;
}
}
}
@media screen and (max-width: 700px) {
.two-block {
flex-direction: column-reverse;
}
#feature-actions.ui.buttons {
flex-direction: column;
align-items: flex-end;
}
}
</style>
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
<template>
<div class="ui mini modal">
<i
class="close icon"
aria-hidden="true"
/>
<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"
>
{% csrf_token %}
<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">
<label>Image (png ou jpeg)</label>
<label
class="ui icon button"
for="image_file"
>
<i
class="file icon"
aria-hidden="true"
/>
<span class="label">Sélectionner une image ...</span>
</label>
<input
id="image_file"
type="file"
accept="image/jpeg, image/png"
style="display: none"
name="image_file"
class="image_file"
>
<p
class="error-message"
style="color: red"
/>
</div>
<button
id="get-geom-from-image-file"
type="button"
class="ui positive right labeled icon button"
>
Importer
<i
class="checkmark icon"
aria-hidden="true"
/>
</button>
</form>
</div>
</div>
</template>
<script>
export default {
name: 'FeatureEditModal'
};
</script>
<template>
<div
id="status"
class="field"
>
<Dropdown
v-if="selectedStatus"
:options="allowedStatusChoices"
:selected="selectedStatus.name"
:selection.sync="selectedStatus"
/>
</div>
</template>
<script>
import Dropdown from '@/components/Dropdown.vue';
import { statusChoices, allowedStatus2change } from '@/utils';
import { mapState } from 'vuex';
export default {
name: 'FeatureEditStatusField',
components: {
Dropdown,
},
props: {
status: {
type: String,
default: '',
},
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS',
]),
...mapState('projects', [
'project'
]),
...mapState('feature', [
'currentFeature'
]),
statusObject() {
return statusChoices.find((key) => key.value === this.status);
},
selectedStatus: {
get() {
return this.statusObject;
},
set(newValue) {
this.$store.commit('feature/UPDATE_FORM_FIELD', { name: 'status', value: newValue.value });
},
},
isFeatureCreator() {
if (this.currentFeature && this.currentFeature.properties && this.user) {
return this.currentFeature.properties.creator === this.user.id ||
this.currentFeature.properties.creator.username === this.user.username;
}
return false;
},
allowedStatusChoices() {
if (this.project && this.currentFeature && this.user) {
const isModerate = this.project.moderation;
const userStatus = this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.project.slug];
return allowedStatus2change(this.user, isModerate, userStatus, this.isFeatureCreator);
}
return [];
},
},
};
</script>
\ No newline at end of file
<template>
<router-link
:is="query && Number.isInteger(query.offset) ? 'router-link' : 'span'"
:to="{
name: 'details-signalement-filtre',
params: { slug },
query,
}"
>
{{ properties.title || featureId }}
</router-link>
</template>
<script>
import { mapState } from 'vuex';
import axios from '@/axios-client.js';
import projectAPI from '@/services/project-api';
export default {
name: 'FeatureFetchOffsetRoute',
props: {
featureId: {
type: String,
default: '',
},
properties: {
type: Object,
default: () => {},
},
},
data() {
return {
position: null,
slug: this.$route.params.slug || this.properties.project_slug,
ordering: null,
filter: null,
};
},
computed: {
...mapState('projects', [
'project'
]),
query() {
if (this.ordering) {
const searchParams = { ordering: this.ordering };
if (this.filter === 'feature_type_slug') { // when feature_type is the default filter of the project,
searchParams['feature_type_slug'] = this.properties.feature_type.slug; // get its slug for the current feature
}
if (Number.isInteger(this.position)) {
searchParams['offset'] = this.position; // get its slug for the current feature
}
return searchParams;
}
return null;
},
},
watch: {
featureId() {
this.initData();
}
},
created() {
this.initData();
},
methods: {
async initData() {
if (this.project) {
this.setProjectParams(this.project);
} else {
await this.getProjectFilterAndSort();
}
this.getFeaturePosition(this.featureId)
.then((position) => {
if (Number.isInteger(position)) {
this.position = position;
}
})
.catch((error) => {
console.error(error);
});
},
setProjectParams(project) {
this.ordering = project.feature_browsing_default_sort;
if (project.feature_browsing_default_filter === 'feature_type_slug') { // when feature_type is the default filter of the project,
this.filter = this.properties.feature_type.slug;
}
},
async getFeaturePosition(featureId) {
const searchParams = new URLSearchParams(this.query);
const response = await axios.get(`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature/${featureId}/position-in-list/?${searchParams.toString()}`);
if (response && response.status === 200) {
return response.data;
}
return null;
},
async getProjectFilterAndSort() {
const project = await projectAPI.getProject(this.$store.state.configuration.VUE_APP_DJANGO_API_BASE, this.slug);
if (project) this.setProjectParams(project);
}
}
};
</script>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.