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 1910 additions and 4104 deletions
<template>
<div>
<div
v-if="permissions && permissions.can_view_project && project"
id="project-detail"
>
<ProjectHeader
:arrays-offline="arraysOffline"
@retrieveInfo="retrieveProjectInfo"
@updateLocalStorage="updateLocalStorage"
/>
<div class="ui grid stackable">
<div class="row">
<div class="eight wide column">
<ProjectFeatureTypes
:loading="featureTypesLoading"
:project="project"
@delete="toggleDeleteFeatureTypeModal"
@update="updateAfterImport"
/>
</div>
<div class="eight wide column block-map">
<div class="map-container">
<div
id="map"
ref="map"
/>
<div
:class="{ active: mapLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Chargement de la carte...
</div>
</div>
<SidebarLayers
v-if="basemaps && map && !projectInfoLoading"
ref="sidebar"
/>
<Geolocation />
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div>
<router-link
id="features-list"
:to="{
name: 'liste-signalements',
params: { slug: slug },
}"
custom
>
<div
class="ui button fluid teal"
>
<i class="ui icon arrow right" />
Voir tous les signalements
</div>
</router-link>
</div>
</div>
<div class="row">
<div class="sixteen wide column">
<div class="ui two stackable cards">
<ProjectLastFeatures
ref="lastFeatures"
/>
<ProjectLastComments
:loading="projectInfoLoading"
/>
</div>
</div>
</div>
<div class="row">
<div class="sixteen wide column">
<ProjectParameters
:project="project"
/>
</div>
</div>
</div>
</div>
<span v-else-if="!projectInfoLoading">
<i
class="icon exclamation triangle"
aria-hidden="true"
/>
<span>Vous ne disposez pas des droits nécessaires pour consulter ce
projet.</span>
</span>
<ProjectModal
:is-subscriber="is_suscriber"
:feature-type-to-delete="featureTypeToDelete"
@action="handleModalAction"
/>
</div>
</template>
<script>
import mapService from '@/services/map-service';
import { mapGetters, mapState, mapActions, mapMutations } from 'vuex';
import projectAPI from '@/services/project-api';
import featureTypeAPI from '@/services/featureType-api';
import featureAPI from '@/services/feature-api';
import ProjectHeader from '@/components/Project/Detail/ProjectHeader';
import ProjectFeatureTypes from '@/components/Project/Detail/ProjectFeatureTypes';
import ProjectLastFeatures from '@/components/Project/Detail/ProjectLastFeatures';
import ProjectLastComments from '@/components/Project/Detail/ProjectLastComments';
import ProjectParameters from '@/components/Project/Detail/ProjectParameters';
import ProjectModal from '@/components/Project/Detail/ProjectModal';
import SidebarLayers from '@/components/Map/SidebarLayers';
import Geolocation from '@/components/Map/Geolocation';
export default {
name: 'ProjectDetail',
components: {
ProjectHeader,
ProjectFeatureTypes,
ProjectLastFeatures,
ProjectLastComments,
ProjectParameters,
ProjectModal,
SidebarLayers,
Geolocation,
},
filters: {
setDate(value) {
const date = new Date(value);
const d = date.toLocaleDateString('fr', {
year: '2-digit',
month: 'numeric',
day: 'numeric',
});
return d;
},
},
props: {
message: {
type: Object,
default: () => {}
}
},
data() {
return {
infoMessage: '',
importMessage: null,
arraysOffline: [],
arraysOfflineErrors: [],
slug: this.$route.params.slug,
is_suscriber: false,
tempMessage: null,
projectInfoLoading: true,
featureTypesLoading: false,
featureTypeToDelete: null,
mapLoading: true,
};
},
computed: {
...mapGetters([
'permissions'
]),
...mapState('projects', [
'project'
]),
...mapState([
'configuration',
]),
...mapState('feature', [
'features'
]),
...mapState('feature-type', [
'feature_types'
]),
...mapState([
'user',
'user_permissions',
'reloadIntervalId',
]),
...mapState('map', [
'map',
'basemaps',
'availableLayers',
]),
API_BASE_URL() {
return this.configuration.VUE_APP_DJANGO_API_BASE;
},
},
created() {
if (this.user) {
projectAPI
.getProjectSubscription({
baseUrl: this.$store.state.configuration.VUE_APP_DJANGO_API_BASE,
projectSlug: this.$route.params.slug
})
.then((data) => (this.is_suscriber = data.is_suscriber));
}
},
mounted() {
this.retrieveProjectInfo();
if (this.message) {
this.DISPLAY_MESSAGE(this.message);
}
},
beforeDestroy() {
this.$store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
this.CLEAR_RELOAD_INTERVAL_ID();
this.CLOSE_PROJECT_MODAL();
},
methods: {
...mapMutations([
'CLEAR_RELOAD_INTERVAL_ID',
'DISPLAY_MESSAGE',
]),
...mapMutations('modals', [
'OPEN_PROJECT_MODAL',
'CLOSE_PROJECT_MODAL'
]),
...mapActions('projects', [
'GET_PROJECT_INFO',
'GET_PROJECT',
]),
...mapActions('map', [
'INITIATE_MAP'
]),
...mapActions('feature', [
'GET_PROJECT_FEATURES'
]),
...mapActions('feature-type', [
'GET_PROJECT_FEATURE_TYPES',
]),
retrieveProjectInfo() {
Promise.all([
this.GET_PROJECT(this.slug),
this.GET_PROJECT_INFO(this.slug)
])
.then(() => {
this.$nextTick(() => {
const map = mapService.getMap();
if (map) mapService.destroyMap();
this.initMap();
});
})
.catch((err) => {
console.error(err);
})
.finally(() => {
this.projectInfoLoading = false;
});
},
checkForOfflineFeature() {
let arraysOffline = [];
const localStorageArray = localStorage.getItem('geocontrib_offline');
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
this.arraysOffline = arraysOffline.filter(
(x) => x.project === this.slug
);
}
},
updateLocalStorage() {
let arraysOffline = [];
const localStorageArray = localStorage.getItem('geocontrib_offline');
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
}
const arraysOfflineOtherProject = arraysOffline.filter(
(x) => x.project !== this.slug
);
this.arraysOffline = [];
arraysOffline = arraysOfflineOtherProject.concat(
this.arraysOfflineErrors
);
localStorage.setItem('geocontrib_offline', JSON.stringify(arraysOffline));
},
subscribeProject() {
projectAPI
.subscribeProject({
baseUrl: this.$store.state.configuration.VUE_APP_DJANGO_API_BASE,
suscribe: !this.is_suscriber,
projectSlug: this.$route.params.slug,
})
.then((data) => {
this.is_suscriber = data.is_suscriber;
this.CLOSE_PROJECT_MODAL();
if (this.is_suscriber) {
this.DISPLAY_MESSAGE({
comment: 'Vous êtes maintenant abonné aux notifications de ce projet.', level: 'positive'
});
} else {
this.DISPLAY_MESSAGE({
comment: 'Vous ne recevrez plus les notifications de ce projet.', level: 'negative'
});
}
});
},
deleteProject() {
projectAPI.deleteProject(this.API_BASE_URL, this.slug)
.then((response) => {
if (response === 'success') {
this.$router.push('/');
this.DISPLAY_MESSAGE({
comment: `Le projet ${this.project.title} a bien été supprimé.`, level: 'positive'
});
} else {
this.DISPLAY_MESSAGE({
comment: `Une erreur est survenu lors de la suppression du projet ${this.project.title}.`,
level: 'negative'
});
}
});
},
deleteFeatureType() {
featureTypeAPI.deleteFeatureType(this.featureTypeToDelete.slug)
.then((response) => {
this.CLOSE_PROJECT_MODAL();
if (response === 'success') {
this.GET_PROJECT(this.slug);
this.retrieveProjectInfo();
this.DISPLAY_MESSAGE({
comment: `Le type de signalement ${this.featureTypeToDelete.title} a bien été supprimé.`,
level: 'positive',
});
} else {
this.DISPLAY_MESSAGE({
comment: `Une erreur est survenu lors de la suppression du type de signalement ${this.featureTypeToDelete.title}.`,
level: 'negative',
});
}
this.featureTypeToDelete = null;
})
.catch(() => {
this.DISPLAY_MESSAGE({
comment: `Une erreur est survenu lors de la suppression du type de signalement ${this.featureTypeToDelete.title}.`,
level: 'negative',
});
this.CLOSE_PROJECT_MODAL();
});
},
handleModalAction(e) {
switch (e) {
case 'subscribe':
this.subscribeProject();
break;
case 'deleteProject':
this.deleteProject();
break;
case 'deleteFeatureType':
this.deleteFeatureType();
break;
}
},
toggleDeleteFeatureTypeModal(featureType) {
this.featureTypeToDelete = featureType;
this.OPEN_PROJECT_MODAL('deleteFeatureType');
},
/**
* Initializes the map if the project is accessible and the user has view permissions.
* This method sets up the map, loads vector tile layers, and handles offline features.
*/
async initMap() {
// Check if the project is accessible and the user has view permissions
if (this.project && this.permissions.can_view_project) {
// Initialize the map using the provided element reference
await this.INITIATE_MAP({ el: this.$refs.map });
// Check for offline features
this.checkForOfflineFeature();
// Define the URL for vector tile layers
const mvtUrl = `${this.API_BASE_URL}features.mvt`;
// Define parameters for loading layers
const params = {
project_slug: this.slug,
featureTypes: this.feature_types,
queryParams: {
ordering: this.project.feature_browsing_default_sort,
filter: this.project.feature_browsing_default_filter,
},
};
// Add vector tile layers to the map
mapService.addVectorTileLayer({
url: mvtUrl,
...params
});
// Modify offline feature properties (setting color to 'red')
this.arraysOffline.forEach((x) => (x.geojson.properties.color = 'red'));
// Extract offline features from arraysOffline
const featuresOffline = this.arraysOffline.map((x) => x.geojson);
// Add offline features to the map if available
if (featuresOffline && featuresOffline.length > 0) {
mapService.addFeatures({
addToMap: true,
features: featuresOffline,
...params
});
}
// Get the bounding box of features and fit the map to it
featureAPI.getFeaturesBbox(this.slug).then((bbox) => {
if (bbox) {
mapService.fitBounds(bbox);
}
this.mapLoading = false; // Mark map loading as complete
});
}
},
updateAfterImport() {
// reload feature types
this.featureTypesLoading = true;
this.GET_PROJECT_FEATURE_TYPES(this.slug)
.then(() => {
this.featureTypesLoading = false;
});
// reload last features
this.$refs.lastFeatures.fetchLastFeatures();
// reload map
const map = mapService.getMap();
if (map) mapService.destroyMap();
this.mapLoading = true;
this.initMap();
},
},
};
</script>
<style lang="less" scoped>
.fullwidth {
width: 100%;
}
.block-map {
display: flex !important;
flex-direction: column;
.map-container {
position: relative;
height: 100%;
#map {
border: 1px solid grey;
}
}
.button {
margin-top: 0.5em;
}
}
div.geolocation-container {
/* each button have .5em space between, zoom buttons are 60px high and full screen button is 34px high */
top: calc(1.1em + 60px);
}
</style>
<template>
<div class="fourteen wide column">
<div id="project-edit">
<div
:class="{ active: loading }"
class="ui inverted dimmer"
......@@ -46,9 +46,10 @@
</li>
</ul>
</div>
<div class="field">
<div class="field file-logo">
<label>Illustration du projet</label>
<img
v-if="thumbnailFileSrc.length || form.thumbnail.length"
id="form-input-file-logo"
class="ui small image"
:src="
......@@ -56,12 +57,16 @@
? thumbnailFileSrc
: DJANGO_BASE_URL + form.thumbnail
"
alt="Thumbnail du projet"
>
<label
class="ui icon button"
for="thumbnail"
>
<i class="file icon" />
<i
class="file icon"
aria-hidden="true"
/>
<span class="label">{{
form.thumbnail_name ? form.thumbnail_name : fileToImport.name
}}</span>
......@@ -86,67 +91,37 @@
</ul>
</div>
</div>
<div class="field">
<label for="description">Description</label>
<textarea
v-model="form.description"
name="description"
rows="5"
/>
<!-- {{ form.description.errors }} -->
<div class="two fields">
<div class="field">
<label for="description">Description</label>
<textarea
id="editor"
v-model="form.description"
data-preview="#preview"
name="description"
rows="5"
/>
<!-- {{ form.description.errors }} -->
</div>
<div class="field">
<label for="preview">Aperçu</label>
<div
id="preview"
class="description preview"
name="preview"
/>
</div>
</div>
<div class="ui horizontal divider">
PARAMÈTRES
</div>
<div class="four fields">
<div class="field">
<label for="archive_feature">Délai avant archivage</label>
<div class="ui right labeled input">
<input
id="archive_feature"
v-model="form.archive_feature"
type="number"
min="0"
oninput="validity.valid||(value=0);"
style="padding: 1px 2px"
name="archive_feature"
@blur="checkEmpty"
>
<div class="ui label">
jour(s)
</div>
</div>
<ul
v-if="errors_archive_feature.length"
id="errorlist-achivage"
class="errorlist"
>
<li>
{{ errors_archive_feature[0] }}
</li>
</ul>
</div>
<div class="field">
<label for="delete_feature">Délai avant suppression</label>
<div class="ui right labeled input">
<input
id="delete_feature"
v-model="form.delete_feature"
type="number"
min="0"
oninput="validity.valid||(value=0);"
style="padding: 1px 2px"
name="delete_feature"
@blur="checkEmpty"
>
<div class="ui label">
jour(s)
</div>
</div>
</div>
<div class="required field">
<div class="two fields">
<div
id="published-visibility"
class="required field"
>
<label
for="access_level_pub_feature"
>Visibilité des signalements publiés</label>
......@@ -167,12 +142,15 @@
</li>
</ul>
</div>
<div class="required field">
<div
id="archived-visibility"
class="required field"
>
<label for="access_level_arch_feature">
Visibilité des signalements archivés
</label>
<Dropdown
:options="levelPermissions"
:options="levelPermissionsArc"
:selected="form.access_level_arch_feature.name"
:selection.sync="form.access_level_arch_feature"
/>
......@@ -190,53 +168,179 @@
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="moderation"
v-model="form.moderation"
class="hidden"
type="checkbox"
name="moderation"
>
<label for="moderation">Modération</label>
<div class="two fields">
<div class="fields grouped checkboxes">
<div class="field">
<div class="ui checkbox">
<input
id="moderation"
v-model="form.moderation"
class="hidden"
type="checkbox"
name="moderation"
>
<label for="moderation">Modération</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="is_project_type"
v-model="form.is_project_type"
class="hidden"
type="checkbox"
name="is_project_type"
>
<label for="is_project_type">Est un projet type</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="generate_share_link"
v-model="form.generate_share_link"
class="hidden"
type="checkbox"
name="generate_share_link"
>
<label for="generate_share_link">Génération d'un lien de partage externe</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="fast_edition_mode"
v-model="form.fast_edition_mode"
class="hidden"
type="checkbox"
name="fast_edition_mode"
>
<label for="fast_edition_mode">Mode d'édition rapide de signalements</label>
<div
class="
ui
small
button
circular
compact
absolute-right
icon
teal
"
data-tooltip="Consulter la documentation"
data-position="right center"
data-variation="mini"
@click="goToDocumentationFeature"
>
<i class="question icon" />
</div>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="feature_assignement"
v-model="form.feature_assignement"
class="hidden"
type="checkbox"
name="feature_assignement"
>
<label for="feature_assignement">Activation de l'assignation de signalements aux membres du projet</label>
</div>
</div>
<div class="fields grouped">
<div class="field">
<label for="feature_browsing">Configuration du parcours de signalement</label>
</div>
<div
id="feature_browsing_filter"
class="field inline"
>
<label for="feature_browsing_default_filter">Filtrer sur</label>
<Dropdown
:options="featureBrowsingOptions.filter"
:selected="form.feature_browsing_default_filter.name"
:selection.sync="form.feature_browsing_default_filter"
/>
</div>
<div
id="feature_browsing_sort"
class="field inline"
>
<label for="feature_browsing_default_sort">Trier par</label>
<Dropdown
:options="featureBrowsingOptions.sort"
:selected="form.feature_browsing_default_sort.name"
:selection.sync="form.feature_browsing_default_sort"
/>
</div>
</div>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="is_project_type"
v-model="form.is_project_type"
class="hidden"
type="checkbox"
name="is_project_type"
>
<label for="is_project_type">Est un projet type</label>
<div class="field">
<label>Niveau de zoom maximum de la carte</label>
<div class="map-maxzoom-selector">
<div class="range-container">
<input
v-model="form.map_max_zoom_level"
type="range"
min="0"
max="22"
step="1"
@input="zoomMap"
><output class="range-output-bubble">{{
scalesTable[form.map_max_zoom_level]
}}</output>
</div>
<div class="map-preview">
<label>Aperçu :</label>
<div
id="map"
ref="map"
/>
<div class="no-preview">
pas de fond&nbsp;de&nbsp;carte disponible à&nbsp;cette&nbsp;échelle
</div>
</div>
</div>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="generate_share_link"
v-model="form.generate_share_link"
class="hidden"
type="checkbox"
name="generate_share_link"
>
<label for="generate_share_link">Génération d'un lien de partage externe</label>
</div>
<div
v-if="filteredAttributes.length > 0"
class="ui horizontal divider"
>
ATTRIBUTS
</div>
<div class="fields grouped">
<ProjectAttributeForm
v-for="(attribute, index) in filteredAttributes"
:key="index"
:attribute="attribute"
:form-project-attributes="form.project_attributes"
@update:project_attributes="updateProjectAttributes($event)"
/>
</div>
<div class="ui divider" />
<button
id="send-project"
type="button"
class="ui teal icon button"
@click="postForm"
>
<i class="white save icon" /> Enregistrer les changements
<i
class="white save icon"
aria-hidden="true"
/> Enregistrer les changements
</button>
</form>
</div>
......@@ -244,15 +348,20 @@
<script>
import axios from '@/axios-client.js';
import Dropdown from '@/components/Dropdown.vue';
import Dropdown from '@/components/Dropdown';
import ProjectAttributeForm from '@/components/Project/Edition/ProjectAttributeForm';
import mapService from '@/services/map-service';
import { mapState, mapGetters, mapActions } from 'vuex';
import TextareaMarkdown from 'textarea-markdown';
import { mapActions, mapState } from 'vuex';
export default {
name: 'ProjectEdit',
components: {
Dropdown,
ProjectAttributeForm
},
data() {
......@@ -263,13 +372,30 @@ export default {
name: 'Sélectionner une image ...',
size: 0,
},
errors_archive_feature: [],
errors: {
title: [],
access_level_pub_feature: [],
access_level_arch_feature: [],
},
errorThumbnail: [],
featureBrowsingOptions: {
filter: [{
name: 'Désactivé',
value: ''
},
{
name: 'Type de signalement',
value: 'feature_type_slug',
}],
sort: [{
name: 'Date de création',
value: '-created_on',
},
{
name: 'Date de modification',
value: '-updated_on'
}],
},
form: {
title: '',
slug: '',
......@@ -282,8 +408,7 @@ export default {
creator: null,
access_level_pub_feature: { name: '', value: '' },
access_level_arch_feature: { name: '', value: '' },
archive_feature: 0,
delete_feature: 0,
map_max_zoom_level: 22,
nb_features: 0,
nb_published_features: 0,
nb_comments: 0,
......@@ -291,32 +416,62 @@ export default {
nb_contributors: 0,
is_project_type: false,
generate_share_link: false,
feature_assignement: false,
fast_edition_mode: false,
feature_browsing_default_filter: '',
feature_browsing_default_sort: '-created_on',
project_attributes: [],
},
thumbnailFileSrc: '',
scalesTable: [
'1:500 000 000',
'1:250 000 000',
'1:150 000 000',
'1:70 000 000',
'1:35 000 000',
'1:15 000 000',
'1:10 000 000',
'1:4 000 000',
'1:2 000 000',
'1:1 000 000',
'1:500 000',
'1:250 000',
'1:150 000',
'1:70 000',
'1:35 000',
'1:15 000',
'1:8 000',
'1:4 000',
'1:2 000',
'1:1 000',
'1:500',
'1:250',
'1:150',
]
};
},
computed: {
...mapState([
'levelsPermissions',
'projectAttributes'
]),
...mapGetters('projects', [
'project'
]),
...mapState('projects', ['project']),
DJANGO_BASE_URL: function () {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
levelPermissions(){
let self = this;
let levels = new Array();
if(self.levelsPermissions){
self.levelsPermissions.map(function(item) {
if (item.user_type_id != 'super_contributor')
levelPermissionsArc(){
const levels = new Array();
if(this.levelsPermissions) {
this.levelsPermissions.forEach((item) => {
if (item.user_type_id !== 'super_contributor') {
levels.push({
name: self.traslateRoleToFrench(item.user_type_id),
name: this.translateRoleToFrench(item.user_type_id),
value: item.user_type_id,
});
if (!self.form.moderation && item.user_type_id == 'moderator'){
}
if (!this.form.moderation && item.user_type_id === 'moderator') {
levels.pop();
}
});
......@@ -324,43 +479,64 @@ export default {
return levels;
},
levelPermissionsPub(){
let self = this;
let levels = new Array();
if(self.levelsPermissions){
self.levelsPermissions.map(function(item) {
if (item.user_type_id != 'super_contributor'
&& item.user_type_id != 'admin'
&& item.user_type_id != 'moderator'){
const levels = new Array();
if (this.levelsPermissions) {
this.levelsPermissions.forEach((item) => {
if (
item.user_type_id !== 'super_contributor' &&
item.user_type_id !== 'admin' &&
item.user_type_id !== 'moderator'
) {
levels.push({
name: self.traslateRoleToFrench(item.user_type_id),
name: this.translateRoleToFrench(item.user_type_id),
value: item.user_type_id,
});
}
});
}
return levels;
}
},
/**
* Filter out attribute of field type list without option
*/
filteredAttributes() {
return this.projectAttributes.filter(attr => attr.field_type === 'boolean' || attr.options);
},
},
watch: {
'form.moderation': function (newValue){
if(!newValue){
this.form.access_level_pub_feature = { name: '', value: '' };
if(!newValue && this.form.access_level_arch_feature.value === 'moderator') {
this.form.access_level_arch_feature = { name: '', value: '' };
}
}
},
created() {
mounted() {
this.definePageType();
if (this.action === 'create') {
this.thumbnailFileSrc = require('@/assets/img/default.png');
this.initPreviewMap();
} else if (this.action === 'edit' || this.action === 'create_from') {
this.fillProjectForm();
if (!this.project) {
this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug)
.then((projet) => {
if (projet) {
this.fillProjectForm();
}
});
} else {
this.fillProjectForm();
}
}
let textarea = document.querySelector('textarea');
new TextareaMarkdown(textarea);
},
methods: {
...mapActions('projects', [
'GET_ALL_PROJECTS'
...mapActions('map', [
'INITIATE_MAP'
]),
definePageType() {
if (this.$router.history.current.name === 'project_create') {
......@@ -372,16 +548,23 @@ export default {
}
},
traslateRoleToFrench(role){
if (role == 'admin') return 'Administrateur projet';
if (role == 'moderator') return 'Modérateur';
if (role == 'contributor') return 'Contributeur';
if (role == 'logged_user') return 'Utilisateur connecté';
if (role == 'anonymous') return 'Utilisateur anonyme';
translateRoleToFrench(role){
switch (role) {
case 'admin':
return 'Administrateur projet';
case 'moderator':
return 'Modérateur';
case 'contributor':
return 'Contributeur';
case 'logged_user':
return 'Utilisateur connecté';
case 'anonymous':
return 'Utilisateur anonyme';
}
},
truncate(n, len) {
let ext = n.substring(n.lastIndexOf('.') + 1, n.length).toLowerCase();
const ext = n.substring(n.lastIndexOf('.') + 1, n.length).toLowerCase();
let filename = n.replace('.' + ext, '');
if (filename.length <= len) {
return n;
......@@ -391,8 +574,8 @@ export default {
},
validateImgFile(files, handleFile) {
let url = window.URL || window.webkitURL;
let image = new Image();
const url = window.URL || window.webkitURL;
const image = new Image();
image.onload = function () {
handleFile(true);
URL.revokeObjectURL(image.src);
......@@ -412,7 +595,7 @@ export default {
function handleFile(isValid) {
if (isValid) {
_this.fileToImport = files[0]; //* store the file to post later
let reader = new FileReader(); //* read the file to display in the page
const reader = new FileReader(); //* read the file to display in the page
reader.onload = function (e) {
_this.thumbnailFileSrc = e.target.result;
};
......@@ -431,17 +614,11 @@ export default {
}
},
checkEmpty() {
//* forbid empty fields
if (!this.form.archive_feature) this.form.archive_feature = 0;
if (!this.form.delete_feature) this.form.delete_feature = 0;
},
goBackNrefresh(slug) {
Promise.all([
this.$store.dispatch('GET_USER_LEVEL_PROJECTS'), //* refresh projects user levels
this.$store.dispatch('GET_USER_LEVEL_PERMISSIONS'), //* refresh projects permissions
this.GET_ALL_PROJECTS(), //* & refresh project list
this.$store.dispatch('projects/GET_PROJECT', slug), //* refresh current project
]).then(() =>
// * go back to project list
this.$router.push({
......@@ -454,7 +631,7 @@ export default {
postProjectThumbnail(projectSlug) {
//* send img to the backend when feature_type is created
if (this.fileToImport) {
let formData = new FormData();
const formData = new FormData();
formData.append('file', this.fileToImport);
const url =
this.$store.state.configuration.VUE_APP_DJANGO_API_BASE +
......@@ -475,7 +652,9 @@ export default {
.catch((error) => {
let err_msg =
"Transférez une image valide. Le fichier que vous avez transféré n'est pas une image, ou il est corrompu.";
if (error.response.data[0]) err_msg = error.response.data[0];
if (error.response.data[0]) {
err_msg = error.response.data[0];
}
this.errorThumbnail.push(err_msg);
throw error;
});
......@@ -483,12 +662,6 @@ export default {
},
checkForm() {
if (this.form.archive_feature > this.form.delete_feature) {
this.errors_archive_feature.push(
"Le délais de suppression doit être supérieur au délais d'archivage."
);
return false;
}
for (const key in this.errors) {
if ((key === 'title' && this.form[key]) || this.form[key].value) {
this.errors[key] = [];
......@@ -508,23 +681,20 @@ export default {
},
async postForm() {
if (!this.checkForm()) return;
if (!this.checkForm()) {
return;
}
const projectData = {
title: this.form.title,
description: this.form.description,
...this.form,
access_level_arch_feature: this.form.access_level_arch_feature.value,
access_level_pub_feature: this.form.access_level_pub_feature.value,
archive_feature: this.form.archive_feature,
delete_feature: this.form.delete_feature,
is_project_type: this.form.is_project_type,
generate_share_link: this.form.generate_share_link,
moderation: this.form.moderation,
feature_browsing_default_sort: this.form.feature_browsing_default_sort.value,
feature_browsing_default_filter: this.form.feature_browsing_default_filter.value,
};
let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/`;
if (this.action === 'edit') {
await axios
.put((url += `${this.project.slug}/`), projectData)
.put((`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/projects/${this.project.slug}/`), projectData)
.then((response) => {
if (response && response.status === 200) {
//* send thumbnail after feature_type was updated
......@@ -542,8 +712,9 @@ export default {
throw error;
});
} else {
let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/projects/`;
if (this.action === 'create_from') {
url += `${this.project.slug}/duplicate/`;
url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.project.slug}/duplicate/`;
}
this.loading = true;
await axios
......@@ -568,11 +739,11 @@ export default {
});
}
},
fillProjectForm() {
if (!this.project) {
this.$store.dispatch('GET_PROJECT_INFO', this.$route.params.slug);
}
this.form = { ...this.project }; //* create a new object to avoid modifying original one
//* create a new object to avoid modifying original one
this.form = { ...this.project };
//* if duplication of project, generate new name
if (this.action === 'create_from') {
this.form.title =
this.project.title +
......@@ -582,43 +753,215 @@ export default {
.replace(',', '')})`;
this.form.is_project_type = false;
}
//* transform string values to objects for dropdowns display (could be in a computed)
if(this.levelPermissionsPub){
let value = {};
value = this.levelPermissionsPub.find(
(el) => el.name === this.project.access_level_pub_feature
//* transform string values to objects used with dropdowns
// fill dropdown current selection for archived feature viewing permission
if (this.levelPermissionsArc) {
const accessLevelArc = this.levelPermissionsArc.find(
(el) => el.name === this.project.access_level_arch_feature
);
if(value){
this.form.access_level_pub_feature = {
name: this.project.access_level_pub_feature,
value: value.value ,
if (accessLevelArc) {
this.form.access_level_arch_feature = {
name: this.project.access_level_arch_feature,
value: accessLevelArc.value ,
};
}
}
if(this.levelPermissions){
let value = {};
value = this.levelPermissions.find(
(el) => el.name === this.project.access_level_arch_feature
// fill dropdown current selection for published feature viewing permission
if (this.levelPermissionsPub) {
const accessLevelPub = this.levelPermissionsPub.find(
(el) => el.name === this.project.access_level_pub_feature
);
if(value){
this.form.access_level_arch_feature = {
name: this.project.access_level_arch_feature,
value: value.value ,
if (accessLevelPub) {
this.form.access_level_pub_feature = {
name: this.project.access_level_pub_feature,
value: accessLevelPub.value ,
};
}
}
// fill dropdown current selection for feature browsing default filtering
const default_filter = this.featureBrowsingOptions.filter.find(
(el) => el.value === this.project.feature_browsing_default_filter
);
if (default_filter) {
this.form.feature_browsing_default_filter = default_filter;
}
// fill dropdown current selection for feature browsing default sorting
const default_sort = this.featureBrowsingOptions.sort.find(
(el) => el.value === this.project.feature_browsing_default_sort
);
if (default_sort) {
this.form.feature_browsing_default_sort = default_sort;
}
this.initPreviewMap();
},
initPreviewMap () {
const map = mapService.getMap();
if (map) mapService.destroyMap();
//On récupère le zoom maximum autorisé par la couche
const maxZoomLayer = this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS.maxZoom;
let activeZoom = maxZoomLayer;
if(this.project && this.project.map_max_zoom_level < maxZoomLayer){
activeZoom = this.project.map_max_zoom_level;
}
this.INITIATE_MAP({
el: this.$refs.map,
zoom: activeZoom,
center: this.$store.state.configuration.MAP_PREVIEW_CENTER,
maxZoom: 22,
controls: [],
zoomControl: false,
//On désactive le zoom et le pan => gérer par le composant zoom max
interactions: { dragPan: false, mouseWheelZoom: false }
});
// add default basemap (in other maps the component SidebarLayer handles layers)
mapService.addLayers(
null,
this.$store.state.configuration.DEFAULT_BASE_MAP_SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS,
this.$store.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE
);
//La tuile au dessus du zoom maximum n'existe pas
//On attend un peu qu'elle se charge et on zoom si besoin
setTimeout(() => {
mapService.zoom(this.project ? this.project.map_max_zoom_level : 22);
}, 500);
},
zoomMap() {
mapService.zoom(this.form.map_max_zoom_level);
},
/**
* Updates the value of a project attribute or adds a new attribute if it does not exist.
*
* This function looks for an attribute by its ID. If the attribute exists, its value is updated.
* If the attribute does not exist, a new attribute object is added to the `project_attributes` array.
*
* @param {String} value - The new value to be assigned to the project attribute.
* @param {Number} attributeId - The ID of the attribute to be updated or added.
*/
updateProjectAttributes({ value, attributeId }) {
// Find the index of the attribute in the project_attributes array.
const attributeIndex = this.form.project_attributes.findIndex(el => el.attribute_id === attributeId);
if (attributeIndex !== -1) {
// Directly update the attribute's value if it exists.
this.form.project_attributes[attributeIndex].value = value;
} else {
// Add a new attribute object if it does not exist.
this.form.project_attributes.push({ attribute_id: attributeId, value });
}
},
goToDocumentationFeature() {
window.open(this.$store.state.configuration.VUE_APP_URL_DOCUMENTATION_FEATURE);
}
},
};
</script>
<style media="screen">
<style media="screen" lang="less">
#form-input-file-logo {
margin-left: auto;
margin-right: auto;
}
.file-logo {
min-height: calc(150px + 2.4285em);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.close.icon:hover {
cursor: pointer;
}
</style>
\ No newline at end of file
textarea {
height: 10em;
}
.description.preview {
height: 10em;
overflow: scroll;
border: 1px solid rgba(34, 36, 38, .15);
padding: .78571429em 1em;
}
.checkboxes {
padding-left: .5em;
.absolute-right.ui.compact.icon.button {
position: absolute;
right: -2.75em;
top: calc(50% - 1em);
padding: .4em;
}
}
.map-maxzoom-selector {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
input, output {
height: fit-content;
}
output {
white-space: nowrap;
min-width: auto;
}
.range-container {
margin-bottom: 2rem;
}
.map-preview {
margin-top: -1rem;
display: flex;
position: relative;
label {
white-space: nowrap;
font-size: .95em;
margin-right: 1rem;
}
#map {
min-height: 80px;
height: 80px;
width: 150px;
max-width: 150px;
z-index: 1;
}
.no-preview {
position: absolute;
top: 25%;
left: 25%;
text-align: center;
font-size: .75em;
color: #656565;
}
}
}
label[for=feature_browsing] {
padding-left: 2em;
}
label[for=feature_browsing_default_filter],
label[for=feature_browsing_default_sort] {
min-width: 4em;
}
#feature_browsing_filter,
#feature_browsing_sort {
margin-left: 2.5rem;
}
@media only screen and (min-width: 1100px) {
#feature_browsing_filter {
margin-top: -2.25em;
}
#feature_browsing_filter,
#feature_browsing_sort {
float: right;
}
}
</style>
<template>
<div class="fourteen wide column">
<div id="project-members">
<h1 class="ui header">
Gérer les membres
</h1>
......@@ -43,10 +43,13 @@
<button
type="button"
class="ui green icon button"
:disabled="newMember.user"
:disabled="!newMember.user.name"
@click="addMember"
>
<i class="white add icon" />
<i
class="white add icon"
aria-hidden="true"
/>
<span class="padding-1">Ajouter</span>
</button>
</div>
......@@ -56,10 +59,13 @@
id="form-members"
class="ui form"
>
<table class="ui red table">
<table
class="ui red table"
aria-describedby="Table des membres du projet"
>
<thead>
<tr>
<th>
<th scope="col">
Membre
<i
:class="{
......@@ -67,10 +73,11 @@
up: isSortedDesc('member'),
}"
class="icon sort"
aria-hidden="true"
@click="changeSort('member')"
/>
</th>
<th>
<th scope="col">
Niveau d'autorisation
<i
:class="{
......@@ -78,6 +85,7 @@
up: isSortedDesc('role'),
}"
class="icon sort"
aria-hidden="true"
@click="changeSort('role')"
/>
</th>
......@@ -89,7 +97,7 @@
:key="member.username"
>
<td>
{{ member.user.last_name }} {{ member.user.first_name }}<br><i>{{ member.user.username }}</i>
{{ member.user.last_name }} {{ member.user.first_name }}<br><em>{{ member.user.username }}</em>
</td>
<td>
<div class="required field online">
......@@ -101,10 +109,14 @@
/>
<button
type="button"
class="ui icon button"
class="ui icon button button-hover-red"
data-tooltip="Retirer ce membre"
@click="removeMember(member)"
>
<i class="times icon" />
<i
class="times icon"
aria-hidden="true"
/>
</button>
</div>
</td>
......@@ -119,7 +131,10 @@
class="ui teal icon button"
@click="saveMembers"
>
<i class="white save icon" />&nbsp;Enregistrer les changements
<i
class="white save icon"
aria-hidden="true"
/>&nbsp;Enregistrer les changements
</button>
</div>
</div>
......@@ -127,16 +142,15 @@
<script>
import axios from '@/axios-client.js';
import frag from 'vue-frag';
import { mapGetters } from 'vuex';
import { mapMutations, mapState } from 'vuex';
import Dropdown from '@/components/Dropdown.vue';
import { formatUserOption } from '@/utils';
export default {
name: 'ProjectMembers',
directives: {
frag,
},
components: {
Dropdown,
},
......@@ -170,29 +184,18 @@ export default {
},
computed: {
...mapGetters('projects', [
'project'
]),
...mapState('projects', ['project']),
userOptions: function () {
return this.projectUsers
.filter((el) => el.userLevel.value === 'logged_user')
.map((el) => {
let name = el.user.first_name || '';
if (el.user.last_name) {
name = name + ' ' + el.user.last_name;
}
return {
name: [name, el.user.username],
value: el.user.id,
};
});
.map((el) => formatUserOption(el.user)); // Format user data to fit dropdown option structure
},
levelOptions: function () {
return this.options.filter(
(el) =>
(this.project.moderation ? el : el.value !== 'moderator') &&
(this.project && this.project.moderation ? el : el.value !== 'moderator') &&
el.value !== 'logged_user'
);
},
......@@ -228,17 +231,24 @@ export default {
created() {
if (!this.project) {
this.$store.dispatch('GET_PROJECT_INFO', this.$route.params.slug);
this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug);
this.$store.dispatch('projects/GET_PROJECT_INFO', this.$route.params.slug);
}
this.populateMembers();
},
destroyed() {
//* allow user to change page if ever stuck on loader
this.$store.commit('DISCARD_LOADER');
this.DISCARD_LOADER();
},
methods: {
...mapMutations([
'DISPLAY_MESSAGE',
'DISPLAY_LOADER',
'DISCARD_LOADER'
]),
validateNewMember() {
this.newMember.errors = [];
if (!this.newMember.user.value) {
......@@ -288,60 +298,69 @@ export default {
});
},
/**
* Saves the updated members and their roles for a project.
* Displays a loader while the update is in progress and provides feedback upon completion or error.
*/
saveMembers() {
// Display a loader to indicate that the update process is ongoing
this.DISPLAY_LOADER('Mise à jour des membres du projet en cours ...');
// Prepare the data to be sent in the API request
const data = this.projectUsers.map((member) => {
return {
user: member.user,
level: {
display: member.userLevel.name,
codename: member.userLevel.value,
display: member.userLevel.name, // Display name of the user level
codename: member.userLevel.value, // Codename of the user level
},
};
});
// Make an API request to update the project members
axios
.put(
`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.project.slug}/utilisateurs/`,
data
)
.then((response) => {
// Check if the response status is 200 (OK)
if (response.status === 200) {
this.$store.dispatch('GET_USER_LEVEL_PROJECTS'); //* update user status in top right menu
this.$store.commit('DISPLAY_MESSAGE', { comment: 'Permissions mises à jour', level: 'positive' });
// Dispatch an action to update the user status in the top right menu
this.$store.dispatch('GET_USER_LEVEL_PROJECTS');
// Display a positive message indicating success
this.DISPLAY_MESSAGE({ comment: 'Permissions mises à jour avec succès', level: 'positive' });
} else {
this.$store.commit(
'DISPLAY_MESSAGE',
{
comment : "Une erreur s'est produite pendant la mises à jour des permissions",
level: 'negative'
}
);
// Display a generic error message if the response status is not 200
this.DISPLAY_MESSAGE({
comment: "Une erreur s'est produite pendant la mises à jour des permissions",
level: 'negative'
});
}
// Hide the loader regardless of the request result
this.DISCARD_LOADER();
})
.catch((error) => {
throw error;
});
},
fetchMembers() {
// todo: move function to a service
return axios
.get(
`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/utilisateurs/`
)
.then((response) => response.data)
.catch((error) => {
throw error;
// Hide the loader if an error occurs
this.DISCARD_LOADER();
// Determine the error message to display
const errorMessage = error.response && error.response.data && error.response.data.error
? error.response.data.error
: "Une erreur s'est produite pendant la mises à jour des permissions";
// Display the error message
this.DISPLAY_MESSAGE({
comment: errorMessage,
level: 'negative'
});
// Log the error to the console for debugging
console.error(error);
});
},
populateMembers() {
this.$store.commit(
'DISPLAY_LOADER',
'Récupération des membres en cours...'
);
this.fetchMembers().then((members) => {
this.$store.commit('DISCARD_LOADER');
this.DISPLAY_LOADER('Récupération des membres en cours...');
this.$store.dispatch('projects/GET_PROJECT_USERS', this.$route.params.slug).then((members) => {
this.DISCARD_LOADER();
this.projectUsers = members.map((el) => {
return {
userLevel: { name: el.level.display, value: el.level.codename },
......
<template>
<div class="fourteen wide column">
<div id="projects">
<h2 class="ui horizontal divider header">
PROJETS
</h2>
<div class="flex">
<router-link
v-if="user && user.can_create_project && isOffline() != true"
v-if="user && user.can_create_project && isOnline"
:to="{ name: 'project_create', params: { action: 'create' } }"
class="ui green basic button"
data-test="create-project"
>
<i class="plus icon" /> Créer un nouveau projet
<i
class="plus icon"
aria-hidden="true"
/>
Créer un nouveau projet
</router-link>
<router-link
v-if="user && user.can_create_project && isOffline() != true"
v-if="user && user.can_create_project && isOnline"
:to="{
name: 'project_type_list',
}"
class="ui blue basic button"
data-test="to-project-models"
>
<i class="copy icon" /> Accéder à la liste des modèles de projets
<i
class="copy icon"
aria-hidden="true"
/>
Accéder à la liste des modèles de projets
</router-link>
</div>
<!-- FILTRES DES PROJETS -->
<projects-menu
<ProjectsMenu
:loading="loading"
@filter="setProjectsFilters"
@getData="getData"
@loading="setLoader"
/>
<div
v-if="configuration.DISPLAY_FORBIDDEN_PROJECTS"
id="forbidden-projects"
class="ui toggle checkbox"
class="ui toggle checkbox margin-top"
>
<input
v-model="displayForbiddenProjects"
:checked="displayForbiddenProjects"
type="checkbox"
@input="toggleForbiddenProjects"
>
<label>
N'afficher que les projets disponibles à la consultation
......@@ -46,107 +60,47 @@
<div
v-if="projects"
class="ui divided items dimmable dimmed"
data-test="project-list"
>
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div :class="['ui inverted dimmer', { active: loading }]">
<div class="ui loader" />
</div>
<div
<ProjectsListItem
v-for="project in projects"
:key="project.slug"
class="item"
>
<div class="ui tiny image">
<img
:src="
!project.thumbnail
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
>
</div>
<div class="middle aligned content">
<router-link
:to="{
name: 'project_detail',
params: { slug: project.slug },
}"
class="header"
>
{{ project.title }}
</router-link>
<div class="description">
<p>{{ project.description }}</p>
</div>
<div class="meta">
<span class="right floated">
<strong v-if="project.moderation">Projet modéré</strong>
<strong v-else>Projet non modéré</strong>
</span>
<span>Niveau d'autorisation requis :
{{ project.access_level_pub_feature }}</span><br>
<span v-if="user">
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">
<i class="calendar icon" />&nbsp; {{ project.created_on }}
</span>
<span data-tooltip="Membres">
{{ project.nb_contributors }}&nbsp;<i class="user icon" />
</span>
<span data-tooltip="Signalements publiés">
{{ project.nb_published_features }}&nbsp;<i
class="map marker icon"
/>
</span>
<span data-tooltip="Commentaires">
{{ project.nb_published_features_comments }}&nbsp;<i
class="comment icon"
/>
</span>
</div>
</div>
</div>
:project="project"
/>
<span
v-if="!projects || projects.length === 0"
>Vous n'avez accès à aucun projet.</span>
<div class="item" />
>
Vous n'avez accès à aucun projet.
</span>
<!-- PAGINATION -->
<Pagination
v-if="count"
:nb-pages="nbPages"
@page-update="changePage"
/>
</div>
<!-- PAGINATION -->
<pagination
v-if="count"
:nb-pages="Math.ceil(count/10)"
:on-page-change="SET_CURRENT_PAGE"
@change-page="changePage"
/>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions } from 'vuex';
import ProjectsMenu from '@/components/Projects/ProjectsMenu.vue';
import Pagination from '@/components/Pagination.vue';
import ProjectsMenu from '@/components/Projects/ProjectsMenu';
import ProjectsListItem from '@/components/Projects/ProjectsListItem';
import Pagination from '@/components/Pagination';
export default {
name: 'Projects',
name: 'ProjectsList',
components: {
ProjectsMenu,
ProjectsListItem,
Pagination
},
......@@ -161,84 +115,45 @@ export default {
...mapState([
'configuration',
'user',
'USER_LEVEL_PROJECTS'
'isOnline',
]),
...mapState('projects', [
'projects',
'count',
'filters'
'filters',
]),
APPLICATION_NAME() {
return this.$store.state.configuration.VUE_APP_APPLICATION_NAME;
},
APPLICATION_ABSTRACT() {
return this.$store.state.configuration.VUE_APP_APPLICATION_ABSTRACT;
},
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
logo() {
return this.$store.state.configuration.VUE_APP_LOGO_PATH;
},
},
watch: {
filters: {
deep: true,
handler(newValue) {
if (newValue) {
this.getData();
}
}
},
displayForbiddenProjects(newValue) {
if (newValue) {
this.SET_PROJECTS_FILTER({
filter: 'accessible',
value: 'true'
});
} else {
this.SET_PROJECTS_FILTER({
filter: 'accessible',
value: null
});
}
this.getData();
nbPages() {
return Math.ceil(this.count / 10);
}
},
created() {
this.SET_PROJECTS_FILTER({
filter: 'accessible',
value: 'true'
});
this.SET_CURRENT_PAGE(1);
// Empty stored text to search
this.SET_PROJECTS_SEARCH_STATE({ text: null });
// Empty stored project list
this.$store.commit('projects/SET_PROJECT', null);
// Init display of restricted access projects
this.displayForbiddenProjects = this.configuration.DISPLAY_FORBIDDEN_PROJECTS_DEFAULT;
if (this.$store.getters.project) {
this.$store.commit('SET_PROJECT_SLUG', null);
}
this.setForbiddenProjectsFilter(true);
},
methods: {
...mapMutations('projects', [
'SET_CURRENT_PAGE',
'SET_PROJECTS_FILTER'
'SET_PROJECTS_FILTER',
'SET_PROJECTS_SEARCH_STATE',
]),
...mapActions('projects', [
'GET_PROJECTS'
'GET_PROJECTS',
]),
isOffline() {
return navigator.onLine == false;
},
refreshId() {
//* change path of thumbnail to update image
return '?ver=' + Math.random();
},
getData(page) {
this.loading = true;
this.GET_PROJECTS(page)
this.GET_PROJECTS({ page })
.then(() => {
this.loading = false;
})
......@@ -247,12 +162,34 @@ export default {
});
},
setLoader(e) {
this.loading = e;
},
changePage(e) {
this.getData(e);
},
setProjectsFilters(e) {
setProjectsFilters(e, noUpdate) {
this.SET_PROJECTS_FILTER(e);
// Reset the page number at filter change
this.SET_CURRENT_PAGE(1);
// Wait that all filters are set in store to fetch data when component is created
if (!noUpdate) {
this.getData();
}
},
toggleForbiddenProjects(e) {
this.displayForbiddenProjects = e.target.checked;
this.setForbiddenProjectsFilter();
},
setForbiddenProjectsFilter(noUpdate) {
this.setProjectsFilters({
filter: 'accessible',
value: this.displayForbiddenProjects ? 'true' : null
}, noUpdate);
},
}
};
......@@ -260,6 +197,20 @@ export default {
<style lang="less" scoped>
#projects {
margin: 0 auto;
.dimmable {
.dimmer {
.loader {
top: 25%;
}
}
}
}
.flex {
display: flex;
justify-content: space-between;
......@@ -277,10 +228,10 @@ export default {
color: rgb(94, 94, 94);
}
input:checked ~ label::before {
background-color: teal !important;
background-color: var(--primary-color, #008c86) !important;
}
input:checked ~ label {
color: teal !important;
color: var(--primary-color, #008c86) !important;
}
}
......
<template>
<div id="projects-types">
<h3 class="ui header">
Créer un projet à partir d'un modèle disponible:
</h3>
<div class="ui divided items">
<div
v-for="project in project_types"
:key="project.slug"
class="item"
>
<div class="ui tiny image">
<img
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
alt="Image associé au projet"
>
</div>
<div class="middle aligned content">
<div class="description">
<router-link
:to="{
name: 'project_create_from',
params: {
slug: project.slug,
},
}"
>
{{ project.title }}
</router-link>
<p>{{ project.description }}</p>
<strong>Projet {{ project.moderation ? '' : 'non' }} modéré</strong>
</div>
<div class="meta">
<span data-tooltip="Date de création">
{{ project.created_on }}&nbsp;
<i
class="calendar icon"
aria-hidden="true"
/>
</span>
</div>
<div class="meta">
<span data-tooltip="Visibilité des signalement publiés">
{{ project.access_level_pub_feature }}&nbsp;<i
class="eye icon"
aria-hidden="true"
/>
</span>
<span data-tooltip="Visibilité des signalement archivés">
{{ project.access_level_arch_feature }}&nbsp;<i
class="archive icon"
aria-hidden="true"
/>
</span>
</div>
</div>
</div>
<span
v-if="!project_types || project_types.length === 0"
>Aucun projet type n'est défini.</span>
</div>
</div>
</template>
<script>
import projectAPI from '@/services/project-api';
export default {
name: 'ProjectTypeList',
data() {
return {
project_types: null,
};
},
computed: {
DJANGO_BASE_URL: function () {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
API_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_API_BASE;
},
},
mounted() {
projectAPI.getProjectTypes(this.API_BASE_URL)
.then((data) => {
if (data) {
this.project_types = data;
}
});
},
methods: {
refreshId() {
const crypto = window.crypto || window.msCrypto;
var array = new Uint32Array(1);
return '?ver=' + crypto.getRandomValues(array); // Compliant for security-sensitive use cases
},
},
};
</script>
<style lang="less" scoped>
#projects-types {
max-width: 800px !important;
}
</style>
\ No newline at end of file
<template>
<div v-frag>
<div
v-if="feature"
v-frag
>
<div class="row">
<div class="fourteen wide column">
<h1 class="ui header">
<div class="content">
{{ feature.title || feature.feature_id }}
<div class="ui icon right floated compact buttons">
<router-link
v-if="permissions && permissions.can_create_feature"
:to="{
name: 'ajouter-signalement',
params: {
slug_type_signal: $route.params.slug_type_signal,
},
}"
class="ui button button-hover-orange"
data-tooltip="Ajouter un signalement"
data-position="bottom left"
>
<i class="plus fitted icon" />
</router-link>
<router-link
v-if="
(permissions && permissions.can_update_feature) ||
isFeatureCreator ||
isModerator
"
:to="{
name: 'editer-signalement',
params: {
slug_signal: $route.params.slug_signal,
slug_type_signal: $route.params.slug_type_signal,
},
}"
class="ui button button-hover-orange"
>
<i class="inverted grey pencil alternate icon" />
</router-link>
<a
v-if="isFeatureCreator"
id="feature-delete"
class="ui button button-hover-red"
@click="isCanceling = true"
>
<i class="inverted grey trash alternate icon" />
</a>
</div>
<div class="ui hidden divider" />
<div class="sub header prewrap">
{{ feature.description }}
</div>
</div>
</h1>
</div>
</div>
<div class="row">
<div class="seven wide column">
<table class="ui very basic table">
<tbody>
<div
v-for="(field, index) in feature.feature_data"
:key="'field' + index"
v-frag
>
<tr v-if="field">
<td>
<b>{{ field.label }}</b>
</td>
<td>
<b>
<i
v-if="field.field_type === 'boolean'"
:class="[
'icon',
field.value ? 'olive check' : 'grey times',
]"
/>
<span v-else>
{{ field.value }}
</span>
</b>
</td>
</tr>
</div>
<tr>
<td>Auteur</td>
<td>{{ feature.display_creator }}</td>
</tr>
<tr>
<td>Statut</td>
<td>
<i
v-if="feature.status"
:class="['icon', statusIcon]"
/>
{{ statusLabel }}
</td>
</tr>
<tr>
<td>Date de création</td>
<td v-if="feature.created_on">
{{ feature.created_on | formatDate }}
</td>
</tr>
<tr>
<td>Date de dernière modification</td>
<td v-if="feature.updated_on">
{{ feature.updated_on | formatDate }}
</td>
</tr>
</tbody>
</table>
<h3>Liaison entre signalements</h3>
<table class="ui very basic table">
<tbody>
<tr
v-for="(link, index) in linked_features"
:key="link.feature_to.title + index"
>
<td v-if="link.feature_to.feature_type_slug">
{{ link.relation_type_display }}
<a @click="pushNgo(link)">{{ link.feature_to.title }} </a>
({{ link.feature_to.display_creator }} -
{{ link.feature_to.created_on }})
</td>
</tr>
</tbody>
</table>
</div>
<div class="seven wide column">
<div
id="map"
ref="map"
/>
</div>
</div>
<div class="row">
<div class="seven wide column">
<h2 class="ui header">
Pièces jointes
</h2>
<div
v-for="pj in attachments"
:key="pj.id"
class="ui divided items"
>
<div class="item">
<a
class="ui tiny image"
target="_blank"
:href="pj.attachment_file"
>
<img
:src="
pj.extension === '.pdf'
? require('@/assets/img/pdf.png')
: pj.attachment_file
"
>
</a>
<div class="middle aligned content">
<a
class="header"
target="_blank"
:href="pj.attachment_file"
>{{
pj.title
}}</a>
<div class="description">
{{ pj.info }}
</div>
</div>
</div>
</div>
<i
v-if="attachments.length === 0"
>Aucune pièce jointe associée au signalement.</i>
</div>
<div class="seven wide column">
<h2 class="ui header">
Activité et commentaires
</h2>
<div
id="feed-event"
class="ui feed"
>
<div
v-for="(event, index) in events"
:key="'event' + index"
v-frag
>
<div
v-if="event.event_type === 'create'"
v-frag
>
<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"
v-frag
>
<br><a
:href="
DJANGO_BASE_URL +
event.related_comment.attachment.url
"
target="_blank"
><i class="paperclip fitted icon" />
{{ 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 && isOffline() !== true"
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" />
<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>
<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" /> Poster le commentaire
</button>
</form>
</div>
</div>
</div>
<div
v-if="isCanceling"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
:class="[
'ui mini modal subscription',
{ 'active visible': isCanceling },
]"
>
<i
class="close icon"
@click="isCanceling = false"
/>
<div class="ui icon header">
<i class="trash alternate icon" />
Supprimer le signalement
</div>
<div class="actions">
<button
type="button"
class="ui red compact fluid button"
@click="deleteFeature"
>
Confirmer la suppression
</button>
</div>
</div>
</div>
</div>
<div
v-else
v-frag
>
Pas de signalement correspondant trouvé
</div>
</div>
</template>
<script>
import frag from 'vue-frag';
import { mapGetters, mapState, mapActions } from 'vuex';
import { mapUtil } from '@/assets/js/map-util.js';
import featureAPI from '@/services/feature-api';
import axios from '@/axios-client.js';
export default {
name: 'FeatureDetail',
directives: {
frag,
},
filters: {
formatDate(value) {
let date = new Date(value);
date = date.toLocaleString().replace(',', '');
return date.substr(0, date.length - 3); //* quick & dirty way to remove seconds from date
},
},
data() {
return {
isCanceling: false,
attachments: [],
events: [],
comment_form: {
attachment_file: {
errors: null,
title: '',
file: null,
},
comment: {
id_for_label: 'add-comment',
html_name: 'add-comment',
errors: '',
value: null,
},
},
};
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS'
]),
...mapGetters([
'permissions',
]),
...mapGetters('projects', [
'project'
]),
...mapState('feature', [
'linked_features',
'statusChoices'
]),
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
feature() {
const result = this.$store.state.feature.current_feature;
return result;
},
isFeatureCreator() {
if (this.feature && this.user) {
return this.feature.creator === this.user.id;
}
return false;
},
isModerator() {
return this.USER_LEVEL_PROJECTS && this.project &&
this.USER_LEVEL_PROJECTS[this.project.slug] === 'Modérateur'
? true
: false;
},
statusIcon() {
switch (this.feature.status) {
case 'archived':
return 'grey archive';
case 'pending':
return 'teal hourglass outline';
case 'published':
return 'olive check';
case 'draft':
return 'orange pencil alternate';
default:
return '';
}
},
statusLabel() {
const status = this.statusChoices.find(
(el) => el.value === this.feature.status
);
return status ? status.name : '';
},
},
created() {
this.$store.commit(
'feature_type/SET_CURRENT_FEATURE_TYPE_SLUG',
this.$route.params.slug_type_signal
);
this.getFeatureEvents();
this.getFeatureAttachments();
this.getLinkedFeatures();
},
mounted() {
this.$store.commit('DISPLAY_LOADER', 'Recherche du signalement');
if (!this.project) {
// Chargements des features et infos projet en cas d'arrivée directe sur la page ou de refresh
axios.all([
this.$store
.dispatch('GET_PROJECT_INFO', this.$route.params.slug),
this.$store.dispatch('feature/GET_PROJECT_FEATURE', {
project_slug: this.$route.params.slug,
feature_id: this.$route.params.slug_signal
})])
.then(() => {
this.$store.commit('DISCARD_LOADER');
this.initMap();
});
} if (!this.feature || this.feature.feature_id != this.$route.params.slug_signal) {
this.$store.dispatch('feature/GET_PROJECT_FEATURE', {
project_slug: this.$route.params.slug,
feature_id: this.$route.params.slug_signal
})
.then(() => {
this.$store.commit('DISCARD_LOADER');
this.initMap();
});
} else {
this.$store.commit('DISCARD_LOADER');
this.initMap();
}
},
beforeDestroy() {
this.$store.commit('CLEAR_MESSAGES');
},
methods: {
...mapActions('feature', [
'GET_PROJECT_FEATURES'
]),
isOffline() {
return navigator.onLine == false;
},
pushNgo(link) {
this.$router.push({
name: 'details-signalement',
params: {
slug_type_signal: link.feature_to.feature_type_slug,
slug_signal: link.feature_to.feature_id,
},
});
this.getFeatureEvents();
this.getFeatureAttachments();
this.getLinkedFeatures();
this.addFeatureToMap();
},
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.$route.params.slug_signal,
comment: this.comment_form.comment.value,
})
.then((response) => {
if (response && this.comment_form.attachment_file.file) {
featureAPI
.postCommentAttachment({
featureId: this.$route.params.slug_signal,
file: this.comment_form.attachment_file.file,
fileName: this.comment_form.attachment_file.fileName,
title: this.comment_form.attachment_file.title,
commentId: response.data.id,
})
.then(() => {
this.confirmComment();
});
} else {
this.confirmComment();
}
});
}
},
confirmComment() {
this.$store.commit('DISPLAY_MESSAGE', { comment: 'Ajout du commentaire confirmé', level: 'positive' });
this.getFeatureEvents(); //* display new comment on the page
this.comment_form.attachment_file.file = null;
this.comment_form.attachment_file.fileName = '';
this.comment_form.attachment_file.title = '';
this.comment_form.comment.value = null;
},
validateImgFile(files, handleFile) {
let url = window.URL || window.webkitURL;
let image = new Image();
image.onload = function () {
handleFile(true);
URL.revokeObjectURL(image.src);
};
image.onerror = function () {
handleFile(false);
URL.revokeObjectURL(image.src);
};
image.src = url.createObjectURL(files);
},
onFileChange(e) {
// * read image file
const files = e.target.files || e.dataTransfer.files;
const 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);
}
}
},
goBackToProject(message) {
this.$router.push({
name: 'project_detail',
params: {
slug: this.$store.state.project_slug,
message,
},
});
},
deleteFeature() {
this.$store
.dispatch('feature/DELETE_FEATURE', this.feature.feature_id)
.then((response) => {
if (response.status === 204) {
this.GET_PROJECT_FEATURES({
project_slug: this.$route.params.slug
});
this.goBackToProject();
}
});
},
initMap() {
var mapDefaultViewCenter =
this.$store.state.configuration.DEFAULT_MAP_VIEW.center;
var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
this.map = mapUtil.createMap(this.$refs.map, {
mapDefaultViewCenter,
mapDefaultViewZoom,
});
// Update link to feature list with map zoom and center
mapUtil.addMapEventListener('moveend', function () {
// update link to feature list with map zoom and center
/*var $featureListLink = $("#feature-list-link")
var baseUrl = $featureListLink.attr("href").split("?")[0]
$featureListLink.attr("href", baseUrl +`?zoom=${this.map.getZoom()}&lat=${this.map.getCenter().lat}&lng=${this.map.getCenter().lng}`)*/
});
// Load the layers.
// - if one basemap exists, we load the layers of the first one
// - if not, load the default map and service options
let layersToLoad = null;
var baseMaps = this.$store.state.map.basemaps;
var layers = this.$store.state.map.availableLayers;
if (baseMaps && baseMaps.length > 0) {
const basemapIndex = 0;
layersToLoad = baseMaps[basemapIndex].layers;
layersToLoad.forEach((layerToLoad) => {
layers.forEach((layer) => {
if (layer.id === layerToLoad.id) {
layerToLoad = Object.assign(layerToLoad, layer);
}
});
});
layersToLoad.reverse();
}
mapUtil.addLayers(
layersToLoad,
this.$store.state.configuration.DEFAULT_BASE_MAP.SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP.OPTIONS
);
mapUtil.getMap().dragging.disable();
mapUtil.getMap().doubleClickZoom.disable();
mapUtil.getMap().scrollWheelZoom.disable();
this.addFeatureToMap();
},
addFeatureToMap() {
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/feature/` +
`?feature_id=${this.$route.params.slug_signal}&output=geojson`;
axios
.get(url)
.then((response) => {
if (response.data.features.length > 0) {
const featureGroup = mapUtil.addFeatures(
response.data.features,
{},
true,
this.$store.state.feature_type.feature_types
);
mapUtil
.getMap()
.fitBounds(featureGroup.getBounds(), { padding: [25, 25] });
}
})
.catch((error) => {
throw error;
});
},
getFeatureEvents() {
featureAPI
.getFeatureEvents(this.$route.params.slug_signal)
.then((data) => (this.events = data));
},
getFeatureAttachments() {
featureAPI
.getFeatureAttachments(this.$route.params.slug_signal)
.then((data) => (this.attachments = data));
},
getLinkedFeatures() {
featureAPI
.getFeatureLinks(this.$route.params.slug_signal)
.then((data) =>
this.$store.commit('feature/SET_LINKED_FEATURES', data)
);
},
},
};
</script>
<style scoped>
#map {
width: 100%;
height: 100%;
min-height: 250px;
max-height: 70vh;
}
#feed-event .event {
margin-bottom: 1em;
}
#feed-event .event .date {
margin-right: 1em !important;
}
#feed-event .event .extra.text {
margin-left: 107px;
margin-top: 0;
}
.prewrap {
white-space: pre-wrap;
}
</style>
\ No newline at end of file
<template>
<div class="fourteen wide column">
<div
id="feature-list-container"
class="ui grid mobile-column"
>
<div class="four wide column mobile-fullwidth">
<h1>Signalements</h1>
</div>
<div class="twelve-wide column no-padding-mobile mobile-fullwidth">
<div class="ui large text loader">
Chargement
</div>
<div class="ui secondary menu no-margin">
<a
:class="['item no-margin', { active: showMap }]"
data-tab="map"
data-tooltip="Carte"
@click="showMap = true"
><i class="map fitted icon" /></a>
<a
:class="['item no-margin', { active: !showMap }]"
data-tab="list"
data-tooltip="Liste"
@click="showMap = false"
><i class="list fitted icon" /></a>
<div class="item">
<h4>
{{ featuresCount }} signalement{{ featuresCount > 1 ? "s" : "" }}
</h4>
</div>
<div
v-if="
project &&
feature_types.length > 0 &&
permissions.can_create_feature
"
id="button-dropdown"
class="item right"
>
<div
class="ui dropdown button compact button-hover-green"
data-tooltip="Ajouter un signalement"
data-position="bottom right"
@click="toggleAddFeature"
>
<i class="plus fitted icon" />
<div
v-if="showAddFeature"
class="menu left transition visible"
style="z-index: 9999"
>
<div class="header">
Ajouter un signalement du type
</div>
<div class="scrolling menu text-wrap">
<router-link
v-for="(type, index) in feature_types"
:key="type.slug + index"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: type.slug },
}"
class="item"
>
{{ type.title }}
</router-link>
</div>
</div>
</div>
<div
v-if="checkedFeatures.length > 0 && mode === 'modify'"
class="ui dropdown button compact button-hover-green margin-left-25"
data-tooltip="Modifier le statut des Signalements"
data-position="bottom right"
@click="toggleModifyStatus"
>
<i class="pencil fitted icon" />
<div
v-if="showModifyStatus"
class="menu left transition visible"
style="z-index: 9999"
>
<div class="header">
Modifier le statut des Signalements
</div>
<div class="scrolling menu text-wrap">
<span
v-for="status in availableStatus"
:key="status.value"
class="item"
@click="modifyStatus(status.value)"
>
{{ status.name }}
</span>
</div>
</div>
</div>
<div
v-if="checkedFeatures.length > 0 && mode === 'delete'"
class="ui button compact button-hover-red margin-left-25"
data-tooltip="Effacer tous les types de signalements sélectionnés"
data-position="bottom right"
@click="modalAllDelete"
>
<i class="grey trash fitted icon" />
</div>
</div>
</div>
</div>
</div>
<section
id="form-filters"
class="ui form grid"
>
<div class="field wide four column no-margin-mobile">
<label>Type</label>
<Dropdown
:options="featureTypeChoices"
:selected="form.type.selected"
:selection.sync="form.type.selected"
:search="true"
:clearable="true"
/>
</div>
<div class="field wide four column no-padding-mobile no-margin-mobile">
<label>Statut</label>
<!-- //* giving an object mapped on key name -->
<Dropdown
:options="statusChoices"
:selected="form.status.selected.name"
:selection.sync="form.status.selected"
:search="true"
:clearable="true"
/>
</div>
<div class="field wide four column">
<label>Nom</label>
<div class="ui icon input">
<i class="search icon" />
<div class="ui action input">
<input
v-model="form.title"
type="text"
name="title"
@keyup.enter="fetchPagedFeatures"
>
<button
id="submit-search"
class="ui teal icon button"
@click="fetchPagedFeatures"
>
<i class="search icon" />
</button>
</div>
</div>
</div>
<!-- map params, updated on map move -->
<input
v-model="zoom"
type="hidden"
name="zoom"
>
<input
v-model="lat"
type="hidden"
name="lat"
>
<input
v-model="lng"
type="hidden"
name="lng"
>
</section>
<div
v-show="showMap"
class="ui tab active map-container"
data-tab="map"
>
<div
id="map"
ref="map"
/>
<SidebarLayers v-if="basemaps && map" />
</div>
<FeatureListTable
v-show="!showMap"
:paginated-features="paginatedFeatures"
:checked-features.sync="checkedFeatures"
:features-count="featuresCount"
:clicked-features.sync="clickedFeatures"
:mode.sync="mode"
:pagination="pagination"
:sort="sort"
@update:page="handlePageChange"
@update:sort="handleSortChange"
/>
<!-- MODAL ALL DELETE FEATURE TYPE -->
<div
v-if="modalAllDeleteOpen"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
:class="[
'ui mini modal subscription',
{ 'active visible': modalAllDeleteOpen },
]"
>
<i
class="close icon"
@click="modalAllDeleteOpen = false"
/>
<div class="ui icon header">
<i class="trash alternate icon" />
Êtes-vous sûr de vouloir effacer
<span v-if="checkedFeatures.length === 1"> un signalement ? </span>
<span v-else> ces {{ checkedFeatures.length }} signalements ? </span>
</div>
<div class="actions">
<button
type="button"
class="ui red compact fluid button"
@click="deleteAllFeatureSelection"
>
Confirmer la suppression
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters, mapState, mapActions } from 'vuex';
import { mapUtil } from '@/assets/js/map-util.js';
import featureAPI from '@/services/feature-api';
import SidebarLayers from '@/components/map-layers/SidebarLayers';
import FeatureListTable from '@/components/feature/FeatureListTable';
import Dropdown from '@/components/Dropdown.vue';
import axios from '@/axios-client.js';
export default {
name: 'FeatureList',
components: {
SidebarLayers,
Dropdown,
FeatureListTable,
},
data() {
return {
form: {
type: {
selected: '',
},
status: {
selected: '',
choices: [
{
name: 'Brouillon',
value: 'draft',
},
{
name: 'En attente de publication',
value: 'pending',
},
{
name: 'Publié',
value: 'published',
},
{
name: 'Archivé',
value: 'archived',
},
],
},
title: null,
},
baseUrl: this.$store.state.configuration.BASE_URL,
clickedFeatures: [],
modalAllDeleteOpen: false,
mode: 'modify',
map: null,
zoom: null,
lat: null,
lng: null,
featuresCount: 0,
paginatedFeatures: [],
currentLayer: null,
pagination: {
currentPage: 1,
pagesize: 15,
start: 0,
end: 15,
},
previous: null,
next: null,
sort: {
column: '',
ascending: true,
},
showMap: true,
showAddFeature: false,
showModifyStatus: false,
};
},
computed: {
...mapState(['user', 'USER_LEVEL_PROJECTS']),
...mapGetters([
'permissions'
]),
...mapGetters('projects', [
'project'
]),
...mapState('feature', [
'checkedFeatures'
]),
...mapState('feature_type', [
'feature_types'
]),
...mapState('map', [
'basemaps'
]),
API_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_API_BASE;
},
statusChoices() {
//* if project is not moderate, remove pending status
return this.form.status.choices.filter((el) =>
this.project && this.project.moderation ? true : el.value !== 'pending'
);
},
availableStatus() {
if (this.project) {
const isModerate = this.project.moderation;
const userStatus = this.USER_LEVEL_PROJECTS[this.project.slug];
const isOwnFeature = this.feature
? this.feature.creator === this.user.id //* prevent undefined feature
: false; //* si le contributeur est l'auteur du signalement
if (
//* si admin, modérateur ou super contributeur, statuts toujours disponible: Brouillon, Publié, Archivé
userStatus === 'Administrateur projet' ||
(userStatus === 'Super Contributeur' && !isModerate)
) {
return this.statusChoices.filter((el) => el.value !== 'pending');
} else if (userStatus === 'Super Contributeur' && isModerate) {
return this.statusChoices.filter(
(el) => el.value === 'draft' || el.value === 'pending'
);
} else if (userStatus === 'Modérateur') {
return this.statusChoices.filter(
(el) => el.value === 'draft' || el.value === 'published'
);
} else if (userStatus === 'Contributeur') {
//* cas particuliers du contributeur
if (
this.currentRouteName === 'ajouter-signalement' ||
!isOwnFeature
) {
//* même cas à l'ajout d'une feature ou si feature n'a pas été créé par le contributeur
return isModerate
? this.statusChoices.filter(
(el) => el.value === 'draft' || el.value === 'pending'
)
: this.statusChoices.filter(
(el) => el.value === 'draft' || el.value === 'published'
);
} else {
//* à l'édition d'une feature et si le contributeur est l'auteur de la feature
return isModerate
? this.statusChoices.filter(
(el) => el.value !== 'published' //* toutes sauf "Publié"
)
: this.statusChoices.filter(
(el) => el.value !== 'pending' //* toutes sauf "En cours de publication"
);
}
}
}
return [];
},
featureTypeChoices() {
return this.feature_types.map((el) => el.title);
},
},
watch: {
'form.type.selected'() {
this.fetchPagedFeatures();
},
'form.status.selected.value'() {
this.fetchPagedFeatures();
},
map(newValue) {
if (newValue && this.paginatedFeatures && this.paginatedFeatures.length) {
if (this.currentLayer) {
this.map.removeLayer(this.currentLayer);
}
this.currentLayer = mapUtil.addFeatures(
this.paginatedFeatures,
{},
true,
this.feature_types
);
}
},
paginatedFeatures: {
deep: true,
handler(newValue, oldValue) {
if (newValue && newValue.length && newValue !== oldValue && this.map) {
if (this.currentLayer) {
this.map.removeLayer(this.currentLayer);
this.currentLayer = null;
}
this.currentLayer = mapUtil.addFeatures(
newValue,
{},
true,
this.feature_types
);
} else if (newValue && newValue.length === 0) {
if (this.currentLayer) {
this.map.removeLayer(this.currentLayer);
this.currentLayer = null;
}
}
}
}
},
mounted() {
if (!this.project) {
// Chargements des features et infos projet en cas d'arrivée directe sur la page ou de refresh
this.$store
.dispatch('GET_PROJECT_INFO', this.$route.params.slug)
.then(() => this.initMap());
} else {
this.initMap();
}
this.fetchPagedFeatures();
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
//* allow user to change page if ever stuck on loader
this.$store.commit('DISCARD_LOADER');
},
methods: {
...mapActions('feature', [
'GET_PROJECT_FEATURES',
'SEND_FEATURE'
]),
toggleAddFeature() {
this.showAddFeature = !this.showAddFeature;
this.showModifyStatus = false;
},
toggleModifyStatus() {
this.showModifyStatus = !this.showModifyStatus;
this.showAddFeature = false;
},
modalAllDelete() {
this.modalAllDeleteOpen = !this.modalAllDeleteOpen;
},
clickOutsideDropdown(e) {
if (!e.target.closest('#button-dropdown')) {
this.showModifyStatus = false;
setTimeout(() => { //* timout necessary to give time to click on link to add feature
this.showAddFeature = false;
}, 500);
}
},
async modifyStatus(newStatus) {
let errorCount = 0;
const promises = this.checkedFeatures.map((feature_id) => {
let feature = this.clickedFeatures.find((el) => el.feature_id === feature_id);
if (feature) {
return featureAPI.updateFeature({
feature_id,
feature_type__slug: feature.feature_type,
project__slug: this.$route.params.slug, newStatus
});
} else {
errorCount += 1;
}
});
const promisesResult = await Promise.all(promises);
promisesResult.forEach((response) => {
if (response && response.data && response.status === 200) {
this.checkedFeatures.splice(this.checkedFeatures.indexOf(response.data.id), 2);
} else {
errorCount += 1;
}
});
let message = {
comment: 'Tous les signalements ont été modifié avec succès.',
level: 'positive'
};
if (errorCount) {
//* display error message
if(errorCount === 1) {
message.comment = "Un signalement n'a pas pu être modifié. (Il reste sélectionné)";
} else {
message.comment = `${errorCount} signalements n'ont pas pu être modifiés. (Ils restent sélectionnés)`;
}
message.level = 'negative';
}
this.fetchPagedFeatures();
this.$store.commit('DISPLAY_MESSAGE', message);
},
deleteFeature(feature_id) {
const url = `${this.API_BASE_URL}features/${feature_id}/?project__slug=${this.project.slug}`;
axios //TODO: REFACTO -> Delete function already exist in store
.delete(url, {})
.then(() => {
if (!this.modalAllDeleteOpen) {
this.GET_PROJECT_FEATURES({
project_slug: this.project.slug,
})
.then(() => {
this.fetchPagedFeatures();
this.checkedFeatures.splice(feature_id);
});
}
})
.catch(() => {
return false;
});
},
deleteAllFeatureSelection() {
let feature = {};
this.checkedFeatures.forEach((feature_id) => {
feature = { feature_id: feature_id }; // ? Is this usefull ?
this.deleteFeature(feature.feature_id); //? since property feature_id is directly used after...
});
this.modalAllDelete();
},
onFilterChange() {
if (mapUtil.getMap()) {
mapUtil.getMap().invalidateSize();
mapUtil.getMap()._onResize(); // force refresh for vector tiles
if (window.layerMVT) {
window.layerMVT.redraw();
}
}
},
initMap() {
this.zoom = this.$route.query.zoom || '';
this.lat = this.$route.query.lat || '';
this.lng = this.$route.query.lng || '';
var mapDefaultViewCenter =
this.$store.state.configuration.DEFAULT_MAP_VIEW.center;
var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
this.map = mapUtil.createMap(this.$refs.map, {
zoom: this.zoom,
lat: this.lat,
lng: this.lng,
mapDefaultViewCenter,
mapDefaultViewZoom,
});
this.fetchBboxNfit();
document.addEventListener('change-layers-order', (event) => {
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
mapUtil.updateOrder(event.detail.layers.slice().reverse());
});
// --------- End sidebar events ----------
setTimeout(() => {
const project_id = this.$route.params.slug.split('-')[0];
const mvtUrl = `${this.API_BASE_URL}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`;
mapUtil.addVectorTileLayer(
mvtUrl,
this.$route.params.slug,
this.feature_types,
this.form
);
mapUtil.addGeocoders(this.$store.state.configuration);
}, 1000);
},
fetchBboxNfit(queryParams) {
featureAPI
.getFeaturesBbox(this.project.slug, queryParams)
.then((bbox) => {
if (bbox) {
mapUtil.getMap().fitBounds(bbox, { padding: [25, 25] });
}
});
},
//* Paginated Features for table *//
getFeatureTypeSlug(title) {
const featureType = this.feature_types.find((el) => el.title === title);
return featureType ? featureType.slug : null;
},
getAvalaibleField(orderField) {
let result = orderField;
if (orderField === 'display_creator') {
result = 'creator';
} else if (orderField === 'display_last_editor') {
result = 'last_editor';
}
return result;
},
buildQueryString() {
let urlParams = '';
let typeFilter = this.getFeatureTypeSlug(this.form.type.selected);
let statusFilter = this.form.status.selected.value;
if (typeFilter) urlParams += `&feature_type_slug=${typeFilter}`;
if (statusFilter) urlParams += `&status__value=${statusFilter}`;
if (this.form.title) urlParams += `&title=${this.form.title}`;
if (this.sort.column) {
urlParams += `&ordering=${
this.sort.ascending ? '-' : ''
}${this.getAvalaibleField(this.sort.column)}`;
}
return urlParams;
},
fetchPagedFeatures(newUrl) {
this.onFilterChange(); //* use paginated event to watch change in filters and modify features on map
let url = `${this.API_BASE_URL}projects/${this.$route.params.slug}/feature-paginated/?output=geojson&limit=${this.pagination.pagesize}&offset=${this.pagination.start}`;
//* if receiving next & previous url
if (newUrl && typeof newUrl === 'string') {
//newUrl = newUrl.replace("8000", "8010"); //* for dev uncomment to use proxy link
url = newUrl;
}
const queryString = this.buildQueryString();
url += queryString;
this.$store.commit(
'DISPLAY_LOADER',
'Récupération des signalements en cours...'
);
featureAPI.getPaginatedFeatures(url)
.then((data) => {
if (data) {
this.featuresCount = data.count;
this.previous = data.previous;
this.next = data.next;
this.paginatedFeatures = data.results.features;
}
//* bbox needs to be updated with the same filters
if (this.paginatedFeatures.length) this.fetchBboxNfit(queryString);
this.$store.commit('DISCARD_LOADER');
});
},
//* Pagination for table *//
handlePageChange(page) {
if (page === 'next') {
this.toNextPage();
} else if (page === 'previous') {
this.toPreviousPage();
} else if (typeof page === 'number') {
//* update limit and offset
this.toPage(page);
}
},
handleSortChange(sort) {
this.sort = sort;
this.fetchPagedFeatures({
filterType: undefined,
filterValue: undefined,
});
},
toPage(pageNumber) {
const toAddOrRemove =
(pageNumber - this.pagination.currentPage) * this.pagination.pagesize;
this.pagination.start += toAddOrRemove;
this.pagination.end += toAddOrRemove;
this.pagination.currentPage = pageNumber;
this.fetchPagedFeatures();
},
toPreviousPage() {
if (this.pagination.currentPage !== 1) {
if (this.pagination.start > 0) {
this.pagination.start -= this.pagination.pagesize;
this.pagination.end -= this.pagination.pagesize;
this.pagination.currentPage -= 1;
}
this.fetchPagedFeatures(this.previous);
}
},
toNextPage() {
if (this.pagination.currentPage !== this.pageNumbers.length) {
if (this.pagination.end < this.featuresCount) {
this.pagination.start += this.pagination.pagesize;
this.pagination.end += this.pagination.pagesize;
this.pagination.currentPage += 1;
}
this.fetchPagedFeatures(this.next);
}
},
},
};
</script>
<style scoped>
#map {
width: 100%;
min-height: 300px;
height: calc(100vh - 300px);
border: 1px solid grey;
/* To not hide the filters */
z-index: 1;
}
#feature-list-container {
justify-content: flex-start;
}
#feature-list-container .ui.menu:not(.vertical) .right.item {
padding-right: 0;
}
.map-container {
width: 80vw;
transform: translateX(-50%);
margin-left: 50%;
}
.margin-left-25 {
margin-left: 0.25em !important;
}
.no-padding {
padding: 0 !important;
}
.ui.dropdown .menu .left.menu, .ui.dropdown > .left.menu .menu {
margin-right: 0 !important;
}
@media screen and (min-width: 767px) {
.twelve-wide {
width: 75% !important;
}
}
@media screen and (max-width: 767px) {
#feature-list-container > .mobile-fullwidth {
width: 100% !important;
}
.no-margin-mobile {
margin: 0 !important;
}
.no-padding-mobile {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.mobile-column {
flex-direction: column !important;
}
#button-dropdown {
transform: translate(-50px, -60px);
}
#form-filters > .field.column {
width: 100% !important;
}
.map-container {
width: 100%;
}
}
</style>
<template>
<div
v-if="structure"
class="row"
>
<div class="five wide column">
<div class="ui attached secondary segment">
<h1 class="ui center aligned header ellipsis">
<img
v-if="structure.geom_type === 'point'"
class="ui medium image"
src="@/assets/img/marker.png"
>
<img
v-if="structure.geom_type === 'linestring'"
class="ui medium image"
src="@/assets/img/line.png"
>
<img
v-if="structure.geom_type === 'polygon'"
class="ui medium image"
src="@/assets/img/polygon.png"
>
{{ structure.title }}
</h1>
</div>
<div class="ui attached segment">
<div class="ui basic segment">
<div class="ui horizontal tiny statistic">
<div
:class="{ active: featuresLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des signalements en cours...
</div>
</div>
<div class="value">
{{ features_count }}
</div>
<div class="label">
Signalement{{ features.length > 1 ? "s" : "" }}
</div>
</div>
<h3 class="ui header">
Champs
</h3>
<div class="ui divided list">
<div
v-for="(field, index) in orderedCustomFields"
:key="field.name + index"
class="item"
>
<div class="right floated content">
<div class="description">
{{ field.field_type }}
</div>
</div>
<div class="content">
{{ field.label }} ({{ field.name }})
</div>
</div>
</div>
</div>
</div>
<div class="ui bottom attached secondary segment">
<div
v-if="permissions.can_create_feature"
class="ui styled accordion"
>
<div
:class="['title', { active: showImport }]"
@click="toggleShowImport"
>
<i class="dropdown icon" />
Importer des signalements
</div>
<div :class="['content', { active: showImport }]">
<div
id="form-import-features"
class="ui form"
:class="loadingImportFile ? 'loading' : ''"
>
<div class="field">
<label
class="ui icon button ellipsis"
for="json_file"
>
<i class="file icon" />
<span class="label">{{ fileToImport.name }}</span>
</label>
<input
id="json_file"
type="file"
accept="application/json, .json, .geojson"
style="display: none"
name="json_file"
@change="onFileChange"
>
</div>
<router-link
v-if="
IDGO &&
permissions &&
permissions.can_create_feature
"
:to="{
name: 'catalog-import',
params: {
slug: project.slug,
feature_type_slug: $route.params.feature_type_slug
},
}"
class="ui icon button import-catalog"
>
Importer les signalements à partir de {{ CATALOG_NAME|| 'IDGO' }}
</router-link>
<div
v-if="$route.params.geojson"
class="ui button import-catalog basic active teal no-hover"
>
Ressource {{ $route.params.geojson.name }}
</div>
<ul
v-if="importError"
class="errorlist"
>
<li>
{{ importError }}
</li>
</ul>
<button
:disabled="fileToImport.size === 0 && !$route.params.geojson"
class="ui fluid teal icon button"
@click="importGeoJson"
>
<i class="upload icon" /> Lancer l'import
</button>
<ImportTask
v-if="importFeatureTypeData && importFeatureTypeData.length"
:data="importFeatureTypeData"
:reloading="reloadingImport"
/>
</div>
</div>
</div>
<div class="ui styled accordion">
<div
:class="['title', { active: !showImport }]"
@click="toggleShowImport"
>
<i class="dropdown icon" />
Exporter les signalements
</div>
<div :class="['content', { active: !showImport }]">
<p>
Vous pouvez télécharger tous les signalements qui vous sont
accessibles.
</p>
<button
type="button"
class="ui fluid teal icon button"
@click="exportFeatures"
>
<i class="download icon" /> Exporter
</button>
</div>
</div>
</div>
</div>
<div class="nine wide column">
<h3 class="ui header">
Derniers signalements
</h3>
<div
:class="{ active: featuresLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des signalements en cours...
</div>
</div>
<div
v-if="
importFeatureTypeData &&
importFeatureTypeData.length &&
importFeatureTypeData.some((el) => el.status === 'pending')
"
class="ui message info"
>
<p>
Des signalements sont en cours d'import. Pour suivre le statut de
l'import, cliquez sur "Importer des Signalements".
</p>
</div>
<div
v-else-if="waitMessage"
class="ui message info"
>
<p>
L'import des signalements a été lancé.
Vous pourrez suivre le statut de l'import dans quelques instants...
</p>
</div>
<div
v-for="(feature, index) in lastFeatures"
:key="feature.feature_id + index"
class="ui small header"
>
<span
v-if="feature.status === 'archived'"
data-tooltip="Archivé"
>
<i class="grey archive icon" />
</span>
<span
v-else-if="feature.status === 'pending'"
data-tooltip="En attente de publication"
>
<i class="teal hourglass outline icon" />
</span>
<span
v-else-if="feature.status === 'published'"
data-tooltip="Publié"
>
<i class="olive check icon" />
</span>
<span
v-else-if="feature.status === 'draft'"
data-tooltip="Brouillon"
>
<i class="orange pencil alternate icon" />
</span>
<router-link
:to="{
name: 'details-signalement',
params: {
slug: project.slug,
slug_type_signal: feature.feature_type.slug,
slug_signal: feature.feature_id,
},
}"
>
{{ feature.title || feature.feature_id }}
</router-link>
<div class="sub header">
<div>
{{
feature.description
? feature.description.substring(0, 200)
: "Pas de description disponible"
}}
</div>
<div>
[ Créé le {{ feature.created_on | formatDate }}
<span v-if="$store.state.user">
par {{ feature.display_creator }}</span>
]
</div>
</div>
</div>
<router-link
v-if="project"
:to="{ name: 'liste-signalements', params: { slug: project.slug } }"
class="ui right labeled icon button margin-25"
>
<i class="right arrow icon" />
Voir tous les signalements
</router-link>
<router-link
v-if="permissions.can_create_feature"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: structure.slug },
}"
class="ui icon button button-hover-green margin-25"
>
Ajouter un signalement
</router-link>
<br>
</div>
</div>
</template>
<script>
import { mapActions, mapMutations, mapGetters, mapState } from 'vuex';
import ImportTask from '@/components/ImportTask';
import featureAPI from '@/services/feature-api';
import { fileConvertSizeToMo } from '@/assets/js/utils';
export default {
name: 'FeatureTypeDetail',
components: {
ImportTask: ImportTask,
},
filters: {
formatDate(value) {
let date = new Date(value);
date = date.toLocaleString().replace(',', ' à');
return date.substr(0, date.length - 3); //* quick & dirty way to remove seconds from date
},
},
props: {
geojson: {
type: Object,
default: null,
},
},
data() {
return {
importError: '',
fileToImport: {
name: 'Sélectionner un fichier GeoJSON ...',
size: 0,
},
showImport: false,
featuresLoading: true,
loadingImportFile: false,
waitMessage: false,
reloadingImport: false,
};
},
computed: {
...mapGetters([
'permissions'
]),
...mapGetters('projects', [
'project'
]),
...mapState([
'reloadIntervalId'
]),
...mapState('feature', [
'features',
'features_count'
]),
...mapState([
'configuration',
]),
...mapState('feature_type', [
'feature_types',
'importFeatureTypeData'
]),
CATALOG_NAME() {
return this.configuration.VUE_APP_CATALOG_NAME;
},
IDGO() {
return this.$store.state.configuration.VUE_APP_IDGO;
},
structure: function () {
if (Object.keys(this.feature_types).length) {
let st = this.feature_types.find(
(el) => el.slug === this.$route.params.feature_type_slug
);
if (st) return st;
}
return {};
},
feature_type_features: function () {
if (this.features.length)
return this.features.filter(
(el) => el.feature_type.slug === this.$route.params.feature_type_slug
);
return {};
},
lastFeatures: function () {
if (this.feature_type_features.length)
return this.feature_type_features.slice(0, 5);
return [];
},
orderedCustomFields() {
if (Object.keys(this.structure).length)
return [...this.structure.customfield_set].sort(
(a, b) => a.position - b.position
);
return {};
},
},
watch: {
structure(newValue) {
if (newValue.slug) {
this.GET_IMPORTS({
feature_type: this.$route.params.feature_type_slug
});
}
},
importFeatureTypeData: {
deep: true,
handler(newValue, oldValue) {
if (newValue && newValue.some(el => el.status === 'pending')) {
setTimeout(() => {
this.reloadingImport = true;
this.GET_IMPORTS({
feature_type: this.$route.params.feature_type_slug
}).then(()=> {
setTimeout(() => {
this.reloadingImport = false;
}, 1000);
});
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL);
} else if (oldValue && oldValue.some(el => el.status === 'pending')) {
this.getFeatures();
}
}
},
},
created() {
if (!this.project) {
this.GET_PROJECT_INFO(this.$route.params.slug);
}
this.$store.commit('feature/SET_FEATURES', []); //* empty remaining features in case they were in geojson format and will be fetch anyway
this.getFeatures();
this.SET_CURRENT_FEATURE_TYPE_SLUG(
this.$route.params.feature_type_slug
);
if (this.$route.params.type === 'external-geojson') {
this.showImport = true;
}
},
methods: {
...mapMutations('feature_type', ['SET_CURRENT_FEATURE_TYPE_SLUG']),
...mapActions(['GET_PROJECT_INFO']),
...mapActions('feature_type', ['GET_IMPORTS']),
...mapActions('feature', ['GET_PROJECT_FEATURES']),
toggleShowImport() {
this.showImport = !this.showImport;
if (this.showImport) {
this.GET_IMPORTS({
feature_type: this.$route.params.feature_type_slug
});
}
},
transformProperties(prop) {
const type = typeof prop;
const date = new Date(prop);
if (type === 'boolean') {
return 'boolean';
} else if (Number.isSafeInteger(prop)) {
return 'integer';
} else if (
type === 'string' &&
['/', ':', '-'].some((el) => prop.includes(el)) && // check for chars found in datestring
date instanceof Date &&
!isNaN(date.valueOf())
) {
return 'date';
} else if (type === 'number' && !isNaN(parseFloat(prop))) {
return 'decimal';
}
return 'char'; //* string by default, most accepted type in database
},
checkJsonValidity(json) {
this.importError = '';
const fields = this.structure.customfield_set.map((el) => {
return {
name: el.name,
field_type: el.field_type,
options: el.options,
};
});
for (const feature of json.features) {
for (const { name, field_type, options } of fields) {
if (name in feature.properties) {
const fieldInFeature = feature.properties[name];
const customType = this.transformProperties(fieldInFeature);
//* if custom field value is not null, then check validity of field
if (fieldInFeature !== null) {
//* if field type is list, it's not possible to guess from value type
if (field_type === 'list') {
//*then check if the value is an available option
if (!options.includes(fieldInFeature)) {
return false;
}
} else if (customType !== field_type) {
//* check if custom field value match
this.importError = `Le fichier est invalide: Un champ de type ${field_type} ne peut pas avoir la valeur [ ${fieldInFeature} ]`;
return false;
}
}
}
}
}
return true;
},
onFileChange(e) {
this.loadingImportFile = true;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
this.loadingImportFile = false;
return;
}
let reader = new FileReader();
reader.addEventListener('load', (e) => {
// bypass json check for files larger then 10 Mo
let jsonValidity;
if (parseFloat(fileConvertSizeToMo(files[0])) <= 10) {
jsonValidity = this.checkJsonValidity(JSON.parse(e.target.result));
} else {
jsonValidity = true;
}
if (jsonValidity) {
this.fileToImport = files[0]; // todo : remove this value from state as it stored (first attempt didn't work)
this.$store.commit(
'feature_type/SET_FILE_TO_IMPORT',
this.fileToImport
);
}
this.loadingImportFile = false;
});
reader.readAsText(files[0]);
},
importGeoJson() {
this.waitMessage = true;
let payload = {
slug: this.$route.params.slug,
feature_type_slug: this.$route.params.feature_type_slug,
};
if (this.$route.params.geojson) {
payload['geojson'] = this.$route.params.geojson;
} else if (this.fileToImport && !this.fileToImport.name) {
payload['fileToImport'] = this.fileToImport;
} else {
this.importError = "La ressource n'a pas pu être récupéré.";
return;
}
this.$store.dispatch('feature_type/SEND_FEATURES_FROM_GEOJSON', payload)
.then(() => {
this.waitMessage = false;
});
},
exportFeatures() {
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/feature-type/${this.$route.params.feature_type_slug}/export/`;
featureAPI.getFeaturesBlob(url).then((blob) => {
if (blob) {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${this.project.title}-${this.structure.title}.json`;
link.click();
URL.revokeObjectURL(link.href);
}
});
},
async getFeatures() {
const response = await this.GET_PROJECT_FEATURES({
project_slug: this.$route.params.slug,
feature_type__slug: this.$route.params.feature_type_slug,
ordering: '-created_on',
limit: '5',
});
if (response) {
this.featuresLoading = false;
}
},
},
};
</script>
<style scoped>
.margin-25 {
margin: 0 0.25em 0.25em 0 !important;
}
.import-catalog {
margin-bottom: 1em;
}
.no-hover {
cursor: default;
}
</style>
\ No newline at end of file
<template>
<div>
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui loader" />
</div>
<div
id="message"
class="fullwidth"
>
<div
v-if="error"
class="ui negative message"
>
<p><i class="close icon" /> {{ error }}</p>
</div>
<div
v-if="success"
class="ui positive message"
>
<i
class="close icon"
@click="success = null"
/>
<p>{{ success }}</p>
</div>
</div>
<div class="fourteen wide column">
<form
id="form-symbology-edit"
action=""
method="post"
enctype="multipart/form-data"
class="ui form"
>
<h1 v-if="feature_type">
Éditer la symbologie du type de signalement "{{ feature_type.title }}" pour le
projet "{{ project.title }}"
</h1>
<SymbologySelector
v-if="feature_type"
:init-color="feature_type.color"
:init-icon="feature_type.icon"
:geom-type="feature_type.geom_type"
@set="setDefaultStyle"
/>
<div
v-if="
feature_type &&
feature_type.customfield_set.length > 0 &&
feature_type.customfield_set.some(el => el.field_type === 'list')
"
>
<div class="ui divider" />
<label
id="customfield-select-label"
for="customfield-select"
>
Personnaliser la symbologie d'une liste de valeurs:
</label>
<select
id="customfield-select"
v-model="selectedCustomfield"
class="ui dropdown"
>
<option
v-for="customfieldList of feature_type.customfield_set.filter(el => el.field_type === 'list')"
:key="customfieldList.name"
:value="customfieldList.name"
>
{{ customfieldList.label }}
</option>
</select>
</div>
<div v-if="selectedCustomfield">
<div
v-for="option of feature_type.customfield_set.find(el => el.name === selectedCustomfield).options"
:key="option"
>
<SymbologySelector
:title="option"
:init-color="feature_type.colors_style.value ?
feature_type.colors_style.value.colors[option] ?
feature_type.colors_style.value.colors[option].value :
feature_type.colors_style.value.colors[option]
: null
"
:init-icon="feature_type.colors_style.value ?
feature_type.colors_style.value.icons[option] :
null
"
:geom-type="feature_type.customfield_set.geomType"
@set="setColorsStyle"
/>
</div>
</div>
<div class="ui divider" />
<button
class="ui teal icon button margin-25"
type="button"
:disabled="!canSaveSymbology"
@click="sendFeatureSymbology"
>
<i class="white save icon" />
Sauvegarder la symbologie du type de signalement
</button>
</form>
</div>
</div>
</template>
<script>
import frag from 'vue-frag';
import { isEqual } from 'lodash';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import SymbologySelector from '@/components/feature_type/SymbologySelector.vue';
export default {
name: 'FeatureTypeSymbology',
directives: {
frag,
},
components: {
SymbologySelector
},
data() {
return {
loading: false,
error: null,
success: null,
selectedCustomfield: null,
form: {
color: '#000000',
icon: 'circle',
colors_style: {
fields: [],
colors: {},
icons: {},
custom_field_name: '',
value: {
colors: {},
icons: {}
}
},
},
canSaveSymbology: false
};
},
computed: {
...mapGetters('projects', [
'project'
]),
...mapState('feature_type', [
'customForms',
'colorsStyleList'
]),
...mapGetters('feature_type', [
'feature_type'
]),
},
watch: {
selectedCustomfield(newValue) {
this.form.colors_style.custom_field_name = newValue;
},
feature_type(newValue) {
if (newValue) {
// Init form
this.form.color = JSON.parse(JSON.stringify(newValue.color));
this.form.icon = JSON.parse(JSON.stringify(newValue.icon));
this.form.colors_style = {
...this.form.colors_style,
...JSON.parse(JSON.stringify(newValue.colors_style))
};
}
},
form: {
deep: true,
handler(newValue) {
if (isEqual(newValue, {
color: this.feature_type.color,
icon: this.feature_type.icon,
colors_style: this.feature_type.colors_style
})) {
this.canSaveSymbology = false;
} else {
this.canSaveSymbology = true;
}
}
}
},
created() {
if (!this.project) {
this.GET_PROJECT_INFO(this.$route.params.slug);
}
this.SET_CURRENT_FEATURE_TYPE_SLUG(this.$route.params.slug_type_signal);
if (this.feature_type) {
this.initForm();
} else {
this.loading = true;
this.GET_PROJECT_FEATURE_TYPES(this.$route.params.slug)
.then(() => {
this.initForm();
this.loading = false;
})
.catch(() => {
this.loading = false;
});
}
},
methods: {
...mapMutations('feature_type', [
'SET_CURRENT_FEATURE_TYPE_SLUG'
]),
...mapActions('feature_type', [
'SEND_FEATURE_SYMBOLOGY',
'GET_PROJECT_FEATURE_TYPES'
]),
...mapActions([
'GET_PROJECT_INFO'
]),
initForm() {
this.form.color = JSON.parse(JSON.stringify(this.feature_type.color));
this.form.icon = JSON.parse(JSON.stringify(this.feature_type.icon));
this.form.colors_style = {
...this.form.colors_style,
...JSON.parse(JSON.stringify(this.feature_type.colors_style))
};
if (this.feature_type.colors_style && Object.keys(this.feature_type.colors_style.colors).length > 0) {
this.selectedCustomfield =
this.feature_type.customfield_set.find(
el => el.name === this.feature_type.colors_style.custom_field_name
).name;
}
},
setDefaultStyle(e) {
const value = e.value;
this.form.color = value.color.value;
this.form.icon = value.icon;
},
setColorsStyle(e) {
const { name, value } = e;
this.form.colors_style.colors[name] = value.color;
this.form.colors_style.icons[name] = value.icon;
this.form.colors_style.value.colors[name] = value.color;
this.form.colors_style.value.icons[name] = value.icon;
},
sendFeatureSymbology() {
this.loading = true;
this.SEND_FEATURE_SYMBOLOGY(this.form)
.then(() => {
this.GET_PROJECT_FEATURE_TYPES(this.$route.params.slug)
.then(() => {
this.loading = false;
this.success =
'La modification de la symbologie a été prise en compte. Vous allez être redirigé vers la page d\'accueil du projet.';
setTimeout(() => {
this.$router.push({
name: 'project_detail',
params: {
slug: this.$store.state.project_slug,
},
});
}, 1500);
})
.catch((err) => {
console.error(err);
});
})
.catch((err) => {
console.error(err);
this.loading = false;
});
}
}
};
</script>
<style lang="less" scoped>
h1 {
margin-top: 1em;
}
form {
text-align: left;
#customfield-select-label {
cursor: pointer;
font-weight: 600;
font-size: 1.1em;
}
#customfield-select {
width: 50% !important;
}
}
</style>
<template>
<div v-frag>
<div
v-if="permissions && permissions.can_view_project && project"
v-frag
>
<div
id="message"
class="fullwidth"
>
<div
v-if="tempMessage"
class="ui positive message"
>
<p><i class="check icon" /> {{ tempMessage }}</p>
</div>
</div>
<div
id="message_info"
class="fullwidth"
>
<div
v-if="infoMessage"
class="ui info message"
style="text-align: left"
>
<div class="header">
<i class="info circle icon" /> Informations
</div>
<ul class="list">
{{
infoMessage
}}
</ul>
</div>
</div>
<div class="row">
<div class="four wide middle aligned column">
<img
class="ui small spaced image"
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
>
<div class="ui hidden divider" />
<div
class="ui basic teal label"
data-tooltip="Membres"
>
<i class="user icon" />{{ project.nb_contributors }}
</div>
<div
class="ui basic teal label"
data-tooltip="Signalements publiés"
>
<i class="map marker icon" />{{ project.nb_published_features }}
</div>
<div
class="ui basic teal label"
data-tooltip="Commentaires"
>
<i class="comment icon" />{{
project.nb_published_features_comments
}}
</div>
</div>
<div class="ten wide column important-flex space-between">
<div>
<h1 class="ui header">
{{ project.title }}
</h1>
<div class="ui hidden divider" />
<div class="sub header">
{{ project.description }}
</div>
</div>
<div class="content flex flex-column-right">
<div class="flex flex-column-right">
<div class="ui icon right compact buttons flex-column-right">
<div>
<a
v-if="
user &&
permissions &&
permissions.can_view_project &&
isOffline() !== true
"
id="subscribe-button"
class="ui button button-hover-green"
data-tooltip="S'abonner au projet"
data-position="top center"
data-variation="mini"
@click="modalType = 'subscribe'"
>
<i class="inverted grey envelope icon" />
</a>
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
:to="{ name: 'project_edit', params: { slug: project.slug } }"
class="ui button button-hover-orange"
data-tooltip="Modifier le projet"
data-position="top center"
data-variation="mini"
>
<i class="inverted grey pencil alternate icon" />
</router-link>
<a
v-if="
user_permissions &&
user_permissions[project.slug] &&
user_permissions[project.slug].is_project_administrator &&
isOffline() !== true
"
id="delete-button"
class="ui button button-hover-red"
data-tooltip="Supprimer le projet"
data-position="top center"
data-variation="mini"
@click="modalType = 'deleteProject'"
>
<i class="inverted grey trash icon" />
</a>
</div>
<button
v-if="user && user.is_administrator && !isSharedProject && project.generate_share_link"
class="ui teal left labeled icon button"
@click="copyLink"
>
<i class="left icon share square" />
Copier le lien de partage
</button>
</div>
<div v-if="confirmMsg">
<div class="ui positive tiny-margin message">
<span>
Le lien a été copié dans le presse-papier
</span>
&nbsp;
<i
class="close icon"
@click="confirmMsg = ''"
/>
</div>
</div>
</div>
</div>
</div>
<div v-if="arraysOffline.length > 0">
{{ arraysOffline.length }} modification<span v-if="arraysOffline.length>1">s</span> en attente
<button
:disabled="isOffline()"
class="ui fluid labeled teal icon button"
@click="sendOfflineFeatures()"
>
<i class="upload icon" />
Envoyer au serveur
</button>
</div>
</div>
<div class="row">
<div class="seven wide column">
<h3 class="ui header">
Types de signalements
</h3>
<div class="ui middle aligned divided list">
<div
:class="{ active: projectInfoLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des types de signalements en cours...
</div>
</div>
<div
:class="{ active: featureTypeImporting }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Traitement du fichier en cours ...
</div>
</div>
<div
v-for="(type, index) in feature_types"
:key="type.title + '-' + index"
class="item"
>
<div class="feature-type-container">
<router-link
:to="{
name: 'details-type-signalement',
params: { feature_type_slug: type.slug },
}"
class="feature-type-title"
>
<img
v-if="type.geom_type === 'point'"
class="list-image-type"
src="@/assets/img/marker.png"
>
<img
v-if="type.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
>
<img
v-if="type.geom_type === 'polygon'"
class="list-image-type"
src="@/assets/img/polygon.png"
>
{{ type.title }}
</router-link>
<div class="middle aligned content">
<router-link
v-if="
project && permissions && permissions.can_create_feature
"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-green
"
data-tooltip="Ajouter un signalement"
data-position="top right"
data-variation="mini"
>
<i class="ui plus icon" />
</router-link>
<router-link
v-if="
project &&
permissions &&
permissions.can_create_feature_type &&
isOffline() !== true
"
:to="{
name: 'dupliquer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-green
"
data-tooltip="Dupliquer un type de signalement"
data-position="top right"
data-variation="mini"
>
<i class="inverted grey copy alternate icon" />
</router-link>
<div
v-if="isImporting(type)"
class="import-message"
>
<i class="info circle icon" />
Import en cours
</div>
<div
v-else
v-frag
>
<a
v-if="
user_permissions &&
user_permissions[project.slug] &&
user_permissions[project.slug].is_project_administrator &&
isOffline() !== true
"
class="
ui
compact
small
icon
right
floated
button button-hover-red
"
data-tooltip="Supprimer le type de signalement"
data-position="top center"
data-variation="mini"
@click="toggleDeleteFeatureType(type)"
>
<i class="inverted grey trash alternate icon" />
</a>
<router-link
v-if="
project &&
permissions &&
permissions.can_create_feature_type &&
isOffline() != true
"
:to="{
name: 'editer-symbologie-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-orange
"
data-tooltip="Éditer la symbologie du type de signalement"
data-position="top center"
data-variation="mini"
>
<i class="inverted grey paint brush alternate icon" />
</router-link>
<router-link
v-if="
project &&
type.is_editable &&
permissions &&
permissions.can_create_feature_type &&
isOffline() !== true
"
:to="{
name: 'editer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-orange
"
data-tooltip="Éditer le type de signalement"
data-position="top center"
data-variation="mini"
>
<i class="inverted grey pencil alternate icon" />
</router-link>
</div>
</div>
</div>
</div>
<div v-if="feature_types.length === 0">
<i> Le projet ne contient pas encore de type de signalements. </i>
</div>
</div>
<div id="nouveau-type-signalement">
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
:to="{
name: 'ajouter-type-signalement',
params: { slug: project.slug },
}"
class="ui compact basic button button-hover-green"
>
<i class="ui plus icon" />Créer un nouveau type de signalement
</router-link>
</div>
<div class="nouveau-type-signalement">
<div
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
class="
ui
compact
basic
button button-hover-green
button-align-left
"
>
<i class="ui plus icon" />
<label
class="ui"
for="json_file"
>
<span
class="label"
>Créer un nouveau type de signalement à partir d'un
GeoJSON</span>
</label>
<input
id="json_file"
type="file"
accept="application/json, .json, .geojson"
style="display: none"
name="json_file"
@change="onFileChange"
>
</div>
</div>
<div class="nouveau-type-signalement">
<router-link
v-if="
IDGO &&
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
:to="{
name: 'catalog-import',
params: {
slug: project.slug,
feature_type_slug: 'create'
},
}"
class="ui compact basic button button-hover-green button-align-left"
>
<i class="ui plus icon" />
Créer un nouveau type de signalement à partir du catalogue {{ CATALOG_NAME|| 'IDGO' }}
</router-link>
</div>
<div
v-if="fileToImport.size > 0"
id="button-import"
>
<button
:disabled="fileToImport.size === 0"
class="ui fluid teal icon button"
@click="toNewFeatureType"
>
<i class="upload icon" /> Lancer l'import avec le fichier
{{ fileToImport.name }}
</button>
</div>
</div>
<div class="seven wide column">
<div
:class="{ active: mapLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Chargement de la carte...
</div>
</div>
<div
id="map"
ref="map"
/>
</div>
</div>
<div class="row">
<div class="fourteen wide column">
<div class="ui two stackable cards">
<div class="red card">
<div class="content">
<div class="center aligned header">
Derniers signalements
</div>
<div class="center aligned description">
<div
:class="{ active: featuresLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des signalements en cours...
</div>
</div>
<div class="ui relaxed list">
<div
v-for="(item, index) in features.slice(-5)"
:key="item.properties.title + index"
class="item"
>
<div class="content">
<div>
<router-link
:to="{
name: 'details-signalement',
params: {
slug: project.slug,
slug_type_signal:
item.properties.feature_type.slug,
slug_signal: item.id,
},
}"
>
{{ item.properties.title || item.id }}
</router-link>
</div>
<div class="description">
<i>
[{{ item.properties.created_on }}
<span v-if="user && item.properties.creator">
, par
{{
item.properties.creator.full_name
? item.properties.creator.full_name
: item.properties.creator.username
}}
</span>
]
</i>
</div>
</div>
</div>
<i
v-if="features.length === 0"
>Aucun signalement pour le moment.</i>
</div>
</div>
</div>
</div>
<div class="orange card">
<div class="content">
<div class="center aligned header">
Derniers commentaires
</div>
<div class="center aligned description">
<div
:class="{ active: projectInfoLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des commentaires en cours...
</div>
</div>
<div class="ui relaxed list">
<div
v-for="(item, index) in last_comments"
:key="'comment ' + index"
class="item"
>
<div class="content">
<div>
<router-link
:to="getRouteUrl(item.related_feature.feature_url)"
>
"{{ item.comment }}"
</router-link>
</div>
<div class="description">
<i>[ {{ item.created_on
}}<span
v-if="user && item.display_author"
>, par {{ item.display_author }}
</span>
]</i>
</div>
</div>
</div>
<i
v-if="!last_comments || last_comments.length === 0"
>Aucun commentaire pour le moment.</i>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="fourteen wide column">
<div class="ui grey segment">
<h3 class="ui header">
Paramètres du projet
</h3>
<div class="ui five stackable cards">
<div class="card">
<div class="center aligned content">
<h4 class="ui center aligned icon header">
<i class="disabled grey archive icon" />
<div class="content">
Délai avant archivage automatique
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.archive_feature }} jours
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey trash alternate icon" />
<div class="content">
Délai avant suppression automatique
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.delete_feature }} jours
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey eye icon" />
<div class="content">
Visibilité des signalements publiés
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.access_level_pub_feature }}
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey eye icon" />
<div class="content">
Visibilité des signalements archivés
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.access_level_arch_feature }}
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey cogs icon" />
<div class="content">
Modération
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.moderation ? "Oui" : "Non" }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<span v-else>
<i class="icon exclamation triangle" />
<span>Vous ne disposez pas des droits nécessaires pour consulter ce
projet.</span>
</span>
<div
v-if="modalType"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
:class="[
'ui mini modal subscription',
{ 'transition visible active': modalType },
]"
>
<i
class="close icon"
@click="modalType = false"
/>
<div class="ui icon header">
<i :class="[modalType === 'subscribe' ? 'envelope' : 'trash', 'icon']" />
{{
modalType === 'subscribe' ? 'Notifications' : 'Suppression'
}} du {{
modalType === 'deleteFeatureType' ? 'type de signalement ' + featureTypeToDelete.title : 'projet'
}}
</div>
<div class="content">
<div v-if="modalType !== 'subscribe'">
<p class="centered-text">
Confirmez vous la suppression du {{
modalType === 'deleteProject' ?
'projet, ainsi que les types de signalements' :
'type de signalement'
}} et tous les signalements associés&nbsp;?
</p>
<p class="centered-text alert">
Attention cette action est irreversible !
</p>
</div>
<button
:class="['ui compact fluid button', modalType === 'subscribe' && !is_suscriber ? 'green' : 'red']"
@click="handleModalClick"
>
<span v-if="modalType === 'subscribe'">
{{
is_suscriber
? "Se désabonner de ce projet"
: "S'abonner à ce projet"
}}
</span>
<span v-else>
Supprimer le
{{
modalType === 'deleteProject'
? 'projet'
: 'type de signalement'
}}
</span>
</button>
</div>
</div>
</div>
<div
:class="isFileSizeModalOpen ? 'active' : ''"
class="ui dimmer"
>
<div
:class="isFileSizeModalOpen ? 'active' : ''"
class="ui modal tiny"
style="top: 20%"
>
<div class="header">
Fichier trop grand!
</div>
<div class="content">
<p>
Impossible de créer un type de signalement à partir d'un fichier
GeoJSON de plus de 10Mo (celui importé fait {{ fileSize }} Mo).
</p>
</div>
<div class="actions">
<div
class="ui button teal"
@click="closeFileSizeModal"
>
Fermer
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import frag from 'vue-frag';
import { mapUtil } from '@/assets/js/map-util.js';
import { mapGetters, mapState, mapActions, mapMutations } from 'vuex';
import projectAPI from '@/services/project-api';
import featureTypeAPI from '@/services/featureType-api';
import featureAPI from '@/services/feature-api';
import axios from '@/axios-client.js';
import { fileConvertSizeToMo } from '@/assets/js/utils';
export default {
name: 'ProjectDetails',
directives: {
frag,
},
filters: {
setDate(value) {
const date = new Date(value);
const d = date.toLocaleDateString('fr', {
year: '2-digit',
month: 'numeric',
day: 'numeric',
});
return d;
},
},
props: {
message: {
type: String,
default: ''
}
},
data() {
return {
infoMessage: '',
importMessage: null,
arraysOffline: [],
arraysOfflineErrors: [],
confirmMsg: false,
geojsonImport: [],
fileToImport: { name: '', size: 0 },
slug: this.$route.params.slug,
modalType: false,
is_suscriber: false,
tempMessage: null,
projectInfoLoading: true,
featureTypeImporting: false,
featureTypeToDelete: null,
featuresLoading: true,
isFileSizeModalOpen: false,
// mapFeatures: null,
mapLoading: true,
};
},
computed: {
...mapGetters([
'permissions'
]),
...mapGetters('projects', [
'project'
]),
...mapState([
'configuration',
]),
...mapState('feature_type', [
'feature_types',
'importFeatureTypeData'
]),
...mapState('feature', [
'features'
]),
...mapState([
'last_comments',
'user',
'user_permissions',
'reloadIntervalId',
]),
...mapState('map', [
'map'
]),
DJANGO_BASE_URL() {
return this.configuration.VUE_APP_DJANGO_BASE;
},
API_BASE_URL() {
return this.configuration.VUE_APP_DJANGO_API_BASE;
},
CATALOG_NAME() {
return this.configuration.VUE_APP_CATALOG_NAME;
},
IDGO() {
return this.$store.state.configuration.VUE_APP_IDGO;
},
fileSize() {
return fileConvertSizeToMo(this.fileToImport.size);
},
isSharedProject() {
return this.$route.path.includes('projet-partage');
}
},
watch: {
feature_types: {
deep: true,
handler(newValue, oldValue) {
if (newValue && newValue !== oldValue) {
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
});
}
},
},
importFeatureTypeData: {
deep: true,
handler(newValue) {
if (
newValue &&
newValue.some((el) => el.status === 'pending') &&
!this.reloadIntervalId
) {
this.SET_RELOAD_INTERVAL_ID(
setInterval(() => {
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
});
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL)
);
} else if (
newValue &&
!newValue.some((el) => el.status === 'pending') &&
this.reloadIntervalId
) {
this.GET_PROJECT_FEATURE_TYPES(this.project.slug);
this.CLEAR_RELOAD_INTERVAL_ID();
}
},
},
},
created() {
if (this.user) {
projectAPI
.getProjectSubscription({
baseUrl: this.$store.state.configuration.VUE_APP_DJANGO_API_BASE,
projectSlug: this.$route.params.slug
})
.then((data) => (this.is_suscriber = data.is_suscriber));
}
this.$store.commit('feature/SET_FEATURES', []); //* empty features remaining in case they were in geojson format and will be fetch after map initialization anyway
this.$store.commit('feature_type/SET_FEATURE_TYPES', []); //* empty feature_types remaining from previous project
},
mounted() {
this.retrieveProjectInfo();
if (this.message) {
this.tempMessage = this.message;
document
.getElementById('message')
.scrollIntoView({ block: 'end', inline: 'nearest' });
setTimeout(() => (this.tempMessage = null), 5000); //* hide message after 5 seconds
}
},
destroyed() {
this.CLEAR_RELOAD_INTERVAL_ID();
},
methods: {
...mapMutations([
'SET_RELOAD_INTERVAL_ID',
'CLEAR_RELOAD_INTERVAL_ID',
'DISPLAY_MESSAGE',
]),
...mapActions([
'GET_PROJECT_INFO',
'GET_ALL_PROJECTS',
]),
...mapActions('map', [
'INITIATE_MAP'
]),
...mapActions('feature_type', [
'GET_IMPORTS'
]),
...mapActions('feature', [
'GET_PROJECT_FEATURES'
]),
...mapActions('feature_type', [
'GET_PROJECT_FEATURE_TYPES'
]),
refreshId() {
return '?ver=' + Math.random();
},
getRouteUrl(url) {
if (this.isSharedProject) {
url = url.replace('projet', 'projet-partage');
}
return url.replace(this.$store.state.configuration.BASE_URL, ''); //* remove duplicate /geocontrib
},
isOffline() {
return navigator.onLine === false;
},
isImporting(type) {
if (this.importFeatureTypeData) {
const singleImportData = this.importFeatureTypeData.find(
(el) => el.feature_type_title === type.slug
);
return singleImportData && singleImportData.status === 'pending';
}
return false;
},
copyLink() {
const sharedLink = window.location.href.replace('projet', 'projet-partage');
navigator.clipboard.writeText(sharedLink).then(()=> {
console.log('success');
this.confirmMsg = true;
}, () => {
console.log('failed');
}
);
},
retrieveProjectInfo() {
this.GET_PROJECT_INFO(this.slug)
.then(() => {
this.projectInfoLoading = false;
setTimeout(() => {
let map = mapUtil.getMap();
if (map) map.remove();
this.initMap();
}, 1000);
})
.catch((err) => {
console.error(err);
this.projectInfoLoading = false;
});
},
checkForOfflineFeature() {
let arraysOffline = [];
let localStorageArray = localStorage.getItem('geocontrib_offline');
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
this.arraysOffline = arraysOffline.filter(
(x) => x.project === this.project.slug
);
}
},
sendOfflineFeatures() {
var promises = [];
let self = this;
this.arraysOfflineErrors = [];
this.arraysOffline.forEach((feature) => {
if (feature.type === 'post') {
promises.push(
axios
.post(`${this.API_BASE_URL}features/`, feature.geojson)
.then((response) => {
if (response.status === 201 && response.data) {
return 'OK';
} else {
self.arraysOfflineErrors.push(feature);
}
})
.catch((error) => {
console.error(error);
self.arraysOfflineErrors.push(feature);
})
);
} else if (feature.type === 'put') {
promises.push(
axios
.put(
`${this.API_BASE_URL}features/${feature.featureId}`,
feature.geojson
)
.then((response) => {
if (response.status === 200 && response.data) {
return 'OK';
} else {
self.arraysOfflineErrors.push(feature);
}
})
.catch((error) => {
console.error(error);
self.arraysOfflineErrors.push(feature);
})
);
}
});
Promise.all(promises).then(() => {
this.updateLocalStorage();
window.location.reload();
});
},
updateLocalStorage() {
let arraysOffline = [];
let localStorageArray = localStorage.getItem('geocontrib_offline');
if (localStorageArray) {
arraysOffline = JSON.parse(localStorageArray);
}
let arraysOfflineOtherProject = arraysOffline.filter(
(x) => x.project !== this.project.slug
);
this.arraysOffline = [];
arraysOffline = arraysOfflineOtherProject.concat(
this.arraysOfflineErrors
);
localStorage.setItem('geocontrib_offline', JSON.stringify(arraysOffline));
},
toNewFeatureType() {
this.featureTypeImporting = true;
this.$router.push({
name: 'ajouter-type-signalement',
params: {
geojson: this.geojsonImport,
fileToImport: this.fileToImport,
},
});
this.featureTypeImporting = false;
},
onFileChange(e) {
this.featureTypeImporting = true;
var files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
this.fileToImport = files[0];
// TODO : VALIDATION IF FILE IS JSON
if (parseFloat(fileConvertSizeToMo(this.fileToImport.size)) > 10) {
this.isFileSizeModalOpen = true;
} else if (this.fileToImport.size > 0) {
const fr = new FileReader();
try {
fr.onload = (e) => {
this.geojsonImport = JSON.parse(e.target.result);
this.featureTypeImporting = false;
};
fr.readAsText(this.fileToImport);
//* stock filename to import features afterward
this.$store.commit(
'feature_type/SET_FILE_TO_IMPORT',
this.fileToImport
);
} catch (err) {
console.error(err);
this.featureTypeImporting = false;
}
} else {
this.featureTypeImporting = false;
}
},
closeFileSizeModal() {
this.fileToImport = { name: '', size: 0 };
this.featureTypeImporting = false;
this.isFileSizeModalOpen = false;
},
subscribeProject() {
projectAPI
.subscribeProject({
baseUrl: this.$store.state.configuration.VUE_APP_DJANGO_API_BASE,
suscribe: !this.is_suscriber,
projectSlug: this.$route.params.slug,
})
.then((data) => {
this.is_suscriber = data.is_suscriber;
this.modalType = false;
if (this.is_suscriber)
this.infoMessage =
'Vous êtes maintenant abonné aux notifications de ce projet.';
else
this.infoMessage =
'Vous ne recevrez plus les notifications de ce projet.';
setTimeout(() => (this.infoMessage = ''), 3000);
});
},
deleteProject() {
projectAPI.deleteProject(this.API_BASE_URL, this.project.slug)
.then((response) => {
if (response === 'success') {
this.GET_ALL_PROJECTS();
this.$router.push('/');
this.DISPLAY_MESSAGE(`Le projet ${this.project.title} a bien été supprimé.`);
} else {
this.DISPLAY_MESSAGE(`Une erreur est survenu lors de la suppression du projet ${this.project.title}.`);
}
});
},
deleteFeatureType() {
featureTypeAPI.deleteFeatureType(this.featureTypeToDelete.slug)
.then((response) => {
this.modalType = false;
if (response === 'success') {
this.GET_ALL_PROJECTS();
this.retrieveProjectInfo();
this.DISPLAY_MESSAGE(`Le type de signalement ${this.featureTypeToDelete.title} a bien été supprimé.`);
} else {
this.DISPLAY_MESSAGE(`Une erreur est survenu lors de la suppression du type de signalement ${this.featureTypeToDelete.title}.`);
}
this.featureTypeToDelete = null;
});
},
handleModalClick() {
switch (this.modalType) {
case 'subscribe':
this.subscribeProject();
break;
case 'deleteProject':
this.deleteProject();
break;
case 'deleteFeatureType':
this.deleteFeatureType();
break;
}
},
toggleDeleteFeatureType(featureType) {
this.featureTypeToDelete = featureType;
this.modalType = 'deleteFeatureType';
},
async initMap() {
if (this.project && this.permissions.can_view_project) {
await this.INITIATE_MAP(this.$refs.map);
this.checkForOfflineFeature();
let project_id = this.$route.params.slug.split('-')[0];
const mvtUrl = `${this.API_BASE_URL}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`;
mapUtil.addVectorTileLayer(
mvtUrl,
this.$route.params.slug,
this.$store.state.feature_type.feature_types
);
this.arraysOffline.forEach((x) => (x.geojson.properties.color = 'red'));
const features = this.arraysOffline.map((x) => x.geojson);
mapUtil.addFeatures(
features,
{},
true,
this.$store.state.feature_type.feature_types
);
this.mapLoading = false;
this.GET_PROJECT_FEATURES({
project_slug: this.slug,
ordering: '-created_on',
limit: null,
geojson: true,
})
.then(() => {
this.featuresLoading = false;
})
.catch((err) => {
console.error(err);
this.featuresLoading = false;
});
featureAPI.getFeaturesBbox(this.project.slug).then((bbox) => {
if (bbox) {
mapUtil.getMap().fitBounds(bbox, { padding: [25, 25] });
}
});
}
},
},
};
</script>
<style>
@import "../../assets/resources/semantic-ui-2.4.2/semantic.min.css";
#map {
width: 100%;
height: 100%;
min-height: 250px;
}
/* // ! missing style in semantic.min.css, je ne comprends pas comment... */
.ui.right.floated.button {
float: right;
}
</style>
<style scoped>
.list-image-type {
margin-right: 5px;
height: 25px;
vertical-align: bottom;
}
.feature-type-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.feature-type-container > .middle.aligned.content {
width: 50%;
}
.feature-type-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.5em;
}
.nouveau-type-signalement {
margin-top: 1em;
}
.nouveau-type-signalement .label{
cursor: pointer;
}
#button-import {
margin-top: 0.5em;
}
.fullwidth {
width: 100%;
}
.button-align-left {
display: flex;
align-items: center;
text-align: left;
width: fit-content;
}
.space-between {
justify-content: space-between;
}
.flex-column-right {
flex-direction: column !important;
align-items: flex-end;
}
.import-message {
width: fit-content;
line-height: 2em;
color: teal;
}
</style>
<style scoped>
.ui.button, .ui.button .button, .tiny-margin {
margin: 0.1rem 0 0.1rem 0.1rem !important;
}
.alert {
color: red;
}
.centered-text {
text-align: center;
}
</style>
<template>
<div class="row">
<div class="seven wide column">
<h3 class="ui header">
Créer un projet à partir d'un modèle disponible:
</h3>
<div class="ui divided items">
<div
v-for="project in project_types"
:key="project.slug"
class="item"
>
<div class="ui tiny image">
<img
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
>
</div>
<div class="middle aligned content">
<div class="description">
<router-link
:to="{
name: 'project_create_from',
params: {
slug: project.slug,
},
}"
>
{{ project.title }}
</router-link>
<p>{{ project.description }}</p>
<strong v-if="project.moderation">Projet modéré</strong>
<strong v-else>Projet non modéré</strong>
</div>
<div class="meta">
<span data-tooltip="Délai avant archivage">
{{ project.archive_feature }}&nbsp;<i class="box icon" />
</span>
<span data-tooltip="Délai avant suppression">
{{ project.archive_feature }}&nbsp;<i
class="trash alternate icon"
/>
</span>
<span data-tooltip="Date de création">
{{ project.created_on }}&nbsp;<i class="calendar icon" />
</span>
</div>
<div class="meta">
<span data-tooltip="Visibilité des signalement publiés">
{{ project.access_level_pub_feature }}&nbsp;<i
class="eye icon"
/>
</span>
<span data-tooltip="Visibilité des signalement archivés">
{{ project.access_level_arch_feature }}&nbsp;<i
class="archive icon"
/>
</span>
</div>
</div>
</div>
<span
v-if="!project_types || project_types.length === 0"
>Aucun projet type n'est défini.</span>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
name: 'ProjectTypeList',
computed: {
...mapGetters(['project_types']),
DJANGO_BASE_URL: function () {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
},
methods: {
refreshId() {
return '?ver=' + Math.random();
},
},
};
</script>
\ No newline at end of file
<template>
<div>
<div class="row">
<div class="fourteen wide column">
<img
class="ui centered small image"
:src="logo"
>
<h2 class="ui center aligned icon header">
<div class="content">
{{ APPLICATION_NAME }}
<div class="sub header">
{{ APPLICATION_ABSTRACT }}
</div>
</div>
</h2>
</div>
</div>
<div class="row">
<div class="six wide column">
<h3 class="ui horizontal divider header">
CONNEXION
</h3>
<div
v-if="form.errors"
class="ui warning message"
>
<div class="header">
Les informations d'identification sont incorrectes.
</div>
NB: Seuls les comptes actifs peuvent se connecter.
</div>
<form
class="ui form"
role="form"
type="post"
@submit.prevent="login"
>
<div class="ui stacked secondary segment">
<div class="six field required">
<div class="ui left icon input">
<i class="user icon" />
<input
v-model="username_value"
type="text"
name="username"
placeholder="Utilisateur"
>
</div>
</div>
<div class="six field required">
<div class="ui left icon input">
<i class="lock icon" />
<input
v-model="password_value"
type="password"
name="password"
placeholder="Mot de passe"
>
</div>
</div>
<button
class="ui fluid large teal submit button"
type="submit"
>
Login
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Login',
data() {
return {
username_value: null,
password_value: null,
logged: false,
form: {
errors: null,
},
};
},
computed: {
logo() {
return this.$store.state.configuration.VUE_APP_LOGO_PATH;
},
APPLICATION_NAME() {
return this.$store.state.configuration.VUE_APP_APPLICATION_NAME;
},
APPLICATION_ABSTRACT() {
return this.$store.state.configuration.VUE_APP_APPLICATION_ABSTRACT;
},
},
mounted() {
if (this.$store.state.user) {
this.$store.commit(
'DISPLAY_MESSAGE',
"Vous êtes déjà connecté, vous allez être redirigé vers la page d'accueil."
);
setTimeout(() => this.$router.push('/'), 3100);
}
},
methods: {
login() {
this.$store
.dispatch('LOGIN', {
username: this.username_value,
password: this.password_value,
})
.then((status) => {
if (status === 200) {
this.form.errors = null;
} else if (status === 'error') {
this.form.errors = status;
}
})
.catch();
},
},
};
</script>
\ No newline at end of file
......@@ -2,6 +2,7 @@ const webpack = require('webpack');
const fs = require('fs');
const packageJson = fs.readFileSync('./package.json');
const version = JSON.parse(packageJson).version || 0;
module.exports = {
publicPath: '/geocontrib/',
devServer: {
......@@ -34,6 +35,7 @@ module.exports = {
themeColor: '#1da025'
},
configureWebpack: {
devtool: 'source-map',
plugins: [
new webpack.DefinePlugin({
'process.env': {
......@@ -42,5 +44,10 @@ module.exports = {
})
]
},
// the rest of your original module.exports code goes here
transpileDependencies: [
// Add dependencies that use modern JavaScript syntax, based on encountered errors
'ol',
'color-rgba',
'color-parse'
]
};
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.vue$/,
loader: 'vue-loader'
}
]
}
};
\ No newline at end of file