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 4392 additions and 963 deletions
<template>
<div>
<h3 class="ui header">
Types de signalements
</h3>
<div
id="feature_type-list"
class="ui middle aligned divided list"
>
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des types de signalements en cours...
</div>
</div>
<div
:class="{ active: importing }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Traitement du fichier en cours ...
</div>
</div>
<div
v-for="(type, index) in feature_types"
:id="type.title"
:key="type.title + '-' + index"
class="item"
>
<div class="feature-type-container">
<FeatureTypeLink :feature-type="type" />
<div class="middle aligned content">
<div
v-if="isImporting(type)"
class="import-message"
>
<i
class="info circle icon"
aria-hidden="true"
/>
Import en cours
</div>
<template v-else>
<router-link
v-if="project && type.is_editable && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'editer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-orange tiny-margin"
data-tooltip="Éditer le type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`edit-feature-type-for-${type.title}`"
>
<i
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<router-link
v-if="project && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'editer-affichage-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-orange tiny-margin"
data-tooltip="Éditer l'affichage du type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`edit-feature-type-display-for-${type.title}`"
>
<i
class="inverted grey paint brush alternate icon"
aria-hidden="true"
/>
</router-link>
<a
v-if="isProjectAdmin && isOnline"
class="ui compact small icon button button-hover-red tiny-margin"
data-tooltip="Supprimer le type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`delete-feature-type-for-${type.title}`"
@click="toggleDeleteFeatureType(type)"
>
<i
class="inverted grey trash alternate icon"
aria-hidden="true"
/>
</a>
</template>
<router-link
v-if="project && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'dupliquer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-green tiny-margin"
data-tooltip="Dupliquer un type de signalement"
data-position="top right"
data-variation="mini"
:data-test="`duplicate-feature-type-for-${type.title}`"
>
<i
class="inverted grey copy alternate icon"
aria-hidden="true"
/>
</router-link>
<router-link
v-if="project && permissions && permissions.can_create_feature && !type.geom_type.includes('multi')"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-green tiny-margin"
data-tooltip="Ajouter un signalement"
data-position="top right"
data-variation="mini"
:data-test="`add-feature-for-${type.title}`"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
</router-link>
</div>
</div>
</div>
<div v-if="feature_types.length === 0">
<em> Le projet ne contient pas encore de type de signalements. </em>
</div>
</div>
<div id="new-feature-type-container">
<div
class="ui small button circular compact floated right icon teal help"
data-tooltip="Consulter la documentation"
data-position="bottom right"
data-variation="mini"
data-test="read-doc"
>
<i
class="question icon"
@click="goToDocumentation"
/>
</div>
<router-link
v-if="permissions && permissions.can_update_project && isOnline"
:to="{
name: 'ajouter-type-signalement',
params: { slug },
}"
class="ui compact basic button"
data-test="add-feature-type"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label class="ui pointer">
Créer un nouveau type de signalement
</label>
</router-link>
<div
v-if="permissions && permissions.can_update_project && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-geojson"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui pointer"
for="geojson_file"
>
Créer un nouveau type de signalement à partir d'un GeoJSON
</label>
<input
id="geojson_file"
type="file"
accept=".geojson"
style="display: none"
name="geojson_file"
@change="onGeoJSONFileChange"
>
</div>
<div
v-if="permissions && permissions.can_update_project && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-json"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui pointer"
for="json_file"
>
Créer un nouveau type de signalement à partir d'un JSON (non-géographique)
</label>
<input
id="json_file"
type="file"
accept="application/json, .json"
style="display: none"
name="json_file"
@change="onGeoJSONFileChange"
>
</div>
<div
v-if="permissions && permissions.can_update_project && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-csv"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui pointer"
for="csv_file"
>
Créer un nouveau type de signalement à partir d'un CSV
</label>
<input
id="csv_file"
type="file"
accept="application/csv, .csv"
style="display: none"
name="csv_file"
@change="onCSVFileChange"
>
</div>
<router-link
v-if="IDGO && permissions && permissions.can_update_project && isOnline"
:to="{
name: 'catalog-import',
params: {
slug,
feature_type_slug: 'create'
},
}"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-catalog"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
Créer un nouveau type de signalement à partir du catalogue {{ CATALOG_NAME || 'IDGO' }}
</router-link>
</div>
<div
v-if="geojsonFileToImport.size > 0"
id="button-import"
>
<button
:disabled="geojsonFileToImport.size === 0"
class="ui fluid teal icon button"
data-test="start-geojson-file-import"
@click="toNewGeojsonFeatureType"
>
<i
class="upload icon"
aria-hidden="true"
/> Lancer l'import avec le fichier
{{ geojsonFileToImport.name }}
</button>
</div>
<div
v-if="csvFileToImport.size > 0 && !csvError"
id="button-import"
>
<button
:disabled="csvFileToImport.size === 0"
class="ui fluid teal icon button"
data-test="start-csv-file-import"
@click="toNewCsvFeatureType"
>
<i
class="upload icon"
aria-hidden="true"
/> Lancer l'import avec le fichier
{{ csvFileToImport.name }}
</button>
</div>
<div
v-if="csvError"
class="ui negative message"
>
<i
class="close icon"
aria-hidden="true"
@click="csvError = null; csvFileToImport = { name: '', size: 0 }"
/>
{{ csvError }}
</div>
<!-- MODALE FILESIZE -->
<div
:class="isFileSizeModalOpen ? 'active' : ''"
class="ui dimmer inverted"
>
<div
:class="isFileSizeModalOpen ? 'active' : ''"
class="ui modal tiny"
style="top: 20%"
>
<div class="header">
Fichier trop grand!
</div>
<div class="content">
<p>
Impossible de créer un type de signalement à partir d'un fichier
de plus de 100Mo (celui importé fait {{ geojsonFileSize > 0 ? geojsonFileSize : csvFileSize }} Mo).
</p>
</div>
<div class="actions">
<div
class="ui button teal"
@click="closeFileSizeModal"
>
Fermer
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { csv } from 'csvtojson';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import { fileConvertSizeToMo, determineDelimiter } from '@/assets/js/utils';
import FeatureTypeLink from '@/components/FeatureType/FeatureTypeLink';
export default {
name: 'ProjectFeatureTypes',
components: {
FeatureTypeLink
},
props: {
loading: {
type: Boolean,
default: false
},
project: {
type: Object,
default: () => {
return {};
},
}
},
data() {
return {
importing: false,
slug: this.$route.params.slug,
isFileSizeModalOpen: false,
geojsonImport: [],
csvImport: null,
csvError: null,
geojsonFileToImport: { name: '', size: 0 },
csvFileToImport: { name: '', size: 0 },
fetchCallCounter: 0,
hadPending: false
};
},
computed: {
...mapState([
'configuration',
'isOnline',
'user_permissions',
]),
...mapState('feature-type', [
'feature_types',
'importFeatureTypeData'
]),
...mapGetters([
'permissions'
]),
CATALOG_NAME() {
return this.configuration.VUE_APP_CATALOG_NAME;
},
IDGO() {
return this.$store.state.configuration.VUE_APP_IDGO;
},
isProjectAdmin() {
return this.user_permissions && this.user_permissions[this.slug] &&
this.user_permissions[this.slug].is_project_administrator;
},
geojsonFileSize() {
return fileConvertSizeToMo(this.geojsonFileToImport.size);
},
csvFileSize() {
return fileConvertSizeToMo(this.csvFileToImport.size);
},
},
mounted() {
this.fetchImports();
},
methods: {
...mapMutations('feature-type', [
'SET_FILE_TO_IMPORT'
]),
...mapActions('feature-type', [
'GET_IMPORTS',
]),
fetchImports() {
this.fetchCallCounter += 1; // register each time function is programmed to be called in order to avoid redundant calls
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
})
.then((response) => {
if (response.data && response.data.some(el => el.status === 'pending')) {
this.hadPending = true; // store pending import to know if project need to be updated, after mounted
// if there is still some pending imports re-fetch imports by calling this function again
setTimeout(() => {
if (this.fetchCallCounter <= 1 ) {
// if the function wasn't called more than once in the reload interval, then call it again
this.fetchImports();
}
this.fetchCallCounter -= 1; // decrease function counter
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL);
} else if (this.hadPending) {
// if no more pending import, get last features
this.$emit('update');
}
});
},
isImporting(type) {
if (this.importFeatureTypeData) {
const singleImportData = this.importFeatureTypeData.find(
(el) => el.feature_type_title === type.slug
);
return singleImportData && singleImportData.status === 'pending';
}
return false;
},
goToDocumentation() {
window.open(this.configuration.VUE_APP_URL_DOCUMENTATION);
},
toNewGeojsonFeatureType() {
this.importing = true;
if(typeof this.geojsonImport == 'object'){
if(!Array.isArray(this.geojsonImport)){
this.$router.push({
name: 'ajouter-type-signalement',
params: {
geojson: this.geojsonImport,
fileToImport: this.geojsonFileToImport,
},
});
}else{
this.$router.push({
name: 'ajouter-type-signalement',
params: {
json: this.geojsonImport,
fileToImport: this.geojsonFileToImport,
},
});
}
}
this.importing = false;
},
toNewCsvFeatureType() {
this.importing = true;
this.$router.push({
name: 'ajouter-type-signalement',
params: {
csv: this.csvImport,
fileToImport: this.csvFileToImport,
},
});
this.importing = false;
},
onGeoJSONFileChange(e) {
this.importing = true;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.geojsonFileToImport = files[0];
// TODO : VALIDATION IF FILE IS JSON
if (parseFloat(fileConvertSizeToMo(this.geojsonFileToImport.size)) > 100) {
this.isFileSizeModalOpen = true;
} else if (this.geojsonFileToImport.size > 0) {
const fr = new FileReader();
try {
fr.onload = (ev) => {
this.geojsonImport = JSON.parse(ev.target.result);
this.importing = false;
};
fr.readAsText(this.geojsonFileToImport);
//* stock filename to import features afterward
this.SET_FILE_TO_IMPORT(this.geojsonFileToImport);
} catch (err) {
console.error(err);
this.importing = false;
}
} else {
this.importing = false;
}
},
onCSVFileChange(e) {
this.featureTypeImporting = true;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.csvFileToImport = files[0];
if (parseFloat(fileConvertSizeToMo(this.csvFileToImport.size)) > 100) {
this.isFileSizeModalOpen = true;
} else if (this.csvFileToImport.size > 0) {
const fr = new FileReader();
try {
fr.readAsText(this.csvFileToImport);
fr.onloadend = () => {
// Find csv delimiter
const delimiter = determineDelimiter(fr.result);
if (!delimiter) {
this.csvError = `Le fichier ${this.csvFileToImport.name} n'est pas formaté correctement`;
this.featureTypeImporting = false;
return;
}
this.csvError = null;
csv({ delimiter })
.fromString(fr.result)
.then((jsonObj)=>{
this.csvImport = jsonObj;
});
this.featureTypeImporting = false;
//* stock filename to import features afterward
this.SET_FILE_TO_IMPORT(this.csvFileToImport);
};
} catch (err) {
console.error(err);
this.featureTypeImporting = false;
}
} else {
this.featureTypeImporting = false;
}
},
closeFileSizeModal() {
this.geojsonFileToImport = { name: '', size: 0 };
this.csvFileToImport = { name: '', size: 0 };
this.importing = false;
this.isFileSizeModalOpen = false;
},
toggleDeleteFeatureType(featureType) {
this.$emit('delete', featureType);
},
}
};
</script>
<style>
/* // ! missing style in semantic.min.css */
.ui.right.floated.button {
float: right;
}
</style>
<style lang="less" scoped>
.feature-type-container {
display: flex;
justify-content: space-between;
align-items: center;
.feature-type-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.5em;
}
& > .middle.aligned.content {
display: flex;
}
}
#new-feature-type-container {
& > div {
margin: 1em 0;
}
& > div.help {
margin-top: 0;
}
.button:not(.help) {
line-height: 1.25em;
.icon {
height: auto;
}
}
}
#button-import {
margin-top: 0.5em;
}
.button-align-left {
display: flex;
align-items: center;
text-align: left;
width: fit-content;
}
.import-message {
color: var(--primary-highlight-color, #008c86);
white-space: nowrap;
padding: .25em;
display: flex;
align-items: center;
& > i {
height: auto;
}
}
</style>
<template>
<div class="project-header ui grid stackable">
<div class="row">
<div class="three wide middle aligned column">
<div class="margin-bottom">
<img
class="ui small centered image"
alt="Thumbnail du projet"
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
>
</div>
<div class="centered">
<div
class="ui basic teal label tiny-margin"
data-tooltip="Membres"
>
<i
class="user icon"
aria-hidden="true"
/>{{ project.nb_contributors }}
</div>
<div
class="ui basic teal label tiny-margin"
data-tooltip="Signalements publiés"
>
<i
class="map marker icon"
aria-hidden="true"
/>{{ project.nb_published_features }}
</div>
<div
class="ui basic teal label tiny-margin"
data-tooltip="Commentaires"
>
<i
class="comment icon"
aria-hidden="true"
/>{{
project.nb_published_features_comments
}}
</div>
</div>
</div>
<div class="nine wide column">
<h1 class="ui header margin-bottom">
{{ project.title }}
</h1>
<div class="sub header">
<!-- {{ project.description }} -->
<div id="preview" />
<textarea
id="editor"
v-model="project.description"
data-preview="#preview"
hidden
/>
</div>
</div>
<div class="four wide column right-column">
<div class="ui icon right compact buttons">
<a
v-if="
user &&
permissions &&
permissions.can_view_project &&
isOnline
"
id="subscribe-button"
class="ui button button-hover-green tiny-margin"
data-tooltip="Gérer mon abonnement au projet"
data-position="bottom center"
data-variation="mini"
@click="OPEN_PROJECT_MODAL('subscribe')"
>
<i
class="inverted grey envelope icon"
aria-hidden="true"
/>
</a>
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOnline
"
id="edit-project"
:to="{ name: 'project_edit', params: { slug } }"
class="ui button button-hover-orange tiny-margin"
data-tooltip="Modifier le projet"
data-position="bottom center"
data-variation="mini"
>
<i
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<a
v-if="isProjectAdmin && isOnline"
id="delete-button"
class="ui button button-hover-red tiny-margin"
data-tooltip="Supprimer le projet"
data-position="bottom right"
data-variation="mini"
@click="OPEN_PROJECT_MODAL('deleteProject')"
>
<i
class="inverted grey trash icon"
aria-hidden="true"
/>
</a>
<div
v-if="isProjectAdmin && !isSharedProject"
id="share-button"
class="ui dropdown button compact tiny-margin"
data-tooltip="Partager le projet"
data-position="bottom right"
data-variation="mini"
@click="toggleShareOptions"
>
<i
class="inverted grey share icon"
aria-hidden="true"
/>
<div
:class="['menu left transition', {'visible': showShareOptions}]"
style="z-index: 9999"
>
<div
v-if="project.generate_share_link"
class="item"
@click="copyLink"
>
Copier le lien de partage
</div>
<div
class="item"
@click="copyCode"
>
Copier le code du Web Component
</div>
</div>
</div>
</div>
<Transition>
<div
v-if="confirmMsg"
class="ui positive tiny-margin message"
>
<span>
Le lien a été copié dans le presse-papier
</span>
&nbsp;
<i
class="close icon"
aria-hidden="true"
@click="confirmMsg = false"
/>
</div>
</Transition>
</div>
<div
v-if="arraysOffline.length > 0"
class="centered"
>
{{ arraysOffline.length }} modification<span v-if="arraysOffline.length > 1">s</span> en attente
<button
:disabled="!isOnline"
class="ui fluid labeled teal icon button"
@click="sendOfflineFeatures"
>
<i
class="upload icon"
aria-hidden="true"
/>
Envoyer au serveur
</button>
</div>
</div>
</div>
</template>
<script>
import TextareaMarkdown from 'textarea-markdown';
import { mapState, mapGetters, mapMutations } from 'vuex';
import featureAPI from '@/services/feature-api';
export default {
name: 'ProjectHeader',
props: {
arraysOffline: {
type: Array,
default: () => {
return [];
}
}
},
data() {
return {
slug: this.$route.params.slug,
confirmMsg: false,
showShareOptions: false,
};
},
computed: {
...mapState('projects', [
'project'
]),
...mapState([
'configuration',
]),
...mapState([
'user',
'user_permissions',
'isOnline',
]),
...mapGetters([
'permissions'
]),
DJANGO_BASE_URL() {
return this.configuration.VUE_APP_DJANGO_BASE;
},
isProjectAdmin() {
return this.user_permissions && this.user_permissions[this.slug] &&
this.user_permissions[this.slug].is_project_administrator;
},
isSharedProject() {
return this.$route.path.includes('projet-partage');
},
},
mounted() {
let textarea = document.querySelector('textarea');
new TextareaMarkdown(textarea);
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
},
methods: {
...mapMutations('modals', [
'OPEN_PROJECT_MODAL'
]),
refreshId() {
const crypto = window.crypto || window.msCrypto;
var array = new Uint32Array(1);
return '?ver=' + crypto.getRandomValues(array); // Compliant for security-sensitive use cases
},
toggleShareOptions() {
this.confirmMsg = false;
this.showShareOptions = !this.showShareOptions;
},
clickOutsideDropdown(e) {
// If the user click outside of the dropdown, close it
if (!e.target.closest('#share-button')) {
this.showShareOptions = false;
}
},
copyLink() {
const sharedLink = window.location.href.replace('projet', 'projet-partage');
navigator.clipboard.writeText(sharedLink).then(()=> {
this.confirmMsg = true;
setTimeout(() => {
this.confirmMsg = false;
}, 15000);
}, (e) => console.error('Failed to copy link: ', e));
},
copyCode() {
// Including <script> directly within template literals cause the JavaScript parser to raise syntax errors.
// The only working workaround, but ugly, is to split and concatenate the <script> tag.
const webComponent = `
<!-- Pour modifier la police, ajoutez l'attribut "font" avec le nom de la police souhaitée (par exemple: font="'Roboto Condensed', Lato, 'Helvetica Neue'"). -->
<!-- Dans le cas où la police souhaitée ne serait pas déjà disponible dans la page affichant le web component, incluez également une balise <style> pour l'importer. -->
<style>@import url('https://fonts.googleapis.com/css?family=Roboto Condensed:400,700,400italic,700italic&subset=latin');</style>
<scr` + `ipt src="${this.configuration.VUE_APP_DJANGO_BASE}/geocontrib/static/wc/project-preview.js"></scr` + `ipt>
<project-preview
domain="${this.configuration.VUE_APP_DJANGO_BASE}"
project-slug="${this.project.slug}"
color="${this.configuration.VUE_APP_PRIMARY_COLOR}"
font="${this.configuration.VUE_APP_FONT_FAMILY}"
width=""
></project-preview>`;
navigator.clipboard.writeText(webComponent).then(()=> {
this.confirmMsg = true;
setTimeout(() => {
this.confirmMsg = false;
}, 15000);
}, (e) => console.error('Failed to copy link: ', e));
},
sendOfflineFeatures() {
this.arraysOfflineErrors = [];
const promises = this.arraysOffline.map((feature) => featureAPI.postOrPutFeature({
data: feature.geojson,
feature_id: feature.featureId,
project__slug: feature.project,
feature_type__slug: feature.geojson.properties.feature_type,
method: feature.type.toUpperCase(),
})
.then((response) => {
if (!response) {
this.arraysOfflineErrors.push(feature);
}
})
.catch((error) => {
console.error(error);
this.arraysOfflineErrors.push(feature);
})
);
this.$store.commit('DISPLAY_LOADER', 'Envoi des signalements en cours.');
Promise.all(promises).then(() => {
this.$emit('updateLocalStorage');
this.$emit('retrieveInfo');
this.$store.commit('DISCARD_LOADER');
});
},
}
};
</script>
<style lang="less" scoped>
.project-header {
.row .right-column {
display: flex;
flex-direction: column;
.ui.buttons {
justify-content: flex-end;
.ui.button {
flex-grow: 0; /* avoid stretching buttons */
}
}
}
.centered {
margin: auto;
text-align: center;
}
.ui.dropdown > .left.menu {
display: block;
overflow: hidden;
opacity: 0;
max-height: 0;
&.transition {
transition: all .5s ease;
}
&.visible {
opacity: 1;
max-height: 6em;
}
.menu {
margin-right: 0 !important;
.item {
white-space: nowrap;
}
}
}
.v-enter-active,
.v-leave-active {
transition: opacity .5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
}
#preview {
max-height: 10em;
overflow-y: scroll;
}
@media screen and (max-width: 767px) {
.middle.aligned.column {
text-align: center;
}
}
</style>
<template>
<div class="orange card">
<div class="content">
<div class="center aligned header">
Derniers commentaires
</div>
<div class="center aligned description">
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des commentaires en cours...
</div>
</div>
<div class="ui relaxed list">
<div
v-for="(item, index) in last_comments"
:key="'comment ' + index"
class="item"
>
<div class="content">
<FeatureFetchOffsetRoute
:feature-id="item.related_feature.feature_id"
:properties="{
title: item.comment,
feature_type: { slug: item.related_feature.feature_type_slug }
}"
/>
<div class="description">
<em>[ {{ item.created_on
}}<span
v-if="user && item.display_author"
>, par {{ item.display_author }}
</span>
]</em>
</div>
</div>
</div>
<em
v-if="!last_comments || last_comments.length === 0"
>Aucun commentaire pour le moment.</em>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectsLastComments',
components: {
FeatureFetchOffsetRoute,
},
props: {
loading: {
type: Boolean,
default: false
}
},
computed: {
...mapState([
'user'
]),
...mapState('projects', [
'last_comments',
]),
},
};
</script>
<template>
<div class="red card">
<div class="content">
<div class="center aligned header">
Derniers signalements
</div>
<div class="center aligned description">
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des signalements en cours...
</div>
</div>
<div class="ui relaxed list">
<div
v-for="(item, index) in features.slice(0,5)"
:key="item.properties.title + index"
class="item"
>
<div class="content">
<div>
<FeatureFetchOffsetRoute
:feature-id="item.id"
:properties="item.properties"
/>
</div>
<div class="description">
<em>
[{{ item.properties.created_on }}
<span v-if="user && item.properties.creator">
, par
{{
item.properties.creator.full_name
? item.properties.creator.full_name
: item.properties.creator.username
}}
</span>
]
</em>
</div>
</div>
</div>
<em
v-if="features.length === 0 && !loading"
>Aucun signalement pour le moment.</em>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectLastFeatures',
components: {
FeatureFetchOffsetRoute,
},
data() {
return {
loading: true,
};
},
computed: {
...mapState('feature', [
'features'
]),
...mapState([
'user'
]),
},
mounted() {
this.fetchLastFeatures();
},
methods: {
fetchLastFeatures() {
this.loading = true;
this.$store.dispatch('feature/GET_PROJECT_FEATURES', {
project_slug: this.$route.params.slug,
ordering: '-created_on',
limit: 5,
})
.then(() => {
this.loading = false;
})
.catch((err) => {
console.error(err);
this.loading = false;
});
}
}
};
</script>
<template>
<div
v-if="isProjectModalOpen"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
:class="[
'ui mini modal',
{ 'transition visible active': projectModalType },
]"
>
<i
class="close icon"
aria-hidden="true"
@click="CLOSE_PROJECT_MODAL"
/>
<div class="ui icon header">
<i
:class="[projectModalType === 'subscribe' ? 'envelope' : 'trash', 'icon']"
aria-hidden="true"
/>
{{
projectModalType === 'subscribe' ? 'Notifications' : 'Suppression'
}} du {{
projectModalType === 'deleteFeatureType' ? 'type de signalement ' + featureTypeToDelete.title : 'projet'
}}
</div>
<div class="content">
<div v-if="projectModalType !== 'subscribe'">
<p class="centered-text">
Confirmez vous la suppression du {{
projectModalType === 'deleteProject' ?
'projet, ainsi que les types de signalements' :
'type de signalement'
}} et tous les signalements associés&nbsp;?
</p>
<p class="centered-text alert">
Attention cette action est irreversible !
</p>
</div>
<button
id="validate-modal"
:class="['ui compact fluid button', projectModalType === 'subscribe' && !isSubscriber ? 'green' : 'red']"
@click="handleModalAction"
>
<span v-if="projectModalType === 'subscribe'">
{{
isSubscriber
? "Se désabonner de ce projet"
: "S'abonner à ce projet"
}}
</span>
<span v-else>
Supprimer le
{{
projectModalType === 'deleteProject'
? 'projet'
: 'type de signalement'
}}
</span>
</button>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
name: 'ProjectModal',
props: {
isSubscriber: {
type: Boolean,
default: false
},
featureTypeToDelete: {
type: Object,
default: () => {
return {};
}
}
},
computed: {
...mapState('modals', [
'isProjectModalOpen',
'projectModalType'
])
},
methods: {
...mapMutations('modals', [
'CLOSE_PROJECT_MODAL'
]),
handleModalAction() {
this.$emit('action', this.projectModalType);
}
}
};
</script>
<style scoped>
.alert {
color: red;
}
.centered-text {
text-align: center;
}
</style>
<template>
<div class="ui grey segment">
<h3 class="ui header">
Paramètres du projet
</h3>
<div class="ui three stackable cards">
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i
class="disabled grey eye icon"
aria-hidden="true"
/>
<div class="content">
Visibilité des signalements publiés
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.access_level_pub_feature }}
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i
class="disabled grey eye icon"
aria-hidden="true"
/>
<div class="content">
Visibilité des signalements archivés
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.access_level_arch_feature }}
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i
class="disabled grey cogs icon"
aria-hidden="true"
/>
<div class="content">
Modération
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.moderation ? "Oui" : "Non" }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ProjectParameters',
props: {
project: {
type: Object,
default: () => {
return {};
}
}
}
};
</script>
<template>
<div class="field">
<label for="attribute-value">
{{ attribute.label }}
</label>
<div>
<ExtraForm
:id="`attribute-value-for-${attribute.name}`"
ref="extraForm"
name="attribute-value"
:field="{ ...attribute, value }"
:use-value-only="true"
@update:value="updateValue($event.toString(), attribute.id)"
/>
</div>
</div>
</template>
<script>
import ExtraForm from '@/components/ExtraForm';
export default {
name: 'ProjectAttributeForm',
components: {
ExtraForm,
},
props: {
attribute: {
type: Object,
default: () => {
return {};
}
},
formProjectAttributes: {
type: Array,
default: () => {
return [];
}
}
},
computed: {
/**
* Retrieves the current value of a specific project attribute.
* This computed property checks the array of project attributes to find the one that matches
* the current attribute's ID. If the attribute is found, its value is returned.
* Otherwise, null is returned to indicate that the attribute is not set for the current project.
*
* @returns {String|null} The value of the attribute if it exists in the project's attributes; otherwise, null.
*/
value() {
// Searches for the attribute within the array of attributes associated with the project.
const projectAttribute = this.formProjectAttributes.find(el => el.attribute_id === this.attribute.id);
// Returns the value of the attribute if it exists, or null if the attribute is not found.
return projectAttribute ? projectAttribute.value : null;
},
},
created() {
// Checks if the component is being used in the context of creating a new project and attribute's default value is set
if (this.$route.name === 'project_create' && this.attribute.default_value !== null) {
// If so, initializes the attribute's value with its default value as defined in the attribute's settings.
this.updateValue(this.attribute.default_value, this.attribute.id);
}
},
methods: {
/**
* Updates or adds a value for a specific attribute in the project.
* This method emits an event to update the project's attributes with a new value for a given attribute ID.
* It is typically called when the user changes the value of an attribute in the UI.
*
* @param {String} value - The new value for the attribute.
* @param {Number} attributeId - The unique ID of the attribute being updated or added to the project.
*/
updateValue(value, attributeId) {
// Emits an event to the parent component, requesting an update to the project's attributes.
this.$emit('update:project_attributes', { value, attributeId });
}
}
};
</script>
<template>
<div>
<div class="ui form">
<div
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
class="inline fields"
>
<label
data-tooltip="Choisir un type de sélection de signalements pour effectuer une action"
data-position="bottom left"
>Mode de sélection :</label>
<div class="field">
<div class="ui radio checkbox">
<input
id="edit-status"
v-model="mode"
type="radio"
name="mode"
value="edit-status"
>
<label for="edit-status">Édition de statut</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input
id="edit-attributes"
v-model="mode"
type="radio"
name="mode"
value="edit-attributes"
>
<label for="edit-attributes">Édition d'attribut</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input
id="delete-features"
v-model="mode"
type="radio"
name="mode"
value="delete-features"
>
<label for="delete-features">Suppression de signalement</label>
</div>
</div>
</div>
</div>
<div
data-tab="list"
class="dataTables_wrapper no-footer"
>
<table
id="table-features"
class="ui compact table unstackable dataTable"
aria-describedby="Liste des signalements du projet"
>
<thead>
<tr>
<th
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
scope="col"
class="dt-center"
>
<div
v-if="massMode === 'edit-status' || massMode === 'delete-features'"
class="ui checkbox"
>
<input
id="select-all"
v-model="isAllSelected"
type="checkbox"
name="select-all"
>
<label for="select-all">
<span v-if="!isAllSelected">
Tout sélectionner
</span>
<span v-else>
Tout désélectionner
</span>
</label>
</div>
<span v-else>Sélection</span>
</th>
<th
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('status')"
>
Statut
<i
:class="{
down: isSortedAsc('status'),
up: isSortedDesc('status'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('feature_type')"
>
Type
<i
:class="{
down: isSortedAsc('feature_type'),
up: isSortedDesc('feature_type'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('title')"
>
Nom
<i
:class="{
down: isSortedAsc('title'),
up: isSortedDesc('title'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('created_on')"
>
Date de création
<i
:class="{
down: isSortedAsc('created_on'),
up: isSortedDesc('created_on'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('updated_on')"
>
Dernière modification
<i
:class="{
down: isSortedAsc('updated_on'),
up: isSortedDesc('updated_on'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
v-if="user"
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('display_creator')"
>
Auteur
<i
:class="{
down: isSortedAsc('display_creator'),
up: isSortedDesc('display_creator'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
v-if="user"
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('display_last_editor')"
>
Dernier éditeur
<i
:class="{
down: isSortedAsc('display_last_editor'),
up: isSortedDesc('display_last_editor'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(feature, index) in paginatedFeatures"
:key="index"
>
<td
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
id="select"
class="dt-center"
>
<div
:class="['ui checkbox', { disabled: isAllSelected || !checkRights(feature) }]"
>
<input
:id="feature.feature_id"
type="checkbox"
:value="feature.feature_id"
:checked="isFeatureSelected(feature)"
:disabled="isAllSelected || !checkRights(feature)"
name="select"
@input="handleFeatureSelection($event, feature)"
>
<label :for="feature.feature_id" />
</div>
</td>
<td
id="status"
class="dt-center"
>
<div
v-if="feature.status === 'archived'"
data-tooltip="Archivé"
>
<i
class="grey archive icon"
aria-hidden="true"
/>
</div>
<div
v-else-if="feature.status === 'pending'"
data-tooltip="En attente de publication"
>
<i
class="teal hourglass outline icon"
aria-hidden="true"
/>
</div>
<div
v-else-if="feature.status === 'published'"
data-tooltip="Publié"
>
<i
class="olive check icon"
aria-hidden="true"
/>
</div>
<div
v-else-if="feature.status === 'draft'"
data-tooltip="Brouillon"
>
<i
class="orange pencil alternate icon"
aria-hidden="true"
/>
</div>
</td>
<td
id="type"
class="dt-center"
>
<router-link
:to="{
name: 'details-type-signalement',
params: {
feature_type_slug: feature.feature_type.slug,
},
}"
class="ellipsis space-left"
>
{{ feature.feature_type.title }}
</router-link>
</td>
<td
id="name"
class="dt-center"
>
<router-link
:to="{
name: 'details-signalement-filtre',
query: { ...queryparams, offset: queryparams.offset + index }
}"
class="ellipsis space-left"
>
{{ feature.title || feature.feature_id }}
</router-link>
</td>
<td
id="create"
class="dt-center"
>
{{ feature.created_on | formatDate }}
</td>
<td
id="update"
class="dt-center"
>
{{ feature.updated_on | formatDate }}
</td>
<td
v-if="user"
id="author"
class="dt-center"
>
{{ feature.display_creator || ' ---- ' }}
</td>
<td
v-if="user"
id="last_editor"
class="dt-center"
>
{{ feature.display_last_editor || ' ---- ' }}
</td>
</tr>
<tr
v-if="featuresCount === 0"
class="odd"
>
<td
colspan="5"
class="dataTables_empty"
valign="top"
>
Aucune donnée disponible
</td>
</tr>
</tbody>
</table>
<div
v-if="pageNumbers.length > 1"
id="table-features_info"
class="dataTables_info"
role="status"
aria-live="polite"
>
Affichage de l'élément {{ pagination.start + 1 }} à
{{ displayedPageEnd }}
sur {{ featuresCount }} éléments
</div>
<div
v-if="pageNumbers.length > 1 && isOnline"
id="table-features_paginate"
class="dataTables_paginate paging_simple_numbers"
>
<a
id="table-features_previous"
:class="[
'paginate_button previous',
{ disabled: pagination.currentPage === 1 },
]"
aria-controls="table-features"
data-dt-idx="0"
tabindex="0"
@click="$emit('update:page', 'previous')"
>Précédent</a>
<span>
<span v-if="pagination.currentPage > 5">
<a
key="page1"
class="paginate_button"
aria-controls="table-features"
data-dt-idx="1"
tabindex="0"
@click="$emit('update:page', 1)"
>1</a>
<span class="ellipsis"></span>
</span>
<a
v-for="pageNumber in displayedPageNumbers"
:key="'page' + pageNumber"
:class="[
'paginate_button',
{ current: pageNumber === pagination.currentPage },
]"
aria-controls="table-features"
data-dt-idx="1"
tabindex="0"
@click="$emit('update:page', pageNumber)"
>{{ pageNumber }}</a>
<span v-if="(lastPageNumber - pagination.currentPage) > 4">
<span class="ellipsis"></span>
<a
:key="'page' + lastPageNumber"
class="paginate_button"
aria-controls="table-features"
data-dt-idx="1"
tabindex="0"
@click="$emit('update:page', lastPageNumber)"
>{{ lastPageNumber }}</a>
</span>
</span>
<a
id="table-features_next"
:class="[
'paginate_button next',
{ disabled: pagination.currentPage === pageNumbers.length },
]"
aria-controls="table-features"
data-dt-idx="7"
tabindex="0"
@click="$emit('update:page', 'next')"
>Suivant</a>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapMutations } from 'vuex';
import { formatStringDate } from '@/utils';
export default {
name: 'FeatureListTable',
filters: {
formatDate(value) {
return formatStringDate(value);
},
},
beforeRouteLeave (to, from, next) {
if (to.name !== 'editer-attribut-signalement') {
this.UPDATE_CHECKED_FEATURES([]); // empty if not needed anymore
}
next(); // continue navigation
},
props: {
paginatedFeatures: {
type: Array,
default: null,
},
pageNumbers: {
type: Array,
default: null,
},
allSelected: {
type: Boolean,
default: false,
},
checkedFeatures: {
type: Array,
default: null,
},
featuresCount: {
type: Number,
default: 0,
},
pagination: {
type: Object,
default: null,
},
sort: {
type: Object,
default: null,
},
queryparams: {
type: Object,
default: null,
},
editAttributesFeatureType: {
type: String,
default: null,
},
},
computed: {
...mapGetters(['permissions']),
...mapState([
'user',
'USER_LEVEL_PROJECTS',
'isOnline'
]),
...mapState('projects', ['project']),
...mapState('feature', ['clickedFeatures', 'massMode']),
mode: {
get() {
return this.massMode;
},
set(newMode) {
this.TOGGLE_MASS_MODE(newMode);
// Reset all selections
this.isAllSelected = false;
this.UPDATE_CLICKED_FEATURES([]);
this.UPDATE_CHECKED_FEATURES([]);
},
},
userStatus() {
return this.USER_LEVEL_PROJECTS ? this.USER_LEVEL_PROJECTS[this.$route.params.slug] : '';
},
checkedFeaturesSet() {
return new Set(this.checkedFeatures); // Set améliore la performance sur la recherche
},
isAllSelected: {
get() {
return this.allSelected;
},
set(isChecked) {
this.$emit('update:allSelected', isChecked);
},
},
displayedPageEnd() {
return this.featuresCount <= this.pagination.end
? this.featuresCount
: this.pagination.end;
},
lastPageNumber() {
return this.pageNumbers.slice(-1)[0];
},
displayedPageNumbers() {
//* s'il y a moins de 5 pages, renvoyer toutes les pages
if (this.lastPageNumber < 5) {
return this.pageNumbers;
}
//* si la page courante est inférieur à 5, la liste commence à l'index 0 et on retourne 5 pages
let firstPageInList = 0;
let pagesQuantity = 5;
//* à partir de la 5ième page et jusqu'à la 4ième page avant la fin : n'afficher que 3 page entre les ellipses et la page courante doit être au milieu
if (this.pagination.currentPage >= 5 && !((this.lastPageNumber - this.pagination.currentPage) < 4)) {
firstPageInList = this.pagination.currentPage - 2;
pagesQuantity = 3;
}
//* à partir de 4 résultat avant la fin afficher seulement les 5 derniers résultats
if ((this.lastPageNumber - this.pagination.currentPage) < 4) {
firstPageInList = this.lastPageNumber - 5;
}
return this.pageNumbers.slice(firstPageInList, firstPageInList + pagesQuantity);
},
},
methods: {
...mapMutations('feature', [
'UPDATE_CLICKED_FEATURES',
'UPDATE_CHECKED_FEATURES',
'TOGGLE_MASS_MODE',
]),
/**
* Vérifie si une feature doit être cochée en fonction de la sélection globale et des droits d'édition.
* @param {Object} feature - L'objet représentant la feature.
* @returns {Boolean} - `true` si la feature doit être cochée.
*/
isFeatureSelected(feature) {
if (this.isAllSelected) {
return this.checkRights(feature); // Si tout doit être sélectionné, on vérifie les droits
}
return this.checkedFeaturesSet.has(feature.feature_id);
},
/**
* Ajoute ou supprime une feature de la sélection en fonction de l'état de la checkbox.
* Met également à jour les features cliquées et les restrictions d'édition d'attributs.
* @param {Event} event - L'événement de changement de l'input checkbox.
* @param {Object} feature - La feature associée.
*/
handleFeatureSelection(event, feature) {
const isChecked = event.target.checked;
const updatedFeatures = isChecked
? [...this.checkedFeatures, feature.feature_id]
: this.checkedFeatures.filter(id => id !== feature.feature_id);
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', updatedFeatures);
this.trackClickedFeature(feature);
this.manageAttributeEdition(feature, updatedFeatures.length);
},
/**
* Gère les restrictions sur la modification des attributs en fonction de la sélection en masse.
* @param {Object} feature - La feature actuellement sélectionnée.
* @param {Number} checkedCount - Nombre total de features actuellement cochées.
*/
manageAttributeEdition(feature, checkedCount) {
if (this.massMode !== 'edit-attributes') return;
if (checkedCount === 1) {
// Premier élément sélectionné, on stocke son type pour restreindre la sélection
this.$emit('update:editAttributesFeatureType', feature.feature_type.slug);
} else if (checkedCount === 0) {
// Dernière feature désélectionnée -> on réinitialise la restriction
this.$emit('update:editAttributesFeatureType', null);
}
},
/**
* Ajoute une feature cliquée à la liste pour conserver son historique de sélection.
* Permet de gérer la sélection de plusieurs features sur différentes pages sans surcharger la mémoire.
* @param {Object} feature - La feature cliquée.
*/
trackClickedFeature(feature) {
this.UPDATE_CLICKED_FEATURES([
...this.clickedFeatures,
{ feature_id: feature.feature_id, feature_type: feature.feature_type.slug }
]);
},
/**
* Vérifie si l'utilisateur a le droit de supprimer une feature.
* @param {Object} feature - La feature à vérifier.
* @returns {Boolean} - `true` si l'utilisateur peut supprimer la feature.
*/
canDeleteFeature(feature) {
if (this.userStatus === 'Administrateur projet' || this.user.is_superuser) {
return true; // Un administrateur ou super utilisateur peut tout supprimer
}
// Sinon, on ne peut supprimer que ses propres features
return feature.creator === this.user.id;
},
/**
* Vérifie si l'utilisateur a le droit de modifier une feature.
* @param {Object} feature - La feature à vérifier.
* @returns {Boolean} - `true` si l'utilisateur peut modifier la feature.
*/
canEditFeature(feature) {
const permissions = {
'Administrateur projet' : ['draft', 'pending', 'published', 'archived'],
Modérateur : ['draft', 'pending', 'published'],
'Super Contributeur' : ['draft', 'pending', 'published'],
Contributeur : ['draft', 'pending', 'published'],
};
if (this.checkedFeatures.length > 0 && // check if selection should be restricted to a specific feature type, for attributes modification
feature.feature_type.slug !== this.editAttributesFeatureType &&
this.massMode === 'edit-attributes') {
return false;
} else if (this.user.is_superuser) {
return true;
} else if (this.userStatus === 'Contributeur' && feature.creator !== this.user.id) {
return false;
} else if (permissions[this.userStatus]) {
return permissions[this.userStatus].includes(feature.status);
} else {
return false;
}
},
checkRights(feature) {
if (this.massMode.includes('edit')) {
return this.canEditFeature(feature);
} else if (this.massMode === 'delete-features') {
return this.canDeleteFeature(feature);
}
},
isSortedAsc(column) {
return this.sort.column === column && this.sort.ascending;
},
isSortedDesc(column) {
return this.sort.column === column && !this.sort.ascending;
},
changeSort(column) {
if (!this.isOnline) return;
if (this.sort.column === column) {
//changer only order
this.$emit('update:sort', {
column: this.sort.column,
ascending: !this.sort.ascending,
});
} else { // change column and reset order
this.$emit('update:sort', { column, ascending: true });
}
},
},
};
</script>
<style scoped>
/* datatables */
.dataTables_wrapper {
position: relative;
clear: both;
}
.dataTables_paginate {
margin-bottom: 1rem;
}
table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable td.dataTables_empty {
text-align: center;
}
.dataTables_wrapper .dataTables_length,
.dataTables_wrapper .dataTables_filter,
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_processing,
.dataTables_wrapper .dataTables_paginate {
color: #333;
}
.dataTables_wrapper .dataTables_info {
clear: both;
float: left;
padding-top: 0.755em;
}
.dataTables_wrapper .dataTables_paginate {
float: right;
text-align: right;
padding-top: 0.25em;
}
.dataTables_wrapper .dataTables_paginate .paginate_button.current,
.dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
color: #333 !important;
border: 1px solid #979797;
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0%, #fff),
color-stop(100%, #dcdcdc)
);
background: -webkit-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -moz-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -ms-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -o-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: linear-gradient(to bottom, #fff 0%, #dcdcdc 100%);
}
.dataTables_wrapper .dataTables_paginate .paginate_button {
box-sizing: border-box;
display: inline-block;
min-width: 1.5em;
padding: 0.5em 1em;
margin-left: 2px;
text-align: center;
text-decoration: none !important;
cursor: pointer;
color: #333 !important;
border: 1px solid transparent;
border-radius: 2px;
}
.dataTables_wrapper .dataTables_paginate .paginate_button:hover {
color: white !important;
border: 1px solid #111;
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0%, #585858),
color-stop(100%, #111)
);
background: -webkit-linear-gradient(top, #585858 0%, #111 100%);
background: -moz-linear-gradient(top, #585858 0%, #111 100%);
background: -ms-linear-gradient(top, #585858 0%, #111 100%);
background: -o-linear-gradient(top, #585858 0%, #111 100%);
background: linear-gradient(to bottom, #585858 0%, #111 100%);
}
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled,
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:hover,
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:active {
cursor: default;
color: #666 !important;
border: 1px solid transparent;
background: transparent;
box-shadow: none;
}
.dataTables_wrapper .dataTables_paginate .ellipsis {
padding: 0 1em;
}
i.icon.sort:not(.down):not(.up) {
color: rgb(220, 220, 220);
}
.grey {
color: #bbbbbb;
}
.ui.dropdown .menu .left.menu, .ui.dropdown > .left.menu .menu {
margin-right: 0 !important;
}
.table-mobile-buttons {
margin-bottom: 1em;
}
/* increase contrast between available checkboxes and disabled ones */
#table-features .ui.disabled.checkbox label::before {
background-color: #fbf5f5;;
}
#select-all + label {
text-align: left;
&:hover {
cursor: pointer;
}
}
@media only screen and (min-width: 761px) {
.table-mobile-buttons {
display: none !important;
}
}
/*
Max width before this PARTICULAR table gets nasty
This query will take effect for any screen smaller than 760px
and also iPads specifically.
*/
@media only screen and (max-width: 760px) {
.table-mobile-buttons {
display: flex !important;
}
.inline.fields label {
width: 100% !important;
}
/* hide table border */
.ui.table {
border: none !important;
margin-top: 2em;
}
/* Force table to not be like tables anymore */
table,
thead,
tbody,
th,
td,
tr {
display: block;
}
/* Hide table headers (but not display: none;, for accessibility) */
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
tr { /* style as a card */
border: 1px solid #ccc;
border-radius: 7px;
margin-bottom: 3vh;
padding: 0 2vw .5em 2vw;
box-shadow: rgba(50, 50, 50, 0.1) 2px 5px 10px ;
}
td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid #eee;
position: relative;
padding-left: 50%;
}
.ui.table tr td {
border-top: none;
}
.ui.compact.table td {
padding: .2em;
}
td:nth-of-type(1) {
border: none !important;
padding: .25em !important;
}
td:nth-of-type(8) {
border-bottom: none !important;
}
td:before {
/* Now like a table header */
position: absolute;
/* Top/left values mimic padding */
left: 6px;
padding-right: 10px;
white-space: nowrap;
}
/*
Label the data
*/
td#select:before {
content: "";
}
td#status:before {
content: "Statut";
}
td#type:before {
content: "Type";
}
td#name:before {
content: "Nom";
}
td#create:before {
content: "Date de création";
}
td#update:before {
content: "Dernière modification";
}
td#author:before {
content: "Auteur";
}
td#last_editor:before {
content: "Dernier éditeur";
}
table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable {
text-align: right;
}
#select .ui.checkbox {
position: absolute;
left: calc(-1vw - 20px);
top: -12px;
min-height: 24px;
font-size: 1rem;
line-height: 24px;
min-width: 24px;
}
#select .ui.checkbox .box::before, #select .ui.checkbox label::before,
#select .ui.checkbox .box::after, #select .ui.checkbox label::after {
width: 24px;
height: 24px;
}
/* cover all the card to ease selection by user */
#select .ui.checkbox {
width: 100%;
}
#select .ui.checkbox input[type="checkbox"] {
width: calc(100% + 1vw + 20px + 4vw);
height: calc(14em + 12px);
}
/* keep the links above the checkbox input to receive the click event */
table a {
z-index: 4;
position: sticky;
}
#select .ui.checkbox .box::before, #select .ui.checkbox label::before {
border-radius: 12px;
}
#select .ui.checkbox .box::after, #select .ui.checkbox label::after {
font-size: 18px;
}
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_paginate {
width: 100%;
text-align: center;
margin: .5em 0;
}
/* preserve space to not overlap column label */
.space-left {
margin-left: 2.5em;
}
}
@media only screen and (max-width: 410px) {
.ui.table tr td {
border: none;
}
}
</style>
<template>
<div>
<div
id="feature-list-container"
class="ui mobile-column"
>
<div class="mobile-fullwidth">
<h1>Signalements</h1>
</div>
<div class="no-padding-mobile mobile-fullwidth">
<div class="ui large text loader">
Chargement
</div>
<div class="ui secondary menu no-margin">
<a
id="show-map"
:class="['item no-margin', { active: showMap }]"
data-tab="map"
data-tooltip="Carte"
data-position="bottom left"
@click="$emit('show-map', true)"
>
<i
class="map fitted icon"
aria-hidden="true"
/>
</a>
<a
id="show-list"
:class="['item no-margin', { active: !showMap }]"
data-tab="list"
data-tooltip="Liste"
data-position="bottom left"
@click="$emit('show-map', false)"
>
<i
class="list fitted icon"
aria-hidden="true"
/>
</a>
<div class="item">
<h4>
{{ featuresCount }} signalement{{ featuresCount > 1 ? "s" : "" }}
</h4>
</div>
<div
v-if="
project &&
filteredFeatureTypeChoices.length > 0 &&
permissions.can_create_feature
"
id="button-dropdown"
class="item right"
>
<div
class="ui dropdown button compact button-hover-green"
data-tooltip="Ajouter un signalement"
data-position="bottom right"
@click="toggleAddFeature"
>
<i
class="plus fitted icon"
aria-hidden="true"
/>
<div
v-if="showAddFeature"
class="menu left transition visible"
style="z-index: 9999"
>
<div class="header">
Ajouter un signalement du type
</div>
<div class="scrolling menu text-wrap">
<router-link
v-for="(type, index) in filteredFeatureTypeChoices"
: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="(allSelected || checkedFeatures.length > 0) && massMode.includes('edit') && isOnline"
id="edit-button"
class="ui dropdown button compact button-hover-green tiny-margin-left"
:data-tooltip="`Modifier le${massMode.includes('status') ? ' statut' : 's attributs'} des signalements`"
data-position="bottom right"
@click="editFeatures"
>
<i
class="pencil fitted icon"
aria-hidden="true"
/>
<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="$emit('edit-status', status.value)"
>
{{ status.name }}
</span>
</div>
</div>
</div>
<div
v-if="(allSelected || checkedFeatures.length > 0) && massMode === 'delete-features' && isOnline"
class="ui button compact button-hover-red tiny-margin-left"
data-tooltip="Supprimer tous les signalements sélectionnés"
data-position="bottom right"
@click="$emit('toggle-delete-modal')"
>
<i
class="grey trash fitted icon"
aria-hidden="true"
/>
</div>
</div>
</div>
</div>
</div>
<section
id="form-filters"
class="ui form grid equal width"
>
<div
id="type"
:class="['field column', { 'disabled': !isOnline }]"
>
<label>Type</label>
<Multiselect
v-model="form.type"
:options="featureTypeOptions"
:multiple="true"
:searchable="false"
:close-on-select="false"
:show-labels="false"
placeholder="Sélectionner un type"
track-by="value"
label="name"
/>
</div>
<div
id="statut"
:class="['field column', { 'disabled': !isOnline }]"
>
<label>Statut</label>
<Multiselect
v-model="form.status"
:options="statusOptions"
:multiple="true"
:searchable="false"
:close-on-select="false"
:show-labels="false"
placeholder="Sélectionner un statut"
track-by="value"
label="name"
/>
</div>
<div
id="name"
:class="['field column', { 'disabled': !isOnline }]"
>
<label>Nom</label>
<div class="ui icon input">
<i
class="search icon"
aria-hidden="true"
/>
<div class="ui action input">
<input
v-model="form.title"
type="text"
name="title"
@keyup.enter="resetPaginationNfetchFeatures"
>
<button
id="submit-search"
class="ui teal icon button"
@click="resetPaginationNfetchFeatures"
>
<i
class="search icon"
aria-hidden="true"
/>
</button>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import Multiselect from 'vue-multiselect';
import { statusChoices, allowedStatus2change } from '@/utils';
const initialPagination = {
currentPage: 1,
pagesize: 15,
start: 0,
end: 15,
};
export default {
name: 'FeaturesListAndMapFilters',
components: {
Multiselect
},
props: {
showMap: {
type: Boolean,
default: true
},
featuresCount: {
type: Number,
default: 0
},
pagination: {
type: Object,
default: () => {
return {
...initialPagination
};
}
},
allSelected: {
type: Boolean,
default: false,
},
editAttributesFeatureType: {
type: String,
default: null,
},
},
data() {
return {
form: {
type: [],
status: [],
title: null,
},
lat: null,
lng: null,
showAddFeature: false,
showModifyStatus: false,
zoom: null,
};
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS',
'isOnline'
]),
...mapState('feature', [
'checkedFeatures',
'massMode',
]),
...mapState('feature-type', [
'feature_types',
]),
...mapState('projects', [
'project',
]),
...mapGetters([
'permissions',
]),
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.user, isModerate, userStatus, isOwnFeature);
}
return [];
},
featureTypeTitles() {
return this.feature_types.map((el) => el.title);
},
featureTypeOptions() {
return this.feature_types.map((el) => ({ name: el.title, value: el.slug }));
},
statusOptions() {
//* if project is not moderate, remove pending status
return statusChoices.filter((el) =>
this.project && this.project.moderation ? true : el.value !== 'pending'
);
},
filteredFeatureTypeChoices() {
return this.feature_types.filter((fType) =>
!fType.geom_type.includes('multi')
);
},
},
watch: {
'form.type'(newValue) {
this.$emit('set-filter', { type: newValue });
this.resetPaginationNfetchFeatures();
},
'form.status': {
deep: true,
handler(newValue) {
this.$emit('set-filter', { status: newValue });
this.resetPaginationNfetchFeatures();
}
},
'form.title'(newValue) {
this.$emit('set-filter', { title: newValue });
this.resetPaginationNfetchFeatures();
},
},
mounted() {
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
},
methods: {
resetPaginationNfetchFeatures() {
this.$emit('reset-pagination');
this.$emit('fetch-features');
},
toggleAddFeature() {
this.showAddFeature = !this.showAddFeature;
this.showModifyStatus = false;
},
editFeatures() {
switch (this.massMode) {
case 'edit-status':
this.toggleModifyStatus();
break;
case 'edit-attributes':
this.displayAttributesForm();
break;
}
},
toggleModifyStatus() {
this.showModifyStatus = !this.showModifyStatus;
this.showAddFeature = false;
},
displayAttributesForm() {
if (this.checkedFeatures.length > 1) {
this.$router.push({
name: 'editer-attribut-signalement',
params: {
slug_type_signal: this.editAttributesFeatureType,
},
});
} else {
this.$store.commit('DISPLAY_MESSAGE', {
comment: 'Veuillez sélectionner au moins 2 signalements pour l\'édition multiple d\'attributs'
});
}
},
clickOutsideDropdown(e) {
if (!e.target.closest('#button-dropdown')) {
this.showModifyStatus = false;
setTimeout(() => { //* timout necessary to give time to click on link to add feature
this.showAddFeature = false;
}, 500);
}
},
}
};
</script>
<style lang="less" scoped>
#feature-list-container {
display: flex;
justify-content: space-between;
.no-padding-mobile {
width: 100%;
margin-left: 25%;
.secondary.menu #button-dropdown {
z-index: 10;
margin-right: 0;
padding-right: 0;
}
}
}
#form-filters {
margin: 0;
label + div {
min-height: 42px;
}
}
.ui.dropdown .menu .left.menu, .ui.dropdown > .left.menu .menu {
margin-right: 0 !important;
}
@media screen and (min-width: 767px) {
#form-filters {
div.field:first-child {
padding-left: 0;
}
div.field:last-child {
padding-right: 0;
}
}
}
@media screen and (max-width: 767px) {
#feature-list-container > .mobile-fullwidth {
width: 100% !important;
}
.no-margin-mobile {
margin: 0 !important;
}
.no-padding-mobile {
padding-top: 0 !important;
padding-bottom: 0 !important;
margin-left: 0 !important;
}
.mobile-column {
flex-direction: column;
}
#form-filters > .field.column {
width: 100%;
padding: 0;
}
#form-filters > .field.column:first-child {
margin-top: 1rem;
}
#form-filters > .field.column:last-child {
margin-bottom: 1rem;
}
.map-container {
width: 100%;
}
}
</style>
<style>
#form-filters .multiselect__tags {
white-space: normal !important;
}
#form-filters .multiselect__tag {
background: var(--primary-color, #008c86) !important;
}
#form-filters .multiselect__tag-icon:focus, #form-filters .multiselect__tag-icon:hover{
background: var(--primary-highlight-color, #006f6a) !important;
}
#form-filters .multiselect__tag-icon:not(:hover)::after {
color: var(--primary-highlight-color, #006f6a);
filter: brightness(0.6);
}
#form-filters .multiselect__option--selected:not(:hover) {
background-color: #e8e8e8 !important;
}
</style>
\ No newline at end of file
<template>
<div class="field">
<Dropdown
v-if="!disabled"
:options="projectMemberOptions"
:selected="selectedMember ? selectedMember.name : ''"
:selection.sync="selectedMember"
:search="true"
:clearable="true"
/>
<div v-else-if="selectedMember && selectedMember.name && Array.isArray(selectedMember.name)">
<span> {{ selectedMember.name[0] || selectedMember.name[1] }}</span>
</div>
</div>
</template>
<script>
import Dropdown from '@/components/Dropdown.vue';
import { formatUserOption } from '@/utils';
import { mapState } from 'vuex';
export default {
name: 'ProjectMemberSelect',
components: {
Dropdown,
},
props: {
selectedUserId: {
type: Number,
default: null,
},
disabled: {
type: Boolean,
default: false,
}
},
computed: {
...mapState('projects', [
'projectUsers'
]),
projectMemberOptions: function () {
return this.projectUsers
.filter((el) => el.level.codename !== 'logged_user') // Filter out user not member of the project (with level lower than contributor)
.map((el) => formatUserOption(el.user)); // Format user data to fit dropdown option structure
},
selectedMember: {
get() {
return this.projectMemberOptions.find(el => el.value === this.selectedUserId);
},
set(newValue) {
/**
* If the user delete previous assigned_member the value is undefined
* We replace it by null in order to allow empty field to be sent with the request
* & to comply with UPDATE_FORM_FIELD mutation logic
* TODO: If refactoring the app one day -> merge together both featureEdit form and feature store form to work the same way
*/
this.$emit('update:user', newValue.value || null);
},
}
},
created() {
this.$store.dispatch('projects/GET_PROJECT_USERS', this.$route.params.slug);
},
};
</script>
\ No newline at end of file
<template>
<Multiselect
v-model="selection"
:class="{ multiple }"
:options="options"
:allow-empty="true"
track-by="label"
label="label"
:reset-after="false"
select-label=""
selected-label=""
deselect-label=""
:searchable="false"
:placeholder="placeholder"
:clear-on-select="false"
:preserve-search="true"
:multiple="multiple"
:disabled="loading"
@select="select"
@remove="remove"
@close="close"
>
<template
slot="option"
slot-scope="props"
>
<span :title="props.option.label">{{ props.option.label }}</span>
</template>
<template
v-if="multiple"
slot="selection"
slot-scope="{ values }"
>
<span
v-if="values && values.length > 1"
class="multiselect__single"
>
{{ values.length }} options sélectionnées
</span>
<span
v-else
class="multiselect__single"
>{{ currentSelectionLabel || selection.label }}</span>
</template>
</Multiselect>
</template>
<script>
import Multiselect from 'vue-multiselect';
export default {
name: 'DropdownMenuItem',
components: {
Multiselect
},
props: {
placeholder: {
type: String,
default: 'Sélectionnez une valeur'
},
options: {
type: Array,
default: () => {
return [];
}
},
loading: {
type: Boolean,
default: false
},
multiple: {
type: Boolean,
default: false
},
currentSelection: {
type: [String, Array, Boolean],
default: null,
},
defaultFilter: {
type: [String, Array, Boolean],
default: null,
}
},
data() {
return {
selection: null,
};
},
computed: {
/**
* Get the label of an option to work with project attributes options as JSON
*/
currentSelectionLabel() {
const option = this.options.find(opt => opt.value === this.currentSelection);
return option ? option.label : '';
}
},
watch: {
selection: {
deep: true,
handler(newValue) {
if (!newValue) {
this.selection = this.options[0];
this.$emit('filter', this.selection);
}
}
},
currentSelection(newValue) {
this.updateSelection(newValue);
},
},
created() {
if (this.currentSelection !== null) {
this.selection = this.options.find(opt => opt.value === this.currentSelection);
} else {
this.selection = this.options[0];
}
},
methods: {
select(e) {
this.$emit('filter', e);
},
remove(e) {
this.$emit('remove', e);
},
close() {
this.$emit('close', this.selection);
},
/**
* Normalizes the input value(s) to an array of strings.
* This handles both single string inputs and comma-separated strings, converting them into an array.
*
* @param {String|Array} value - The input value to normalize, can be a string or an array of strings.
* @return {Array} An array of strings representing the input values.
*/
normalizeValues(value) {
// If the value is a string and contains commas, split it into an array; otherwise, wrap it in an array.
return typeof value === 'string' ? (value.includes(',') ? value.split(',') : [value]) : value;
},
/**
* Updates the current selection based on new value, ensuring compatibility with multiselect.
* This method processes the new selection value, accommodating both single and multiple selections,
* and updates the internal `selection` state with the corresponding option objects from `options`.
*
* @param {String|Array} value - The new selection value(s), can be a string or an array of strings.
*/
// Check if the component is in multiple selection mode and the new value is provided.
updateSelection(value) {
if (this.multiple && value) {
// Normalize the value to an array format, accommodating both single and comma-separated values.
const normalizedValues = this.normalizeValues(value);
// Map each value to its corresponding option object based on the 'value' field.
this.selection = normalizedValues.map(value =>
this.options.find(option => option.value === value)
);
} else {
// For single selection mode or null value, find the option object that matches the value.
this.selection = this.options.find(option => option.value === value);
}
}
}
};
</script>
<style>
#filters-container .multiple .multiselect__option--selected:not(:hover) {
background-color: #e8e8e8 !important;
}
#filters-container .multiselect--disabled .multiselect__select {
background: 0, 0 !important;
}
</style>
\ No newline at end of file
<template>
<div
:id="project.title"
class="item"
data-test="project-list-item"
>
<div class="ui tiny image">
<img
:src="
!project.thumbnail
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
alt="Thumbnail du projet"
>
</div>
<div class="middle aligned content">
<router-link
:to="{
name: 'project_detail',
params: { slug: project.slug },
}"
class="header"
>
{{ project.title }}
</router-link>
<div class="description">
<textarea
:id="`editor-${project.slug}`"
:value="project.description"
:data-preview="`#preview-${project.slug}`"
hidden
/>
<div
:id="`preview-${project.slug}`"
class="preview"
/>
</div>
<div class="meta top">
<span class="right floated">
<strong v-if="project.moderation">Projet modéré</strong>
<strong v-else>Projet non modéré</strong>
</span>
<span>Niveau d'autorisation requis :
{{ project.access_level_pub_feature }}</span><br>
<span v-if="user">
Mon niveau d'autorisation :
<span v-if="USER_LEVEL_PROJECTS && project">{{
USER_LEVEL_PROJECTS[project.slug]
}}</span>
<span v-if="user && user.is_administrator">{{
"+ Gestionnaire métier"
}}</span>
</span>
</div>
<div class="meta">
<span class="right floated">
<i
class="calendar icon"
aria-hidden="true"
/>&nbsp; {{ project.created_on }}
</span>
<span data-tooltip="Membres">
{{ project.nb_contributors }}&nbsp;
<i
class="user icon"
aria-hidden="true"
/>
</span>
<span data-tooltip="Signalements publiés">
{{ project.nb_published_features }}&nbsp;
<i
class="map marker icon"
aria-hidden="true"
/>
</span>
<span data-tooltip="Commentaires">
{{ project.nb_published_features_comments }}&nbsp;
<i
class="comment icon"
aria-hidden="true"
/>
</span>
</div>
</div>
</div>
</template>
<script>
import TextareaMarkdown from 'textarea-markdown';
import { mapState } from 'vuex';
export default {
name: 'ProjectsListItem',
props: {
project: {
type: Object,
default: () => {
return {};
}
}
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS'
]),
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
},
mounted() {
let textarea = document.getElementById(`editor-${this.project.slug}`);
new TextareaMarkdown(textarea);
},
methods: {
refreshId() {
const crypto = window.crypto || window.msCrypto;
var array = new Uint32Array(1);
return '?ver=' + crypto.getRandomValues(array); // Compliant for security-sensitive use cases
},
}
};
</script>
<style lang="less" scoped>
.preview {
max-height: 10em;
overflow-y: scroll;
margin-bottom: 0.8em;
}
.description {
p {
text-align: justify;
}
}
@media screen and (max-width: 767px) {
.content {
width: 90% !important;
.meta.top {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
.right.floated {
float: none !important;
margin-left: 0 !important;
margin-bottom: 0.5em;
}
span {
margin: 0.15em 0;
}
}
}
}
</style>
<template>
<div
v-if="chunkedNsortedFilters.length > 0"
id="filters-container"
>
<div
class="ui styled accordion"
@click="displayFilters = !displayFilters"
>
<div
id="filters"
class="title collapsible-filters"
>
FILTRES
<i
:class="['ui icon customcaret', { 'collapsed': !displayFilters }]"
aria-hidden="true"
/>
</div>
</div>
<div :class="['full-width', 'filters', { 'hidden': displayFilters }]">
<div
v-for="(chunkedFilters, index) in chunkedNsortedFilters"
:key="index"
class="ui menu filter-row"
>
<div
v-for="filter in chunkedFilters"
:key="filter.name"
class="item"
>
<label>
{{ filter.label }}
</label>
<search-projects
v-if="filter.name === 'search'"
v-on="$listeners"
/>
<DropdownMenuItem
v-else-if="!filter.id"
:options="filter.options"
:loading="loading"
v-on="$listeners"
/>
<DropdownMenuItem
v-else
:options="filter.options"
:loading="loading"
:multiple="isMultiple(filter)"
:current-selection="attributesFilter[filter.id]"
:default-filter="filter.default_filter_enabled ? filter.default_filter_value : null"
@filter="updateAttributeFilter"
@remove="removeAttributeFilter"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
import DropdownMenuItem from '@/components/Projects/DropdownMenuItem.vue';
import SearchProjects from '@/components/Projects/SearchProjects.vue';
export default {
name: 'ProjectsMenu',
components: {
DropdownMenuItem,
SearchProjects,
},
props: {
loading: {
type: Boolean,
default: false
},
},
data() {
return {
displayFilters: false,
classicFilters: [
{
name: 'access_level',
label: 'Niveau d\'autorisation requis',
options: [
{
label: 'Utilisateur anonyme',
value: 'anonymous'
},
{
label: 'Utilisateur connecté',
value: 'logged_user'
},
{
label: 'Contributeur',
value: 'contributor'
},
],
},
{
name: 'user_access_level',
label: 'Mon niveau d\'autorisation',
options: [
{
label: 'Utilisateur connecté',
value: '1'
},
{
label: 'Contributeur',
value: '2'
},
{
label: 'Super contributeur',
value: '3'
},
{
label: 'Modérateur',
value: '4'
},
{
label: 'Administrateur projet',
value: '5'
},
],
},
{
name: 'moderation',
label: 'Modération',
options: [
{
label: 'Projet modéré',
value: 'true'
},
{
label: 'Projet non modéré',
value: 'false'
},
]
},
{
name: 'search',
label: 'Recherche par nom',
}
],
attributesFilter: {},
};
},
computed: {
...mapState([
'user',
'configuration',
'projectAttributes'
]),
...mapState('projects', [
'filters',
]),
/**
* Processes project filters to prepare them for display.
* It also adds a global 'Tous' (All) option to each attribute's options for filtering purposes.
*
* @returns {Array} An array of filter objects with modified options for display.
*/
displayedClassicFilters() {
if (!this.configuration.VUE_APP_PROJECT_FILTERS) return [];
const projectFilters = this.configuration.VUE_APP_PROJECT_FILTERS.split(',');
// Filter filters to be displayed according to configuration and process filters
return this.classicFilters.filter(filter => projectFilters.includes(filter.name))
.map(filter => {
if (filter.options) {
// if user is not connected display its user access level corresponding to anonymous user
if (!this.user && filter.name ==='user_access_level') {
filter.options.unshift({
label: 'Utilisateur anonyme',
value: '0'
});
}
// Format the options to be displayed by dropdowns
const options = this.generateFilterOptions(filter);
// Add the global option at beginning
options.unshift({
label: 'Tous',
filter: filter.name,
value: null,
});
return { ...filter, options };
} else { // Search input field doesn't take options
return filter;
}
});
},
/**
* Processes project attributes to prepare them for display, adjusting the options based on the attribute type.
* For boolean attributes, it creates specific options for true and false values.
* It also adds a global 'Tous' (All) option to each attribute's options for filtering purposes.
* Finally, it chunks the array of attributes into multiple arrays, each containing up to 4 elements.
*
* @returns {Array} An array of arrays, where each sub-array contains up to 4 project attributes with modified options for display.
*/
displayedAttributeFilters() {
// Filter displayed filters & filter only attribute of boolean type (no need for option property) or list type with options
return this.projectAttributes.filter(attribute => attribute.display_filter && (attribute.field_type === 'boolean' || attribute.options))
// Process attributes for display
.map(attribute => {
// Format the options to be displayed by dropdowns
const options = this.generateFilterOptions(attribute);
// Add the global option at beginning
options.unshift({
label: 'Tous',
filter: attribute.id,
value: null,
});
return { ...attribute, options };
});
},
/**
* Merge all filters and place the search filters at the end of the array
* Then chunks the array into rows of 4 filters to display each chunk in a row
*/
chunkedNsortedFilters() {
const allFilters = [...this.displayedClassicFilters, ...this.displayedAttributeFilters];
const sortedFilters = [
...allFilters.filter(el => el.name !== 'search'),
...allFilters.filter(el => el.name === 'search'),
];
// Chunk the filters into arrays of up to 4 elements
return this.chunkArray(sortedFilters, 4);
},
},
created() {
// parse all project attributes to find default value and set filters in store before updating project list results
for (const attribFilter of this.displayedAttributeFilters) {
this.setDefaultFilters(attribFilter);
}
// When all the default filters are set, fetch projects list data
this.$emit('getData');
},
methods: {
...mapMutations('projects', [
'SET_PROJECTS_FILTER'
]),
/**
* Helper function to chunk an array into smaller arrays of a specified size.
*
* @param {Array} array - The original array to be chunked.
* @param {Number} size - The maximum size of each chunk.
* @returns {Array} An array of chunked arrays.
*/
chunkArray(array, size) {
const chunkedArr = [];
for (let i = 0; i < array.length; i += size) {
chunkedArr.push(array.slice(i, i + size));
}
return chunkedArr;
},
/**
* Generates options for a given filter.
* It handles boolean attributes specially by creating explicit true/false options.
* Other attribute types use their predefined options.
*
* @param {Object} attribute - The project attribute for which to generate options.
* @returns {Array} An array of options for the given attribute.
*/
generateFilterOptions(filter) {
// Handle boolean attributes specially by creating true/false options
if (filter.field_type === 'boolean') {
return [
{ filter: filter.id, label: 'Oui', value: 'true' },
{ filter: filter.id, label: 'Non', value: 'false' },
];
} else if (filter.options) {
// For other filter types, map each option to the expected format
return filter.options.map(option => ({
filter: filter.id || filter.name,
label: option.name || option.label || option,
value: option.id || option.value || option,
}));
}
return [];
},
/**
* Retrieves a project attribute by its ID.
* Returns an empty object if not found to prevent errors from undefined access.
*
* @param {Number|String} id - The ID of the attribute to find.
* @returns {Object} The found attribute or an empty object.
*/
getProjectAttribute(id) {
// Search for the attribute by ID, default to an empty object if not found
return this.projectAttributes.find(el => el.id === id) || {};
},
/**
* Emits an updated filter event with the current state of attributesFilter.
* This method serializes the attributesFilter object to a JSON string and emits it,
* allowing the parent component to update the query parameters.
*/
emitUpdatedFilter() {
// Emit an 'filter' event with the updated attributes filter as a JSON string
this.$emit('filter', { filter: 'attributes', value: JSON.stringify(this.attributesFilter) });
},
/**
* Updates or adds a new attribute value to the attributesFilter.
* Handles both single-choice and multi-choice attribute types.
* @param {Object} newFilter - The new filter to be added, containing the attribute key and value.
*/
updateAttributeFilter({ filter, value, noUpdate }) {
// Retrieve the attribute type information to determine how to handle the update
const attribute = this.getProjectAttribute(filter);
// Check if the attribute allows multiple selections
const isMultiChoice = attribute.field_type.includes('list');
if (isMultiChoice) {
// For multi-choice attributes, manage the values as an array to allow multiple selections
let arrayValue = this.attributesFilter[filter] ? this.attributesFilter[filter].split(',') : [];
if (value) {
// If a value is provided, add it to the array, ensuring no duplicates and removing null corresponding to "Tous" default option
arrayValue.push(value);
arrayValue = [...new Set(arrayValue)].filter(el => el !== null);
// Convert the array back to a comma-separated string to store in the filter object
this.attributesFilter[filter] = arrayValue.join(',');
} else {
// If null value is provided "Tous" is selected, it indicates removal of the attribute filter
delete this.attributesFilter[filter];
}
} else {
// For single-choice attributes, directly set or delete the value
value ? this.attributesFilter[filter] = value : delete this.attributesFilter[filter];
}
if (noUpdate) {
this.SET_PROJECTS_FILTER({ filter: 'attributes', value: JSON.stringify(this.attributesFilter) });
} else {
// After updating the filter object, emit the updated filter for application-wide use
this.emitUpdatedFilter();
}
},
/**
* Removes a specified value from a project attribute filter.
* Particularly useful for multi-choice attributes where individual values can be deselected.
* @param {Object} removedFilter - The filter to be removed, containing the attribute key and value.
*/
removeAttributeFilter({ filter, value }) {
// Retrieve attribute information to determine if it's a multi-choice attribute
const attribute = this.getProjectAttribute(filter);
const isMultiChoice = attribute.field_type.includes('list');
if (isMultiChoice) {
// For multi-choice attributes, convert the current filter value to an array for manipulation
let arrayValue = this.attributesFilter[filter] ? this.attributesFilter[filter].split(',') : [];
// Remove the specified value from the array
arrayValue = arrayValue.filter(val => val !== value);
// Update the attributesFilter with the new array, converted back to a string
this.attributesFilter[filter] = arrayValue.join(',');
} else {
// For single-choice attributes, directly update the filter to remove the value
delete this.attributesFilter[filter];
}
// Emit the updated filter after removal
this.emitUpdatedFilter();
},
isMultiple(filter) {
return filter.field_type.includes('list');
},
setDefaultFilters(filter) {
const defaultFilter = filter.default_filter_enabled ? filter.default_filter_value : null;
if (defaultFilter) {
// make an array from the string in case of a list
const filtersArray = defaultFilter.split(',');
// for each value update the filter
filtersArray.forEach(defaultValue => {
const defaultOption = filter.options.find(option => option.value === defaultValue);
if (defaultOption) {
this.updateAttributeFilter({ ...defaultOption, noUpdate: true });
}
});
}
},
}
};
</script>
<style lang="less" scoped>
.transition-properties(...) {
-webkit-transition: @arguments;
-moz-transition: @arguments;
-o-transition: @arguments;
transition: @arguments;
}
#filters-container {
width: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
.accordion {
width: fit-content;
.collapsible-filters {
font-size: 1.25em;
padding-right: 0;
.customcaret{
transition: transform .2s ease;
&.collapsed {
transform: rotate(180deg);
}
&::before{
position: relative;
right: 0;
top: 65%;
color: #999;
margin-top: 4px;
border-color: #999 transparent transparent;
border-style: solid;
border-width: 5px 5px 0;
content: "";
}
}
}
}
.filters {
width: 100%;
height:auto;
max-height:100vh;
opacity: 1;
z-index: 1001;
.transition-properties(all 0.2s ease;);
.filter-row {
border: none;
box-shadow: none;
}
.item {
display: flex;
flex-direction: column;
align-items: flex-start !important;
padding: 0.5em;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
label {
margin-bottom: 0.2em;
font-size: 0.9em;
font-weight: 600;
}
}
.item {
width: 25%;
}
.item::before {
width: 0;
}
#search-projects {
width: 100%;
}
}
.filters.hidden {
overflow: hidden;
opacity: 0;
max-height: 0;
}
}
@media screen and (min-width: 701px) {
.item {
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
}
@media screen and (max-width: 700px) {
#filters-container {
.filter-row {
display: flex;
flex-direction: column;
max-height: 275px;
.transition-properties(all 0.2s ease-out;);
.item {
width: 100%;
padding-right: 0;
padding-left: 0;
}
}
}
}
</style>
<template>
<div id="search-projects">
<input
type="text"
placeholder="Rechercher un projet ..."
@input="searchProjects"
>
</div>
</template>
<script>
import { debounce } from 'lodash';
import { mapActions, mapMutations } from 'vuex';
export default {
name: 'SearchProjects',
methods: {
...mapMutations('projects', [
'SET_CURRENT_PAGE'
]),
...mapActions('projects', [
'SEARCH_PROJECTS'
]),
searchProjects:
debounce(function(e) {
this.$emit('loading', true);
this.SET_CURRENT_PAGE(1);
this.SEARCH_PROJECTS({ text: e.target.value })
.then(() => {
this.$emit('loading', false);
})
.catch((err) => {
if (err.message) {
this.$emit('loading', false);
}
});
}, 100)
}
};
</script>
<style lang="less" scoped>
#search-projects {
height: 100%;
min-height: 40px;
display: flex;
flex-direction: column;
justify-content: flex-end;
font-size: 1rem;
input {
display: block;
width: 100%;
height: 100%;
text-align: left;
color: #35495e;
padding: 8px 40px 8px 8px;
border: 1px solid #ced4da;
font-size: 1rem;
font-family: var(--font-family, 'Roboto Condensed', 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif);
}
input:focus {
outline: none !important;
box-shadow: 0 0 1px grey;
}
}
</style>
<template>
<div>
<Multiselect
v-model="selection"
:options="options"
:options-limit="10"
:allow-empty="true"
track-by="feature_id"
label="title"
:reset-after="false"
select-label=""
selected-label=""
deselect-label=""
:searchable="true"
:placeholder="placeholder"
:show-no-results="true"
:loading="loading"
:clear-on-select="false"
:preserve-search="true"
@search-change="search"
@select="select"
@close="close"
>
<template slot="clear">
<div
v-if="selection"
class="multiselect__clear"
@click.prevent.stop="selection = null"
>
<i
class="close icon"
aria-hidden="true"
/>
</div>
</template>
<span slot="noResult">
Aucun résultat.
</span>
<span slot="noOptions">
Saisissez les premiers caractères ...
</span>
</Multiselect>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions } from 'vuex';
import Multiselect from 'vue-multiselect';
export default {
name: 'SearchFeature',
components: {
Multiselect
},
props: {
currentSelection : {
type: Object,
default: null,
}
},
data() {
return {
loading: false,
selection: null,
text: null,
results: [],
placeholder: 'Rechercher un signalement ...'
};
},
computed: {
...mapState('feature', [
'features'
]),
options() {
return this.results.map((el) => {
return {
featureId: el.id,
title: el.properties.title
};
});
},
},
watch: {
text: function(newValue) {
this.loading = true;
this.GET_PROJECT_FEATURES({
project_slug: this.$route.params.slug,
feature_type__slug: this.$route.params.slug_type_signal,
search: newValue,
limit: '10'
})
.then(() => {
if (newValue) { // filter out current feature
this.results = this.features.filter((el) => el.id !== this.$route.params.slug_signal);
} else {
this.results.splice(0);
}
this.loading = false;
})
.catch((err) => {
console.error(err);
this.loading = false;
});
}
},
created() {
this.RESET_CANCELLABLE_SEARCH_REQUEST();
},
mounted() {
if (this.currentSelection && this.currentSelection.feature_to) {
this.placeholder = this.currentSelection.feature_to.title;
}
},
methods: {
...mapMutations(['RESET_CANCELLABLE_SEARCH_REQUEST']),
...mapActions('feature', [
'GET_PROJECT_FEATURES'
]),
search(text) {
this.text = text;
},
select(e) {
this.$emit('select', e.featureId);
},
close() { // close calls as well selectFeatureTo, in case user didn't select a value
this.$emit('close', this.selection && this.selection.featureId ?
this.selection.featureId : this.selection);
}
}
};
</script>
<style>
.multiselect input {
line-height: 1em !important;
padding: 0 !important;
}
.multiselect__placeholder {
margin: 0;
padding: 0;
}
.multiselect__input {
min-height: auto !important;
line-height: 1em !important;
}
</style>
<template>
<div>
<div class="ui teal segment">
<h4>
Pièce jointe
<button
@click="removeAttachmentFormset(form.dataKey)"
class="ui small compact right floated icon button remove-formset"
type="button"
>
<i class="ui times icon"></i>
</button>
</h4>
<!-- {{ form.errors }} -->
<div class="visible-fields">
<div class="two fields">
<div class="required field">
<label :for="form.title.id_for_label">{{ form.title.label }}</label>
<input
type="text"
required
:maxlength="form.title.field.max_length"
:name="form.title.html_name"
:id="form.title.id_for_label"
v-model="form.title.value"
@change="updateStore"
/>
<ul :id="form.title.id_for_error" class="errorlist">
<li v-for="error in form.title.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label>Fichier (PDF, PNG, JPEG)</label>
<label
class="ui icon button"
:for="'attachment_file' + attachmentForm.dataKey"
>
<i class="file icon"></i>
<span v-if="form.attachment_file.value" class="label">{{
form.attachment_file.value
}}</span>
<span v-else class="label">Sélectionner un fichier ... </span>
</label>
<input
@change="onFileChange"
type="file"
style="display: none"
:name="form.attachment_file.html_name"
:id="'attachment_file' + attachmentForm.dataKey"
/>
<ul :id="form.attachment_file.id_for_error" class="errorlist">
<li v-for="error in form.attachment_file.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
</div>
<div class="field">
<label for="form.info.id_for_label">{{ form.info.label }}</label>
<textarea
name="form.info.html_name"
rows="5"
v-model="form.info.value"
@change="updateStore"
></textarea>
<!-- {{ form.info.errors }} -->
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "FeatureAttachmentFormset",
props: ["attachmentForm"],
data() {
return {
fileToImport: null,
form: {
title: {
errors: [],
id_for_error: `errorlist-title-${this.attachmentForm.dataKey}`,
id_for_label: "titre",
field: {
max_length: 30, // todo : vérifier dans django
},
html_name: "titre",
label: "Titre",
value: "",
},
attachment_file: {
errors: [],
id_for_error: `errorlist-file-${this.attachmentForm.dataKey}`,
html_name: "titre",
label: "Titre",
value: null,
},
info: {
value: "",
errors: null,
label: "Info",
},
},
};
},
watch: {
attachmentForm(newValue) {
this.initForm(newValue);
},
},
methods: {
initForm(attachmentForm) {
for (let el in attachmentForm) {
if (el && this.form[el]) {
if (el === "attachment_file" && attachmentForm[el]) {
this.form[el].value = attachmentForm[el].split("/").pop(); //* keep only the file name, not the path
} else {
this.form[el].value = attachmentForm[el];
}
}
}
},
removeAttachmentFormset() {
this.$store.commit(
"feature/REMOVE_ATTACHMENT_FORM",
this.attachmentForm.dataKey
);
},
updateStore() {
this.$store.commit("feature/UPDATE_ATTACHMENT_FORM", {
dataKey: this.attachmentForm.dataKey,
title: this.form.title.value,
attachment_file: this.form.attachment_file.value,
info: this.form.info.value,
fileToImport: this.fileToImport,
});
},
onFileChange(e) {
const files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
this.fileToImport = files[0]; //* store file to import
this.form.attachment_file.value = files[0].name; //* add name to the form for display, in order to match format return from API
this.updateStore();
},
checkForm() {
let isValid = true;
if (this.form.title.value === "") {
this.form.title.errors = ["Veuillez compléter ce champ."];
document
.getElementById(this.form.title.id_for_error)
.scrollIntoView({ block: "start", inline: "nearest" });
isValid = false;
} else if (this.form.attachment_file.value === null) {
this.form.attachment_file.errors = ["Veuillez compléter ce champ."];
this.form.title.errors = [];
document
.getElementById(this.form.attachment_file.id_for_error)
.scrollIntoView({ block: "start", inline: "nearest" });
isValid = false;
} else {
this.form.title.errors = [];
this.form.attachment_file.errors = [];
}
return isValid;
},
},
mounted() {
this.initForm(this.attachmentForm);
},
};
</script>
\ No newline at end of file
<template>
<div v-frag v-if="field.field_type === 'char'">
<label for="field.name">{{ field.label }}</label>
<input
type="text"
:name="field.name"
:id="field.name"
v-model="field.value"
@blur="updateStore_extra_form"
/>
</div>
<div v-frag v-else-if="field.field_type === 'list'">
<label for="field.name">{{ field.label }}</label>
<Dropdown
:options="field.options"
:selected="selected_extra_form_list"
:selection.sync="selected_extra_form_list"
/>
</div>
<div v-frag v-else-if="field.field_type === 'integer'">
<label for="field.name">{{ field.label }}</label>
<div class="ui input">
<!-- //* si click sur fléche dans champ input, pas de focus, donc pas de blur, donc utilisation de @change -->
<input
type="number"
:name="field.name"
:id="field.name"
v-model.number="field.value"
@change="updateStore_extra_form"
/>
</div>
</div>
<div v-frag v-else-if="field.field_type === 'boolean'">
<div class="ui checkbox">
<input
type="checkbox"
:checked="field.value"
:name="field.name"
:id="field.name"
@change="updateStore_extra_form"
/>
<label for="field.name">{{ field.label }}</label>
</div>
</div>
<div v-frag v-else-if="field.field_type === 'date'">
<label for="field.name">{{ field.label }}</label>
<input
type="date"
:name="field.name"
:id="field.name"
v-model="field.value"
@blur="updateStore_extra_form"
/>
</div>
<div v-frag v-else-if="field.field_type === 'decimal'">
<label for="field.name">{{ field.label }}</label>
<div class="ui input">
<input
type="number"
step=".01"
:name="field.name"
:id="field.name"
v-model.number="field.value"
@change="updateStore_extra_form"
/>
</div>
</div>
<div v-frag v-else-if="field.field_type === 'text'">
<label :for="field.name">{{ field.label }}</label>
<textarea
:name="field.name"
rows="3"
v-model="field.value"
@blur="updateStore_extra_form"
></textarea>
</div>
</template>
<script>
import frag from "vue-frag";
import Dropdown from "@/components/Dropdown.vue";
export default {
name: "FeatureExtraForm",
directives: {
frag,
},
components: {
Dropdown,
},
props: ["field"],
computed: {
selected_extra_form_list: {
get() {
return this.field.value || "";
},
set(newValue) {
//* set the value selected in the dropdown
let newExtraForm = this.field;
newExtraForm["value"] = newValue;
this.$store.commit("feature/UPDATE_EXTRA_FORM", newExtraForm);
},
},
},
methods: {
updateStore_extra_form(evt) {
let newExtraForm = this.field;
newExtraForm["value"] = evt.target.checked || evt.target.value; //* if checkbox use "check", if undefined, use "value"
this.$store.commit("feature/UPDATE_EXTRA_FORM", newExtraForm);
},
},
};
</script>
\ No newline at end of file
<template>
<div class="ui teal segment pers-field">
<h4>
Champ personnalisé
<button
@click="removeCustomForm()"
class="ui small compact right floated icon button remove-field"
type="button"
>
<i class="ui times icon"></i>
</button>
</h4>
<div class="visible-fields">
<div class="two fields">
<div class="required field">
<label :for="form.label.id_for_label">{{ form.label.label }}</label>
<input
type="text"
required
:maxlength="form.label.field.max_length"
:name="form.label.html_name"
:id="form.label.id_for_label"
v-model="form.label.value"
@blur="updateStore"
/>
<small>{{ form.label.help_text }}</small>
<ul id="errorlist" class="errorlist">
<li v-for="error in form.label.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label :for="form.name.id_for_label">{{ form.name.label }}</label>
<input
type="text"
required
:maxlength="form.name.field.max_length"
:name="form.name.html_name"
:id="form.name.id_for_label"
v-model="form.name.value"
@blur="updateStore"
/>
<small>{{ form.name.help_text }}</small>
<ul id="errorlist" class="errorlist">
<li v-for="error in form.name.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
</div>
<div class="three fields">
<div class="required field">
<label :for="form.position.id_for_label">{{
form.position.label
}}</label>
<div class="ui input">
<input
type="number"
:min="form.position.field.min_value"
:name="form.position.html_name"
:id="form.position.id_for_label"
v-model="form.position.value"
@change="updateStore"
/>
</div>
<small>{{ form.position.help_text }}</small>
<ul id="errorlist" class="errorlist">
<li v-for="error in form.position.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label :for="form.field_type.id_for_label">{{
form.field_type.label
}}</label>
<Dropdown
:disabled="!form.label.value || !form.name.value"
:options="fieldTypeChoices"
:selected="selectedFieldType"
:selection.sync="selectedFieldType"
/>
<ul id="errorlist" class="errorlist">
<li v-for="error in form.field_type.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
<div
v-if="selectedFieldType === 'Liste de valeurs'"
class="field field-list-options required field"
>
<label :for="form.options.id_for_label">{{
form.options.label
}}</label>
<input
type="text"
:maxlength="form.options.field.max_length"
:name="form.options.html_name"
:id="form.options.id_for_label"
v-model="arrayOption"
class="options-field"
/>
<small>{{ form.help_text }}</small>
<ul id="errorlist" class="errorlist">
<li v-for="error in form.options.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
import Dropdown from "@/components/Dropdown.vue";
export default {
name: "FeatureTypeCustomForm",
components: {
Dropdown,
},
props: ["customForm"],
data() {
return {
fieldTypeChoices: [
{ name: "Booléen", value: "boolean" },
{ name: "Chaîne de caractères", value: "char" },
{ name: "Date", value: "date" },
{ name: "Liste de valeurs", value: "list" },
{ name: "Nombre entier", value: "integer" },
{ name: "Nombre décimal", value: "decimal" },
{ name: "Texte multiligne", value: "text" },
],
form: {
dataKey: 0,
label: {
errors: [],
id_for_label: "label",
label: "Label",
help_text: "Nom en language naturel du champ",
html_name: "label",
field: {
max_length: 128,
},
value: null,
},
name: {
errors: [],
id_for_label: "name",
label: "Nom",
html_name: "name",
help_text:
"Nom technique du champ tel qu'il apparaît dans la base de données ou dans l'export GeoJSON. Seuls les caractères alphanumériques et les traits d'union sont autorisés: a-z, A-Z, 0-9, _ et -)",
field: {
max_length: 128,
},
value: null,
},
position: {
errors: [],
id_for_label: "position",
label: "Position",
min_value: 0, // ! check if good values (not found)
html_name: "position",
help_text:
"Numéro d'ordre du champ dans le formulaire de saisie du signalement",
field: {
max_length: 128, // ! check if good values (not found)
},
value: this.customForm.dataKey - 1,
},
field_type: {
errors: [],
id_for_label: "field_type",
label: "Type de champ",
html_name: "field_type",
help_text: "",
field: {
max_length: 50,
},
value: "boolean",
},
options: {
errors: [],
id_for_label: "options",
label: "Options",
html_name: "options",
help_text: "",
field: {
max_length: 256,
},
value: [],
},
},
};
},
computed: {
selectedFieldType: {
// getter
get() {
const currentFieldType = this.fieldTypeChoices.find(
(el) => el.value === this.form.field_type.value
);
if (currentFieldType) {
return currentFieldType.name;
}
return null;
},
// setter
set(newValue) {
this.form.field_type.value = newValue.value;
this.form = { ...this.form }; // ! quick & dirty fix for getter not updating because of Vue caveat https://vuejs.org/v2/guide/reactivity.html#For-Objects
// Vue.set(this.form.field_type, "value", newValue.value); // ? vue.set didn't work, maybe should flatten form ?
this.updateStore();
},
},
arrayOption: {
get() {
return this.form.options.value.join();
},
// * create an array, because backend expects an array
set(newValue) {
this.form.options.value = this.trimWhiteSpace(newValue).split(",");
this.updateStore();
},
},
},
methods: {
fillCustomFormData(customFormData) {
for (let el in customFormData) {
if (el && this.form[el] && customFormData[el]) {
//* check if is an object, because data from api is a string, while import from django is an object
this.form[el].value = customFormData[el].value
? customFormData[el].value
: customFormData[el];
}
}
this.updateStore();
},
removeCustomForm() {
this.$store.commit(
"feature_type/REMOVE_CUSTOM_FORM",
this.customForm.dataKey
);
},
updateStore() {
const data = {
dataKey: this.customForm.dataKey,
label: this.form.label.value,
name: this.form.name.value,
position: this.form.position.value,
field_type: this.form.field_type.value,
options: this.form.options.value,
};
this.$store.commit("feature_type/UPDATE_CUSTOM_FORM", data);
},
trimWhiteSpace(string) {
// TODO : supprimer les espaces pour chaque option au début et à la fin QUE à la validation
return string.replace(/\s*,\s*/gi, ",");
},
checkCustomForm() {
if (this.form.label.value === null) {
this.form.label.errors = ["Veuillez compléter ce champ."];
return false;
} else if (this.form.name.value === null) {
this.form.name.errors = ["Veuillez compléter ce champ."];
this.form.label.errors = [];
return false;
}
this.form.label.errors = [];
this.form.name.errors = [];
return true;
},
},
beforeDestroy() {
this.$store.commit("feature_type/EMPTY_CUSTOM_FORMS");
},
mounted() {
//* add datas from store to state to avoid mutating directly store with v-model (not good practice), could have used computed with getter and setter as well
this.fillCustomFormData(this.customForm);
},
};
</script>
<template>
<div :class="['sidebar-container', { expanded }]">
<!-- <div class="sidebar-layers"></div> -->
<div @click="expanded = !expanded" class="layers-icon">
<!-- // ! svg point d'interrogation pas accepté par linter -->
<!-- <?xml version="1.0" encoding="iso-8859-1"?> -->
<svg
version="1.1"
id="Capa_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 491.203 491.203"
style="enable-background: new 0 0 491.203 491.203"
xml:space="preserve"
>
<g>
<g>
<path
d="M487.298,326.733l-62.304-37.128l62.304-37.128c2.421-1.443,3.904-4.054,3.904-6.872s-1.483-5.429-3.904-6.872
l-62.304-37.128l62.304-37.128c3.795-2.262,5.038-7.172,2.776-10.968c-0.68-1.142-1.635-2.096-2.776-2.776l-237.6-141.6
c-2.524-1.504-5.669-1.504-8.192,0l-237.6,141.6c-3.795,2.262-5.038,7.172-2.776,10.968c0.68,1.142,1.635,2.096,2.776,2.776
l62.304,37.128L3.905,238.733c-3.795,2.262-5.038,7.172-2.776,10.968c0.68,1.142,1.635,2.096,2.776,2.776l62.304,37.128
L3.905,326.733c-3.795,2.262-5.038,7.172-2.776,10.968c0.68,1.142,1.635,2.096,2.776,2.776l237.6,141.6
c2.526,1.494,5.666,1.494,8.192,0l237.6-141.6c3.795-2.262,5.038-7.172,2.776-10.968
C489.393,328.368,488.439,327.414,487.298,326.733z M23.625,157.605L245.601,25.317l221.976,132.288L245.601,289.893
L23.625,157.605z M23.625,245.605l58.208-34.68l159.672,95.2c2.524,1.504,5.668,1.504,8.192,0l159.672-95.2l58.208,34.68
L245.601,377.893L23.625,245.605z M245.601,465.893L23.625,333.605l58.208-34.68l159.672,95.2c2.524,1.504,5.668,1.504,8.192,0
l159.672-95.2l58.208,34.68L245.601,465.893z"
/>
</g>
</g>
</svg>
</div>
<div class="basemaps-title">
<h4>
Fonds cartographiques
<!-- <span data-tooltip="Il est possible pour chaque fond cartographique de modifier l'ordre des couches"
data-position="bottom left">
<i class="question circle outline icon"></i>
</span> -->
</h4>
</div>
<div
v-for="basemap in baseMaps"
:key="`list-${basemap.id}`"
class="basemaps-items ui accordion styled"
>
<div
:class="{ active: isActive(basemap) }"
class="basemap-item title"
@click="activateGroup(basemap)"
>
{{ basemap.title }}
</div>
<div :id="`queryable-layers-selector-${basemap.id}`" v-if="isQueryable(basemap)">
<b>Couche requêtable</b>
<Dropdown
@update:selection="onQueryLayerChange($event)"
:options="getQueryableLayers(basemap)"
:selected="selectedQueryLayer"
:search="true"
/>
</div>
<div
:class="{ active: isActive(basemap) }"
class="content"
:id="`list-${basemap.id}`"
:data-basemap-index="basemap.id"
>
<div
v-for="(layer, index) in basemap.layers"
:key="basemap.id + '-' + layer.id + '-' + index"
class="layer-item transition visible item list-group-item" :data-id="layer.id"
>
<p class="layer-handle-sort">
<i class="th icon"></i>{{ layer.title }}
</p>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<!-- // todo : rendre réactif les valeurs et connectés avec store/Map -->
<input
@change="updateOpacity($event, layer)"
type="range"
min="0"
max="1"
:value="layer.opacity"
step="0.01"
/><output class="range-output-bubble">{{
getOpacity(layer.opacity)
}}</output>
</div>
<div class="ui divider"></div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapUtil } from "@/assets/js/map-util.js";
import Dropdown from "@/components/Dropdown.vue";
import Sortable from 'sortablejs';
export default {
name: "SidebarLayers",
components: {
Dropdown,
},
data() {
return {
selectedQueryLayer:null,
activeBasemap:null,
baseMaps: [],
layers: [],
expanded: false,
};
},
methods: {
isActive(basemap) {
return basemap.active != undefined && basemap.active;
},
activateGroup(basemap) {
this.baseMaps.forEach((basemap) => (basemap.active = false));
basemap.active = true;
this.activeBasemap=basemap;
basemap.title += " "; //weird!! Force refresh
this.addLayers(basemap);
let mapOptions = localStorage.getItem('geocontrib-map-options') || {};
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
let project=this.$route.params.slug;
mapOptions[project] = {
...mapOptions[project],
'current-basemap-index': basemap.id
};
localStorage.setItem('geocontrib-map-options', JSON.stringify(mapOptions));
},
updateOpacity(event, layer) {
console.log(event.target.value, layer);
mapUtil.updateOpacity(layer.id, event.target.value);
layer.opacity = event.target.value;
},
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
onQueryLayerChange(layer){
console.log(layer);
this.selectedQueryLayer=layer.name;
},
isQueryable(baseMap){
let queryableLayer=baseMap.layers.filter(l => l.queryable === true);
return queryableLayer.length>0;
},
onlayerMove(event){
console.log(event)
// Get the names of the current layers in order.
const currentLayersNamesInOrder = Array.from(document.getElementsByClassName('layer-item transition visible')).map(el => el.children[0].innerText);
// Create an array to put the layers in order.
let movedLayers = [];
for (const layerName of currentLayersNamesInOrder) {
movedLayers.push(this.activeBasemap.layers.filter(el => el.title === layerName)[0]);
}
// Remove existing layers undefined
movedLayers = movedLayers.filter(function(x) {
return x !== undefined;
});
const eventOrder = new CustomEvent('change-layers-order', {
detail: {
layers: movedLayers
}
})
document.dispatchEvent(eventOrder);
// Save the basemaps options into the localstorage
console.log(this.baseMaps)
this.setLocalstorageMapOptions(this.baseMaps)
},
setLocalstorageMapOptions(basemaps) {
let mapOptions = localStorage.getItem('geocontrib-map-options') || {};
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
let project=this.$route.params.slug;
mapOptions[project] = {
...mapOptions[project],
'basemaps': basemaps
};
localStorage.setItem('geocontrib-map-options', JSON.stringify(mapOptions));
},
initSortable(){
this.baseMaps.forEach((basemap) => {
new Sortable(document.getElementById(`list-${basemap.id}`), {
animation: 150,
handle: '.layer-handle-sort', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: this.onlayerMove.bind(this)
});
});
},
// Check if there are changes in the basemaps settings. Changes are detected if:
// - one basemap has been added or deleted
// - one layer has been added or deleted to a basemap
areChangesInBasemaps(basemapFromServer, basemapFromLocalstorage={}) {
let isSameBasemaps = false;
let isSameLayers = true;
let isSameTitles = true;
// Compare the length and the id values of the basemaps
const idBasemapsServer = basemapFromServer.map(b => b.id).sort();
const idBasemapsLocalstorage = basemapFromLocalstorage.length ? basemapFromLocalstorage.map(b => b.id).sort() : {};
isSameBasemaps = (idBasemapsServer.length === idBasemapsLocalstorage.length
&& idBasemapsServer.every((value, index) => value === idBasemapsLocalstorage[index]))
// For each basemap, compare the length and id values of the layers
outer_block: {
for(let basemapServer of basemapFromServer) {
let idLayersServer = basemapServer.layers.map(b => b.id).sort();
if (basemapFromLocalstorage.length){
for (let basemapLocalstorage of basemapFromLocalstorage) {
if (basemapServer.id === basemapLocalstorage.id) {
let idLayersLocalstorage = basemapLocalstorage.layers.map(b => b.id).sort();
isSameLayers = (idLayersServer.length === idLayersLocalstorage.length
&& idLayersServer.every((value, index) => value === idLayersLocalstorage[index]));
if (!isSameLayers) {
break outer_block;
}
}
}
}
}
// Compare basemaps titles
const titlesBasemapsServer = basemapFromServer.map(b => b.title).sort();
const titlesBasemapsLocalstorage = basemapFromLocalstorage.length ? basemapFromLocalstorage.map(b => b.title).sort() : {};
isSameTitles = titlesBasemapsServer.every((title, index) => title === titlesBasemapsLocalstorage[index]);
if (!isSameTitles) {
break outer_block;
}
}
return !(isSameBasemaps && isSameLayers && isSameTitles);
},
getQueryableLayers(baseMap){
let queryableLayer=baseMap.layers.filter(l => l.queryable === true);
return queryableLayer.map(x=>{
return {
name:x.title,
value:x
}
});
},
addLayers(baseMap) {
baseMap.layers.forEach((layer) => {
var layerOptions = this.layers.find((l) => l.id == layer.id);
console.log(layerOptions);
layer = Object.assign(layer, layerOptions);
layer.options.basemapId = baseMap.id;
});
mapUtil.removeLayers(mapUtil.getMap());
// 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.addLayers(baseMap.layers.slice().reverse(), null, null);
},
},
mounted() {
this.baseMaps = this.$store.state.map.basemaps;
let project=this.$route.params.slug;
const mapOptions = JSON.parse(localStorage.getItem('geocontrib-map-options')) || {};
if (mapOptions
&& mapOptions[project]) {
// If already in the storage, we need to check if the admin did some
// modification in the basemaps on the server side. The rule is: if one layer has been added
// or deleted in the server, then we reset the localstorage.
const baseMapsFromLocalstorage = mapOptions[project]['basemaps'];
const areChanges = this.areChangesInBasemaps(this.baseMaps, baseMapsFromLocalstorage);
if (areChanges) {
mapOptions[project] = {
'map-options': this.baseMaps,
'current-basemap-index': 0,
};
localStorage.setItem('geocontrib-map-options', JSON.stringify(mapOptions));
} else {
this.baseMaps = baseMapsFromLocalstorage;
}
}
this.layers = this.$store.state.map.layers;
if (this.baseMaps.length > 0) {
this.baseMaps[0].active = true;
this.activeBasemap=this.baseMaps[0];
this.addLayers(this.baseMaps[0]);
} else {
mapUtil.addLayers(
null,
this.$store.state.configuration.DEFAULT_BASE_MAP.SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP.OPTIONS
);
}
setTimeout(this.initSortable.bind(this),1000)
},
};
</script>
<style>
@import "../../assets/styles/sidebar-layers.css";
.queryable-layers-dropdown {
margin-bottom: 1em;
}
.queryable-layers-dropdown > label {
font-weight: bold;
}
</style>
\ No newline at end of file
const axios = require("axios")
import Vue from 'vue'
// Importing necessary libraries and components
const axios = require('axios'); // Axios for HTTP requests
import Vue from 'vue'; // Vue.js framework
import App from './App.vue'
import './registerServiceWorker'
import router from './router'
import store from './store'
import 'leaflet/dist/leaflet.css';
import 'leaflet-draw/dist/leaflet.draw.css';
import '@/assets/resources/leaflet-control-geocoder-1.13.0/Control.Geocoder.css';
Vue.config.productionTip = false
import App from './App.vue'; // Main Vue component
import './registerServiceWorker'; // Service worker registration
import router from '@/router'; // Application router
import store from '@/store'; // Vuex store for state management
import * as Sentry from '@sentry/vue';
axios.get("/config/config.json")
.then((response) => {
if (response && response.status === 200) {
store.commit("SET_CONFIG", response.data);
window.proxy_url=response.data.VUE_APP_DJANGO_API_BASE+"proxy/"
axios.all([store.dispatch("USER_INFO"),
store.dispatch("GET_ALL_PROJECTS"),
store.dispatch("GET_STATIC_PAGES"),
store.dispatch("GET_USER_LEVEL_PROJECTS"),
store.dispatch("map/GET_LAYERS"),
store.dispatch("GET_USER_LEVEL_PERMISSIONS"),
]).then(axios.spread(function () {
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
}))
// Importing CSS for styling
import './assets/styles/base.css'; // Base styles
import './assets/resources/semantic-ui-2.4.2/semantic.min.css'; // Semantic UI for UI components
import '@fortawesome/fontawesome-free/css/all.css'; // Font Awesome for icons
import '@fortawesome/fontawesome-free/js/all.js'; // Font Awesome JS
import 'ol/ol.css'; // OpenLayers CSS for maps
import '@/assets/styles/openlayers-custom.css'; // Custom styles for OpenLayers
import '@/assets/styles/sidebar-layers.css'; // Styles for sidebar layers
// Font Awesome library setup
import { library } from '@fortawesome/fontawesome-svg-core';
import { fas } from '@fortawesome/free-solid-svg-icons'; // Importing solid icons
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; // Font Awesome component
// Vue Multiselect CSS
import 'vue-multiselect/dist/vue-multiselect.min.css'; // Multiselect component styles
Sentry.init({
Vue,
dsn: 'https://de982b53ff2a58de08749f46c3f7f830@sentry.neogeo.fr/32',
integrations: [
Sentry.browserTracingIntegration({ router }),
Sentry.replayIntegration(),
],
// Tracing
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: ['localhost', /^https:\/\/yourserver\.io\/api/],
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
beforeSend(event) {
console.log('Event about to be sent to Sentry:', event);
return event;
}
});
// Adding Font Awesome icons to the library
library.add(fas);
// Registering Font Awesome as a Vue component for use in templates
Vue.component('FontAwesomeIcon', FontAwesomeIcon);
// Setting Vue's production tip configuration
Vue.config.productionTip = false;
Vue.config.ignoredElements = ['geor-header'];
// Handling service worker updates and precaching
var refreshing = false; // Flag to prevent multiple refreshes
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
// Check if the page is already refreshing to prevent duplicate refreshes
if (refreshing) {
return;
}
refreshing = true;
// Reload the page to activate the new service worker
window.location.reload();
});
}
/**
* Dynamically loads a font from Google Fonts and sets CSS variables.
* @param {string} fontNames - A comma-separated list of font names, where the first font is the one to be imported and others are fallbacks.
* @param {string} headerColor - The color to be used for headers.
* @param {string} primaryColor - The primary color for the application.
* @param {string} primaryHighlightColor - The primary color to highlight elements in the application.
*/
const setAppTheme = (fontNames, headerColor, primaryColor, primaryHighlightColor) => {
// Set CSS variables for header and primary color.
if (headerColor) {
document.documentElement.style.setProperty('--header-color', headerColor);
}
if (primaryColor) {
document.documentElement.style.setProperty('--primary-color', primaryColor);
}
if (primaryHighlightColor) {
document.documentElement.style.setProperty('--primary-highlight-color', primaryHighlightColor);
}
// Proceed to load the font if fontNames is provided.
if (fontNames) {
const fontNameToImport = fontNames.split(',')[0].trim();
const link = document.createElement('link');
link.href = `https://fonts.googleapis.com/css?family=${fontNameToImport.replace(/ /g, '+')}:400,700&display=swap`;
link.rel = 'stylesheet';
document.head.appendChild(link);
// Set the CSS variable for font family.
document.documentElement.style.setProperty('--font-family', fontNames);
}
};
/**
* Sets the favicon of the application.
* @param {string} favicoUrl - The URL of the favicon to be set.
*/
const setFavicon = (favicoUrl) => {
const link = document.createElement('link');
link.id = 'dynamic-favicon';
link.rel = 'shortcut icon';
link.href = favicoUrl;
document.head.appendChild(link);
};
/**
* Regularly updates the online status of the application.
*/
const updateOnlineStatus = () => {
setInterval(() => {
store.commit('SET_IS_ONLINE', navigator.onLine);
}, 2000);
};
/**
* Regularly updates the user status if using external auth to keep the frontend updated with backend.
*/
function handleLogout() {
if (store.state.user) {
store.commit('SET_USER', false);
store.commit('SET_USER_PERMISSIONS', null);
store.commit('SET_USER_LEVEL_PROJECTS', null);
store.commit('DISPLAY_MESSAGE', {
level: 'negative',
comment: `Vous avez été déconnecté du service d'authentification.
Reconnectez-vous ou continuez en mode anonyme.`
});
store.dispatch('projects/GET_PROJECTS');
store.dispatch('GET_USER_LEVEL_PERMISSIONS');
store.dispatch('GET_USER_LEVEL_PROJECTS');
}
}
const updateUserStatus = () => {
setInterval(() => {
if (navigator.onLine) {
axios
.get(`${store.state.configuration.VUE_APP_DJANGO_API_BASE}user_info/`)
.then((response) => {
const user = response.data?.user || null;
// Cas où l'utilisateur a changé
if (store.state.user?.username !== user.username) {
store.commit('SET_USER', user);
// Cas où l'utilisateur est bien authentifié
if (user) {
store.commit('DISPLAY_MESSAGE', {
level: 'positive',
comment: 'Bienvenue à nouveau ! Vous êtes reconnecté au service d\'authentification'
});
store.dispatch('projects/GET_PROJECTS');
store.dispatch('GET_USER_LEVEL_PERMISSIONS');
store.dispatch('GET_USER_LEVEL_PROJECTS');
} else {
// On force la suppression de l'utilisateur au cas où le serveur SSO ne permet pas à la requête API d'aboutir (ex: redirection si non authentifié SSO)
handleLogout();
}
}
})
.catch((error) => {
throw error;
});
.catch(() => {
handleLogout();
});
}
}, 10000);
};
/**
* Fetches initial data for the application and initializes the Vue instance.
*/
const fetchDataAndInitializeApp = async () => {
await Promise.all([
store.dispatch('GET_USER_INFO'),
store.dispatch('GET_STATIC_PAGES'),
store.dispatch('map/GET_AVAILABLE_LAYERS'),
store.dispatch('GET_LEVELS_PERMISSIONS'),
store.dispatch('GET_PROJECT_ATTRIBUTES'),
]);
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app');
};
/**
* Initializes the application configuration.
* @param {object} config - Configuration object with application settings.
*/
const onConfigLoaded = async (config) => {
// Set application configuration in the store.
store.commit('SET_CONFIG', config);
// Update the online status at regular intervals.
updateOnlineStatus();
// Update the user status at regular intervals to check if backend session expired.
updateUserStatus();
// Set the document title and favicon from the configuration.
document.title = `${config.VUE_APP_APPLICATION_NAME} ${config.VUE_APP_APPLICATION_ABSTRACT}`;
setFavicon(config.VUE_APP_APPLICATION_FAVICO);
// Apply the application theme settings using values specified in the configuration.
setAppTheme(
config.VUE_APP_FONT_FAMILY,
config.VUE_APP_HEADER_COLOR,
config.VUE_APP_PRIMARY_COLOR,
config.VUE_APP_PRIMARY_HIGHLIGHT_COLOR
);
// Set a global proxy URL based on the configuration.
window.proxy_url = config.VUE_APP_DJANGO_API_BASE + 'proxy/';
// Fetch initial data and initialize the Vue application.
await fetchDataAndInitializeApp();
};
// Attempt to load the application configuration from an external JSON file.
axios.get('./config/config.json')
.catch((error) => {
// Log an error if the configuration file cannot be loaded.
console.error(error);
console.log('Attempting to get config from Localstorage');
// Attempt to retrieve the configuration from local storage as a fallback.
const conf = localStorage.getItem('geontrib_conf');
if (conf) {
// If a configuration is found in local storage, parse it and load the config.
onConfigLoaded(JSON.parse(conf));
}
})
.then((response) => {
// Check if the response is valid and the request was successful.
if (response && response.status === 200) {
// Store the retrieved configuration in local storage for future use.
localStorage.setItem('geontrib_conf', JSON.stringify(response.data));
// Load the configuration into the application.
onConfigLoaded(response.data);
}
})
.catch((error) => {
// Throw an error if there are issues processing the response.
throw error;
});