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 1865 additions and 941 deletions
......@@ -9,7 +9,7 @@
data-type="layer-field"
>
<label
for="form.layer.id_for_label"
:for="layer.title"
class="layer-handle-sort"
>
<i
......@@ -26,48 +26,43 @@
:placeholder="placeholder"
/>
</div>
<div class="fields">
<div class="six wide field">
<label for="opacity">Opacité</label>
<input
v-model.number="layerOpacity"
type="number"
oninput="validity.valid||(value='');"
step="0.01"
min="0"
max="1"
>
</div>
<div class="field">
<div
class="field three wide {% if form.opacity.errors %} error{% endif %}"
class="ui checkbox"
@click="updateLayer({ ...layer, queryable: !layer.queryable })"
>
<label for="opacity">Opacité</label>
<input
v-model.number="layerOpacity"
type="number"
oninput="validity.valid||(value='');"
step="0.01"
min="0"
max="1"
>
</div>
<div class="field three wide">
<div
class="ui checkbox"
@click="updateLayer({ ...layer, queryable: !layer.queryable })"
:checked="layer.queryable"
class="hidden"
type="checkbox"
name="queryable"
>
<input
:checked="layer.queryable"
class="hidden"
type="checkbox"
name="queryable"
>
<label for="queryable"> Requêtable</label>
</div>
<label for="queryable">&nbsp;Requêtable</label>
</div>
</div>
<div
class="field"
<button
type="button"
class="ui compact small icon floated button button-hover-red"
@click="removeLayer"
>
<div class="ui compact small icon floated button button-hover-red">
<i
class="ui grey trash alternate icon"
aria-hidden="true"
/>
<span>Supprimer cette couche</span>
</div>
</div>
<i
class="ui grey trash alternate icon"
aria-hidden="true"
/>
<span>&nbsp;Supprimer cette couche</span>
</button>
</div>
</div>
</template>
......
......@@ -32,65 +32,6 @@
<div class="feature-type-container">
<FeatureTypeLink :feature-type="type" />
<div class="middle aligned content">
<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
right
floated
button
button-hover-green
tiny-margin
"
data-tooltip="Ajouter un signalement"
data-position="top right"
data-variation="mini"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
</router-link>
<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
right
floated
button
button-hover-green
tiny-margin
"
data-tooltip="Dupliquer un type de signalement"
data-position="top right"
data-variation="mini"
>
<i
class="inverted grey copy alternate icon"
aria-hidden="true"
/>
</router-link>
<div
v-if="isImporting(type)"
class="import-message"
......@@ -101,96 +42,94 @@
/>
Import en cours
</div>
<div
v-else
>
<a
v-if="isProjectAdmin && isOnline"
class="
ui
compact
small
icon
right
floated
button
button-hover-red
tiny-margin
"
data-tooltip="Supprimer le type de signalement"
<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"
@click="toggleDeleteFeatureType(type)"
:data-test="`edit-feature-type-for-${type.title}`"
>
<i
class="inverted grey trash alternate icon"
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</a>
</router-link>
<router-link
v-if="
project &&
permissions &&
permissions.can_create_feature_type &&
isOnline
"
v-if="project && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'editer-symbologie-signalement',
name: 'editer-affichage-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button
button-hover-orange
tiny-margin
"
data-tooltip="Éditer la symbologie du type de signalement"
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>
<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
right
floated
button
button-hover-orange
tiny-margin
"
data-tooltip="Éditer le type de signalement"
<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 pencil alternate icon"
class="inverted grey trash alternate icon"
aria-hidden="true"
/>
</router-link>
</div>
</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>
......@@ -199,22 +138,13 @@
</div>
</div>
<div class="nouveau-type-signalement-container">
<div id="new-feature-type-container">
<div
class="
ui
small
button
circular
compact
floated
right
icon
teal
"
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"
......@@ -222,130 +152,117 @@
/>
</div>
<div id="nouveau-type-signalement">
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOnline
"
:to="{
name: 'ajouter-type-signalement',
params: { slug },
}"
class="ui compact basic button"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<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
</router-link>
</div>
</label>
</router-link>
<div class="nouveau-type-signalement">
<div
v-if="
permissions &&
permissions.can_update_project &&
isOnline
"
class="
ui
compact
basic
button
button-align-left
"
<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"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui"
for="json_file"
>
<span
class="label"
>Créer un nouveau type de signalement à partir d'un
GeoJSON</span>
</label>
<input
id="json_file"
type="file"
accept="application/json, .json, .geojson"
style="display: none"
name="json_file"
@change="onGeoJSONFileChange"
>
</div>
</div>
<div class="nouveau-type-signalement">
<div
v-if="
permissions &&
permissions.can_update_project &&
isOnline
"
class="
ui
compact
basic
button
button-align-left
"
<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"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui"
for="csv_file"
>
<span
class="label"
>
Créer un nouveau type de signalement à partir d'un CSV
</span>
</label>
<input
id="csv_file"
type="file"
accept="application/csv, .csv"
style="display: none"
name="csv_file"
@change="onCSVFileChange"
>
</div>
</div>
<div class="nouveau-type-signalement">
<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"
<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"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
Créer un nouveau type de signalement à partir du catalogue {{ CATALOG_NAME || 'IDGO' }}
</router-link>
</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
......@@ -355,6 +272,7 @@
<button
:disabled="geojsonFileToImport.size === 0"
class="ui fluid teal icon button"
data-test="start-geojson-file-import"
@click="toNewGeojsonFeatureType"
>
<i
......@@ -372,6 +290,7 @@
<button
:disabled="csvFileToImport.size === 0"
class="ui fluid teal icon button"
data-test="start-csv-file-import"
@click="toNewCsvFeatureType"
>
<i
......@@ -409,7 +328,7 @@
<div class="content">
<p>
Impossible de créer un type de signalement à partir d'un fichier
GeoJSON de plus de 10Mo (celui importé fait {{ geojsonFileSize > 0 ? geojsonFileSize : csvFileSize }} Mo).
de plus de 100Mo (celui importé fait {{ geojsonFileSize > 0 ? geojsonFileSize : csvFileSize }} Mo).
</p>
</div>
<div class="actions">
......@@ -430,7 +349,7 @@ import { csv } from 'csvtojson';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import { fileConvertSizeToMo } from '@/assets/js/utils';
import { fileConvertSizeToMo, determineDelimiter } from '@/assets/js/utils';
import FeatureTypeLink from '@/components/FeatureType/FeatureTypeLink';
export default {
......@@ -464,7 +383,8 @@ export default {
csvError: null,
geojsonFileToImport: { name: '', size: 0 },
csvFileToImport: { name: '', size: 0 },
fetchCallCounter: 0,
hadPending: false
};
},
......@@ -473,7 +393,6 @@ export default {
'configuration',
'isOnline',
'user_permissions',
'isOnline',
]),
...mapState('feature-type', [
'feature_types',
......@@ -499,59 +418,43 @@ export default {
csvFileSize() {
return fileConvertSizeToMo(this.csvFileToImport.size);
},
},
watch: {
feature_types: {
deep: true,
handler(newValue, oldValue) {
if (newValue && newValue !== oldValue) {
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
});
}
},
},
importFeatureTypeData: {
deep: true,
handler(newValue) {
if (
newValue &&
newValue.some((el) => el.status === 'pending') &&
!this.reloadIntervalId
) {
this.SET_RELOAD_INTERVAL_ID(
setInterval(() => {
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
});
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL)
);
} else if (
newValue &&
!newValue.some((el) => el.status === 'pending') &&
this.reloadIntervalId
) {
this.GET_PROJECT_FEATURE_TYPES(this.slug);
this.CLEAR_RELOAD_INTERVAL_ID();
}
},
},
mounted() {
this.fetchImports();
},
methods: {
...mapMutations([
'SET_RELOAD_INTERVAL_ID'
]),
...mapMutations('feature-type', [
'SET_FILE_TO_IMPORT'
]),
...mapActions('feature-type', [
'GET_IMPORTS',
'GET_PROJECT_FEATURE_TYPES'
]),
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(
......@@ -563,18 +466,30 @@ export default {
},
goToDocumentation() {
window.open('https://geocontrib.readthedocs.io/fr/latest/documentation_fonctionnelle/import_export/');
window.open(this.configuration.VUE_APP_URL_DOCUMENTATION);
},
toNewGeojsonFeatureType() {
this.importing = true;
this.$router.push({
name: 'ajouter-type-signalement',
params: {
geojson: this.geojsonImport,
fileToImport: this.geojsonFileToImport,
},
});
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;
},
......@@ -599,7 +514,7 @@ export default {
}
this.geojsonFileToImport = files[0];
// TODO : VALIDATION IF FILE IS JSON
if (parseFloat(fileConvertSizeToMo(this.geojsonFileToImport.size)) > 10) {
if (parseFloat(fileConvertSizeToMo(this.geojsonFileToImport.size)) > 100) {
this.isFileSizeModalOpen = true;
} else if (this.geojsonFileToImport.size > 0) {
const fr = new FileReader();
......@@ -627,7 +542,7 @@ export default {
return;
}
this.csvFileToImport = files[0];
if (parseFloat(fileConvertSizeToMo(this.csvFileToImport.size)) > 10) {
if (parseFloat(fileConvertSizeToMo(this.csvFileToImport.size)) > 100) {
this.isFileSizeModalOpen = true;
} else if (this.csvFileToImport.size > 0) {
const fr = new FileReader();
......@@ -635,50 +550,21 @@ export default {
fr.readAsText(this.csvFileToImport);
fr.onloadend = () => {
// Find csv delimiter
const commaDelimited = fr.result.split('\n')[0].includes(',');
const semicolonDelimited = fr.result.split('\n')[0].includes(';');
const delimiter = commaDelimited && !semicolonDelimited ? ',' : semicolonDelimited ? ';' : false;
if ((commaDelimited && semicolonDelimited) || !delimiter) {
const delimiter = determineDelimiter(fr.result);
if (!delimiter) {
this.csvError = `Le fichier ${this.csvFileToImport.name} n'est pas formaté correctement`;
this.featureTypeImporting = false;
return;
}
// Check if file contains 'lat' and 'long' fields
const headers = fr.result
.split('\n')[0]
.split(delimiter)
.map(el => {
return el.replace('\r', '');
this.csvError = null;
csv({ delimiter })
.fromString(fr.result)
.then((jsonObj)=>{
this.csvImport = jsonObj;
});
const headersCoord = headers.filter(el => {
return el === 'lat' || el === 'lon';
});
// Look for 2 decimal fields in first line of csv
// corresponding to lon and lat
const sampleLine =
fr.result
.split('\n')[1]
.split(delimiter)
.map(el => {
return !isNaN(el) && el.indexOf('.') !== -1;
})
.filter(Boolean);
if (sampleLine.length > 1 && headersCoord.length === 2) {
this.csvError = null;
csv()
.fromString(fr.result)
.then((jsonObj)=>{
this.csvImport = jsonObj;
});
this.featureTypeImporting = false;
//* stock filename to import features afterward
this.SET_FILE_TO_IMPORT(this.csvFileToImport);
} else {
// File doesn't seem to contain coords
this.csvError = `Le fichier ${this.csvFileToImport.name} ne semble pas contenir de coordonnées`;
this.featureTypeImporting = false;
}
this.featureTypeImporting = false;
//* stock filename to import features afterward
this.SET_FILE_TO_IMPORT(this.csvFileToImport);
};
} catch (err) {
console.error(err);
......@@ -705,46 +591,41 @@ export default {
</script>
<style>
/* // ! missing style in semantic.min.css, je ne comprends pas comment... */
/* // ! 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-container > .middle.aligned.content {
width: 50%;
}
.feature-type-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.5em;
}
.nouveau-type-signalement-container {
.help {
position: absolute;
right: 0.5em;
cursor: pointer;
.feature-type-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.5em;
}
& > .middle.aligned.content {
display: flex;
}
}
.nouveau-type-signalement {
margin-top: 1em;
}
.nouveau-type-signalement .label{
cursor: pointer;
#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 {
......@@ -759,8 +640,13 @@ export default {
}
.import-message {
width: fit-content;
line-height: 2em;
color: teal;
color: var(--primary-highlight-color, #008c86);
white-space: nowrap;
padding: .25em;
display: flex;
align-items: center;
& > i {
height: auto;
}
}
</style>
......@@ -73,7 +73,7 @@
"
id="subscribe-button"
class="ui button button-hover-green tiny-margin"
data-tooltip="S'abonner au projet"
data-tooltip="Gérer mon abonnement au projet"
data-position="bottom center"
data-variation="mini"
@click="OPEN_PROJECT_MODAL('subscribe')"
......@@ -89,6 +89,7 @@
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"
......@@ -114,22 +115,44 @@
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>
<button
v-if="isProjectAdmin && !isSharedProject && project.generate_share_link"
class="ui teal left labeled icon button share-button tiny-margin"
@click="copyLink"
>
<i
class="left icon share square"
aria-hidden="true"
/>
Copier le lien de partage
</button>
<div v-if="confirmMsg">
<div class="ui positive tiny-margin message">
<Transition>
<div
v-if="confirmMsg"
class="ui positive tiny-margin message"
>
<span>
Le lien a été copié dans le presse-papier
</span>
......@@ -137,10 +160,10 @@
<i
class="close icon"
aria-hidden="true"
@click="confirmMsg = ''"
@click="confirmMsg = false"
/>
</div>
</div>
</Transition>
</div>
<div
......@@ -188,6 +211,7 @@ export default {
return {
slug: this.$route.params.slug,
confirmMsg: false,
showShareOptions: false,
};
},
......@@ -224,6 +248,11 @@ export default {
mounted() {
let textarea = document.querySelector('textarea');
new TextareaMarkdown(textarea);
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
},
methods: {
......@@ -237,15 +266,50 @@ export default {
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(()=> {
console.log('success');
this.confirmMsg = true;
}, () => {
console.log('failed');
}
);
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() {
......@@ -285,14 +349,13 @@ export default {
<style lang="less" scoped>
.project-header {
.row .right-column {
display: flex;
flex-direction: column;
.ui.buttons {
justify-content: flex-end;
a.ui.button {
.ui.button {
flex-grow: 0; /* avoid stretching buttons */
}
}
......@@ -301,6 +364,36 @@ export default {
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 {
......
......@@ -20,13 +20,13 @@
class="item"
>
<div class="content">
<div>
<router-link
:to="getRouteUrl(item.related_feature.feature_url)"
>
"{{ item.comment }}"
</router-link>
</div>
<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
......@@ -49,10 +49,14 @@
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectsLastComments',
components: {
FeatureFetchOffsetRoute,
},
props: {
loading: {
......@@ -65,10 +69,9 @@ export default {
...mapState([
'user'
]),
...mapState([
...mapState('projects', [
'last_comments',
]),
}
},
};
</script>
......@@ -15,25 +15,16 @@
</div>
<div class="ui relaxed list">
<div
v-for="(item, index) in features.slice(-5)"
v-for="(item, index) in features.slice(0,5)"
:key="item.properties.title + index"
class="item"
>
<div class="content">
<div>
<router-link
:to="{
name: 'details-signalement',
params: {
slug,
slug_type_signal:
item.properties.feature_type.slug,
slug_signal: item.id,
},
}"
>
{{ item.properties.title || item.id }}
</router-link>
<FeatureFetchOffsetRoute
:feature-id="item.id"
:properties="item.properties"
/>
</div>
<div class="description">
<em>
......@@ -63,32 +54,52 @@
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectLastFeatures',
props: {
loading: {
type: Boolean,
default: false
}
components: {
FeatureFetchOffsetRoute,
},
data() {
return {
slug: this.$route.params.slug,
loading: true,
};
},
computed: {
...mapState([
'user'
]),
...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 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>
......@@ -2,7 +2,7 @@
<div>
<div class="ui form">
<div
v-if="isOnline"
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
class="inline fields"
>
<label
......@@ -60,11 +60,30 @@
<thead>
<tr>
<th
v-if="isOnline"
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
scope="col"
class="dt-center"
>
Sélection
<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
......@@ -124,6 +143,25 @@
/>
</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"
......@@ -191,23 +229,23 @@
:key="index"
>
<td
v-if="isOnline"
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
id="select"
class="dt-center"
>
<div
:class="['ui checkbox', {disabled: !checkRights(feature)}]"
:class="['ui checkbox', { disabled: isAllSelected || !checkRights(feature) }]"
>
<input
:id="feature.feature_id"
v-model="checked"
type="checkbox"
:value="feature.feature_id"
:disabled="!checkRights(feature)"
:checked="isFeatureSelected(feature)"
:disabled="isAllSelected || !checkRights(feature)"
name="select"
@input="storeClickedFeature(feature)"
@input="handleFeatureSelection($event, feature)"
>
<label for="select" />
<label :for="feature.feature_id" />
</div>
</td>
......@@ -275,9 +313,6 @@
<router-link
:to="{
name: 'details-signalement-filtre',
params: {
slug_type_signal: feature.feature_type.slug,
},
query: { ...queryparams, offset: queryparams.offset + index }
}"
class="ellipsis space-left"
......@@ -285,6 +320,12 @@
{{ 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"
......@@ -348,7 +389,7 @@
@click="$emit('update:page', 'previous')"
>Précédent</a>
<span>
<span v-if="pagination.currentPage >= 5">
<span v-if="pagination.currentPage > 5">
<a
key="page1"
class="paginate_button"
......@@ -356,7 +397,7 @@
data-dt-idx="1"
tabindex="0"
@click="$emit('update:page', 1)"
>{{ 1 }}</a>
>1</a>
<span class="ellipsis"></span>
</span>
<a
......@@ -371,7 +412,7 @@
tabindex="0"
@click="$emit('update:page', pageNumber)"
>{{ pageNumber }}</a>
<span v-if="(lastPageNumber - pagination.currentPage) >= 4">
<span v-if="(lastPageNumber - pagination.currentPage) > 4">
<span class="ellipsis"></span>
<a
:key="'page' + lastPageNumber"
......@@ -428,6 +469,10 @@ export default {
type: Array,
default: null,
},
allSelected: {
type: Boolean,
default: false,
},
checkedFeatures: {
type: Array,
default: null,
......@@ -470,21 +515,27 @@ export default {
},
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.$route.params.slug];
return this.USER_LEVEL_PROJECTS ? this.USER_LEVEL_PROJECTS[this.$route.params.slug] : '';
},
checked: {
checkedFeaturesSet() {
return new Set(this.checkedFeatures); // Set améliore la performance sur la recherche
},
isAllSelected: {
get() {
return this.checkedFeatures;
return this.allSelected;
},
set(newChecked) {
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', newChecked);
set(isChecked) {
this.$emit('update:allSelected', isChecked);
},
},
......@@ -526,28 +577,83 @@ export default {
'TOGGLE_MASS_MODE',
]),
storeClickedFeature(feature) {
if (this.massMode === 'edit-attributes') { // if modifying attributes
if (this.checkedFeatures.length === 0) { // store feature type slug to restrict selection for next selected features
this.$emit('update:editAttributesFeatureType', feature.feature_type.slug);
} else if (this.checkedFeatures.length === 1 && this.checkedFeatures[0] === feature.feature_id) {
this.$emit('update:editAttributesFeatureType', null); // delete feature type slug if last checkedFeatures is unselected, to allow other types selection
}
/**
* 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') {
return true; //* can delete all
if (this.userStatus === 'Administrateur projet' || this.user.is_superuser) {
return true; // Un administrateur ou super utilisateur peut tout supprimer
}
//* others can delete only their own features
return feature.display_creator === this.user.username;
// 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'],
......@@ -562,7 +668,7 @@ export default {
return false;
} else if (this.user.is_superuser) {
return true;
} else if (this.userStatus === 'Contributeur' && feature.display_creator !== `${this.user.first_name} ${this.user.last_name}`) {
} else if (this.userStatus === 'Contributeur' && feature.creator !== this.user.id) {
return false;
} else if (permissions[this.userStatus]) {
return permissions[this.userStatus].includes(feature.status);
......@@ -608,6 +714,9 @@ export default {
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;
}
......@@ -709,6 +818,13 @@ i.icon.sort:not(.down):not(.up) {
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;
......@@ -723,9 +839,13 @@ and also iPads specifically.
.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,
......@@ -748,12 +868,12 @@ and also iPads specifically.
border: 1px solid #ccc;
border-radius: 7px;
margin-bottom: 3vh;
padding: 0 1vw .5em 1vw;
padding: 0 2vw .5em 2vw;
box-shadow: rgba(50, 50, 50, 0.1) 2px 5px 10px ;
}
td {
/* Behave like a "row" */
/* Behave like a "row" */
border: none;
border-bottom: 1px solid #eee;
position: relative;
......@@ -769,16 +889,14 @@ and also iPads specifically.
border: none !important;
padding: .25em !important;
}
td:nth-of-type(7) {
td:nth-of-type(8) {
border-bottom: none !important;
}
td:before {
/* Now like a table header */
position: absolute;
/* Top/left values mimic padding */
/* top: 6px; */
left: 6px;
/* width: 45%; */
padding-right: 10px;
white-space: nowrap;
}
......@@ -797,6 +915,9 @@ and also iPads specifically.
td#name:before {
content: "Nom";
}
td#create:before {
content: "Date de création";
}
td#update:before {
content: "Dernière modification";
}
......@@ -807,14 +928,42 @@ and also iPads specifically.
content: "Dernier éditeur";
}
table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable td.dataTables_empty {
table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable {
text-align: right;
}
.ui.checkbox {
#select .ui.checkbox {
position: absolute;
left: calc(-1vw - .75em);
top: -.75em;
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 {
......@@ -822,10 +971,9 @@ and also iPads specifically.
text-align: center;
margin: .5em 0;
}
/* preserve space to not overlap column label */
.space-left {
max-width: 100%;
display: inline-block;
padding-left: 3em;
margin-left: 2.5em;
}
}
@media only screen and (max-width: 410px) {
......
......@@ -46,7 +46,7 @@
<div
v-if="
project &&
feature_types.length > 0 &&
filteredFeatureTypeChoices.length > 0 &&
permissions.can_create_feature
"
id="button-dropdown"
......@@ -86,7 +86,8 @@
</div>
</div>
<div
v-if="checkedFeatures.length > 0 && massMode.includes('edit') && isOnline"
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"
......@@ -117,7 +118,7 @@
</div>
</div>
<div
v-if="checkedFeatures.length > 0 && massMode === 'delete-features' && isOnline"
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"
......@@ -141,12 +142,16 @@
:class="['field column', { 'disabled': !isOnline }]"
>
<label>Type</label>
<Dropdown
:options="featureTypeTitles"
:selected="form.type.selected"
:selection.sync="form.type.selected"
:search="true"
:clearable="true"
<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
......@@ -154,13 +159,16 @@
:class="['field column', { 'disabled': !isOnline }]"
>
<label>Statut</label>
<!-- //* giving an object mapped on key name -->
<Dropdown
:options="filteredStatusChoices"
:selected="form.status.selected.name"
:selection.sync="form.status.selected"
:search="true"
:clearable="true"
<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
......@@ -199,11 +207,10 @@
<script>
import { mapState, mapGetters } from 'vuex';
import Multiselect from 'vue-multiselect';
import { statusChoices, allowedStatus2change } from '@/utils';
import Dropdown from '@/components/Dropdown.vue';
const initialPagination = {
currentPage: 1,
pagesize: 15,
......@@ -216,7 +223,7 @@ export default {
name: 'FeaturesListAndMapFilters',
components: {
Dropdown
Multiselect
},
props: {
......@@ -236,22 +243,21 @@ export default {
};
}
},
allSelected: {
type: Boolean,
default: false,
},
editAttributesFeatureType: {
type: String,
default: null,
},
},
data() {
return {
form: {
type: {
selected: '',
},
status: {
selected: '',
},
type: [],
status: [],
title: null,
},
lat: null,
......@@ -294,7 +300,10 @@ export default {
featureTypeTitles() {
return this.feature_types.map((el) => el.title);
},
filteredStatusChoices() {
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'
......@@ -308,11 +317,11 @@ export default {
},
watch: {
'form.type.selected'(newValue) {
'form.type'(newValue) {
this.$emit('set-filter', { type: newValue });
this.resetPaginationNfetchFeatures();
},
'form.status.selected': {
'form.status': {
deep: true,
handler(newValue) {
this.$emit('set-filter', { status: newValue });
......@@ -397,7 +406,7 @@ export default {
width: 100%;
margin-left: 25%;
.secondary.menu #button-dropdown {
z-index: 1;
z-index: 10;
margin-right: 0;
padding-right: 0;
}
......@@ -406,6 +415,9 @@ export default {
#form-filters {
margin: 0;
label + div {
min-height: 42px;
}
}
.ui.dropdown .menu .left.menu, .ui.dropdown > .left.menu .menu {
......@@ -455,3 +467,22 @@ export default {
}
}
</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"
......@@ -13,9 +14,35 @@
: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>
......@@ -38,6 +65,22 @@ export default {
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,
}
},
......@@ -47,6 +90,16 @@ export default {
};
},
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,
......@@ -56,20 +109,74 @@ export default {
this.$emit('filter', this.selection);
}
}
}
},
currentSelection(newValue) {
this.updateSelection(newValue);
},
},
created() {
this.selection = this.options[0];
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 class="item">
<div
:id="project.title"
class="item"
data-test="project-list-item"
>
<div class="ui tiny image">
<img
:src="
......
<template>
<div id="filters-container">
<div
v-if="chunkedNsortedFilters.length > 0"
id="filters-container"
>
<div
class="ui styled accordion"
@click="displayFilters = !displayFilters"
......@@ -15,49 +18,48 @@
/>
</div>
</div>
<div :class="['ui menu filters', { 'hidden': displayFilters }]">
<div class="item">
<label>
Niveau d'autorisation requis
</label>
<DropdownMenuItem
:options="accessLevelOptions"
v-on="$listeners"
/>
</div>
<div class="item">
<label>
Mon niveau d'autorisation
</label>
<DropdownMenuItem
:options="userAccessLevelOptions"
v-on="$listeners"
/>
</div>
<div class="item">
<label>
Modération
</label>
<DropdownMenuItem
:options="moderationOptions"
v-on="$listeners"
/>
</div>
<div class="item">
<label>
Recherche par nom
</label>
<search-projects
:search-function="SEARCH_PROJECTS"
v-on="$listeners"
/>
<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, mapActions } from 'vuex';
import { mapState, mapMutations } from 'vuex';
import DropdownMenuItem from '@/components/Projects/DropdownMenuItem.vue';
import SearchProjects from '@/components/Projects/SearchProjects.vue';
......@@ -70,101 +72,322 @@ export default {
SearchProjects,
},
props: {
loading: {
type: Boolean,
default: false
},
},
data() {
return {
displayFilters: false,
moderationOptions: [
{
label: 'Tous',
filter: 'moderation',
value: null
},
{
label: 'Projet modéré',
filter: 'moderation',
value: 'true'
},
{
label: 'Projet non modéré',
filter: 'moderation',
value: 'false'
},
],
accessLevelOptions: [
classicFilters: [
{
label: 'Tous',
filter: 'access_level',
value: null
name: 'access_level',
label: 'Niveau d\'autorisation requis',
options: [
{
label: 'Utilisateur anonyme',
value: 'anonymous'
},
{
label: 'Utilisateur connecté',
value: 'logged_user'
},
{
label: 'Contributeur',
value: 'contributor'
},
],
},
{
label: 'Utilisateur anonyme',
filter: 'access_level',
value: 'anonymous'
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'
},
],
},
{
label: 'Utilisateur connecté',
filter: 'access_level',
value: 'logged_user'
name: 'moderation',
label: 'Modération',
options: [
{
label: 'Projet modéré',
value: 'true'
},
{
label: 'Projet non modéré',
value: 'false'
},
]
},
{
label: 'Contributeur',
filter: 'access_level',
value: 'contributor'
},
name: 'search',
label: 'Recherche par nom',
}
],
userAccessLevelOptions: [
{
label: 'Tous',
filter: 'user_access_level',
value: null
},
{
label: 'Utilisateur connecté',
filter: 'user_access_level',
value: '1'
},
{
label: 'Contributeur',
filter: 'user_access_level',
value: '2'
},
{
label: 'Super contributeur',
filter: 'user_access_level',
value: '3'
},
{
label: 'Modérateur',
filter: 'user_access_level',
value: '4'
},
{
label: 'Administrateur projet',
filter: 'user_access_level',
value: '5'
},
]
attributesFilter: {},
};
},
computed: {
...mapState(['user'])
...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() {
if (!this.user) {
this.userAccessLevelOptions.splice(1, 0, {
label: 'Utilisateur anonyme',
filter: 'user_access_level',
value: '0'
});
// 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: {
...mapActions('projects', [
'SEARCH_PROJECTS'
...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>
......@@ -210,18 +433,25 @@ export default {
.filters {
width: 100%;
height:auto;
min-height: 0;
max-height:75px;
max-height:100vh;
opacity: 1;
margin: 0 0 1em 0;
border: none;
box-shadow: none;
.transition-properties(all 0.2s ease-out;);
z-index: 1001;
.transition-properties(all 0.2s ease;);
.filter-row {
border: none;
box-shadow: none;
}
.item {
display: flex;
display: flex;
flex-direction: column;
align-items: flex-start !important;
padding: 0.5em;
padding: 0.5em;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
label {
margin-bottom: 0.2em;
......@@ -260,7 +490,7 @@ export default {
@media screen and (max-width: 700px) {
#filters-container {
.filters {
.filter-row {
display: flex;
flex-direction: column;
max-height: 275px;
......
<template>
<div id="search-projects">
<input
v-model="text"
type="text"
placeholder="Rechercher un projet ..."
@input="searchProjects"
>
</div>
</template>
<script>
import _ from 'lodash';
import { mapMutations } from 'vuex';
import { debounce } from 'lodash';
import { mapActions, mapMutations } from 'vuex';
export default {
name: 'SearchProjects',
props: {
searchFunction: {
type: Function,
default: () => { return {}; }
}
},
data() {
return {
text: null
};
},
watch: {
text: _.debounce(function(newValue) {
this.$emit('loading', true);
this.SET_CURRENT_PAGE(1);
this.searchFunction(newValue)
.then(() => {
this.$emit('loading', false);
})
.catch((err) => {
if (err.message) {
this.$emit('loading', false);
}
});
}, 100)
},
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>
......@@ -69,7 +58,7 @@ export default {
padding: 8px 40px 8px 8px;
border: 1px solid #ced4da;
font-size: 1rem;
font-family: 'Roboto Condensed', Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif !important;
font-family: var(--font-family, 'Roboto Condensed', 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif);
}
input:focus {
outline: none !important;
......
......@@ -2,7 +2,7 @@
<div>
<Multiselect
v-model="selection"
:options="results"
:options="options"
:options-limit="10"
:allow-empty="true"
track-by="feature_id"
......@@ -75,7 +75,16 @@ export default {
computed: {
...mapState('feature', [
'features'
])
]),
options() {
return this.results.map((el) => {
return {
featureId: el.id,
title: el.properties.title
};
});
},
},
watch: {
......@@ -88,12 +97,16 @@ export default {
limit: '10'
})
.then(() => {
if (newValue) {
this.results = this.features;
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;
});
}
},
......@@ -117,10 +130,11 @@ export default {
this.text = text;
},
select(e) {
this.$emit('select', e);
this.$emit('select', e.featureId);
},
close() {
this.$emit('close', this.selection);
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);
}
}
};
......
const axios = require('axios');
import Vue from 'vue';
import App from './App.vue';
import './registerServiceWorker';
import router from '@/router';
import store from '@/store';
import './assets/styles/base.css';
import './assets/resources/semantic-ui-2.4.2/semantic.min.css';
import '@fortawesome/fontawesome-free/css/all.css';
import '@fortawesome/fontawesome-free/js/all.js';
import 'ol/ol.css';
import '@/assets/styles/openlayers-custom.css';
import '@/assets/styles/sidebar-layers.css';
// 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'; // Main Vue component
import './registerServiceWorker'; // Service worker registration
import router from '@/router'; // Application router
import store from '@/store'; // Vuex store for state management
// 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';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
// Multiselect installation
import 'vue-multiselect/dist/vue-multiselect.min.css';
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
// Adding Font Awesome icons to the library
library.add(fas);
Vue.component(FontAwesomeIcon);
// 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'];
// gestion mise à jour du serviceWorker et du precache
var refreshing=false;
// Handling service worker updates and precaching
var refreshing = false; // Flag to prevent multiple refreshes
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
// We'll also need to add 'refreshing' to our data originally set to false.
// Check if the page is already refreshing to prevent duplicate refreshes
if (refreshing) {
return;
}
refreshing = true;
// Here the actual reload of the page occurs
// 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);
const onConfigLoaded = function(config){
store.commit('SET_CONFIG', config);
setInterval(() => { //* check if navigator is online
store.commit('SET_IS_ONLINE', navigator.onLine);
}, 2000);
// Set the CSS variable for font family.
document.documentElement.style.setProperty('--font-family', fontNames);
}
};
// set title and favico
document.title = `${config.VUE_APP_APPLICATION_NAME} ${config.VUE_APP_APPLICATION_ABSTRACT}`;
/**
* 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 = config.VUE_APP_APPLICATION_FAVICO;
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');
}
}
window.proxy_url=config.VUE_APP_DJANGO_API_BASE+'proxy/';
axios.all([
store.dispatch('USER_INFO'),
store.dispatch('projects/GET_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(() => {
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('GET_USER_LEVEL_PROJECTS'),
store.dispatch('map/GET_AVAILABLE_LAYERS'),
store.dispatch('GET_USER_LEVEL_PERMISSIONS'),
store.dispatch('GET_LEVELS_PERMISSIONS'),
]).then(axios.spread(function () {
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app');
}));
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)=>{
.catch((error) => {
// Log an error if the configuration file cannot be loaded.
console.error(error);
console.log('try to get From Localstorage');
const conf=localStorage.getItem('geontrib_conf');
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) {
localStorage.setItem('geontrib_conf',JSON.stringify(response.data));
// 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;
});
import Vue from 'vue';
import VueRouter from 'vue-router';
import ProjectsList from '../views/Projects/ProjectsList.vue';
import store from '@/store';
import featureAPI from '@/services/feature-api';
Vue.use(VueRouter);
......@@ -18,6 +20,18 @@ const routes = [
{
path: `${projectBase === 'projet' ? '': `/${projectBase}/:slug`}/connexion/`,
name: 'login',
props: true,
component: () => import('../views/Login.vue')
},
{
path: `${projectBase === 'projet' ? '': `/${projectBase}/:slug`}/inscription/`,
name: 'signup',
component: () => import('../views/Login.vue')
},
{
path: `${projectBase === 'projet' ? '': `/${projectBase}/:slug`}/inscription/succes`,
name: 'sso-signup-success',
props: true,
component: () => import('../views/Login.vue')
},
{
......@@ -105,9 +119,9 @@ const routes = [
component: () => import('../views/FeatureType/FeatureTypeEdit.vue')
},
{
path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/symbologie/`,
name: 'editer-symbologie-signalement',
component: () => import('../views/FeatureType/FeatureTypeSymbology.vue')
path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/affichage/`,
name: 'editer-affichage-signalement',
component: () => import('../views/FeatureType/FeatureTypeDisplay.vue')
},
// * FEATURE
{
......@@ -118,7 +132,66 @@ const routes = [
{
path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/signalement/:slug_signal`,
name: 'details-signalement',
component: () => import('../views/Feature/FeatureDetail.vue')
component: () => import('../views/Feature/FeatureDetail.vue'),
/**
* Handles routing logic before entering the details-signalement route.
* This function manages access and navigation based on user permissions and feature data.
*/
beforeEnter: async (to, from, next) => {
try {
const { slug, slug_type_signal, slug_signal } = to.params;
// Retrieve the project details from the store
const project = await store.dispatch('projects/GET_PROJECT', slug);
// Prepare query based on the project settings for feature browsing
const query = { ordering: project.feature_browsing_default_sort };
// Check if the default filter of the project is set to feature_type and apply it
if (project.feature_browsing_default_filter) { // when feature_type is the default filter of the project,
query['feature_type_slug'] = slug_type_signal; // set feature_type slug in query
}
// Get the feature's position based on the feature slug and query settings
const offset = await featureAPI.getFeaturePosition(slug, slug_signal, query);
// Decide next routing based on the offset result
if (offset >= 0) {
next({
name: 'details-signalement-filtre',
params: { slug },
query: { ...query, offset }
});
} else if (offset === 'No Content') {
// API return no content when user is not allowed to see the feature or isn't connected
if (store.state.user) {
// If the user is connected, display information that he's not allowed to view the feature
store.commit('DISPLAY_MESSAGE', { comment: 'Vous n\'avez pas accès à ce signalement avec cet utilisateur', level: 'negative' });
// and redirect to main page
next({ path: '/' });
} else {
// If the user is not connected, remove other messages to avoid displaying twice that the user is not connected
store.commit('CLEAR_MESSAGES');
// display information that user need to be connected
store.commit('DISPLAY_MESSAGE', { comment: 'Vous n\'avez pas accès à ce signalement hors connexion, veuillez-vous connecter au préalable', level: 'negative' });
// Then redirect to login page
if (store.state.configuration.VUE_APP_LOGIN_URL) {
// If the login is through SSO, redirect to external login page (if the instance accepts a redirect_url it would be caught before when requesting user_info with GET_USER_INFO)
setTimeout(() => { // delay switching page to allow the info message to be read by user
window.open(store.state.configuration.VUE_APP_LOGIN_URL);
}, 1500);
} else {
// In a classic installation, redirect to the login page of this application
next({ name: 'login' });
}
}
} else {
store.commit('DISPLAY_MESSAGE', { comment: 'Désolé, une erreur est survenue pendant la recherche du signalement', level: 'negative' });
next({ path: '/' });
}
} catch (error) {
console.error('error', error);
store.commit('DISPLAY_MESSAGE', { comment: `Désolé, une erreur est survenue pendant la recherche du signalement - ${error}`, level: 'negative' });
next({ path: '/' });
}
}
},
{
path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/offline`,
......@@ -142,6 +215,12 @@ const routes = [
component: () => import('../views/Catalog.vue')
},
{
path: '/projet/:slug/type-signalement/:slug_type_signal/signalement/:slug_signal/attachment-preview/',
name: 'attachment-preview',
component: () => import('../views/AttachmentPreview.vue')
},
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('../views/NotFound.vue') },
];
......
......@@ -74,7 +74,6 @@ self.addEventListener('message', (e) => {
if (!e.data) {
return;
}
//console.log(e.data);
switch (e.data.type) {
case 'SKIP_WAITING':
self.skipWaiting();
......
import { Draw, Snap } from 'ol/interaction';
import GeometryType from 'ol/geom/GeometryType';
import Modify from 'ol/interaction/Modify';
import Select from 'ol/interaction/Select';
import { Collection } from 'ol';
import MultiPoint from 'ol/geom/MultiPoint';
import Point from 'ol/geom/Point';
import {
Fill, Stroke, Style, Circle, Text //RegularShape, Circle as CircleStyle, Text,Icon
} from 'ol/style';
......@@ -12,53 +10,93 @@ import VectorLayer from 'ol/layer/Vector';
import mapService from '@/services/map-service';
import { buffer } from 'ol/extent';
// device detection
const isMobile = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|ipad|iris|kindle|Android|Silk|lge |maemo|midp|mmp|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(navigator.userAgent)
//eslint-disable-next-line
|| /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(navigator.userAgent.substr(0,4));
const editionService = {
drawnFeature: null,
featureToEdit: null,
editing_feature: {},
geom_type: 'linestring',
drawStyle: new Style({
fill: new Fill({
color: 'rgba(255, 255, 255, 0.2)'
}),
stroke: new Stroke({
color: 'rgba(0, 0, 255, 0.5)',
lineDash: [],
width: 2
}),
image: new Circle({
radius: 7,
stroke: new Stroke({
color: 'rgba(255, 0, 0, 0.5)',
lineDash: [],
width: 2
}),
fill: new Fill({
color: 'rgba(255, 255, 255, 0.5)'
})
}),
text: new Text({
font: '12px Calibri,sans-serif',
fill: new Fill({ color: '#000' }),
stroke: new Stroke({
color: '#fff',
width: 1
// Création d'une collection filtrée
filteredFeatures: new Collection(),
// Méthode pour créer un style basé sur la couleur actuelle
createDrawStyle(isEditing) {
return [
new Style({
// Style principal pour le polygone
fill: new Fill({
color: isEditing ? 'rgba(255, 145, 0, .2)' : 'rgba(255, 255, 255, .2)',
}),
// Style principal pour la ligne et le tour du polygone
stroke: new Stroke({
color: isEditing ? 'rgba(255, 145, 0, .9)' : 'rgba(255, 45, 0, 0.5)',
lineDash: [],
width: 2,
}),
// Style principal pour le point
image: new Circle({
radius: 7,
stroke: new Stroke({
color: 'rgba(255, 0, 0, 0.5)',
lineDash: [],
width: 2
}),
fill: new Fill({
color: isEditing ? 'rgba(255, 145, 0, 0.9)' : 'rgba(255, 255, 255, 0.5)'
})
}),
// Style pour le texte, pas utilisé mais peut être conservé au cas où
text: new Text({
font: '12px Calibri,sans-serif',
fill: new Fill({ color: '#000' }),
stroke: new Stroke({
color: '#fff',
width: 1
}),
text: ''
}),
zIndex: 50
}),
text: ''
}),
zIndex: 50
}),
// Style pour afficher des points sur les sommets de ligne ou polygone (seulement en mode édition)
...(isEditing
? [
// Définition du style de point
new Style({
image: new Circle({
radius: 5,
stroke: new Stroke({
color: 'rgba(255, 145, 0, .9)',
width: 2,
}),
fill: new Fill({
color: 'rgba(255, 145, 0, .5)',
}),
}),
// Récupération des sommets où afficher les points uniquement pour ligne et polygone
geometry: function (feature) {
const geometry = feature.getGeometry();
if (geometry.getType() === 'LineString') {
return new MultiPoint(geometry.getCoordinates()); // Sommets de la ligne
} else if (geometry.getType() === 'Polygon') {
return new MultiPoint(geometry.getCoordinates()[0]); // Sommets du premier anneau
}
return null;
},
}),
]
: []),
];
},
// Méthode pour changer la couleur de la géométrie existante en passant en mode édition
toggleEditionColor(isEditing) {
const drawStyle = this.createDrawStyle(isEditing); // Re-crée le style
this.drawnItems.setStyle(drawStyle); // Applique le style à la couche
},
setEditingFeature(feature) {
this.editing_feature = feature;
},
startEditFeature(feature){
initFeatureToEdit(feature) {
this.editing_feature = feature;
this.draw.setActive(false);
this.drawSource.addFeature(feature);
......@@ -71,27 +109,26 @@ const editionService = {
this.drawSource = new VectorSource();
this.drawnItems = new VectorLayer({
source: this.drawSource,
style: this.drawStyle,
style: this.createDrawStyle(),
zIndex: 4000
});
window.olMap = mapService.getMap();
mapService.getMap().addLayer(this.drawnItems);
if (this.draw) {
mapService.getMap().removeInteraction(this.draw);
}
let gType = GeometryType.POINT;
let gType = 'Point';
if (geomType.toUpperCase().indexOf('POLYGON') >= 0) {
gType = GeometryType.POLYGON;
gType = 'Polygon';
}
else if (geomType.toUpperCase().indexOf('LINE') >= 0) {
gType = GeometryType.LINE_STRING;
gType = 'LineString';
}
this.draw = new Draw({
source: this.drawSource,
type: gType,
style: this.drawStyle,
//geometryName: layer.getGeomAttributeName()
style: this.createDrawStyle()
});
mapService.getMap().addInteraction(this.draw);
this.setEditingFeature(undefined);
......@@ -103,59 +140,11 @@ const editionService = {
this.draw.setActive(false);
});
this.selectForUpdate = new Select({
style: [
this.drawStyle,
new Style({
image: new Circle({
radius: 5,
fill: new Fill({
color: 'orange',
}),
}),
geometry: function (feature) {
// return the coordinates of the first ring of the polygon
const coordinates = feature.getGeometry().getCoordinates()[0];
if (feature.getGeometry() instanceof Point){
return feature.getGeometry();
}
return new MultiPoint(coordinates);
},
})
],
filter: (feature) => {
if (this.featureToEdit && feature.id_ === this.featureToEdit.id) {
return true;
} else if (this.drawnFeature && feature.ol_uid === this.drawnFeature.ol_uid) {
return true;
} else if (!this.drawnFeature && !this.featureToEdit) {
return true;
}
return false;
}
});
// On mobile stop drawing when selecting a drawn point
if (isMobile) {
this.selectForUpdate.on('select', () => {
// Permet de stopper le dessin de ligne ou polygone sur mobile
if (this.draw.getActive() && (this.draw.sketchCoords_.length > 2 || this.draw.sketchCoords_[0].length > 3)) {
this.draw.finishDrawing();
}
});
}
this.modify = new Modify({
style: this.drawStyle,
features: this.selectForUpdate.getFeatures()
style: this.createDrawStyle(),
features: this.filteredFeatures, // Limite la modification aux entités filtrées
});
// je garde ce bout de code pour l'implementation a venir du snapping
//snapping
// var snap = new Snap({
// source: this.drawSource
//});
// This workaround allows to avoid the ol freeze
// referenced bug : https://github.com/openlayers/openlayers/issues/6310
// May be corrected in a future version
......@@ -168,23 +157,18 @@ const editionService = {
}
};
mapService.getMap().addInteraction(this.selectForUpdate);
mapService.getMap().addInteraction(this.modify);
// je garde ce bout de code pour l'implementation a venir du snapping
//map.addInteraction(snap);
// Supprime dynamiquement la feature des entités modifiables
this.drawSource.on('removefeature', (event) => {
const feature = event.feature;
this.filteredFeatures.remove(feature);
});
},
resetAllTools() {
if (this.draw) {
this.draw.setActive(false);
}
if (this.selectForDeletion) {
this.removeSelectInteraction(this.selectForDeletion);
}
if (this.selectForUpdate) {
this.removeSelectInteraction(this.selectForUpdate);
}
if (this.modify) {
this.modify.setActive(false);
}
......@@ -193,71 +177,58 @@ const editionService = {
interaction.getFeatures().clear();
interaction.setActive(false);
},
activeUpdateFeature() {
activeUpdateFeature(isEditing) {
this.resetAllTools();
this.modify.setActive(true);
this.selectForUpdate.setActive(true);
this.selectForUpdate.getFeatures().push(this.editing_feature);
},
activeDeleteFeature() {
this.resetAllTools();
if (!this.selectForDeletion) {
const style = new Style({
fill: new Fill({
color: 'rgba(255, 0, 0, 0.2)'
}),
stroke: new Stroke({
color: 'rgba(255, 0, 0, 0.5)',
width: 2
}),
image: new Circle({
radius: 7,
fill: new Fill({
color: 'rgba(255, 0, 0, 0.5)'
})
}),
text: new Text({
font: '12px Calibri,sans-serif',
fill: new Fill({ color: 'rgba(255, 0, 0, 0.5)' }),
}),
zIndex: 50
});
this.selectForDeletion = new Select({
style: style,
filter: (feature) => {
if (this.featureToEdit && feature.id_ === this.featureToEdit.id) {
return true;
} else if (this.drawnFeature && feature.ol_uid === this.drawnFeature.ol_uid) {
return true;
} else if (!this.drawnFeature && !this.featureToEdit) {
return true;
}
return false;
}
});
mapService.getMap().addInteraction(this.selectForDeletion);
// Lorsque de nouvelles features sont sélectionnées
const selected_features = this.selectForDeletion.getFeatures();
this.listenerKey = selected_features.on('add', (evt) => {
var feature = evt.element;
if (feature) {
setTimeout(() => {
if (confirm('Etes-vous sur de vouloir supprimer cet objet ?')) {
// supprimer l'edition de la sélection
this.selectForDeletion.getFeatures().clear();
// supprimer l'edition de la carte
this.drawSource.removeFeature(feature);
this.editing_feature = undefined;
this.draw.setActive(true);
this.selectForDeletion.setActive(false);
}
}, 300);
if (isEditing) {
// Mise à jour des entités modifiables
this.drawSource.forEachFeature((feature) => {
if (
(this.featureToEdit && feature.id_ === this.featureToEdit.id) ||
(this.drawnFeature && feature.ol_uid === this.drawnFeature.ol_uid) ||
(!this.drawnFeature && !this.featureToEdit)
) {
this.filteredFeatures.push(feature);
}
});
} else {
this.selectForDeletion.setActive(true);
this.modify.setActive(true);
}
this.toggleEditionColor(isEditing);
},
/**
* Deletes the currently displayed feature from the map.
* This method removes the feature directly from the source without additional selection steps.
* It assumes that there is only one feature present in the source.
* Resets the color for future drawings to the default to ensure that the editing color
* is not displayed if the edit mode was active prior to deletion.
*/
removeFeatureFromMap() {
// Access the source where the features are stored
const source = this.drawSource; // Replace with the correct reference to your OpenLayers source
// Get all features from the source
const features = source.getFeatures();
// Check if there is a feature to delete
if (features.length > 0 && confirm('Etes-vous sur de vouloir supprimer cet objet ?')) {
try {
// Reset all other tools to ensure only the delete feature functionality is active
this.resetAllTools();
// Remove the feature from the source
const featureToRemove = features[0];
source.removeFeature(featureToRemove);
// Reinitialize the feature edited on the map
this.editing_feature = undefined;
// Toggle draw mode to create a new feature
this.draw.setActive(true);
// Reset color to default
this.toggleEditionColor(false);
// Return operation result after user confirmed to remove the feature
return true;
} catch (error) {
// Log an error if the feature cannot be removed
console.error('Error while deleting the feature: ', error);
}
}
return false;
},
setFeatureToEdit(feature) {
......
import axios from '@/axios-client.js';
import store from '../store';
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const featureAPI = {
async getFeaturesBbox(project_slug, queryParams) {
async getFeaturesBbox(project_slug, queryString) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.get(
`${baseUrl}projects/${project_slug}/feature-bbox/${queryParams ? '?' + queryParams : ''}`
`${baseUrl}projects/${project_slug}/feature-bbox/${queryString ? '?' + queryString : ''}`
);
if (
response.status === 200 &&
......@@ -22,8 +21,28 @@ const featureAPI = {
}
},
async getProjectFeature(project_slug, feature_id) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.get(
`${baseUrl}v2/features/${feature_id}/?project__slug=${project_slug}`
);
if (
response.status === 200 &&
response.data
) {
return response.data;
} else {
return null;
}
},
async getPaginatedFeatures(url) {
const response = await axios.get(url);
// Cancel any ongoing search request.
store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
// Prepare the cancel token for the new request and store it.
const cancelToken = axios.CancelToken.source();
store.commit('SET_CANCELLABLE_SEARCH_REQUEST', cancelToken);
const response = await axios.get(url, { cancelToken: cancelToken.token });
if (
response.status === 200 &&
response.data
......@@ -35,6 +54,7 @@ const featureAPI = {
},
async getFeatureEvents(featureId) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.get(
`${baseUrl}features/${featureId}/events/`
);
......@@ -49,6 +69,7 @@ const featureAPI = {
},
async getFeatureAttachments(featureId) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.get(
`${baseUrl}features/${featureId}/attachments/`
);
......@@ -63,6 +84,7 @@ const featureAPI = {
},
async getFeatureLinks(featureId) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.get(
`${baseUrl}features/${featureId}/feature-links/`
);
......@@ -88,17 +110,16 @@ const featureAPI = {
return null;
}
},
// todo : fonction pour faire un post ou un put du signalement
async postOrPutFeature({ method, feature_id, feature_type__slug, project__slug, data }) {
let url = `${baseUrl}features/`;
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
let url = `${baseUrl}v2/features/`;
if (method === 'PUT') {
url += `${feature_id}/?
feature_type__slug=${feature_type__slug}
&project__slug=${project__slug}`;
}
const response = await axios({
url,
method,
......@@ -112,7 +133,8 @@ const featureAPI = {
},
async updateFeature({ feature_id, feature_type__slug, project__slug, newStatus }) {
const url = `${baseUrl}features/${feature_id}/?feature_type__slug=${feature_type__slug}&project__slug=${project__slug}`;
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const url = `${baseUrl}v2/features/${feature_id}/?feature_type__slug=${feature_type__slug}&project__slug=${project__slug}`;
const response = await axios({
url,
......@@ -126,7 +148,39 @@ const featureAPI = {
}
},
async projectFeatureBulkUpdateStatus(projecSlug, queryString, newStatus) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const url = `${baseUrl}projects/${projecSlug}/feature-bulk-modify/?${queryString}`;
const response = await axios({
url,
method: 'PUT',
data: { status: newStatus }
});
if (response.status === 200 && response.data) {
return response;
} else {
return null;
}
},
async projectFeatureBulkDelete(projecSlug, queryString) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const url = `${baseUrl}projects/${projecSlug}/feature-bulk-modify/?${queryString}`;
const response = await axios({
url,
method: 'DELETE'
});
if (response.status === 200 && response.data) {
return response;
} else {
return null;
}
},
async postComment({ featureId, comment }) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.post(
`${baseUrl}features/${featureId}/comments/`, { comment }
);
......@@ -140,10 +194,12 @@ const featureAPI = {
}
},
async postCommentAttachment({ featureId, file, fileName, commentId, title }) {
async postCommentAttachment({ featureId, file, fileName, title, isKeyDocument, commentId }) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const formdata = new FormData();
formdata.append('file', file, fileName);
formdata.append('title', title);
formdata.append('is_key_document', isKeyDocument);
const response = await axios.put(
`${baseUrl}features/${featureId}/comments/${commentId}/upload-file/`, formdata
......@@ -157,6 +213,18 @@ const featureAPI = {
return null;
}
},
async getFeaturePosition(projectSlug, featureId, query) {
const searchParams = new URLSearchParams(query);
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.get(`${baseUrl}projects/${projectSlug}/feature/${featureId}/position-in-list/?${searchParams.toString()}`);
if (response && response.status === 200) {
return response.data;
} else if (response.status === 204) {
return response.statusText;
}
return null;
},
};
export default featureAPI;
......@@ -6,7 +6,7 @@ const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const featureTypeAPI = {
async deleteFeatureType(featureType_slug) {
const response = await axios.delete(
`${baseUrl}feature-types/${featureType_slug}`
`${baseUrl}v2/feature-types/${featureType_slug}/`
);
if (
response.status === 204
......