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 1857 additions and 1305 deletions
<template>
<div
id="feature-type-edit"
class="page"
>
<div
id="message"
class="fullwidth"
>
<div
v-if="error"
class="ui negative message"
>
<p>
<i
class="cross icon"
aria-hidden="true"
/>
{{ error }}
</p>
</div>
</div>
<div id="feature-type-edit">
<div class="fourteen wide column">
<div
:class="{ active: loading }"
......@@ -30,9 +10,6 @@
<form
v-if="project"
id="form-type-edit"
action=""
method="post"
enctype="multipart/form-data"
class="ui form"
>
<h1 v-if="action === 'create'">
......@@ -74,8 +51,8 @@
</div>
<div
:class="{ disabled: csv }"
class="required field"
id="geometry-type"
:class="['required field', { disabled: csv }]"
>
<label :for="form.geom_type.id_for_label">{{
form.geom_type.label
......@@ -99,61 +76,82 @@
<label :for="form.title_optional.html_name">{{ form.title_optional.label }}</label>
</div>
</div>
<span v-if="action === 'duplicate' || action === 'edit'" />
<div v-else>
<div id="formsets">
<FeatureTypeCustomForm
v-for="customForm in customForms"
:key="customForm.dataKey"
ref="customForms"
:data-key="customForm.dataKey"
:custom-form="customForm"
:selected-color-style="form.colors_style.value.custom_field_name"
@update="updateColorsStyle($event)"
/>
<div class="field">
<div class="ui checkbox">
<input
:id="form.enable_key_doc_notif.html_name"
v-model="form.enable_key_doc_notif.value"
class="hidden"
:name="form.enable_key_doc_notif.html_name"
type="checkbox"
>
<label :for="form.enable_key_doc_notif.html_name">{{ form.enable_key_doc_notif.label }}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
:id="form.disable_notification.html_name"
v-model="form.disable_notification.value"
class="hidden"
:name="form.disable_notification.html_name"
type="checkbox"
>
<label :for="form.disable_notification.html_name">{{ form.disable_notification.label }}</label>
</div>
</div>
<button
id="add-field"
type="button"
class="ui compact basic button"
@click="addCustomForm"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
Ajouter un champ personnalisé
</button>
<div class="ui divider" />
<button
class="ui teal icon button margin-25"
type="button"
@click="sendFeatureType"
>
<i
class="white save icon"
aria-hidden="true"
/>
{{ action === "create" ? "Créer" : "Sauvegarder" }} le type de
signalement
</button>
<button
v-if="geojson || csv"
class="ui teal icon button margin-25"
type="button"
@click="postFeatureTypeThenFeatures"
>
<i
class="white save icon"
aria-hidden="true"
/>
Créer et importer le(s) signalement(s) du {{ geojson ? 'geojson' : 'csv' }}
</button>
<div id="formsets">
<FeatureTypeCustomForm
v-for="customForm in customForms"
:key="customForm.dataKey"
ref="customForms"
:data-key="customForm.dataKey"
:custom-form="customForm"
:selected-color-style="form.colors_style.value.custom_field_name"
@update="updateColorsStyle($event)"
/>
</div>
<button
id="add-field"
type="button"
class="ui compact basic button"
@click="addCustomForm"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
Ajouter un champ personnalisé
</button>
<div class="ui divider" />
<button
id="send-feature_type"
:class="['ui teal icon button margin-25', { disabled: loading }]"
type="button"
@click="sendFeatureType"
>
<i
class="white save icon"
aria-hidden="true"
/>
{{ action === "create" ? "Créer" : "Sauvegarder" }} le type de
signalement
</button>
<button
v-if="geojson || csv || json"
:class="['ui teal icon button margin-25', { disabled: loading }]"
type="button"
@click="postFeatureTypeThenFeatures"
>
<i
class="white save icon"
aria-hidden="true"
/>
Créer et importer le(s) signalement(s) du {{ geojson ? 'geojson' : csv ? 'csv' : 'json' }}
</button>
</form>
</div>
</div>
......@@ -165,6 +163,7 @@ import { mapGetters, mapState, mapMutations, mapActions } from 'vuex';
import Dropdown from '@/components/Dropdown.vue';
import FeatureTypeCustomForm from '@/components/FeatureType/FeatureTypeCustomForm.vue';
import { transformProperties, reservedKeywords } from'@/utils';
export default {
name: 'FeatureTypeEdit',
......@@ -183,6 +182,10 @@ export default {
type: Array,
default: null,
},
json: {
type: Array,
default: null,
},
},
data() {
......@@ -190,12 +193,12 @@ export default {
loading: false,
action: 'create',
dataKey: 0,
error: null,
csvFields: null,
geomTypeChoices: [
{ value: 'linestring', name: 'Ligne' },
{ value: 'point', name: 'Point' },
{ value: 'polygon', name: 'Polygone' },
{ value: 'none', name: 'Aucune' },
],
form: {
colors_style: {
......@@ -219,7 +222,7 @@ export default {
id_for_label: 'title',
label: 'Titre',
field: {
max_length: 128, // ! Vérifier la valeur dans django
max_length: 128,
},
html_name: 'title',
value: null,
......@@ -231,35 +234,31 @@ export default {
label: 'Titre du signalement optionnel',
value: false,
},
enable_key_doc_notif: {
errors: null,
id_for_label: 'enable_key_doc_notif',
html_name: 'enable_key_doc_notif',
label: 'Activer la notification de publication de pièces jointes',
value: false,
},
disable_notification: {
errors: null,
id_for_label: 'disable_notification',
html_name: 'disable_notification',
label: 'Désactiver les notifications',
value: false,
},
geom_type: {
id_for_label: 'geom_type',
label: 'Type de géométrie',
field: {
max_length: 128, // ! Vérifier la valeur dans django
max_length: 128,
},
html_name: 'geom_type',
value: 'point',
},
},
slug: this.$route.params.slug,
reservedKeywords: [
// todo : add keywords for mapstyle (strokewidth...)
'id',
'title',
'description',
'status',
'created_on',
'updated_on',
'archived_on',
'deletion_on',
'feature_type',
'display_creator',
'display_last_editor',
'project',
'creator',
'lat',
'lon'
],
};
},
......@@ -334,14 +333,16 @@ export default {
},
customForms(newValue, oldValue) {
if (newValue !== oldValue) {
const name = this.form.colors_style.value.custom_field_name;
const customField = this.customForms.find((el) => el.name === name);
if (!customField || customField.length === 0) {
//* if the customForm corresponding doesn't exist reset colors_style values
this.form.colors_style.value = {
colors: {},
custom_field_name: '',
};
// Retrieve custom_field_name; returns undefined if colors_style.value is null/undefined
const customFieldName = this.form.colors_style.value?.custom_field_name;
// Determine if a custom field with the given name exists in customForms
// 'some' returns true if any element matches the condition
const customFieldExists = customFieldName && this.customForms.some(el => el.name === customFieldName);
// Reset colors_style if no corresponding custom field is found
if (!customFieldExists) {
this.form.colors_style.value = { colors: {}, custom_field_name: '' };
}
}
},
......@@ -375,21 +376,18 @@ export default {
//* when creation from a geojson
if (this.geojson) {
this.importGeoJsonFeatureType();
if (this.fileToImport && this.fileToImport.name) {
this.form.title.value = // * use the filename as title by default
this.fileToImport.name.split('.')[0];
} else { //* case when the geojson comes from datasud catalog
this.form.title.value = this.geojson.name;// * use the typename as title by default
}
//* add multiple geometries options available only for geojson (therefore when importing from catalog also)
this.geomTypeChoices.push(
{ value: 'multilinestring', name: 'Multiligne' },
{ value: 'multipoint', name: 'Multipoint' },
{ value: 'multipolygon', name: 'Multipolygone' },
);
}
if (this.csv) {
this.importCSVFeatureType();
if (this.fileToImport && this.fileToImport.name) {
this.form.title.value = // * use the filename as title by default
this.fileToImport.name.split('.')[0];
} else { //* case when the geojson comes from datasud catalog
this.form.title.value = this.csv.name;// * use the typename as title by default
}
}
if (this.json) {
this.importJsonFeatureType();
}
},
beforeDestroy() {
......@@ -399,6 +397,9 @@ export default {
},
methods: {
...mapMutations([
'DISPLAY_MESSAGE',
]),
...mapMutations('feature-type', [
'ADD_CUSTOM_FORM',
'EMPTY_FORM',
......@@ -453,7 +454,7 @@ export default {
}
}
//! add custom fields using ONLY this function, incrementing dataKey for Vue to correctly update components
formData.customfield_set.forEach((el) => this.addCustomForm(el));
[...formData.customfield_set].forEach((el) => this.addCustomForm(el));
this.updateStore();
},
......@@ -491,6 +492,8 @@ export default {
color: this.form.color,
title: this.form.title,
title_optional: this.form.title_optional,
enable_key_doc_notif: this.form.enable_key_doc_notif,
disable_notification: this.form.disable_notification,
geom_type: this.form.geom_type,
colors_style: this.form.colors_style,
});
......@@ -538,15 +541,25 @@ export default {
const requestType = this.action === 'edit' ? 'put' : 'post';
if (this.checkForms()) {
this.SEND_FEATURE_TYPE(requestType)
.then(({ status }) => {
.then((response) => {
const { status, data } = response;
if (status === 200) {
this.goBackToProject('Le type de signalement a été mis à jour');
this.goBackToProject({ comment: 'Le type de signalement a été mis à jour', level: 'positive' });
} else if (status === 201) {
this.goBackToProject('Le nouveau type de signalement a été créé');
this.goBackToProject({ comment: 'Le nouveau type de signalement a été créé', level: 'positive' });
} else {
this.displayMessage(
"Une erreur est survenue lors de l'import du type de signalement"
);
let comment = 'Une erreur est survenue lors de l\'import du type de signalement';
if (data.customfield_set) {
let errors = data.customfield_set.find((el) => el.options);
if (errors.options) {
let customFieldError = errors.options[0];
if(customFieldError) comment = customFieldError[0].replace('ce champ', 'chaque option de champ personnalisé');
}
}
this.DISPLAY_MESSAGE({
comment,
level: 'negative'
});
}
});
}
......@@ -556,16 +569,19 @@ export default {
this.SEND_FEATURES_FROM_GEOJSON({
slug: this.slug,
feature_type_slug,
geojson: this.geojson
geojson: this.geojson || this.json
})
.then((response) => {
if (response && response.status === 200) {
this.goBackToProject();
this.goBackToProject({
comment: 'Le nouveau type de signalement a été créé. L\'import des signalements est en cours',
level: 'positive'
});
} else {
this.displayMessage(
"Une erreur est survenue lors de l'import de signalements.\n " +
response.data.detail
);
this.DISPLAY_MESSAGE({
comment: `Une erreur est survenue lors de l'import de signalements.\n ${ response.data.detail }`,
level: 'negative'
});
}
this.loading = false;
})
......@@ -573,6 +589,7 @@ export default {
this.loading = false;
});
},
postCSVFeatures(feature_type_slug) {
this.$store
.dispatch('feature-type/SEND_FEATURES_FROM_CSV', {
......@@ -582,12 +599,15 @@ export default {
})
.then((response) => {
if (response && response.status === 200) {
this.goBackToProject();
this.goBackToProject({
comment: 'Le nouveau type de signalement a été créé. Import des signalements est en cours',
level: 'positive'
});
} else {
this.displayMessage(
"Une erreur est survenue lors de l'import de signalements.\n " +
response.data.detail
);
this.DISPLAY_MESSAGE({
comment: `Une erreur est survenue lors de l'import de signalements.\n ${ response.data.detail }`,
level: 'negative'
});
}
this.loading = false;
})
......@@ -604,10 +624,9 @@ export default {
.dispatch('feature-type/SEND_FEATURE_TYPE', requestType)
.then(({ feature_type_slug }) => {
if (feature_type_slug) {
if (this.geojson) {
if (this.geojson || this.json) {
this.postGeojsonFeatures(feature_type_slug);
}
else if (this.csv) {
} else if (this.csv) {
this.postCSVFeatures(feature_type_slug);
}
} else {
......@@ -620,13 +639,6 @@ export default {
}
},
displayMessage(message) {
this.error = message;
document
.getElementById('message')
.scrollIntoView({ block: 'end', inline: 'nearest' });
},
// ****** Methodes for geojson import ****** //
toNewFeatureType() {
this.$router.push({
......@@ -635,128 +647,119 @@ export default {
});
},
translateLabel(value) {
if (value === 'LineString') {
return 'linestring';
} else if (value === 'Polygon' || value === 'MultiPolygon') {
return 'polygon';
/**
* Builds custom form fields based on the properties of data entries.
*
* This function iterates through a subset of data entries (such as rows from a CSV, JSON objects, or GeoJSON features)
* to determine the most appropriate type for each field. It tracks confirmed types to avoid redundant checks and
* stops processing a field once its type is definitively determined. If a field is initially detected as a 'char',
* it remains as 'char' unless a multiline text ('text') is detected later. The function prioritizes the detection
* of definitive types (like 'text', 'boolean', 'integer') and updates the form with the confirmed types.
*
* @param {Array} propertiesList - An array of data entries, where each entry is an object representing a set of properties.
*/
buildCustomForm(propertiesList) {
const confirmedTypes = {}; // Store confirmed types for each field
const detectedAsChar = {}; // Track fields initially detected as 'char'
// Iterate over each row or feature in the subset
propertiesList.forEach((properties) => {
for (const [key, val] of Object.entries(properties)) {
if (!reservedKeywords.includes(key)) {
// If the type for this field has already been confirmed as something other than 'char', skip it
if (confirmedTypes[key] && confirmedTypes[key] !== 'char') {
continue;
}
// Determine the type of the current value
const detectedType = transformProperties(val);
if (detectedType === 'text') {
// Once 'text' (multiline) is detected, confirm it immediately
confirmedTypes[key] = 'text';
} else if (!confirmedTypes[key] && detectedType !== 'char') {
// If a type is detected that is not 'char' and not yet confirmed, confirm it
confirmedTypes[key] = detectedType;
} else if (!confirmedTypes[key]) {
// If this field hasn't been confirmed yet, initialize it as 'char'
confirmedTypes[key] = 'char';
detectedAsChar[key] = true;
} else if (detectedAsChar[key] && detectedType !== 'char') {
// If a field was initially detected as 'char' but now has a different type, update it
confirmedTypes[key] = detectedType;
delete detectedAsChar[key]; // Remove from 'char' tracking once updated
}
}
}
});
// Build custom forms using the confirmed types
for (const [key, confirmedType] of Object.entries(confirmedTypes)) {
const customForm = {
label: { value: key || '' },
name: { value: key || '' },
position: this.dataKey, // use dataKey already incremented by addCustomForm
field_type: { value: confirmedType }, // use the confirmed type
options: { value: [] }, // not available in export
};
this.addCustomForm(customForm);
}
return 'point';
},
transformProperties(prop) {
const type = typeof prop;
const date = new Date(prop);
if (type === 'boolean') {
return 'boolean';
} else if (Number.isSafeInteger(prop)) {
return 'integer';
} else if (
type === 'string' &&
date instanceof Date &&
!isNaN(date.valueOf())
) {
return 'date';
} else if (type === 'number' && !isNaN(parseFloat(prop))) {
return 'decimal';
setTitleFromFile() {
if (this.fileToImport && this.fileToImport.name) {
this.form.title.value = // * use the filename as title by default
this.fileToImport.name.split('.')[0];
} else { //* case when the data comes from datasud catalog
// * use the typename as title by default
this.form.title.value = this.geojson.name || this.csv.name || this.json.name;
}
return 'char'; //* string by default, most accepted type in database
},
importGeoJsonFeatureType() {
if (this.geojson.features && this.geojson.features.length) {
//* in order to get feature_type properties, the first feature is enough
const { properties, geometry } = this.geojson.features[0];
this.form.title.value = properties.feature_type;
this.form.geom_type.value = this.translateLabel(geometry.type);
this.updateStore(); //* register title & geom_type in store
//* loop properties to create a customForm for each of them
for (const [key, val] of Object.entries(properties)) {
//* check that the property is not a keyword from the backend or map style
// todo: add map style keywords
if (!this.reservedKeywords.includes(key)) {
const customForm = {
label: { value: key || '' },
name: { value: key || '' },
position: this.dataKey, // * use dataKey already incremented by addCustomForm
field_type: { value: this.transformProperties(val) }, // * guessed from the type
options: { value: [] }, // * not available in export
};
this.addCustomForm(customForm);
}
}
const { geometry } = this.geojson.features[0];
this.form.geom_type.value = geometry.type.toLowerCase();
this.updateStore(); // register geom_type in store
// Use a subset of the first N features to build the form
const subsetFeatures = this.geojson.features.slice(0, 200); // Adjust '200' based on performance needs
const propertiesList = subsetFeatures.map(feature => feature.properties);
this.buildCustomForm(propertiesList);
}
this.setTitleFromFile();
},
importCSVFeatureType() {
if (this.csv.length) {
this.updateStore(); //* register title & geom_type in store
// List fileds for user to select coords fields
// this.csvFields =
// Object.keys(this.csv[0])
// .map(el => {
// return {
// field: el,
// x: false,
// y:false
// };
// });
for (const [key, val] of Object.entries(this.csv[0])) {
//* check that the property is not a keyword from the backend or map style
// todo: add map style keywords
if (!this.reservedKeywords.includes(key)) {
const customForm = {
label: { value: key || '' },
name: { value: key || '' },
position: this.dataKey, // * use dataKey already incremented by addCustomForm
field_type: { value: this.transformProperties(val) }, // * guessed from the type
options: { value: [] }, // * not available in export
};
this.addCustomForm(customForm);
}
this.updateStore(); // register title in store
// Use a subset of the first N rows to build the form
const subsetCSV = this.csv.slice(0, 200); // Adjust '200' based on performance needs
this.buildCustomForm(subsetCSV);
// Check for geom data
if (!('lat' in this.csv[0]) || !('lon' in this.csv[0])) {
this.form.geom_type.value = 'none';
}
}
this.setTitleFromFile();
},
importJsonFeatureType() {
if (this.json.length) {
this.form.geom_type.value = 'none'; // JSON are non-geom features
this.updateStore(); // register title in store
// Use a subset of the first N objects to build the form
const subsetJson = this.json.slice(0, 200); // Adjust '200' based on performance needs
this.buildCustomForm(subsetJson);
}
this.setTitleFromFile();
},
// pickXcsvCoordField(e) {
// this.csvFields.forEach(el => {
// if (el.field === e.field) {
// el.x = true;
// } else {
// el.x = false;
// }
// });
// },
// pickYcsvCoordField(e) {
// this.csvFields.forEach(el => {
// if (el.field === e.field) {
// el.y = true;
// } else {
// el.y = false;
// }
// });
// },
// setCSVCoordsFields() {
// const xField = this.csvFields.find(el => el.x === true).field;
// const yField = this.csvFields.find(el => el.y === true).field;
// this.csvFields = null;
// for (const [key, val] of Object.entries(this.csv[0])) {
// //* check that the property is not a keyword from the backend or map style
// // todo: add map style keywords
// if (!this.reservedKeywords.includes(key) && key !== xField && key !== yField) {
// const customForm = {
// label: { value: key || '' },
// name: { value: key || '' },
// position: this.dataKey, // * use dataKey already incremented by addCustomForm
// field_type: { value: this.transformProperties(val) }, // * guessed from the type
// options: { value: [] }, // * not available in export
// };
// this.addCustomForm(customForm);
// }
// }
// }
},
};
</script>
......
<template>
<div>
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui loader" />
</div>
<div
id="message"
class="fullwidth"
>
<div
v-if="error"
class="ui negative message"
>
<p>
<i
class="close icon"
aria-hidden="true"
/>
{{ error }}
</p>
</div>
<div
v-if="success"
class="ui positive message"
>
<i
class="close icon"
aria-hidden="true"
@click="success = null"
/>
<p>{{ success }}</p>
</div>
</div>
<div class="fourteen wide column">
<form
id="form-symbology-edit"
action=""
method="post"
enctype="multipart/form-data"
class="ui form"
>
<h1 v-if="project && feature_type">
Éditer la symbologie du type de signalement "{{ feature_type.title }}" pour le
projet "{{ project.title }}"
</h1>
<SymbologySelector
v-if="feature_type"
:init-color="feature_type.color"
:init-icon="feature_type.icon"
:geom-type="feature_type.geom_type"
@set="setDefaultStyle"
/>
<div
v-if="
feature_type &&
feature_type.customfield_set.length > 0 &&
feature_type.customfield_set.some(el => el.field_type === 'list')
"
>
<div class="ui divider" />
<label
id="customfield-select-label"
for="customfield-select"
>
Personnaliser la symbologie d'une liste de valeurs:
</label>
<select
id="customfield-select"
v-model="selectedCustomfield"
class="ui dropdown"
>
<option
v-for="customfieldList of feature_type.customfield_set.filter(el => el.field_type === 'list')"
:key="customfieldList.name"
:value="customfieldList.name"
>
{{ customfieldList.label }}
</option>
</select>
</div>
<div v-if="selectedCustomfield">
<div
v-for="option of feature_type.customfield_set.find(el => el.name === selectedCustomfield).options"
:key="option"
>
<SymbologySelector
:title="option"
:init-color="feature_type.colors_style.value ?
feature_type.colors_style.value.colors[option] ?
feature_type.colors_style.value.colors[option].value :
feature_type.colors_style.value.colors[option]
: null
"
:init-icon="feature_type.colors_style.value ?
feature_type.colors_style.value.icons[option] :
null
"
:geom-type="feature_type.customfield_set.geomType"
@set="setColorsStyle"
/>
</div>
</div>
<div class="ui divider" />
<button
class="ui teal icon button margin-25"
type="button"
:disabled="!canSaveSymbology"
@click="sendFeatureSymbology"
>
<i
class="white save icon"
aria-hidden="true"
/>
Sauvegarder la symbologie du type de signalement
</button>
</form>
</div>
</div>
</template>
<script>
import { isEqual } from 'lodash';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import SymbologySelector from '@/components/FeatureType/SymbologySelector.vue';
export default {
name: 'FeatureTypeSymbology',
components: {
SymbologySelector
},
data() {
return {
loading: false,
error: null,
success: null,
selectedCustomfield: null,
form: {
color: '#000000',
icon: 'circle',
colors_style: {
fields: [],
colors: {},
icons: {},
custom_field_name: '',
value: {
colors: {},
icons: {}
}
},
},
canSaveSymbology: false
};
},
computed: {
...mapState('projects', [
'project'
]),
...mapState('feature-type', [
'customForms',
'colorsStyleList'
]),
...mapGetters('feature-type', [
'feature_type'
]),
},
watch: {
selectedCustomfield(newValue) {
this.form.colors_style.custom_field_name = newValue;
},
feature_type(newValue) {
if (newValue) {
// Init form
this.form.color = JSON.parse(JSON.stringify(newValue.color));
this.form.icon = JSON.parse(JSON.stringify(newValue.icon));
this.form.colors_style = {
...this.form.colors_style,
...JSON.parse(JSON.stringify(newValue.colors_style))
};
}
},
form: {
deep: true,
handler(newValue) {
if (isEqual(newValue, {
color: this.feature_type.color,
icon: this.feature_type.icon,
colors_style: this.feature_type.colors_style
})) {
this.canSaveSymbology = false;
} else {
this.canSaveSymbology = true;
}
}
}
},
created() {
if (!this.project) {
this.GET_PROJECT(this.$route.params.slug);
this.GET_PROJECT_INFO(this.$route.params.slug);
}
this.SET_CURRENT_FEATURE_TYPE_SLUG(this.$route.params.slug_type_signal);
if (this.feature_type) {
this.initForm();
} else {
this.loading = true;
this.GET_PROJECT_FEATURE_TYPES(this.$route.params.slug)
.then(() => {
this.initForm();
this.loading = false;
})
.catch(() => {
this.loading = false;
});
}
},
methods: {
...mapMutations('feature-type', [
'SET_CURRENT_FEATURE_TYPE_SLUG'
]),
...mapActions('feature-type', [
'SEND_FEATURE_SYMBOLOGY',
'GET_PROJECT_FEATURE_TYPES'
]),
...mapActions('projects', [
'GET_PROJECT',
'GET_PROJECT_INFO',
]),
initForm() {
this.form.color = JSON.parse(JSON.stringify(this.feature_type.color));
this.form.icon = JSON.parse(JSON.stringify(this.feature_type.icon));
this.form.colors_style = {
...this.form.colors_style,
...JSON.parse(JSON.stringify(this.feature_type.colors_style))
};
if (this.feature_type.colors_style && Object.keys(this.feature_type.colors_style.colors).length > 0) {
this.selectedCustomfield =
this.feature_type.customfield_set.find(
el => el.name === this.feature_type.colors_style.custom_field_name
).name;
}
},
setDefaultStyle(e) {
const value = e.value;
this.form.color = value.color.value;
this.form.icon = value.icon;
},
setColorsStyle(e) {
const { name, value } = e;
this.form.colors_style.colors[name] = value.color;
this.form.colors_style.icons[name] = value.icon;
this.form.colors_style.value.colors[name] = value.color;
this.form.colors_style.value.icons[name] = value.icon;
},
sendFeatureSymbology() {
this.loading = true;
this.SEND_FEATURE_SYMBOLOGY(this.form)
.then(() => {
this.GET_PROJECT_FEATURE_TYPES(this.$route.params.slug)
.then(() => {
this.loading = false;
this.success =
'La modification de la symbologie a été prise en compte. Vous allez être redirigé vers la page d\'accueil du projet.';
setTimeout(() => {
this.$router.push({
name: 'project_detail',
params: {
slug: this.$route.params.slug,
},
});
}, 1500);
})
.catch((err) => {
console.error(err);
});
})
.catch((err) => {
console.error(err);
this.loading = false;
});
}
}
};
</script>
<style lang="less" scoped>
h1 {
margin-top: 1em;
}
form {
text-align: left;
#customfield-select-label {
cursor: pointer;
font-weight: 600;
font-size: 1.1em;
}
#customfield-select {
width: 50% !important;
}
}
</style>
......@@ -16,7 +16,7 @@
import { mapState } from 'vuex';
export default {
name: 'Default',
name: 'Help',
computed: {
...mapState(['staticPages']),
......
......@@ -34,7 +34,7 @@
import { mapState } from 'vuex';
export default {
name: 'WithRightMenu',
name: 'Mentions',
data() {
return {
......
......@@ -4,29 +4,29 @@
<div class="fourteen wide column">
<img
class="ui centered small image"
:src="logo"
:src="appLogo"
alt="Logo de l'application"
>
<h2 class="ui center aligned icon header">
<div class="content">
{{ APPLICATION_NAME }}
{{ appName }}
<div class="sub header">
{{ APPLICATION_ABSTRACT }}
{{ appAbstract }}
</div>
</div>
</h2>
</div>
</div>
<div class="row">
<div class="six wide column">
<div
v-if="$route.name === 'login'"
class="six wide column"
>
<h3 class="ui horizontal divider header">
CONNEXION
</h3>
<div
v-if="form.errors"
class="ui warning message"
>
<div :class="['ui warning message', {'closed': !errors.global}]">
<div class="header">
Les informations d'identification sont incorrectes.
</div>
......@@ -39,29 +39,29 @@
type="post"
@submit.prevent="login"
>
<div class="ui stacked secondary segment">
<div class="six field required">
<div class="ui secondary segment">
<div class="six field">
<div class="ui left icon input">
<i
class="user icon"
aria-hidden="true"
/>
<input
v-model="username_value"
v-model="loginForm.username"
type="text"
name="username"
placeholder="Utilisateur"
>
</div>
</div>
<div class="six field required">
<div class="six field">
<div class="ui left icon input">
<i
class="lock icon"
aria-hidden="true"
/>
<input
v-model="password_value"
v-model="loginForm.password"
type="password"
name="password"
placeholder="Mot de passe"
......@@ -77,70 +77,467 @@
</div>
</form>
</div>
<div
v-else-if="$route.name === 'signup'"
class="six wide column"
>
<h3 class="ui horizontal divider header">
INSCRIPTION
</h3>
<div :class="['ui warning message', {'closed': !error}]">
{{ error }}
</div>
<form
class="ui form"
role="form"
type="post"
@submit.prevent="signup"
>
<div class="ui secondary segment">
<div class="six field">
<div class="ui left icon input">
<i
class="user outline icon"
aria-hidden="true"
/>
<input
v-model="signupForm.first_name"
type="text"
name="first_name"
placeholder="Prénom"
required
>
</div>
</div>
<div class="six field">
<div class="ui left icon input">
<i
class="id card icon"
aria-hidden="true"
/>
<input
v-model="signupForm.last_name"
type="text"
name="last_name"
placeholder="Nom"
required
>
</div>
</div>
<div class="six field">
<div class="ui left icon input">
<i
class="envelope icon"
aria-hidden="true"
/>
<input
v-model="signupForm.email"
type="email"
name="email"
placeholder="Adresse courriel"
required
>
</div>
</div>
<div class="six field">
<div class="ui left icon input">
<i
class="user icon"
aria-hidden="true"
/>
<input
v-model="signupForm.username"
type="text"
name="username"
placeholder="Utilisateur"
disabled
>
</div>
</div>
<div :class="['six field', {'error': errors.passwd}]">
<div class="ui action left icon input">
<i
class="lock icon"
aria-hidden="true"
/>
<input
v-model="signupForm.password"
:type="showPwd ? 'text' : 'password'"
name="password"
placeholder="Mot de passe"
required
@blur="isValidPwd"
>
<button
class="ui icon button"
@click="showPwd = !showPwd"
>
<i :class="[showPwd ? 'eye slash' : 'eye', 'icon']" />
</button>
</div>
</div>
<div :class="['six field', {'error': errors.comments}]">
<div class="ui left icon input">
<i
class="pencil icon"
aria-hidden="true"
/>
<input
v-model="signupForm.comments"
type="text"
name="comments"
:placeholder="commentsFieldLabel || `Commentaires`"
:required="commentsFieldRequired"
>
</div>
</div>
<div
v-if="usersGroupsOptions.length > 0"
class="six field"
>
<div class="ui divider" />
<Multiselect
v-model="usersGroupsSelections"
:options="usersGroupsOptions"
:multiple="true"
track-by="value"
label="name"
select-label=""
selected-label=""
deselect-label=""
:searchable="false"
:placeholder="'Sélectionez un ou plusieurs groupe de la liste ...'"
/>
<p v-if="adminMail">
Si le groupe d'utilisateurs recherché n'apparaît pas, vous pouvez demander à
<a :href="'mailto:'+adminMail">{{ adminMail }}</a> de le créer
</p>
</div>
<button
:class="['ui fluid large teal submit button']"
type="submit"
>
Valider
</button>
</div>
</form>
</div>
<div
v-else-if="$route.name === 'sso-signup-success'"
class="six wide column"
>
<h3 class="ui horizontal divider header">
INSCRIPTION RÉUSSIE
</h3>
<h4 class="ui center aligned icon header">
<div class="content">
<p
v-if="username"
class="sub header"
>
Le compte pour le nom d'utilisateur <strong>{{ username }}</strong> a été créé
</p>
<p>
Un e-mail de confirmation vient d'être envoyé à l'adresse indiquée.
</p>
<p class="sub header">
Merci de bien vouloir suivre les instructions données afin de finaliser la création de votre compte.
</p>
</div>
</h4>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapMutations } from 'vuex';
import Multiselect from 'vue-multiselect';
import userAPI from '../services/user-api';
export default {
name: 'Login',
components: {
Multiselect
},
props: {
username: {
type: String,
default: null
}
},
data() {
return {
username_value: null,
password_value: null,
logged: false,
form: {
errors: null,
loginForm: {
username: null,
password: null,
},
signupForm: {
username: null,
password: null,
first_name: null,
last_name: null,
email: null,
comments: null,
usersgroups: [],
},
errors: {
global: null,
passwd: null,
comments: null,
},
showPwd: false,
};
},
computed: {
logo() {
return this.$store.state.configuration.VUE_APP_LOGO_PATH;
...mapState({
appLogo: state => state.configuration.VUE_APP_LOGO_PATH,
appName: state => state.configuration.VUE_APP_APPLICATION_NAME,
appAbstract: state => state.configuration.VUE_APP_APPLICATION_ABSTRACT,
adminMail: state => state.configuration.VUE_APP_ADMIN_MAIL,
ssoSignupUrl: state => state.configuration.VUE_APP_SSO_SIGNUP_URL,
commentsFieldLabel: state => state.configuration.VUE_APP_SIGNUP_COMMENTS_FIELD_LABEL,
commentsFieldRequired: state => state.configuration.VUE_APP_SIGNUP_COMMENTS_FIELD_REQUIRED,
}),
...mapGetters(['usersGroupsOptions']),
usersGroupsSelections: {
get() {
return this.usersGroupsOptions.filter((el) => this.signupForm.usersgroups?.includes(el.value));
},
set(newValue) {
this.signupForm.usersgroups = newValue.map(el => el.value);
}
},
APPLICATION_NAME() {
return this.$store.state.configuration.VUE_APP_APPLICATION_NAME;
error() {
return this.errors.global || this.errors.passwd || this.errors.comments;
}
},
watch: {
'signupForm.first_name': function (newValue, oldValue) {
if (newValue !== oldValue) {
this.signupForm.username = `${newValue.charAt(0)}${this.signupForm.last_name}`.toLowerCase().replace(/\s/g, '');
}
},
'signupForm.last_name': function (newValue, oldValue) {
if (newValue !== oldValue) {
this.signupForm.username = `${this.signupForm.first_name.charAt(0)}${newValue}`.toLowerCase().replace(/\s/g, '');
}
},
APPLICATION_ABSTRACT() {
return this.$store.state.configuration.VUE_APP_APPLICATION_ABSTRACT;
'signupForm.password': function (newValue, oldValue) {
if (newValue.length >= 8) {
if (newValue !== oldValue) {
this.isValidPwd();
}
} else {
this.errors.passwd = null;
}
},
username(newValue, oldValue) {
if (newValue !== oldValue) {
this.loginForm.username = newValue;
}
}
},
created() {
if (this.$route.name === 'signup') {
this.$store.dispatch('GET_USERS_GROUPS'); // récupére les groupes d'utilisateurs pour extra_forms
}
},
mounted() {
if (this.$store.state.user) {
this.$store.commit(
'DISPLAY_MESSAGE',
{ comment: 'Vous êtes déjà connecté, vous allez être redirigé vers la page précédente.' }
);
setTimeout(() => this.$store.dispatch('REDIRECT_AFTER_LOGIN'), 3100);
if (this.$route.name === 'login') {
if (this.$store.state.user) {
this.DISPLAY_MESSAGE({ header: 'Vous êtes déjà connecté', comment: 'Vous allez être redirigé vers la page précédente.' });
setTimeout(() => this.$store.dispatch('REDIRECT_AFTER_LOGIN'), 3100);
}
}
},
methods: {
...mapMutations(['DISPLAY_MESSAGE']),
login() {
this.$store
.dispatch('LOGIN', {
username: this.username_value,
password: this.password_value,
username: this.loginForm.username,
password: this.loginForm.password,
})
.then((status) => {
if (status === 200) {
this.form.errors = null;
this.errors.global = null;
} else if (status === 'error') {
this.form.errors = status;
this.errors.global = status;
}
})
.catch();
},
async signup() {
if (this.hasUnvalidFields()) return;
// Étape 1 : Création de l'utilisateur auprès du service d'authentification SSO si nécessaire
if (this.ssoSignupUrl) {
const ssoResponse = await userAPI.signup({
...this.signupForm,
// Ajout du label personnalisé pour affichage plus précis dans admin OGS
comments: `{"${this.commentsFieldLabel}":"${this.signupForm.comments}"}`,
// Pour permettre la visualisation dans OGS Maps, l'utilisateur doit être ajouté à un groupe OGS, mis en dur pour aller vite pour l'instant
usergroup_roles:[{ organisation: { id: 1 } }]
}, this.ssoSignupUrl);
if (ssoResponse.status !== 201) {
if (ssoResponse.status === 400) {
this.errors.global = 'Un compte associé à ce courriel existe déjà';
} else {
this.errors.global = `Erreur lors de l'inscription: ${ssoResponse.data?.detail || 'Problème inconnu'}`;
}
return; // Stoppe la fonction si l'inscription SSO échoue
} else {
this.signupForm.username = ssoResponse.data.username;
this.signupForm.first_name = ssoResponse.data.first_name;
this.signupForm.last_name = ssoResponse.data.last_name;
}
}
// Étape 2 : Création de l'utilisateur dans Geocontrib
const response = await userAPI.signup(this.signupForm);
if (response.status !== 201) {
const errorMessage = response.data
? Object.values(response.data)?.[0]?.[0] || 'Problème inconnu'
: 'Problème inconnu';
this.errors.global = `Erreur lors de l'inscription: ${errorMessage}`;
return;
}
this.DISPLAY_MESSAGE({ header: 'Inscription réussie !', comment: `Bienvenue sur la plateforme ${this.signupForm.username}.`, level: 'positive' });
if (this.ssoSignupUrl) {
setTimeout(() => {
this.$router.push({ name: 'sso-signup-success', params: { username: this.signupForm.username } });
}, 3100);
} else {
setTimeout(() => {
this.$router.push({ name: 'login', params: { username: this.signupForm.username } });
}, 3100);
}
},
isValidPwd() {
const regPwd = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z\d/$&+,:;=?#|'<>.^*()%!-]{8,}$/;
if (!regPwd.test(this.signupForm.password)) {
this.errors.passwd = `Le mot de passe doit comporter au moins 8 caractères, dont 1 majuscule, 1 minuscule et 1 chiffre.
Vous pouvez utiliser les caractères spéciaux suivants : /$ & + , : ; = ? # | ' < > . ^ * ( ) % ! -.`;
return false;
}
this.errors.passwd = null;
return true;
},
hasUnvalidFields() {
const { last_name, email, first_name, comments } = this.signupForm;
if (this.commentsFieldRequired && !comments) {
this.errors.comments = `Le champ ${ this.commentsFieldLabel || 'Commentaires'} est requis`;
return true;
} else {
this.errors.comments = null;
}
if (email && last_name && first_name) {
this.errors.global = null;
} else {
this.errors.global = 'Certains champs requis ne sont pas renseignés';
return true;
}
return !this.isValidPwd();
}
},
};
</script>
<style lang="less" scoped>
#login-page {
max-width: 500px;
min-width: 200px;
margin: 3em auto;
.ui.message {
min-height: 0px;
&.closed {
overflow: hidden;
opacity: 0;
padding: 0;
max-height: 0px;
}
}
input[required] {
background-image: linear-gradient(45deg, transparent, transparent 50%, rgb(209, 0, 0) 50%, rgb(209, 0, 0) 100%);
background-position: top right;
background-size: .5em .5em;
background-repeat: no-repeat;
}
}
p {
margin: 1em 0 !important;
}
</style>
<style>
.multiselect__placeholder {
position: absolute;
width: calc(100% - 48px);
overflow: hidden;
text-overflow: ellipsis;
}
.multiselect__tags {
position: relative;
}
/* keep font-weight from overide of semantic classes */
.multiselect__placeholder,
.multiselect__content,
.multiselect__tags {
font-weight: initial !important;
}
/* keep placeholder eigth */
.multiselect .multiselect__placeholder {
margin-bottom: 9px !important;
padding-top: 1px;
}
/* keep placeholder height when opening dropdown without selection */
input.multiselect__input {
padding: 3px 0 0 0 !important;
}
/* keep placeholder height when opening dropdown with already a value selected */
.multiselect__tags .multiselect__single {
padding: 1px 0 0 0 !important;
margin-bottom: 9px;
}
</style>
\ No newline at end of file
<template>
<div
id="project-features"
class="page grid"
>
<div id="project-features">
<div class="column">
<FeaturesListAndMapFilters
:show-map="showMap"
:features-count="featuresCount"
:features-count="featuresCountDisplay"
:pagination="pagination"
:all-selected="allSelected"
:edit-attributes-feature-type="editAttributesFeatureType"
@set-filter="setFilters"
@reset-pagination="resetPagination"
@fetch-features="fetchPagedFeatures"
@show-map="setShowMap"
@edit-status="modifyStatus"
@toggle-delete-modal="toggleDeleteModal"
/>
<div
v-if="showMap"
class="ui tab active map-container visible"
data-tab="map"
>
<div class="hider" />
<div class="loader-container">
<div
id="map"
ref="map"
/>
<SidebarLayers v-if="basemaps && map" />
<Geocoder />
<div
id="popup"
class="ol-popup"
:class="['ui tab active map-container', { 'visible': showMap }]"
data-tab="map"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="map"
ref="map"
>
<SidebarLayers
v-if="basemaps && map"
ref="sidebar"
/>
<Geolocation />
<Geocoder />
</div>
<div
id="popup-content"
/>
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div>
<FeatureListTable
v-show="!showMap"
:paginated-features="paginatedFeatures"
:page-numbers="pageNumbers"
:all-selected="allSelected"
:checked-features.sync="checkedFeatures"
:features-count="featuresCount"
:pagination="pagination"
:sort="sort"
:edit-attributes-feature-type.sync="editAttributesFeatureType"
:queryparams="queryparams"
@update:page="handlePageChange"
@update:sort="handleSortChange"
@update:allSelected="handleAllSelectedChange"
/>
<Transition name="fadeIn">
<div
v-if="loading"
class="ui inverted dimmer active"
>
<div class="ui text loader">
Récupération des signalements en cours...
</div>
</div>
</Transition>
</div>
<FeatureListTable
v-else
:paginated-features="paginatedFeatures"
:page-numbers="pageNumbers"
:checked-features.sync="checkedFeatures"
:features-count="featuresCount"
:pagination="pagination"
:sort="sort"
@update:page="handlePageChange"
@update:sort="handleSortChange"
/>
<!-- MODAL ALL DELETE FEATURE TYPE -->
......@@ -61,7 +81,7 @@
>
<div
:class="[
'ui mini modal subscription',
'ui mini modal',
{ 'active visible': isDeleteModalOpen },
]"
>
......@@ -76,8 +96,11 @@
aria-hidden="true"
/>
Êtes-vous sûr de vouloir effacer
<span v-if="checkedFeatures.length === 1"> un signalement ? </span>
<span v-else> ces {{ checkedFeatures.length }} signalements ? </span>
<span v-if="checkedFeatures.length === 1"> un signalement&nbsp;?</span>
<span v-else-if="checkedFeatures.length > 1">ces {{ checkedFeatures.length }} signalements&nbsp;?</span>
<span v-else>tous les signalements sélectionnés&nbsp;?<br>
<small>Seuls ceux que vous êtes autorisé à supprimer seront réellement effacés.</small>
</span>
</div>
<div class="actions">
<button
......@@ -102,8 +125,9 @@ import Geocoder from '@/components/Map/Geocoder';
import featureAPI from '@/services/feature-api';
import FeaturesListAndMapFilters from '@/components/Project/FeaturesListAndMap/FeaturesListAndMapFilters';
import SidebarLayers from '@/components/Map/SidebarLayers';
import FeatureListTable from '@/components/Project/FeaturesListAndMap/FeatureListTable';
import SidebarLayers from '@/components/Map/SidebarLayers';
import Geolocation from '@/components/Map/Geolocation';
const initialPagination = {
currentPage: 1,
......@@ -119,34 +143,34 @@ export default {
FeaturesListAndMapFilters,
SidebarLayers,
Geocoder,
Geolocation,
FeatureListTable,
},
data() {
return {
allSelected: false,
editAttributesFeatureType: null,
currentLayer: null,
featuresCount: 0,
featuresWithGeomCount:0,
form: {
type: {
selected: '',
},
status: {
selected: '',
},
type: [],
status: [],
title: null,
},
isDeleteModalOpen: false,
loading: false,
lat: null,
lng: null,
map: null,
paginatedFeatures: [],
pagination: { ...initialPagination },
projectSlug: this.$route.params.slug,
queryparams: {},
showMap: true,
showAddFeature: false,
showModifyStatus: false,
sort: {
column: '',
column: 'updated_on',
ascending: true,
},
zoom: null,
......@@ -154,6 +178,9 @@ export default {
},
computed: {
...mapState([
'isOnline'
]),
...mapState('projects', [
'project',
]),
......@@ -175,68 +202,46 @@ export default {
pageNumbers() {
return this.createPagesArray(this.featuresCount, this.pagination.pagesize);
},
featuresCountDisplay() {
return this.showMap ? this.featuresWithGeomCount : this.featuresCount;
}
},
watch: {
/*map(newValue) {
if (newValue && this.paginatedFeatures && this.paginatedFeatures.length) {
if (this.currentLayer) {
this.map.removeLayer(this.currentLayer);
}
this.currentLayer = mapService.addFeatures(
this.paginatedFeatures,
{},
this.feature_types
);
}
},*/
/*paginatedFeatures: {
deep: true,
handler(newValue, oldValue) {
if (newValue && newValue.length && newValue !== oldValue && this.map) {
if (this.currentLayer) {
this.map.removeLayer(this.currentLayer);
this.currentLayer = null;
}
this.currentLayer = mapService.addFeatures(
newValue,
{},
this.feature_types
);
} else if (newValue && newValue.length === 0) {
if (this.currentLayer) {
this.map.removeLayer(this.currentLayer);
this.currentLayer = null;
}
}
isOnline(newValue, oldValue) {
if (newValue != oldValue && !newValue) {
this.DISPLAY_MESSAGE({
comment: 'Les signalements du projet non mis en cache ne sont pas accessibles en mode déconnecté',
});
}
},*/
},
},
mounted() {
this.UPDATE_CHECKED_FEATURES([]); // empty for when turning back from edit attributes page
if (!this.project) {
// Chargements des features et infos projet en cas d'arrivée directe sur la page ou de refresh
this.$store.dispatch('projects/GET_PROJECT', this.projectSlug);
this.$store
.dispatch('projects/GET_PROJECT_INFO', this.projectSlug)
.then(() => this.initMap());
Promise.all([
this.$store.dispatch('projects/GET_PROJECT', this.projectSlug),
this.$store.dispatch('projects/GET_PROJECT_INFO', this.projectSlug)
]).then(()=> this.initPage());
} else {
this.initMap();
this.initPage();
}
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
//* allow user to change page if ever stuck on loader
this.$store.commit('DISCARD_LOADER');
this.loading = false;
},
methods: {
...mapMutations([
'DISPLAY_MESSAGE',
]),
...mapActions('feature', [
'GET_PROJECT_FEATURES',
'SEND_FEATURE',
'DELETE_FEATURE',
]),
......@@ -244,93 +249,211 @@ export default {
'UPDATE_CHECKED_FEATURES'
]),
setShowMap(e) {
this.showMap = e;
setShowMap(newValue) {
this.showMap = newValue;
// expanded sidebar is visible under the list, even when the map is closed (position:absolute), solved by closing it when switching to list
if (newValue === false && this.$refs.sidebar) this.$refs.sidebar.toggleSidebar(false);
},
resetPagination() {
this.pagination = { ...initialPagination };
},
/**
* Updates the filters based on the provided key-value pair.
*
* @param {Object} e - The key-value pair representing the filter to update.
*/
setFilters(e) {
const filter = Object.keys(e)[0];
const value = Object.values(e)[0];
if (filter === 'title') {
this.form[filter] = value;
} else {
this.form[filter].selected = value;
let value = Object.values(e)[0];
if (value && Array.isArray(value)) {
value = value.map(el => el.value);
}
},
toggleModifyStatus() {
this.showModifyStatus = !this.showModifyStatus;
this.showAddFeature = false;
this.form[filter] = value;
},
toggleDeleteModal() {
this.isDeleteModalOpen = !this.isDeleteModalOpen;
},
clickOutsideDropdown(e) {
if (!e.target.closest('#button-dropdown')) {
this.showModifyStatus = false;
setTimeout(() => { //* timout necessary to give time to click on link to add feature
this.showAddFeature = false;
}, 500);
}
},
/**
* Modifie le statut des objets sélectionnés.
*
* Cette méthode prend en charge deux cas :
* 1. Si tous les objets sont sélectionnés (`allSelected`), une requête unique en mode "bulk update" est envoyée
* au backend pour modifier le statut de tous les objets correspondant aux critères.
* 2. Si des objets spécifiques sont sélectionnés (`checkedFeatures`), ils sont traités un par un de manière
* récursive. Chaque objet modifié est retiré de la liste des objets sélectionnés.
*
* En cas d'erreur (réseau ou backend), un message d'erreur est affiché, et les données sont rafraîchies.
* Si tous les objets sont modifiés avec succès, un message de confirmation est affiché.
*
* @param {string} newStatus - Le nouveau statut à appliquer aux objets sélectionnés.
* @returns {Promise<void>} - Une promesse qui se résout lorsque tous les objets ont été traités.
*/
async modifyStatus(newStatus) {
if (this.checkedFeatures.length > 0) {
const feature_id = this.checkedFeatures[0];
const feature = this.clickedFeatures.find((el) => el.feature_id === feature_id);
if (this.allSelected) {
// Cas : Modification en masse de tous les objets
try {
// Update additional query parameters based on the current filter states.
this.updateQueryParams();
const queryString = new URLSearchParams(this.queryparams).toString();
const response = await featureAPI.projectFeatureBulkUpdateStatus(this.projectSlug, queryString, newStatus);
if (response && response.data) {
// Affiche un message basé sur la réponse du backend
this.DISPLAY_MESSAGE({
comment: response.data.message,
level: response.data.level,
});
}
} catch (error) {
// Gère les erreurs de type Axios (400, 500, etc.)
if (error.response && error.response.data) {
this.DISPLAY_MESSAGE({
comment: error.response.data.error || 'Une erreur est survenue.',
level: 'negative',
});
} else {
// Gère les erreurs réseau ou autres
this.DISPLAY_MESSAGE({
comment: 'Impossible de communiquer avec le serveur.',
level: 'negative',
});
}
}
// Rafraîchit les données après un traitement global
this.resetPagination();
this.fetchPagedFeatures();
} else if (this.checkedFeatures.length > 0) {
// Cas : Traitement des objets un par un
const feature_id = this.checkedFeatures[0]; // Récupère l'ID du premier objet sélectionné
const feature = this.clickedFeatures.find((el) => el.feature_id === feature_id); // Trouve l'objet complet
if (feature) {
featureAPI.updateFeature({
// Envoie une requête pour modifier le statut d'un objet spécifique
const response = await featureAPI.updateFeature({
feature_id,
feature_type__slug: feature.feature_type,
project__slug: this.projectSlug,
newStatus
}).then((response) => {
if (response && response.data && response.status === 200) {
const newCheckedFeatures = [...this.checkedFeatures];
newCheckedFeatures.splice(this.checkedFeatures.indexOf(response.data.id), 1);
this.UPDATE_CHECKED_FEATURES(newCheckedFeatures);
this.modifyStatus(newStatus);
} else {
this.$store.commit('DISPLAY_MESSAGE', {
comment: `Le signalement ${feature.title} n'a pas pu être modifié`,
level: 'negative'
});
this.fetchPagedFeatures();
}
newStatus,
});
if (response && response.data && response.status === 200) {
// Supprime l'objet traité de la liste des objets sélectionnés
const newCheckedFeatures = [...this.checkedFeatures];
newCheckedFeatures.splice(this.checkedFeatures.indexOf(response.data.id), 1);
this.UPDATE_CHECKED_FEATURES(newCheckedFeatures);
// Rappel récursif pour traiter l'objet suivant
this.modifyStatus(newStatus);
} else {
// Affiche un message d'erreur si la modification échoue
this.DISPLAY_MESSAGE({
comment: `Le signalement ${feature.title} n'a pas pu être modifié.`,
level: 'negative',
});
// Rafraîchit les données en cas d'erreur
this.fetchPagedFeatures();
}
}
} else {
this.fetchPagedFeatures();
this.$store.commit('DISPLAY_MESSAGE', {
comment: 'Tous les signalements ont été modifié avec succès.',
level: 'positive'
// Cas : Tous les objets ont été traités après le traitement récursif
this.fetchPagedFeatures(); // Rafraîchit les données pour afficher les mises à jour
this.DISPLAY_MESSAGE({
comment: 'Tous les signalements ont été modifiés avec succès.',
level: 'positive',
});
}
},
deleteAllFeatureSelection() {
const initialFeaturesCount = this.featuresCount;
const initialCurrentPage = this.pagination.currentPage;
const promises = this.checkedFeatures.map(
(feature_id) => this.DELETE_FEATURE({ feature_id, noFeatureType: true })
);
Promise.all(promises).then((response) => {
const deletedFeaturesCount = response.reduce((acc, curr) => curr.status === 204 ? acc += 1 : acc, 0);
const newFeaturesCount = initialFeaturesCount - deletedFeaturesCount;
const newPagesArray = this.createPagesArray(newFeaturesCount, this.pagination.pagesize);
const newLastPageNum = newPagesArray[newPagesArray.length - 1];
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', []);
if (initialCurrentPage > newLastPageNum) { //* if page doesn't exist anymore
this.toPage(newLastPageNum); //* go to new last page
} else {
this.fetchPagedFeatures();
/**
* Supprime tous les objets sélectionnés.
*
* Cette méthode prend en charge deux cas :
* 1. Si tous les objets sont sélectionnés (`allSelected`), une requête unique en mode "bulk delete" est envoyée
* au backend pour supprimer tous les objets correspondant aux critères. La liste des résultats est ensuite rafraichie.
* 2. Si des objets spécifiques sont sélectionnés (`checkedFeatures`), ils sont traités un par un de manière
* récursive. Cette méthode utilise `Promise.all` pour envoyer les requêtes de suppression en parallèle
* pour tous les objets dans la liste `checkedFeatures`. Après suppression, elle met à jour la pagination
* et rafraîchit les objets affichés pour refléter les changements.
*
* En cas d'erreur (réseau ou backend), un message d'erreur est affiché, et les données sont rafraîchies.
* Si tous les objets sont supprimé avec succès, un message de confirmation est affiché.
*
* @returns {Promise<void>} - Une promesse qui se résout lorsque tous les objets ont été traités.
*/
async deleteAllFeatureSelection() {
if (this.allSelected) {
// Cas : Suppression en masse de tous les objets
try {
// Update additional query parameters based on the current filter states.
this.updateQueryParams();
const queryString = new URLSearchParams(this.queryparams).toString();
const response = await featureAPI.projectFeatureBulkDelete(this.projectSlug, queryString);
if (response && response.data) {
// Affiche un message basé sur la réponse du backend
this.DISPLAY_MESSAGE({
comment: response.data.message,
level: response.data.level,
});
}
} catch (error) {
// Gère les erreurs de type Axios (400, 500, etc.)
if (error.response && error.response.data) {
this.DISPLAY_MESSAGE({
comment: error.response.data.error || 'Une erreur est survenue.',
level: 'negative',
});
} else {
// Gère les erreurs réseau ou autres
this.DISPLAY_MESSAGE({
comment: 'Impossible de communiquer avec le serveur.',
level: 'negative',
});
}
}
})
.catch((err) => console.error(err));
// Rafraîchit les données après un traitement global
this.resetPagination();
this.fetchPagedFeatures();
} else {
// Sauvegarde le nombre total d'objets
const initialFeaturesCount = this.featuresCount;
// Sauvegarde la page actuelle
const initialCurrentPage = this.pagination.currentPage;
// Crée une liste de promesses pour supprimer chaque objet sélectionné
const promises = this.checkedFeatures.map((feature_id) =>
this.DELETE_FEATURE({ feature_id, noFeatureType: true })
);
// Exécute toutes les suppressions en parallèle
Promise.all(promises)
.then((response) => {
// Compte le nombre d'objets supprimés avec succès
const deletedFeaturesCount = response.reduce(
(acc, curr) => (curr.status === 204 ? acc + 1 : acc),
0
);
// Calcule le nouveau total d'objets
const newFeaturesCount = initialFeaturesCount - deletedFeaturesCount;
// Recalcule les pages
const newPagesArray = this.createPagesArray(newFeaturesCount, this.pagination.pagesize);
// Dernière page valide
const newLastPageNum = newPagesArray[newPagesArray.length - 1];
// Réinitialise la sélection
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', []);
if (initialCurrentPage > newLastPageNum) {
// Navigue à la dernière page valide si la page actuelle n'existe plus
this.toPage(newLastPageNum);
} else {
// Rafraîchit les objets affichés
this.fetchPagedFeatures();
}
})
// Gère les erreurs éventuelles
.catch((err) => console.error(err));
}
// Ferme la modale de confirmation de suppression
this.toggleDeleteModal();
},
......@@ -340,6 +463,14 @@ export default {
}
},
initPage() {
this.sort = {
column: this.project.feature_browsing_default_sort.replace('-', ''),
ascending: this.project.feature_browsing_default_sort.includes('-')
};
this.initMap();
},
initMap() {
this.zoom = this.$route.query.zoom || '';
this.lat = this.$route.query.lat || '';
......@@ -356,37 +487,29 @@ export default {
lng: this.lng,
mapDefaultViewCenter,
mapDefaultViewZoom,
interactions : { doubleClickZoom :false,mouseWheelZoom:true,dragPan:true }
maxZoom: this.project.map_max_zoom_level,
interactions : { doubleClickZoom :false, mouseWheelZoom:true, dragPan:true },
fullScreenControl: true,
geolocationControl: true,
});
//this.fetchBboxNfit(); cette methode est appelée a nouveau par la suite donc pas utile ici
document.addEventListener('change-layers-order', (event) => {
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
mapService.updateOrder(event.detail.layers.slice().reverse());
this.$nextTick(() => {
const mvtUrl = `${this.API_BASE_URL}features.mvt`;
mapService.addVectorTileLayer({
url: mvtUrl,
project_slug: this.projectSlug,
featureTypes: this.feature_types,
formFilters: this.form,
queryParams: this.queryparams,
});
});
// --------- End sidebar events ----------
let self=this;
setTimeout(() => {
const project_id = this.projectSlug.split('-')[0];
const mvtUrl = `${this.API_BASE_URL}features.mvt/`;
mapService.addVectorTileLayer(
mvtUrl,
project_id,
self.projectSlug,
self.feature_types,
self.form
);
}, 1000);
this.fetchPagedFeatures();
},
fetchBboxNfit(queryParams) {
fetchBboxNfit(queryString) {
featureAPI
.getFeaturesBbox(this.projectSlug, queryParams)
.getFeaturesBbox(this.projectSlug, queryString)
.then((bbox) => {
if (bbox) {
mapService.fitBounds(bbox);
......@@ -410,55 +533,74 @@ export default {
return result;
},
buildQueryString() {
let urlParams = '';
const typeFilter = this.getFeatureTypeSlug(this.form.type.selected);
const statusFilter = this.form.status.selected.value;
if (typeFilter) {
urlParams += `&feature_type_slug=${typeFilter}`;
/**
* Updates the query parameters based on the current state of the pagination and form filters.
* This function sets various parameters like offset, feature_type_slug, status__value, title,
* and ordering to be used in an API request and to filter hidden features on mvt tiles.
*/
updateQueryParams() {
// empty queryparams to remove params when removed from the form
this.queryparams = {};
// Update the 'offset' parameter based on the current pagination start value.
this.queryparams['offset'] = this.pagination.start;
// Set 'feature_type_slug' if a type is selected in the form.
if (this.form.type.length > 0) {
this.queryparams['feature_type_slug'] = this.form.type;
}
if (statusFilter) {
urlParams += `&status__value=${statusFilter}`;
// Set 'status__value' if a status is selected in the form.
if (this.form.status.length > 0) {
this.queryparams['status__value'] = this.form.status;
}
// Set 'title' if a title is entered in the form.
if (this.form.title) {
urlParams += `&title=${this.form.title}`;
}
if (this.sort.column) {
urlParams += `&ordering=${
this.sort.ascending ? '-' : ''
}${this.getAvalaibleField(this.sort.column)}`;
this.queryparams['title'] = this.form.title;
}
return urlParams;
// Update the 'ordering' parameter based on the current sorting state.
// Prepends a '-' for descending order if sort.ascending is false.
this.queryparams['ordering'] = `${this.sort.ascending ? '-' : ''}${this.getAvalaibleField(this.sort.column)}`;
},
fetchPagedFeatures(newUrl) {
let url = `${this.API_BASE_URL}projects/${this.projectSlug}/feature-paginated/?limit=${this.pagination.pagesize}&offset=${this.pagination.start}`;
//* if receiving next & previous url (// todo : might be not used anymore, to check)
if (newUrl && typeof newUrl === 'string') {
//newUrl = newUrl.replace("8000", "8010"); //* for dev uncomment to use proxy link
url = newUrl;
/**
* Fetches paginated feature data from the API.
* This function is called to retrieve a specific page of features based on the current pagination settings and any applied filters.
* If the application is offline, it displays a message and does not proceed with the API call.
*/
fetchPagedFeatures() {
// Check if the application is online; if not, display a message and return.
if (!this.isOnline) {
this.DISPLAY_MESSAGE({
comment: 'Les signalements du projet non mis en cache ne sont pas accessibles en mode déconnecté',
});
return;
}
const queryString = this.buildQueryString();
url += queryString;
this.$store.commit(
'DISPLAY_LOADER',
'Récupération des signalements en cours...'
);
// Display a loading message.
this.loading = true;
// Update additional query parameters based on the current filter states.
this.updateQueryParams();
const queryString = new URLSearchParams(this.queryparams).toString();
// Construct the base URL with query parameters.
const url = `${this.API_BASE_URL}projects/${this.projectSlug}/feature-paginated/?limit=${this.pagination.pagesize}&${queryString}`;
// Make an API call to get the paginated features.
featureAPI.getPaginatedFeatures(url)
.then((data) => {
if (data) {
// Update the component state with the data received from the API.
this.featuresCount = data.count;
this.featuresWithGeomCount = data.geom_count;
this.previous = data.previous;
this.next = data.next;
this.paginatedFeatures = data.results;
}
//* bbox needs to be updated with the same filters
// If there are features, update the bounding box.
if (this.paginatedFeatures.length) {
this.fetchBboxNfit(queryString);
}
this.onFilterChange(); //* use paginated event to watch change in filters and modify features on map
this.$store.commit('DISCARD_LOADER');
// Trigger actions on filter change.
this.onFilterChange();
// Hide the loading message.
this.loading = false;
});
},
......@@ -487,10 +629,15 @@ export default {
handleSortChange(sort) {
this.sort = sort;
this.fetchPagedFeatures({
filterType: undefined,
filterValue: undefined,
});
this.fetchPagedFeatures();
},
handleAllSelectedChange(isChecked) {
this.allSelected = isChecked;
// Si des sélections existent, tout déselectionner
if (this.checkedFeatures.length > 0) {
this.UPDATE_CHECKED_FEATURES([]);
}
},
toPage(pageNumber) {
......@@ -529,50 +676,92 @@ export default {
<style lang="less" scoped>
#project-features {
margin: 2em auto 1em;
.loader-container {
position: relative;
min-height: 250px; // keep a the spinner above result and below table header
z-index: 1;
.ui.inverted.dimmer.active {
opacity: .6;
}
}
.map-container {
width: 80vw;
transform: translateX(-50%);
margin-left: 50%;
visibility: hidden;
position: absolute;
#map {
min-height: 0;
}
}
.map-container.visible {
visibility: visible;
position: relative;
width: calc(100% - 1em);
.hider {
position: absolute;
width: calc(1em - 1px);
height: 100%;
background-color: white;
z-index: 999999999;
}
width: 100%;
.sidebar-container {
left: calc(-250px + 1em);
left: -250px;
}
.sidebar-container.expanded {
left: 1em;
left: 0;
}
#map {
width: 100%;
min-height: 300px;
height: calc(100vh - 300px);
min-height: 310px;
height: calc(100vh - 310px);
border: 1px solid grey;
/* To not hide the filters */
z-index: 1;
}
}
div.geolocation-container {
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(1.3em + 60px + 34px);
}
@media screen and (max-width: 767px) {
#project-features {
margin: 1em auto 1em;
}
.map-container {
width: 100%;
position: relative;
}
}
.fadeIn-enter-active {
animation: fadeIn .5s;
}
.fadeIn-leave-active {
animation: fadeIn .5s reverse;
}
.transition.fade.in {
-webkit-animation-name: fadeIn;
animation-name: fadeIn
}
@-webkit-keyframes fadeIn {
0% {
opacity: 0
}
100% {
opacity: .9
}
}
@keyframes fadeIn {
0% {
opacity: 0
}
100% {
opacity: .9
}
}
</style>
<template>
<div
id="project-basemaps"
class="page"
>
<div
id="message_info"
class="fullwidth"
>
<div v-if="infoMessage.length > 0">
<div
v-for="(message, index) of infoMessage"
:key="index"
:class="['ui message', message.success ? 'positive' : 'negative']"
style="text-align: left"
>
<div class="header">
<i
class="info circle icon"
aria-hidden="true"
/>
Informations
</div>
{{ message.comment }}
</div>
</div>
</div>
<div id="project-basemaps">
<h1 class="ui header">
Administration des fonds cartographiques
</h1>
<form
id="form-layers"
action="."
method="post"
enctype="multipart/form-data"
class="ui form"
>
<!-- {{ formset.management_form }} -->
<div class="ui buttons">
<a
<button
class="ui compact small icon left floated button green"
type="button"
data-variation="mini"
@click="addBasemap"
>
......@@ -48,12 +21,12 @@
aria-hidden="true"
/>
<span>&nbsp;Créer un fond cartographique</span>
</a>
</button>
</div>
<div
v-if="basemaps"
class="ui"
class="ui margin-bottom margin-top"
>
<BasemapListItem
v-for="basemap in basemaps"
......@@ -61,26 +34,26 @@
:basemap="basemap"
/>
</div>
<div class="margin-top">
<button
type="button"
class="ui teal icon floated button"
@click="saveChanges"
>
<i
class="white save icon"
aria-hidden="true"
/>
Enregistrer les changements
</button>
</div>
<button
v-if="basemaps && basemaps[0] && basemaps[0].title && basemaps[0].layers.length > 0"
type="button"
class="ui teal icon floated button"
@click="saveChanges"
>
<i
class="white save icon"
aria-hidden="true"
/>
Enregistrer les changements
</button>
</form>
</div>
</template>
<script>
import BasemapListItem from '@/components/Project/Basemaps/BasemapListItem.vue';
import { mapState, mapGetters } from 'vuex';
import { mapState, mapGetters, mapMutations } from 'vuex';
export default {
name: 'ProjectBasemaps',
......@@ -91,7 +64,6 @@ export default {
data() {
return {
infoMessage: [],
newBasemapIds: [],
};
},
......@@ -113,6 +85,7 @@ export default {
},
methods: {
...mapMutations(['DISPLAY_MESSAGE']),
addBasemap() {
this.newBasemapIds.push(this.basemapMaxId + 1); //* register new basemaps to seperate post and put
this.$store.commit('map/CREATE_BASEMAP', this.basemapMaxId + 1);
......@@ -141,30 +114,20 @@ export default {
.then((response) => {
const errors = response.filter(
(res) =>
res.status === 200 && res.status === 201 && res.status === 204
res.status !== 200 && res.status !== 201 && res.status !== 204
);
if (errors.length === 0) {
this.infoMessage.push({
success: true,
this.DISPLAY_MESSAGE({
comment: 'Enregistrement effectué.',
level: 'positive'
});
this.newBasemapIds = [];
} else {
this.infoMessage.push({
success: false,
comment: "L'édition des fonds cartographiques a échoué. ",
this.DISPLAY_MESSAGE({
comment: 'L\'édition des fonds cartographiques a échoué.',
level: 'negative'
});
}
document
.getElementById('message_info')
.scrollIntoView({ block: 'end', inline: 'nearest' });
setTimeout(
function () {
this.infoMessage = [];
}.bind(this),
5000
);
})
.catch((error) => {
console.error(error);
......@@ -173,12 +136,4 @@ export default {
},
},
};
</script>
<style lang="less" scoped>
#project-basemaps {
min-width: 300px;
}
</style>
\ No newline at end of file
</script>
\ No newline at end of file
......@@ -3,48 +3,7 @@
<div
v-if="permissions && permissions.can_view_project && project"
id="project-detail"
class="page"
>
<div
id="message"
class="fullwidth"
>
<div
v-if="tempMessage"
class="ui positive message"
>
<p>
<i
class="check icon"
aria-hidden="true"
/>
{{ tempMessage }}
</p>
</div>
</div>
<div
id="message_info"
class="fullwidth"
>
<div
v-if="infoMessage"
class="ui info message"
style="text-align: left"
>
<div class="header">
<i
class="info circle icon"
aria-hidden="true"
/> Informations
</div>
<ul class="list">
{{
infoMessage
}}
</ul>
</div>
</div>
<ProjectHeader
:arrays-offline="arraysOffline"
@retrieveInfo="retrieveProjectInfo"
......@@ -55,51 +14,62 @@
<div class="row">
<div class="eight wide column">
<ProjectFeatureTypes
:loading="projectInfoLoading"
:loading="featureTypesLoading"
:project="project"
@delete="toggleDeleteFeatureTypeModal"
@update="updateAfterImport"
/>
</div>
<div class="eight wide column map-container">
<div
:class="{ active: mapLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Chargement de la carte...
<div class="eight wide column block-map">
<div class="map-container">
<div
id="map"
ref="map"
/>
<div
:class="{ active: mapLoading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Chargement de la carte...
</div>
</div>
<SidebarLayers
v-if="basemaps && map && !projectInfoLoading"
ref="sidebar"
/>
<Geolocation />
<div
id="popup"
class="ol-popup"
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
</div>
<div
id="map"
ref="map"
/>
<div
class="ui button fluid teal"
@click="$router.push({
<router-link
id="features-list"
:to="{
name: 'liste-signalements',
params: { slug: slug },
})"
>
<i class="ui icon arrow right" />
Voir tous les signalements
</div>
<div
id="popup"
class="ol-popup"
}"
custom
>
<a
id="popup-closer"
href="#"
class="ol-popup-closer"
/>
<div
id="popup-content"
/>
</div>
<div
class="ui button fluid teal"
>
<i class="ui icon arrow right" />
Voir tous les signalements
</div>
</router-link>
</div>
</div>
......@@ -107,7 +77,7 @@
<div class="sixteen wide column">
<div class="ui two stackable cards">
<ProjectLastFeatures
:loading="featuresLoading"
ref="lastFeatures"
/>
<ProjectLastComments
:loading="projectInfoLoading"
......@@ -157,6 +127,8 @@ import ProjectLastFeatures from '@/components/Project/Detail/ProjectLastFeatures
import ProjectLastComments from '@/components/Project/Detail/ProjectLastComments';
import ProjectParameters from '@/components/Project/Detail/ProjectParameters';
import ProjectModal from '@/components/Project/Detail/ProjectModal';
import SidebarLayers from '@/components/Map/SidebarLayers';
import Geolocation from '@/components/Map/Geolocation';
export default {
name: 'ProjectDetail',
......@@ -167,7 +139,9 @@ export default {
ProjectLastFeatures,
ProjectLastComments,
ProjectParameters,
ProjectModal
ProjectModal,
SidebarLayers,
Geolocation,
},
filters: {
......@@ -184,8 +158,8 @@ export default {
props: {
message: {
type: String,
default: ''
type: Object,
default: () => {}
}
},
......@@ -199,8 +173,8 @@ export default {
is_suscriber: false,
tempMessage: null,
projectInfoLoading: true,
featureTypesLoading: false,
featureTypeToDelete: null,
featuresLoading: true,
mapLoading: true,
};
},
......@@ -222,20 +196,18 @@ export default {
'feature_types'
]),
...mapState([
'last_comments',
'user',
'user_permissions',
'reloadIntervalId',
]),
...mapState('map', [
'map'
'map',
'basemaps',
'availableLayers',
]),
API_BASE_URL() {
return this.configuration.VUE_APP_DJANGO_API_BASE;
},
isSharedProject() {
return this.$route.path.includes('projet-partage');
},
},
created() {
......@@ -247,32 +219,25 @@ export default {
})
.then((data) => (this.is_suscriber = data.is_suscriber));
}
this.$store.commit('feature/SET_FEATURES', []); //* empty features remaining in case they were in geojson format and will be fetch after map initialization anyway
this.$store.commit('feature-type/SET_FEATURE_TYPES', []); //* empty feature_types remaining from previous project
},
mounted() {
this.retrieveProjectInfo();
if (this.message) {
this.tempMessage = this.message;
document
.getElementById('message')
.scrollIntoView({ block: 'end', inline: 'nearest' });
setTimeout(() => (this.tempMessage = null), 5000); //* hide message after 5 seconds
this.DISPLAY_MESSAGE(this.message);
}
},
destroyed() {
beforeDestroy() {
this.$store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
this.CLEAR_RELOAD_INTERVAL_ID();
this.CLOSE_PROJECT_MODAL();
},
methods: {
...mapMutations([
'CLEAR_RELOAD_INTERVAL_ID',
'DISPLAY_MESSAGE',
'DISPLAY_LOADER',
'DISCARD_LOADER',
]),
...mapMutations('modals', [
'OPEN_PROJECT_MODAL',
......@@ -289,33 +254,25 @@ export default {
'GET_PROJECT_FEATURES'
]),
...mapActions('feature-type', [
'GET_IMPORTS'
'GET_PROJECT_FEATURE_TYPES',
]),
getRouteUrl(url) {
if (this.isSharedProject) {
url = url.replace('projet', 'projet-partage');
}
return url.replace(this.$store.state.configuration.BASE_URL, ''); //* remove duplicate /geocontrib
},
retrieveProjectInfo() {
this.DISPLAY_LOADER('Projet en cours de chargement.');
Promise.all([
this.GET_PROJECT(this.slug),
this.GET_PROJECT_INFO(this.slug)
])
.then(() => {
this.DISCARD_LOADER();
this.projectInfoLoading = false;
setTimeout(() => {
let map = mapService.getMap();
this.$nextTick(() => {
const map = mapService.getMap();
if (map) mapService.destroyMap();
this.initMap();
}, 1000);
});
})
.catch((err) => {
console.error(err);
this.DISCARD_LOADER();
})
.finally(() => {
this.projectInfoLoading = false;
});
},
......@@ -358,13 +315,14 @@ export default {
this.is_suscriber = data.is_suscriber;
this.CLOSE_PROJECT_MODAL();
if (this.is_suscriber) {
this.infoMessage =
'Vous êtes maintenant abonné aux notifications de ce projet.';
this.DISPLAY_MESSAGE({
comment: 'Vous êtes maintenant abonné aux notifications de ce projet.', level: 'positive'
});
} else {
this.infoMessage =
'Vous ne recevrez plus les notifications de ce projet.';
this.DISPLAY_MESSAGE({
comment: 'Vous ne recevrez plus les notifications de ce projet.', level: 'negative'
});
}
setTimeout(() => (this.infoMessage = ''), 3000);
});
},
......@@ -431,67 +389,96 @@ export default {
this.featureTypeToDelete = featureType;
this.OPEN_PROJECT_MODAL('deleteFeatureType');
},
/**
* Initializes the map if the project is accessible and the user has view permissions.
* This method sets up the map, loads vector tile layers, and handles offline features.
*/
async initMap() {
// Check if the project is accessible and the user has view permissions
if (this.project && this.permissions.can_view_project) {
await this.INITIATE_MAP(this.$refs.map);
// Initialize the map using the provided element reference
await this.INITIATE_MAP({ el: this.$refs.map });
// Check for offline features
this.checkForOfflineFeature();
let project_id = this.$route.params.slug.split('-')[0];
// Define the URL for vector tile layers
const mvtUrl = `${this.API_BASE_URL}features.mvt`;
mapService.addVectorTileLayer(
mvtUrl,
project_id,
this.$route.params.slug,
this.feature_types
);
this.mapLoading = false;
// Define parameters for loading layers
const params = {
project_slug: this.slug,
featureTypes: this.feature_types,
queryParams: {
ordering: this.project.feature_browsing_default_sort,
filter: this.project.feature_browsing_default_filter,
},
};
// Add vector tile layers to the map
mapService.addVectorTileLayer({
url: mvtUrl,
...params
});
// Modify offline feature properties (setting color to 'red')
this.arraysOffline.forEach((x) => (x.geojson.properties.color = 'red'));
// Extract offline features from arraysOffline
const featuresOffline = this.arraysOffline.map((x) => x.geojson);
this.GET_PROJECT_FEATURES({
project_slug: this.slug,
ordering: '-created_on',
limit: null,
geojson: true,
})
.then(() => {
this.featuresLoading = false;
mapService.addFeatures(
[...this.features, ...featuresOffline],
{},
this.feature_types,
true
);
})
.catch((err) => {
console.error(err);
this.featuresLoading = false;
// Add offline features to the map if available
if (featuresOffline && featuresOffline.length > 0) {
mapService.addFeatures({
addToMap: true,
features: featuresOffline,
...params
});
}
// Get the bounding box of features and fit the map to it
featureAPI.getFeaturesBbox(this.slug).then((bbox) => {
if (bbox) {
mapService.fitBounds(bbox);
}
this.mapLoading = false; // Mark map loading as complete
});
}
},
updateAfterImport() {
// reload feature types
this.featureTypesLoading = true;
this.GET_PROJECT_FEATURE_TYPES(this.slug)
.then(() => {
this.featureTypesLoading = false;
});
// reload last features
this.$refs.lastFeatures.fetchLastFeatures();
// reload map
const map = mapService.getMap();
if (map) mapService.destroyMap();
this.mapLoading = true;
this.initMap();
},
},
};
</script>
<style lang="less" scoped>
.fullwidth {
width: 100%;
}
.map-container {
.block-map {
display: flex !important;
flex-direction: column;
.map-container {
position: relative;
height: 100%;
#map {
border: 1px solid grey;
}
}
.button {
margin-top: 0.5em;
}
}
div.geolocation-container {
/* each button have .5em space between, zoom buttons are 60px high and full screen button is 34px high */
top: calc(1.1em + 60px);
}
</style>
<template>
<div
id="project-edit"
class="page"
>
<div id="project-edit">
<div
:class="{ active: loading }"
class="ui inverted dimmer"
......@@ -49,7 +46,7 @@
</li>
</ul>
</div>
<div class="field">
<div class="field file-logo">
<label>Illustration du projet</label>
<img
v-if="thumbnailFileSrc.length || form.thumbnail.length"
......@@ -94,14 +91,26 @@
</ul>
</div>
</div>
<div class="field">
<label for="description">Description</label>
<textarea
v-model="form.description"
name="description"
rows="5"
/>
<!-- {{ form.description.errors }} -->
<div class="two fields">
<div class="field">
<label for="description">Description</label>
<textarea
id="editor"
v-model="form.description"
data-preview="#preview"
name="description"
rows="5"
/>
<!-- {{ form.description.errors }} -->
</div>
<div class="field">
<label for="preview">Aperçu</label>
<div
id="preview"
class="description preview"
name="preview"
/>
</div>
</div>
<div class="ui horizontal divider">
......@@ -109,52 +118,10 @@
</div>
<div class="two fields">
<!-- <div class="field">
<label for="archive_feature">Délai avant archivage</label>
<div class="ui right labeled input">
<input
id="archive_feature"
v-model="form.archive_feature"
type="number"
min="0"
oninput="validity.valid||(value=0);"
style="padding: 1px 2px"
name="archive_feature"
@blur="checkEmpty"
>
<div class="ui label">
jour(s)
</div>
</div>
<ul
v-if="errors_archive_feature.length"
id="errorlist-achivage"
class="errorlist"
>
<li>
{{ errors_archive_feature[0] }}
</li>
</ul>
</div>
<div class="field">
<label for="delete_feature">Délai avant suppression</label>
<div class="ui right labeled input">
<input
id="delete_feature"
v-model="form.delete_feature"
type="number"
min="0"
oninput="validity.valid||(value=0);"
style="padding: 1px 2px"
name="delete_feature"
@blur="checkEmpty"
>
<div class="ui label">
jour(s)
</div>
</div>
</div> -->
<div class="required field">
<div
id="published-visibility"
class="required field"
>
<label
for="access_level_pub_feature"
>Visibilité des signalements publiés</label>
......@@ -175,12 +142,15 @@
</li>
</ul>
</div>
<div class="required field">
<div
id="archived-visibility"
class="required field"
>
<label for="access_level_arch_feature">
Visibilité des signalements archivés
</label>
<Dropdown
:options="levelPermissions"
:options="levelPermissionsArc"
:selected="form.access_level_arch_feature.name"
:selection.sync="form.access_level_arch_feature"
/>
......@@ -198,48 +168,171 @@
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="moderation"
v-model="form.moderation"
class="hidden"
type="checkbox"
name="moderation"
>
<label for="moderation">Modération</label>
<div class="two fields">
<div class="fields grouped checkboxes">
<div class="field">
<div class="ui checkbox">
<input
id="moderation"
v-model="form.moderation"
class="hidden"
type="checkbox"
name="moderation"
>
<label for="moderation">Modération</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="is_project_type"
v-model="form.is_project_type"
class="hidden"
type="checkbox"
name="is_project_type"
>
<label for="is_project_type">Est un projet type</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="generate_share_link"
v-model="form.generate_share_link"
class="hidden"
type="checkbox"
name="generate_share_link"
>
<label for="generate_share_link">Génération d'un lien de partage externe</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="fast_edition_mode"
v-model="form.fast_edition_mode"
class="hidden"
type="checkbox"
name="fast_edition_mode"
>
<label for="fast_edition_mode">Mode d'édition rapide de signalements</label>
<div
class="
ui
small
button
circular
compact
absolute-right
icon
teal
"
data-tooltip="Consulter la documentation"
data-position="right center"
data-variation="mini"
@click="goToDocumentationFeature"
>
<i class="question icon" />
</div>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="feature_assignement"
v-model="form.feature_assignement"
class="hidden"
type="checkbox"
name="feature_assignement"
>
<label for="feature_assignement">Activation de l'assignation de signalements aux membres du projet</label>
</div>
</div>
<div class="fields grouped">
<div class="field">
<label for="feature_browsing">Configuration du parcours de signalement</label>
</div>
<div
id="feature_browsing_filter"
class="field inline"
>
<label for="feature_browsing_default_filter">Filtrer sur</label>
<Dropdown
:options="featureBrowsingOptions.filter"
:selected="form.feature_browsing_default_filter.name"
:selection.sync="form.feature_browsing_default_filter"
/>
</div>
<div
id="feature_browsing_sort"
class="field inline"
>
<label for="feature_browsing_default_sort">Trier par</label>
<Dropdown
:options="featureBrowsingOptions.sort"
:selected="form.feature_browsing_default_sort.name"
:selection.sync="form.feature_browsing_default_sort"
/>
</div>
</div>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="is_project_type"
v-model="form.is_project_type"
class="hidden"
type="checkbox"
name="is_project_type"
>
<label for="is_project_type">Est un projet type</label>
<div class="field">
<label>Niveau de zoom maximum de la carte</label>
<div class="map-maxzoom-selector">
<div class="range-container">
<input
v-model="form.map_max_zoom_level"
type="range"
min="0"
max="22"
step="1"
@input="zoomMap"
><output class="range-output-bubble">{{
scalesTable[form.map_max_zoom_level]
}}</output>
</div>
<div class="map-preview">
<label>Aperçu :</label>
<div
id="map"
ref="map"
/>
<div class="no-preview">
pas de fond&nbsp;de&nbsp;carte disponible à&nbsp;cette&nbsp;échelle
</div>
</div>
</div>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input
id="generate_share_link"
v-model="form.generate_share_link"
class="hidden"
type="checkbox"
name="generate_share_link"
>
<label for="generate_share_link">Génération d'un lien de partage externe</label>
</div>
<div
v-if="filteredAttributes.length > 0"
class="ui horizontal divider"
>
ATTRIBUTS
</div>
<div class="fields grouped">
<ProjectAttributeForm
v-for="(attribute, index) in filteredAttributes"
:key="index"
:attribute="attribute"
:form-project-attributes="form.project_attributes"
@update:project_attributes="updateProjectAttributes($event)"
/>
</div>
<div class="ui divider" />
<button
id="send-project"
type="button"
class="ui teal icon button"
@click="postForm"
......@@ -255,15 +348,20 @@
<script>
import axios from '@/axios-client.js';
import Dropdown from '@/components/Dropdown.vue';
import Dropdown from '@/components/Dropdown';
import ProjectAttributeForm from '@/components/Project/Edition/ProjectAttributeForm';
import mapService from '@/services/map-service';
import { mapState, mapActions } from 'vuex';
import TextareaMarkdown from 'textarea-markdown';
import { mapActions, mapState } from 'vuex';
export default {
name: 'ProjectEdit',
components: {
Dropdown,
ProjectAttributeForm
},
data() {
......@@ -274,13 +372,30 @@ export default {
name: 'Sélectionner une image ...',
size: 0,
},
errors_archive_feature: [],
errors: {
title: [],
access_level_pub_feature: [],
access_level_arch_feature: [],
},
errorThumbnail: [],
featureBrowsingOptions: {
filter: [{
name: 'Désactivé',
value: ''
},
{
name: 'Type de signalement',
value: 'feature_type_slug',
}],
sort: [{
name: 'Date de création',
value: '-created_on',
},
{
name: 'Date de modification',
value: '-updated_on'
}],
},
form: {
title: '',
slug: '',
......@@ -293,8 +408,7 @@ export default {
creator: null,
access_level_pub_feature: { name: '', value: '' },
access_level_arch_feature: { name: '', value: '' },
archive_feature: 0,
delete_feature: 0,
map_max_zoom_level: 22,
nb_features: 0,
nb_published_features: 0,
nb_comments: 0,
......@@ -302,20 +416,52 @@ export default {
nb_contributors: 0,
is_project_type: false,
generate_share_link: false,
feature_assignement: false,
fast_edition_mode: false,
feature_browsing_default_filter: '',
feature_browsing_default_sort: '-created_on',
project_attributes: [],
},
thumbnailFileSrc: '',
scalesTable: [
'1:500 000 000',
'1:250 000 000',
'1:150 000 000',
'1:70 000 000',
'1:35 000 000',
'1:15 000 000',
'1:10 000 000',
'1:4 000 000',
'1:2 000 000',
'1:1 000 000',
'1:500 000',
'1:250 000',
'1:150 000',
'1:70 000',
'1:35 000',
'1:15 000',
'1:8 000',
'1:4 000',
'1:2 000',
'1:1 000',
'1:500',
'1:250',
'1:150',
]
};
},
computed: {
...mapState([
'levelsPermissions',
'projectAttributes'
]),
...mapState('projects', ['project']),
DJANGO_BASE_URL: function () {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
levelPermissions(){
levelPermissionsArc(){
const levels = new Array();
if(this.levelsPermissions) {
this.levelsPermissions.forEach((item) => {
......@@ -325,7 +471,7 @@ export default {
value: item.user_type_id,
});
}
if (!this.form.moderation && item.user_type_id == 'moderator') {
if (!this.form.moderation && item.user_type_id === 'moderator') {
levels.pop();
}
});
......@@ -349,7 +495,14 @@ export default {
});
}
return levels;
}
},
/**
* Filter out attribute of field type list without option
*/
filteredAttributes() {
return this.projectAttributes.filter(attr => attr.field_type === 'boolean' || attr.options);
},
},
watch: {
......@@ -359,11 +512,12 @@ export default {
}
}
},
created() {
mounted() {
this.definePageType();
if (this.action === 'create') {
this.thumbnailFileSrc = require('@/assets/img/default.png');
this.initPreviewMap();
} else if (this.action === 'edit' || this.action === 'create_from') {
if (!this.project) {
this.$store.dispatch('projects/GET_PROJECT', this.$route.params.slug)
......@@ -376,11 +530,13 @@ export default {
this.fillProjectForm();
}
}
let textarea = document.querySelector('textarea');
new TextareaMarkdown(textarea);
},
methods: {
...mapActions('projects', [
'GET_ALL_PROJECTS'
...mapActions('map', [
'INITIATE_MAP'
]),
definePageType() {
if (this.$router.history.current.name === 'project_create') {
......@@ -458,21 +614,11 @@ export default {
}
},
checkEmpty() {
//* forbid empty fields
if (!this.form.archive_feature) {
this.form.archive_feature = 0;
}
if (!this.form.delete_feature) {
this.form.delete_feature = 0;
}
},
goBackNrefresh(slug) {
Promise.all([
this.$store.dispatch('GET_USER_LEVEL_PROJECTS'), //* refresh projects user levels
this.$store.dispatch('GET_USER_LEVEL_PERMISSIONS'), //* refresh projects permissions
this.GET_ALL_PROJECTS(), //* & refresh project list
this.$store.dispatch('projects/GET_PROJECT', slug), //* refresh current project
]).then(() =>
// * go back to project list
this.$router.push({
......@@ -516,12 +662,6 @@ export default {
},
checkForm() {
if (this.form.archive_feature > this.form.delete_feature) {
this.errors_archive_feature.push(
"Le délais de suppression doit être supérieur au délais d'archivage."
);
return false;
}
for (const key in this.errors) {
if ((key === 'title' && this.form[key]) || this.form[key].value) {
this.errors[key] = [];
......@@ -545,21 +685,16 @@ export default {
return;
}
const projectData = {
title: this.form.title,
description: this.form.description,
...this.form,
access_level_arch_feature: this.form.access_level_arch_feature.value,
access_level_pub_feature: this.form.access_level_pub_feature.value,
archive_feature: this.form.archive_feature,
delete_feature: this.form.delete_feature,
is_project_type: this.form.is_project_type,
generate_share_link: this.form.generate_share_link,
moderation: this.form.moderation,
feature_browsing_default_sort: this.form.feature_browsing_default_sort.value,
feature_browsing_default_filter: this.form.feature_browsing_default_filter.value,
};
let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/`;
if (this.action === 'edit') {
await axios
.put((url += `${this.project.slug}/`), projectData)
.put((`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/projects/${this.project.slug}/`), projectData)
.then((response) => {
if (response && response.status === 200) {
//* send thumbnail after feature_type was updated
......@@ -577,8 +712,9 @@ export default {
throw error;
});
} else {
let url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}v2/projects/`;
if (this.action === 'create_from') {
url += `${this.project.slug}/duplicate/`;
url = `${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.project.slug}/duplicate/`;
}
this.loading = true;
await axios
......@@ -603,9 +739,12 @@ export default {
});
}
},
fillProjectForm() {
this.form = { ...this.project }; //* create a new object to avoid modifying original one
if (this.action === 'create_from') { //* if duplication of project, generate new name
//* create a new object to avoid modifying original one
this.form = { ...this.project };
//* if duplication of project, generate new name
if (this.action === 'create_from') {
this.form.title =
this.project.title +
` (Copie-${new Date()
......@@ -614,41 +753,215 @@ export default {
.replace(',', '')})`;
this.form.is_project_type = false;
}
//* transform string values to objects for dropdowns display (could be in a computed)
//* transform string values to objects used with dropdowns
// fill dropdown current selection for archived feature viewing permission
if (this.levelPermissionsArc) {
const accessLevelArc = this.levelPermissionsArc.find(
(el) => el.name === this.project.access_level_arch_feature
);
if (accessLevelArc) {
this.form.access_level_arch_feature = {
name: this.project.access_level_arch_feature,
value: accessLevelArc.value ,
};
}
}
// fill dropdown current selection for published feature viewing permission
if (this.levelPermissionsPub) {
const value = this.levelPermissionsPub.find(
const accessLevelPub = this.levelPermissionsPub.find(
(el) => el.name === this.project.access_level_pub_feature
);
if(value){
if (accessLevelPub) {
this.form.access_level_pub_feature = {
name: this.project.access_level_pub_feature,
value: value.value ,
value: accessLevelPub.value ,
};
}
}
if (this.levelPermissions) {
const value = this.levelPermissions.find(
(el) => el.name === this.project.access_level_arch_feature
);
if(value){
this.form.access_level_arch_feature = {
name: this.project.access_level_arch_feature,
value: value.value ,
};
}
// fill dropdown current selection for feature browsing default filtering
const default_filter = this.featureBrowsingOptions.filter.find(
(el) => el.value === this.project.feature_browsing_default_filter
);
if (default_filter) {
this.form.feature_browsing_default_filter = default_filter;
}
// fill dropdown current selection for feature browsing default sorting
const default_sort = this.featureBrowsingOptions.sort.find(
(el) => el.value === this.project.feature_browsing_default_sort
);
if (default_sort) {
this.form.feature_browsing_default_sort = default_sort;
}
this.initPreviewMap();
},
initPreviewMap () {
const map = mapService.getMap();
if (map) mapService.destroyMap();
//On récupère le zoom maximum autorisé par la couche
const maxZoomLayer = this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS.maxZoom;
let activeZoom = maxZoomLayer;
if(this.project && this.project.map_max_zoom_level < maxZoomLayer){
activeZoom = this.project.map_max_zoom_level;
}
this.INITIATE_MAP({
el: this.$refs.map,
zoom: activeZoom,
center: this.$store.state.configuration.MAP_PREVIEW_CENTER,
maxZoom: 22,
controls: [],
zoomControl: false,
//On désactive le zoom et le pan => gérer par le composant zoom max
interactions: { dragPan: false, mouseWheelZoom: false }
});
// add default basemap (in other maps the component SidebarLayer handles layers)
mapService.addLayers(
null,
this.$store.state.configuration.DEFAULT_BASE_MAP_SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS,
this.$store.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE
);
//La tuile au dessus du zoom maximum n'existe pas
//On attend un peu qu'elle se charge et on zoom si besoin
setTimeout(() => {
mapService.zoom(this.project ? this.project.map_max_zoom_level : 22);
}, 500);
},
zoomMap() {
mapService.zoom(this.form.map_max_zoom_level);
},
/**
* Updates the value of a project attribute or adds a new attribute if it does not exist.
*
* This function looks for an attribute by its ID. If the attribute exists, its value is updated.
* If the attribute does not exist, a new attribute object is added to the `project_attributes` array.
*
* @param {String} value - The new value to be assigned to the project attribute.
* @param {Number} attributeId - The ID of the attribute to be updated or added.
*/
updateProjectAttributes({ value, attributeId }) {
// Find the index of the attribute in the project_attributes array.
const attributeIndex = this.form.project_attributes.findIndex(el => el.attribute_id === attributeId);
if (attributeIndex !== -1) {
// Directly update the attribute's value if it exists.
this.form.project_attributes[attributeIndex].value = value;
} else {
// Add a new attribute object if it does not exist.
this.form.project_attributes.push({ attribute_id: attributeId, value });
}
},
goToDocumentationFeature() {
window.open(this.$store.state.configuration.VUE_APP_URL_DOCUMENTATION_FEATURE);
}
},
};
</script>
<style media="screen">
<style media="screen" lang="less">
#form-input-file-logo {
margin-left: auto;
margin-right: auto;
}
.file-logo {
min-height: calc(150px + 2.4285em);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.close.icon:hover {
cursor: pointer;
}
</style>
\ No newline at end of file
textarea {
height: 10em;
}
.description.preview {
height: 10em;
overflow: scroll;
border: 1px solid rgba(34, 36, 38, .15);
padding: .78571429em 1em;
}
.checkboxes {
padding-left: .5em;
.absolute-right.ui.compact.icon.button {
position: absolute;
right: -2.75em;
top: calc(50% - 1em);
padding: .4em;
}
}
.map-maxzoom-selector {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
input, output {
height: fit-content;
}
output {
white-space: nowrap;
min-width: auto;
}
.range-container {
margin-bottom: 2rem;
}
.map-preview {
margin-top: -1rem;
display: flex;
position: relative;
label {
white-space: nowrap;
font-size: .95em;
margin-right: 1rem;
}
#map {
min-height: 80px;
height: 80px;
width: 150px;
max-width: 150px;
z-index: 1;
}
.no-preview {
position: absolute;
top: 25%;
left: 25%;
text-align: center;
font-size: .75em;
color: #656565;
}
}
}
label[for=feature_browsing] {
padding-left: 2em;
}
label[for=feature_browsing_default_filter],
label[for=feature_browsing_default_sort] {
min-width: 4em;
}
#feature_browsing_filter,
#feature_browsing_sort {
margin-left: 2.5rem;
}
@media only screen and (min-width: 1100px) {
#feature_browsing_filter {
margin-top: -2.25em;
}
#feature_browsing_filter,
#feature_browsing_sort {
float: right;
}
}
</style>
<template>
<div
id="project-members"
class="page"
>
<div id="project-members">
<h1 class="ui header">
Gérer les membres
</h1>
......@@ -146,9 +143,10 @@
<script>
import axios from '@/axios-client.js';
import { mapState } from 'vuex';
import { mapMutations, mapState } from 'vuex';
import Dropdown from '@/components/Dropdown.vue';
import { formatUserOption } from '@/utils';
export default {
name: 'ProjectMembers',
......@@ -191,16 +189,7 @@ export default {
userOptions: function () {
return this.projectUsers
.filter((el) => el.userLevel.value === 'logged_user')
.map((el) => {
let name = el.user.first_name || '';
if (el.user.last_name) {
name = name + ' ' + el.user.last_name;
}
return {
name: [name, el.user.username],
value: el.user.id,
};
});
.map((el) => formatUserOption(el.user)); // Format user data to fit dropdown option structure
},
levelOptions: function () {
......@@ -250,10 +239,16 @@ export default {
destroyed() {
//* allow user to change page if ever stuck on loader
this.$store.commit('DISCARD_LOADER');
this.DISCARD_LOADER();
},
methods: {
...mapMutations([
'DISPLAY_MESSAGE',
'DISPLAY_LOADER',
'DISCARD_LOADER'
]),
validateNewMember() {
this.newMember.errors = [];
if (!this.newMember.user.value) {
......@@ -303,60 +298,69 @@ export default {
});
},
/**
* Saves the updated members and their roles for a project.
* Displays a loader while the update is in progress and provides feedback upon completion or error.
*/
saveMembers() {
// Display a loader to indicate that the update process is ongoing
this.DISPLAY_LOADER('Mise à jour des membres du projet en cours ...');
// Prepare the data to be sent in the API request
const data = this.projectUsers.map((member) => {
return {
user: member.user,
level: {
display: member.userLevel.name,
codename: member.userLevel.value,
display: member.userLevel.name, // Display name of the user level
codename: member.userLevel.value, // Codename of the user level
},
};
});
// Make an API request to update the project members
axios
.put(
`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.project.slug}/utilisateurs/`,
data
)
.then((response) => {
// Check if the response status is 200 (OK)
if (response.status === 200) {
this.$store.dispatch('GET_USER_LEVEL_PROJECTS'); //* update user status in top right menu
this.$store.commit('DISPLAY_MESSAGE', { comment: 'Permissions mises à jour', level: 'positive' });
// Dispatch an action to update the user status in the top right menu
this.$store.dispatch('GET_USER_LEVEL_PROJECTS');
// Display a positive message indicating success
this.DISPLAY_MESSAGE({ comment: 'Permissions mises à jour avec succès', level: 'positive' });
} else {
this.$store.commit(
'DISPLAY_MESSAGE',
{
comment : "Une erreur s'est produite pendant la mises à jour des permissions",
level: 'negative'
}
);
// Display a generic error message if the response status is not 200
this.DISPLAY_MESSAGE({
comment: "Une erreur s'est produite pendant la mises à jour des permissions",
level: 'negative'
});
}
// Hide the loader regardless of the request result
this.DISCARD_LOADER();
})
.catch((error) => {
throw error;
});
},
fetchMembers() {
// todo: move function to a service
return axios
.get(
`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.$route.params.slug}/utilisateurs/`
)
.then((response) => response.data)
.catch((error) => {
throw error;
// Hide the loader if an error occurs
this.DISCARD_LOADER();
// Determine the error message to display
const errorMessage = error.response && error.response.data && error.response.data.error
? error.response.data.error
: "Une erreur s'est produite pendant la mises à jour des permissions";
// Display the error message
this.DISPLAY_MESSAGE({
comment: errorMessage,
level: 'negative'
});
// Log the error to the console for debugging
console.error(error);
});
},
populateMembers() {
this.$store.commit(
'DISPLAY_LOADER',
'Récupération des membres en cours...'
);
this.fetchMembers().then((members) => {
this.$store.commit('DISCARD_LOADER');
this.DISPLAY_LOADER('Récupération des membres en cours...');
this.$store.dispatch('projects/GET_PROJECT_USERS', this.$route.params.slug).then((members) => {
this.DISCARD_LOADER();
this.projectUsers = members.map((el) => {
return {
userLevel: { name: el.level.display, value: el.level.codename },
......
<template>
<div
id="projects"
class="page"
>
<div id="projects">
<h2 class="ui horizontal divider header">
PROJETS
</h2>
......@@ -12,6 +9,7 @@
v-if="user && user.can_create_project && isOnline"
:to="{ name: 'project_create', params: { action: 'create' } }"
class="ui green basic button"
data-test="create-project"
>
<i
class="plus icon"
......@@ -25,6 +23,7 @@
name: 'project_type_list',
}"
class="ui blue basic button"
data-test="to-project-models"
>
<i
class="copy icon"
......@@ -36,18 +35,21 @@
<!-- FILTRES DES PROJETS -->
<ProjectsMenu
:loading="loading"
@filter="setProjectsFilters"
@getData="getData"
@loading="setLoader"
/>
<div
v-if="configuration.DISPLAY_FORBIDDEN_PROJECTS"
id="forbidden-projects"
class="ui toggle checkbox"
class="ui toggle checkbox margin-top"
>
<input
v-model="displayForbiddenProjects"
:checked="displayForbiddenProjects"
type="checkbox"
@input="toggleForbiddenProjects"
>
<label>
N'afficher que les projets disponibles à la consultation
......@@ -58,14 +60,12 @@
<div
v-if="projects"
class="ui divided items dimmable dimmed"
data-test="project-list"
>
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div :class="['ui inverted dimmer', { active: loading }]">
<div class="ui loader" />
</div>
<ProjectsListItem
v-for="project in projects"
:key="project.slug"
......@@ -81,9 +81,8 @@
<!-- PAGINATION -->
<Pagination
v-if="count"
:nb-pages="Math.ceil(count/10)"
:on-page-change="SET_CURRENT_PAGE"
@change-page="changePage"
:nb-pages="nbPages"
@page-update="changePage"
/>
</div>
</div>
......@@ -126,49 +125,30 @@ export default {
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
},
watch: {
filters: {
deep: true,
handler(newValue) {
if (newValue) {
this.getData();
}
}
},
displayForbiddenProjects(newValue) {
if (newValue) {
this.SET_PROJECTS_FILTER({
filter: 'accessible',
value: 'true'
});
} else {
this.SET_PROJECTS_FILTER({
filter: 'accessible',
value: null
});
}
this.getData();
nbPages() {
return Math.ceil(this.count / 10);
}
},
created() {
this.SET_CURRENT_PAGE(1);
// Empty stored text to search
this.SET_PROJECTS_SEARCH_STATE({ text: null });
// Empty stored project list
this.$store.commit('projects/SET_PROJECT', null);
this.SET_PROJECTS_FILTER({
filter: 'accessible',
value: 'true'
});
// Init display of restricted access projects
this.displayForbiddenProjects = this.configuration.DISPLAY_FORBIDDEN_PROJECTS_DEFAULT;
this.setForbiddenProjectsFilter(true);
},
methods: {
...mapMutations('projects', [
'SET_CURRENT_PAGE',
'SET_PROJECTS_FILTER'
'SET_PROJECTS_FILTER',
'SET_PROJECTS_SEARCH_STATE',
]),
...mapActions('projects', [
'GET_PROJECTS'
'GET_PROJECTS',
]),
getData(page) {
......@@ -190,8 +170,26 @@ export default {
this.getData(e);
},
setProjectsFilters(e) {
setProjectsFilters(e, noUpdate) {
this.SET_PROJECTS_FILTER(e);
// Reset the page number at filter change
this.SET_CURRENT_PAGE(1);
// Wait that all filters are set in store to fetch data when component is created
if (!noUpdate) {
this.getData();
}
},
toggleForbiddenProjects(e) {
this.displayForbiddenProjects = e.target.checked;
this.setForbiddenProjectsFilter();
},
setForbiddenProjectsFilter(noUpdate) {
this.setProjectsFilters({
filter: 'accessible',
value: this.displayForbiddenProjects ? 'true' : null
}, noUpdate);
},
}
};
......@@ -230,10 +228,10 @@ export default {
color: rgb(94, 94, 94);
}
input:checked ~ label::before {
background-color: teal !important;
background-color: var(--primary-color, #008c86) !important;
}
input:checked ~ label {
color: teal !important;
color: var(--primary-color, #008c86) !important;
}
}
......
<template>
<div
id="projects-types"
class="page"
>
<div id="projects-types">
<h3 class="ui header">
Créer un projet à partir d'un modèle disponible:
</h3>
......
......@@ -2,6 +2,7 @@ const webpack = require('webpack');
const fs = require('fs');
const packageJson = fs.readFileSync('./package.json');
const version = JSON.parse(packageJson).version || 0;
module.exports = {
publicPath: '/geocontrib/',
devServer: {
......@@ -34,6 +35,7 @@ module.exports = {
themeColor: '#1da025'
},
configureWebpack: {
devtool: 'source-map',
plugins: [
new webpack.DefinePlugin({
'process.env': {
......@@ -42,5 +44,14 @@ module.exports = {
})
]
},
// the rest of your original module.exports code goes here
transpileDependencies: [
// Add dependencies that use modern JavaScript syntax, based on encountered errors
'ol',
'color-rgba',
'color-parse',
'@sentry/browser',
'@sentry/core',
'@sentry/vue',
'@sentry-internal'
]
};
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.vue$/,
loader: 'vue-loader'
}
]
}
};
\ No newline at end of file