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 4240 additions and 47 deletions
/* ---------------------------------- */
/* HEADER */
/* APP */
/* ---------------------------------- */
header {
background: #373636;
body {
height: 100%;
width: 100%;
margin: 0;
}
#app {
position: relative;
min-height: 100vh;
/* keep the space for loader, before page contents are injected */
display: flex;
/* used to fix height on sticky header and footer */
flex-direction: column;
/* used to fix height on sticky header and footer */
}
#app-header {
position: sticky;
top: 0;
z-index: 1;
background: var(--header-color, #373636);
.ui.inverted.menu {
background: var(--header-color, #373636);
}
.item {
background: var(--header-color, #373636);
}
}
#app-content {
overflow: auto;
flex: 1 0 auto;
/* used to fix height on sticky header and footer */
min-height: 61px;
/* value by default of the header, defined here to be sync with anchor below, in order to keep the page stuck to top */
position: relative;
/* for anchor below */
}
#scroll-top-anchor {
position: absolute;
top: -61px;
visibility: hidden;
}
.page-content {
max-width: 1200px;
padding: 0 2em;
margin: 2em auto;
}
#map {
width: 100%;
height: 100%;
min-height: 250px;
touch-action: none;
/* workaround for modifying feature on mobile */
}
#app-footer {
overflow: hidden;
background-color: #464646;
text-align: center;
flex-shrink: 0;
/* used to fix height on sticky header and footer */
position: sticky;
bottom: 0;
z-index: 1000;
}
#app-footer .ui.text.menu {
min-height: 30px !important;
}
#app-footer .ui.text.menu .item {
color: #ffffff;
padding: 5px 14px;
}
#app-footer .ui.text.menu a.item:hover {
color: var(--primary-color, #008c86);
}
#app-footer .ui.text.menu .item:not(:first-child) {
border-left: 1px solid rgba(34, 36, 38, .15);
}
/* ---------------------------------- */
/* UTILS */
/* ---------------------------------- */
.inline {
display: inline;
}
.no-margin {
margin: 0 !important;
}
.margin-top {
margin-top: 1rem;
}
.margin-bottom {
margin-bottom: 1rem;
}
main {
padding: 2em 0em;
.tiny-margin {
margin: 0.1rem 0 0.1rem 0.1rem !important;
}
.tiny-margin-left {
margin-left: 0.1rem !important;
}
.ellipsis {
text-overflow: ellipsis;
overflow: hidden;
}
.nowrap {
white-space: nowrap;
}
.important-flex {
display: flex !important;
}
.pointer:hover {
cursor: pointer !important;
}
.dimmer-anchor {
position: relative;
}
.full-width {
width: 100%;
}
/* ---------------------------------- */
/* MAIN */
/* MAIN */
/* ---------------------------------- */
.button-hover-orange:hover {
background: #fbbd08 !important;
}
.button-hover-green:hover {
background: #5bba21 !important;
}
.button-hover-red:hover {
background: #ee2e24 !important;
}
.ui.button.button-hover-red:hover,
.ui.button.button-hover-red:hover i.icon,
.ui.button.button-hover-green:hover,
.ui.button.button-hover-green:hover i.icon {
color: #fff !important;
}
.ui.button.button-hover-red:hover i.icon,
.ui.button.button-hover-green:hover i.icon {
transition: all 0.5s ease !important;
}
.ui.header .content {
width: 100%;
}
.ui.horizontal.divider {
color: #1ab2b6!important;
color: var(--primary-color, #008c86) !important;
padding-top: 1.5em;
}
......@@ -37,7 +185,7 @@ main {
display: none;
}
.ui.dropdown .menu > .header {
.ui.dropdown .menu>.header {
font-size: 1em;
text-transform: none;
}
......@@ -47,23 +195,33 @@ main {
overflow: auto;
}
.ui.dropdown .menu.text-wrap > .item {
.ui.dropdown .menu.text-wrap>.item {
white-space: normal;
word-wrap: normal;
}
.ui.checkbox.disabled>input {
cursor: default !important;
}
/* Add basemap view */
#form-layers .ui.buttons{
margin-bottom: 1rem;
#form-layers button.button:not(:last-of-type) {
margin-right: 0.5em !important;
}
#form-layers .errorlist{
#form-layers .errorlist {
list-style: none;
padding-left: 0;
color: #9f3a38;
}
#form-layers .infoslist {
list-style: none;
padding-left: 0;
color: #38989f;
}
/* Fix semantic ui overflow when is too long */
.layer-item .form div.text {
width: 100%
......@@ -79,9 +237,9 @@ main {
}
/* Thicker borders for each basemap segment */
#form-layers [data-segments=basemap_set-SEGMENTS] > .ui.segment {
#form-layers [data-segments=basemap_set-SEGMENTS]>.ui.segment {
margin-bottom: 3rem;
border: 1px solid rgba(34,36,38,.30);
border: 1px solid rgba(34, 36, 38, .30);
}
......@@ -99,59 +257,202 @@ main {
opacity: 0.9;
}
/* */
/* ---------------------------------- */
/* LEAFLET DRAW TOOLBAR */
/* LEAFLET DRAW TOOLBAR */
/* ---------------------------------- */
.leaflet-draw-toolbar a.leaflet-draw-draw-circlemarker,
.leaflet-draw-toolbar a.leaflet-draw-draw-polyline,
.leaflet-draw-toolbar a.leaflet-draw-draw-polygon {
background-color: #FFA19E;
background-color: #FFA19E;
}
/* ---------------------------------- */
/* ERROR LIST */
/* ---------------------------------- */
.errorlist {
margin-top: 1rem;
padding: 0;
}
.infoslist {
margin-top: 0.1rem;
padding: 0;
}
.errorlist>li {
list-style: none;
color: rgb(177, 55, 55);
border: thin solid rgb(197, 157, 157);
border-radius: 3px;
background-color: rgb(250, 241, 242);
padding: 0.5rem 1rem;
}
.infoslist>li {
list-style: none;
color: #38989f;
border-radius: 3px;
padding: 0;
text-align: right;
}
/* ---------------------------------- */
/* LEAFLET*/
/* PAGINATION */
/* ---------------------------------- */
.leaflet-container {
background: #FFF;
.custom-pagination {
display: flex;
align-items: center;
list-style: none;
font-size: 1.2em;
}
.custom-pagination>.page-item>.page-link {
border: none;
font-weight: 400;
color: #008080;
}
.custom-pagination>.page-item.active>.page-link {
color: #008080;
background-color: transparent;
font-weight: bolder;
text-shadow: 0 0 2px #008080;
padding: 0.325em 0.75em;
pointer-events: none;
}
.custom-pagination>.page-item.disabled>.page-link {
opacity: 0.5;
pointer-events: none;
}
.custom-pagination>div>.page-item>.page-link {
border: none;
font-weight: 400;
color: #008080;
padding: 0.325em 0.75em;
}
.custom-pagination>div>.page-item.active>.page-link {
color: #008080;
background-color: transparent;
font-weight: bolder;
font-size: 1.2em;
text-shadow: 0 0 2px #008080;
padding: 0.325em 0.75em;
pointer-events: none;
}
.custom-pagination>div>.page-item.disabled>.page-link {
opacity: 0.5;
padding: 0.325em 0.75em;
pointer-events: none;
}
/* ---------------------------------- */
/* FOOTER */
/* MULTISELECT */
/* ---------------------------------- */
footer {
background-color: #464646;
.multiselect {
font-family: var(--font-family, 'Roboto Condensed', 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif) !important;
}
.multiselect__tags {
border: 1px solid #ced4da;
border-radius: 0 !important;
font-family: var(--font-family, 'Roboto Condensed', 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif) !important;
font-size: 1rem !important;
}
.multiselect__tags>.multiselect__input {
border: none !important;
font-size: 1rem !important;
overflow: hidden;
text-overflow: ellipsis;
}
.multiselect__placeholder {
color: #838383;
margin-bottom: 0px;
padding-top: 0;
}
.multiselect__single,
.multiselect__tags,
.multiselect__content,
.multiselect__option {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 100%;
background-color: #fff;
font-size: 1rem !important;
}
.multiselect__select {
z-index: 1 !important;
}
.multiselect__content-wrapper {
box-shadow: 0 2px 3px 0 rgba(34, 36, 38, .15);
}
.multiselect__clear {
position: absolute;
right: 1px;
top: 8px;
width: 40px;
display: block;
cursor: pointer;
z-index: 9;
background-color: #fff;
padding: 0 4px;
text-align: center;
}
footer .ui.text.menu .item {
color: #ffffff;
padding: 5px 14px;
.multiselect__spinner {
z-index: 2 !important;
background-color: #fff;
opacity: 1;
top: 2px;
}
footer .ui.text.menu a.item:hover {
color: #1ab2b6;
.menu.projects>.item>.multiselect {
min-height: 0px !important;
}
footer .ui.text.menu .item:not(:first-child) {
border-left: 1px solid rgba(34,36,38,.15);
.menu.projects>.item>.multiselect>.multiselect__tags {
min-height: 0px !important;
}
.multiselect__option--selected {
background: #fff !important;
color: #35495e !important;
}
.multiselect__option--highlight {
background: #f3f3f3 !important;
color: #35495e !important;
}
.multiselect__option--selected.multiselect__option--highlight {
background: #f3f3f3 !important;
color: #35495e !important;
}
.multiselect__clear i.icon {
font-size: .75em;
color: #999;
margin: 0;
}
/* ---------------------------------- */
/* ERROR LIST */
/* OVERRIDE SEMANTIC STYLES */
/* ---------------------------------- */
.errorlist {
margin-top: 1rem;
padding: 0;
}
.errorlist > li {
list-style: none;
color: rgb(177, 55, 55);
border: thin solid rgb(197, 157, 157);
border-radius: 3px;
background-color: rgb(250, 241, 242);
padding: 1rem;
.ui.page.dimmer {
/* keep the dimmer above the dropdown (z-index 1001: above the map)*/
z-index: 1002;
}
\ No newline at end of file
/* OPENLAYERS */
.ol-zoom{
right: 5px !important;
left:unset !important;
}
.ol-popup {
position: absolute;
background-color: white;
padding: 15px 5px 15px 15px;
border-radius: 10px;
bottom: 12px;
left: -120px;
width: 240px;
line-height: 1.4;
-webkit-box-shadow: 0 3px 14px rgba(0,0,0,.4);
box-shadow: 0 3px 14px rgba(0,0,0,.4);
}
.ol-popup #popup-content {
line-height: 1.3;
font-size: .95em;
}
.ol-popup #popup-content h4 {
margin-right: 15px;
margin-bottom: .5em;
color: #cacaca;
}
.ol-popup #popup-content h4,
.ol-popup #popup-content div {
text-overflow: ellipsis;
overflow: hidden;
}
.ol-popup #popup-content div {
color: #434343;
}
.ol-popup #popup-content .fields {
max-height: 200px;
overflow-y: scroll;
overflow-x: hidden;
padding-right: 10px;
display: block; /* overide .ui.form.fields rule conflict in featureEdit page */
margin: 0; /* overide .ui.form.fields rule conflict in featureEdit page */
}
.ol-popup #popup-content .divider {
margin-bottom: 0;
}
.ol-popup #popup-content #customFields h5 {
max-height: 20;
margin: .5em 0;
}
.ol-popup:after, .ol-popup:before {
top: 100%;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
}
.ol-popup:after {
border-top-color: white;
border-width: 10px;
left: 50%;
margin-left: -10px;
}
.ol-popup:before {
border-top-color: #cccccc;
border-width: 11px;
left: 50%;
margin-left: -11px;
}
.ol-popup-closer {
text-decoration: none;
position: absolute;
top: 4px;
right: 0;
width: 24px;
height: 24px;
font: 18px/24px Tahoma,Verdana,sans-serif;
color: #757575;
}
.ol-popup-closer:after {
content: "×";
}
.ol-scale-line {
left: 2em;
background: hsla(0,0%,100%,.5);
padding: 0;
}
.ol-scale-line-inner {
color: #333;
font-size: 0.9em;
text-align: left;
padding-left: 0.5em;
border: 2px solid #777;
border-top: none;
}
.ol-control {
border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box;
padding: 0;
}
.ol-control button {
background-color: #fff;
color: #000;
height: 30px;
width: 30px;
font: 700 18px Lucida Console,Monaco,monospace;
margin: 0;
}
.ol-control button:hover {
cursor: pointer;
background-color: #ebebeb;
}
.ol-control button:focus {
background-color: #ebebeb;
}
/* hide the popup before the map get loaded */
.map-container > #popup.ol-popup {
display: none;
}
.ol-full-screen {
top: calc(1em + 60px);
right: 5px !important;
}
/* Geolocation button */
div.geolocation-container {
position: absolute;
right: 6px;
z-index: 9;
border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box;
padding: 0;
border-radius: 4px;
}
button.button-geolocation {
border: none;
padding: 0;
margin: 0;
text-align: center;
background-color: #fff;
color: rgb(39, 39, 39);
width: 30px;
height: 30px;
font: 700 18px Lucida Console,Monaco,monospace;
border-radius: 2px;
line-height: 1.15;
cursor: pointer;
}
button.button-geolocation:hover {
background-color: #ebebeb;
}
button.button-geolocation.tracking {
background-color: rgba(255, 145, 0, 0.904);
color: #fff;
}
button.button-geolocation i {
margin: 0;
vertical-align: top; /* strangely top is the only value that center at middle */
background-image: url(../img/geolocation-icon.png);
background-size: cover;
width: 25px;
height: 25px;
}
\ No newline at end of file
......@@ -13,8 +13,7 @@
border: 1px solid grey;
top: 0;
position: absolute;
/* Under this value, the map hide the sidebar */
z-index: 400;
z-index: 9;
}
.sidebar-layers {
......@@ -62,7 +61,7 @@
.sidebar-container.expanded .layers-icon svg path,
.sidebar-container.closing .layers-icon svg path {
fill: #00b5ad;
fill: var(--primary-color, #00b5ad);
}
@keyframes open-sidebar {
......@@ -132,7 +131,7 @@
}
.layers-icon:hover svg path {
fill: #00b5ad;
fill: var(--primary-color, #00b5ad);
}
.basemaps-title {
......@@ -144,7 +143,6 @@
}
/* Layer item */
.layer-item {
padding-bottom: 0.5rem;
}
......@@ -164,6 +162,7 @@
.range-container {
display: flex;
min-width: 15em; /* give space for the bubble since adding a min-width to keep its shape */
}
.range-output-bubble {
......@@ -172,6 +171,8 @@
padding: 4px 7px;
border-radius: 40px;
background-color: #2c3e50;
min-width: 2em;
text-align: center;
}
/* Overrides default padding of semantic-ui accordion */
......
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
This diff is collapsed.
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>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.