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 3976 additions and 2611 deletions
<template> <template>
<div <div id="account">
id="account"
class="page"
>
<h1>Mon compte</h1> <h1>Mon compte</h1>
<div class="ui stackable grid"> <div class="ui stackable grid">
<div class="five wide column"> <div class="five wide column">
<UserProfile /> <UserProfile />
<UserActivity />
</div> </div>
<div class="eleven wide column"> <div class="eleven wide column">
<UserProjectsList /> <UserProjectsList />
</div> </div>
<div class="sixteen wide column">
<UserActivity />
</div>
</div> </div>
</div> </div>
</template> </template>
......
<template>
<div
v-if="(configuration.VUE_APP_SSO_LOGIN_URL_WITH_REDIRECT && !user) || !currentFeature"
class="no-access"
>
<h3>
🔒&nbsp;Vous n'avez pas accès à ce signalement
<span v-if="!user"> en tant qu'utilisateur anonyme&nbsp;🥸</span>
</h3>
<p v-if="!user">
Veuillez vous connectez afin de pouvoir visualiser le document
</p>
</div>
<div
v-else
:class="['preview', { is_pdf }]"
>
<embed
v-if="is_pdf"
:src="src"
type="application/pdf"
>
<div v-else>
<img
:src="src"
alt="Aperçu de l'image"
>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'AttachmentPreview',
computed: {
...mapState([
'configuration',
'user'
]),
...mapState('feature', [
'currentFeature'
]),
src() {
return this.$route.query.file;
},
is_pdf() {
return this.src && this.src.includes('pdf');
},
},
watch: {
user() {
/**
* Specific for platform with login by token
* When the user is setted, fetching again the feature with the cookies setted
* since automatic authentification can take time to return the response
* setting the cookies, while the app is loading already
*/
this.getFeature();
}
},
mounted() {
this.getFeature();
},
methods: {
getFeature() {
console.log('getFeature'); // Keeping for debugging after deployment
this.$store.dispatch('feature/GET_PROJECT_FEATURE', {
project_slug: this.$route.params.slug,
feature_id: this.$route.params.slug_signal,
});
}
}
};
</script>
<style scoped lang="less">
.no-access {
> h1 {
margin: 2em 0;
}
height: 60vh;
display: flex;
justify-content: center;
flex-direction: column;
> * {
text-align: center;
}
}
.preview {
width: 100vw;
&.is_pdf {
padding: 0;
@media screen and (min-width: 726px) {
height: calc(100vh - 70px - 1em);
margin: .5em auto;
box-shadow: 1px 2px 10px grey;
}
@media screen and (max-width: 725px) {
height: calc(100vh - 110px);
margin: 0 auto;
}
}
> * {
height: 100%;
width: 100%;
}
> div {
display: flex;
justify-content: center;
img {
max-width: 100%;
}
}
}
</style>
\ No newline at end of file
<template> <template>
<div <div id="feature-detail">
id="feature-detail"
class="page"
>
<div <div
v-if="currentFeature" v-if="currentFeature"
class="ui grid" class="ui grid stackable"
> >
<div class="row"> <div class="row">
<div class="sixteen wide column"> <div class="sixteen wide column">
<FeatureHeader /> <FeatureHeader
v-if="project"
:features-count="featuresCount"
:slug-signal="slugSignal"
:feature-type="feature_type"
:fast-edition-mode="project.fast_edition_mode"
:is-feature-creator="isFeatureCreator"
:can-edit-feature="canEditFeature"
:can-delete-feature="canDeleteFeature"
@fastEditFeature="validateFastEdition"
@setIsDeleting="isDeleting = true"
@tofeature="pushNgo"
/>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="eight wide column"> <div class="eight wide column">
<FeatureTable <FeatureTable
@pushNGo="pushNgo" v-if="project"
ref="featureTable"
:feature-type="feature_type"
:fast-edition-mode="project.fast_edition_mode"
:can-edit-feature="canEditFeature"
/> />
</div> </div>
<div class="eight wide column"> <div
<div v-if="feature_type && feature_type.geom_type !== 'none'"
id="map" class="eight wide column"
ref="map" >
/> <div class="map-container">
<div
id="map"
ref="map"
>
<SidebarLayers
v-if="basemaps && map"
ref="sidebar"
/>
<Geolocation />
</div>
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
...@@ -34,32 +71,38 @@ ...@@ -34,32 +71,38 @@
<div class="eight wide column"> <div class="eight wide column">
<FeatureComments <FeatureComments
:events="events" :events="events"
:enable-key-doc-notif="feature_type.enable_key_doc_notif"
@fetchEvents="getFeatureEvents"
/> />
</div> </div>
</div> </div>
<div <div
v-if="isCanceling" v-if="isDeleting"
class="ui dimmer modals page transition visible active" class="ui dimmer modals visible active"
style="display: flex !important"
> >
<div <div
:class="[ :class="[
'ui mini modal subscription', 'ui mini modal',
{ 'active visible': isCanceling }, { 'active visible': isDeleting },
]" ]"
> >
<i <i
class="close icon" class="close icon"
aria-hidden="true" aria-hidden="true"
@click="isCanceling = false" @click="isDeleting = false"
/> />
<div class="ui icon header"> <div
v-if="isDeleting"
class="ui icon header"
>
<i <i
class="trash alternate icon" class="trash alternate icon"
aria-hidden="true" aria-hidden="true"
/> />
Supprimer le signalement Supprimer le signalement
</div> </div>
<div class="actions"> <div class="actions">
<button <button
type="button" type="button"
...@@ -71,7 +114,60 @@ ...@@ -71,7 +114,60 @@
</div> </div>
</div> </div>
</div> </div>
<div
v-if="isLeaving"
class="ui dimmer modals visible active"
>
<div
:class="[
'ui mini modal',
{ 'active visible': isLeaving },
]"
>
<i
class="close icon"
aria-hidden="true"
@click="isLeaving = false"
/>
<div class="ui icon header">
<i
class="sign-out icon"
aria-hidden="true"
/>
Abandonner les modifications
</div>
<div class="content">
Les modifications apportées au signalement ne seront pas sauvegardées, continuer ?
</div>
<div class="actions">
<button
type="button"
class="ui green compact button"
@click="stayOnPage"
>
<i
class="close icon"
aria-hidden="true"
/>
Annuler
</button>
<button
type="button"
class="ui red compact button"
@click="leavePage"
>
Continuer
<i
class="arrow right icon"
aria-hidden="true"
/>
</button>
</div>
</div>
</div>
</div> </div>
<div v-else> <div v-else>
Pas de signalement correspondant trouvé Pas de signalement correspondant trouvé
</div> </div>
...@@ -79,8 +175,9 @@ ...@@ -79,8 +175,9 @@
</template> </template>
<script> <script>
import { mapState, mapActions, mapMutations } from 'vuex'; import { isEqual } from 'lodash';
import { mapUtil } from '@/assets/js/map-util.js'; import { mapState, mapActions, mapMutations, mapGetters } from 'vuex';
import mapService from '@/services/map-service';
import axios from '@/axios-client.js'; import axios from '@/axios-client.js';
import featureAPI from '@/services/feature-api'; import featureAPI from '@/services/feature-api';
...@@ -89,6 +186,10 @@ import FeatureHeader from '@/components/Feature/Detail/FeatureHeader'; ...@@ -89,6 +186,10 @@ import FeatureHeader from '@/components/Feature/Detail/FeatureHeader';
import FeatureTable from '@/components/Feature/Detail/FeatureTable'; import FeatureTable from '@/components/Feature/Detail/FeatureTable';
import FeatureAttachements from '@/components/Feature/Detail/FeatureAttachements'; import FeatureAttachements from '@/components/Feature/Detail/FeatureAttachements';
import FeatureComments from '@/components/Feature/Detail/FeatureComments'; import FeatureComments from '@/components/Feature/Detail/FeatureComments';
import SidebarLayers from '@/components/Map/SidebarLayers';
import Geolocation from '@/components/Map/Geolocation';
import { buffer } from 'ol/extent';
export default { export default {
name: 'FeatureDetail', name: 'FeatureDetail',
...@@ -97,7 +198,25 @@ export default { ...@@ -97,7 +198,25 @@ export default {
FeatureHeader, FeatureHeader,
FeatureTable, FeatureTable,
FeatureAttachements, FeatureAttachements,
FeatureComments FeatureComments,
SidebarLayers,
Geolocation,
},
beforeRouteUpdate (to, from, next) {
if (this.hasUnsavedChange && !this.isSavingChanges) {
this.confirmLeave(next); // prompt user that there is unsaved changes or that features order might change
} else {
next(); // continue navigation
}
},
beforeRouteLeave (to, from, next) {
if (this.hasUnsavedChange && !this.isSavingChanges) {
this.confirmLeave(next); // prompt user that there is unsaved changes or that features order might change
} else {
next(); // continue navigation
}
}, },
data() { data() {
...@@ -117,63 +236,130 @@ export default { ...@@ -117,63 +236,130 @@ export default {
}, },
}, },
events: [], events: [],
isCanceling: false, featuresCount: null,
projectSlug: this.$route.params.slug, isDeleting: false,
isLeaving: false,
isSavingChanges: false,
map: null,
slug: this.$route.params.slug,
slugSignal: '',
}; };
}, },
computed: { computed: {
...mapState([
'USER_LEVEL_PROJECTS',
'user'
]),
...mapState('projects', [ ...mapState('projects', [
'project' 'project'
]), ]),
...mapState('feature-type', [ ...mapState('feature-type', [
'feature_types' 'feature_types',
]), ]),
...mapState('feature', [ ...mapState('feature', [
'currentFeature' 'currentFeature',
'form',
]),
...mapGetters('feature-type', [
'feature_type',
]), ]),
...mapGetters([
'permissions',
]),
...mapState('map', [
'basemaps',
]),
/**
* Checks if there are any unsaved changes in the form compared to the current feature's properties.
* This function is useful for prompting the user before they navigate away from a page with unsaved changes.
*
* @returns {boolean} - Returns true if there are unsaved changes; otherwise, returns false.
*/
hasUnsavedChange() {
// Ensure we are in edition mode and all required objects are present.
if (this.project && this.project.fast_edition_mode &&
this.form && this.currentFeature && this.currentFeature.properties) {
// Check for changes in title, description, and status.
if (this.form.title !== this.currentFeature.properties.title) return true;
if (this.form.description.value !== this.currentFeature.properties.description) return true;
if (this.form.status.value !== this.currentFeature.properties.status) return true;
if (this.form.assigned_member.value !== this.currentFeature.properties.assigned_member) return true;
// Iterate over extra forms to check for any changes.
for (const xForm of this.$store.state.feature.extra_forms) {
const originalValue = this.currentFeature.properties[xForm.name];
// Check if the form value has changed, considering edge cases for undefined, null, or empty values.
if (
!isEqual(xForm.value, originalValue) && // Check if values have changed.
!(!xForm.value && !originalValue) // Ensure both aren't undefined/null/empty, treating null as equivalent to false for unactivated conditionals or unset booleans.
) {
// Log the difference for debugging purposes.
console.log(`In custom form [${xForm.name}], the current form value [${xForm.value}] differs from original value [${originalValue}]`);
return true;
}
}
}
// If none of the above conditions are met, return false indicating no unsaved changes.
return false;
},
isFeatureCreator() {
if (this.currentFeature && this.currentFeature.properties && this.user) {
return this.currentFeature.properties.creator === this.user.id ||
this.currentFeature.properties.creator.username === this.user.username;
}
return false;
},
isModerator() {
return this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.slug] === 'Modérateur';
},
isAdministrator() {
return this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.slug] === 'Administrateur projet';
},
canEditFeature() {
return (this.permissions && this.permissions.can_update_feature) ||
this.isFeatureCreator ||
this.isModerator ||
this.user.is_superuser;
},
canDeleteFeature() {
return (this.permissions && this.permissions.can_delete_feature && this.isFeatureCreator) ||
this.isFeatureCreator ||
this.isModerator ||
this.isAdministrator ||
this.user.is_superuser;
},
},
watch: {
/**
* To navigate back or forward to the previous or next URL, the query params in url are updated
* since the route doesn't change, mounted is not called, then the page isn't updated
* To reload page infos we need to call initPage() when query changes
*/
'$route.query'(newValue, oldValue) {
if (newValue !== oldValue) {
this.initPage();
}
},
}, },
created() { created() {
this.SET_CURRENT_FEATURE_TYPE_SLUG(this.$route.params.slug_type_signal); this.$store.dispatch('GET_USERS_GROUPS'); // récupére les groupes d'utilisateurs pour extra_forms
this.getFeatureEvents();
this.getFeatureAttachments();
this.getLinkedFeatures();
}, },
mounted() { mounted() {
this.DISPLAY_LOADER('Recherche du signalement'); this.initPage();
if (!this.project) {
// Chargements des features et infos projet en cas d'arrivée directe sur la page ou de refresh
axios.all([
this.GET_PROJECT(this.$route.params.slug),
this.GET_PROJECT_INFO(this.$route.params.slug),
this.GET_PROJECT_FEATURE({
project_slug: this.$route.params.slug,
feature_id: this.$route.params.slug_signal
})])
.then(() => {
this.DISCARD_LOADER();
this.initMap();
});
}
if (!this.currentFeature || this.currentFeature.feature_id !== this.$route.params.slug_signal) {
this.GET_PROJECT_FEATURE({
project_slug: this.$route.params.slug,
feature_id: this.$route.params.slug_signal
})
.then(() => {
this.DISCARD_LOADER();
this.initMap();
});
} else {
this.DISCARD_LOADER();
this.initMap();
}
}, },
beforeDestroy() { beforeDestroy() {
this.$store.commit('CLEAR_MESSAGES'); this.$store.commit('CLEAR_MESSAGES');
this.$store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
}, },
methods: { methods: {
...@@ -181,6 +367,9 @@ export default { ...@@ -181,6 +367,9 @@ export default {
'DISPLAY_LOADER', 'DISPLAY_LOADER',
'DISCARD_LOADER' 'DISCARD_LOADER'
]), ]),
...mapMutations('feature', [
'SET_CURRENT_FEATURE'
]),
...mapMutations('feature-type', [ ...mapMutations('feature-type', [
'SET_CURRENT_FEATURE_TYPE_SLUG' 'SET_CURRENT_FEATURE_TYPE_SLUG'
]), ]),
...@@ -193,158 +382,245 @@ export default { ...@@ -193,158 +382,245 @@ export default {
'GET_PROJECT_FEATURES' 'GET_PROJECT_FEATURES'
]), ]),
pushNgo(link) { async initPage() {
this.$router.push({ await this.getPageInfo();
name: 'details-signalement', if(this.feature_type && this.feature_type.geom_type === 'none') {
params: { // setting map to null to ensure map would be created when navigating next to a geographical feature
slug_type_signal: link.feature_to.feature_type_slug, this.map = null;
slug_signal: link.feature_to.feature_id, } else if (this.currentFeature) {
}, this.initMap();
}); }
this.getFeatureEvents(); },
this.getFeatureAttachments();
this.getLinkedFeatures(); async getPageInfo() {
this.addFeatureToMap(); if (this.$route.params.slug_signal && this.$route.params.slug_type_signal) { // if coming from the route with an id
this.SET_CURRENT_FEATURE_TYPE_SLUG(this.$route.params.slug_type_signal);
this.slugSignal = this.$route.params.slug_signal;
} //* else it would be retrieve after fetchFilteredFeature with offset
this.DISPLAY_LOADER('Recherche du signalement');
let promises = [];
//* Récupération du projet, en cas d'arrivée directe sur la page ou de refresh
if (!this.project) {
promises.push(this.GET_PROJECT(this.slug));
}
//* Récupération des types de signalement, en cas de redirection page détails signalement avec id (projet déjà récupéré) ou cas précédent
if (!this.featureType || !this.basemaps) {
promises.push(
this.GET_PROJECT_INFO(this.slug),
);
}
//* changement de requête selon s'il y a un id ou un offset(dans le cas du parcours des signalements filtrés)
if (this.$route.query.offset >= 0) {
promises.push(this.fetchFilteredFeature());
} else if (!this.currentFeature || this.currentFeature.id !== this.slugSignal) {
promises.push(
this.GET_PROJECT_FEATURE({
project_slug: this.slug,
feature_id: this.slugSignal,
})
);
}
await axios.all(promises);
this.DISCARD_LOADER();
if (this.currentFeature) {
this.getFeatureEvents();
this.getFeatureAttachments();
this.getLinkedFeatures();
if (this.project.fast_edition_mode) {
this.$store.commit('feature/INIT_FORM');
this.$store.dispatch('feature/INIT_EXTRA_FORMS');
}
}
},
confirmLeave(next) {
this.next = next;
this.isLeaving = true;
},
stayOnPage() {
this.isLeaving = false;
},
leavePage() {
this.isLeaving = false;
this.next();
},
pushNgo(newEntry) {
//* update the params or queries in the route/url
this.$router.push(newEntry)
//* catch error if navigation get aborted (in beforeRouteUpdate)
.catch(() => true);
}, },
goBackToProject(message) { goBackToProject(message) {
this.$router.push({ this.$router.push({
name: 'project_detail', name: 'project_detail',
params: { params: {
slug: this.$route.params.slug, slug: this.slug,
message, message,
}, },
}); });
}, },
deleteFeature() { deleteFeature() {
this.isDeleting = false;
this.DISPLAY_LOADER('Suppression du signalement en cours...');
this.$store this.$store
.dispatch('feature/DELETE_FEATURE', { feature_id: this.currentFeature.feature_id }) .dispatch('feature/DELETE_FEATURE', { feature_id: this.currentFeature.id })
.then((response) => { .then(async (response) => {
if (response.status === 204) { this.DISCARD_LOADER();
this.GET_PROJECT_FEATURES({ if (response.status === 200) {
project_slug: this.$route.params.slug this.goBackToProject({ comment: 'Le signalement a bien été supprimé', level: 'positive' });
}); } else {
this.goBackToProject(); this.$store.commit('DISPLAY_MESSAGE', { comment: 'Une erreur est survenue pendant la suppression du signalement', level: 'negative' });
} }
}); });
}, },
fetchFilteredFeature() { // TODO : if no query for sort, use project default ones
const queryString = new URLSearchParams({ ...this.$route.query });
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-paginated/?limit=1&${queryString}&output=geojson`;
return featureAPI.getPaginatedFeatures(url)
.then((data) => {
if (data && data.results && data.results.features && data.results.features[0]) {
this.featuresCount = data.count;
this.previous = data.previous;
this.next = data.next;
const currentFeature = data.results.features[0];
this.slugSignal = currentFeature.id;
this.SET_CURRENT_FEATURE(currentFeature);
this.SET_CURRENT_FEATURE_TYPE_SLUG(currentFeature.properties.feature_type.slug);
return { feature_id: currentFeature.id };
}
return;
});
},
initMap() { initMap() {
var mapDefaultViewCenter = var mapDefaultViewCenter =
this.$store.state.configuration.DEFAULT_MAP_VIEW.center; this.$store.state.configuration.DEFAULT_MAP_VIEW.center;
var mapDefaultViewZoom = var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom; this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
this.map = mapUtil.createMap(this.$refs.map, { if (this.map) {
mapDefaultViewCenter, mapService.removeFeatures();
mapDefaultViewZoom, } else {
}); this.map = mapService.createMap(this.$refs.map, {
mapDefaultViewCenter,
// Update link to feature list with map zoom and center mapDefaultViewZoom,
mapUtil.addMapEventListener('moveend', function () { maxZoom: this.project.map_max_zoom_level,
// update link to feature list with map zoom and center interactions : {
/*var $featureListLink = $("#feature-list-link") doubleClickZoom :false,
var baseUrl = $featureListLink.attr("href").split("?")[0] mouseWheelZoom: true,
dragPan: true
$featureListLink.attr("href", baseUrl +`?zoom=${this.map.getZoom()}&lat=${this.map.getCenter().lat}&lng=${this.map.getCenter().lng}`)*/ },
}); fullScreenControl: true,
geolocationControl: true,
// 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,
this.$store.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE,
);
mapUtil.getMap().dragging.disable();
mapUtil.getMap().doubleClickZoom.disable();
mapUtil.getMap().scrollWheelZoom.disable();
this.addFeatureToMap(); this.addFeatureToMap();
}, },
addFeatureToMap() { addFeatureToMap() {
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/feature/` + const featureGroup = mapService.addFeatures({
`?feature_id=${this.$route.params.slug_signal}&output=geojson`; project_slug: this.slug,
axios features: [this.currentFeature],
.get(url) featureTypes: this.feature_types,
.then((response) => { addToMap: true,
if (response.data.features.length > 0) { });
const featureGroup = mapUtil.addFeatures( mapService.fitExtent(buffer(featureGroup.getExtent(),200));
response.data.features,
{},
true,
this.feature_types
);
mapUtil
.getMap()
.fitBounds(featureGroup.getBounds(), { padding: [25, 25] });
}
})
.catch((error) => {
throw error;
});
}, },
getFeatureEvents() { getFeatureEvents() {
featureAPI featureAPI
.getFeatureEvents(this.$route.params.slug_signal) .getFeatureEvents(this.slugSignal)
.then((data) => (this.events = data)); .then((data) => (this.events = data));
}, },
getFeatureAttachments() { getFeatureAttachments() {
featureAPI featureAPI
.getFeatureAttachments(this.$route.params.slug_signal) .getFeatureAttachments(this.slugSignal)
.then((data) => (this.attachments = data)); .then((data) => (this.attachments = data));
}, },
getLinkedFeatures() { getLinkedFeatures() {
featureAPI featureAPI
.getFeatureLinks(this.$route.params.slug_signal) .getFeatureLinks(this.slugSignal)
.then((data) => .then((data) =>
this.$store.commit('feature/SET_LINKED_FEATURES', data) this.$store.commit('feature/SET_LINKED_FEATURES', data)
); );
}, },
checkAddedForm() {
let isValid = true; //* fallback if all customForms returned true
if (this.$refs.featureTable && this.$refs.featureTable.$refs.extraForm) {
for (const extraForm of this.$refs.featureTable.$refs.extraForm) {
if (extraForm.checkForm() === false) {
isValid = false;
}
}
}
return isValid;
},
validateFastEdition() {
let is_valid = true;
is_valid = this.checkAddedForm();
if (is_valid) {
this.isSavingChanges = true; // change the value to avoid confirmation popup after redirection with new query
this.$store.dispatch(
'feature/SEND_FEATURE',
{
routeName: this.$route.name,
query: this.$route.query
}
).then((response) => {
if (response === 'reloadPage') {
// when query doesn't change we need to reload the page infos with initPage(),
// since it would not be called from the watcher'$route.query' when the query does change
this.initPage();
}
});
}
}
}, },
}; };
</script> </script>
<style scoped> <style scoped>
.map-container {
height: 100%;
max-height: 70vh;
position: relative;
overflow: hidden;
background-color: #fff;
}
#map { #map {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 250px; min-height: 250px;
max-height: 70vh; border: 1px solid grey;
} }
#feed-event .event { div.geolocation-container {
margin-bottom: 1em; /* each button have (more or less depends on borders) .5em space between */
} /* zoom buttons are 60px high, geolocation and full screen button is 34px high with borders */
#feed-event .event .date { top: calc(1.3em + 60px + 34px);
margin-right: 1em !important;
}
#feed-event .event .extra.text {
margin-left: 107px;
margin-top: 0;
} }
.prewrap { .prewrap {
white-space: pre-wrap; white-space: pre-wrap;
} }
.ui.active.dimmer {
position: fixed;
}
.ui.modal > .content {
text-align: center;
}
.ui.modal > .actions {
display: flex;
justify-content: space-evenly;
}
</style> </style>
\ No newline at end of file
<template> <template>
<div <div id="feature-edit">
id="feature-edit" <h1>
class="page" <span v-if="feature_type && isCreation">
> Création d'un signalement <small>[{{ feature_type.title }}]</small>
<h1 v-if="feature && currentRouteName === 'editer-signalement'"> </span>
Mise à jour du signalement "{{ feature.title || feature.feature_id }}" <span v-else-if="currentFeature && currentRouteName === 'editer-signalement'">
</h1> Mise à jour du signalement "{{ currentFeature.properties ?
<h1 currentFeature.properties.title : currentFeature.id }}"
v-else-if="feature_type && currentRouteName === 'ajouter-signalement'" </span>
> <span v-else-if="feature_type && currentRouteName === 'editer-attribut-signalement'">
Création d'un signalement <small>[{{ feature_type.title }}]</small> Mise à jour des attributs de {{ checkedFeatures.length }} signalements
</span>
</h1> </h1>
<form <form
id="form-feature-edit" id="form-feature-edit"
action=""
method="post"
enctype="multipart/form-data" enctype="multipart/form-data"
class="ui form" class="ui form"
> >
<!-- Feature Fields --> <!-- Feature Fields -->
<div class="two fields"> <div
<div :class="field_title"> v-if="currentRouteName !== 'editer-attribut-signalement'"
:class="[ project && project.feature_assignement ? 'three' : 'two', 'fields']"
>
<div :class="['field', {'required': !titleIsOptional}]">
<label :for="form.title.id_for_label">{{ form.title.label }}</label> <label :for="form.title.id_for_label">{{ form.title.label }}</label>
<input <input
:id="form.title.id_for_label" :id="form.title.id_for_label"
v-model="form.title.value" v-model="form.title.value"
type="text" type="text"
required :required="!titleIsOptional"
:maxlength="form.title.field.max_length" :maxlength="form.title.field.max_length"
:name="form.title.html_name" :name="form.title.html_name"
@blur="updateStore" @blur="updateStore"
> >
<ul
id="infoslist-title"
class="infoslist"
>
<li
v-for="info in form.title.infos"
:key="info"
>
{{ info }}
</li>
</ul>
<ul <ul
id="errorlist-title" id="errorlist-title"
class="errorlist" class="errorlist"
...@@ -54,9 +67,22 @@ ...@@ -54,9 +67,22 @@
:selection.sync="selected_status" :selection.sync="selected_status"
/> />
</div> </div>
<div
v-if="project && project.feature_assignement"
class="field"
>
<label for="assigned_member">Membre assigné</label>
<ProjectMemberSelect
:selected-user-id="form.assigned_member.value"
@update:user="setMemberAssigned($event)"
/>
</div>
</div> </div>
<div class="field"> <div
v-if="currentRouteName !== 'editer-attribut-signalement'"
class="field"
>
<label :for="form.description.id_for_label">{{ <label :for="form.description.id_for_label">{{
form.description.label form.description.label
}}</label> }}</label>
...@@ -69,7 +95,11 @@ ...@@ -69,7 +95,11 @@
</div> </div>
<!-- Geom Field --> <!-- Geom Field -->
<div class="field"> <div
v-if="currentRouteName !== 'editer-attribut-signalement'
&& feature_type && feature_type.geom_type !== 'none'"
class="field"
>
<label :for="form.geom.id_for_label">{{ form.geom.label }}</label> <label :for="form.geom.id_for_label">{{ form.geom.label }}</label>
<!-- Import GeoImage --> <!-- Import GeoImage -->
<div <div
...@@ -173,7 +203,11 @@ ...@@ -173,7 +203,11 @@
class="ui compact button" class="ui compact button"
@click="create_point_geoposition" @click="create_point_geoposition"
> >
<i class="ui map marker alternate icon" />Positionner le <i
class="ui map marker alternate icon"
aria-hidden="true"
/>
Positionner le
signalement à partir de votre géolocalisation signalement à partir de votre géolocalisation
</button> </button>
</p> </p>
...@@ -216,11 +250,38 @@ ...@@ -216,11 +250,38 @@
class="ui tab active map-container" class="ui tab active map-container"
data-tab="map" data-tab="map"
> >
<div
:class="{ active: mapLoading }"
class="ui inverted dimmer"
>
<div class="ui loader" />
</div>
<div <div
id="map" id="map"
ref="map" ref="map"
/> tabindex="0"
<SidebarLayers v-if="basemaps && map" /> >
<SidebarLayers v-if="basemaps && map" />
<Geolocation />
<Geocoder />
<EditingToolbar
v-if="isEditable"
:map="map"
/>
</div>
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div> </div>
</div> </div>
...@@ -229,16 +290,22 @@ ...@@ -229,16 +290,22 @@
DONNÉES MÉTIER DONNÉES MÉTIER
</div> </div>
<div <div
v-for="(field, index) in orderedCustomFields" v-for="field in extra_forms"
:key="field.field_type + index" :key="field.name"
class="field" class="extraform"
> >
<FeatureExtraForm :field="field" /> <ExtraForm
v-if="!field.isDeactivated"
:id="field.label"
ref="extraForm"
:field="field"
class="field"
/>
{{ field.errors }} {{ field.errors }}
</div> </div>
<!-- Pièces jointes --> <!-- Pièces jointes -->
<div v-if="isOnline"> <div v-if="isOnline && currentRouteName !== 'editer-attribut-signalement'">
<div class="ui horizontal divider"> <div class="ui horizontal divider">
PIÈCES JOINTES PIÈCES JOINTES
</div> </div>
...@@ -251,6 +318,7 @@ ...@@ -251,6 +318,7 @@
:key="attachForm.dataKey" :key="attachForm.dataKey"
ref="attachementForm" ref="attachementForm"
:attachment-form="attachForm" :attachment-form="attachForm"
:enable-key-doc-notif="feature_type && feature_type.enable_key_doc_notif"
/> />
</div> </div>
<button <button
...@@ -259,12 +327,16 @@ ...@@ -259,12 +327,16 @@
class="ui compact basic button" class="ui compact basic button"
@click="add_attachement_formset" @click="add_attachement_formset"
> >
<i class="ui plus icon" />Ajouter une pièce jointe <i
class="ui plus icon"
aria-hidden="true"
/>
Ajouter une pièce jointe
</button> </button>
</div> </div>
<!-- Signalements liés --> <!-- Signalements liés -->
<div v-if="isOnline"> <div v-if="isOnline && currentRouteName !== 'editer-attribut-signalement'">
<div class="ui horizontal divider"> <div class="ui horizontal divider">
SIGNALEMENTS LIÉS SIGNALEMENTS LIÉS
</div> </div>
...@@ -282,16 +354,25 @@ ...@@ -282,16 +354,25 @@
class="ui compact basic button" class="ui compact basic button"
@click="add_linked_formset" @click="add_linked_formset"
> >
<i class="ui plus icon" />Ajouter une liaison <i
class="ui plus icon"
aria-hidden="true"
/>
Ajouter une liaison
</button> </button>
</div> </div>
<div class="ui divider" /> <div class="ui divider" />
<button <button
id="save-changes"
type="button" type="button"
class="ui teal icon button" :class="['ui teal icon button', { loading: sendingFeature }]"
@click="postForm" @click="onSave"
> >
<i class="white save icon" /> Enregistrer les changements <i
class="white save icon"
aria-hidden="true"
/>
Enregistrer les changements
</button> </button>
</form> </form>
</div> </div>
...@@ -299,21 +380,24 @@ ...@@ -299,21 +380,24 @@
<script> <script>
import { mapState, mapGetters } from 'vuex'; import { mapState, mapGetters } from 'vuex';
import { GeoJSON } from 'ol/format';
import L from 'leaflet';
import 'leaflet-draw';
import axios from '@/axios-client.js';
import flip from '@turf/flip';
import featureAPI from '@/services/feature-api';
import { mapUtil } from '@/assets/js/map-util.js';
import { allowedStatus2change } from '@/utils';
import FeatureAttachmentForm from '@/components/Feature/FeatureAttachmentForm'; import FeatureAttachmentForm from '@/components/Feature/FeatureAttachmentForm';
import FeatureLinkedForm from '@/components/Feature/FeatureLinkedForm'; import FeatureLinkedForm from '@/components/Feature/FeatureLinkedForm';
import FeatureExtraForm from '@/components/Feature/Edit/FeatureExtraForm'; import ExtraForm from '@/components/ExtraForm';
import Dropdown from '@/components/Dropdown.vue'; import Dropdown from '@/components/Dropdown.vue';
import SidebarLayers from '@/components/SidebarLayers'; import SidebarLayers from '@/components/Map/SidebarLayers';
import EditingToolbar from '@/components/Map/EditingToolbar';
import Geocoder from '@/components/Map/Geocoder';
import Geolocation from '@/components/Map/Geolocation';
import ProjectMemberSelect from '@/components/ProjectMemberSelect';
import featureAPI from '@/services/feature-api';
import mapService from '@/services/map-service';
import editionService from '@/services/edition-service';
import { statusChoices, allowedStatus2change } from '@/utils';
import axios from '@/axios-client.js';
export default { export default {
name: 'FeatureEdit', name: 'FeatureEdit',
...@@ -323,12 +407,18 @@ export default { ...@@ -323,12 +407,18 @@ export default {
FeatureLinkedForm, FeatureLinkedForm,
Dropdown, Dropdown,
SidebarLayers, SidebarLayers,
FeatureExtraForm, Geocoder,
Geolocation,
EditingToolbar,
ExtraForm,
ProjectMemberSelect
}, },
data() { data() {
return { return {
map: null, map: null,
mapLoading: false,
sendingFeature: false,
baseUrl: this.$store.state.configuration.BASE_URL, baseUrl: this.$store.state.configuration.BASE_URL,
file: null, file: null,
showGeoRef: false, showGeoRef: false,
...@@ -340,9 +430,10 @@ export default { ...@@ -340,9 +430,10 @@ export default {
form: { form: {
title: { title: {
errors: [], errors: [],
infos: [],
id_for_label: 'name', id_for_label: 'name',
field: { field: {
max_length: 30, max_length: 128,
}, },
html_name: 'name', html_name: 'name',
label: 'Nom', label: 'Nom',
...@@ -357,6 +448,9 @@ export default { ...@@ -357,6 +448,9 @@ export default {
name: 'Brouillon', name: 'Brouillon',
}, },
}, },
assigned_member: {
value: null,
},
description: { description: {
errors: [], errors: [],
id_for_label: 'description', id_for_label: 'description',
...@@ -387,10 +481,11 @@ export default { ...@@ -387,10 +481,11 @@ export default {
]), ]),
...mapState('feature', [ ...mapState('feature', [
'attachmentFormset', 'attachmentFormset',
'linkedFormset', 'checkedFeatures',
'currentFeature',
'extra_forms',
'features', 'features',
'extra_form', 'linkedFormset',
'statusChoices',
]), ]),
...mapState('feature-type', [ ...mapState('feature-type', [
'feature_types' 'feature_types'
...@@ -403,25 +498,16 @@ export default { ...@@ -403,25 +498,16 @@ export default {
'feature_type' 'feature_type'
]), ]),
field_title() { titleIsOptional() {
if (this.feature_type) { return this.feature_type && this.feature_type.title_optional;
if (this.feature_type.title_optional) {
return 'field';
}
}
return 'required field';
}, },
currentRouteName() { currentRouteName() {
return this.$route.name; return this.$route.name;
}, },
feature() { isCreation() {
return this.$store.state.feature.currentFeature; return this.currentRouteName === 'ajouter-signalement';
},
orderedCustomFields() {
return [...this.extra_form].sort((a, b) => a.position - b.position);
}, },
geoRefFileLabel() { geoRefFileLabel() {
...@@ -434,6 +520,7 @@ export default { ...@@ -434,6 +520,7 @@ export default {
selected_status: { selected_status: {
get() { get() {
return this.form.status.value; return this.form.status.value;
}, },
set(newValue) { set(newValue) {
this.form.status.value = newValue; this.form.status.value = newValue;
...@@ -441,24 +528,47 @@ export default { ...@@ -441,24 +528,47 @@ export default {
}, },
}, },
isFeatureCreator() {
if (this.currentFeature && this.currentFeature.properties && this.user) {
return this.currentFeature.properties.creator === this.user.id ||
this.currentFeature.properties.creator.username === this.user.username;
}
return false;
},
allowedStatusChoices() { allowedStatusChoices() {
if (this.project && this.feature && this.user) { if (this.project && this.USER_LEVEL_PROJECTS && this.user) {
const isModerate = this.project.moderation; const isModerate = this.project.moderation;
const userStatus = this.USER_LEVEL_PROJECTS[this.project.slug]; const userStatus = this.USER_LEVEL_PROJECTS[this.project.slug];
const isOwnFeature = this.feature.creator === this.user.id; //* si le contributeur est l'auteur du signalement return allowedStatus2change(this.user, isModerate, userStatus, this.isFeatureCreator, this.currentRouteName);
return allowedStatus2change(this.statusChoices, isModerate, userStatus, isOwnFeature, this.currentRouteName);
} }
return []; return [];
}, },
isEditable() {
return this.basemaps && this.map && (this.feature_type && !this.feature_type.geom_type.includes('multi'));
}
},
watch: {
'form.title.value': function(newValue) {
if (newValue && newValue.length === 128) {
this.form.title.infos.push('Le nombre de caractères et limité à 128.');
} else {
this.form.title.infos = [];
}
}
}, },
created() { created() {
this.$store.dispatch('GET_USERS_GROUPS'); // récupére les groupes d'utilisateurs pour extra_forms
this.$store.commit('feature/CLEAR_EXTRA_FORM');
this.$store.commit( this.$store.commit(
'feature-type/SET_CURRENT_FEATURE_TYPE_SLUG', 'feature-type/SET_CURRENT_FEATURE_TYPE_SLUG',
this.$route.params.slug_type_signal this.$route.params.slug_type_signal
); );
//* empty previous feature data, not emptying by itself since it doesn't update by itself anymore //* empty previous feature data, not emptying by itself since it doesn't update by itself anymore
if (this.currentRouteName === 'ajouter-signalement') { if (this.currentRouteName === 'ajouter-signalement' || this.currentRouteName === 'editer-attribut-signalement') {
this.$store.commit('feature/SET_CURRENT_FEATURE', []); this.$store.commit('feature/SET_CURRENT_FEATURE', []);
} }
...@@ -469,10 +579,13 @@ export default { ...@@ -469,10 +579,13 @@ export default {
}, },
mounted() { mounted() {
const promises = [ const promises = [];
this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug), if (!this.project) {
this.$store.dispatch('projects/GET_PROJECT_INFO', this.$route.params.slug), promises.push(
]; this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug),
this.$store.dispatch('projects/GET_PROJECT_INFO', this.$route.params.slug),
);
}
if (this.$route.params.slug_signal) { if (this.$route.params.slug_signal) {
promises.push( promises.push(
this.$store.dispatch('feature/GET_PROJECT_FEATURE', { this.$store.dispatch('feature/GET_PROJECT_FEATURE', {
...@@ -483,21 +596,27 @@ export default { ...@@ -483,21 +596,27 @@ export default {
} }
Promise.all(promises).then(() => { Promise.all(promises).then(() => {
this.initForm(); if (this.currentRouteName !== 'editer-attribut-signalement') {
this.initMap(); this.initForm();
this.onFeatureTypeLoaded(); // if not in the case of a non geographical feature type, init map
this.initExtraForms(this.feature); if (this.feature_type.geom_type !== 'none') {
this.initMap();
setTimeout( this.initMapTools();
function () { this.initDeleteFeatureOnKey();
mapUtil.addGeocoders(this.$store.state.configuration); }
}.bind(this), }
1000 this.$store.dispatch('feature/INIT_EXTRA_FORMS');
);
}); });
}, },
beforeDestroy() {
this.$store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
},
destroyed() { destroyed() {
editionService.removeActiveFeatures();
// emptying to enable adding event listener at feature edition straight after creation
editionService.selectForDeletion = null;
//* be sure that previous Formset have been cleared for creation //* be sure that previous Formset have been cleared for creation
this.$store.commit('feature/CLEAR_ATTACHMENT_FORM'); this.$store.commit('feature/CLEAR_ATTACHMENT_FORM');
this.$store.commit('feature/CLEAR_LINKED_FORM'); this.$store.commit('feature/CLEAR_LINKED_FORM');
...@@ -506,31 +625,42 @@ export default { ...@@ -506,31 +625,42 @@ export default {
methods: { methods: {
initForm() { initForm() {
if (this.currentRouteName === 'editer-signalement') { if (this.currentRouteName.includes('editer')) {
for (const key in this.feature) { for (const key in this.currentFeature.properties) {
if (key && this.form[key]) { if (key && this.form[key]) {
if (key === 'status') { if (key === 'status') {
const value = this.feature[key]; const value = this.currentFeature.properties[key];
this.form[key].value = this.statusChoices.find( this.form[key].value = statusChoices.find(
(key) => key.value === value (key) => key.value === value
); );
} else { } else {
this.form[key].value = this.feature[key]; this.form[key].value = this.currentFeature.properties[key];
} }
} }
} }
this.form.geom.value = this.currentFeature.geometry;
this.updateStore(); this.updateStore();
} }
}, },
addPointToCoordinates(coordinates){
let json = {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: coordinates,
},
properties: {},
};
this.updateMap(json);
this.updateGeomField(json.geometry);
this.showGeoPositionBtn = false;
this.erreurGeolocalisationMessage = '';
},
create_point_geoposition() { create_point_geoposition() {
function success(position) { function success(position) {
const latitude = position.coords.latitude; this.addPointToCoordinates([position.coords.longitude, position.coords.latitude]);
const longitude = position.coords.longitude;
var layer = L.circleMarker([latitude, longitude]);
this.add_layer_call_back(layer);
this.map.setView([latitude, longitude]);
} }
function error(err) { function error(err) {
...@@ -580,24 +710,15 @@ export default { ...@@ -580,24 +710,15 @@ export default {
if (response.data.geom.indexOf('POINT') >= 0) { if (response.data.geom.indexOf('POINT') >= 0) {
const regexp = /POINT\s\((.*)\s(.*)\)/; const regexp = /POINT\s\((.*)\s(.*)\)/;
const arr = regexp.exec(response.data.geom); const arr = regexp.exec(response.data.geom);
const json = { this.addPointToCoordinates([parseFloat(arr[1]), parseFloat(arr[2])]);
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [parseFloat(arr[1]), parseFloat(arr[2])],
},
properties: {},
};
this.updateMap(json);
this.updateGeomField(json);
// Set Attachment // Set Attachment
this.addAttachment({ this.addAttachment({
title: 'Localisation', title: 'Localisation',
info: '', info: '',
id: 'loc',
attachment_file: this.file.name, attachment_file: this.file.name,
fileToImport: this.file, fileToImport: this.file,
}); });
this.toggleGeoRefModal();
} }
}) })
...@@ -612,24 +733,6 @@ export default { ...@@ -612,24 +733,6 @@ export default {
}); });
}, },
initExtraForms(feature) {
function findCurrentValue(label) {
const field = feature.feature_data.find((el) => el.label === label);
return field ? field.value : null;
}
const extraForm = this.feature_type.customfield_set.map((field) => {
return {
...field,
//* add value field to extra forms from feature_type and existing values if feature is defined
value:
feature && feature.feature_data
? findCurrentValue(field.label)
: null,
};
});
this.$store.commit('feature/SET_EXTRA_FORM', extraForm);
},
add_attachement_formset() { add_attachement_formset() {
this.$store.commit('feature/ADD_ATTACHMENT_FORM', { this.$store.commit('feature/ADD_ATTACHMENT_FORM', {
dataKey: this.attachmentDataKey, dataKey: this.attachmentDataKey,
...@@ -640,11 +743,7 @@ export default { ...@@ -640,11 +743,7 @@ export default {
addAttachment(attachment) { addAttachment(attachment) {
this.$store.commit('feature/ADD_ATTACHMENT_FORM', { this.$store.commit('feature/ADD_ATTACHMENT_FORM', {
dataKey: this.attachmentDataKey, dataKey: this.attachmentDataKey,
title: attachment.title, ...attachment
attachment_file: attachment.attachment_file,
info: attachment.info,
fileToImport: attachment.fileToImport,
id: attachment.id,
}); });
this.attachmentDataKey += 1; this.attachmentDataKey += 1;
}, },
...@@ -673,12 +772,13 @@ export default { ...@@ -673,12 +772,13 @@ export default {
}, },
updateStore() { updateStore() {
this.$store.commit('feature/UPDATE_FORM', { return this.$store.commit('feature/UPDATE_FORM', {
title: this.form.title.value, title: this.form.title.value,
status: this.form.status.value, status: this.form.status.value,
description: this.form.description, description: this.form.description,
assigned_member: this.form.assigned_member,
geometry: this.form.geom.value, geometry: this.form.geom.value,
feature_id: this.feature ? this.feature.feature_id : '', feature_id: this.currentFeature ? this.currentFeature.id : '',
}); });
}, },
...@@ -714,6 +814,13 @@ export default { ...@@ -714,6 +814,13 @@ export default {
checkAddedForm() { checkAddedForm() {
let isValid = true; //* fallback if all customForms returned true let isValid = true; //* fallback if all customForms returned true
if (this.$refs.extraForm) {
for (const extraForm of this.$refs.extraForm) {
if (extraForm.checkForm() === false) {
isValid = false;
}
}
}
if (this.$refs.attachementForm) { if (this.$refs.attachementForm) {
for (const attachementForm of this.$refs.attachementForm) { for (const attachementForm of this.$refs.attachementForm) {
if (attachementForm.checkForm() === false) { if (attachementForm.checkForm() === false) {
...@@ -731,22 +838,30 @@ export default { ...@@ -731,22 +838,30 @@ export default {
return isValid; return isValid;
}, },
postForm() { onSave() {
let is_valid = true; if (this.currentRouteName === 'editer-attribut-signalement') {
if (!this.feature_type.title_optional) { this.postMultipleFeatures();
is_valid =
this.checkFormTitle() &&
this.checkFormGeom() &&
this.checkAddedForm();
} else { } else {
is_valid = this.checkFormGeom() && this.checkAddedForm(); this.postForm();
}
},
async postForm(extraForms) {
let response;
let is_valid = this.checkAddedForm();
// if not in the case of a non geographical feature type, check geometry's validity
if (this.feature_type && this.feature_type.geom_type !== 'none') {
is_valid = is_valid && this.checkFormGeom();
}
if (!this.feature_type.title_optional) {
is_valid = is_valid && this.checkFormTitle();
} }
if (is_valid) { if (is_valid) {
//* in a moderate project, at edition of a published feature by someone else than admin or moderator, switch published status to draft. //* in a moderate project, at edition of a published feature by someone else than admin or moderator, switch published status to draft.
if ( if (
this.project.moderation && this.project.moderation &&
this.currentRouteName === 'editer-signalement' && this.currentRouteName.includes('editer') &&
this.form.status.value.value === 'published' && this.form.status.value.value === 'published' &&
!this.permissions.is_project_administrator && !this.permissions.is_project_administrator &&
!this.permissions.is_project_moderator !this.permissions.is_project_moderator
...@@ -754,183 +869,84 @@ export default { ...@@ -754,183 +869,84 @@ export default {
this.form.status.value = { name: 'Brouillon', value: 'draft' }; this.form.status.value = { name: 'Brouillon', value: 'draft' };
this.updateStore(); this.updateStore();
} }
this.$store.dispatch('feature/SEND_FEATURE', this.currentRouteName); this.sendingFeature = true;
response = await this.$store.dispatch(
'feature/SEND_FEATURE',
{
routeName: this.currentRouteName,
query: this.$route.query,
extraForms// if multiple features, pass directly extraForms object to avoid mutating the store
}
);
this.sendingFeature = false;
return response;
} }
}, },
//* ************* MAP *************** *// async postMultipleFeatures() {
this.$store.commit('DISPLAY_LOADER', 'Envoi des signalements en cours...');
onFeatureTypeLoaded() { const responses = [];
var geomLeaflet = { // loop over each selected feature id
point: 'circlemarker', for (const featureId of this.checkedFeatures) {
linestring: 'polyline', // get other infos from this feature to feel the form
polygon: 'polygon', const response = await this.$store.dispatch('feature/GET_PROJECT_FEATURE', {
}; project_slug: this.$route.params.slug,
var geomType = this.feature_type.geom_type; feature_id: featureId,
var drawConfig = { });
polygon: false, if (response.status === 200) {
marker: false, // fill title, status & description in store, required to send feature update request
polyline: false, this.initForm();
rectangle: false, // create a new object of custom form to send directly with the request, to avoid multiple asynchronous mutation in store
circle: false, const newXtraForms = [];
circlemarker: false, // parse each current custom form values to update the new custom form for this feature
for (const extraForm of this.extra_forms) {
// copy current custom form to prevent modifying the original one
let newXtForm = { ...extraForm };
// if value wasn't changed in this page, get back previous value of the feature (rather than using feature orginal form, which is missing information to send in request)
if (newXtForm.value === null) {
newXtForm.value = this.currentFeature.properties[newXtForm.name];
}
newXtraForms.push(newXtForm);
}
const response = await this.postForm(newXtraForms);
responses.push(response);
}
}
this.$store.commit('DISCARD_LOADER');
const errors = responses.filter((res) => res === undefined || res.status !== 200).length > 0;
const message = {
comment: errors ? 'Des signalements n\'ont pas pu être mis à jour' : 'Les signalements ont été mis à jour',
level: errors ? 'negative' : 'positive'
}; };
drawConfig[geomLeaflet[geomType]] = true; this.$store.commit('DISPLAY_MESSAGE', message);
this.$router.push({
L.drawLocal = { name: 'liste-signalements',
draw: { params: {
toolbar: { slug: this.$route.params.slug,
actions: {
title: 'Annuler le dessin',
text: 'Annuler',
},
finish: {
title: 'Terminer le dessin',
text: 'Terminer',
},
undo: {
title: 'Supprimer le dernier point dessiné',
text: 'Supprimer le dernier point',
},
buttons: {
polyline: 'Dessiner une polyligne',
polygon: 'Dessiner un polygone',
rectangle: 'Dessiner un rectangle',
circle: 'Dessiner un cercle',
marker: 'Dessiner une balise',
circlemarker: 'Dessiner un point',
},
},
handlers: {
circle: {
tooltip: {
start: 'Cliquer et glisser pour dessiner le cercle.',
},
radius: 'Rayon',
},
circlemarker: {
tooltip: {
start: 'Cliquer sur la carte pour placer le point.',
},
},
marker: {
tooltip: {
start: 'Cliquer sur la carte pour placer la balise.',
},
},
polygon: {
tooltip: {
start: 'Cliquer pour commencer à dessiner.',
cont: 'Cliquer pour continuer à dessiner.',
end: 'Cliquer sur le premier point pour terminer le dessin.',
},
},
polyline: {
error: '<strong>Error:</strong> shape edges cannot cross!',
tooltip: {
start: 'Cliquer pour commencer à dessiner.',
cont: 'Cliquer pour continuer à dessiner.',
end: 'Cliquer sur le dernier point pour terminer le dessin.',
},
},
rectangle: {
tooltip: {
start: 'Cliquer et glisser pour dessiner le rectangle.',
},
},
simpleshape: {
tooltip: {
end: 'Relâcher la souris pour terminer de dessiner.',
},
},
},
}, },
edit: { });
toolbar: { },
actions: {
save: {
title: 'Sauver les modifications',
text: 'Sauver',
},
cancel: {
title:
'Annuler la modification, annule toutes les modifications',
text: 'Annuler',
},
clearAll: {
title: "Effacer l'objet",
text: 'Effacer',
},
},
buttons: {
edit: "Modifier l'objet",
editDisabled: 'Aucun objet à modifier',
remove: "Supprimer l'objet",
removeDisabled: 'Aucun objet à supprimer',
},
},
handlers: {
edit: {
tooltip: {
text: "Faites glisser les marqueurs ou les balises pour modifier l'élément.",
subtext: 'Cliquez sur Annuler pour annuler les modifications..',
},
},
remove: {
tooltip: {
text: 'Cliquez sur un élément pour le supprimer.',
},
},
},
},
};
this.drawnItems = new L.FeatureGroup(); //* ************* MAP *************** *//
this.map.addLayer(this.drawnItems);
this.drawControlFull = new L.Control.Draw({ initMapTools() {
position: 'topright', const geomType = this.feature_type.geom_type;
edit: { editionService.addEditionControls(geomType);
featureGroup: this.drawnItems, editionService.draw.on('drawend', (evt) => {
}, const feature = evt.feature;
draw: drawConfig, this.updateGeomField(new GeoJSON().writeGeometry(feature.getGeometry(),{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' }));
if (this.feature_type.geomType === 'point') {
this.showGeoPositionBtn = false;
this.erreurGeolocalisationMessage = '';
}
}); });
editionService.modify.on('modifyend', (evt) => {
this.drawControlEditOnly = new L.Control.Draw({ let feature = evt.features.getArray()[0];
position: 'topright', this.updateGeomField(new GeoJSON().writeGeometry(feature.getGeometry(),{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' }));
edit: {
featureGroup: this.drawnItems,
},
draw: false,
}); });
if (this.currentRouteName === 'editer-signalement') {
this.map.addControl(this.drawControlEditOnly);
} else {
this.map.addControl(this.drawControlFull);
}
this.changeMobileBtnOrder();
this.map.on(
'draw:created',
function (e) {
var layer = e.layer;
this.add_layer_call_back(layer);
}.bind(this)
);
//var wellknown;// TODO Remplacer par autre chose
this.map.on(
'draw:edited',
function (e) {
var layers = e.layers;
const self = this;
layers.eachLayer(function (layer) {
self.updateGeomField(layer.toGeoJSON());
});
}.bind(this)
);
this.map.on( this.map.on(
'draw:deleted', 'draw:deleted',
function () { function () {
...@@ -946,20 +962,13 @@ export default { ...@@ -946,20 +962,13 @@ export default {
}, },
updateMap(geomFeatureJSON) { updateMap(geomFeatureJSON) {
if (this.drawnItems) { if (editionService.drawSource) {
this.drawnItems.clearLayers(); editionService.drawSource.clear();
} }
var geomType = this.feature_type.geom_type;
if (geomFeatureJSON) { if (geomFeatureJSON) {
var geomJSON = flip(geomFeatureJSON.geometry); let retour = new GeoJSON().readFeature(geomFeatureJSON,{ dataProjection:'EPSG:4326',featureProjection:'EPSG:3857' });
if (geomType === 'point') { editionService.initFeatureToEdit(retour);
L.circleMarker(geomJSON.coordinates).addTo(this.drawnItems);
} else if (geomType === 'linestring') {
L.polyline(geomJSON.coordinates).addTo(this.drawnItems);
} else if (geomType === 'polygon') {
L.polygon(geomJSON.coordinates).addTo(this.drawnItems);
}
this.map.fitBounds(this.drawnItems.getBounds(), { padding: [25, 25] });
} else { } else {
this.map.setView( this.map.setView(
this.$store.state.configuration.DEFAULT_MAP_VIEW.center, this.$store.state.configuration.DEFAULT_MAP_VIEW.center,
...@@ -968,99 +977,62 @@ export default { ...@@ -968,99 +977,62 @@ export default {
} }
}, },
updateGeomField(newGeom) { async updateGeomField(newGeom) {
this.form.geom.value = newGeom.geometry; this.form.geom.value = newGeom;
this.updateStore(); await this.updateStore();
}, },
initMap() { initMap() {
this.mapLoading = true;
var mapDefaultViewCenter = var mapDefaultViewCenter =
this.$store.state.configuration.DEFAULT_MAP_VIEW.center; this.$store.state.configuration.DEFAULT_MAP_VIEW.center;
var mapDefaultViewZoom = var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom; this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
// Create the map, then init the layers and features // Create the map, then init features
this.map = mapUtil.createMap(this.$refs.map, { this.map = mapService.createMap(this.$refs.map, {
mapDefaultViewCenter, mapDefaultViewCenter,
mapDefaultViewZoom, mapDefaultViewZoom,
maxZoom: this.project.map_max_zoom_level,
interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true },
fullScreenControl: true,
geolocationControl: true,
}); });
const currentFeatureId = this.$route.params.slug_signal; const currentFeatureId = this.$route.params.slug_signal;
setTimeout(() => { const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/features/?feature_type__slug=${this.$route.params.slug_type_signal}&project__slug=${this.$route.params.slug}&output=geojson`;
const project_id = this.$route.params.slug.split('-')[0];
const mvtUrl = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`;
mapUtil.addVectorTileLayer(
mvtUrl,
this.$route.params.slug,
this.feature_types
);
}, 1000);
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/feature/?feature_type__slug=${this.$route.params.slug_type_signal}&output=geojson`;
axios axios
.get(url) .get(url)
.then((response) => { .then((response) => {
const features = response.data.features; const features = response.data.features;
if (features) { if (features.length > 0) {
const allFeaturesExceptCurrent = features.filter( const allFeaturesExceptCurrent = features.filter(
(feat) => feat.id !== currentFeatureId (feat) => feat.id !== currentFeatureId
); );
mapUtil.addFeatures( mapService.addFeatures({
allFeaturesExceptCurrent, addToMap: true,
{}, project_slug: this.project.slug,
true, features: allFeaturesExceptCurrent,
this.feature_types featureTypes: this.feature_types,
); });
if (this.currentRouteName === 'editer-signalement') { if (this.currentRouteName === 'editer-signalement') {
const currentFeature = features.filter( editionService.setFeatureToEdit(this.currentFeature);
(feat) => feat.id === currentFeatureId this.updateMap(this.currentFeature);
)[0];
this.updateMap(currentFeature);
} }
} }
this.mapLoading = false;
}) })
.catch((error) => { .catch((error) => {
this.mapLoading = false;
throw error; throw error;
}); });
document.addEventListener('change-layers-order', (event) => {
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
mapUtil.updateOrder(event.detail.layers.slice().reverse());
});
}, },
add_layer_call_back(layer) { enableSnap() {
layer.addTo(this.drawnItems); editionService.addSnapInteraction(this.map);
this.drawControlFull.remove(this.map);
this.drawControlEditOnly.addTo(this.map);
//var wellknown;// TODO Remplacer par autre chose
this.updateGeomField(layer.toGeoJSON());
if (this.feature_type.geomType === 'point') {
this.showGeoPositionBtn = false;
this.erreurGeolocalisationMessage = '';
}
}, },
changeMobileBtnOrder() { //* move large toolbar for polygon creation, cutting map in the middle disableSnap() {
function changeDisplay() { editionService.removeSnapInteraction(this.map);
const buttons = document.querySelector('.leaflet-draw-actions.leaflet-draw-actions-top.leaflet-draw-actions-bottom');
if (buttons && buttons.style) {
buttons.style.display = 'flex';
buttons.style['flex-direction'] = 'column';
}
}
if (window.screen.availWidth < 767) { //* change button order all the time to keep homogeinity on mobile
const wrapper = document.querySelector('.leaflet-top.leaflet-right');
if (wrapper) {
wrapper.appendChild(wrapper.children[0]);
}
if (this.feature_type.geom_type === 'polygon') { //* if it's a polygon, change tools direction to vertical
let polygonBtn = document.querySelector('.leaflet-draw-draw-polygon'); //* since elements are generated
if (polygonBtn) {
polygonBtn.addEventListener('click', changeDisplay); //* it should be done at each click
}
}
}
}, },
getFeatureAttachments() { getFeatureAttachments() {
...@@ -1074,6 +1046,25 @@ export default { ...@@ -1074,6 +1046,25 @@ export default {
.getFeatureLinks(this.$route.params.slug_signal) .getFeatureLinks(this.$route.params.slug_signal)
.then((data) => this.addExistingLinkedFormset(data)); .then((data) => this.addExistingLinkedFormset(data));
}, },
/**
* Deletes the selected feature when the "Delete" or "Escape" key is pressed.
* The map element has been made focusable by adding tabindex=0.
*/
initDeleteFeatureOnKey() {
// Add an event listener for key presses
document.addEventListener('keydown', function(event) {
// Check if the element with the ID "map" has focus
if ((event.key === 'Delete' || event.key === 'Escape') && document.activeElement.id === 'map') {
// If the conditions are met, call the deleteSelectedFeature function
editionService.removeFeatureFromMap();
}
});
},
setMemberAssigned(e) {
this.form.assigned_member.value = e;
this.updateStore();
}
}, },
}; };
</script> </script>
...@@ -1084,6 +1075,13 @@ export default { ...@@ -1084,6 +1075,13 @@ export default {
width: 100%; width: 100%;
border: 1px solid grey; border: 1px solid grey;
} }
div.geolocation-container {
/* each button have .5em space between, zoom buttons are 60px high and full screen button is 34px high */
top: calc(1.3em + 60px + 34px);
}
#get-geom-from-image-file { #get-geom-from-image-file {
margin-bottom: 5px; margin-bottom: 5px;
} }
...@@ -1111,4 +1109,7 @@ export default { ...@@ -1111,4 +1109,7 @@ export default {
.leaflet-bottom { .leaflet-bottom {
z-index: 800; z-index: 800;
} }
.extraform {
margin-bottom: 1em;
}
</style> </style>
<template> <template>
<div <div
v-if="structure" v-if="feature_type"
id="feature-type-detail" id="feature-type-detail"
class="page"
> >
<div class="ui stackable grid"> <div class="ui stackable grid">
<div class="five wide column"> <div class="five wide column">
<div class="ui attached secondary segment"> <div
id="feature-type-title"
class="ui attached secondary segment"
>
<h1 class="ui center aligned header ellipsis"> <h1 class="ui center aligned header ellipsis">
<img <img
v-if="structure.geom_type === 'point'" v-if="feature_type.geom_type === 'point'"
class="ui medium image" class="ui medium image"
alt="Géométrie point" alt="Géométrie point"
src="@/assets/img/marker.png" src="@/assets/img/marker.png"
> >
<img <img
v-if="structure.geom_type === 'linestring'" v-if="feature_type.geom_type === 'linestring'"
class="ui medium image" class="ui medium image"
alt="Géométrie ligne" alt="Géométrie ligne"
src="@/assets/img/line.png" src="@/assets/img/line.png"
> >
<img <img
v-if="structure.geom_type === 'polygon'" v-if="feature_type.geom_type === 'polygon'"
class="ui medium image" class="ui medium image"
alt="Géométrie polygone" alt="Géométrie polygone"
src="@/assets/img/polygon.png" src="@/assets/img/polygon.png"
> >
{{ structure.title }} <img
v-if="feature_type.geom_type === 'multipoint'"
class="ui medium image"
alt="Géométrie point"
src="@/assets/img/multimarker.png"
>
<img
v-if="feature_type.geom_type === 'multilinestring'"
class="ui medium image"
alt="Géométrie ligne"
src="@/assets/img/multiline.png"
>
<img
v-if="feature_type.geom_type === 'multipolygon'"
class="ui medium image"
alt="Géométrie polygone"
src="@/assets/img/multipolygon.png"
>
<span
v-if="feature_type.geom_type === 'none'"
class="ui medium image"
title="Aucune géométrie"
>
<i class="ui icon big outline file" />
</span>
{{ feature_type.title }}
</h1> </h1>
</div> </div>
<div class="ui attached segment"> <div class="ui attached segment">
...@@ -41,10 +68,12 @@ ...@@ -41,10 +68,12 @@
</div> </div>
</div> </div>
<div class="value"> <div class="value">
{{ features_count }} {{ isOnline ? featuresCount : '?' }}
</div> </div>
<div class="label"> <div
Signalement{{ features.length > 1 ? "s" : "" }} class="label"
>
Signalement{{ featuresCount > 1 || !isOnline ? "s" : "" }}
</div> </div>
</div> </div>
...@@ -53,11 +82,11 @@ ...@@ -53,11 +82,11 @@
</h3> </h3>
<div class="ui divided list"> <div class="ui divided list">
<div <div
v-for="(field, index) in orderedCustomFields" v-for="(field, index) in feature_type.customfield_set"
:key="field.name + index" :key="field.name + index"
class="item" class="item"
> >
<div class="right floated content"> <div class="right floated content custom-field">
<div class="description"> <div class="description">
{{ field.field_type }} {{ field.field_type }}
</div> </div>
...@@ -72,17 +101,22 @@ ...@@ -72,17 +101,22 @@
<div class="ui bottom attached secondary segment"> <div class="ui bottom attached secondary segment">
<div <div
:class="['title', { active: showImport && isOnline, nohover: !isOnline }]" v-if="user && permissions.can_create_feature"
@click="toggleShowImport" class="ui styled accordion"
data-test="features-import"
> >
<i class="dropdown icon" />
Importer des signalements
</div>
<div :class="['content', { active: showImport && isOnline }]">
<div <div
:class="['title', { active: showImport }]" id="toggle-show-import"
:class="['title', { active: showImport && isOnline, nohover: !isOnline }]"
@click="toggleShowImport" @click="toggleShowImport"
> >
<i
class="dropdown icon"
aria-hidden="true"
/>
Importer des signalements
</div>
<div :class="['content', { active: showImport && isOnline }]">
<div class="field"> <div class="field">
<label <label
class="ui icon button ellipsis" class="ui icon button ellipsis"
...@@ -104,7 +138,10 @@ ...@@ -104,7 +138,10 @@
> >
</div> </div>
<div class="field"> <div
v-if="feature_type.geom_type === 'point' || feature_type.geom_type === 'none'"
class="field"
>
<label <label
class="ui icon button ellipsis" class="ui icon button ellipsis"
for="csv_file" for="csv_file"
...@@ -124,7 +161,6 @@ ...@@ -124,7 +161,6 @@
@change="onCsvFileChange" @change="onCsvFileChange"
> >
</div> </div>
<router-link <router-link
v-if=" v-if="
IDGO && IDGO &&
...@@ -142,149 +178,94 @@ ...@@ -142,149 +178,94 @@
> >
Importer les signalements à partir de {{ CATALOG_NAME|| 'IDGO' }} Importer les signalements à partir de {{ CATALOG_NAME|| 'IDGO' }}
</router-link> </router-link>
<div
v-if="$route.params.geojson" <ul
class="ui button import-catalog basic active teal nohover" v-if="importError"
class="errorlist"
> >
<div class="field"> <li>
<label {{ importError }}
class="ui icon button ellipsis" </li>
for="json_file" </ul>
>
<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 <button
v-if=" id="start-import"
IDGO && :disabled="
permissions && (geojsonFileToImport.size === 0 && !$route.params.geojson) &&
permissions.can_create_feature (csvFileToImport.size === 0 && !$route.params.csv)
" "
:to="{ class="ui fluid teal icon button"
name: 'catalog-import', @click="geojsonFileToImport.size !== 0 ? importGeoJson() : importCSV()"
params: { >
slug, <i
feature_type_slug: $route.params.feature_type_slug class="upload icon"
}, aria-hidden="true"
}"
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> Lancer l'import
</button>
<ImportTask
v-if="importsForFeatureType.length > 0"
ref="importTask"
:imports="importsForFeatureType"
@reloadFeatureType="reloadFeatureType"
/>
</div> </div>
</div> </div>
<div class="ui styled accordion"> <div
class="ui styled accordion"
data-test="features-export"
>
<div <div
:class="['title', { active: !showImport }]" :class="['title', { active: !showImport && isOnline, nohover: !isOnline }]"
@click="toggleShowImport" @click="toggleShowImport"
> >
<i class="dropdown icon" /> <i
class="dropdown icon"
aria-hidden="true"
/>
Exporter les signalements Exporter les signalements
</div> </div>
<div :class="['content', { active: !showImport }]"> <div :class="['content', { active: !showImport && isOnline }]">
<p> <p>
Vous pouvez télécharger tous les signalements qui vous sont Vous pouvez télécharger tous les signalements qui vous sont
accessibles. accessibles.
</p> </p>
<select
v-model="exportFormat"
class="ui fluid dropdown"
style="margin-bottom: 1em;"
>
<option value="GeoJSON">
{{ feature_type.geom_type === 'none' ? 'JSON' : 'GeoJSON' }}
</option>
<option
v-if="feature_type.geom_type === 'point' || feature_type.geom_type === 'none'"
value="CSV"
>
CSV
</option>
</select>
<button <button
:disabled=" :class="{ loading: exportLoading }"
(geojsonFileToImport.size === 0 && !$route.params.geojson) && type="button"
(csvFileToImport.size === 0 && !$route.params.csv)
"
class="ui fluid teal icon button" class="ui fluid teal icon button"
@click="geojsonFileToImport.size !== 0 ? importGeoJson() : importCSV()" @click="exportFeatures"
> >
<i class="download icon" /> Exporter <i
class="download icon"
aria-hidden="true"
/>
Exporter
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="ui styled accordion">
<div
:class="['title', { active: !showImport && isOnline, nohover: !isOnline }]"
@click="toggleShowImport"
>
<i class="dropdown icon" />
Exporter les signalements
</div>
<div :class="['content', { active: !showImport && isOnline}]">
<p>
Vous pouvez télécharger tous les signalements qui vous sont
accessibles.
</p>
<!-- <div class="ui selection dropdown fluid">
<input type="hidden" name="format">
<i class="dropdown icon"></em>
<div class="default text">Format</div>
<div class="menu">
<div class="item" data-value="1">GeoJSON</div>
<div class="item" data-value="2">CSV</div>
</div>
</div> -->
<select
v-model="exportFormat"
class="ui fluid dropdown"
style="margin-bottom: 1em;"
>
<option value="GeoJSON">
GeoJSON
</option>
<option value="CSV">
CSV
</option>
</select>
<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"> <div
v-if="isOnline"
class="nine wide column"
>
<h3 class="ui header"> <h3 class="ui header">
Derniers signalements Derniers signalements
</h3> </h3>
...@@ -298,11 +279,12 @@ ...@@ -298,11 +279,12 @@
</div> </div>
<div <div
v-if=" v-if="
importFeatureTypeData && importsForFeatureType &&
importFeatureTypeData.length && importsForFeatureType.length &&
importFeatureTypeData.some((el) => el.status === 'pending') importsForFeatureType.some((el) => el.status === 'pending')
" "
class="ui message info" class="ui message info"
data-test="wait-import-message"
> >
<p> <p>
Des signalements sont en cours d'import. Pour suivre le statut de Des signalements sont en cours d'import. Pour suivre le statut de
...@@ -322,6 +304,7 @@ ...@@ -322,6 +304,7 @@
v-for="(feature, index) in lastFeatures" v-for="(feature, index) in lastFeatures"
:key="feature.feature_id + index" :key="feature.feature_id + index"
class="ui small header" class="ui small header"
data-test="last-features"
> >
<span <span
v-if="feature.status === 'archived'" v-if="feature.status === 'archived'"
...@@ -359,18 +342,10 @@ ...@@ -359,18 +342,10 @@
aria-hidden="true" aria-hidden="true"
/> />
</span> </span>
<router-link <FeatureFetchOffsetRoute
:to="{ :feature-id="feature.feature_id"
name: 'details-signalement', :properties="feature"
params: { />
slug,
slug_type_signal: $route.params.feature_type_slug,
slug_signal: feature.feature_id,
},
}"
>
{{ feature.title || feature.feature_id }}
</router-link>
<div class="sub header"> <div class="sub header">
<div> <div>
{{ {{
...@@ -381,7 +356,7 @@ ...@@ -381,7 +356,7 @@
</div> </div>
<div> <div>
[ Créé le {{ feature.created_on | formatDate }} [ Créé le {{ feature.created_on | formatDate }}
<span v-if="$store.state.user"> <span v-if="user">
par {{ feature.display_creator }}</span> par {{ feature.display_creator }}</span>
] ]
</div> </div>
...@@ -392,14 +367,17 @@ ...@@ -392,14 +367,17 @@
:to="{ name: 'liste-signalements', params: { slug } }" :to="{ name: 'liste-signalements', params: { slug } }"
class="ui right labeled icon button margin-25" class="ui right labeled icon button margin-25"
> >
<i class="right arrow icon" /> <i
class="right arrow icon"
aria-hidden="true"
/>
Voir tous les signalements Voir tous les signalements
</router-link> </router-link>
<router-link <router-link
v-if="permissions.can_create_feature" v-if="permissions.can_create_feature && feature_type.geom_type && !feature_type.geom_type.includes('multi')"
:to="{ :to="{
name: 'ajouter-signalement', name: 'ajouter-signalement',
params: { slug_type_signal: structure.slug }, params: { slug_type_signal: feature_type.slug },
}" }"
class="ui icon button button-hover-green margin-25" class="ui icon button button-hover-green margin-25"
> >
...@@ -407,23 +385,49 @@ ...@@ -407,23 +385,49 @@
</router-link> </router-link>
<br> <br>
</div> </div>
<div
v-else
class="nine wide column"
>
<h3 class="ui header">
Derniers signalements
</h3>
<div class="ui message info">
<p>
Information non disponible en mode déconnecté.
</p>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { csv } from 'csvtojson';
import { mapActions, mapMutations, mapGetters, mapState } from 'vuex'; import { mapActions, mapMutations, mapGetters, mapState } from 'vuex';
import { formatStringDate } from '@/utils'; import { formatStringDate, transformProperties } from '@/utils';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
import ImportTask from '@/components/ImportTask'; import ImportTask from '@/components/ImportTask';
import featureAPI from '@/services/feature-api'; import featureAPI from '@/services/feature-api';
import { fileConvertSizeToMo, determineDelimiter, parseCSV, checkLonLatValues } from '@/assets/js/utils';
import { fileConvertSizeToMo, csvToJson } from '@/assets/js/utils'; const geojsonFileToImport = {
name: 'Sélectionner un fichier GeoJSON ...',
size: 0,
};
const csvFileToImport = {
name: 'Sélectionner un fichier CSV ...',
size: 0,
};
export default { export default {
name: 'FeatureTypeDetail', name: 'FeatureTypeDetail',
components: { components: {
ImportTask: ImportTask, FeatureFetchOffsetRoute,
ImportTask,
}, },
filters: { filters: {
...@@ -446,21 +450,18 @@ export default { ...@@ -446,21 +450,18 @@ export default {
data() { data() {
return { return {
importError: '', importError: '',
geojsonFileToImport: { geojsonFileToImport,
name: 'Sélectionner un fichier GeoJSON ...', csvFileToImport,
size: 0,
},
csvFileToImport: {
name: 'Sélectionner un fichier CSV ...',
size: 0,
},
showImport: false, showImport: false,
slug: this.$route.params.slug, slug: this.$route.params.slug,
featureTypeSlug: this.$route.params.feature_type_slug,
featuresLoading: true, featuresLoading: true,
loadingImportFile: false, loadingImportFile: false,
waitMessage: false, waitMessage: false,
reloadingImport: false, exportFormat: 'GeoJSON',
exportFormat: 'GeoJSON' exportLoading: false,
lastFeatures: [],
featuresCount: 0,
}; };
}, },
...@@ -471,94 +472,41 @@ export default { ...@@ -471,94 +472,41 @@ export default {
...mapGetters('projects', [ ...mapGetters('projects', [
'project' 'project'
]), ]),
...mapGetters('feature-type', [
'feature_type'
]),
...mapState([ ...mapState([
'reloadIntervalId', 'reloadIntervalId',
'configuration', 'configuration',
'isOnline', 'isOnline',
'user',
]), ]),
...mapState('projects', [ ...mapState('projects', [
'project' 'project'
]), ]),
...mapState('feature', [
'features',
'features_count'
]),
...mapState('feature-type', [ ...mapState('feature-type', [
'feature_types', 'feature_types',
'importFeatureTypeData' 'importFeatureTypeData',
'selectedPrerecordedListValues'
]), ]),
importsForFeatureType() { // filter import task datas only for this feature type
if (this.importFeatureTypeData) {
return this.importFeatureTypeData.filter((el) => el.feature_type_title === this.featureTypeSlug);
}
return [];
},
CATALOG_NAME() { CATALOG_NAME() {
return this.configuration.VUE_APP_CATALOG_NAME; return this.configuration.VUE_APP_CATALOG_NAME;
}, },
IDGO() { IDGO() {
return this.$store.state.configuration.VUE_APP_IDGO; return this.$store.state.configuration.VUE_APP_IDGO;
}, },
structure: function () {
if (Object.keys(this.feature_types).length) {
const 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: { watch: {
structure(newValue) { feature_type(newValue) {
if (newValue.slug) { this.toggleJsonUploadOption(newValue);
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.getLastFeatures();
}
}
},
}, },
created() { created() {
...@@ -566,204 +514,399 @@ export default { ...@@ -566,204 +514,399 @@ export default {
this.$store.dispatch('projects/GET_PROJECT', this.slug); this.$store.dispatch('projects/GET_PROJECT', this.slug);
this.$store.dispatch('projects/GET_PROJECT_INFO', this.slug); this.$store.dispatch('projects/GET_PROJECT_INFO', this.slug);
} }
this.$store.commit('feature/SET_FEATURES', []); //* empty remaining features in case they were in geojson format and will be fetch anyway
this.getLastFeatures();
this.SET_CURRENT_FEATURE_TYPE_SLUG( this.SET_CURRENT_FEATURE_TYPE_SLUG(
this.$route.params.feature_type_slug this.featureTypeSlug
); );
this.$store.dispatch('feature-type/GET_IMPORTS', {
project_slug: this.$route.params.slug,
feature_type: this.featureTypeSlug
});
this.getLastFeatures();
if (this.$route.params.type === 'external-geojson') { if (this.$route.params.type === 'external-geojson') {
this.showImport = true; this.showImport = true;
} }
// empty prerecorded lists in case the list has been previously loaded with a limit in other component like ExtraForm
this.SET_PRERECORDED_LISTS([]);
// This function is also called by watcher at this stage, but to be safe in edge case
this.toggleJsonUploadOption(this.feature_type);
}, },
methods: { methods: {
...mapMutations([
'DISPLAY_MESSAGE',
]),
...mapMutations('feature-type', [ ...mapMutations('feature-type', [
'SET_CURRENT_FEATURE_TYPE_SLUG' 'SET_CURRENT_FEATURE_TYPE_SLUG',
'SET_FILE_TO_IMPORT',
'SET_PRERECORDED_LISTS'
]), ]),
...mapActions('feature-type', [ ...mapActions('feature-type', [
'GET_IMPORTS' 'GET_PROJECT_FEATURE_TYPES',
'GET_SELECTED_PRERECORDED_LIST_VALUES',
'SEND_FEATURES_FROM_CSV',
]), ]),
...mapActions('feature', [ ...mapActions('feature', [
'GET_PROJECT_FEATURES' 'GET_PROJECT_FEATURES'
]), ]),
getLastFeatures() {
let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-paginated/?feature_type_slug=${this.featureTypeSlug}&ordering=-created_on&limit=5&offset=0`;
featureAPI.getPaginatedFeatures(url)
.then((data) => {
if (data) {
this.lastFeatures = data.results;
this.featuresCount = data.count;
}
this.featuresLoading = false;
});
},
reloadFeatureType() {
this.GET_PROJECT_FEATURE_TYPES(this.slug);
this.getLastFeatures();
},
toggleShowImport() { toggleShowImport() {
this.showImport = !this.showImport; this.showImport = !this.showImport;
if (this.showImport) {
this.GET_IMPORTS({
feature_type: this.$route.params.feature_type_slug
});
}
}, },
transformProperties(prop) { /**
const type = typeof prop; * In the case of a non geographical feature type, replace geoJSON by JSON in file to upload options
const date = new Date(prop); *
if (type === 'boolean') { * @param {Object} featureType - The current featureType.
return 'boolean'; */
} else if (Number.isSafeInteger(prop)) { toggleJsonUploadOption(featureType) {
return 'integer'; if (featureType && featureType.geom_type === 'none') {
} else if ( this.geojsonFileToImport = {
type === 'string' && name: 'Sélectionner un fichier JSON ...',
['/', ':', '-'].some((el) => prop.includes(el)) && // check for chars found in datestring size: 0,
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) { async checkPreRecordedValue(fieldValue, listName) {
const fieldLabel = fieldValue.label || fieldValue;
// encode special characters like apostrophe or white space
const encodedPattern = encodeURIComponent(fieldLabel);
// query existing prerecorded list values (with label to limit results in response, there could be many) and escape special characters, since single quote causes error in backend
await this.GET_SELECTED_PRERECORDED_LIST_VALUES({ name: listName, pattern: encodedPattern });
// check if the value exist in available prerecorded list values
return this.selectedPrerecordedListValues[listName].some((el) => el.label === fieldLabel);
},
/**
* Validates the imported data against the pre-determined field types.
*
* This function iterates over all imported features and checks if each property's value matches
* the expected type specified in the feature type schema. It accommodates specific type conversions,
* such as allowing numerical strings for 'char' or 'text' fields and converting string representations
* of booleans and lists as necessary.
*
* @param {Array} features - The array of imported features to validate.
* @returns {boolean} Returns true if all features pass the validation; otherwise, false with an error message.
*/
async isValidTypes(features) {
this.importError = ''; this.importError = '';
const fields = this.structure.customfield_set.map((el) => {
// Extract relevant field type information from the feature type schema
const fields = this.feature_type.customfield_set.map((el) => {
return { return {
name: el.name, name: el.name,
field_type: el.field_type, field_type: el.field_type,
options: el.options, options: el.options,
}; };
}); });
for (const feature of json.features) { let count = 1;
for (const feature of features) {
this.$store.commit('DISPLAY_LOADER', `Vérification du signalement ${count} sur ${features.length}`);
for (const { name, field_type, options } of fields) { for (const { name, field_type, options } of fields) {
if (name in feature.properties) { const properties = feature.properties || feature;
const fieldInFeature = feature.properties[name];
const customType = this.transformProperties(fieldInFeature); if (name in properties) {
//* if custom field value is not null, then check validity of field let fieldInFeature = properties[name];
if (fieldInFeature !== null) {
//* if field type is list, it's not possible to guess from value type // Convert boolean strings from CSV to actual booleans
if (field_type === 'boolean') {
fieldInFeature = fieldInFeature === 'True' ? true : (fieldInFeature === 'False' ? false : fieldInFeature);
}
const customType = transformProperties(fieldInFeature);
// Validate field only if it has a non-null, non-empty, defined value
if (fieldInFeature !== null && fieldInFeature !== '' && fieldInFeature !== undefined) {
// Handle 'list' type by checking if value is among the defined options
if (field_type === 'list') { if (field_type === 'list') {
//*then check if the value is an available option
if (!options.includes(fieldInFeature)) { if (!options.includes(fieldInFeature)) {
this.importError = `Fichier invalide: La valeur "${fieldInFeature}" n'est pas une option valide dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
// Handle 'pre_recorded_list' by checking if the value matches pre-recorded options
} else if (field_type === 'pre_recorded_list') {
if (typeof fieldInFeature === 'string' && fieldInFeature.charAt(0) === '{') { // data from CSV come as string, if it doesn't start with bracket then it should not be converted to an object and stay as a string, since the structure has been simplified: https://redmine.neogeo.fr/issues/18740
try {
const jsonStr = fieldInFeature.replace(/['‘’"]\s*label\s*['‘’"]\s*:/g, '"label":')
.replace(/:\s*['‘’"](.+?)['‘’"]\s*(?=[,}])/g, ':"$1"');
fieldInFeature = JSON.parse(jsonStr);
} catch (e) {
console.error(e);
this.DISPLAY_MESSAGE({ comment: `La valeur "${fieldInFeature}" n'a pas pu être vérifiée dans le champ "${name}" du signalement "${properties.title}"` });
}
}
let fieldLabel = fieldInFeature.label || fieldInFeature;
const isPreRecordedValue = await this.checkPreRecordedValue(fieldLabel, options[0]);
if (!isPreRecordedValue) {
this.importError = `Fichier invalide: La valeur "${fieldLabel}" ne fait pas partie des valeurs pré-enregistrées dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
// Handle 'multi_choices_list' by checking if each value in the array is among the defined options
} else if (field_type === 'multi_choices_list') {
if (typeof fieldInFeature === 'string' && fieldInFeature.charAt(0) === '[') { // data from CSV come as string, if it doesn't start with bracket then there's no need to convert it to an array
try {
fieldInFeature = JSON.parse(fieldInFeature.replaceAll('\'', '"'));
} catch (e) {
console.error(e);
this.DISPLAY_MESSAGE({ comment: `La valeur "${fieldInFeature}" n'a pas pu être vérifiée dans le champ "${name}" du signalement "${properties.title}"` });
}
}
// Check that the value is an array before asserting its validity
if (Array.isArray(fieldInFeature)) {
const invalidValues = fieldInFeature.filter((el) => !options.includes(el));
if (invalidValues.length > 0) {
const plural = invalidValues.length > 1;
this.importError = `Fichier invalide: ${plural ? 'Les valeurs' : 'La valeur'} "${invalidValues.join(', ')}" ${plural ? 'ne sont pas des options valides' : 'n\'est pas une option valide'}
dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false;
}
} else {
this.importError = `Fichier invalide: La valeur "${fieldInFeature}" doit être un tableau dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false; return false;
} }
} else if (customType !== field_type) {
//* check if custom field value match // Validate custom field value type
this.importError = `Le fichier est invalide: Un champ de type ${field_type} ne peut pas avoir la valeur [ ${fieldInFeature} ]`; } else if (customType !== field_type &&
// at feature type at creation, in case the value was 0, since it can be either float or integer, by default we've set its type as a float
// when importing features, to avoid an error with different types, we bypass this check when the incoming feature value is a integer while the feature type says it should be a float
!(
// Allow integers where decimals are expected
(customType === 'integer' && field_type === 'decimal') ||
// Allow numbers formatted as strings when 'char' or 'text' type is expected
((customType === 'integer' || customType === 'float') && field_type === 'char' || field_type === 'text') ||
// Allow 'char' values where 'text' (multiline string) is expected
(customType === 'char' && field_type === 'text')
)
) {
this.importError = `Fichier invalide : Le type de champ "${field_type}" ne peut pas avoir la valeur "${fieldInFeature}" dans le champ "${name}" du signalement "${properties.title}".`;
this.$store.commit('DISCARD_LOADER');
return false; return false;
} }
} }
} }
} }
count +=1;
} }
this.$store.commit('DISCARD_LOADER');
return true; return true;
}, },
checkCsvValidity(csv) { /**
* Checks the validity of a CSV string. It ensures the CSV uses a recognized delimiter,
* contains 'lat' and 'lon' headers, and that these columns contain decimal values within valid ranges.
* Additionally, it verifies the consistency and presence of data in the CSV, and that the types of values are valid.
*
* @param {string} csvString - The CSV content in string format.
* @returns {boolean|Promise<boolean>} Returns a boolean or a Promise resolving to a boolean,
* indicating the validity of the CSV.
*/
async checkCsvValidity(csvString) {
this.importError = ''; this.importError = '';
// Check if file contains 'lat' and 'long' fields
const headersLine = // Determine the delimiter of the CSV
csv const delimiter = determineDelimiter(csvString);
.split('\n')[0] if (!delimiter) {
.split(',') this.importError = `Le fichier ${this.csvFileToImport.name} n'est pas formaté correctement`;
.filter(el => { return false;
return el === 'lat' || el === 'lon'; }
});
// Look for 2 decimal fields in first line of csv // Parse the CSV string into rows
// corresponding to lon and lat const rows = parseCSV(csvString, delimiter);
const sampleLine =
csv // Extract headers
.split('\n')[1] const headers = rows.shift();
.split(',') if (this.feature_type.geom_type !== 'none') {
.map(el => { // Check for required fields 'lat' and 'lon' in headers
return !isNaN(el) && el.indexOf('.') != -1; if (!headers.includes('lat') || !headers.includes('lon')) {
}) this.importError = 'Les champs obligatoires "lat" et "lon" sont absents des headers.';
.filter(Boolean); return false;
if (sampleLine.length > 1 && headersLine.length === 2) {
const fields = this.structure.customfield_set.map((el) => {
return {
name: el.name,
field_type: el.field_type,
options: el.options,
};
});
const csvFeatures = csvToJson(csv);
for (const feature of csvFeatures) {
for (const { name, field_type, options } of fields) {
if (name in feature) {
const fieldInFeature = feature[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; // Verify the presence and validity of coordinate values
} else { const hasCoordValues = checkLonLatValues(headers, rows);
if (!hasCoordValues) {
this.importError = 'Les valeurs de "lon" et "lat" ne sont pas valides ou absentes.';
return false;
}
}
// Ensure there are data rows after the headers
if (rows.length === 0) {
this.importError = 'Aucune donnée trouvée après les en-têtes.';
return false;
}
// Ensure that each row has the same number of columns as the headers
if (rows.some(row => row.length !== headers.length)) {
this.importError = 'Incohérence dans le nombre de colonnes par ligne.';
return false; return false;
} }
// Convert the CSV string to a JSON object for further processing
const jsonFromCsv = await csv({ delimiter }).fromString(csvString);
// Validate the types of values in the JSON object
const validity = await this.isValidTypes(jsonFromCsv);
return validity;
}, },
onGeojsonFileChange(e) { /**
* Handles the change event for GeoJSON file input. This function is triggered when a user selects a file.
* It reads the file, checks its validity if it's not too large, and updates the component state accordingly.
*
* @param {Event} e - The event triggered by file input change.
*/
async onGeojsonFileChange(e) {
// Start loading process
this.loadingImportFile = true; this.loadingImportFile = true;
// Clear any previously selected CSV file to avoid confusion
this.csvFileToImport = csvFileToImport;
// Retrieve the files from the event
const files = e.target.files || e.dataTransfer.files; const files = e.target.files || e.dataTransfer.files;
// If no file is selected, stop the loading process and return
if (!files.length) { if (!files.length) {
this.loadingImportFile = false; this.loadingImportFile = false;
return; return;
} }
const reader = new FileReader(); const reader = new FileReader();
reader.addEventListener('load', (e) => {
// bypass json check for files larger then 10 Mo /**
* Asynchronously processes the content of the file.
* Checks the validity of the GeoJSON file if it's smaller than a certain size.
* Updates the state with the GeoJSON file if it's valid.
*
* @param {string} fileContent - The content of the file read by FileReader.
*/
const processFile = async (fileContent) => {
let jsonValidity; let jsonValidity;
if (parseFloat(fileConvertSizeToMo(files[0])) <= 10) {
jsonValidity = this.checkJsonValidity(JSON.parse(e.target.result)); // Check the file size and determine the GeoJSON validity
if (parseFloat(fileConvertSizeToMo(files[0].size)) <= 10) {
// If the file is smaller than 10 Mo, check its validity
try {
const json = JSON.parse(fileContent);
jsonValidity = await this.isValidTypes(json.features || json);
} catch (error) {
this.DISPLAY_MESSAGE({ comment: error, level: 'negative' });
jsonValidity = false;
}
} else { } else {
// Assume validity for larger files
jsonValidity = true; jsonValidity = true;
} }
// If the GeoJSON is valid, update the component state with the file and set the file in store
if (jsonValidity) { if (jsonValidity) {
this.geojsonFileToImport = files[0]; // todo : remove this value from state as it stored (first attempt didn't work) this.geojsonFileToImport = files[0];
this.$store.commit( this.SET_FILE_TO_IMPORT(this.geojsonFileToImport);
'feature_type/SET_FILE_TO_IMPORT', } else {
this.geojsonFileToImport // Clear any previously selected geojson file to disable import button
); this.geojsonFileToImport = geojsonFileToImport;
this.toggleJsonUploadOption(this.feature_type);
} }
// Stop the loading process
this.loadingImportFile = false; this.loadingImportFile = false;
}); };
// Setup the load event listener for FileReader
reader.addEventListener('load', (e) => processFile(e.target.result));
// Read the text from the selected file
reader.readAsText(files[0]); reader.readAsText(files[0]);
}, },
onCsvFileChange(e) { /**
* Handles the change event for CSV file input. This function is triggered when a user selects a file.
* It reads the file, checks its validity if it's not too large, and updates the component state accordingly.
*
* @param {Event} e - The event triggered by file input change.
*/
async onCsvFileChange(e) {
// Start loading process
this.loadingImportFile = true; this.loadingImportFile = true;
// Clear any previously selected geojson file to avoid confusion
this.geojsonFileToImport = geojsonFileToImport;
this.toggleJsonUploadOption(this.feature_type);
// Retrieve the files from the event
const files = e.target.files || e.dataTransfer.files; const files = e.target.files || e.dataTransfer.files;
// If no file is selected, stop the loading process and return
if (!files.length) { if (!files.length) {
this.loadingImportFile = false; this.loadingImportFile = false;
return; return;
} }
// Create a new FileReader to read the selected file
const reader = new FileReader(); const reader = new FileReader();
reader.addEventListener('load', (e) => {
// bypass csv check for files larger then 10 Mo /**
* Asynchronously processes the content of the file.
* Checks the validity of the CSV file if it's smaller than a certain size.
* Updates the state with the CSV file if it's valid.
*
* @param {string} fileContent - The content of the file read by FileReader.
*/
const processFile = async (fileContent) => {
let csvValidity; let csvValidity;
// Check the file size and determine the CSV validity
if (parseFloat(fileConvertSizeToMo(files[0].size)) <= 10) { if (parseFloat(fileConvertSizeToMo(files[0].size)) <= 10) {
csvValidity = this.checkCsvValidity(e.target.result); // If the file is smaller than 10 Mo, check its validity
csvValidity = await this.checkCsvValidity(fileContent);
} else { } else {
// Assume validity for larger files
csvValidity = true; csvValidity = true;
} }
// If the CSV is valid, update the component state with the file
if (csvValidity) { if (csvValidity) {
this.csvFileToImport = files[0]; // todo : remove this value from state as it stored (first attempt didn't work) this.csvFileToImport = files[0]; // TODO: Remove this value from state as it is stored (first attempt didn't work)
this.$store.commit( this.SET_FILE_TO_IMPORT(this.csvFileToImport);
'feature_type/SET_FILE_TO_IMPORT', } else {
this.csvFileToImport // Clear any previously selected geojson file to disable import button
); this.csvFileToImport = csvFileToImport;
} }
// Stop the loading process
this.loadingImportFile = false; this.loadingImportFile = false;
}); };
// Setup the load event listener for FileReader
reader.addEventListener('load', (e) => processFile(e.target.result));
// Read the text from the selected file
reader.readAsText(files[0]); reader.readAsText(files[0]);
}, },
...@@ -771,19 +914,20 @@ export default { ...@@ -771,19 +914,20 @@ export default {
this.waitMessage = true; this.waitMessage = true;
const payload = { const payload = {
slug: this.slug, slug: this.slug,
feature_type_slug: this.$route.params.feature_type_slug, feature_type_slug: this.featureTypeSlug,
}; };
if (this.$route.params.geojson) { //* import after redirection, for instance with data from catalog if (this.$route.params.geojson) { //* import after redirection, for instance with data from catalog
payload['geojson'] = this.$route.params.geojson; payload['geojson'] = this.$route.params.geojson;
} else if (this.geojsonFileToImport.size > 0) { //* import directly from geojson } else if (this.geojsonFileToImport.size > 0) { //* import directly from geojson
payload['fileToImport'] = this.geojsonFileToImport; payload['fileToImport'] = this.geojsonFileToImport;
} else { } else {
this.importError = "La ressource n'a pas pu être récupéré."; this.importError = 'La ressource n\'a pas pu être récupéré.';
return; return;
} }
this.$store.dispatch('feature-type/SEND_FEATURES_FROM_GEOJSON', payload) this.$store.dispatch('feature-type/SEND_FEATURES_FROM_GEOJSON', payload)
.then(() => { .then(() => {
this.waitMessage = false; this.waitMessage = false;
this.$refs.importTask.fetchImports();
}); });
}, },
...@@ -791,52 +935,60 @@ export default { ...@@ -791,52 +935,60 @@ export default {
this.waitMessage = true; this.waitMessage = true;
const payload = { const payload = {
slug: this.slug, slug: this.slug,
feature_type_slug: this.$route.params.feature_type_slug, feature_type_slug: this.featureTypeSlug,
}; };
if (this.$route.params.csv) { //* import after redirection, for instance with data from catalog if (this.$route.params.csv) { //* import after redirection, for instance with data from catalog
payload['csv'] = this.$route.params.csv; payload['csv'] = this.$route.params.csv;
} else if (this.csvFileToImport.size > 0) { //* import directly from geojson } else if (this.csvFileToImport.size > 0) { //* import directly from csv file
payload['fileToImport'] = this.csvFileToImport; payload['fileToImport'] = this.csvFileToImport;
} else { } else {
this.importError = "La ressource n'a pas pu être récupéré."; this.importError = "La ressource n'a pas pu être récupéré.";
return; return;
} }
this.$store.dispatch('feature_type/SEND_FEATURES_FROM_CSV', payload) this.SEND_FEATURES_FROM_CSV(payload)
.then(() => { .then(() => {
this.waitMessage = false; this.waitMessage = false;
this.$refs.importTask.fetchImports();
}); });
}, },
exportFeatures() { exportFeatures() {
const url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-type/${this.$route.params.feature_type_slug}/export/`; this.exportLoading = true;
featureAPI.getFeaturesBlob(url).then((blob) => { let exportFormat = this.feature_type.geom_type === 'none' && this.exportFormat === 'GeoJSON' ? 'json' : this.exportFormat.toLowerCase();
if (blob) { const url = `
const link = document.createElement('a'); ${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature-type/${this.featureTypeSlug}/export/?format_export=${exportFormat}
link.href = URL.createObjectURL(blob); `;
link.download = `${this.project.title}-${this.structure.title}.json`; featureAPI.getFeaturesBlob(url)
link.click(); .then((blob) => {
URL.revokeObjectURL(link.href); if (blob) {
} const link = document.createElement('a');
}); link.href = URL.createObjectURL(blob);
}, link.download = `${this.project.title}-${this.feature_type.title}.${exportFormat}`;
async getLastFeatures(){ link.click();
const response = await setTimeout(function(){
this.GET_PROJECT_FEATURES({ URL.revokeObjectURL(link.href);
project_slug: this.slug, }, 1000);
feature_type__slug : this.$route.params.feature_type_slug, }
ordering: '-created_on', this.exportLoading = false;
limit: '5' })
}); .catch(() => {
this.exportLoading = false;
if (response) { });
this.featuresLoading = false;
}
}, },
}, },
}; };
</script> </script>
<style scoped> <style scoped lang="less">
#feature-type-title i {
color: #000000;
margin: auto;
}
.custom-field.content {
overflow: hidden;
text-overflow: ellipsis;
}
.margin-25 { .margin-25 {
margin: 0 0.25em 0.25em 0 !important; margin: 0 0.25em 0.25em 0 !important;
} }
...@@ -852,4 +1004,17 @@ export default { ...@@ -852,4 +1004,17 @@ export default {
.ui.styled.accordion .nohover.title:hover { .ui.styled.accordion .nohover.title:hover {
color: rgba(0, 0, 0, .4); color: rgba(0, 0, 0, .4);
} }
.ui.styled.accordion {
.content {
.field {
label {
width: 100%;
}
}
.import-catalog {
width: 100%;
}
}
}
</style> </style>
\ No newline at end of file
<template>
<div id="displayCustomisation">
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui loader" />
</div>
<h1 v-if="project && feature_type">
Modifier l'affichage sur la carte des signalements de type "{{ feature_type.title }}" pour le
projet "{{ project.title }}"
</h1>
<section id="symbology">
<h3>Symbologie</h3>
<form
id="form-symbology-edit"
action=""
method="post"
enctype="multipart/form-data"
class="ui form"
>
<SymbologySelector
v-if="feature_type"
id="default"
:init-color="feature_type.color"
:init-icon="feature_type.icon"
:init-opacity="feature_type.opacity"
:geom-type="feature_type.geom_type"
@set="setDefaultStyle"
/>
<div
v-if="customizableFields.length > 0"
class="fields inline"
>
<label
id="customfield-select-label"
for="customfield-select"
>
Champ de personnalisation de la symbologie:
</label>
<div id="custom_types-dropdown">
<Dropdown
:options="customizableFields"
:selected="selectedCustomfield"
:selection.sync="selectedCustomfield"
:clearable="true"
/>
</div>
</div>
<div
v-if="selectedCustomfield"
id="customFieldSymbology"
class="field"
>
<SymbologySelector
v-for="option of selectedFieldOptions"
:id="option"
:key="option"
: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
"
:init-opacity="getOpacity(feature_type, option)"
:geom-type="feature_type.geom_type"
@set="setColorsStyle"
/>
</div>
</form>
</section>
<div class="ui divider" />
<section
v-if="feature_type && feature_type.customfield_set"
id="popupDisplay"
>
<h3>Prévisualisation des champs personnalisés de l'info-bulle</h3>
<table
id="table-fields-to-display"
class="ui definition single line compact table"
aria-describedby="Liste des champs à afficher"
>
<thead>
<tr>
<th scope="col">
Prévisualisation du champ
</th>
<th scope="col">
Champ
</th>
<th scope="col">
Type
</th>
</tr>
</thead>
<tbody>
<tr
v-for="field in featureAnyFields"
:key="field.name"
:class="{ first_customfield: feature_type.customfield_set[0] &&
field.name === feature_type.customfield_set[0].name }"
>
<td
scope="row"
class="collapsing center aligned"
>
<div class="ui toggle checkbox">
<input
:checked="form.displayed_fields.includes(field.name)"
type="checkbox"
@input="toggleDisplay($event, field.name)"
>
<label />
</div>
</td>
<td scope="row">
{{ field.name }} ({{ field.label }})
</td>
<td scope="row">
{{ field.field_type || getCustomFieldType(field.field_type) }}
</td>
</tr>
</tbody>
</table>
</section>
<section id="notification">
<h3>Configuration de la notification d'abonnement</h3>
<div class="ui form">
<div class="field">
<div class="ui checkbox">
<input
id="enable_key_doc_notif"
v-model="form.enable_key_doc_notif"
class="hidden"
name="enable_key_doc_notif"
type="checkbox"
>
<label for="enable_key_doc_notif">Activer la notification de publication de pièces jointes</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="disable_notification"
v-model="form.disable_notification"
class="hidden"
name="disable_notification"
type="checkbox"
>
<label for="disable_notification">Désactiver les notifications</label>
</div>
</div>
</div>
</section>
<button
id="save-display"
class="ui teal icon button margin-25"
type="button"
:disabled="!canSaveDisplayConfig"
@click="sendDisplayConfig"
>
<i
class="white save icon"
aria-hidden="true"
/>
Sauvegarder l'affichage du type de signalement
</button>
</div>
</template>
<script>
import { isEqual } from 'lodash';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import { customFieldTypeChoices, featureNativeFields } from '@/utils';
import SymbologySelector from '@/components/FeatureType/SymbologySelector.vue';
import Dropdown from '@/components/Dropdown.vue';
export default {
name: 'FeatureTypeDisplay',
components: {
SymbologySelector,
Dropdown,
},
data() {
return {
loading: false,
form: {
color: '#000000',
icon: 'circle',
colors_style: {
fields: [],
colors: {},
icons: {},
opacities: {},
custom_field_name: '',
value: {
colors: {},
icons: {},
opacities: {},
}
},
displayed_fields: ['status', 'feature_type', 'updated_on'],
enable_key_doc_notif: false,
disable_notification: false,
},
canSaveDisplayConfig: false
};
},
computed: {
...mapState('projects', [
'project'
]),
...mapState('feature-type', [
'customForms',
'colorsStyleList'
]),
...mapGetters('feature-type', [
'feature_type'
]),
customizableFields() {
if (this.feature_type) {
let options = this.feature_type.customfield_set.filter(el => el.field_type === 'list' || el.field_type === 'char' || el.field_type === 'boolean');
options = options.map((el) => {
return { name: [el.name, this.getCustomFieldType(el.field_type)], value: el };
});
return options;
}
return [];
},
selectedFieldOptions() {
if (this.selectedCustomfield) {
const customFieldSet = this.feature_type.customfield_set.find(el => el.name === this.selectedCustomfield);
if (customFieldSet) {
if (customFieldSet.options && customFieldSet.options.length > 0) {
return customFieldSet.options;
} else if (customFieldSet.field_type === 'char') {
return ['Vide', 'Non vide'];
} else if (customFieldSet.field_type === 'boolean') {
return ['Décoché', 'Coché'];
}
}
}
return [];
},
selectedCustomfield: {
get() {
return this.form.colors_style.custom_field_name;
},
set(newValue) {
if (newValue !== undefined) {
this.form.colors_style.custom_field_name = newValue.value ? newValue.value.name : null;
}
}
},
featureAnyFields() {
return [...featureNativeFields, ...this.feature_type.customfield_set];
}
},
watch: {
feature_type(newValue) {
// In which case the feature type would change while on this page ?
if (newValue) {
this.initForm();
}
},
form: {
deep: true,
handler(newValue) {
// checks if they are changes to be saved to enable save button
if (isEqual(newValue, {
color: this.feature_type.color,
icon: this.feature_type.icon,
opacity: this.feature_type.opacity,
colors_style: this.feature_type.colors_style,
displayed_fields: this.feature_type.displayed_fields,
enable_key_doc_notif: this.feature_type.enable_key_doc_notif,
disable_notification: this.feature_type.disable_notification
})) {
this.canSaveDisplayConfig = false;
} else {
this.canSaveDisplayConfig = true;
}
}
}
},
created() {
if (!this.project) {
this.GET_PROJECT(this.$route.params.slug);
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();
// TODO : Use the global loader and get rid of this redondant loader
this.loading = false;
})
.catch(() => {
this.loading = false;
});
}
},
methods: {
...mapMutations('feature-type', [
'SET_CURRENT_FEATURE_TYPE_SLUG'
]),
...mapActions('feature-type', [
'SEND_FEATURE_DISPLAY_CONFIG',
'GET_PROJECT_FEATURE_TYPES'
]),
...mapActions('projects', [
'GET_PROJECT',
'GET_PROJECT_INFO',
]),
initForm() {
this.form.color = JSON.parse(JSON.stringify(this.feature_type.color)); //? wouldn't be better to use lodash: https://medium.com/@pmzubar/why-json-parse-json-stringify-is-a-bad-practice-to-clone-an-object-in-javascript-b28ac5e36521
this.form.icon = JSON.parse(JSON.stringify(this.feature_type.icon)); //? since the library is already imported ?
this.form.colors_style = {
...this.form.colors_style,
...JSON.parse(JSON.stringify(this.feature_type.colors_style))
};
if (!this.form.colors_style.value['opacities']) { //* if the opacity values were never setted (but why would it happen, is it necessary ?)
this.form.colors_style.value['opacities'] = {};
}
if (this.feature_type.colors_style && Object.keys(this.feature_type.colors_style.colors).length > 0) {
const coloredCustomField = this.feature_type.customfield_set.find(
el => el.name === this.feature_type.colors_style.custom_field_name
);
if (coloredCustomField) {
this.selectedCustomfield = coloredCustomField.name;
}
}
if (this.feature_type && this.feature_type.displayed_fields) {
this.form.displayed_fields = [...this.feature_type.displayed_fields];
}
this.form.enable_key_doc_notif = this.feature_type.enable_key_doc_notif;
this.form.disable_notification = this.feature_type.disable_notification;
},
setDefaultStyle(e) {
const { color, icon, opacity } = e.value;
this.form.color = color.value;
this.form.icon = icon;
this.form.opacity = opacity;
},
setColorsStyle(e) {
const { name, value } = e;
const { color, icon, opacity } = value;
this.form.colors_style.colors[name] = color;
this.form.colors_style.icons[name] = icon;
this.form.colors_style.opacities[name] = opacity;
this.form.colors_style.value.colors[name] = color;
this.form.colors_style.value.icons[name] = icon;
this.form.colors_style.value.opacities[name] = opacity; //? why do we need to duplicate values ? for MVT ?
},
toggleDisplay(evt, name) {
if (evt.target.checked) {
this.form.displayed_fields.push(name);
} else {
this.form.displayed_fields = this.form.displayed_fields.filter(el => el !== name);
}
},
sendDisplayConfig() {
this.loading = true;
this.SEND_FEATURE_DISPLAY_CONFIG(this.form)
.then(() => {
this.loading = false;
this.$router.push({
name: 'project_detail',
params: {
slug: this.$route.params.slug,
message: { comment: `La modification de l'affichage du type de signalement "${this.feature_type.title}" a été prise en compte.`, level: 'positive' }
},
});
})
.catch((err) => {
console.error(err);
this.$store.commit('DISPLAY_MESSAGE', {
comment: `Une erreur est survenue pendant l'envoi des modifications de l'affichage du type de signalement "${this.feature_type.title}"`,
level: 'negative'
});
this.loading = false;
});
},
getOpacity(feature_type, optionName) {
if (feature_type.colors_style.value && feature_type.colors_style.value.opacities) {
return feature_type.colors_style.value.opacities[optionName];
}
return null;
},
getCustomFieldType(fieldType) {
return customFieldTypeChoices.find(el => el.value === fieldType).name;
}
}
};
</script>
<style lang="less" scoped>
#displayCustomisation {
h1 {
margin-top: 1em;
}
form {
text-align: left;
margin-left: 1em;
#customfield-select-label {
font-weight: 600;
font-size: 1.1em;
}
#custom_types-dropdown {
margin: 1em;
&& > .dropdown {
width: 50%;
}
}
}
}
section {
padding: 1.5em 0;
// shrink toggle background width and height
.ui.toggle.checkbox .box::after, .ui.toggle.checkbox label::after {
height: 15px;
width: 15px;
}
.ui.toggle.checkbox .box, .ui.toggle.checkbox label {
padding-left: 2.5rem;
}
// reduce toggle button width and height
.ui.toggle.checkbox .box::before, .ui.toggle.checkbox label::before {
height: 15px;
width: 35px;
}
// adjust toggled button placement
.ui.toggle.checkbox input:checked ~ .box::after, .ui.toggle.checkbox input:checked ~ label::after {
left: 20px;
}
.ui.toggle.checkbox .box, .ui.toggle.checkbox label, .ui.toggle.checkbox {
min-height: 15px;
}
table {
border-collapse: collapse;
}
tr.first_customfield td {
border-top-width: 4px !important;
}
}
</style>
<template> <template>
<div <div id="feature-type-edit">
id="feature-type-edit"
class="page"
>
<div
id="message"
class="fullwidth"
>
<div
v-if="error"
class="ui negative message"
>
<p>
<i
class="cross icon"
aria-hidden="true"
/>
{{ error }}
</p>
</div>
</div>
<div class="fourteen wide column"> <div class="fourteen wide column">
<div <div
:class="{ active: loading }" :class="{ active: loading }"
...@@ -30,9 +10,6 @@ ...@@ -30,9 +10,6 @@
<form <form
v-if="project" v-if="project"
id="form-type-edit" id="form-type-edit"
action=""
method="post"
enctype="multipart/form-data"
class="ui form" class="ui form"
> >
<h1 v-if="action === 'create'"> <h1 v-if="action === 'create'">
...@@ -74,8 +51,8 @@ ...@@ -74,8 +51,8 @@
</div> </div>
<div <div
:class="{ disabled: csv }" id="geometry-type"
class="required field" :class="['required field', { disabled: csv }]"
> >
<label :for="form.geom_type.id_for_label">{{ <label :for="form.geom_type.id_for_label">{{
form.geom_type.label form.geom_type.label
...@@ -86,22 +63,6 @@ ...@@ -86,22 +63,6 @@
:selection.sync="selectedGeomType" :selection.sync="selectedGeomType"
/> />
</div> </div>
<div
v-if="selectedGeomType !== 'Point'"
class="required field"
>
<label :for="form.color.id_for_label">{{ form.color.label }}</label>
<input
:id="form.color.id_for_label"
v-model="form.color.value"
type="color"
required
style="width: 100%; height: 38px"
:name="form.color.html_name"
@blur="updateStore"
>
</div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
...@@ -115,167 +76,82 @@ ...@@ -115,167 +76,82 @@
<label :for="form.title_optional.html_name">{{ form.title_optional.label }}</label> <label :for="form.title_optional.html_name">{{ form.title_optional.label }}</label>
</div> </div>
</div> </div>
<div class="field">
<!-- //* s'affiche après sélection d'option de type liste dans type de champ --> <div class="ui checkbox">
<div <input
v-if="colorsStyleList.length > 0 && selectedGeomType !== 'Point'" :id="form.enable_key_doc_notif.html_name"
id="id_style_container" v-model="form.enable_key_doc_notif.value"
class="custom_style" class="hidden"
> :name="form.enable_key_doc_notif.html_name"
<div type="checkbox"
id="id_list_selection" >
class="list_selection" <label :for="form.enable_key_doc_notif.html_name">{{ form.enable_key_doc_notif.label }}</label>
>
<Dropdown
:options="colorsStyleList"
:selected="selected_colors_style"
:selection.sync="selected_colors_style"
:placeholder="'Sélectionner la liste de valeurs'"
/>
</div> </div>
<div </div>
id="id_colors_selection" <div class="field">
class="colors_selection" <div class="ui checkbox">
hidden <input
> :id="form.disable_notification.html_name"
<div v-model="form.disable_notification.value"
v-for="(value, key, index) in form.colors_style.value.colors" class="hidden"
:key="'colors_style-' + index" :name="form.disable_notification.html_name"
type="checkbox"
> >
<div <label :for="form.disable_notification.html_name">{{ form.disable_notification.label }}</label>
v-if="key"
class="color-input"
>
<label>{{ key }}</label><input
:name="key"
type="color"
:value="value"
@input="setColorStyles"
>
</div>
</div>
</div> </div>
</div> </div>
<span v-if="action === 'duplicate' || action === 'edit'" /> <div id="formsets">
<FeatureTypeCustomForm
v-for="customForm in customForms"
:key="customForm.dataKey"
ref="customForms"
:data-key="customForm.dataKey"
:custom-form="customForm"
:selected-color-style="form.colors_style.value.custom_field_name"
@update="updateColorsStyle($event)"
/>
</div>
<!-- <div <button
v-if="csvFields && csvFields.length" id="add-field"
id="csv-fields" type="button"
class="ui compact basic button"
@click="addCustomForm"
> >
<table class="ui striped table"> <i
<thead> class="ui plus icon"
<tr> aria-hidden="true"
<th>Champ</th> />
<th>X</th> Ajouter un champ personnalisé
<th>Y</th> </button>
</tr>
</thead> <div class="ui divider" />
<tbody> <button
<tr id="send-feature_type"
v-for="field in csvFields" :class="['ui teal icon button margin-25', { disabled: loading }]"
:key="field.field" type="button"
> @click="sendFeatureType"
<td> >
{{ field.field }} <i
</td> class="white save icon"
<td> aria-hidden="true"
<div />
class="ui radio checkbox" {{ action === "create" ? "Créer" : "Sauvegarder" }} le type de
:class="{ disabled: field.y }" signalement
> </button>
<input <button
:disabled="field.y" v-if="geojson || csv || json"
type="radio" :class="['ui teal icon button margin-25', { disabled: loading }]"
name="x" type="button"
@input="pickXcsvCoordField(field)" @click="postFeatureTypeThenFeatures"
> >
<label /> <i
</div> class="white save icon"
</td> aria-hidden="true"
<td> />
<div Créer et importer le(s) signalement(s) du {{ geojson ? 'geojson' : csv ? 'csv' : 'json' }}
class="ui radio checkbox" </button>
:class="{ disabled: field.x }"
>
<input
:disabled="field.x"
type="radio"
name="y"
@input="pickYcsvCoordField(field)"
>
<label />
</div>
</td>
</tr>
</tbody>
</table>
<button
class="ui teal icon button margin-25"
type="button"
:disabled="
!csvFields.some(el => el.x === true) ||
!csvFields.some(el => el.y === true)
"
@click="setCSVCoordsFields"
>
<i class="white save icon" />
Continuer
</button>
</div> -->
<div v-else>
<div id="formsets">
<FeatureTypeCustomForm
v-for="customForm in customForms"
:key="customForm.dataKey"
ref="customForms"
:data-key="customForm.dataKey"
:custom-form="customForm"
:selected-color-style="form.colors_style.value.custom_field_name"
@update="updateColorsStyle($event)"
/>
</div>
<button
id="add-field"
type="button"
class="ui compact basic button"
@click="addCustomForm"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
Ajouter un champ personnalisé
</button>
<div class="ui divider" />
<button
class="ui teal icon button margin-25"
type="button"
@click="sendFeatureType"
>
<i
class="white save icon"
aria-hidden="true"
/>
{{ action === "create" ? "Créer" : "Sauvegarder" }} le type de
signalement
</button>
<button
v-if="geojson || csv"
class="ui teal icon button margin-25"
type="button"
@click="postFeatureTypeThenFeatures"
>
<i
class="white save icon"
aria-hidden="true"
/>
Créer et importer le(s) signalement(s) du geojson
</button>
</div>
</form> </form>
</div> </div>
</div> </div>
...@@ -287,6 +163,7 @@ import { mapGetters, mapState, mapMutations, mapActions } from 'vuex'; ...@@ -287,6 +163,7 @@ import { mapGetters, mapState, mapMutations, mapActions } from 'vuex';
import Dropdown from '@/components/Dropdown.vue'; import Dropdown from '@/components/Dropdown.vue';
import FeatureTypeCustomForm from '@/components/FeatureType/FeatureTypeCustomForm.vue'; import FeatureTypeCustomForm from '@/components/FeatureType/FeatureTypeCustomForm.vue';
import { transformProperties, reservedKeywords } from'@/utils';
export default { export default {
name: 'FeatureTypeEdit', name: 'FeatureTypeEdit',
...@@ -305,6 +182,10 @@ export default { ...@@ -305,6 +182,10 @@ export default {
type: Array, type: Array,
default: null, default: null,
}, },
json: {
type: Array,
default: null,
},
}, },
data() { data() {
...@@ -312,12 +193,12 @@ export default { ...@@ -312,12 +193,12 @@ export default {
loading: false, loading: false,
action: 'create', action: 'create',
dataKey: 0, dataKey: 0,
error: null,
csvFields: null, csvFields: null,
geomTypeChoices: [ geomTypeChoices: [
{ value: 'linestring', name: 'Ligne' }, { value: 'linestring', name: 'Ligne' },
{ value: 'point', name: 'Point' }, { value: 'point', name: 'Point' },
{ value: 'polygon', name: 'Polygone' }, { value: 'polygon', name: 'Polygone' },
{ value: 'none', name: 'Aucune' },
], ],
form: { form: {
colors_style: { colors_style: {
...@@ -341,7 +222,7 @@ export default { ...@@ -341,7 +222,7 @@ export default {
id_for_label: 'title', id_for_label: 'title',
label: 'Titre', label: 'Titre',
field: { field: {
max_length: 128, // ! Vérifier la valeur dans django max_length: 128,
}, },
html_name: 'title', html_name: 'title',
value: null, value: null,
...@@ -353,34 +234,31 @@ export default { ...@@ -353,34 +234,31 @@ export default {
label: 'Titre du signalement optionnel', label: 'Titre du signalement optionnel',
value: false, value: false,
}, },
enable_key_doc_notif: {
errors: null,
id_for_label: 'enable_key_doc_notif',
html_name: 'enable_key_doc_notif',
label: 'Activer la notification de publication de pièces jointes',
value: false,
},
disable_notification: {
errors: null,
id_for_label: 'disable_notification',
html_name: 'disable_notification',
label: 'Désactiver les notifications',
value: false,
},
geom_type: { geom_type: {
id_for_label: 'geom_type', id_for_label: 'geom_type',
label: 'Type de géométrie', label: 'Type de géométrie',
field: { field: {
max_length: 128, // ! Vérifier la valeur dans django max_length: 128,
}, },
html_name: 'geom_type', html_name: 'geom_type',
value: 'point', value: 'point',
}, },
}, },
slug: this.$route.params.slug, slug: this.$route.params.slug,
reservedKeywords: [
// todo : add keywords for mapstyle (strokewidth...)
'title',
'description',
'status',
'created_on',
'updated_on',
'archived_on',
'deletion_on',
'feature_type',
'display_creator',
'display_last_editor',
'project',
'creator',
'lat',
'lon'
],
}; };
}, },
...@@ -455,14 +333,16 @@ export default { ...@@ -455,14 +333,16 @@ export default {
}, },
customForms(newValue, oldValue) { customForms(newValue, oldValue) {
if (newValue !== oldValue) { if (newValue !== oldValue) {
const name = this.form.colors_style.value.custom_field_name; // Retrieve custom_field_name; returns undefined if colors_style.value is null/undefined
const customField = this.customForms.find((el) => el.name === name); const customFieldName = this.form.colors_style.value?.custom_field_name;
if (!customField || customField.length === 0) {
//* if the customForm corresponding doesn't exist reset colors_style values // Determine if a custom field with the given name exists in customForms
this.form.colors_style.value = { // 'some' returns true if any element matches the condition
colors: {}, const customFieldExists = customFieldName && this.customForms.some(el => el.name === customFieldName);
custom_field_name: '',
}; // Reset colors_style if no corresponding custom field is found
if (!customFieldExists) {
this.form.colors_style.value = { colors: {}, custom_field_name: '' };
} }
} }
}, },
...@@ -496,21 +376,18 @@ export default { ...@@ -496,21 +376,18 @@ export default {
//* when creation from a geojson //* when creation from a geojson
if (this.geojson) { if (this.geojson) {
this.importGeoJsonFeatureType(); this.importGeoJsonFeatureType();
if (this.fileToImport && this.fileToImport.name) { //* add multiple geometries options available only for geojson (therefore when importing from catalog also)
this.form.title.value = // * use the filename as title by default this.geomTypeChoices.push(
this.fileToImport.name.split('.')[0]; { value: 'multilinestring', name: 'Multiligne' },
} else { //* case when the geojson comes from datasud catalog { value: 'multipoint', name: 'Multipoint' },
this.form.title.value = this.geojson.name;// * use the typename as title by default { value: 'multipolygon', name: 'Multipolygone' },
} );
} }
if (this.csv) { if (this.csv) {
this.importCSVFeatureType(); this.importCSVFeatureType();
if (this.fileToImport && this.fileToImport.name) { }
this.form.title.value = // * use the filename as title by default if (this.json) {
this.fileToImport.name.split('.')[0]; this.importJsonFeatureType();
} else { //* case when the geojson comes from datasud catalog
this.form.title.value = this.csv.name;// * use the typename as title by default
}
} }
}, },
beforeDestroy() { beforeDestroy() {
...@@ -520,6 +397,9 @@ export default { ...@@ -520,6 +397,9 @@ export default {
}, },
methods: { methods: {
...mapMutations([
'DISPLAY_MESSAGE',
]),
...mapMutations('feature-type', [ ...mapMutations('feature-type', [
'ADD_CUSTOM_FORM', 'ADD_CUSTOM_FORM',
'EMPTY_FORM', 'EMPTY_FORM',
...@@ -574,7 +454,7 @@ export default { ...@@ -574,7 +454,7 @@ export default {
} }
} }
//! add custom fields using ONLY this function, incrementing dataKey for Vue to correctly update components //! add custom fields using ONLY this function, incrementing dataKey for Vue to correctly update components
formData.customfield_set.forEach((el) => this.addCustomForm(el)); [...formData.customfield_set].forEach((el) => this.addCustomForm(el));
this.updateStore(); this.updateStore();
}, },
...@@ -612,6 +492,8 @@ export default { ...@@ -612,6 +492,8 @@ export default {
color: this.form.color, color: this.form.color,
title: this.form.title, title: this.form.title,
title_optional: this.form.title_optional, title_optional: this.form.title_optional,
enable_key_doc_notif: this.form.enable_key_doc_notif,
disable_notification: this.form.disable_notification,
geom_type: this.form.geom_type, geom_type: this.form.geom_type,
colors_style: this.form.colors_style, colors_style: this.form.colors_style,
}); });
...@@ -659,15 +541,25 @@ export default { ...@@ -659,15 +541,25 @@ export default {
const requestType = this.action === 'edit' ? 'put' : 'post'; const requestType = this.action === 'edit' ? 'put' : 'post';
if (this.checkForms()) { if (this.checkForms()) {
this.SEND_FEATURE_TYPE(requestType) this.SEND_FEATURE_TYPE(requestType)
.then(({ status }) => { .then((response) => {
const { status, data } = response;
if (status === 200) { if (status === 200) {
this.goBackToProject('Le type de signalement a été mis à jour'); this.goBackToProject({ comment: 'Le type de signalement a été mis à jour', level: 'positive' });
} else if (status === 201) { } else if (status === 201) {
this.goBackToProject('Le nouveau type de signalement a été créé'); this.goBackToProject({ comment: 'Le nouveau type de signalement a été créé', level: 'positive' });
} else { } else {
this.displayMessage( let comment = 'Une erreur est survenue lors de l\'import du type de signalement';
"Une erreur est survenue lors de l'import du type de signalement" if (data.customfield_set) {
); let errors = data.customfield_set.find((el) => el.options);
if (errors.options) {
let customFieldError = errors.options[0];
if(customFieldError) comment = customFieldError[0].replace('ce champ', 'chaque option de champ personnalisé');
}
}
this.DISPLAY_MESSAGE({
comment,
level: 'negative'
});
} }
}); });
} }
...@@ -677,16 +569,19 @@ export default { ...@@ -677,16 +569,19 @@ export default {
this.SEND_FEATURES_FROM_GEOJSON({ this.SEND_FEATURES_FROM_GEOJSON({
slug: this.slug, slug: this.slug,
feature_type_slug, feature_type_slug,
geojson: this.geojson geojson: this.geojson || this.json
}) })
.then((response) => { .then((response) => {
if (response && response.status === 200) { if (response && response.status === 200) {
this.goBackToProject(); this.goBackToProject({
comment: 'Le nouveau type de signalement a été créé. L\'import des signalements est en cours',
level: 'positive'
});
} else { } else {
this.displayMessage( this.DISPLAY_MESSAGE({
"Une erreur est survenue lors de l'import de signalements.\n " + comment: `Une erreur est survenue lors de l'import de signalements.\n ${ response.data.detail }`,
response.data.detail level: 'negative'
); });
} }
this.loading = false; this.loading = false;
}) })
...@@ -694,6 +589,7 @@ export default { ...@@ -694,6 +589,7 @@ export default {
this.loading = false; this.loading = false;
}); });
}, },
postCSVFeatures(feature_type_slug) { postCSVFeatures(feature_type_slug) {
this.$store this.$store
.dispatch('feature-type/SEND_FEATURES_FROM_CSV', { .dispatch('feature-type/SEND_FEATURES_FROM_CSV', {
...@@ -703,12 +599,15 @@ export default { ...@@ -703,12 +599,15 @@ export default {
}) })
.then((response) => { .then((response) => {
if (response && response.status === 200) { if (response && response.status === 200) {
this.goBackToProject(); this.goBackToProject({
comment: 'Le nouveau type de signalement a été créé. Import des signalements est en cours',
level: 'positive'
});
} else { } else {
this.displayMessage( this.DISPLAY_MESSAGE({
"Une erreur est survenue lors de l'import de signalements.\n " + comment: `Une erreur est survenue lors de l'import de signalements.\n ${ response.data.detail }`,
response.data.detail level: 'negative'
); });
} }
this.loading = false; this.loading = false;
}) })
...@@ -725,10 +624,9 @@ export default { ...@@ -725,10 +624,9 @@ export default {
.dispatch('feature-type/SEND_FEATURE_TYPE', requestType) .dispatch('feature-type/SEND_FEATURE_TYPE', requestType)
.then(({ feature_type_slug }) => { .then(({ feature_type_slug }) => {
if (feature_type_slug) { if (feature_type_slug) {
if (this.geojson) { if (this.geojson || this.json) {
this.postGeojsonFeatures(feature_type_slug); this.postGeojsonFeatures(feature_type_slug);
} } else if (this.csv) {
else if (this.csv) {
this.postCSVFeatures(feature_type_slug); this.postCSVFeatures(feature_type_slug);
} }
} else { } else {
...@@ -741,13 +639,6 @@ export default { ...@@ -741,13 +639,6 @@ export default {
} }
}, },
displayMessage(message) {
this.error = message;
document
.getElementById('message')
.scrollIntoView({ block: 'end', inline: 'nearest' });
},
// ****** Methodes for geojson import ****** // // ****** Methodes for geojson import ****** //
toNewFeatureType() { toNewFeatureType() {
this.$router.push({ this.$router.push({
...@@ -756,129 +647,119 @@ export default { ...@@ -756,129 +647,119 @@ export default {
}); });
}, },
translateLabel(value) { /**
if (value === 'LineString') { * Builds custom form fields based on the properties of data entries.
return 'linestring'; *
} else if (value === 'Polygon' || value === 'MultiPolygon') { * This function iterates through a subset of data entries (such as rows from a CSV, JSON objects, or GeoJSON features)
return 'polygon'; * to determine the most appropriate type for each field. It tracks confirmed types to avoid redundant checks and
* stops processing a field once its type is definitively determined. If a field is initially detected as a 'char',
* it remains as 'char' unless a multiline text ('text') is detected later. The function prioritizes the detection
* of definitive types (like 'text', 'boolean', 'integer') and updates the form with the confirmed types.
*
* @param {Array} propertiesList - An array of data entries, where each entry is an object representing a set of properties.
*/
buildCustomForm(propertiesList) {
const confirmedTypes = {}; // Store confirmed types for each field
const detectedAsChar = {}; // Track fields initially detected as 'char'
// Iterate over each row or feature in the subset
propertiesList.forEach((properties) => {
for (const [key, val] of Object.entries(properties)) {
if (!reservedKeywords.includes(key)) {
// If the type for this field has already been confirmed as something other than 'char', skip it
if (confirmedTypes[key] && confirmedTypes[key] !== 'char') {
continue;
}
// Determine the type of the current value
const detectedType = transformProperties(val);
if (detectedType === 'text') {
// Once 'text' (multiline) is detected, confirm it immediately
confirmedTypes[key] = 'text';
} else if (!confirmedTypes[key] && detectedType !== 'char') {
// If a type is detected that is not 'char' and not yet confirmed, confirm it
confirmedTypes[key] = detectedType;
} else if (!confirmedTypes[key]) {
// If this field hasn't been confirmed yet, initialize it as 'char'
confirmedTypes[key] = 'char';
detectedAsChar[key] = true;
} else if (detectedAsChar[key] && detectedType !== 'char') {
// If a field was initially detected as 'char' but now has a different type, update it
confirmedTypes[key] = detectedType;
delete detectedAsChar[key]; // Remove from 'char' tracking once updated
}
}
}
});
// Build custom forms using the confirmed types
for (const [key, confirmedType] of Object.entries(confirmedTypes)) {
const customForm = {
label: { value: key || '' },
name: { value: key || '' },
position: this.dataKey, // use dataKey already incremented by addCustomForm
field_type: { value: confirmedType }, // use the confirmed type
options: { value: [] }, // not available in export
};
this.addCustomForm(customForm);
} }
return 'point';
}, },
transformProperties(prop) { setTitleFromFile() {
const type = typeof prop; if (this.fileToImport && this.fileToImport.name) {
const date = new Date(prop); this.form.title.value = // * use the filename as title by default
if (type === 'boolean') { this.fileToImport.name.split('.')[0];
return 'boolean'; } else { //* case when the data comes from datasud catalog
} else if (Number.isSafeInteger(prop)) { // * use the typename as title by default
return 'integer'; this.form.title.value = this.geojson.name || this.csv.name || this.json.name;
} else if (
type === 'string' &&
date instanceof Date &&
!isNaN(date.valueOf())
) {
return 'date';
} else if (type === 'number' && !isNaN(parseFloat(prop))) {
return 'decimal';
} }
return 'char'; //* string by default, most accepted type in database
}, },
importGeoJsonFeatureType() { importGeoJsonFeatureType() {
if (this.geojson.features && this.geojson.features.length) { if (this.geojson.features && this.geojson.features.length) {
//* in order to get feature_type properties, the first feature is enough const { geometry } = this.geojson.features[0];
const { properties, geometry } = this.geojson.features[0]; this.form.geom_type.value = geometry.type.toLowerCase();
this.form.title.value = properties.feature_type; this.updateStore(); // register geom_type in store
this.form.geom_type.value = this.translateLabel(geometry.type);
this.updateStore(); //* register title & geom_type in store // Use a subset of the first N features to build the form
const subsetFeatures = this.geojson.features.slice(0, 200); // Adjust '200' based on performance needs
//* loop properties to create a customForm for each of them const propertiesList = subsetFeatures.map(feature => feature.properties);
for (const [key, val] of Object.entries(properties)) { this.buildCustomForm(propertiesList);
//* check that the property is not a keyword from the backend or map style
// todo: add map style keywords
if (!this.reservedKeywords.includes(key)) {
const customForm = {
label: { value: key || '' },
name: { value: key || '' },
position: this.dataKey, // * use dataKey already incremented by addCustomForm
field_type: { value: this.transformProperties(val) }, // * guessed from the type
options: { value: [] }, // * not available in export
};
this.addCustomForm(customForm);
}
}
} }
this.setTitleFromFile();
}, },
importCSVFeatureType() { importCSVFeatureType() {
if (this.csv.length) { if (this.csv.length) {
this.updateStore(); //* register title & geom_type in store this.updateStore(); // register title in store
// List fileds for user to select coords fields
// this.csvFields = // Use a subset of the first N rows to build the form
// Object.keys(this.csv[0]) const subsetCSV = this.csv.slice(0, 200); // Adjust '200' based on performance needs
// .map(el => { this.buildCustomForm(subsetCSV);
// return {
// field: el, // Check for geom data
// x: false, if (!('lat' in this.csv[0]) || !('lon' in this.csv[0])) {
// y:false this.form.geom_type.value = 'none';
// };
// });
for (const [key, val] of Object.entries(this.csv[0])) {
//* check that the property is not a keyword from the backend or map style
// todo: add map style keywords
if (!this.reservedKeywords.includes(key)) {
const customForm = {
label: { value: key || '' },
name: { value: key || '' },
position: this.dataKey, // * use dataKey already incremented by addCustomForm
field_type: { value: this.transformProperties(val) }, // * guessed from the type
options: { value: [] }, // * not available in export
};
this.addCustomForm(customForm);
}
} }
} }
this.setTitleFromFile();
},
importJsonFeatureType() {
if (this.json.length) {
this.form.geom_type.value = 'none'; // JSON are non-geom features
this.updateStore(); // register title in store
// Use a subset of the first N objects to build the form
const subsetJson = this.json.slice(0, 200); // Adjust '200' based on performance needs
this.buildCustomForm(subsetJson);
}
this.setTitleFromFile();
}, },
// pickXcsvCoordField(e) {
// this.csvFields.forEach(el => {
// if (el.field === e.field) {
// el.x = true;
// } else {
// el.x = false;
// }
// });
// },
// pickYcsvCoordField(e) {
// this.csvFields.forEach(el => {
// if (el.field === e.field) {
// el.y = true;
// } else {
// el.y = false;
// }
// });
// },
// setCSVCoordsFields() {
// const xField = this.csvFields.find(el => el.x === true).field;
// const yField = this.csvFields.find(el => el.y === true).field;
// this.csvFields = null;
// for (const [key, val] of Object.entries(this.csv[0])) {
// //* check that the property is not a keyword from the backend or map style
// // todo: add map style keywords
// if (!this.reservedKeywords.includes(key) && key !== xField && key !== yField) {
// const customForm = {
// label: { value: key || '' },
// name: { value: key || '' },
// position: this.dataKey, // * use dataKey already incremented by addCustomForm
// field_type: { value: this.transformProperties(val) }, // * guessed from the type
// options: { value: [] }, // * not available in export
// };
// this.addCustomForm(customForm);
// }
// }
// }
}, },
}; };
</script> </script>
......
<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"
aria-hidden="true"
/>
{{ error }}
</p>
</div>
<div
v-if="success"
class="ui positive message"
>
<i
class="close icon"
aria-hidden="true"
@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="project && 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 { isEqual } from 'lodash';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import SymbologySelector from '@/components/FeatureType/SymbologySelector.vue';
export default {
name: 'FeatureTypeSymbology',
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: {
...mapState('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(this.$route.params.slug);
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('projects', [
'GET_PROJECT',
'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.$route.params.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>
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
import { mapState } from 'vuex'; import { mapState } from 'vuex';
export default { export default {
name: 'Default', name: 'Help',
computed: { computed: {
...mapState(['staticPages']), ...mapState(['staticPages']),
......
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
import { mapState } from 'vuex'; import { mapState } from 'vuex';
export default { export default {
name: 'WithRightMenu', name: 'Mentions',
data() { data() {
return { return {
......
...@@ -4,28 +4,29 @@ ...@@ -4,28 +4,29 @@
<div class="fourteen wide column"> <div class="fourteen wide column">
<img <img
class="ui centered small image" class="ui centered small image"
:src="logo" :src="appLogo"
alt="Logo de l'application"
> >
<h2 class="ui center aligned icon header"> <h2 class="ui center aligned icon header">
<div class="content"> <div class="content">
{{ APPLICATION_NAME }} {{ appName }}
<div class="sub header"> <div class="sub header">
{{ APPLICATION_ABSTRACT }} {{ appAbstract }}
</div> </div>
</div> </div>
</h2> </h2>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="six wide column"> <div
v-if="$route.name === 'login'"
class="six wide column"
>
<h3 class="ui horizontal divider header"> <h3 class="ui horizontal divider header">
CONNEXION CONNEXION
</h3> </h3>
<div <div :class="['ui warning message', {'closed': !errors.global}]">
v-if="form.errors"
class="ui warning message"
>
<div class="header"> <div class="header">
Les informations d'identification sont incorrectes. Les informations d'identification sont incorrectes.
</div> </div>
...@@ -38,23 +39,29 @@ ...@@ -38,23 +39,29 @@
type="post" type="post"
@submit.prevent="login" @submit.prevent="login"
> >
<div class="ui stacked secondary segment"> <div class="ui secondary segment">
<div class="six field required"> <div class="six field">
<div class="ui left icon input"> <div class="ui left icon input">
<i class="user icon" /> <i
class="user icon"
aria-hidden="true"
/>
<input <input
v-model="username_value" v-model="loginForm.username"
type="text" type="text"
name="username" name="username"
placeholder="Utilisateur" placeholder="Utilisateur"
> >
</div> </div>
</div> </div>
<div class="six field required"> <div class="six field">
<div class="ui left icon input"> <div class="ui left icon input">
<i class="lock icon" /> <i
class="lock icon"
aria-hidden="true"
/>
<input <input
v-model="password_value" v-model="loginForm.password"
type="password" type="password"
name="password" name="password"
placeholder="Mot de passe" placeholder="Mot de passe"
...@@ -70,70 +77,467 @@ ...@@ -70,70 +77,467 @@
</div> </div>
</form> </form>
</div> </div>
<div
v-else-if="$route.name === 'signup'"
class="six wide column"
>
<h3 class="ui horizontal divider header">
INSCRIPTION
</h3>
<div :class="['ui warning message', {'closed': !error}]">
{{ error }}
</div>
<form
class="ui form"
role="form"
type="post"
@submit.prevent="signup"
>
<div class="ui secondary segment">
<div class="six field">
<div class="ui left icon input">
<i
class="user outline icon"
aria-hidden="true"
/>
<input
v-model="signupForm.first_name"
type="text"
name="first_name"
placeholder="Prénom"
required
>
</div>
</div>
<div class="six field">
<div class="ui left icon input">
<i
class="id card icon"
aria-hidden="true"
/>
<input
v-model="signupForm.last_name"
type="text"
name="last_name"
placeholder="Nom"
required
>
</div>
</div>
<div class="six field">
<div class="ui left icon input">
<i
class="envelope icon"
aria-hidden="true"
/>
<input
v-model="signupForm.email"
type="email"
name="email"
placeholder="Adresse courriel"
required
>
</div>
</div>
<div class="six field">
<div class="ui left icon input">
<i
class="user icon"
aria-hidden="true"
/>
<input
v-model="signupForm.username"
type="text"
name="username"
placeholder="Utilisateur"
disabled
>
</div>
</div>
<div :class="['six field', {'error': errors.passwd}]">
<div class="ui action left icon input">
<i
class="lock icon"
aria-hidden="true"
/>
<input
v-model="signupForm.password"
:type="showPwd ? 'text' : 'password'"
name="password"
placeholder="Mot de passe"
required
@blur="isValidPwd"
>
<button
class="ui icon button"
@click="showPwd = !showPwd"
>
<i :class="[showPwd ? 'eye slash' : 'eye', 'icon']" />
</button>
</div>
</div>
<div :class="['six field', {'error': errors.comments}]">
<div class="ui left icon input">
<i
class="pencil icon"
aria-hidden="true"
/>
<input
v-model="signupForm.comments"
type="text"
name="comments"
:placeholder="commentsFieldLabel || `Commentaires`"
:required="commentsFieldRequired"
>
</div>
</div>
<div
v-if="usersGroupsOptions.length > 0"
class="six field"
>
<div class="ui divider" />
<Multiselect
v-model="usersGroupsSelections"
:options="usersGroupsOptions"
:multiple="true"
track-by="value"
label="name"
select-label=""
selected-label=""
deselect-label=""
:searchable="false"
:placeholder="'Sélectionez un ou plusieurs groupe de la liste ...'"
/>
<p v-if="adminMail">
Si le groupe d'utilisateurs recherché n'apparaît pas, vous pouvez demander à
<a :href="'mailto:'+adminMail">{{ adminMail }}</a> de le créer
</p>
</div>
<button
:class="['ui fluid large teal submit button']"
type="submit"
>
Valider
</button>
</div>
</form>
</div>
<div
v-else-if="$route.name === 'sso-signup-success'"
class="six wide column"
>
<h3 class="ui horizontal divider header">
INSCRIPTION RÉUSSIE
</h3>
<h4 class="ui center aligned icon header">
<div class="content">
<p
v-if="username"
class="sub header"
>
Le compte pour le nom d'utilisateur <strong>{{ username }}</strong> a été créé
</p>
<p>
Un e-mail de confirmation vient d'être envoyé à l'adresse indiquée.
</p>
<p class="sub header">
Merci de bien vouloir suivre les instructions données afin de finaliser la création de votre compte.
</p>
</div>
</h4>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { mapState, mapGetters, mapMutations } from 'vuex';
import Multiselect from 'vue-multiselect';
import userAPI from '../services/user-api';
export default { export default {
name: 'Login', name: 'Login',
components: {
Multiselect
},
props: {
username: {
type: String,
default: null
}
},
data() { data() {
return { return {
username_value: null,
password_value: null,
logged: false, logged: false,
form: { loginForm: {
errors: null, username: null,
password: null,
},
signupForm: {
username: null,
password: null,
first_name: null,
last_name: null,
email: null,
comments: null,
usersgroups: [],
},
errors: {
global: null,
passwd: null,
comments: null,
}, },
showPwd: false,
}; };
}, },
computed: { computed: {
logo() { ...mapState({
return this.$store.state.configuration.VUE_APP_LOGO_PATH; appLogo: state => state.configuration.VUE_APP_LOGO_PATH,
appName: state => state.configuration.VUE_APP_APPLICATION_NAME,
appAbstract: state => state.configuration.VUE_APP_APPLICATION_ABSTRACT,
adminMail: state => state.configuration.VUE_APP_ADMIN_MAIL,
ssoSignupUrl: state => state.configuration.VUE_APP_SSO_SIGNUP_URL,
commentsFieldLabel: state => state.configuration.VUE_APP_SIGNUP_COMMENTS_FIELD_LABEL,
commentsFieldRequired: state => state.configuration.VUE_APP_SIGNUP_COMMENTS_FIELD_REQUIRED,
}),
...mapGetters(['usersGroupsOptions']),
usersGroupsSelections: {
get() {
return this.usersGroupsOptions.filter((el) => this.signupForm.usersgroups?.includes(el.value));
},
set(newValue) {
this.signupForm.usersgroups = newValue.map(el => el.value);
}
},
error() {
return this.errors.global || this.errors.passwd || this.errors.comments;
}
},
watch: {
'signupForm.first_name': function (newValue, oldValue) {
if (newValue !== oldValue) {
this.signupForm.username = `${newValue.charAt(0)}${this.signupForm.last_name}`.toLowerCase().replace(/\s/g, '');
}
}, },
APPLICATION_NAME() { 'signupForm.last_name': function (newValue, oldValue) {
return this.$store.state.configuration.VUE_APP_APPLICATION_NAME; if (newValue !== oldValue) {
this.signupForm.username = `${this.signupForm.first_name.charAt(0)}${newValue}`.toLowerCase().replace(/\s/g, '');
}
}, },
APPLICATION_ABSTRACT() { 'signupForm.password': function (newValue, oldValue) {
return this.$store.state.configuration.VUE_APP_APPLICATION_ABSTRACT; if (newValue.length >= 8) {
if (newValue !== oldValue) {
this.isValidPwd();
}
} else {
this.errors.passwd = null;
}
}, },
username(newValue, oldValue) {
if (newValue !== oldValue) {
this.loginForm.username = newValue;
}
}
},
created() {
if (this.$route.name === 'signup') {
this.$store.dispatch('GET_USERS_GROUPS'); // récupére les groupes d'utilisateurs pour extra_forms
}
}, },
mounted() { mounted() {
if (this.$store.state.user) { if (this.$route.name === 'login') {
this.$store.commit( if (this.$store.state.user) {
'DISPLAY_MESSAGE', this.DISPLAY_MESSAGE({ header: 'Vous êtes déjà connecté', comment: 'Vous allez être redirigé vers la page précédente.' });
{ comment: 'Vous êtes déjà connecté, vous allez être redirigé vers la page précédente.' } setTimeout(() => this.$store.dispatch('REDIRECT_AFTER_LOGIN'), 3100);
); }
setTimeout(() => this.$store.dispatch('REDIRECT_AFTER_LOGIN'), 3100);
} }
}, },
methods: { methods: {
...mapMutations(['DISPLAY_MESSAGE']),
login() { login() {
this.$store this.$store
.dispatch('LOGIN', { .dispatch('LOGIN', {
username: this.username_value, username: this.loginForm.username,
password: this.password_value, password: this.loginForm.password,
}) })
.then((status) => { .then((status) => {
if (status === 200) { if (status === 200) {
this.form.errors = null; this.errors.global = null;
} else if (status === 'error') { } else if (status === 'error') {
this.form.errors = status; this.errors.global = status;
} }
}) })
.catch(); .catch();
}, },
async signup() {
if (this.hasUnvalidFields()) return;
// Étape 1 : Création de l'utilisateur auprès du service d'authentification SSO si nécessaire
if (this.ssoSignupUrl) {
const ssoResponse = await userAPI.signup({
...this.signupForm,
// Ajout du label personnalisé pour affichage plus précis dans admin OGS
comments: `{"${this.commentsFieldLabel}":"${this.signupForm.comments}"}`,
// Pour permettre la visualisation dans OGS Maps, l'utilisateur doit être ajouté à un groupe OGS, mis en dur pour aller vite pour l'instant
usergroup_roles:[{ organisation: { id: 1 } }]
}, this.ssoSignupUrl);
if (ssoResponse.status !== 201) {
if (ssoResponse.status === 400) {
this.errors.global = 'Un compte associé à ce courriel existe déjà';
} else {
this.errors.global = `Erreur lors de l'inscription: ${ssoResponse.data?.detail || 'Problème inconnu'}`;
}
return; // Stoppe la fonction si l'inscription SSO échoue
} else {
this.signupForm.username = ssoResponse.data.username;
this.signupForm.first_name = ssoResponse.data.first_name;
this.signupForm.last_name = ssoResponse.data.last_name;
}
}
// Étape 2 : Création de l'utilisateur dans Geocontrib
const response = await userAPI.signup(this.signupForm);
if (response.status !== 201) {
const errorMessage = response.data
? Object.values(response.data)?.[0]?.[0] || 'Problème inconnu'
: 'Problème inconnu';
this.errors.global = `Erreur lors de l'inscription: ${errorMessage}`;
return;
}
this.DISPLAY_MESSAGE({ header: 'Inscription réussie !', comment: `Bienvenue sur la plateforme ${this.signupForm.username}.`, level: 'positive' });
if (this.ssoSignupUrl) {
setTimeout(() => {
this.$router.push({ name: 'sso-signup-success', params: { username: this.signupForm.username } });
}, 3100);
} else {
setTimeout(() => {
this.$router.push({ name: 'login', params: { username: this.signupForm.username } });
}, 3100);
}
},
isValidPwd() {
const regPwd = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d/$&+,:;=?#|'<>.^*()%!-]{8,}$/;
if (!regPwd.test(this.signupForm.password)) {
this.errors.passwd = `Le mot de passe doit comporter au moins 8 caractères, dont 1 majuscule, 1 minuscule et 1 chiffre.
Vous pouvez utiliser les caractères spéciaux suivants : /$ & + , : ; = ? # | ' < > . ^ * ( ) % ! -.`;
return false;
}
this.errors.passwd = null;
return true;
},
hasUnvalidFields() {
const { last_name, email, first_name, comments } = this.signupForm;
if (this.commentsFieldRequired && !comments) {
this.errors.comments = `Le champ ${ this.commentsFieldLabel || 'Commentaires'} est requis`;
return true;
} else {
this.errors.comments = null;
}
if (email && last_name && first_name) {
this.errors.global = null;
} else {
this.errors.global = 'Certains champs requis ne sont pas renseignés';
return true;
}
return !this.isValidPwd();
}
}, },
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
#login-page { #login-page {
max-width: 500px; max-width: 500px;
min-width: 200px; min-width: 200px;
margin: 3em auto; margin: 3em auto;
.ui.message {
min-height: 0px;
&.closed {
overflow: hidden;
opacity: 0;
padding: 0;
max-height: 0px;
}
}
input[required] {
background-image: linear-gradient(45deg, transparent, transparent 50%, rgb(209, 0, 0) 50%, rgb(209, 0, 0) 100%);
background-position: top right;
background-size: .5em .5em;
background-repeat: no-repeat;
}
}
p {
margin: 1em 0 !important;
}
</style>
<style>
.multiselect__placeholder {
position: absolute;
width: calc(100% - 48px);
overflow: hidden;
text-overflow: ellipsis;
}
.multiselect__tags {
position: relative;
} }
/* keep font-weight from overide of semantic classes */
.multiselect__placeholder,
.multiselect__content,
.multiselect__tags {
font-weight: initial !important;
}
/* keep placeholder eigth */
.multiselect .multiselect__placeholder {
margin-bottom: 9px !important;
padding-top: 1px;
}
/* keep placeholder height when opening dropdown without selection */
input.multiselect__input {
padding: 3px 0 0 0 !important;
}
/* keep placeholder height when opening dropdown with already a value selected */
.multiselect__tags .multiselect__single {
padding: 1px 0 0 0 !important;
margin-bottom: 9px;
}
</style> </style>
\ No newline at end of file
<template> <template>
<div <div id="project-features">
id="project-features" <div class="column">
class="page" <FeaturesListAndMapFilters
> :show-map="showMap"
<div :features-count="featuresCountDisplay"
id="feature-list-container" :pagination="pagination"
class="ui grid mobile-column" :all-selected="allSelected"
> :edit-attributes-feature-type="editAttributesFeatureType"
<div class="mobile-fullwidth"> @set-filter="setFilters"
<h1>Signalements</h1> @reset-pagination="resetPagination"
</div> @fetch-features="fetchPagedFeatures"
<div class="no-padding-mobile mobile-fullwidth"> @show-map="setShowMap"
<div class="ui large text loader"> @edit-status="modifyStatus"
Chargement @toggle-delete-modal="toggleDeleteModal"
</div> />
<div class="ui secondary menu no-margin">
<a
:class="['item no-margin', { active: showMap }]"
data-tab="map"
data-tooltip="Carte"
data-position="bottom left"
@click="showMap = true"
><i class="map fitted icon" /></a>
<a
:class="['item no-margin', { active: !showMap }]"
data-tab="list"
data-tooltip="Liste"
data-position="bottom left"
@click="showMap = false"
><i class="list fitted icon" /></a>
<div class="item">
<h4>
{{ featuresCount }} signalement{{ featuresCount > 1 ? "s" : "" }}
</h4>
</div>
<div class="loader-container">
<div
:class="['ui tab active map-container', { 'visible': showMap }]"
data-tab="map"
>
<div <div
v-if=" id="map"
project && ref="map"
feature_types.length > 0 &&
permissions.can_create_feature
"
id="button-dropdown"
class="item right"
> >
<div <SidebarLayers
class="ui dropdown button compact button-hover-green" v-if="basemaps && map"
data-tooltip="Ajouter un signalement" ref="sidebar"
data-position="bottom right" />
@click="toggleAddFeature" <Geolocation />
> <Geocoder />
<i class="plus fitted icon" /> </div>
<div <div
v-if="showAddFeature" id="popup"
class="menu left transition visible" class="ol-popup"
style="z-index: 9999" >
> <a
<div class="header"> id="popup-closer"
Ajouter un signalement du type href="#"
</div> class="ol-popup-closer"
<div class="scrolling menu text-wrap"> />
<router-link <div
v-for="(type, index) in feature_types" id="popup-content"
: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 && massMode === '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 && massMode === 'delete'"
class="ui button compact button-hover-red margin-left-25"
data-tooltip="Supprimer tous les signalements sélectionnés"
data-position="bottom right"
@click="toggleDeleteModal"
>
<i class="grey trash fitted icon" />
</div>
</div> </div>
</div> </div>
</div> <FeatureListTable
</div> v-show="!showMap"
:paginated-features="paginatedFeatures"
<section :page-numbers="pageNumbers"
id="form-filters" :all-selected="allSelected"
class="ui form grid" :checked-features.sync="checkedFeatures"
> :features-count="featuresCount"
<div class="field wide four column no-margin-mobile"> :pagination="pagination"
<label>Type</label> :sort="sort"
<Dropdown :edit-attributes-feature-type.sync="editAttributesFeatureType"
:options="featureTypeChoices" :queryparams="queryparams"
:selected="form.type.selected" @update:page="handlePageChange"
:selection.sync="form.type.selected" @update:sort="handleSortChange"
:search="true" @update:allSelected="handleAllSelectedChange"
: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="filteredStatusChoices"
:selected="form.status.selected.name"
:selection.sync="form.status.selected"
:search="true"
:clearable="true"
/> />
</div> <Transition name="fadeIn">
<div class="field wide four column"> <div
<label>Nom</label> v-if="loading"
<div class="ui icon input"> class="ui inverted dimmer active"
<i class="search icon" /> >
<div class="ui action input"> <div class="ui text loader">
<input Récupération des signalements en cours...
v-model="form.title" </div>
type="text"
name="title"
@keyup.enter="resetPaginationNfetchFeatures"
>
<button
id="submit-search"
class="ui teal icon button"
@click="resetPaginationNfetchFeatures"
>
<i class="search icon" />
</button>
</div> </div>
</div> </Transition>
</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-if="showMap" <!-- MODAL ALL DELETE FEATURE TYPE -->
class="ui tab active map-container visible"
data-tab="map"
>
<div <div
id="map" v-if="isDeleteModalOpen"
ref="map" class="ui dimmer modals page transition visible active"
/> style="display: flex !important"
<SidebarLayers v-if="basemaps && map" />
</div>
<FeatureListTable
v-else
:paginated-features="paginatedFeatures"
:page-numbers="pageNumbers"
:checked-features.sync="checkedFeatures"
:features-count="featuresCount"
:pagination="pagination"
:sort="sort"
@update:page="handlePageChange"
@update:sort="handleSortChange"
/>
<!-- MODAL ALL DELETE FEATURE TYPE -->
<div
v-if="isDeleteModalOpen"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
:class="[
'ui mini modal subscription',
{ 'active visible': isDeleteModalOpen },
]"
> >
<i <div
class="close icon" :class="[
@click="isDeleteModalOpen = false" 'ui mini modal',
/> { 'active visible': isDeleteModalOpen },
<div class="ui icon header"> ]"
<i class="trash alternate icon" /> >
Êtes-vous sûr de vouloir effacer <i
<span v-if="checkedFeatures.length === 1"> un signalement ? </span> class="close icon"
<span v-else> ces {{ checkedFeatures.length }} signalements ? </span> aria-hidden="true"
</div> @click="isDeleteModalOpen = false"
<div class="actions"> />
<button <div class="ui icon header">
type="button" <i
class="ui red compact fluid button" class="trash alternate icon"
@click="deleteAllFeatureSelection" aria-hidden="true"
> />
Confirmer la suppression Êtes-vous sûr de vouloir effacer
</button> <span v-if="checkedFeatures.length === 1"> un signalement&nbsp;?</span>
<span v-else-if="checkedFeatures.length > 1">ces {{ checkedFeatures.length }} signalements&nbsp;?</span>
<span v-else>tous les signalements sélectionnés&nbsp;?<br>
<small>Seuls ceux que vous êtes autorisé à supprimer seront réellement effacés.</small>
</span>
</div>
<div class="actions">
<button
type="button"
class="ui red compact fluid button"
@click="deleteAllFeatureSelection"
>
Confirmer la suppression
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -244,15 +119,15 @@ ...@@ -244,15 +119,15 @@
<script> <script>
import { mapState, mapGetters, mapActions, mapMutations } from 'vuex'; import { mapState, mapActions, mapMutations } from 'vuex';
import { mapUtil } from '@/assets/js/map-util.js'; import mapService from '@/services/map-service';
import { allowedStatus2change } from '@/utils'; import Geocoder from '@/components/Map/Geocoder';
import featureAPI from '@/services/feature-api'; import featureAPI from '@/services/feature-api';
import SidebarLayers from '@/components/SidebarLayers'; import FeaturesListAndMapFilters from '@/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters';
import FeatureListTable from '@/components/Project/FeaturesListAndMap/FeatureListTable'; import FeatureListTable from '@/components/Project/FeaturesListAndMap/FeatureListTable';
import Dropdown from '@/components/Dropdown.vue'; import SidebarLayers from '@/components/Map/SidebarLayers';
import Geolocation from '@/components/Map/Geolocation';
const initialPagination = { const initialPagination = {
currentPage: 1, currentPage: 1,
...@@ -265,36 +140,37 @@ export default { ...@@ -265,36 +140,37 @@ export default {
name: 'FeaturesListAndMap', name: 'FeaturesListAndMap',
components: { components: {
FeaturesListAndMapFilters,
SidebarLayers, SidebarLayers,
Dropdown, Geocoder,
Geolocation,
FeatureListTable, FeatureListTable,
}, },
data() { data() {
return { return {
allSelected: false,
editAttributesFeatureType: null,
currentLayer: null, currentLayer: null,
featuresCount: 0, featuresCount: 0,
featuresWithGeomCount:0,
form: { form: {
type: { type: [],
selected: '', status: [],
},
status: {
selected: '',
},
title: null, title: null,
}, },
isDeleteModalOpen: false, isDeleteModalOpen: false,
loading: false,
lat: null, lat: null,
lng: null, lng: null,
map: null, map: null,
paginatedFeatures: [], paginatedFeatures: [],
pagination: { ...initialPagination }, pagination: { ...initialPagination },
projectSlug: this.$route.params.slug, projectSlug: this.$route.params.slug,
queryparams: {},
showMap: true, showMap: true,
showAddFeature: false,
showModifyStatus: false,
sort: { sort: {
column: '', column: 'updated_on',
ascending: true, ascending: true,
}, },
zoom: null, zoom: null,
...@@ -303,11 +179,7 @@ export default { ...@@ -303,11 +179,7 @@ export default {
computed: { computed: {
...mapState([ ...mapState([
'user', 'isOnline'
'USER_LEVEL_PROJECTS'
]),
...mapGetters([
'permissions',
]), ]),
...mapState('projects', [ ...mapState('projects', [
'project', 'project',
...@@ -315,8 +187,6 @@ export default { ...@@ -315,8 +187,6 @@ export default {
...mapState('feature', [ ...mapState('feature', [
'checkedFeatures', 'checkedFeatures',
'clickedFeatures', 'clickedFeatures',
'statusChoices',
'massMode',
]), ]),
...mapState('feature-type', [ ...mapState('feature-type', [
'feature_types', 'feature_types',
...@@ -329,99 +199,49 @@ export default { ...@@ -329,99 +199,49 @@ export default {
return this.$store.state.configuration.VUE_APP_DJANGO_API_BASE; return this.$store.state.configuration.VUE_APP_DJANGO_API_BASE;
}, },
filteredStatusChoices() {
//* if project is not moderate, remove pending status
return this.statusChoices.filter((el) =>
this.project && this.project.moderation ? true : el.value !== 'pending'
);
},
availableStatus() {
if (this.project && this.user) {
const isModerate = this.project.moderation;
const userStatus = this.USER_LEVEL_PROJECTS[this.project.slug];
const isOwnFeature = true; //* dans ce cas le contributeur est toujours l'auteur des signalements qu'il peut modifier
return allowedStatus2change(this.statusChoices, isModerate, userStatus, isOwnFeature);
}
return [];
},
featureTypeChoices() {
return this.feature_types.map((el) => el.title);
},
pageNumbers() { pageNumbers() {
return this.createPagesArray(this.featuresCount, this.pagination.pagesize); return this.createPagesArray(this.featuresCount, this.pagination.pagesize);
}, },
featuresCountDisplay() {
return this.showMap ? this.featuresWithGeomCount : this.featuresCount;
}
}, },
watch: { watch: {
'form.type.selected'() { isOnline(newValue, oldValue) {
this.resetPaginationNfetchFeatures(); if (newValue != oldValue && !newValue) {
}, this.DISPLAY_MESSAGE({
'form.status.selected.value'() { comment: 'Les signalements du projet non mis en cache ne sont pas accessibles en mode déconnecté',
this.resetPaginationNfetchFeatures(); });
},
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() { mounted() {
this.UPDATE_CHECKED_FEATURES([]); // empty for when turning back from edit attributes page
if (!this.project) { if (!this.project) {
// Chargements des features et infos projet en cas d'arrivée directe sur la page ou de refresh // Chargements des features et infos projet en cas d'arrivée directe sur la page ou de refresh
this.$store.dispatch('projects/GET_PROJECT', this.projectSlug); Promise.all([
this.$store this.$store.dispatch('projects/GET_PROJECT', this.projectSlug),
.dispatch('projects/GET_PROJECT_INFO', this.projectSlug) this.$store.dispatch('projects/GET_PROJECT_INFO', this.projectSlug)
.then(() => this.initMap()); ]).then(()=> this.initPage());
} else { } else {
this.initMap(); this.initPage();
} }
this.fetchPagedFeatures();
window.addEventListener('mousedown', this.clickOutsideDropdown);
}, },
destroyed() { destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
//* allow user to change page if ever stuck on loader //* allow user to change page if ever stuck on loader
this.$store.commit('DISCARD_LOADER'); this.loading = false;
}, },
methods: { methods: {
...mapMutations([
'DISPLAY_MESSAGE',
]),
...mapActions('feature', [ ...mapActions('feature', [
'GET_PROJECT_FEATURES',
'SEND_FEATURE',
'DELETE_FEATURE', 'DELETE_FEATURE',
]), ]),
...@@ -429,95 +249,228 @@ export default { ...@@ -429,95 +249,228 @@ export default {
'UPDATE_CHECKED_FEATURES' 'UPDATE_CHECKED_FEATURES'
]), ]),
toggleAddFeature() { setShowMap(newValue) {
this.showAddFeature = !this.showAddFeature; this.showMap = newValue;
this.showModifyStatus = false; // expanded sidebar is visible under the list, even when the map is closed (position:absolute), solved by closing it when switching to list
if (newValue === false && this.$refs.sidebar) this.$refs.sidebar.toggleSidebar(false);
}, },
resetPagination() {
toggleModifyStatus() { this.pagination = { ...initialPagination };
this.showModifyStatus = !this.showModifyStatus; },
this.showAddFeature = false;
/**
* Updates the filters based on the provided key-value pair.
*
* @param {Object} e - The key-value pair representing the filter to update.
*/
setFilters(e) {
const filter = Object.keys(e)[0];
let value = Object.values(e)[0];
if (value && Array.isArray(value)) {
value = value.map(el => el.value);
}
this.form[filter] = value;
}, },
toggleDeleteModal() { toggleDeleteModal() {
this.isDeleteModalOpen = !this.isDeleteModalOpen; this.isDeleteModalOpen = !this.isDeleteModalOpen;
}, },
clickOutsideDropdown(e) { /**
if (!e.target.closest('#button-dropdown')) { * Modifie le statut des objets sélectionnés.
this.showModifyStatus = false; *
setTimeout(() => { //* timout necessary to give time to click on link to add feature * Cette méthode prend en charge deux cas :
this.showAddFeature = false; * 1. Si tous les objets sont sélectionnés (`allSelected`), une requête unique en mode "bulk update" est envoyée
}, 500); * au backend pour modifier le statut de tous les objets correspondant aux critères.
} * 2. Si des objets spécifiques sont sélectionnés (`checkedFeatures`), ils sont traités un par un de manière
}, * récursive. Chaque objet modifié est retiré de la liste des objets sélectionnés.
*
* En cas d'erreur (réseau ou backend), un message d'erreur est affiché, et les données sont rafraîchies.
* Si tous les objets sont modifiés avec succès, un message de confirmation est affiché.
*
* @param {string} newStatus - Le nouveau statut à appliquer aux objets sélectionnés.
* @returns {Promise<void>} - Une promesse qui se résout lorsque tous les objets ont été traités.
*/
async modifyStatus(newStatus) { async modifyStatus(newStatus) {
if (this.checkedFeatures.length > 0) { if (this.allSelected) {
const feature_id = this.checkedFeatures[0]; // Cas : Modification en masse de tous les objets
const feature = this.clickedFeatures.find((el) => el.feature_id === feature_id); try {
// Update additional query parameters based on the current filter states.
this.updateQueryParams();
const queryString = new URLSearchParams(this.queryparams).toString();
const response = await featureAPI.projectFeatureBulkUpdateStatus(this.projectSlug, queryString, newStatus);
if (response && response.data) {
// Affiche un message basé sur la réponse du backend
this.DISPLAY_MESSAGE({
comment: response.data.message,
level: response.data.level,
});
}
} catch (error) {
// Gère les erreurs de type Axios (400, 500, etc.)
if (error.response && error.response.data) {
this.DISPLAY_MESSAGE({
comment: error.response.data.error || 'Une erreur est survenue.',
level: 'negative',
});
} else {
// Gère les erreurs réseau ou autres
this.DISPLAY_MESSAGE({
comment: 'Impossible de communiquer avec le serveur.',
level: 'negative',
});
}
}
// Rafraîchit les données après un traitement global
this.resetPagination();
this.fetchPagedFeatures();
} else if (this.checkedFeatures.length > 0) {
// Cas : Traitement des objets un par un
const feature_id = this.checkedFeatures[0]; // Récupère l'ID du premier objet sélectionné
const feature = this.clickedFeatures.find((el) => el.feature_id === feature_id); // Trouve l'objet complet
if (feature) { if (feature) {
featureAPI.updateFeature({ // Envoie une requête pour modifier le statut d'un objet spécifique
const response = await featureAPI.updateFeature({
feature_id, feature_id,
feature_type__slug: feature.feature_type, feature_type__slug: feature.feature_type,
project__slug: this.projectSlug, project__slug: this.projectSlug,
newStatus newStatus,
}).then((response) => {
if (response && response.data && response.status === 200) {
const newCheckedFeatures = [...this.checkedFeatures];
newCheckedFeatures.splice(this.checkedFeatures.indexOf(response.data.id), 1);
this.UPDATE_CHECKED_FEATURES(newCheckedFeatures);
this.modifyStatus(newStatus);
} else {
this.$store.commit('DISPLAY_MESSAGE', {
comment: `Le signalement ${feature.title} n'a pas pu être modifié`,
level: 'negative'
});
this.fetchPagedFeatures();
}
}); });
if (response && response.data && response.status === 200) {
// Supprime l'objet traité de la liste des objets sélectionnés
const newCheckedFeatures = [...this.checkedFeatures];
newCheckedFeatures.splice(this.checkedFeatures.indexOf(response.data.id), 1);
this.UPDATE_CHECKED_FEATURES(newCheckedFeatures);
// Rappel récursif pour traiter l'objet suivant
this.modifyStatus(newStatus);
} else {
// Affiche un message d'erreur si la modification échoue
this.DISPLAY_MESSAGE({
comment: `Le signalement ${feature.title} n'a pas pu être modifié.`,
level: 'negative',
});
// Rafraîchit les données en cas d'erreur
this.fetchPagedFeatures();
}
} }
} else { } else {
this.fetchPagedFeatures(); // Cas : Tous les objets ont été traités après le traitement récursif
this.$store.commit('DISPLAY_MESSAGE', { this.fetchPagedFeatures(); // Rafraîchit les données pour afficher les mises à jour
comment: 'Tous les signalements ont été modifié avec succès.', this.DISPLAY_MESSAGE({
level: 'positive' comment: 'Tous les signalements ont été modifiés avec succès.',
level: 'positive',
}); });
} }
}, },
deleteAllFeatureSelection() { /**
const initialFeaturesCount = this.featuresCount; * Supprime tous les objets sélectionnés.
const initialCurrentPage = this.pagination.currentPage; *
const promises = this.checkedFeatures.map( * Cette méthode prend en charge deux cas :
(feature_id) => this.DELETE_FEATURE({ feature_id, noFeatureType: true }) * 1. Si tous les objets sont sélectionnés (`allSelected`), une requête unique en mode "bulk delete" est envoyée
); * au backend pour supprimer tous les objets correspondant aux critères. La liste des résultats est ensuite rafraichie.
Promise.all(promises).then((response) => { * 2. Si des objets spécifiques sont sélectionnés (`checkedFeatures`), ils sont traités un par un de manière
const deletedFeaturesCount = response.reduce((acc, curr) => curr.status === 204 ? acc += 1 : acc, 0); * récursive. Cette méthode utilise `Promise.all` pour envoyer les requêtes de suppression en parallèle
const newFeaturesCount = initialFeaturesCount - deletedFeaturesCount; * pour tous les objets dans la liste `checkedFeatures`. Après suppression, elle met à jour la pagination
const newPagesArray = this.createPagesArray(newFeaturesCount, this.pagination.pagesize); * et rafraîchit les objets affichés pour refléter les changements.
const newLastPageNum = newPagesArray[newPagesArray.length - 1]; *
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', []); * En cas d'erreur (réseau ou backend), un message d'erreur est affiché, et les données sont rafraîchies.
if (initialCurrentPage > newLastPageNum) { //* if page doesn't exist anymore * Si tous les objets sont supprimé avec succès, un message de confirmation est affiché.
this.toPage(newLastPageNum); //* go to new last page *
} else { * @returns {Promise<void>} - Une promesse qui se résout lorsque tous les objets ont été traités.
this.fetchPagedFeatures(); */
async deleteAllFeatureSelection() {
if (this.allSelected) {
// Cas : Suppression en masse de tous les objets
try {
// Update additional query parameters based on the current filter states.
this.updateQueryParams();
const queryString = new URLSearchParams(this.queryparams).toString();
const response = await featureAPI.projectFeatureBulkDelete(this.projectSlug, queryString);
if (response && response.data) {
// Affiche un message basé sur la réponse du backend
this.DISPLAY_MESSAGE({
comment: response.data.message,
level: response.data.level,
});
}
} catch (error) {
// Gère les erreurs de type Axios (400, 500, etc.)
if (error.response && error.response.data) {
this.DISPLAY_MESSAGE({
comment: error.response.data.error || 'Une erreur est survenue.',
level: 'negative',
});
} else {
// Gère les erreurs réseau ou autres
this.DISPLAY_MESSAGE({
comment: 'Impossible de communiquer avec le serveur.',
level: 'negative',
});
}
} }
}) // Rafraîchit les données après un traitement global
.catch((err) => console.error(err)); this.resetPagination();
this.fetchPagedFeatures();
} else {
// Sauvegarde le nombre total d'objets
const initialFeaturesCount = this.featuresCount;
// Sauvegarde la page actuelle
const initialCurrentPage = this.pagination.currentPage;
// Crée une liste de promesses pour supprimer chaque objet sélectionné
const promises = this.checkedFeatures.map((feature_id) =>
this.DELETE_FEATURE({ feature_id, noFeatureType: true })
);
// Exécute toutes les suppressions en parallèle
Promise.all(promises)
.then((response) => {
// Compte le nombre d'objets supprimés avec succès
const deletedFeaturesCount = response.reduce(
(acc, curr) => (curr.status === 204 ? acc + 1 : acc),
0
);
// Calcule le nouveau total d'objets
const newFeaturesCount = initialFeaturesCount - deletedFeaturesCount;
// Recalcule les pages
const newPagesArray = this.createPagesArray(newFeaturesCount, this.pagination.pagesize);
// Dernière page valide
const newLastPageNum = newPagesArray[newPagesArray.length - 1];
// Réinitialise la sélection
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', []);
if (initialCurrentPage > newLastPageNum) {
// Navigue à la dernière page valide si la page actuelle n'existe plus
this.toPage(newLastPageNum);
} else {
// Rafraîchit les objets affichés
this.fetchPagedFeatures();
}
})
// Gère les erreurs éventuelles
.catch((err) => console.error(err));
}
// Ferme la modale de confirmation de suppression
this.toggleDeleteModal(); this.toggleDeleteModal();
}, },
onFilterChange() { onFilterChange() {
if (mapUtil.getMap()) { if (mapService.getMap() && mapService.mvtLayer) {
mapUtil.getMap().invalidateSize(); mapService.mvtLayer.changed();
mapUtil.getMap()._onResize(); // force refresh for vector tiles
if (window.layerMVT) {
window.layerMVT.redraw();
}
} }
}, },
initPage() {
this.sort = {
column: this.project.feature_browsing_default_sort.replace('-', ''),
ascending: this.project.feature_browsing_default_sort.includes('-')
};
this.initMap();
},
initMap() { initMap() {
this.zoom = this.$route.query.zoom || ''; this.zoom = this.$route.query.zoom || '';
this.lat = this.$route.query.lat || ''; this.lat = this.$route.query.lat || '';
...@@ -528,44 +481,38 @@ export default { ...@@ -528,44 +481,38 @@ export default {
var mapDefaultViewZoom = var mapDefaultViewZoom =
this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom; this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom;
this.map = mapUtil.createMap(this.$refs.map, { this.map = mapService.createMap(this.$refs.map, {
zoom: this.zoom, zoom: this.zoom,
lat: this.lat, lat: this.lat,
lng: this.lng, lng: this.lng,
mapDefaultViewCenter, mapDefaultViewCenter,
mapDefaultViewZoom, mapDefaultViewZoom,
maxZoom: this.project.map_max_zoom_level,
interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true },
fullScreenControl: true,
geolocationControl: true,
}); });
this.fetchBboxNfit(); this.$nextTick(() => {
const mvtUrl = `${this.API_BASE_URL}features.mvt`;
document.addEventListener('change-layers-order', (event) => { mapService.addVectorTileLayer({
// Reverse is done because the first layer in order has to be added in the map in last. url: mvtUrl,
// Slice is done because reverse() changes the original array, so we make a copy first project_slug: this.projectSlug,
mapUtil.updateOrder(event.detail.layers.slice().reverse()); featureTypes: this.feature_types,
formFilters: this.form,
queryParams: this.queryparams,
});
}); });
// --------- End sidebar events ---------- this.fetchPagedFeatures();
setTimeout(() => {
const project_id = this.projectSlug.split('-')[0];
const mvtUrl = `${this.API_BASE_URL}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`;
mapUtil.addVectorTileLayer(
mvtUrl,
this.projectSlug,
this.feature_types,
this.form
);
mapUtil.addGeocoders(this.$store.state.configuration);
}, 1000);
}, },
fetchBboxNfit(queryParams) { fetchBboxNfit(queryString) {
featureAPI featureAPI
.getFeaturesBbox(this.projectSlug, queryParams) .getFeaturesBbox(this.projectSlug, queryString)
.then((bbox) => { .then((bbox) => {
const map = mapUtil.getMap(); if (bbox) {
if (bbox && map) { mapService.fitBounds(bbox);
map.fitBounds(bbox, { padding: [25, 25] });
} }
}); });
}, },
...@@ -586,63 +533,77 @@ export default { ...@@ -586,63 +533,77 @@ export default {
return result; return result;
}, },
buildQueryString() { /**
let urlParams = ''; * Updates the query parameters based on the current state of the pagination and form filters.
const typeFilter = this.getFeatureTypeSlug(this.form.type.selected); * This function sets various parameters like offset, feature_type_slug, status__value, title,
const statusFilter = this.form.status.selected.value; * and ordering to be used in an API request and to filter hidden features on mvt tiles.
*/
if (typeFilter) { updateQueryParams() {
urlParams += `&feature_type_slug=${typeFilter}`; // empty queryparams to remove params when removed from the form
this.queryparams = {};
// Update the 'offset' parameter based on the current pagination start value.
this.queryparams['offset'] = this.pagination.start;
// Set 'feature_type_slug' if a type is selected in the form.
if (this.form.type.length > 0) {
this.queryparams['feature_type_slug'] = this.form.type;
} }
if (statusFilter) { // Set 'status__value' if a status is selected in the form.
urlParams += `&status__value=${statusFilter}`; if (this.form.status.length > 0) {
this.queryparams['status__value'] = this.form.status;
} }
// Set 'title' if a title is entered in the form.
if (this.form.title) { if (this.form.title) {
urlParams += `&title=${this.form.title}`; this.queryparams['title'] = this.form.title;
} }
if (this.sort.column) { // Update the 'ordering' parameter based on the current sorting state.
urlParams += `&ordering=${ // Prepends a '-' for descending order if sort.ascending is false.
this.sort.ascending ? '-' : '' this.queryparams['ordering'] = `${this.sort.ascending ? '-' : ''}${this.getAvalaibleField(this.sort.column)}`;
}${this.getAvalaibleField(this.sort.column)}`;
}
return urlParams;
}, },
fetchPagedFeatures(newUrl) { /**
let url = `${this.API_BASE_URL}projects/${this.projectSlug}/feature-paginated/?limit=${this.pagination.pagesize}&offset=${this.pagination.start}`; * Fetches paginated feature data from the API.
//* if receiving next & previous url (// todo : might be not used anymore, to check) * This function is called to retrieve a specific page of features based on the current pagination settings and any applied filters.
if (newUrl && typeof newUrl === 'string') { * If the application is offline, it displays a message and does not proceed with the API call.
//newUrl = newUrl.replace("8000", "8010"); //* for dev uncomment to use proxy link */
url = newUrl; fetchPagedFeatures() {
// Check if the application is online; if not, display a message and return.
if (!this.isOnline) {
this.DISPLAY_MESSAGE({
comment: 'Les signalements du projet non mis en cache ne sont pas accessibles en mode déconnecté',
});
return;
} }
const queryString = this.buildQueryString();
url += queryString; // Display a loading message.
this.$store.commit( this.loading = true;
'DISPLAY_LOADER',
'Récupération des signalements en cours...' // Update additional query parameters based on the current filter states.
); this.updateQueryParams();
const queryString = new URLSearchParams(this.queryparams).toString();
// Construct the base URL with query parameters.
const url = `${this.API_BASE_URL}projects/${this.projectSlug}/feature-paginated/?limit=${this.pagination.pagesize}&${queryString}`;
// Make an API call to get the paginated features.
featureAPI.getPaginatedFeatures(url) featureAPI.getPaginatedFeatures(url)
.then((data) => { .then((data) => {
if (data) { if (data) {
// Update the component state with the data received from the API.
this.featuresCount = data.count; this.featuresCount = data.count;
this.featuresWithGeomCount = data.geom_count;
this.previous = data.previous; this.previous = data.previous;
this.next = data.next; this.next = data.next;
this.paginatedFeatures = data.results; this.paginatedFeatures = data.results;
} }
//* bbox needs to be updated with the same filters // If there are features, update the bounding box.
if (this.paginatedFeatures.length) { if (this.paginatedFeatures.length) {
this.fetchBboxNfit(queryString); this.fetchBboxNfit(queryString);
this.onFilterChange(); //* use paginated event to watch change in filters and modify features on map
} }
this.$store.commit('DISCARD_LOADER'); // Trigger actions on filter change.
this.onFilterChange();
// Hide the loading message.
this.loading = false;
}); });
}, },
resetPaginationNfetchFeatures() {
this.pagination = { ...initialPagination };
this.fetchPagedFeatures();
},
//* Pagination for table *// //* Pagination for table *//
createPagesArray(featuresCount, pagesize) { createPagesArray(featuresCount, pagesize) {
...@@ -668,10 +629,15 @@ export default { ...@@ -668,10 +629,15 @@ export default {
handleSortChange(sort) { handleSortChange(sort) {
this.sort = sort; this.sort = sort;
this.fetchPagedFeatures({ this.fetchPagedFeatures();
filterType: undefined, },
filterValue: undefined,
}); handleAllSelectedChange(isChecked) {
this.allSelected = isChecked;
// Si des sélections existent, tout déselectionner
if (this.checkedFeatures.length > 0) {
this.UPDATE_CHECKED_FEATURES([]);
}
}, },
toPage(pageNumber) { toPage(pageNumber) {
...@@ -710,85 +676,92 @@ export default { ...@@ -710,85 +676,92 @@ export default {
<style lang="less" scoped> <style lang="less" scoped>
.loader-container {
#project-features { position: relative;
margin: 2em auto 1em; min-height: 250px; // keep a the spinner above result and below table header
}
#map {
width: 100%;
min-height: 300px;
height: calc(100vh - 300px);
border: 1px solid grey;
/* To not hide the filters */
z-index: 1; z-index: 1;
.ui.inverted.dimmer.active {
opacity: .6;
}
} }
#feature-list-container {
justify-content: flex-start;
}
#feature-list-container .ui.menu:not(.vertical) .right.item {
padding-right: 0;
}
.map-container { .map-container {
width: 80vw; width: 80vw;
transform: translateX(-50%); transform: translateX(-50%);
margin-left: 50%; margin-left: 50%;
visibility: hidden; visibility: hidden;
position: absolute; position: absolute;
#map {
min-height: 0;
}
} }
.map-container.visible { .map-container.visible {
visibility: visible; visibility: visible;
position: initial; position: relative;
} width: 100%;
.margin-left-25 {
margin-left: 0.25em !important;
}
.no-padding {
padding: 0 !important;
}
.ui.dropdown .menu .left.menu, .ui.dropdown > .left.menu .menu { .sidebar-container {
margin-right: 0 !important; left: -250px;
} }
#button-dropdown { .sidebar-container.expanded {
z-index: 1; left: 0;
} }
@media screen and (min-width: 767px) { #map {
.twelve-wide { width: 100%;
width: 75% !important; min-height: 310px;
height: calc(100vh - 310px);
border: 1px solid grey;
/* To not hide the filters */
z-index: 1;
} }
} }
div.geolocation-container {
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(1.3em + 60px + 34px);
}
@media screen and (max-width: 767px) { @media screen and (max-width: 767px) {
#feature-list-container > .mobile-fullwidth { #project-features {
width: 100% !important; margin: 1em auto 1em;
}
.no-margin-mobile {
margin: 0 !important;
} }
.no-padding-mobile { .map-container {
padding-top: 0 !important; width: 100%;
padding-bottom: 0 !important; position: relative;
} }
.mobile-column { }
flex-direction: column !important;
.fadeIn-enter-active {
animation: fadeIn .5s;
}
.fadeIn-leave-active {
animation: fadeIn .5s reverse;
}
.transition.fade.in {
-webkit-animation-name: fadeIn;
animation-name: fadeIn
}
@-webkit-keyframes fadeIn {
0% {
opacity: 0
} }
#button-dropdown {
transform: translate(-50px, -60px); 100% {
opacity: .9
} }
#form-filters > .field.column { }
width: 100% !important;
@keyframes fadeIn {
0% {
opacity: 0
} }
.map-container {
width: 100%; 100% {
opacity: .9
} }
} }
</style> </style>
<template> <template>
<div <div id="project-basemaps">
id="project-basemaps"
class="page"
>
<div
id="message_info"
class="fullwidth"
>
<div v-if="infoMessage.length > 0">
<div
v-for="(message, index) of infoMessage"
:key="index"
:class="['ui message', message.success ? 'positive' : 'negative']"
style="text-align: left"
>
<div class="header">
<i class="info circle icon" /> Informations
</div>
{{ message.comment }}
</div>
</div>
</div>
<h1 class="ui header"> <h1 class="ui header">
Administration des fonds cartographiques Administration des fonds cartographiques
</h1> </h1>
<form <form
id="form-layers" id="form-layers"
action="."
method="post"
enctype="multipart/form-data"
class="ui form" class="ui form"
> >
<!-- {{ formset.management_form }} --> <!-- {{ formset.management_form }} -->
<div class="ui buttons"> <div class="ui buttons">
<a <button
class="ui compact small icon left floated button green" class="ui compact small icon left floated button green"
type="button"
data-variation="mini" data-variation="mini"
@click="addBasemap" @click="addBasemap"
> >
<i class="ui plus icon" /> <i
class="ui plus icon"
aria-hidden="true"
/>
<span>&nbsp;Créer un fond cartographique</span> <span>&nbsp;Créer un fond cartographique</span>
</a> </button>
</div> </div>
<div <div
v-if="basemaps" v-if="basemaps"
class="ui" class="ui margin-bottom margin-top"
> >
<BasemapListItem <BasemapListItem
v-for="basemap in basemaps" v-for="basemap in basemaps"
...@@ -54,22 +34,26 @@ ...@@ -54,22 +34,26 @@
:basemap="basemap" :basemap="basemap"
/> />
</div> </div>
<div class="margin-top">
<button <button
type="button" v-if="basemaps && basemaps[0] && basemaps[0].title && basemaps[0].layers.length > 0"
class="ui teal icon floated button" type="button"
@click="saveChanges" class="ui teal icon floated button"
> @click="saveChanges"
<i class="white save icon" /> Enregistrer les changements >
</button> <i
</div> class="white save icon"
aria-hidden="true"
/>
Enregistrer les changements
</button>
</form> </form>
</div> </div>
</template> </template>
<script> <script>
import BasemapListItem from '@/components/Project/Basemaps/BasemapListItem.vue'; import BasemapListItem from '@/components/Project/Basemaps/BasemapListItem.vue';
import { mapState, mapGetters } from 'vuex'; import { mapState, mapGetters, mapMutations } from 'vuex';
export default { export default {
name: 'ProjectBasemaps', name: 'ProjectBasemaps',
...@@ -80,7 +64,6 @@ export default { ...@@ -80,7 +64,6 @@ export default {
data() { data() {
return { return {
infoMessage: [],
newBasemapIds: [], newBasemapIds: [],
}; };
}, },
...@@ -102,6 +85,7 @@ export default { ...@@ -102,6 +85,7 @@ export default {
}, },
methods: { methods: {
...mapMutations(['DISPLAY_MESSAGE']),
addBasemap() { addBasemap() {
this.newBasemapIds.push(this.basemapMaxId + 1); //* register new basemaps to seperate post and put this.newBasemapIds.push(this.basemapMaxId + 1); //* register new basemaps to seperate post and put
this.$store.commit('map/CREATE_BASEMAP', this.basemapMaxId + 1); this.$store.commit('map/CREATE_BASEMAP', this.basemapMaxId + 1);
...@@ -130,30 +114,20 @@ export default { ...@@ -130,30 +114,20 @@ export default {
.then((response) => { .then((response) => {
const errors = response.filter( const errors = response.filter(
(res) => (res) =>
res.status === 200 && res.status === 201 && res.status === 204 res.status !== 200 && res.status !== 201 && res.status !== 204
); );
if (errors.length === 0) { if (errors.length === 0) {
this.infoMessage.push({ this.DISPLAY_MESSAGE({
success: true,
comment: 'Enregistrement effectué.', comment: 'Enregistrement effectué.',
level: 'positive'
}); });
this.newBasemapIds = []; this.newBasemapIds = [];
} else { } else {
this.infoMessage.push({ this.DISPLAY_MESSAGE({
success: false, comment: 'L\'édition des fonds cartographiques a échoué.',
comment: "L'édition des fonds cartographiques a échoué. ", level: 'negative'
}); });
} }
document
.getElementById('message_info')
.scrollIntoView({ block: 'end', inline: 'nearest' });
setTimeout(
function () {
this.infoMessage = [];
}.bind(this),
5000
);
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
...@@ -162,12 +136,4 @@ export default { ...@@ -162,12 +136,4 @@ export default {
}, },
}, },
}; };
</script> </script>
\ No newline at end of file
<style lang="less" scoped>
#project-basemaps {
min-width: 300px;
}
</style>
\ No newline at end of file
...@@ -3,67 +3,73 @@ ...@@ -3,67 +3,73 @@
<div <div
v-if="permissions && permissions.can_view_project && project" v-if="permissions && permissions.can_view_project && project"
id="project-detail" id="project-detail"
class="page"
> >
<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>
<ProjectHeader <ProjectHeader
:arrays-offline="arraysOffline" :arrays-offline="arraysOffline"
@retrieveInfo="retrieveProjectInfo" @retrieveInfo="retrieveProjectInfo"
@updateLocalStorage="updateLocalStorage" @updateLocalStorage="updateLocalStorage"
/> />
<div class="ui grid"> <div class="ui grid stackable">
<div class="row"> <div class="row">
<div class="eight wide column"> <div class="eight wide column">
<ProjectFeatureTypes <ProjectFeatureTypes
:loading="projectInfoLoading" :loading="featureTypesLoading"
:project="project" :project="project"
@delete="toggleDeleteFeatureTypeModal"
@update="updateAfterImport"
/> />
</div> </div>
<div class="eight wide column"> <div class="eight wide column block-map">
<div <div class="map-container">
:class="{ active: mapLoading }" <div
class="ui inverted dimmer" id="map"
> ref="map"
<div class="ui text loader"> />
Chargement de la carte... <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>
</div> </div>
<div
id="map" <router-link
ref="map" 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> </div>
...@@ -71,7 +77,7 @@ ...@@ -71,7 +77,7 @@
<div class="sixteen wide column"> <div class="sixteen wide column">
<div class="ui two stackable cards"> <div class="ui two stackable cards">
<ProjectLastFeatures <ProjectLastFeatures
:loading="featuresLoading" ref="lastFeatures"
/> />
<ProjectLastComments <ProjectLastComments
:loading="projectInfoLoading" :loading="projectInfoLoading"
...@@ -91,20 +97,24 @@ ...@@ -91,20 +97,24 @@
</div> </div>
<span v-else-if="!projectInfoLoading"> <span v-else-if="!projectInfoLoading">
<i class="icon exclamation triangle" /> <i
class="icon exclamation triangle"
aria-hidden="true"
/>
<span>Vous ne disposez pas des droits nécessaires pour consulter ce <span>Vous ne disposez pas des droits nécessaires pour consulter ce
projet.</span> projet.</span>
</span> </span>
<ProjectModal <ProjectModal
:is-subscriber="is_suscriber" :is-subscriber="is_suscriber"
:feature-type-to-delete="featureTypeToDelete"
@action="handleModalAction" @action="handleModalAction"
/> />
</div> </div>
</template> </template>
<script> <script>
import { mapUtil } from '@/assets/js/map-util.js'; import mapService from '@/services/map-service';
import { mapGetters, mapState, mapActions, mapMutations } from 'vuex'; import { mapGetters, mapState, mapActions, mapMutations } from 'vuex';
import projectAPI from '@/services/project-api'; import projectAPI from '@/services/project-api';
...@@ -117,6 +127,8 @@ import ProjectLastFeatures from '@/components/Project/Detail/ProjectLastFeatures ...@@ -117,6 +127,8 @@ import ProjectLastFeatures from '@/components/Project/Detail/ProjectLastFeatures
import ProjectLastComments from '@/components/Project/Detail/ProjectLastComments'; import ProjectLastComments from '@/components/Project/Detail/ProjectLastComments';
import ProjectParameters from '@/components/Project/Detail/ProjectParameters'; import ProjectParameters from '@/components/Project/Detail/ProjectParameters';
import ProjectModal from '@/components/Project/Detail/ProjectModal'; import ProjectModal from '@/components/Project/Detail/ProjectModal';
import SidebarLayers from '@/components/Map/SidebarLayers';
import Geolocation from '@/components/Map/Geolocation';
export default { export default {
name: 'ProjectDetail', name: 'ProjectDetail',
...@@ -127,7 +139,9 @@ export default { ...@@ -127,7 +139,9 @@ export default {
ProjectLastFeatures, ProjectLastFeatures,
ProjectLastComments, ProjectLastComments,
ProjectParameters, ProjectParameters,
ProjectModal ProjectModal,
SidebarLayers,
Geolocation,
}, },
filters: { filters: {
...@@ -144,8 +158,8 @@ export default { ...@@ -144,8 +158,8 @@ export default {
props: { props: {
message: { message: {
type: String, type: Object,
default: '' default: () => {}
} }
}, },
...@@ -159,8 +173,8 @@ export default { ...@@ -159,8 +173,8 @@ export default {
is_suscriber: false, is_suscriber: false,
tempMessage: null, tempMessage: null,
projectInfoLoading: true, projectInfoLoading: true,
featureTypesLoading: false,
featureTypeToDelete: null, featureTypeToDelete: null,
featuresLoading: true,
mapLoading: true, mapLoading: true,
}; };
}, },
...@@ -182,20 +196,18 @@ export default { ...@@ -182,20 +196,18 @@ export default {
'feature_types' 'feature_types'
]), ]),
...mapState([ ...mapState([
'last_comments',
'user', 'user',
'user_permissions', 'user_permissions',
'reloadIntervalId', 'reloadIntervalId',
]), ]),
...mapState('map', [ ...mapState('map', [
'map' 'map',
'basemaps',
'availableLayers',
]), ]),
API_BASE_URL() { API_BASE_URL() {
return this.configuration.VUE_APP_DJANGO_API_BASE; return this.configuration.VUE_APP_DJANGO_API_BASE;
}, },
isSharedProject() {
return this.$route.path.includes('projet-partage');
},
}, },
created() { created() {
...@@ -207,35 +219,28 @@ export default { ...@@ -207,35 +219,28 @@ export default {
}) })
.then((data) => (this.is_suscriber = data.is_suscriber)); .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() { mounted() {
this.retrieveProjectInfo(); this.retrieveProjectInfo();
if (this.message) { if (this.message) {
this.tempMessage = this.message; this.DISPLAY_MESSAGE(this.message);
document
.getElementById('message')
.scrollIntoView({ block: 'end', inline: 'nearest' });
setTimeout(() => (this.tempMessage = null), 5000); //* hide message after 5 seconds
} }
}, },
destroyed() { beforeDestroy() {
this.$store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
this.CLEAR_RELOAD_INTERVAL_ID(); this.CLEAR_RELOAD_INTERVAL_ID();
this.CLOSE_PROJECT_MODAL();
}, },
methods: { methods: {
...mapMutations([ ...mapMutations([
'SET_RELOAD_INTERVAL_ID',
'CLEAR_RELOAD_INTERVAL_ID', 'CLEAR_RELOAD_INTERVAL_ID',
'DISPLAY_MESSAGE', 'DISPLAY_MESSAGE',
'DISPLAY_LOADER',
'DISCARD_LOADER',
]), ]),
...mapMutations('modals', [ ...mapMutations('modals', [
'OPEN_PROJECT_MODAL',
'CLOSE_PROJECT_MODAL' 'CLOSE_PROJECT_MODAL'
]), ]),
...mapActions('projects', [ ...mapActions('projects', [
...@@ -249,35 +254,25 @@ export default { ...@@ -249,35 +254,25 @@ export default {
'GET_PROJECT_FEATURES' 'GET_PROJECT_FEATURES'
]), ]),
...mapActions('feature-type', [ ...mapActions('feature-type', [
'GET_IMPORTS' 'GET_PROJECT_FEATURE_TYPES',
]), ]),
getRouteUrl(url) {
if (this.isSharedProject) {
url = url.replace('projet', 'projet-partage');
}
return url.replace(this.$store.state.configuration.BASE_URL, ''); //* remove duplicate /geocontrib
},
retrieveProjectInfo() { retrieveProjectInfo() {
this.DISPLAY_LOADER('Projet en cours de chargement.');
Promise.all([ Promise.all([
this.GET_PROJECT(this.slug), this.GET_PROJECT(this.slug),
this.GET_PROJECT_INFO(this.slug) this.GET_PROJECT_INFO(this.slug)
]) ])
.then(() => { .then(() => {
this.DISCARD_LOADER(); this.$nextTick(() => {
this.projectInfoLoading = false; const map = mapService.getMap();
setTimeout(() => { if (map) mapService.destroyMap();
const map = mapUtil.getMap();
if (map) {
map.remove();
}
this.initMap(); this.initMap();
}, 1000); });
}) })
.catch((err) => { .catch((err) => {
console.error(err); console.error(err);
this.DISCARD_LOADER(); })
.finally(() => {
this.projectInfoLoading = false; this.projectInfoLoading = false;
}); });
}, },
...@@ -318,15 +313,16 @@ export default { ...@@ -318,15 +313,16 @@ export default {
}) })
.then((data) => { .then((data) => {
this.is_suscriber = data.is_suscriber; this.is_suscriber = data.is_suscriber;
this.modalType = false; this.CLOSE_PROJECT_MODAL();
if (this.is_suscriber) { if (this.is_suscriber) {
this.infoMessage = this.DISPLAY_MESSAGE({
'Vous êtes maintenant abonné aux notifications de ce projet.'; comment: 'Vous êtes maintenant abonné aux notifications de ce projet.', level: 'positive'
});
} else { } else {
this.infoMessage = this.DISPLAY_MESSAGE({
'Vous ne recevrez plus les notifications de ce projet.'; comment: 'Vous ne recevrez plus les notifications de ce projet.', level: 'negative'
});
} }
setTimeout(() => (this.infoMessage = ''), 3000);
}); });
}, },
...@@ -350,9 +346,9 @@ export default { ...@@ -350,9 +346,9 @@ export default {
deleteFeatureType() { deleteFeatureType() {
featureTypeAPI.deleteFeatureType(this.featureTypeToDelete.slug) featureTypeAPI.deleteFeatureType(this.featureTypeToDelete.slug)
.then((response) => { .then((response) => {
this.modalType = false; this.CLOSE_PROJECT_MODAL();
if (response === 'success') { if (response === 'success') {
this.GET_PROJECT(); this.GET_PROJECT(this.slug);
this.retrieveProjectInfo(); this.retrieveProjectInfo();
this.DISPLAY_MESSAGE({ this.DISPLAY_MESSAGE({
comment: `Le type de signalement ${this.featureTypeToDelete.title} a bien été supprimé.`, comment: `Le type de signalement ${this.featureTypeToDelete.title} a bien été supprimé.`,
...@@ -365,6 +361,13 @@ export default { ...@@ -365,6 +361,13 @@ export default {
}); });
} }
this.featureTypeToDelete = null; 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();
}); });
}, },
...@@ -382,61 +385,100 @@ export default { ...@@ -382,61 +385,100 @@ export default {
} }
}, },
toggleModalType(featureType) { toggleDeleteFeatureTypeModal(featureType) {
this.featureTypeToDelete = featureType; this.featureTypeToDelete = featureType;
this.modalType = 'deleteFeatureType'; 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() { async initMap() {
// Check if the project is accessible and the user has view permissions
if (this.project && this.permissions.can_view_project) { if (this.project && this.permissions.can_view_project) {
await this.INITIATE_MAP(this.$refs.map); // Initialize the map using the provided element reference
await this.INITIATE_MAP({ el: this.$refs.map });
// Check for offline features
this.checkForOfflineFeature(); this.checkForOfflineFeature();
const project_id = this.$route.params.slug.split('-')[0]; // Define the URL for vector tile layers
const mvtUrl = `${this.API_BASE_URL}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`; const mvtUrl = `${this.API_BASE_URL}features.mvt`;
mapUtil.addVectorTileLayer( // Define parameters for loading layers
mvtUrl, const params = {
this.$route.params.slug, project_slug: this.slug,
this.feature_types featureTypes: this.feature_types,
); queryParams: {
this.mapLoading = false; 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')); this.arraysOffline.forEach((x) => (x.geojson.properties.color = 'red'));
// Extract offline features from arraysOffline
const featuresOffline = this.arraysOffline.map((x) => x.geojson); const featuresOffline = this.arraysOffline.map((x) => x.geojson);
// Add offline features to the map if available
this.GET_PROJECT_FEATURES({ if (featuresOffline && featuresOffline.length > 0) {
project_slug: this.slug, mapService.addFeatures({
ordering: '-created_on', addToMap: true,
limit: null, features: featuresOffline,
geojson: true, ...params
})
.then(() => {
this.featuresLoading = false;
mapUtil.addFeatures(
[...this.features, ...featuresOffline],
{},
true,
this.feature_types
);
})
.catch((err) => {
console.error(err);
this.featuresLoading = false;
}); });
}
// Get the bounding box of features and fit the map to it
featureAPI.getFeaturesBbox(this.slug).then((bbox) => { featureAPI.getFeaturesBbox(this.slug).then((bbox) => {
if (bbox) { if (bbox) {
mapUtil.getMap().fitBounds(bbox, { padding: [25, 25] }); 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> </script>
<style scoped> <style lang="less" scoped>
.fullwidth { .fullwidth {
width: 100%; 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> </style>
<template> <template>
<div <div id="project-edit">
id="project-edit"
class="page"
>
<div <div
:class="{ active: loading }" :class="{ active: loading }"
class="ui inverted dimmer" class="ui inverted dimmer"
...@@ -49,9 +46,10 @@ ...@@ -49,9 +46,10 @@
</li> </li>
</ul> </ul>
</div> </div>
<div class="field"> <div class="field file-logo">
<label>Illustration du projet</label> <label>Illustration du projet</label>
<img <img
v-if="thumbnailFileSrc.length || form.thumbnail.length"
id="form-input-file-logo" id="form-input-file-logo"
class="ui small image" class="ui small image"
:src=" :src="
...@@ -59,12 +57,16 @@ ...@@ -59,12 +57,16 @@
? thumbnailFileSrc ? thumbnailFileSrc
: DJANGO_BASE_URL + form.thumbnail : DJANGO_BASE_URL + form.thumbnail
" "
alt="Thumbnail du projet"
> >
<label <label
class="ui icon button" class="ui icon button"
for="thumbnail" for="thumbnail"
> >
<i class="file icon" /> <i
class="file icon"
aria-hidden="true"
/>
<span class="label">{{ <span class="label">{{
form.thumbnail_name ? form.thumbnail_name : fileToImport.name form.thumbnail_name ? form.thumbnail_name : fileToImport.name
}}</span> }}</span>
...@@ -89,14 +91,26 @@ ...@@ -89,14 +91,26 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class="field"> <div class="two fields">
<label for="description">Description</label> <div class="field">
<textarea <label for="description">Description</label>
v-model="form.description" <textarea
name="description" id="editor"
rows="5" v-model="form.description"
/> data-preview="#preview"
<!-- {{ form.description.errors }} --> 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>
<div class="ui horizontal divider"> <div class="ui horizontal divider">
...@@ -104,52 +118,10 @@ ...@@ -104,52 +118,10 @@
</div> </div>
<div class="two fields"> <div class="two fields">
<!-- <div class="field"> <div
<label for="archive_feature">Délai avant archivage</label> id="published-visibility"
<div class="ui right labeled input"> class="required field"
<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">
<label <label
for="access_level_pub_feature" for="access_level_pub_feature"
>Visibilité des signalements publiés</label> >Visibilité des signalements publiés</label>
...@@ -170,12 +142,15 @@ ...@@ -170,12 +142,15 @@
</li> </li>
</ul> </ul>
</div> </div>
<div class="required field"> <div
id="archived-visibility"
class="required field"
>
<label for="access_level_arch_feature"> <label for="access_level_arch_feature">
Visibilité des signalements archivés Visibilité des signalements archivés
</label> </label>
<Dropdown <Dropdown
:options="levelPermissions" :options="levelPermissionsArc"
:selected="form.access_level_arch_feature.name" :selected="form.access_level_arch_feature.name"
:selection.sync="form.access_level_arch_feature" :selection.sync="form.access_level_arch_feature"
/> />
...@@ -193,53 +168,179 @@ ...@@ -193,53 +168,179 @@
</div> </div>
</div> </div>
<div class="field"> <div class="two fields">
<div class="ui checkbox"> <div class="fields grouped checkboxes">
<input <div class="field">
id="moderation" <div class="ui checkbox">
v-model="form.moderation" <input
class="hidden" id="moderation"
type="checkbox" v-model="form.moderation"
name="moderation" class="hidden"
> type="checkbox"
<label for="moderation">Modération</label> 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>
<div class="field"> <div class="field">
<div class="ui checkbox"> <label>Niveau de zoom maximum de la carte</label>
<input <div class="map-maxzoom-selector">
id="is_project_type" <div class="range-container">
v-model="form.is_project_type" <input
class="hidden" v-model="form.map_max_zoom_level"
type="checkbox" type="range"
name="is_project_type" min="0"
> max="22"
<label for="is_project_type">Est un projet type</label> 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> </div>
<div class="field">
<div class="ui checkbox"> <div
<input v-if="filteredAttributes.length > 0"
id="generate_share_link" class="ui horizontal divider"
v-model="form.generate_share_link" >
class="hidden" ATTRIBUTS
type="checkbox" </div>
name="generate_share_link"
> <div class="fields grouped">
<label for="generate_share_link">Génération d'un lien de partage externe</label> <ProjectAttributeForm
</div> v-for="(attribute, index) in filteredAttributes"
:key="index"
:attribute="attribute"
:form-project-attributes="form.project_attributes"
@update:project_attributes="updateProjectAttributes($event)"
/>
</div> </div>
<div class="ui divider" /> <div class="ui divider" />
<button <button
id="send-project"
type="button" type="button"
class="ui teal icon button" class="ui teal icon button"
@click="postForm" @click="postForm"
> >
<i class="white save icon" /> Enregistrer les changements <i
class="white save icon"
aria-hidden="true"
/> Enregistrer les changements
</button> </button>
</form> </form>
</div> </div>
...@@ -247,15 +348,20 @@ ...@@ -247,15 +348,20 @@
<script> <script>
import axios from '@/axios-client.js'; 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, mapActions } from 'vuex'; import TextareaMarkdown from 'textarea-markdown';
import { mapActions, mapState } from 'vuex';
export default { export default {
name: 'ProjectEdit', name: 'ProjectEdit',
components: { components: {
Dropdown, Dropdown,
ProjectAttributeForm
}, },
data() { data() {
...@@ -266,13 +372,30 @@ export default { ...@@ -266,13 +372,30 @@ export default {
name: 'Sélectionner une image ...', name: 'Sélectionner une image ...',
size: 0, size: 0,
}, },
errors_archive_feature: [],
errors: { errors: {
title: [], title: [],
access_level_pub_feature: [], access_level_pub_feature: [],
access_level_arch_feature: [], access_level_arch_feature: [],
}, },
errorThumbnail: [], 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: { form: {
title: '', title: '',
slug: '', slug: '',
...@@ -285,8 +408,7 @@ export default { ...@@ -285,8 +408,7 @@ export default {
creator: null, creator: null,
access_level_pub_feature: { name: '', value: '' }, access_level_pub_feature: { name: '', value: '' },
access_level_arch_feature: { name: '', value: '' }, access_level_arch_feature: { name: '', value: '' },
archive_feature: 0, map_max_zoom_level: 22,
delete_feature: 0,
nb_features: 0, nb_features: 0,
nb_published_features: 0, nb_published_features: 0,
nb_comments: 0, nb_comments: 0,
...@@ -294,20 +416,52 @@ export default { ...@@ -294,20 +416,52 @@ export default {
nb_contributors: 0, nb_contributors: 0,
is_project_type: false, is_project_type: false,
generate_share_link: 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: '', 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: { computed: {
...mapState([ ...mapState([
'levelsPermissions', 'levelsPermissions',
'projectAttributes'
]), ]),
...mapState('projects', ['project']), ...mapState('projects', ['project']),
DJANGO_BASE_URL: function () { DJANGO_BASE_URL: function () {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE; return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
}, },
levelPermissions(){ levelPermissionsArc(){
const levels = new Array(); const levels = new Array();
if(this.levelsPermissions) { if(this.levelsPermissions) {
this.levelsPermissions.forEach((item) => { this.levelsPermissions.forEach((item) => {
...@@ -317,7 +471,7 @@ export default { ...@@ -317,7 +471,7 @@ export default {
value: item.user_type_id, value: item.user_type_id,
}); });
} }
if (!this.form.moderation && item.user_type_id == 'moderator') { if (!this.form.moderation && item.user_type_id === 'moderator') {
levels.pop(); levels.pop();
} }
}); });
...@@ -341,7 +495,14 @@ export default { ...@@ -341,7 +495,14 @@ export default {
}); });
} }
return levels; return levels;
} },
/**
* Filter out attribute of field type list without option
*/
filteredAttributes() {
return this.projectAttributes.filter(attr => attr.field_type === 'boolean' || attr.options);
},
}, },
watch: { watch: {
...@@ -351,11 +512,12 @@ export default { ...@@ -351,11 +512,12 @@ export default {
} }
} }
}, },
created() { mounted() {
this.definePageType(); this.definePageType();
if (this.action === 'create') { if (this.action === 'create') {
this.thumbnailFileSrc = require('@/assets/img/default.png'); this.thumbnailFileSrc = require('@/assets/img/default.png');
this.initPreviewMap();
} else if (this.action === 'edit' || this.action === 'create_from') { } else if (this.action === 'edit' || this.action === 'create_from') {
if (!this.project) { if (!this.project) {
this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug) this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug)
...@@ -368,11 +530,13 @@ export default { ...@@ -368,11 +530,13 @@ export default {
this.fillProjectForm(); this.fillProjectForm();
} }
} }
let textarea = document.querySelector('textarea');
new TextareaMarkdown(textarea);
}, },
methods: { methods: {
...mapActions('projects', [ ...mapActions('map', [
'GET_ALL_PROJECTS' 'INITIATE_MAP'
]), ]),
definePageType() { definePageType() {
if (this.$router.history.current.name === 'project_create') { if (this.$router.history.current.name === 'project_create') {
...@@ -450,21 +614,11 @@ export default { ...@@ -450,21 +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) { goBackNrefresh(slug) {
Promise.all([ Promise.all([
this.$store.dispatch('GET_USER_LEVEL_PROJECTS'), //* refresh projects user levels this.$store.dispatch('GET_USER_LEVEL_PROJECTS'), //* refresh projects user levels
this.$store.dispatch('GET_USER_LEVEL_PERMISSIONS'), //* refresh projects permissions 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(() => ]).then(() =>
// * go back to project list // * go back to project list
this.$router.push({ this.$router.push({
...@@ -508,12 +662,6 @@ export default { ...@@ -508,12 +662,6 @@ export default {
}, },
checkForm() { 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) { for (const key in this.errors) {
if ((key === 'title' && this.form[key]) || this.form[key].value) { if ((key === 'title' && this.form[key]) || this.form[key].value) {
this.errors[key] = []; this.errors[key] = [];
...@@ -537,21 +685,16 @@ export default { ...@@ -537,21 +685,16 @@ export default {
return; return;
} }
const projectData = { const projectData = {
title: this.form.title, ...this.form,
description: this.form.description,
access_level_arch_feature: this.form.access_level_arch_feature.value, access_level_arch_feature: this.form.access_level_arch_feature.value,
access_level_pub_feature: this.form.access_level_pub_feature.value, access_level_pub_feature: this.form.access_level_pub_feature.value,
archive_feature: this.form.archive_feature, feature_browsing_default_sort: this.form.feature_browsing_default_sort.value,
delete_feature: this.form.delete_feature, feature_browsing_default_filter: this.form.feature_browsing_default_filter.value,
is_project_type: this.form.is_project_type,
generate_share_link: this.form.generate_share_link,
moderation: this.form.moderation,
}; };
let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/`;
if (this.action === 'edit') { if (this.action === 'edit') {
await axios 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) => { .then((response) => {
if (response && response.status === 200) { if (response && response.status === 200) {
//* send thumbnail after feature_type was updated //* send thumbnail after feature_type was updated
...@@ -569,8 +712,9 @@ export default { ...@@ -569,8 +712,9 @@ export default {
throw error; throw error;
}); });
} else { } else {
let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/projects/`;
if (this.action === 'create_from') { 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; this.loading = true;
await axios await axios
...@@ -595,9 +739,12 @@ export default { ...@@ -595,9 +739,12 @@ export default {
}); });
} }
}, },
fillProjectForm() { fillProjectForm() {
this.form = { ...this.project }; //* create a new object to avoid modifying original one //* create a new object to avoid modifying original one
if (this.action === 'create_from') { //* if duplication of project, generate new name this.form = { ...this.project };
//* if duplication of project, generate new name
if (this.action === 'create_from') {
this.form.title = this.form.title =
this.project.title + this.project.title +
` (Copie-${new Date() ` (Copie-${new Date()
...@@ -606,41 +753,215 @@ export default { ...@@ -606,41 +753,215 @@ export default {
.replace(',', '')})`; .replace(',', '')})`;
this.form.is_project_type = false; this.form.is_project_type = false;
} }
//* transform string values to objects for dropdowns display (could be in a computed) //* transform string values to objects used with dropdowns
// fill dropdown current selection for archived feature viewing permission
if (this.levelPermissionsArc) {
const accessLevelArc = this.levelPermissionsArc.find(
(el) => el.name === this.project.access_level_arch_feature
);
if (accessLevelArc) {
this.form.access_level_arch_feature = {
name: this.project.access_level_arch_feature,
value: accessLevelArc.value ,
};
}
}
// fill dropdown current selection for published feature viewing permission
if (this.levelPermissionsPub) { if (this.levelPermissionsPub) {
const value = this.levelPermissionsPub.find( const accessLevelPub = this.levelPermissionsPub.find(
(el) => el.name === this.project.access_level_pub_feature (el) => el.name === this.project.access_level_pub_feature
); );
if(value){ if (accessLevelPub) {
this.form.access_level_pub_feature = { this.form.access_level_pub_feature = {
name: this.project.access_level_pub_feature, name: this.project.access_level_pub_feature,
value: value.value , value: accessLevelPub.value ,
}; };
} }
} }
if (this.levelPermissions) { // fill dropdown current selection for feature browsing default filtering
const value = this.levelPermissions.find( const default_filter = this.featureBrowsingOptions.filter.find(
(el) => el.name === this.project.access_level_arch_feature (el) => el.value === this.project.feature_browsing_default_filter
); );
if(value){ if (default_filter) {
this.form.access_level_arch_feature = { this.form.feature_browsing_default_filter = default_filter;
name: this.project.access_level_arch_feature, }
value: value.value , // 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> </script>
<style media="screen"> <style media="screen" lang="less">
#form-input-file-logo { #form-input-file-logo {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.file-logo {
min-height: calc(150px + 2.4285em);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.close.icon:hover { .close.icon:hover {
cursor: pointer; 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> <template>
<div <div id="project-members">
id="project-members"
class="page"
>
<h1 class="ui header"> <h1 class="ui header">
Gérer les membres Gérer les membres
</h1> </h1>
...@@ -49,7 +46,10 @@ ...@@ -49,7 +46,10 @@
:disabled="!newMember.user.name" :disabled="!newMember.user.name"
@click="addMember" @click="addMember"
> >
<i class="white add icon" /> <i
class="white add icon"
aria-hidden="true"
/>
<span class="padding-1">Ajouter</span> <span class="padding-1">Ajouter</span>
</button> </button>
</div> </div>
...@@ -59,7 +59,10 @@ ...@@ -59,7 +59,10 @@
id="form-members" id="form-members"
class="ui form" class="ui form"
> >
<table class="ui red table"> <table
class="ui red table"
aria-describedby="Table des membres du projet"
>
<thead> <thead>
<tr> <tr>
<th scope="col"> <th scope="col">
...@@ -70,6 +73,7 @@ ...@@ -70,6 +73,7 @@
up: isSortedDesc('member'), up: isSortedDesc('member'),
}" }"
class="icon sort" class="icon sort"
aria-hidden="true"
@click="changeSort('member')" @click="changeSort('member')"
/> />
</th> </th>
...@@ -81,6 +85,7 @@ ...@@ -81,6 +85,7 @@
up: isSortedDesc('role'), up: isSortedDesc('role'),
}" }"
class="icon sort" class="icon sort"
aria-hidden="true"
@click="changeSort('role')" @click="changeSort('role')"
/> />
</th> </th>
...@@ -108,7 +113,10 @@ ...@@ -108,7 +113,10 @@
data-tooltip="Retirer ce membre" data-tooltip="Retirer ce membre"
@click="removeMember(member)" @click="removeMember(member)"
> >
<i class="times icon" /> <i
class="times icon"
aria-hidden="true"
/>
</button> </button>
</div> </div>
</td> </td>
...@@ -123,7 +131,10 @@ ...@@ -123,7 +131,10 @@
class="ui teal icon button" class="ui teal icon button"
@click="saveMembers" @click="saveMembers"
> >
<i class="white save icon" />&nbsp;Enregistrer les changements <i
class="white save icon"
aria-hidden="true"
/>&nbsp;Enregistrer les changements
</button> </button>
</div> </div>
</div> </div>
...@@ -132,9 +143,10 @@ ...@@ -132,9 +143,10 @@
<script> <script>
import axios from '@/axios-client.js'; import axios from '@/axios-client.js';
import { mapState } from 'vuex'; import { mapMutations, mapState } from 'vuex';
import Dropdown from '@/components/Dropdown.vue'; import Dropdown from '@/components/Dropdown.vue';
import { formatUserOption } from '@/utils';
export default { export default {
name: 'ProjectMembers', name: 'ProjectMembers',
...@@ -177,16 +189,7 @@ export default { ...@@ -177,16 +189,7 @@ export default {
userOptions: function () { userOptions: function () {
return this.projectUsers return this.projectUsers
.filter((el) => el.userLevel.value === 'logged_user') .filter((el) => el.userLevel.value === 'logged_user')
.map((el) => { .map((el) => formatUserOption(el.user)); // Format user data to fit dropdown option structure
let name = el.user.first_name || '';
if (el.user.last_name) {
name = name + ' ' + el.user.last_name;
}
return {
name: [name, el.user.username],
value: el.user.id,
};
});
}, },
levelOptions: function () { levelOptions: function () {
...@@ -236,10 +239,16 @@ export default { ...@@ -236,10 +239,16 @@ export default {
destroyed() { destroyed() {
//* allow user to change page if ever stuck on loader //* allow user to change page if ever stuck on loader
this.$store.commit('DISCARD_LOADER'); this.DISCARD_LOADER();
}, },
methods: { methods: {
...mapMutations([
'DISPLAY_MESSAGE',
'DISPLAY_LOADER',
'DISCARD_LOADER'
]),
validateNewMember() { validateNewMember() {
this.newMember.errors = []; this.newMember.errors = [];
if (!this.newMember.user.value) { if (!this.newMember.user.value) {
...@@ -289,60 +298,69 @@ export default { ...@@ -289,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() { saveMembers() {
// Display a loader to indicate that the update process is ongoing
this.DISPLAY_LOADER('Mise à jour des membres du projet en cours ...');
// Prepare the data to be sent in the API request
const data = this.projectUsers.map((member) => { const data = this.projectUsers.map((member) => {
return { return {
user: member.user, user: member.user,
level: { level: {
display: member.userLevel.name, display: member.userLevel.name, // Display name of the user level
codename: member.userLevel.value, codename: member.userLevel.value, // Codename of the user level
}, },
}; };
}); });
// Make an API request to update the project members
axios axios
.put( .put(
`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.project.slug}/utilisateurs/`, `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.project.slug}/utilisateurs/`,
data data
) )
.then((response) => { .then((response) => {
// Check if the response status is 200 (OK)
if (response.status === 200) { if (response.status === 200) {
this.$store.dispatch('GET_USER_LEVEL_PROJECTS'); //* update user status in top right menu // Dispatch an action to update the user status in the top right menu
this.$store.commit('DISPLAY_MESSAGE', { comment: 'Permissions mises à jour', level: 'positive' }); this.$store.dispatch('GET_USER_LEVEL_PROJECTS');
// Display a positive message indicating success
this.DISPLAY_MESSAGE({ comment: 'Permissions mises à jour avec succès', level: 'positive' });
} else { } else {
this.$store.commit( // Display a generic error message if the response status is not 200
'DISPLAY_MESSAGE', this.DISPLAY_MESSAGE({
{ comment: "Une erreur s'est produite pendant la mises à jour des permissions",
comment : "Une erreur s'est produite pendant la mises à jour des permissions", level: 'negative'
level: 'negative' });
}
);
} }
// Hide the loader regardless of the request result
this.DISCARD_LOADER();
}) })
.catch((error) => { .catch((error) => {
throw error; // Hide the loader if an error occurs
}); this.DISCARD_LOADER();
}, // Determine the error message to display
const errorMessage = error.response && error.response.data && error.response.data.error
fetchMembers() { ? error.response.data.error
// todo: move function to a service : "Une erreur s'est produite pendant la mises à jour des permissions";
return axios // Display the error message
.get( this.DISPLAY_MESSAGE({
`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/utilisateurs/` comment: errorMessage,
) level: 'negative'
.then((response) => response.data) });
.catch((error) => { // Log the error to the console for debugging
throw error; console.error(error);
}); });
}, },
populateMembers() { populateMembers() {
this.$store.commit( this.DISPLAY_LOADER('Récupération des membres en cours...');
'DISPLAY_LOADER', this.$store.dispatch('projects/GET_PROJECT_USERS', this.$route.params.slug).then((members) => {
'Récupération des membres en cours...' this.DISCARD_LOADER();
);
this.fetchMembers().then((members) => {
this.$store.commit('DISCARD_LOADER');
this.projectUsers = members.map((el) => { this.projectUsers = members.map((el) => {
return { return {
userLevel: { name: el.level.display, value: el.level.codename }, userLevel: { name: el.level.display, value: el.level.codename },
......
<template> <template>
<div <div id="projects">
id="projects"
class="page"
>
<h2 class="ui horizontal divider header"> <h2 class="ui horizontal divider header">
PROJETS PROJETS
</h2> </h2>
...@@ -12,8 +9,13 @@ ...@@ -12,8 +9,13 @@
v-if="user && user.can_create_project && isOnline" v-if="user && user.can_create_project && isOnline"
:to="{ name: 'project_create', params: { action: 'create' } }" :to="{ name: 'project_create', params: { action: 'create' } }"
class="ui green basic button" 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>
<router-link <router-link
v-if="user && user.can_create_project && isOnline" v-if="user && user.can_create_project && isOnline"
...@@ -21,25 +23,33 @@ ...@@ -21,25 +23,33 @@
name: 'project_type_list', name: 'project_type_list',
}" }"
class="ui blue basic button" 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> </router-link>
</div> </div>
<!-- FILTRES DES PROJETS --> <!-- FILTRES DES PROJETS -->
<ProjectsMenu <ProjectsMenu
:loading="loading"
@filter="setProjectsFilters" @filter="setProjectsFilters"
@getData="getData"
@loading="setLoader" @loading="setLoader"
/> />
<div <div
v-if="configuration.DISPLAY_FORBIDDEN_PROJECTS" v-if="configuration.DISPLAY_FORBIDDEN_PROJECTS"
id="forbidden-projects" id="forbidden-projects"
class="ui toggle checkbox" class="ui toggle checkbox margin-top"
> >
<input <input
v-model="displayForbiddenProjects" :checked="displayForbiddenProjects"
type="checkbox" type="checkbox"
@input="toggleForbiddenProjects"
> >
<label> <label>
N'afficher que les projets disponibles à la consultation N'afficher que les projets disponibles à la consultation
...@@ -50,14 +60,12 @@ ...@@ -50,14 +60,12 @@
<div <div
v-if="projects" v-if="projects"
class="ui divided items dimmable dimmed" class="ui divided items dimmable dimmed"
data-test="project-list"
> >
<div <div :class="['ui inverted dimmer', { active: loading }]">
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui loader" /> <div class="ui loader" />
</div> </div>
<ProjectsListItem <ProjectsListItem
v-for="project in projects" v-for="project in projects"
:key="project.slug" :key="project.slug"
...@@ -66,21 +74,15 @@ ...@@ -66,21 +74,15 @@
<span <span
v-if="!projects || projects.length === 0" v-if="!projects || projects.length === 0"
>Vous n'avez accès à aucun projet.</span>
<div
:class="{ active: loading }"
class="ui inverted dimmer"
> >
<div class="ui loader" /> Vous n'avez accès à aucun projet.
</div> </span>
<!-- PAGINATION --> <!-- PAGINATION -->
<Pagination <Pagination
v-if="count" v-if="count"
:nb-pages="Math.ceil(count/10)" :nb-pages="nbPages"
:on-page-change="SET_CURRENT_PAGE" @page-update="changePage"
@change-page="changePage"
/> />
</div> </div>
</div> </div>
...@@ -123,49 +125,30 @@ export default { ...@@ -123,49 +125,30 @@ export default {
DJANGO_BASE_URL() { DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE; return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
}, },
}, nbPages() {
return Math.ceil(this.count / 10);
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();
} }
}, },
created() { created() {
this.SET_CURRENT_PAGE(1);
// Empty stored text to search
this.SET_PROJECTS_SEARCH_STATE({ text: null });
// Empty stored project list
this.$store.commit('projects/SET_PROJECT', null); this.$store.commit('projects/SET_PROJECT', null);
this.SET_PROJECTS_FILTER({ // Init display of restricted access projects
filter: 'accessible',
value: 'true'
});
this.displayForbiddenProjects = this.configuration.DISPLAY_FORBIDDEN_PROJECTS_DEFAULT; this.displayForbiddenProjects = this.configuration.DISPLAY_FORBIDDEN_PROJECTS_DEFAULT;
this.setForbiddenProjectsFilter(true);
}, },
methods: { methods: {
...mapMutations('projects', [ ...mapMutations('projects', [
'SET_CURRENT_PAGE', 'SET_CURRENT_PAGE',
'SET_PROJECTS_FILTER' 'SET_PROJECTS_FILTER',
'SET_PROJECTS_SEARCH_STATE',
]), ]),
...mapActions('projects', [ ...mapActions('projects', [
'GET_PROJECTS' 'GET_PROJECTS',
]), ]),
getData(page) { getData(page) {
...@@ -187,8 +170,26 @@ export default { ...@@ -187,8 +170,26 @@ export default {
this.getData(e); this.getData(e);
}, },
setProjectsFilters(e) { setProjectsFilters(e, noUpdate) {
this.SET_PROJECTS_FILTER(e); 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);
}, },
} }
}; };
...@@ -198,6 +199,16 @@ export default { ...@@ -198,6 +199,16 @@ export default {
#projects { #projects {
margin: 0 auto; margin: 0 auto;
.dimmable {
.dimmer {
.loader {
top: 25%;
}
}
}
} }
.flex { .flex {
...@@ -217,10 +228,10 @@ export default { ...@@ -217,10 +228,10 @@ export default {
color: rgb(94, 94, 94); color: rgb(94, 94, 94);
} }
input:checked ~ label::before { input:checked ~ label::before {
background-color: teal !important; background-color: var(--primary-color, #008c86) !important;
} }
input:checked ~ label { input:checked ~ label {
color: teal !important; color: var(--primary-color, #008c86) !important;
} }
} }
......
<template> <template>
<div <div id="projects-types">
id="projects-types"
class="page"
>
<h3 class="ui header"> <h3 class="ui header">
Créer un projet à partir d'un modèle disponible: Créer un projet à partir d'un modèle disponible:
</h3> </h3>
...@@ -19,6 +16,7 @@ ...@@ -19,6 +16,7 @@
? require('@/assets/img/default.png') ? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId() : DJANGO_BASE_URL + project.thumbnail + refreshId()
" "
alt="Image associé au projet"
> >
</div> </div>
<div class="middle aligned content"> <div class="middle aligned content">
...@@ -37,27 +35,25 @@ ...@@ -37,27 +35,25 @@
<strong>Projet {{ project.moderation ? '' : 'non' }} modéré</strong> <strong>Projet {{ project.moderation ? '' : 'non' }} modéré</strong>
</div> </div>
<div class="meta"> <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"> <span data-tooltip="Date de création">
{{ project.created_on }}&nbsp;<i class="calendar icon" /> {{ project.created_on }}&nbsp;
<i
class="calendar icon"
aria-hidden="true"
/>
</span> </span>
</div> </div>
<div class="meta"> <div class="meta">
<span data-tooltip="Visibilité des signalement publiés"> <span data-tooltip="Visibilité des signalement publiés">
{{ project.access_level_pub_feature }}&nbsp;<i {{ project.access_level_pub_feature }}&nbsp;<i
class="eye icon" class="eye icon"
aria-hidden="true"
/> />
</span> </span>
<span data-tooltip="Visibilité des signalement archivés"> <span data-tooltip="Visibilité des signalement archivés">
{{ project.access_level_arch_feature }}&nbsp;<i {{ project.access_level_arch_feature }}&nbsp;<i
class="archive icon" class="archive icon"
aria-hidden="true"
/> />
</span> </span>
</div> </div>
......
...@@ -2,6 +2,7 @@ const webpack = require('webpack'); ...@@ -2,6 +2,7 @@ const webpack = require('webpack');
const fs = require('fs'); const fs = require('fs');
const packageJson = fs.readFileSync('./package.json'); const packageJson = fs.readFileSync('./package.json');
const version = JSON.parse(packageJson).version || 0; const version = JSON.parse(packageJson).version || 0;
module.exports = { module.exports = {
publicPath: '/geocontrib/', publicPath: '/geocontrib/',
devServer: { devServer: {
...@@ -34,6 +35,7 @@ module.exports = { ...@@ -34,6 +35,7 @@ module.exports = {
themeColor: '#1da025' themeColor: '#1da025'
}, },
configureWebpack: { configureWebpack: {
devtool: 'source-map',
plugins: [ plugins: [
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env': { 'process.env': {
...@@ -42,5 +44,14 @@ module.exports = { ...@@ -42,5 +44,14 @@ 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',
'@sentry/browser',
'@sentry/core',
'@sentry/vue',
'@sentry-internal'
]
}; };
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.vue$/,
loader: 'vue-loader'
}
]
}
};
\ No newline at end of file