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 2963 additions and 1494 deletions
<template> <template>
<Multiselect <Multiselect
v-model="selection" v-model="selection"
:options="options" :class="{ multiple }"
:allow-empty="true" :options="options"
track-by="label" :allow-empty="true"
label="label" track-by="label"
:reset-after="false" label="label"
select-label="" :reset-after="false"
selected-label="" select-label=""
deselect-label="" selected-label=""
:searchable="false" deselect-label=""
:placeholder="placeholder" :searchable="false"
:clear-on-select="false" :placeholder="placeholder"
:preserve-search="true" :clear-on-select="false"
@select="select" :preserve-search="true"
@close="close" :multiple="multiple"
> :disabled="loading"
<!-- <template slot="clear"> @select="select"
<div @remove="remove"
v-if="selection" @close="close"
class="multiselect__clear" >
@click.prevent.stop="selection = options[0]" <template
> slot="option"
<i class="close icon"></i> slot-scope="props"
</div> >
</template> --> <span :title="props.option.label">{{ props.option.label }}</span>
</Multiselect> </template>
<template
v-if="multiple"
slot="selection"
slot-scope="{ values }"
>
<span
v-if="values && values.length > 1"
class="multiselect__single"
>
{{ values.length }} options sélectionnées
</span>
<span
v-else
class="multiselect__single"
>{{ currentSelectionLabel || selection.label }}</span>
</template>
</Multiselect>
</template> </template>
<script> <script>
...@@ -34,56 +51,132 @@ import Multiselect from 'vue-multiselect'; ...@@ -34,56 +51,132 @@ import Multiselect from 'vue-multiselect';
export default { export default {
name: 'DropdownMenuItem', name: 'DropdownMenuItem',
components: { components: {
Multiselect Multiselect
}, },
props: { props: {
placeholder: { placeholder: {
type: String, type: String,
default: 'Sélectionnez une valeur' default: 'Sélectionnez une valeur'
}, },
options: { options: {
type: Array, type: Array,
default: () => { default: () => {
return []; return [];
} }
} },
}, loading: {
type: Boolean,
default: false
},
multiple: {
type: Boolean,
default: false
},
currentSelection: {
type: [String, Array, Boolean],
default: null,
},
defaultFilter: {
type: [String, Array, Boolean],
default: null,
}
},
data() { data() {
return { return {
selection: null, selection: null,
};
},
computed: {
/**
* Get the label of an option to work with project attributes options as JSON
*/
currentSelectionLabel() {
const option = this.options.find(opt => opt.value === this.currentSelection);
return option ? option.label : '';
} }
}, },
watch: { watch: {
selection: { selection: {
deep: true, deep: true,
handler(newValue) { handler(newValue) {
if (!newValue) { if (!newValue) {
this.selection = this.options[0]; this.selection = this.options[0];
this.$emit('filter', this.selection); this.$emit('filter', this.selection);
} }
} }
} },
},
created() { currentSelection(newValue) {
this.selection = this.options[0]; this.updateSelection(newValue);
}, },
},
created() {
if (this.currentSelection !== null) {
this.selection = this.options.find(opt => opt.value === this.currentSelection);
} else {
this.selection = this.options[0];
}
},
methods: { methods: {
select(e) { select(e) {
this.$emit('filter', e); this.$emit('filter', e);
}, },
remove(e) {
this.$emit('remove', e);
},
close() { close() {
this.$emit('close', this.selection); this.$emit('close', this.selection);
},
/**
* Normalizes the input value(s) to an array of strings.
* This handles both single string inputs and comma-separated strings, converting them into an array.
*
* @param {String|Array} value - The input value to normalize, can be a string or an array of strings.
* @return {Array} An array of strings representing the input values.
*/
normalizeValues(value) {
// If the value is a string and contains commas, split it into an array; otherwise, wrap it in an array.
return typeof value === 'string' ? (value.includes(',') ? value.split(',') : [value]) : value;
},
/**
* Updates the current selection based on new value, ensuring compatibility with multiselect.
* This method processes the new selection value, accommodating both single and multiple selections,
* and updates the internal `selection` state with the corresponding option objects from `options`.
*
* @param {String|Array} value - The new selection value(s), can be a string or an array of strings.
*/
// Check if the component is in multiple selection mode and the new value is provided.
updateSelection(value) {
if (this.multiple && value) {
// Normalize the value to an array format, accommodating both single and comma-separated values.
const normalizedValues = this.normalizeValues(value);
// Map each value to its corresponding option object based on the 'value' field.
this.selection = normalizedValues.map(value =>
this.options.find(option => option.value === value)
);
} else {
// For single selection mode or null value, find the option object that matches the value.
this.selection = this.options.find(option => option.value === value);
}
} }
} }
} };
</script> </script>
<style lang="less" scoped> <style>
#filters-container .multiple .multiselect__option--selected:not(:hover) {
</style> background-color: #e8e8e8 !important;
}
#filters-container .multiselect--disabled .multiselect__select {
background: 0, 0 !important;
}
</style>
\ No newline at end of file
<template>
<div
:id="project.title"
class="item"
data-test="project-list-item"
>
<div class="ui tiny image">
<img
:src="
!project.thumbnail
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
alt="Thumbnail du projet"
>
</div>
<div class="middle aligned content">
<router-link
:to="{
name: 'project_detail',
params: { slug: project.slug },
}"
class="header"
>
{{ project.title }}
</router-link>
<div class="description">
<textarea
:id="`editor-${project.slug}`"
:value="project.description"
:data-preview="`#preview-${project.slug}`"
hidden
/>
<div
:id="`preview-${project.slug}`"
class="preview"
/>
</div>
<div class="meta top">
<span class="right floated">
<strong v-if="project.moderation">Projet modéré</strong>
<strong v-else>Projet non modéré</strong>
</span>
<span>Niveau d'autorisation requis :
{{ project.access_level_pub_feature }}</span><br>
<span v-if="user">
Mon niveau d'autorisation :
<span v-if="USER_LEVEL_PROJECTS && project">{{
USER_LEVEL_PROJECTS[project.slug]
}}</span>
<span v-if="user && user.is_administrator">{{
"+ Gestionnaire métier"
}}</span>
</span>
</div>
<div class="meta">
<span class="right floated">
<i
class="calendar icon"
aria-hidden="true"
/>&nbsp; {{ project.created_on }}
</span>
<span data-tooltip="Membres">
{{ project.nb_contributors }}&nbsp;
<i
class="user icon"
aria-hidden="true"
/>
</span>
<span data-tooltip="Signalements publiés">
{{ project.nb_published_features }}&nbsp;
<i
class="map marker icon"
aria-hidden="true"
/>
</span>
<span data-tooltip="Commentaires">
{{ project.nb_published_features_comments }}&nbsp;
<i
class="comment icon"
aria-hidden="true"
/>
</span>
</div>
</div>
</div>
</template>
<script>
import TextareaMarkdown from 'textarea-markdown';
import { mapState } from 'vuex';
export default {
name: 'ProjectsListItem',
props: {
project: {
type: Object,
default: () => {
return {};
}
}
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS'
]),
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
},
mounted() {
let textarea = document.getElementById(`editor-${this.project.slug}`);
new TextareaMarkdown(textarea);
},
methods: {
refreshId() {
const crypto = window.crypto || window.msCrypto;
var array = new Uint32Array(1);
return '?ver=' + crypto.getRandomValues(array); // Compliant for security-sensitive use cases
},
}
};
</script>
<style lang="less" scoped>
.preview {
max-height: 10em;
overflow-y: scroll;
margin-bottom: 0.8em;
}
.description {
p {
text-align: justify;
}
}
@media screen and (max-width: 767px) {
.content {
width: 90% !important;
.meta.top {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
.right.floated {
float: none !important;
margin-left: 0 !important;
margin-bottom: 0.5em;
}
span {
margin: 0.15em 0;
}
}
}
}
</style>
<template> <template>
<div class="ui menu projects"> <div
<div class="item"> v-if="chunkedNsortedFilters.length > 0"
<label> id="filters-container"
Modération >
</label> <div
<DropdownMenuItem class="ui styled accordion"
:options="moderationOptions" @click="displayFilters = !displayFilters"
v-on="$listeners" >
/> <div
</div> id="filters"
<div class="item"> class="title collapsible-filters"
<label> >
Niveau d'autorisation requis FILTRES
</label> <i
<DropdownMenuItem :class="['ui icon customcaret', { 'collapsed': !displayFilters }]"
:options="accessLevelOptions" aria-hidden="true"
v-on="$listeners" />
/> </div>
</div> </div>
<div class="item"> <div :class="['full-width', 'filters', { 'hidden': displayFilters }]">
<label> <div
Mon niveau d'autorisation v-for="(chunkedFilters, index) in chunkedNsortedFilters"
</label> :key="index"
<DropdownMenuItem class="ui menu filter-row"
:options="userAccessLevelOptions" >
v-on="$listeners" <div
/> v-for="filter in chunkedFilters"
</div> :key="filter.name"
<div class="right item"> class="item"
<search-projects >
:search-function="SEARCH_PROJECTS" <label>
/> {{ filter.label }}
</div> </label>
</div> <search-projects
v-if="filter.name === 'search'"
v-on="$listeners"
/>
<DropdownMenuItem
v-else-if="!filter.id"
:options="filter.options"
:loading="loading"
v-on="$listeners"
/>
<DropdownMenuItem
v-else
:options="filter.options"
:loading="loading"
:multiple="isMultiple(filter)"
:current-selection="attributesFilter[filter.id]"
:default-filter="filter.default_filter_enabled ? filter.default_filter_value : null"
@filter="updateAttributeFilter"
@remove="removeAttributeFilter"
/>
</div>
</div>
</div>
</div>
</template> </template>
<script> <script>
import { mapActions } from 'vuex'; import { mapState, mapMutations } from 'vuex';
import DropdownMenuItem from '@/components/Projects/DropdownMenuItem.vue'; import DropdownMenuItem from '@/components/Projects/DropdownMenuItem.vue';
import SearchProjects from '@/components/Projects/SearchProjects.vue'; import SearchProjects from '@/components/Projects/SearchProjects.vue';
export default { export default {
name: 'ProjectsMenu', name: 'ProjectsMenu',
components: {
DropdownMenuItem,
SearchProjects,
},
data() {
return {
moderationOptions: [
{
label: 'Tous',
filter: 'moderation',
value: null
},
{
label: 'Projet modéré',
filter: 'moderation',
value: 'true'
},
{
label: 'Projet non modéré',
filter: 'moderation',
value: 'false'
},
],
accessLevelOptions: [
{
label: 'Tous',
filter: 'access_level',
value: null
},
{
label: 'Utilisateur connecté',
filter: 'access_level',
value: 'logged_user'
},
{
label: 'Contributeur',
filter: 'access_level',
value: 'contributor'
},
{
label: 'Modérateur',
filter: 'access_level',
value: 'moderator'
},
{
label: 'Administrateur projet',
filter: 'access_level',
value: 'admin'
},
],
userAccessLevelOptions: [
{
label: 'Tous',
filter: 'user_access_level',
value: null
},
{
label: 'Utilisateur connecté',
filter: 'user_access_level',
value: '1'
},
{
label: 'Contributeur',
filter: 'user_access_level',
value: '2'
},
{
label: 'Modérateur',
filter: 'user_access_level',
value: '3'
},
{
label: 'Administrateur projet',
filter: 'user_access_level',
value: '4'
},
]
}
},
methods: { components: {
...mapActions('projects', [ DropdownMenuItem,
'SEARCH_PROJECTS' SearchProjects,
]) },
}
} props: {
loading: {
type: Boolean,
default: false
},
},
data() {
return {
displayFilters: false,
classicFilters: [
{
name: 'access_level',
label: 'Niveau d\'autorisation requis',
options: [
{
label: 'Utilisateur anonyme',
value: 'anonymous'
},
{
label: 'Utilisateur connecté',
value: 'logged_user'
},
{
label: 'Contributeur',
value: 'contributor'
},
],
},
{
name: 'user_access_level',
label: 'Mon niveau d\'autorisation',
options: [
{
label: 'Utilisateur connecté',
value: '1'
},
{
label: 'Contributeur',
value: '2'
},
{
label: 'Super contributeur',
value: '3'
},
{
label: 'Modérateur',
value: '4'
},
{
label: 'Administrateur projet',
value: '5'
},
],
},
{
name: 'moderation',
label: 'Modération',
options: [
{
label: 'Projet modéré',
value: 'true'
},
{
label: 'Projet non modéré',
value: 'false'
},
]
},
{
name: 'search',
label: 'Recherche par nom',
}
],
attributesFilter: {},
};
},
computed: {
...mapState([
'user',
'configuration',
'projectAttributes'
]),
...mapState('projects', [
'filters',
]),
/**
* Processes project filters to prepare them for display.
* It also adds a global 'Tous' (All) option to each attribute's options for filtering purposes.
*
* @returns {Array} An array of filter objects with modified options for display.
*/
displayedClassicFilters() {
if (!this.configuration.VUE_APP_PROJECT_FILTERS) return [];
const projectFilters = this.configuration.VUE_APP_PROJECT_FILTERS.split(',');
// Filter filters to be displayed according to configuration and process filters
return this.classicFilters.filter(filter => projectFilters.includes(filter.name))
.map(filter => {
if (filter.options) {
// if user is not connected display its user access level corresponding to anonymous user
if (!this.user && filter.name ==='user_access_level') {
filter.options.unshift({
label: 'Utilisateur anonyme',
value: '0'
});
}
// Format the options to be displayed by dropdowns
const options = this.generateFilterOptions(filter);
// Add the global option at beginning
options.unshift({
label: 'Tous',
filter: filter.name,
value: null,
});
return { ...filter, options };
} else { // Search input field doesn't take options
return filter;
}
});
},
/**
* Processes project attributes to prepare them for display, adjusting the options based on the attribute type.
* For boolean attributes, it creates specific options for true and false values.
* It also adds a global 'Tous' (All) option to each attribute's options for filtering purposes.
* Finally, it chunks the array of attributes into multiple arrays, each containing up to 4 elements.
*
* @returns {Array} An array of arrays, where each sub-array contains up to 4 project attributes with modified options for display.
*/
displayedAttributeFilters() {
// Filter displayed filters & filter only attribute of boolean type (no need for option property) or list type with options
return this.projectAttributes.filter(attribute => attribute.display_filter && (attribute.field_type === 'boolean' || attribute.options))
// Process attributes for display
.map(attribute => {
// Format the options to be displayed by dropdowns
const options = this.generateFilterOptions(attribute);
// Add the global option at beginning
options.unshift({
label: 'Tous',
filter: attribute.id,
value: null,
});
return { ...attribute, options };
});
},
/**
* Merge all filters and place the search filters at the end of the array
* Then chunks the array into rows of 4 filters to display each chunk in a row
*/
chunkedNsortedFilters() {
const allFilters = [...this.displayedClassicFilters, ...this.displayedAttributeFilters];
const sortedFilters = [
...allFilters.filter(el => el.name !== 'search'),
...allFilters.filter(el => el.name === 'search'),
];
// Chunk the filters into arrays of up to 4 elements
return this.chunkArray(sortedFilters, 4);
},
},
created() {
// parse all project attributes to find default value and set filters in store before updating project list results
for (const attribFilter of this.displayedAttributeFilters) {
this.setDefaultFilters(attribFilter);
}
// When all the default filters are set, fetch projects list data
this.$emit('getData');
},
methods: {
...mapMutations('projects', [
'SET_PROJECTS_FILTER'
]),
/**
* Helper function to chunk an array into smaller arrays of a specified size.
*
* @param {Array} array - The original array to be chunked.
* @param {Number} size - The maximum size of each chunk.
* @returns {Array} An array of chunked arrays.
*/
chunkArray(array, size) {
const chunkedArr = [];
for (let i = 0; i < array.length; i += size) {
chunkedArr.push(array.slice(i, i + size));
}
return chunkedArr;
},
/**
* Generates options for a given filter.
* It handles boolean attributes specially by creating explicit true/false options.
* Other attribute types use their predefined options.
*
* @param {Object} attribute - The project attribute for which to generate options.
* @returns {Array} An array of options for the given attribute.
*/
generateFilterOptions(filter) {
// Handle boolean attributes specially by creating true/false options
if (filter.field_type === 'boolean') {
return [
{ filter: filter.id, label: 'Oui', value: 'true' },
{ filter: filter.id, label: 'Non', value: 'false' },
];
} else if (filter.options) {
// For other filter types, map each option to the expected format
return filter.options.map(option => ({
filter: filter.id || filter.name,
label: option.name || option.label || option,
value: option.id || option.value || option,
}));
}
return [];
},
/**
* Retrieves a project attribute by its ID.
* Returns an empty object if not found to prevent errors from undefined access.
*
* @param {Number|String} id - The ID of the attribute to find.
* @returns {Object} The found attribute or an empty object.
*/
getProjectAttribute(id) {
// Search for the attribute by ID, default to an empty object if not found
return this.projectAttributes.find(el => el.id === id) || {};
},
/**
* Emits an updated filter event with the current state of attributesFilter.
* This method serializes the attributesFilter object to a JSON string and emits it,
* allowing the parent component to update the query parameters.
*/
emitUpdatedFilter() {
// Emit an 'filter' event with the updated attributes filter as a JSON string
this.$emit('filter', { filter: 'attributes', value: JSON.stringify(this.attributesFilter) });
},
/**
* Updates or adds a new attribute value to the attributesFilter.
* Handles both single-choice and multi-choice attribute types.
* @param {Object} newFilter - The new filter to be added, containing the attribute key and value.
*/
updateAttributeFilter({ filter, value, noUpdate }) {
// Retrieve the attribute type information to determine how to handle the update
const attribute = this.getProjectAttribute(filter);
// Check if the attribute allows multiple selections
const isMultiChoice = attribute.field_type.includes('list');
if (isMultiChoice) {
// For multi-choice attributes, manage the values as an array to allow multiple selections
let arrayValue = this.attributesFilter[filter] ? this.attributesFilter[filter].split(',') : [];
if (value) {
// If a value is provided, add it to the array, ensuring no duplicates and removing null corresponding to "Tous" default option
arrayValue.push(value);
arrayValue = [...new Set(arrayValue)].filter(el => el !== null);
// Convert the array back to a comma-separated string to store in the filter object
this.attributesFilter[filter] = arrayValue.join(',');
} else {
// If null value is provided "Tous" is selected, it indicates removal of the attribute filter
delete this.attributesFilter[filter];
}
} else {
// For single-choice attributes, directly set or delete the value
value ? this.attributesFilter[filter] = value : delete this.attributesFilter[filter];
}
if (noUpdate) {
this.SET_PROJECTS_FILTER({ filter: 'attributes', value: JSON.stringify(this.attributesFilter) });
} else {
// After updating the filter object, emit the updated filter for application-wide use
this.emitUpdatedFilter();
}
},
/**
* Removes a specified value from a project attribute filter.
* Particularly useful for multi-choice attributes where individual values can be deselected.
* @param {Object} removedFilter - The filter to be removed, containing the attribute key and value.
*/
removeAttributeFilter({ filter, value }) {
// Retrieve attribute information to determine if it's a multi-choice attribute
const attribute = this.getProjectAttribute(filter);
const isMultiChoice = attribute.field_type.includes('list');
if (isMultiChoice) {
// For multi-choice attributes, convert the current filter value to an array for manipulation
let arrayValue = this.attributesFilter[filter] ? this.attributesFilter[filter].split(',') : [];
// Remove the specified value from the array
arrayValue = arrayValue.filter(val => val !== value);
// Update the attributesFilter with the new array, converted back to a string
this.attributesFilter[filter] = arrayValue.join(',');
} else {
// For single-choice attributes, directly update the filter to remove the value
delete this.attributesFilter[filter];
}
// Emit the updated filter after removal
this.emitUpdatedFilter();
},
isMultiple(filter) {
return filter.field_type.includes('list');
},
setDefaultFilters(filter) {
const defaultFilter = filter.default_filter_enabled ? filter.default_filter_value : null;
if (defaultFilter) {
// make an array from the string in case of a list
const filtersArray = defaultFilter.split(',');
// for each value update the filter
filtersArray.forEach(defaultValue => {
const defaultOption = filter.options.find(option => option.value === defaultValue);
if (defaultOption) {
this.updateAttributeFilter({ ...defaultOption, noUpdate: true });
}
});
}
},
}
};
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.projects { .transition-properties(...) {
.item { -webkit-transition: @arguments;
display: flex; -moz-transition: @arguments;
flex-direction: column; -o-transition: @arguments;
align-items: flex-start !important; transition: @arguments;
}
padding: 0.4em 0.6em;
#filters-container {
label { width: 100%;
margin-bottom: 0.2em; display: flex;
font-size: 0.9em; flex-direction: column;
font-weight: 600; justify-content: flex-end;
align-items: flex-end;
.accordion {
width: fit-content;
.collapsible-filters {
font-size: 1.25em;
padding-right: 0;
.customcaret{
transition: transform .2s ease;
&.collapsed {
transform: rotate(180deg);
}
&::before{
position: relative;
right: 0;
top: 65%;
color: #999;
margin-top: 4px;
border-color: #999 transparent transparent;
border-style: solid;
border-width: 5px 5px 0;
content: "";
}
}
} }
} }
.item { .filters {
width: 25%; width: 100%;
height:auto;
max-height:100vh;
opacity: 1;
z-index: 1001;
.transition-properties(all 0.2s ease;);
.filter-row {
border: none;
box-shadow: none;
}
.item {
display: flex;
flex-direction: column;
align-items: flex-start !important;
padding: 0.5em;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
label {
margin-bottom: 0.2em;
font-size: 0.9em;
font-weight: 600;
}
}
.item {
width: 25%;
}
.item::before {
width: 0;
}
#search-projects {
width: 100%;
}
} }
.filters.hidden {
overflow: hidden;
opacity: 0;
max-height: 0;
}
}
@media screen and (min-width: 701px) {
.item {
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
}
@media screen and (max-width: 700px) {
#filters-container {
.filter-row {
display: flex;
flex-direction: column;
max-height: 275px;
.transition-properties(all 0.2s ease-out;);
.item {
width: 100%;
padding-right: 0;
padding-left: 0;
}
}
}
} }
</style> </style>
<template> <template>
<div id="search-projects"> <div id="search-projects">
<input <input
v-model="text" type="text"
type="search" placeholder="Rechercher un projet ..."
placeholder="Rechercher..." @input="searchProjects"
> >
</div> </div>
</template> </template>
<script> <script>
import _ from 'lodash'; import { debounce } from 'lodash';
import { mapMutations } from 'vuex'; import { mapActions, mapMutations } from 'vuex';
export default { export default {
name: 'SearchProjects', name: 'SearchProjects',
props: {
searchFunction: {
type: Function,
default: () => { return {} }
}
},
data() {
return {
text: null
}
},
watch: {
text: _.debounce(function(newValue) {
this.$emit('loading', true);
this.SET_CURRENT_PAGE(1);
this.searchFunction(newValue)
.then(() => {
this.$emit('loading', false);
});
}, 100)
},
methods: { methods: {
...mapMutations('projects', [ ...mapMutations('projects', [
'SET_CURRENT_PAGE' 'SET_CURRENT_PAGE'
]) ]),
...mapActions('projects', [
'SEARCH_PROJECTS'
]),
searchProjects:
debounce(function(e) {
this.$emit('loading', true);
this.SET_CURRENT_PAGE(1);
this.SEARCH_PROJECTS({ text: e.target.value })
.then(() => {
this.$emit('loading', false);
})
.catch((err) => {
if (err.message) {
this.$emit('loading', false);
}
});
}, 100)
} }
} };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
#search-projects { #search-projects {
height: 100%; height: 100%;
min-height: 40px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
font-size: 1rem;
input { input {
display: block;
width: 100%; width: 100%;
height: 72%; height: 100%;
text-align: left; text-align: left;
color: #35495e; color: #35495e;
padding: 8px 40px 0 8px; padding: 8px 40px 8px 8px;
border-radius: 5px; border: 1px solid #ced4da;
border: 1px solid #e8e8e8; font-size: 1rem;
font-size: 14px; font-family: var(--font-family, 'Roboto Condensed', 'Lato', 'Helvetica Neue', Arial, Helvetica, sans-serif);
}
input:focus {
outline: none !important;
box-shadow: 0 0 1px grey;
} }
} }
</style> </style>
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div> <div>
<Multiselect <Multiselect
v-model="selection" v-model="selection"
:options="results" :options="options"
:options-limit="10" :options-limit="10"
:allow-empty="true" :allow-empty="true"
track-by="feature_id" track-by="feature_id"
...@@ -12,7 +12,7 @@ ...@@ -12,7 +12,7 @@
selected-label="" selected-label=""
deselect-label="" deselect-label=""
:searchable="true" :searchable="true"
:placeholder="'Rechercher un signalement ...'" :placeholder="placeholder"
:show-no-results="true" :show-no-results="true"
:loading="loading" :loading="loading"
:clear-on-select="false" :clear-on-select="false"
...@@ -27,7 +27,10 @@ ...@@ -27,7 +27,10 @@
class="multiselect__clear" class="multiselect__clear"
@click.prevent.stop="selection = null" @click.prevent.stop="selection = null"
> >
<i class="close icon"></i> <i
class="close icon"
aria-hidden="true"
/>
</div> </div>
</template> </template>
<span slot="noResult"> <span slot="noResult">
...@@ -52,19 +55,36 @@ export default { ...@@ -52,19 +55,36 @@ export default {
Multiselect Multiselect
}, },
props: {
currentSelection : {
type: Object,
default: null,
}
},
data() { data() {
return { return {
loading: false, loading: false,
selection: null, selection: null,
text: null, text: null,
results: [] results: [],
} placeholder: 'Rechercher un signalement ...'
};
}, },
computed: { computed: {
...mapState('feature', [ ...mapState('feature', [
'features' 'features'
]) ]),
options() {
return this.results.map((el) => {
return {
featureId: el.id,
title: el.properties.title
};
});
},
}, },
watch: { watch: {
...@@ -77,12 +97,16 @@ export default { ...@@ -77,12 +97,16 @@ export default {
limit: '10' limit: '10'
}) })
.then(() => { .then(() => {
if (newValue) { if (newValue) { // filter out current feature
this.results = this.features; this.results = this.features.filter((el) => el.id !== this.$route.params.slug_signal);
} else { } else {
this.results.splice(0); this.results.splice(0);
} }
this.loading = false; this.loading = false;
})
.catch((err) => {
console.error(err);
this.loading = false;
}); });
} }
}, },
...@@ -91,6 +115,12 @@ export default { ...@@ -91,6 +115,12 @@ export default {
this.RESET_CANCELLABLE_SEARCH_REQUEST(); this.RESET_CANCELLABLE_SEARCH_REQUEST();
}, },
mounted() {
if (this.currentSelection && this.currentSelection.feature_to) {
this.placeholder = this.currentSelection.feature_to.title;
}
},
methods: { methods: {
...mapMutations(['RESET_CANCELLABLE_SEARCH_REQUEST']), ...mapMutations(['RESET_CANCELLABLE_SEARCH_REQUEST']),
...mapActions('feature', [ ...mapActions('feature', [
...@@ -100,15 +130,29 @@ export default { ...@@ -100,15 +130,29 @@ export default {
this.text = text; this.text = text;
}, },
select(e) { select(e) {
this.$emit('select', e); this.$emit('select', e.featureId);
}, },
close() { close() { // close calls as well selectFeatureTo, in case user didn't select a value
this.$emit('close', this.selection); this.$emit('close', this.selection && this.selection.featureId ?
this.selection.featureId : this.selection);
} }
} }
} };
</script> </script>
<style scoped> <style>
.multiselect input {
line-height: 1em !important;
padding: 0 !important;
}
.multiselect__placeholder {
margin: 0;
padding: 0;
}
.multiselect__input {
min-height: auto !important;
line-height: 1em !important;
}
</style> </style>
<template>
<div v-frag v-if="field.field_type === 'char'">
<label for="field.name">{{ field.label }}</label>
<input
type="text"
:name="field.name"
:id="field.name"
v-model="field.value"
@blur="updateStore_extra_form"
/>
</div>
<div v-frag v-else-if="field.field_type === 'list'">
<label for="field.name">{{ field.label }}</label>
<Dropdown
:options="field.options"
:selected="selected_extra_form_list"
:selection.sync="selected_extra_form_list"
/>
</div>
<div v-frag v-else-if="field.field_type === 'integer'">
<label for="field.name">{{ field.label }}</label>
<div class="ui input">
<!-- //* si click sur fléche dans champ input, pas de focus, donc pas de blur, donc utilisation de @change -->
<input
type="number"
:name="field.name"
:id="field.name"
v-model.number="field.value"
@change="updateStore_extra_form"
/>
</div>
</div>
<div v-frag v-else-if="field.field_type === 'boolean'">
<div class="ui checkbox">
<input
type="checkbox"
:checked="field.value"
:name="field.name"
:id="field.name"
@change="updateStore_extra_form"
/>
<label for="field.name">{{ field.label }}</label>
</div>
</div>
<div v-frag v-else-if="field.field_type === 'date'">
<label for="field.name">{{ field.label }}</label>
<input
type="date"
:name="field.name"
:id="field.name"
v-model="field.value"
@blur="updateStore_extra_form"
/>
</div>
<div v-frag v-else-if="field.field_type === 'decimal'">
<label for="field.name">{{ field.label }}</label>
<div class="ui input">
<input
type="number"
step=".01"
:name="field.name"
:id="field.name"
v-model.number="field.value"
@change="updateStore_extra_form"
/>
</div>
</div>
<div v-frag v-else-if="field.field_type === 'text'">
<label :for="field.name">{{ field.label }}</label>
<textarea
:name="field.name"
rows="3"
v-model="field.value"
@blur="updateStore_extra_form"
></textarea>
</div>
</template>
<script>
import frag from "vue-frag";
import Dropdown from "@/components/Dropdown.vue";
export default {
name: "FeatureExtraForm",
directives: {
frag,
},
components: {
Dropdown,
},
props: ["field"],
computed: {
selected_extra_form_list: {
get() {
return this.field.value || "";
},
set(newValue) {
//* set the value selected in the dropdown
let newExtraForm = this.field;
newExtraForm["value"] = newValue;
this.$store.commit("feature/UPDATE_EXTRA_FORM", newExtraForm);
},
},
},
methods: {
updateStore_extra_form(evt) {
let newExtraForm = this.field;
if (this.field.field_type === "boolean") {
newExtraForm["value"] = evt.target.checked; //* if checkbox use "checked"
} else {
newExtraForm["value"] = evt.target.value;
}
this.$store.commit("feature/UPDATE_EXTRA_FORM", newExtraForm);
},
},
};
</script>
\ No newline at end of file
<template>
<div data-tab="list" class="dataTables_wrapper no-footer">
<table id="table-features" class="ui compact table">
<thead>
<tr>
<th class="center"></th>
<th class="center">
Statut
<i
:class="{
down: isSortedAsc('status'),
up: isSortedDesc('status'),
}"
class="icon sort"
@click="changeSort('status')"
/>
</th>
<th class="center">
Type
<i
:class="{
down: isSortedAsc('feature_type'),
up: isSortedDesc('feature_type'),
}"
class="icon sort"
@click="changeSort('feature_type')"
/>
</th>
<th class="center">
Nom
<i
:class="{
down: isSortedAsc('title'),
up: isSortedDesc('title'),
}"
class="icon sort"
@click="changeSort('title')"
/>
</th>
<th class="center">
Dernière modification
<i
:class="{
down: isSortedAsc('updated_on'),
up: isSortedDesc('updated_on'),
}"
class="icon sort"
@click="changeSort('updated_on')"
/>
</th>
<th class="center" v-if="user">
Auteur
<i
:class="{
down: isSortedAsc('display_creator'),
up: isSortedDesc('display_creator'),
}"
class="icon sort"
@click="changeSort('display_creator')"
/>
</th>
<th class="center" v-if="user">
Dernier éditeur
<i
:class="{
down: isSortedAsc('display_last_editor'),
up: isSortedDesc('display_last_editor'),
}"
class="icon sort"
@click="changeSort('display_last_editor')"
/>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(feature, index) in paginatedFeatures" :key="index">
<td class="center">
<div
class="ui checkbox"
:class="
feature.properties.creator.username !== user.username &&
!user.is_superuser &&
!isUserProjectAdministrator
? 'disabled'
: ''
"
>
<input
type="checkbox"
:id="feature.id"
:value="feature.id"
v-model="checked"
:disabled="
feature.properties.creator.username !== user.username &&
!user.is_superuser &&
!isUserProjectAdministrator
"
/>
<label></label>
</div>
</td>
<td class="center">
<div
v-if="feature.properties.status.value === 'archived'"
data-tooltip="Archivé"
>
<i class="grey archive icon"></i>
</div>
<div
v-else-if="feature.properties.status.value === 'pending'"
data-tooltip="En attente de publication"
>
<i class="teal hourglass outline icon"></i>
</div>
<div
v-else-if="feature.properties.status.value === 'published'"
data-tooltip="Publié"
>
<i class="olive check icon"></i>
</div>
<div
v-else-if="feature.properties.status.value === 'draft'"
data-tooltip="Brouillon"
>
<i class="orange pencil alternate icon"></i>
</div>
</td>
<td class="center">
<router-link
:to="{
name: 'details-type-signalement',
params: {
feature_type_slug: feature.properties.feature_type.slug,
},
}"
>
{{ feature.properties.feature_type.title }}
</router-link>
</td>
<td class="center">
<router-link
:to="{
name: 'details-signalement',
params: {
slug_type_signal: feature.properties.feature_type.slug,
slug_signal: feature.properties.slug || feature.id,
},
}"
>{{ getFeatureDisplayName(feature) }}</router-link
>
</td>
<td class="center">
{{ feature.properties.updated_on }}
</td>
<td class="center" v-if="user">
{{ getUserName(feature) }}
</td>
<td class="center" v-if="user">
{{ feature.properties.display_last_editor }}
</td>
</tr>
<tr v-if="featuresCount === 0" class="odd">
<td colspan="5" class="dataTables_empty" valign="top">
Aucune donnée disponible
</td>
</tr>
</tbody>
</table>
<div
v-if="pageNumbers.length > 1"
id="table-features_info"
class="dataTables_info"
role="status"
aria-live="polite"
>
Affichage de l'élément {{ pagination.start + 1 }} à
{{ displayedPageEnd }}
sur {{ featuresCount }} éléments
</div>
<div
v-if="pageNumbers.length > 1"
id="table-features_paginate"
class="dataTables_paginate paging_simple_numbers"
>
<a
@click="$emit('update:page', 'previous')"
id="table-features_previous"
:class="[
'paginate_button previous',
{ disabled: pagination.currentPage === 1 },
]"
aria-controls="table-features"
data-dt-idx="0"
tabindex="0"
>Précédent</a
>
<span>
<span v-if="pagination.currentPage >= 5">
<a
key="page1"
@click="$emit('update:page', 1)"
class="paginate_button"
aria-controls="table-features"
data-dt-idx="1"
tabindex="0"
>{{ 1 }}</a
>
<span class="ellipsis"></span>
</span>
<a
v-for="pageNumber in displayedPageNumbers"
:key="'page' + pageNumber"
@click="$emit('update:page', pageNumber)"
:class="[
'paginate_button',
{ current: pageNumber === pagination.currentPage },
]"
aria-controls="table-features"
data-dt-idx="1"
tabindex="0"
>{{ pageNumber }}</a
>
<span v-if="(lastPageNumber - pagination.currentPage) >= 4">
<span class="ellipsis"></span>
<a
:key="'page' + lastPageNumber"
@click="$emit('update:page', lastPageNumber)"
class="paginate_button"
aria-controls="table-features"
data-dt-idx="1"
tabindex="0"
>{{ lastPageNumber }}</a
>
</span>
</span>
<a
id="table-features_next"
:class="[
'paginate_button next',
{ disabled: pagination.currentPage === pageNumbers.length },
]"
aria-controls="table-features"
data-dt-idx="7"
tabindex="0"
@click="$emit('update:page', 'next')"
>Suivant</a
>
</div>
</div>
</template>
<script>
import { mapState, mapGetters } from "vuex";
export default {
name: "FeatureListTable",
props: [
"paginatedFeatures",
"checkedFeatures",
"featuresCount",
"pagination",
"sort",
],
computed: {
...mapState(["user"]),
...mapGetters(["project", "permissions"]),
isUserProjectAdministrator() {
return this.permissions.is_project_administrator;
},
checked: {
get() {
return this.checkedFeatures;
},
set(newChecked) {
this.$store.commit("feature/UPDATE_CHECKED_FEATURES", newChecked);
},
},
displayedPageEnd() {
return this.featuresCount <= this.pagination.end
? this.featuresCount
: this.pagination.end;
},
pageNumbers() {
const totalPages = Math.ceil(
this.featuresCount / this.pagination.pagesize
);
return [...Array(totalPages).keys()].map((pageNumb) => {
++pageNumb;
return pageNumb;
});
},
lastPageNumber() {
return this.pageNumbers.slice(-1)[0];
},
displayedPageNumbers() {
//* si la page courante est inférieur à 5, la liste commence à l'index 0 et on retourne 5 pages
let firstPageInList = 0;
let pagesQuantity = 5;
//* à partir de la 5ième page et jusqu'à la 4ième page avant la fin : n'afficher que 3 page entre les ellipses et la page courante doit être au milieu
if (this.pagination.currentPage >= 5 && !(this.lastPageNumber - this.pagination.currentPage < 4)) {
firstPageInList = this.pagination.currentPage - 2;
pagesQuantity = 3
}
//* a partir de 4 résultat avant la fin afficher seulement les 5 derniers résultats
if (this.lastPageNumber - this.pagination.currentPage < 4) {
firstPageInList = this.lastPageNumber - 5;
}
return this.pageNumbers.slice(firstPageInList, firstPageInList + pagesQuantity);
},
},
methods: {
getUserName(feature) {
if (!feature.properties.creator) {
return " ---- ";
}
return feature.properties.creator.username || " ---- ";
},
getFeatureDisplayName(feature) {
return feature.properties.title || feature.id;
},
isSortedAsc(column) {
return this.sort.column === column && this.sort.ascending;
},
isSortedDesc(column) {
return this.sort.column === column && !this.sort.ascending;
},
changeSort(column) {
if (this.sort.column === column) {
//changer order
this.$emit("update:sort", {
column: this.sort.column,
ascending: !this.sort.ascending,
});
} else {
this.sort.column = column;
this.sort.ascending = true;
this.$emit("update:sort", { column, ascending: true });
}
},
},
};
</script>
<style scoped>
/* datatables */
.dataTables_wrapper {
position: relative;
clear: both;
}
.dataTables_wrapper .dataTables_length,
.dataTables_wrapper .dataTables_filter,
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_processing,
.dataTables_wrapper .dataTables_paginate {
color: #333;
}
.dataTables_wrapper .dataTables_info {
clear: both;
float: left;
padding-top: 0.755em;
}
.dataTables_wrapper .dataTables_paginate {
float: right;
text-align: right;
padding-top: 0.25em;
}
.dataTables_wrapper .dataTables_paginate .paginate_button.current,
.dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
color: #333 !important;
border: 1px solid #979797;
background-color: white;
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0%, #fff),
color-stop(100%, #dcdcdc)
);
background: -webkit-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -moz-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -ms-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -o-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: linear-gradient(to bottom, #fff 0%, #dcdcdc 100%);
}
.dataTables_wrapper .dataTables_paginate .paginate_button {
box-sizing: border-box;
display: inline-block;
min-width: 1.5em;
padding: 0.5em 1em;
margin-left: 2px;
text-align: center;
text-decoration: none !important;
cursor: pointer;
color: #333 !important;
border: 1px solid transparent;
border-radius: 2px;
}
.dataTables_wrapper .dataTables_paginate .paginate_button:hover {
color: white !important;
border: 1px solid #111;
background-color: #585858;
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0%, #585858),
color-stop(100%, #111)
);
background: -webkit-linear-gradient(top, #585858 0%, #111 100%);
background: -moz-linear-gradient(top, #585858 0%, #111 100%);
background: -ms-linear-gradient(top, #585858 0%, #111 100%);
background: -o-linear-gradient(top, #585858 0%, #111 100%);
background: linear-gradient(to bottom, #585858 0%, #111 100%);
}
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled,
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:hover,
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:active {
cursor: default;
color: #666 !important;
border: 1px solid transparent;
background: transparent;
box-shadow: none;
}
.dataTables_wrapper .dataTables_paginate .ellipsis {
padding: 0 1em;
}
i.icon.sort:not(.down):not(.up) {
color: rgb(220, 220, 220);
}
/*
Max width before this PARTICULAR table gets nasty
This query will take effect for any screen smaller than 760px
and also iPads specifically.
*/
@media only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
/* Force table to not be like tables anymore */
table,
thead,
tbody,
th,
td,
tr {
display: block;
}
/* Hide table headers (but not display: none;, for accessibility) */
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
tr {
border: 1px solid #ccc;
}
td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid #eee;
position: relative;
padding-left: 50%;
}
td:before {
/* Now like a table header */
position: absolute;
/* Top/left values mimic padding */
/* top: 6px; */
left: 6px;
/* width: 45%; */
padding-right: 10px;
white-space: nowrap;
}
/*
Label the data
*/
td:nth-of-type(1):before {
content: "";
}
td:nth-of-type(2):before {
content: "Statut";
}
td:nth-of-type(3):before {
content: "Type";
}
td:nth-of-type(4):before {
content: "Nom";
}
td:nth-of-type(5):before {
content: "Dernière modification";
}
td:nth-of-type(6):before {
content: "Auteur";
}
.center {
text-align: right !important;
}
#table-features {
margin-left: 1em;
width: calc(100% - 1em);
}
.ui.checkbox {
position: absolute;
left: -1.75em;
top: 5em;
}
}
</style>
\ No newline at end of file
<template>
<div class="ui teal segment pers-field">
<h4>
Champ personnalisé
<button
@click="removeCustomForm()"
class="ui small compact right floated icon button remove-field"
type="button"
>
<i class="ui times icon"></i>
</button>
</h4>
<div class="visible-fields">
<div class="two fields">
<div class="required field">
<label :for="form.label.id_for_label">{{ form.label.label }}</label>
<input
type="text"
required
:maxlength="form.label.field.max_length"
:name="form.label.html_name"
:id="form.label.id_for_label"
v-model="form.label.value"
@blur="updateStore"
/>
<small>{{ form.label.help_text }}</small>
<ul id="errorlist" class="errorlist">
<li v-for="error in form.label.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label :for="form.name.id_for_label">{{ form.name.label }}</label>
<input
type="text"
required
:maxlength="form.name.field.max_length"
:name="form.name.html_name"
:id="form.name.id_for_label"
v-model="form.name.value"
@blur="updateStore"
/>
<small>{{ form.name.help_text }}</small>
<ul id="errorlist" class="errorlist">
<li v-for="error in form.name.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
</div>
<div class="three fields">
<div class="required field">
<label :for="form.position.id_for_label">{{
form.position.label
}}</label>
<div class="ui input">
<input
type="number"
:min="form.position.field.min_value"
:name="form.position.html_name"
:id="form.position.id_for_label"
v-model="form.position.value"
@change="updateStore"
/>
</div>
<small>{{ form.position.help_text }}</small>
<ul id="errorlist" class="errorlist">
<li v-for="error in form.position.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label :for="form.field_type.id_for_label">{{
form.field_type.label
}}</label>
<Dropdown
:disabled="!form.label.value || !form.name.value"
:options="fieldTypeChoices"
:selected="selectedFieldType"
:selection.sync="selectedFieldType"
/>
<ul id="errorlist" class="errorlist">
<li v-for="error in form.field_type.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
<div
v-if="selectedFieldType === 'Liste de valeurs'"
class="field field-list-options required field"
>
<label :for="form.options.id_for_label">{{
form.options.label
}}</label>
<input
type="text"
:maxlength="form.options.field.max_length"
:name="form.options.html_name"
:id="form.options.id_for_label"
v-model="arrayOption"
class="options-field"
/>
<small>{{ form.options.help_text }}</small>
<ul id="errorlist" class="errorlist">
<li v-for="error in form.options.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
import Dropdown from "@/components/Dropdown.vue";
export default {
name: "FeatureTypeCustomForm",
components: {
Dropdown,
},
props: ["customForm", "selectedColorStyle"],
data() {
return {
fieldTypeChoices: [
{ name: "Booléen", value: "boolean" },
{ name: "Chaîne de caractères", value: "char" },
{ name: "Date", value: "date" },
{ name: "Liste de valeurs", value: "list" },
{ name: "Nombre entier", value: "integer" },
{ name: "Nombre décimal", value: "decimal" },
{ name: "Texte multiligne", value: "text" },
],
form: {
dataKey: 0,
label: {
errors: [],
id_for_label: "label",
label: "Label",
help_text: "Nom en language naturel du champ",
html_name: "label",
field: {
max_length: 128,
},
value: null,
},
name: {
errors: [],
id_for_label: "name",
label: "Nom",
html_name: "name",
help_text:
"Nom technique du champ tel qu'il apparaît dans la base de données ou dans l'export GeoJSON. Seuls les caractères alphanumériques et les traits d'union sont autorisés: a-z, A-Z, 0-9, _ et -)",
field: {
max_length: 128,
},
value: null,
},
position: {
errors: [],
id_for_label: "position",
label: "Position",
min_value: 0, // ! check if good values (not found)
html_name: "position",
help_text:
"Numéro d'ordre du champ dans le formulaire de saisie du signalement",
field: {
max_length: 128, // ! check if good values (not found)
},
value: this.customForm.dataKey - 1,
},
field_type: {
errors: [],
id_for_label: "field_type",
label: "Type de champ",
html_name: "field_type",
help_text: "",
field: {
max_length: 50,
},
value: "boolean",
},
options: {
errors: [],
id_for_label: "options",
label: "Options",
html_name: "options",
help_text: "Valeurs possibles de ce champ, séparées par des virgules",
field: {
max_length: 256,
},
value: [],
},
},
};
},
computed: {
selectedFieldType: {
// getter
get() {
const currentFieldType = this.fieldTypeChoices.find(
(el) => el.value === this.form.field_type.value
);
if (currentFieldType) {
return currentFieldType.name;
}
return null;
},
// setter
set(newValue) {
this.form.field_type.value = newValue.value;
this.form = { ...this.form }; // ! quick & dirty fix for getter not updating because of Vue caveat https://vuejs.org/v2/guide/reactivity.html#For-Objects
// Vue.set(this.form.field_type, "value", newValue.value); // ? vue.set didn't work, maybe should flatten form ?
this.updateStore();
},
},
arrayOption: {
get() {
return this.form.options.value.join();
},
// * create an array, because backend expects an array
set(newValue) {
this.form.options.value = this.trimWhiteSpace(newValue).split(",");
if (!this.hasDuplicateOptions()) {
this.updateStore();
}
},
},
},
methods: {
hasDuplicateOptions() {
this.form.options.errors = [];
const isDup =
new Set(this.form.options.value).size !==
this.form.options.value.length;
if (isDup) {
this.form.options.errors = ["Veuillez saisir des valeurs différentes"];
return true;
}
return false;
},
fillCustomFormData(customFormData) {
for (let el in customFormData) {
if (el && this.form[el] && customFormData[el]) {
//* check if is an object, because data from api is a string, while import from django is an object
this.form[el].value = customFormData[el].value
? customFormData[el].value
: customFormData[el];
}
}
this.updateStore();
},
removeCustomForm() {
this.$store.commit(
"feature_type/REMOVE_CUSTOM_FORM",
this.customForm.dataKey
);
},
updateStore() {
const data = {
dataKey: this.customForm.dataKey,
label: this.form.label.value,
name: this.form.name.value,
position: this.form.position.value,
field_type: this.form.field_type.value,
options: this.form.options.value,
};
this.$store.commit("feature_type/UPDATE_CUSTOM_FORM", data);
if (this.customForm.name === this.selectedColorStyle ) {
this.$emit("update", this.form.options.value);
}
},
trimWhiteSpace(string) {
// TODO : supprimer les espaces pour chaque option au début et à la fin QUE à la validation
return string.replace(/\s*,\s*/gi, ",");
},
hasRegularCharacters(input) {
for (let char of input) {
if (!/[a-zA-Z0-9-_]/.test(char)) {
return false;
}
}
return true;
},
checkUniqueName() {
const occurences = this.$store.state.feature_type.customForms
.map((el) => el.name)
.filter((el) => el === this.form.name.value);
return occurences.length === 1;
},
checkFilledOptions() {
if (this.form.field_type.value === "list") {
if (this.form.options.value.length < 1) {
return false;
} else if (
this.form.options.value.length === 1 &&
this.form.options.value[0] === ""
) {
return false;
}
}
return true;
},
checkCustomForm() {
this.form.label.errors = [];
this.form.name.errors = [];
this.form.options.errors = [];
if (!this.form.label.value) {
//* vérifier que le label est renseigné
this.form.label.errors = ["Veuillez compléter ce champ."];
return false;
} else if (!this.form.name.value) {
//* vérifier que le nom est renseigné
this.form.name.errors = ["Veuillez compléter ce champ."];
return false;
} else if (!this.hasRegularCharacters(this.form.name.value)) {
//* vérifier qu'il n'y a pas de caractères spéciaux
this.form.name.errors = [
"Veuillez utiliser seulement les caratères autorisés.",
];
return false;
} else if (!this.checkUniqueName()) {
//* vérifier si les noms sont pas dupliqués
this.form.name.errors = [
"Les champs personnalisés ne peuvent pas avoir des noms similaires.",
];
return false;
} else if (!this.checkFilledOptions()) {
//* s'il s'agit d'un type liste, vérifier que le champ option est bien renseigné
this.form.options.errors = ["Veuillez compléter ce champ."];
return false;
} else if (this.hasDuplicateOptions()) {
//* pour le cas d'options dupliqués
return false;
}
return true;
},
},
mounted() {
//* add datas from store to state to avoid mutating directly store with v-model (not good practice), could have used computed with getter and setter as well
this.fillCustomFormData(this.customForm);
},
};
</script>
const axios = require("axios") // Importing necessary libraries and components
import Vue from 'vue' const axios = require('axios'); // Axios for HTTP requests
import Vue from 'vue'; // Vue.js framework
import App from './App.vue'
import './registerServiceWorker' import App from './App.vue'; // Main Vue component
import router from '@/router' import './registerServiceWorker'; // Service worker registration
import store from '@/store' import router from '@/router'; // Application router
import 'leaflet/dist/leaflet.css'; import store from '@/store'; // Vuex store for state management
import 'leaflet-draw/dist/leaflet.draw.css';
import '@/assets/resources/leaflet-control-geocoder-1.13.0/Control.Geocoder.css'; // Importing CSS for styling
import '@fortawesome/fontawesome-free/css/all.css' import './assets/styles/base.css'; // Base styles
import '@fortawesome/fontawesome-free/js/all.js' import './assets/resources/semantic-ui-2.4.2/semantic.min.css'; // Semantic UI for UI components
import '@fortawesome/fontawesome-free/css/all.css'; // Font Awesome for icons
import '@fortawesome/fontawesome-free/js/all.js'; // Font Awesome JS
import 'ol/ol.css'; // OpenLayers CSS for maps
import '@/assets/styles/openlayers-custom.css'; // Custom styles for OpenLayers
import '@/assets/styles/sidebar-layers.css'; // Styles for sidebar layers
// Font Awesome library setup
import { library } from '@fortawesome/fontawesome-svg-core'; import { library } from '@fortawesome/fontawesome-svg-core';
import { fas } from '@fortawesome/free-solid-svg-icons'; import { fas } from '@fortawesome/free-solid-svg-icons'; // Importing solid icons
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; // Font Awesome component
// Multiselect installation
import 'vue-multiselect/dist/vue-multiselect.min.css';
library.add(fas) // Vue Multiselect CSS
import 'vue-multiselect/dist/vue-multiselect.min.css'; // Multiselect component styles
Vue.component('font-awesome-icon', FontAwesomeIcon); // Adding Font Awesome icons to the library
library.add(fas);
Vue.config.productionTip = false // Registering Font Awesome as a Vue component for use in templates
Vue.component('FontAwesomeIcon', FontAwesomeIcon);
// gestion mise à jour du serviceWorker et du precache // Setting Vue's production tip configuration
var refreshing=false; Vue.config.productionTip = false;
if(navigator.serviceWorker){ Vue.config.ignoredElements = ['geor-header'];
// Handling service worker updates and precaching
var refreshing = false; // Flag to prevent multiple refreshes
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('controllerchange', () => { navigator.serviceWorker.addEventListener('controllerchange', () => {
// We'll also need to add 'refreshing' to our data originally set to false. // Check if the page is already refreshing to prevent duplicate refreshes
if (refreshing) return if (refreshing) {
refreshing = true return;
// Here the actual reload of the page occurs }
window.location.reload() refreshing = true;
}) // Reload the page to activate the new service worker
window.location.reload();
});
} }
/**
* Dynamically loads a font from Google Fonts and sets CSS variables.
* @param {string} fontNames - A comma-separated list of font names, where the first font is the one to be imported and others are fallbacks.
* @param {string} headerColor - The color to be used for headers.
* @param {string} primaryColor - The primary color for the application.
* @param {string} primaryHighlightColor - The primary color to highlight elements in the application.
*/
const setAppTheme = (fontNames, headerColor, primaryColor, primaryHighlightColor) => {
// Set CSS variables for header and primary color.
if (headerColor) {
document.documentElement.style.setProperty('--header-color', headerColor);
}
if (primaryColor) {
document.documentElement.style.setProperty('--primary-color', primaryColor);
}
if (primaryHighlightColor) {
document.documentElement.style.setProperty('--primary-highlight-color', primaryHighlightColor);
}
// Proceed to load the font if fontNames is provided.
if (fontNames) {
const fontNameToImport = fontNames.split(',')[0].trim();
const link = document.createElement('link');
link.href = `https://fonts.googleapis.com/css?family=${fontNameToImport.replace(/ /g, '+')}:400,700&display=swap`;
link.rel = 'stylesheet';
document.head.appendChild(link);
let onConfigLoaded = function(config){ // Set the CSS variable for font family.
store.commit("SET_CONFIG", config); document.documentElement.style.setProperty('--font-family', fontNames);
}
};
// set title and favico /**
document.title= config.VUE_APP_APPLICATION_NAME+' '+config.VUE_APP_APPLICATION_ABSTRACT; * Sets the favicon of the application.
let link = document.createElement('link'); * @param {string} favicoUrl - The URL of the favicon to be set.
*/
const setFavicon = (favicoUrl) => {
const link = document.createElement('link');
link.id = 'dynamic-favicon'; link.id = 'dynamic-favicon';
link.rel = 'shortcut icon'; link.rel = 'shortcut icon';
link.href = config.VUE_APP_APPLICATION_FAVICO; link.href = favicoUrl;
document.head.appendChild(link); document.head.appendChild(link);
};
window.proxy_url=config.VUE_APP_DJANGO_API_BASE+"proxy/"; /**
axios.all([ * Regularly updates the online status of the application.
store.dispatch("USER_INFO"), */
store.dispatch('projects/GET_ALL_PROJECTS'), const updateOnlineStatus = () => {
store.dispatch("GET_STATIC_PAGES"), setInterval(() => {
store.dispatch("GET_USER_LEVEL_PROJECTS"), store.commit('SET_IS_ONLINE', navigator.onLine);
store.dispatch("map/GET_AVAILABLE_LAYERS"), }, 2000);
store.dispatch("GET_USER_LEVEL_PERMISSIONS"), };
store.dispatch("GET_LEVELS_PERMISSIONS"),
]).then(axios.spread(function () {
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
}));
/**
* Regularly updates the user status if using external auth to keep the frontend updated with backend.
*/
function handleLogout() {
if (store.state.user) {
store.commit('SET_USER', false);
store.commit('SET_USER_PERMISSIONS', null);
store.commit('SET_USER_LEVEL_PROJECTS', null);
store.commit('DISPLAY_MESSAGE', {
level: 'negative',
comment: `Vous avez été déconnecté du service d'authentification.
Reconnectez-vous ou continuez en mode anonyme.`
});
store.dispatch('projects/GET_PROJECTS');
store.dispatch('GET_USER_LEVEL_PERMISSIONS');
store.dispatch('GET_USER_LEVEL_PROJECTS');
}
} }
axios.get("./config/config.json") const updateUserStatus = () => {
.catch((error)=>{ setInterval(() => {
if (navigator.onLine) {
axios
.get(`${store.state.configuration.VUE_APP_DJANGO_API_BASE}user_info/`)
.then((response) => {
const user = response.data?.user || null;
// Cas où l'utilisateur a changé
if (store.state.user?.username !== user.username) {
store.commit('SET_USER', user);
// Cas où l'utilisateur est bien authentifié
if (user) {
store.commit('DISPLAY_MESSAGE', {
level: 'positive',
comment: 'Bienvenue à nouveau ! Vous êtes reconnecté au service d\'authentification'
});
store.dispatch('projects/GET_PROJECTS');
store.dispatch('GET_USER_LEVEL_PERMISSIONS');
store.dispatch('GET_USER_LEVEL_PROJECTS');
} else {
// On force la suppression de l'utilisateur au cas où le serveur SSO ne permet pas à la requête API d'aboutir (ex: redirection si non authentifié SSO)
handleLogout();
}
}
})
.catch(() => {
handleLogout();
});
}
}, 10000);
};
/**
* Fetches initial data for the application and initializes the Vue instance.
*/
const fetchDataAndInitializeApp = async () => {
await Promise.all([
store.dispatch('GET_USER_INFO'),
store.dispatch('GET_STATIC_PAGES'),
store.dispatch('map/GET_AVAILABLE_LAYERS'),
store.dispatch('GET_LEVELS_PERMISSIONS'),
store.dispatch('GET_PROJECT_ATTRIBUTES'),
]);
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app');
};
/**
* Initializes the application configuration.
* @param {object} config - Configuration object with application settings.
*/
const onConfigLoaded = async (config) => {
// Set application configuration in the store.
store.commit('SET_CONFIG', config);
// Update the online status at regular intervals.
updateOnlineStatus();
// Update the user status at regular intervals to check if backend session expired.
updateUserStatus();
// Set the document title and favicon from the configuration.
document.title = `${config.VUE_APP_APPLICATION_NAME} ${config.VUE_APP_APPLICATION_ABSTRACT}`;
setFavicon(config.VUE_APP_APPLICATION_FAVICO);
// Apply the application theme settings using values specified in the configuration.
setAppTheme(
config.VUE_APP_FONT_FAMILY,
config.VUE_APP_HEADER_COLOR,
config.VUE_APP_PRIMARY_COLOR,
config.VUE_APP_PRIMARY_HIGHLIGHT_COLOR
);
// Set a global proxy URL based on the configuration.
window.proxy_url = config.VUE_APP_DJANGO_API_BASE + 'proxy/';
// Fetch initial data and initialize the Vue application.
await fetchDataAndInitializeApp();
};
// Attempt to load the application configuration from an external JSON file.
axios.get('./config/config.json')
.catch((error) => {
// Log an error if the configuration file cannot be loaded.
console.error(error); console.error(error);
console.log("try to get From Localstorage"); console.log('Attempting to get config from Localstorage');
let conf=localStorage.getItem("geontrib_conf");
if(conf){ // Attempt to retrieve the configuration from local storage as a fallback.
onConfigLoaded(JSON.parse(conf)) const conf = localStorage.getItem('geontrib_conf');
if (conf) {
// If a configuration is found in local storage, parse it and load the config.
onConfigLoaded(JSON.parse(conf));
} }
}) })
.then((response) => { .then((response) => {
// Check if the response is valid and the request was successful.
if (response && response.status === 200) { if (response && response.status === 200) {
localStorage.setItem("geontrib_conf",JSON.stringify(response.data)); // Store the retrieved configuration in local storage for future use.
onConfigLoaded(response.data) localStorage.setItem('geontrib_conf', JSON.stringify(response.data));
// Load the configuration into the application.
onConfigLoaded(response.data);
} }
}) })
.catch((error) => { .catch((error) => {
// Throw an error if there are issues processing the response.
throw error; throw error;
}); });
/* eslint-disable no-console */ /* eslint-disable no-console */
import { register } from 'register-service-worker' import { register } from 'register-service-worker';
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, { register(`${process.env.BASE_URL}service-worker.js`, {
...@@ -8,37 +8,43 @@ if (process.env.NODE_ENV === 'production') { ...@@ -8,37 +8,43 @@ if (process.env.NODE_ENV === 'production') {
console.log( console.log(
'App is being served from cache by a service worker.\n' + 'App is being served from cache by a service worker.\n' +
'For more details, visit https://goo.gl/AFskqB' 'For more details, visit https://goo.gl/AFskqB'
) );
}, },
registered (registration) { registered (registration) {
//console.log('Service worker has been registered.') //console.log('Service worker has been registered.')
console.log( console.log(
'Service worker has been registered and now polling for updates.' 'Service worker has been registered and now polling for updates.'
) );
setInterval(() => { setInterval(() => {
registration.update() registration.update();
}, 10000) // every 10 seconds }, 10000); // every 10 seconds
}, },
cached () { cached () {
console.log('Content has been cached for offline use.') console.log('Content has been cached for offline use.');
}, },
updatefound () { updatefound () {
console.log('New content is downloading.') console.log('New content is downloading.');
}, },
updated (registration) { updated (registration) {
alert('Une nouvelle version de l\'application est disponible, l\'application va se recharger'); if (!navigator.webdriver) {
console.log('New content is available; please refresh.'); alert('Une nouvelle version de l\'application est disponible, l\'application va se recharger');
// console.log('New content is available; please refresh.');
if (!registration || !registration.waiting) return //
// Send message to SW to skip the waiting and activate the new SW if (!registration || !registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' }) return;
//window.location.reload(true); }
// Send message to SW to skip the waiting and activate the new SW
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
//window.location.reload(true);
} else {
console.log('Execution dans un navigateur controlé par un agent automatisé, la mise à jour n\'est pas appliqué pendant le test.');
}
}, },
offline () { offline () {
console.log('No internet connection found. App is running in offline mode.') console.log('No internet connection found. App is running in offline mode.');
}, },
error (error) { error (error) {
console.error('Error during service worker registration:', error) console.error('Error during service worker registration:', error);
} }
}) });
} }
import Vue from 'vue' import Vue from 'vue';
import VueRouter from 'vue-router' import VueRouter from 'vue-router';
import Projects from '../views/Projects.vue' import ProjectsList from '../views/Projects/ProjectsList.vue';
import store from '@/store';
import featureAPI from '@/services/feature-api';
Vue.use(VueRouter) Vue.use(VueRouter);
let projectBase = 'projet';
if (window.location.pathname.includes('projet-partage')) {
projectBase = 'projet-partage';
}
const routes = [ const routes = [
{ {
path: '/', path: '/',
name: 'index', name: 'index',
component: Projects component: ProjectsList
}, },
{ {
path: '/connexion/', path: `${projectBase === 'projet' ? '': `/${projectBase}/:slug`}/connexion/`,
name: 'login', name: 'login',
// route level code-splitting props: true,
// this generates a separate chunk (login.[hash].js) for this route component: () => import('../views/Login.vue')
// which is lazy-loaded when the route is visited. },
component: () => import(/* webpackChunkName: "login" */'../views/registration/Login.vue') {
path: `${projectBase === 'projet' ? '': `/${projectBase}/:slug`}/inscription/`,
name: 'signup',
component: () => import('../views/Login.vue')
},
{
path: `${projectBase === 'projet' ? '': `/${projectBase}/:slug`}/inscription/succes`,
name: 'sso-signup-success',
props: true,
component: () => import('../views/Login.vue')
}, },
{ {
path: '/my_account/', path: `${projectBase === 'projet' ? '': `/${projectBase}/:slug`}/my_account/`,
name: 'my_account', name: 'my_account',
component: () => import('../views/My_account.vue') component: () => import('../views/Account.vue')
}, },
{ {
path: '/mentions/', path: `${projectBase === 'projet' ? '': '/' + projectBase}/mentions/`,
name: 'mentions', name: 'mentions',
component: () => import('../views/flatpages/with_right_menu.vue') component: () => import('../views/FlatPages/LegalMentions.vue')
}, },
{ {
path: '/aide/', path: `${projectBase === 'projet' ? '': '/' + projectBase}/aide/`,
name: 'aide', name: 'aide',
component: () => import('../views/flatpages/Default.vue') component: () => import('../views/FlatPages/Help.vue')
}, },
// * PROJECT // * PROJECT
{ {
path: '/creer-projet/', path: '/creer-projet/',
name: 'project_create', name: 'project_create',
component: () => import('../views/project/Project_edit.vue') component: () => import('../views/Project/ProjectEdit.vue')
}, },
{ {
path: '/projet/:slug', path: `/${projectBase}/:slug`,
name: 'project_detail', name: 'project_detail',
props: true, props: true,
component: () => import('../views/project/Project_detail.vue'), component: () => import('../views/Project/ProjectDetail.vue'),
},
{
path: `/${projectBase}/:slug/signalement/lister/`,
name: 'liste-signalements',
component: () => import('../views/Project/FeaturesListAndMap.vue')
}, },
{ {
path: '/projet/:slug/editer', path: `/${projectBase}/:slug/editer`,
name: 'project_edit', name: 'project_edit',
component: () => import('../views/project/Project_edit.vue') component: () => import('../views/Project/ProjectEdit.vue')
}, },
{ {
path: '/projet-type/', path: '/projet-type/',
name: 'project_type_list', name: 'project_type_list',
component: () => import('../views/project/Project_type_list.vue') component: () => import('../views/Projects/ProjectsTypes.vue')
}, },
{ {
path: '/creer-projet/create_from/:slug/', path: '/creer-projet/create_from/:slug/',
name: 'project_create_from', name: 'project_create_from',
component: () => import('../views/project/Project_edit.vue') component: () => import('../views/Project/ProjectEdit.vue')
}, },
{ {
path: '/projet/:slug/administration-carte/', path: `/${projectBase}/:slug/administration-carte/`,
name: 'project_mapping', name: 'project_mapping',
component: () => import('../views/project/Project_mapping.vue') component: () => import('../views/Project/ProjectBasemaps.vue')
}, },
{ {
path: '/projet/:slug/membres/', path: `/${projectBase}/:slug/membres/`,
name: 'project_members', name: 'project_members',
component: () => import('../views/project/Project_members.vue') component: () => import('../views/Project/ProjectMembers.vue')
},
{
path: `/${projectBase}/:slug/signalement-filtre/`,
name: 'details-signalement-filtre',
component: () => import('../views/Feature/FeatureDetail.vue')
}, },
// * FEATURE TYPE // * FEATURE TYPE
{ {
path: '/projet/:slug/type-signalement/ajouter/', path: `/${projectBase}/:slug/type-signalement/ajouter/`,
name: 'ajouter-type-signalement', name: 'ajouter-type-signalement',
props: true, props: true,
component: () => import('../views/feature_type/Feature_type_edit.vue') component: () => import('../views/FeatureType/FeatureTypeEdit.vue')
}, },
{ {
path: '/projet/:slug/type-signalement/ajouter/create_from/:slug_type_signal', path: `/${projectBase}/:slug/type-signalement/ajouter/create_from/:slug_type_signal`,
name: 'dupliquer-type-signalement', name: 'dupliquer-type-signalement',
component: () => import('../views/feature_type/Feature_type_edit.vue') component: () => import('../views/FeatureType/FeatureTypeEdit.vue')
}, },
{ {
path: '/projet/:slug/type-signalement/:feature_type_slug/', path: `/${projectBase}/:slug/type-signalement/:feature_type_slug/`,
name: 'details-type-signalement', name: 'details-type-signalement',
component: () => import('../views/feature_type/Feature_type_detail.vue') component: () => import('../views/FeatureType/FeatureTypeDetail.vue')
}, },
{ {
path: '/projet/:slug/type-signalement/:slug_type_signal/editer/', path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/editer/`,
name: 'editer-type-signalement', name: 'editer-type-signalement',
component: () => import('../views/feature_type/Feature_type_edit.vue') component: () => import('../views/FeatureType/FeatureTypeEdit.vue')
}, },
{ {
path: '/projet/:slug/type-signalement/:slug_type_signal/symbologie/', path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/affichage/`,
name: 'editer-symbologie-signalement', name: 'editer-affichage-signalement',
component: () => import('../views/feature_type/Feature_type_symbology.vue') component: () => import('../views/FeatureType/FeatureTypeDisplay.vue')
}, },
// * FEATURE // * FEATURE
{ {
path: '/projet/:slug/signalement/lister/', path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/signalement/ajouter/`,
name: 'liste-signalements',
component: () => import('../views/feature/Feature_list.vue')
},
{
path: '/projet/:slug/type-signalement/:slug_type_signal/signalement/ajouter/',
name: 'ajouter-signalement', name: 'ajouter-signalement',
component: () => import('../views/feature/Feature_edit.vue') component: () => import('../views/Feature/FeatureEdit.vue')
}, },
{ {
path: '/projet/:slug/type-signalement/:slug_type_signal/signalement/:slug_signal', path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/signalement/:slug_signal`,
name: 'details-signalement', name: 'details-signalement',
component: () => import('../views/feature/Feature_detail.vue') component: () => import('../views/Feature/FeatureDetail.vue'),
/**
* Handles routing logic before entering the details-signalement route.
* This function manages access and navigation based on user permissions and feature data.
*/
beforeEnter: async (to, from, next) => {
try {
const { slug, slug_type_signal, slug_signal } = to.params;
// Retrieve the project details from the store
const project = await store.dispatch('projects/GET_PROJECT', slug);
// Prepare query based on the project settings for feature browsing
const query = { ordering: project.feature_browsing_default_sort };
// Check if the default filter of the project is set to feature_type and apply it
if (project.feature_browsing_default_filter) { // when feature_type is the default filter of the project,
query['feature_type_slug'] = slug_type_signal; // set feature_type slug in query
}
// Get the feature's position based on the feature slug and query settings
const offset = await featureAPI.getFeaturePosition(slug, slug_signal, query);
// Decide next routing based on the offset result
if (offset >= 0) {
next({
name: 'details-signalement-filtre',
params: { slug },
query: { ...query, offset }
});
} else if (offset === 'No Content') {
// API return no content when user is not allowed to see the feature or isn't connected
if (store.state.user) {
// If the user is connected, display information that he's not allowed to view the feature
store.commit('DISPLAY_MESSAGE', { comment: 'Vous n\'avez pas accès à ce signalement avec cet utilisateur', level: 'negative' });
// and redirect to main page
next({ path: '/' });
} else {
// If the user is not connected, remove other messages to avoid displaying twice that the user is not connected
store.commit('CLEAR_MESSAGES');
// display information that user need to be connected
store.commit('DISPLAY_MESSAGE', { comment: 'Vous n\'avez pas accès à ce signalement hors connexion, veuillez-vous connecter au préalable', level: 'negative' });
// Then redirect to login page
if (store.state.configuration.VUE_APP_LOGIN_URL) {
// If the login is through SSO, redirect to external login page (if the instance accepts a redirect_url it would be caught before when requesting user_info with GET_USER_INFO)
setTimeout(() => { // delay switching page to allow the info message to be read by user
window.open(store.state.configuration.VUE_APP_LOGIN_URL);
}, 1500);
} else {
// In a classic installation, redirect to the login page of this application
next({ name: 'login' });
}
}
} else {
store.commit('DISPLAY_MESSAGE', { comment: 'Désolé, une erreur est survenue pendant la recherche du signalement', level: 'negative' });
next({ path: '/' });
}
} catch (error) {
console.error('error', error);
store.commit('DISPLAY_MESSAGE', { comment: `Désolé, une erreur est survenue pendant la recherche du signalement - ${error}`, level: 'negative' });
next({ path: '/' });
}
}
}, },
{ {
path: '/projet/:slug/type-signalement/:slug_type_signal/offline', path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/offline`,
name: 'offline-signalement', name: 'offline-signalement',
component: () => import('../views/feature/Feature_offline.vue') component: () => import('../views/Feature/FeatureOffline.vue')
}, },
{ {
path: '/projet/:slug/type-signalement/:slug_type_signal/signalement/:slug_signal/editer/', path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/signalement/:slug_signal/editer/`,
name: 'editer-signalement', name: 'editer-signalement',
component: () => import('../views/feature/Feature_edit.vue') component: () => import('../views/Feature/FeatureEdit.vue')
},
{
path: `/${projectBase}/:slug/type-signalement/:slug_type_signal/editer-signalements-attributs/`,
name: 'editer-attribut-signalement',
component: () => import('../views/Feature/FeatureEdit.vue')
},
{
path: '/projet/:slug/catalog/:feature_type_slug',
name: 'catalog-import',
component: () => import('../views/Catalog.vue')
},
{
path: '/projet/:slug/type-signalement/:slug_type_signal/signalement/:slug_signal/attachment-preview/',
name: 'attachment-preview',
component: () => import('../views/AttachmentPreview.vue')
}, },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('../views/NotFound.vue') }, { path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('../views/NotFound.vue') },
] ];
//let routerHistory = [];
const router = new VueRouter({ const router = new VueRouter({
mode: 'history', mode: 'history',
base: '/geocontrib/', base: '/geocontrib/',
routes, routes,
routerHistory: [], routerHistory: [],
scrollBehavior(to, from, savedPosition) { //* record each route change to turn back to origin after redirect scrollBehavior(to, from, savedPosition) { //* record each route change to keep scroll position
const fromHistory = Boolean(savedPosition); const fromHistory = Boolean(savedPosition);
if (fromHistory && this.options.routerHistory.length > 0) { if (fromHistory && this.options.routerHistory.length > 0) {
...@@ -143,6 +239,6 @@ const router = new VueRouter({ ...@@ -143,6 +239,6 @@ const router = new VueRouter({
} }
return savedPosition || { x: 0, y: 0 }; return savedPosition || { x: 0, y: 0 };
}, },
}) });
export default router export default router;
/* global workbox */ //* allow undefined variable for 'workbox' in this file (because global variable) to avoid eslint error
// custom service-worker.js // custom service-worker.js
if (workbox) { if (workbox) {
// adjust log level for displaying workbox logs // adjust log level for displaying workbox logs
//workbox.core.setLogLevel(workbox.core.LOG_LEVELS.debug) //workbox.core.setLogLevel(workbox.core.LOG_LEVELS.debug)
// apply precaching. In the built version, the precacheManifest will // apply precaching. In the built version, the precacheManifest will
// be imported using importScripts (as is workbox itself) and we can // be imported using importScripts (as is workbox itself) and we can
// precache this. This is all we need for precaching // precache this. This is all we need for precaching
workbox.precaching.precacheAndRoute(self.__precacheManifest); workbox.precaching.precacheAndRoute(self.__precacheManifest);
//workbox.core.skipWaiting(); //workbox.core.skipWaiting();
// Make sure to return a specific response for all navigation requests. // Make sure to return a specific response for all navigation requests.
// Since we have a SPA here, this should be index.html always. // Since we have a SPA here, this should be index.html always.
// https://stackoverflow.com/questions/49963982/vue-router-history-mode-with-pwa-in-offline-mode // https://stackoverflow.com/questions/49963982/vue-router-history-mode-with-pwa-in-offline-mode
workbox.routing.registerNavigationRoute('/geocontrib/index.html', { workbox.routing.registerNavigationRoute('/geocontrib/index.html', {
blacklist: [/\/api/,/\/admin/,/\/media/,/\/cas/], blacklist: [/\/api/,/\/admin/,/\/media/,/\/cas/],
}) });
workbox.routing.registerRoute( workbox.routing.registerRoute(
new RegExp('.*/config/config.json'), new RegExp('.*/config/config.json'),
new workbox.strategies.StaleWhileRevalidate({ new workbox.strategies.StaleWhileRevalidate({
cacheName: 'config', cacheName: 'config',
}) })
) );
workbox.routing.registerRoute( workbox.routing.registerRoute(
new RegExp('.*/api/.*'), new RegExp('.*/api/.*'),
new workbox.strategies.NetworkFirst({ new workbox.strategies.NetworkFirst({
cacheName: 'api', cacheName: 'api',
}) plugins: [
) new workbox.cacheableResponse.Plugin({
workbox.routing.registerRoute( statuses: [0, 200],
/^https:\/\/c\.tile\.openstreetmap\.fr/, }),
new workbox.strategies.CacheFirst({ ],
cacheName: 'osm', })
plugins: [ );
new workbox.cacheableResponse.Plugin({ workbox.routing.registerRoute(
statuses: [0, 200], /^https:\/\/[a-zA-Z]\.tile\.openstreetmap\.fr/,
}), new workbox.strategies.CacheFirst({
new workbox.expiration.Plugin({ cacheName: 'osm',
maxAgeSeconds: 60 * 60 * 24 * 365, plugins: [
// maxEntries: 30, pour limiter le nombre d'entrée dans le cache new workbox.cacheableResponse.Plugin({
}), statuses: [0, 200],
], }),
}) new workbox.expiration.Plugin({
) maxAgeSeconds: 60 * 60 * 24 * 365,
// maxEntries: 30, pour limiter le nombre d'entrée dans le cache
}),
],
})
);
workbox.routing.registerRoute(
new RegExp('.*/service=WMS&request=GetMap/.*'),
new workbox.strategies.CacheFirst({
cacheName: 'wms',
plugins: [
new workbox.cacheableResponse.Plugin({
statuses: [0, 200],
}),
new workbox.expiration.Plugin({
maxAgeSeconds: 60 * 60 * 24 * 365,
// maxEntries: 30, pour limiter le nombre d'entrée dans le cache
}),
],
})
);
} }
// This code listens for the user's confirmation to update the app. // This code listens for the user's confirmation to update the app.
self.addEventListener('message', (e) => { self.addEventListener('message', (e) => {
if (!e.data) { if (!e.data) {
return; return;
} }
//console.log(e.data); switch (e.data.type) {
switch (e.data.type) { case 'SKIP_WAITING':
case 'SKIP_WAITING': self.skipWaiting();
self.skipWaiting(); break;
break; default:
default: // NOOP
// NOOP break;
break; }
} });
})
import { Draw, Snap } from 'ol/interaction';
import Modify from 'ol/interaction/Modify';
import { Collection } from 'ol';
import MultiPoint from 'ol/geom/MultiPoint';
import {
Fill, Stroke, Style, Circle, Text //RegularShape, Circle as CircleStyle, Text,Icon
} from 'ol/style';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import mapService from '@/services/map-service';
import { buffer } from 'ol/extent';
const editionService = {
drawnFeature: null,
featureToEdit: null,
editing_feature: {},
geom_type: 'linestring',
// Création d'une collection filtrée
filteredFeatures: new Collection(),
// Méthode pour créer un style basé sur la couleur actuelle
createDrawStyle(isEditing) {
return [
new Style({
// Style principal pour le polygone
fill: new Fill({
color: isEditing ? 'rgba(255, 145, 0, .2)' : 'rgba(255, 255, 255, .2)',
}),
// Style principal pour la ligne et le tour du polygone
stroke: new Stroke({
color: isEditing ? 'rgba(255, 145, 0, .9)' : 'rgba(255, 45, 0, 0.5)',
lineDash: [],
width: 2,
}),
// Style principal pour le point
image: new Circle({
radius: 7,
stroke: new Stroke({
color: 'rgba(255, 0, 0, 0.5)',
lineDash: [],
width: 2
}),
fill: new Fill({
color: isEditing ? 'rgba(255, 145, 0, 0.9)' : 'rgba(255, 255, 255, 0.5)'
})
}),
// Style pour le texte, pas utilisé mais peut être conservé au cas où
text: new Text({
font: '12px Calibri,sans-serif',
fill: new Fill({ color: '#000' }),
stroke: new Stroke({
color: '#fff',
width: 1
}),
text: ''
}),
zIndex: 50
}),
// Style pour afficher des points sur les sommets de ligne ou polygone (seulement en mode édition)
...(isEditing
? [
// Définition du style de point
new Style({
image: new Circle({
radius: 5,
stroke: new Stroke({
color: 'rgba(255, 145, 0, .9)',
width: 2,
}),
fill: new Fill({
color: 'rgba(255, 145, 0, .5)',
}),
}),
// Récupération des sommets où afficher les points uniquement pour ligne et polygone
geometry: function (feature) {
const geometry = feature.getGeometry();
if (geometry.getType() === 'LineString') {
return new MultiPoint(geometry.getCoordinates()); // Sommets de la ligne
} else if (geometry.getType() === 'Polygon') {
return new MultiPoint(geometry.getCoordinates()[0]); // Sommets du premier anneau
}
return null;
},
}),
]
: []),
];
},
// Méthode pour changer la couleur de la géométrie existante en passant en mode édition
toggleEditionColor(isEditing) {
const drawStyle = this.createDrawStyle(isEditing); // Re-crée le style
this.drawnItems.setStyle(drawStyle); // Applique le style à la couche
},
setEditingFeature(feature) {
this.editing_feature = feature;
},
initFeatureToEdit(feature) {
this.editing_feature = feature;
this.draw.setActive(false);
this.drawSource.addFeature(feature);
this.drawnItems.setZIndex(50);
mapService.fitExtent(buffer(this.drawSource.getExtent(),200));
},
addEditionControls(geomType) {
this.geom_type = geomType;
this.drawSource = new VectorSource();
this.drawnItems = new VectorLayer({
source: this.drawSource,
style: this.createDrawStyle(),
zIndex: 4000
});
mapService.getMap().addLayer(this.drawnItems);
if (this.draw) {
mapService.getMap().removeInteraction(this.draw);
}
let gType = 'Point';
if (geomType.toUpperCase().indexOf('POLYGON') >= 0) {
gType = 'Polygon';
}
else if (geomType.toUpperCase().indexOf('LINE') >= 0) {
gType = 'LineString';
}
this.draw = new Draw({
source: this.drawSource,
type: gType,
style: this.createDrawStyle()
});
mapService.getMap().addInteraction(this.draw);
this.setEditingFeature(undefined);
this.draw.on('drawend', (evt) => {
var feature = evt.feature;
this.drawnFeature = feature;
this.setEditingFeature(feature);
this.draw.setActive(false);
});
this.modify = new Modify({
style: this.createDrawStyle(),
features: this.filteredFeatures, // Limite la modification aux entités filtrées
});
// This workaround allows to avoid the ol freeze
// referenced bug : https://github.com/openlayers/openlayers/issues/6310
// May be corrected in a future version
this.modify.handleUpEvent_old = this.modify.handleUpEvent;
this.modify.handleUpEvent = function (evt) {
try {
this.handleUpEvent_old(evt);
} catch (ex) {
console.log(ex);
}
};
mapService.getMap().addInteraction(this.modify);
// Supprime dynamiquement la feature des entités modifiables
this.drawSource.on('removefeature', (event) => {
const feature = event.feature;
this.filteredFeatures.remove(feature);
});
},
resetAllTools() {
if (this.draw) {
this.draw.setActive(false);
}
if (this.modify) {
this.modify.setActive(false);
}
},
removeSelectInteraction(interaction) {
interaction.getFeatures().clear();
interaction.setActive(false);
},
activeUpdateFeature(isEditing) {
this.resetAllTools();
if (isEditing) {
// Mise à jour des entités modifiables
this.drawSource.forEachFeature((feature) => {
if (
(this.featureToEdit && feature.id_ === this.featureToEdit.id) ||
(this.drawnFeature && feature.ol_uid === this.drawnFeature.ol_uid) ||
(!this.drawnFeature && !this.featureToEdit)
) {
this.filteredFeatures.push(feature);
}
});
this.modify.setActive(true);
}
this.toggleEditionColor(isEditing);
},
/**
* Deletes the currently displayed feature from the map.
* This method removes the feature directly from the source without additional selection steps.
* It assumes that there is only one feature present in the source.
* Resets the color for future drawings to the default to ensure that the editing color
* is not displayed if the edit mode was active prior to deletion.
*/
removeFeatureFromMap() {
// Access the source where the features are stored
const source = this.drawSource; // Replace with the correct reference to your OpenLayers source
// Get all features from the source
const features = source.getFeatures();
// Check if there is a feature to delete
if (features.length > 0 && confirm('Etes-vous sur de vouloir supprimer cet objet ?')) {
try {
// Reset all other tools to ensure only the delete feature functionality is active
this.resetAllTools();
// Remove the feature from the source
const featureToRemove = features[0];
source.removeFeature(featureToRemove);
// Reinitialize the feature edited on the map
this.editing_feature = undefined;
// Toggle draw mode to create a new feature
this.draw.setActive(true);
// Reset color to default
this.toggleEditionColor(false);
// Return operation result after user confirmed to remove the feature
return true;
} catch (error) {
// Log an error if the feature cannot be removed
console.error('Error while deleting the feature: ', error);
}
}
return false;
},
setFeatureToEdit(feature) {
this.featureToEdit = feature;
},
removeActiveFeatures() {
this.drawnFeature = null;
this.featureToEdit = null;
},
addSnapInteraction(map) {
// The snap interaction must be added after the Modify and Draw interactions
// in order for its map browser event handlers to be fired first. Its handlers
// are responsible of doing the snapping.
// Since we can't give a list of source to snap,
// we use this workaround, an interaction collection: https://github.com/openlayers/openlayers/issues/7100
let interactions = [];
map.getLayers().forEach((layer) => {
if (layer instanceof VectorLayer) {
let interaction = new Snap({
source: layer.getSource()
});
interactions.push(interaction);
}
});
for(let snap of interactions ) {
map.addInteraction(snap);
}
},
removeSnapInteraction(map) {
// Find the double click interaction that is on the map.
let interactions = [];
map.getInteractions().forEach(function (interaction) {
if (interaction instanceof Snap) {
interactions.push(interaction);
}
});
// Remove the interaction from the map.
for(let snap of interactions ) {
map.removeInteraction(snap);
}
}
};
export default editionService;
import axios from "@/axios-client.js"; import axios from '@/axios-client.js';
import store from '../store' import store from '../store';
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const featureAPI = { const featureAPI = {
async getFeaturesBbox(project_slug, queryParams) { async getFeaturesBbox(project_slug, queryString) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.get( const response = await axios.get(
`${baseUrl}projects/${project_slug}/feature-bbox/${queryParams ? '?' + queryParams : ""}` `${baseUrl}projects/${project_slug}/feature-bbox/${queryString ? '?' + queryString : ''}`
); );
if ( if (
response.status === 200 && response.status === 200 &&
...@@ -22,8 +21,28 @@ const featureAPI = { ...@@ -22,8 +21,28 @@ const featureAPI = {
} }
}, },
async getProjectFeature(project_slug, feature_id) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.get(
`${baseUrl}v2/features/${feature_id}/?project__slug=${project_slug}`
);
if (
response.status === 200 &&
response.data
) {
return response.data;
} else {
return null;
}
},
async getPaginatedFeatures(url) { async getPaginatedFeatures(url) {
const response = await axios.get(url); // Cancel any ongoing search request.
store.dispatch('CANCEL_CURRENT_SEARCH_REQUEST');
// Prepare the cancel token for the new request and store it.
const cancelToken = axios.CancelToken.source();
store.commit('SET_CANCELLABLE_SEARCH_REQUEST', cancelToken);
const response = await axios.get(url, { cancelToken: cancelToken.token });
if ( if (
response.status === 200 && response.status === 200 &&
response.data response.data
...@@ -35,6 +54,7 @@ const featureAPI = { ...@@ -35,6 +54,7 @@ const featureAPI = {
}, },
async getFeatureEvents(featureId) { async getFeatureEvents(featureId) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.get( const response = await axios.get(
`${baseUrl}features/${featureId}/events/` `${baseUrl}features/${featureId}/events/`
); );
...@@ -49,6 +69,7 @@ const featureAPI = { ...@@ -49,6 +69,7 @@ const featureAPI = {
}, },
async getFeatureAttachments(featureId) { async getFeatureAttachments(featureId) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.get( const response = await axios.get(
`${baseUrl}features/${featureId}/attachments/` `${baseUrl}features/${featureId}/attachments/`
); );
...@@ -62,64 +83,148 @@ const featureAPI = { ...@@ -62,64 +83,148 @@ const featureAPI = {
} }
}, },
async postComment({ featureId, comment }) { async getFeatureLinks(featureId) {
const response = await axios.post( const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
`${baseUrl}features/${featureId}/comments/`, { comment } const response = await axios.get(
`${baseUrl}features/${featureId}/feature-links/`
); );
if ( if (
response.status === 201 && response.status === 200 &&
response.data response.data
) { ) {
return response; return response.data;
} else { } else {
return null; return null;
} }
}, },
async postCommentAttachment({ featureId, file, fileName, commentId, title }) { async getFeaturesBlob(url) {
let formdata = new FormData(); const response = await axios
formdata.append("file", file, fileName); .get(url, { responseType: 'blob' });
formdata.append("title", title);
const response = await axios.put(
`${baseUrl}features/${featureId}/comments/${commentId}/upload-file/`, formdata
);
if ( if (
response.status === 200 && response.status === 200 &&
response.data response.data
) { ) {
return response.data;
} else {
return null;
}
},
async postOrPutFeature({ method, feature_id, feature_type__slug, project__slug, data }) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
let url = `${baseUrl}v2/features/`;
if (method === 'PUT') {
url += `${feature_id}/?
feature_type__slug=${feature_type__slug}
&project__slug=${project__slug}`;
}
const response = await axios({
url,
method,
data,
});
if ((response.status === 200 || response.status === 201) && response.data) {
return response; return response;
} else { } else {
return null; return null;
} }
}, },
async getFeatureLinks(featureId) { async updateFeature({ feature_id, feature_type__slug, project__slug, newStatus }) {
const response = await axios.get( const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
`${baseUrl}features/${featureId}/feature-links/` const url = `${baseUrl}v2/features/${feature_id}/?feature_type__slug=${feature_type__slug}&project__slug=${project__slug}`;
const response = await axios({
url,
method: 'PATCH',
data: { id: feature_id, status: newStatus, feature_type: feature_type__slug }
});
if (response.status === 200 && response.data) {
return response;
} else {
return null;
}
},
async projectFeatureBulkUpdateStatus(projecSlug, queryString, newStatus) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const url = `${baseUrl}projects/${projecSlug}/feature-bulk-modify/?${queryString}`;
const response = await axios({
url,
method: 'PUT',
data: { status: newStatus }
});
if (response.status === 200 && response.data) {
return response;
} else {
return null;
}
},
async projectFeatureBulkDelete(projecSlug, queryString) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const url = `${baseUrl}projects/${projecSlug}/feature-bulk-modify/?${queryString}`;
const response = await axios({
url,
method: 'DELETE'
});
if (response.status === 200 && response.data) {
return response;
} else {
return null;
}
},
async postComment({ featureId, comment }) {
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.post(
`${baseUrl}features/${featureId}/comments/`, { comment }
); );
if ( if (
response.status === 200 && response.status === 201 &&
response.data response.data
) { ) {
return response.data; return response;
} else { } else {
return null; return null;
} }
}, },
async getFeaturesBlob(url) { async postCommentAttachment({ featureId, file, fileName, title, isKeyDocument, commentId }) {
const response = await axios const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
.get(url, { responseType: "blob" }) const formdata = new FormData();
formdata.append('file', file, fileName);
formdata.append('title', title);
formdata.append('is_key_document', isKeyDocument);
const response = await axios.put(
`${baseUrl}features/${featureId}/comments/${commentId}/upload-file/`, formdata
);
if ( if (
response.status === 200 && response.status === 200 &&
response.data response.data
) { ) {
return response.data; return response;
} else { } else {
return null; return null;
} }
}, },
}
async getFeaturePosition(projectSlug, featureId, query) {
const searchParams = new URLSearchParams(query);
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const response = await axios.get(`${baseUrl}projects/${projectSlug}/feature/${featureId}/position-in-list/?${searchParams.toString()}`);
if (response && response.status === 200) {
return response.data;
} else if (response.status === 204) {
return response.statusText;
}
return null;
},
};
export default featureAPI; export default featureAPI;
import axios from '@/axios-client.js';
import store from '@/store';
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const featureTypeAPI = {
async deleteFeatureType(featureType_slug) {
const response = await axios.delete(
`${baseUrl}v2/feature-types/${featureType_slug}/`
);
if (
response.status === 204
) {
return 'success';
} else {
return null;
}
},
};
export default featureTypeAPI;
...@@ -7,17 +7,17 @@ const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE; ...@@ -7,17 +7,17 @@ const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const mapAPI = { const mapAPI = {
async postOrPut({ basemap, projectSlug, newBasemapIds }) { async postOrPut({ basemap, projectSlug, newBasemapIds }) {
basemap["project"] = projectSlug basemap['project'] = projectSlug;
if (newBasemapIds.includes(basemap.id)) { if (newBasemapIds.includes(basemap.id)) {
return axios return axios
.post(`${baseUrl}base-maps/`, basemap) .post(`${baseUrl}v2/base-maps/`, basemap)
.then((response) => response) .then((response) => response)
.catch((error) => { .catch((error) => {
throw error; throw error;
}); });
} else { } else {
return axios return axios
.put(`${baseUrl}base-maps/${basemap.id}/`, basemap) .put(`${baseUrl}v2/base-maps/${basemap.id}/`, basemap)
.then((response) => response) .then((response) => response)
.catch((error) => { .catch((error) => {
throw error; throw error;
...@@ -26,6 +26,6 @@ const mapAPI = { ...@@ -26,6 +26,6 @@ const mapAPI = {
} }
} };
export default mapAPI; export default mapAPI;
import TileWMS from 'ol/source/TileWMS';
import { View, Map } from 'ol';
import { ScaleLine, Zoom, Attribution, FullScreen } from 'ol/control';
import TileLayer from 'ol/layer/Tile';
import { transform, transformExtent, fromLonLat } from 'ol/proj';
import { defaults } from 'ol/interaction';
import XYZ from 'ol/source/XYZ';
import VectorTileLayer from 'ol/layer/VectorTile';
import VectorTileSource from 'ol/source/VectorTile';
import { MVT, GeoJSON } from 'ol/format';
import { boundingExtent } from 'ol/extent';
import Overlay from 'ol/Overlay';
import { Fill, Stroke, Style, Circle } from 'ol/style';
import { asArray } from 'ol/color';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import WMTSTileGrid from 'ol/tilegrid/WMTS';
import WMTS, { optionsFromCapabilities } from 'ol/source/WMTS';
import WMTSCapabilities from 'ol/format/WMTSCapabilities';
import Geolocation from 'ol/Geolocation.js';
import Feature from 'ol/Feature.js';
import Point from 'ol/geom/Point.js';
import { applyStyle } from 'ol-mapbox-style';
import { isEqual } from 'lodash';
import axios from '@/axios-client.js';
import router from '@/router';
import store from '@/store';
import { retrieveFeatureProperties } from '@/utils';
const parser = new WMTSCapabilities();
let dictLayersToMap = {};
let layersCount = 0;
const geolocationStyle = new Style({
image: new Circle({
radius: 6,
fill: new Fill({
color: '#3399CC',
}),
stroke: new Stroke({
color: '#fff',
width: 2,
}),
}),
});
const mapService = {
layers: [],
mvtLayer: undefined,
content: {},
overlay: {},
map: undefined,
queryParams: {},
geolocation: undefined, // for geolocation
geolocationSource: null, // for geolocation
positionFeature: null, // for geolocation
lastPosition: null, // for geolocation
getMap() {
return this.map;
},
destroyMap() {
this.map = undefined;
},
createMap(el, options) {
const {
lat,
lng,
mapDefaultViewCenter,
mapDefaultViewZoom,
maxZoom,
zoom,
zoomControl = true,
fullScreenControl = false,
geolocationControl = false,
interactions = { doubleClickZoom: false, mouseWheelZoom: false, dragPan: true },
controls = [
new Attribution({ collapsible: false }),
new ScaleLine({
units: 'metric',
}),
],
} = options;
if (fullScreenControl) {
controls.push(new FullScreen({ tipLabel: 'Mode plein écran' }));
}
const mapOptions = {
layers: [],
target: el,
controls,
interactions: defaults(interactions),
view: new View({
center: transform([ //* since 0 is considered false, check for number instead of just defined (though boolean will pass through)
Number(lng) ? lng : mapDefaultViewCenter[1],
Number(lat) ? lat : mapDefaultViewCenter[0],
], 'EPSG:4326', 'EPSG:3857'),
zoom: Number(mapDefaultViewZoom) ? mapDefaultViewZoom : zoom,
maxZoom
}),
};
this.map = new Map(mapOptions);
if (zoomControl) {
this.map.addControl(new Zoom({ zoomInTipLabel: 'Zoomer', zoomOutTipLabel: 'Dézoomer' }));
}
if (geolocationControl) {
this.initGeolocation();
}
this.map.once('rendercomplete', () => {
this.map.updateSize();
});
const container = document.getElementById('popup');
this.content = document.getElementById('popup-content');
const closer = document.getElementById('popup-closer');
this.overlay = new Overlay({
element: container,
autoPan: true,
autoPanAnimation: {
duration: 500,
},
});
let overlay = this.overlay;
if (closer) {
closer.onclick = function () {
overlay.setPosition(undefined);
closer.blur();
return false;
};
}
this.map.addOverlay(this.overlay);
this.map.on('click', this.onMapClick.bind(this));
// catch event from sidebarLayer to update layers order (since all maps use it now)
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
this.updateOrder(event.detail.layers.slice().reverse());
});
return this.map;
},
addRouterToPopup({ featureId, featureTypeSlug, index }) {
const getFeaturePosition = async (searchParams) => {
const response = await axios.get(`${store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${router.history.current.params.slug}/feature/${featureId}/position-in-list/?${searchParams.toString()}`);
return response.data;
};
const goToBrowseFeatureDetail = async () => {
const currentQuery = { ...this.queryParams };
if (this.queryParams && this.queryParams.filter === 'feature_type_slug') { // when feature_type is the default filter of the project,
currentQuery['feature_type_slug'] = featureTypeSlug; // get its slug for the current feature
}
const searchParams = new URLSearchParams(currentQuery); // urlSearchParams allow to get rid of undefined values
if (!index >= 0) { // with mvt feature, there can't be an index
index = await getFeaturePosition(searchParams);
}
router.push({
name: 'details-signalement-filtre',
query: {
...Object.fromEntries(searchParams.entries()), // transform search params into object and spread it into query
offset: index
}
});
};
function goToFeatureDetail() {
router.push({
name: 'details-signalement',
params: {
slug_type_signal: featureTypeSlug,
slug_signal: featureId,
},
});
}
const goToFeatureTypeDetail = () => {
router.push({
name: 'details-type-signalement',
params: {
feature_type_slug: featureTypeSlug,
},
});
};
const isFeatureBrowsing = (router.history.current.name === 'project_detail' || router.history.current.name === 'liste-signalements');
const featureEl = document.getElementById('goToFeatureDetail');
if (featureEl) featureEl.onclick = isFeatureBrowsing ? goToBrowseFeatureDetail : goToFeatureDetail;
const featureTypeEl = document.getElementById('goToFeatureTypeDetail');
if (featureTypeEl) featureTypeEl.onclick = goToFeatureTypeDetail;
},
async onMapClick (event) {
//* retrieve features under pointer
const features = this.map.getFeaturesAtPixel(event.pixel, {
layerFilter: (l) => l === this.mvtLayer || this.olLayer
});
//* prepare popup content
if (features && features.length > 0 && this.content) {
const featureId = features[0].properties_ ? features[0].properties_.feature_id : features[0].id_;
const isEdited = router.history.current.name === 'editer-signalement' &&
router.history.current.params.slug_signal === featureId; //* avoid opening popup on feature currently edited
if (featureId && !isEdited) {
const popupContent = await this._createContentPopup(features[0]);
this.content.innerHTML = popupContent.html;
this.overlay.setPosition(event.coordinate);
this.addRouterToPopup({
featureId,
featureTypeSlug: popupContent.feature_type ? popupContent.feature_type.slug : '',
index: popupContent.index,
});
}
} else if (this.layers) { // If no feature under the mouse pointer, attempt to find a query layer
const queryLayer = this.layers.find(x => x.query);
if (queryLayer) {
// pour compatibilité avec le proxy django
const proxyparams = [
'request',
'service',
'srs',
'version',
'bbox',
'height',
'width',
'layers',
'query_layers',
'info_format', 'x', 'y', 'i', 'j',
];
const url = this.getFeatureInfoUrl(event, queryLayer);
const urlInfos = url ? url.split('?') : [];
const urlParams = new URLSearchParams(urlInfos[1]);
const params = {};
Array.from(urlParams.keys()).forEach(param => {
if (proxyparams.indexOf(param.toLowerCase()) >= 0) {
params[param.toLowerCase()] = urlParams.get(param);
}
});
params.url = urlInfos[0];
axios.get(
window.proxy_url,
{ params }
).then(response => {
const data = response.data;
const err = typeof data === 'object' ? null : data;
if (data.features || err) this.showGetFeatureInfo(err, event, data, queryLayer);
}).catch(error => {
throw error;
});
}
}
},
showGetFeatureInfo: function (err, event, data, layer) {
let content;
if (err) {
content = `
<h4>${layer.options.title}</h4>
<p>Données de la couche inaccessibles</p>
`;
this.content.innerHTML = content;
this.overlay.setPosition(event.coordinate);
} else { // Otherwise show the content in a popup
const contentLines = [];
let contentTitle;
if (data.features.length > 0) {
Object.entries(data.features[0].properties).forEach(entry => {
const [key, value] = entry;
if (key !== 'bbox') {
contentLines.push(`<div>${key}: ${value}</div>`);
}
});
contentTitle = `<h4>${layer.options.title}</h4>`;
content = contentTitle.concat(contentLines.join(''));
this.content.innerHTML = content;
this.overlay.setPosition(event.coordinate);
}
}
},
getFeatureInfoUrl(event, layer) {
const olLayer = dictLayersToMap[layer.id];
const source = olLayer.getSource();
const viewResolution = this.map.getView().getResolution();
let url;
const wmsOptions = { info_format: 'application/json', query_layers: layer.options.layers };
if (source && source.getFeatureInfoUrl) {
url = source.getFeatureInfoUrl(event.coordinate, viewResolution, 'EPSG:3857', wmsOptions);
}
return url;
},
fitBounds(bounds) {
let ext = boundingExtent([[bounds[0][1], bounds[0][0]], [bounds[1][1], bounds[1][0]]]);
ext = transformExtent(ext, 'EPSG:4326', 'EPSG:3857');
this.map.getView().fit(ext, { padding: [25, 25, 25, 25], maxZoom: 16 });
},
fitExtent(ext) {
//ext = transformExtent(ext, 'EPSG:4326', 'EPSG:3857');
this.map.getView().fit(ext, { padding: [25, 25, 25, 25] });
},
/**
* Add multiple layers to the map. If custom layers are defined, they will be added using `addConfigLayer`.
* If no custom layers are defined, a default basemap will be added based on the schema type (WMS, WMTS, or XYZ).
*
* @param {Array} layers - Array of layer configurations to be added.
* @param {string} serviceMap - URL or service for the map base layer.
* @param {Object} optionsMap - Options for the base layer (e.g., attribution, noWrap).
* @param {string} schemaType - Type of the base layer (either 'wms', 'wmts', or fallback to XYZ).
*
* @returns {void}
*/
addLayers: function (layers, serviceMap, optionsMap, schemaType) {
// Set the current layers to the provided layers array
this.layers = layers;
// Check if custom layers are defined (admin-defined basemaps)
if (layers) {
// Reset the layer count for managing Z-index
layersCount = 0;
// Loop through each layer and add it using the addConfigLayer method
layers.forEach((layer) => {
if (!layer) {
console.error('Layer is missing in the provided layers array.');
} else {
this.addConfigLayer(layer);
}
});
}
// If no custom layers are defined, fall back to the base map
else {
// Ensure that options for the base map are provided
if (!optionsMap) {
console.error('Options for the base map are missing.');
return;
}
// Set noWrap to true to prevent map wrapping around the globe
optionsMap.noWrap = true;
// Handle the base map based on the schema type (WMS, WMTS, or fallback)
if (schemaType === 'wms') {
// Add WMS layer if the schema type is 'wms'
if (!serviceMap) {
console.error('Service URL is missing for WMS base layer.');
} else {
this.addWMSLayer(serviceMap, optionsMap);
}
} else if (schemaType === 'wmts') {
// Add WMTS layer if the schema type is 'wmts'
if (!serviceMap) {
console.error('Service URL is missing for WMTS base layer.');
} else {
this.addWMTSLayerFromCapabilities(serviceMap, optionsMap);
}
} else {
// Default to XYZ tile layer if the schema type is not WMS or WMTS
if (!serviceMap) {
console.error('Service URL is missing for XYZ base layer.');
} else {
const layer = new TileLayer({
source: new XYZ({
attributions: optionsMap.attribution, // Attribution for the layer
url: serviceMap.replace('{s}', '{a-c}') // Handle subdomains in the URL
})
});
this.map.addLayer(layer); // Add the layer to the map
}
}
}
},
/**
* Add a configuration layer (WMS, WMTS, or TMS) to the map based on the layer's schema type.
* The function handles multiple types of map layers and applies the necessary configurations.
*
* @param {Object} layer - The layer configuration object.
* @param {Object} layer.options - Options for the layer (e.g., opacity, noWrap).
* @param {string} layer.schema_type - Type of the layer ('wms', 'wmts', or 'tms').
* @param {string} layer.service - URL or service for the layer.
* @param {number} layer.opacity - Opacity of the layer.
*
* @returns {void}
*/
addConfigLayer: async function (layer) {
// Check if the layer object is provided
if (!layer) {
console.error('Layer object is missing');
return;
}
// Increment the layers count (to manage Z-index)
layersCount += 1;
// Extract options from the layer
const options = layer.options;
// Check if options are provided for the layer
if (!options) {
console.error(`Options are missing for layer: ${layer.id}`);
return;
}
// Set default layer options (noWrap and opacity)
options.noWrap = true; // Prevent wrapping of the layer around the globe
options['opacity'] = layer.opacity; // Set opacity based on the layer's configuration
// Handle WMS layers
if (layer.schema_type === 'wms') {
// Add title for queryable WMS layers
if (layer.queryable) options['title'] = layer.title;
dictLayersToMap[layer.id] = this.addWMSLayer(layer.service, options); // Add WMS layer
}
// Handle WMTS layers
else if (layer.schema_type === 'wmts') {
try {
const newLayer = await this.addWMTSLayerFromCapabilities(layer.service, options); // Add WMTS layer asynchronously
dictLayersToMap[layer.id] = newLayer;
} catch (error) {
console.error(`Error adding WMTS layer: ${layer.id}`, error);
}
}
// Handle TMS layers
else if (layer.schema_type === 'tms') {
try {
const newLayer = await this.addTMSLayer(layer.service, options); // Add TMS layer asynchronously
dictLayersToMap[layer.id] = newLayer;
} catch (error) {
console.error(`Error adding TMS layer: ${layer.id}`, error);
}
} else {
console.error(`Unsupported schema type: ${layer.schema_type}`);
}
// Set Z-index for the layer if it was successfully added to the map
if (dictLayersToMap[layer.id]) {
dictLayersToMap[layer.id].setZIndex(layersCount);
} else {
console.error(`Failed to add layer to map: ${layer.id}`);
}
},
addWMSLayer: function (url, options) {
options.VERSION = options.version || '1.3.0'; // pour compatibilité avec le proxy django
const source = new TileWMS({
attributions: options.attribution,
url: url,
crossOrigin: 'anonymous',
params: options
});
const layer = new TileLayer({
source: source,
opacity: parseFloat(options.opacity),
});
this.map.addLayer(layer);
return layer;
},
getWMTSLayerCapabilities: async function (url) {
// adapted from : https://openlayers.org/en/latest/examples/wmts-layer-from-capabilities.html
// get capabilities with request to the service
try {
const response = await fetch(url);
const text = await response.text();
const capabilities = parser.read(text);
return capabilities;
} catch (error) {
console.error(error);
}
},
addWMTSLayerFromCapabilities: async function (url, options) {
// adapted from : https://git.neogeo.fr/onegeo-suite/sites/onegeo-suite-site-maps-vuejs/-/blob/draft/src/services/MapService.ts
const wmtsCapabilities = await this.getWMTSLayerCapabilities(url);
const { layer, opacity, attributions, format, ignoreUrlInCapabiltiesResponse } = options;
let sourceOptions;
try {
if (format) {
sourceOptions = optionsFromCapabilities(wmtsCapabilities, { layer, format });
} else {
sourceOptions = optionsFromCapabilities(wmtsCapabilities, { layer });
}
}
catch (e) {
console.error(e);
if (e.message == 'projection is null') {
return 'Projection non reconnue';
}
else {
return 'Problème d\'analyse du getCapabilities';
}
}
if (ignoreUrlInCapabiltiesResponse) {
var searchMask = 'request(=|%3D)getCapabilities';
var regEx = new RegExp(searchMask, 'ig');
var replaceMask = '';
sourceOptions.urls[0] = url.replace(regEx, replaceMask);
}
sourceOptions.attributions = attributions;
sourceOptions.crossOrigin= 'anonymous';
if (layer === 'ORTHOIMAGERY.ORTHOPHOTOS') {
// un peu bourrin mais il semble y avoir qq chose de spécifique avec cette couche ORTHO
// https://geoservices.ign.fr/documentation/services/utilisation-web/affichage-wmts/openlayers-et-wmts
sourceOptions.tileGrid = new WMTSTileGrid({
origin: [-20037508,20037508],
resolutions: [
156543.03392804103,
78271.5169640205,
39135.75848201024,
19567.879241005125,
9783.939620502562,
4891.969810251281,
2445.9849051256406,
1222.9924525628203,
611.4962262814101,
305.74811314070485,
152.87405657035254,
76.43702828517625,
38.218514142588134,
19.109257071294063,
9.554628535647034,
4.777314267823517,
2.3886571339117584,
1.1943285669558792,
0.5971642834779396,
0.29858214173896974,
0.14929107086948493,
0.07464553543474241
],
matrixIds: ['0','1','2','3','4','5','6','7','8','9','10','11','12','13','14','15','16','17','18','19'],
});
}
const newLayer = new TileLayer({
opacity: parseFloat(opacity) || 1,
source: new WMTS(sourceOptions),
});
this.map.addLayer(newLayer);
return newLayer;
},
/**
* Add a TMS (Tile Map Service) layer to the map.
* If the URL includes `.pbf` (vector tiles), it will apply a style from the provided options.
* The layer will be added to the map, and the style will be applied asynchronously if needed.
*
* @param {string} url - The URL of the TMS layer service.
* @param {Object} options - Configuration options for the TMS layer, including opacity and style.
* @param {string} options.style - URL to the style JSON (required for vector tiles).
* @param {number} options.opacity - Opacity of the layer (optional).
*
* @returns {VectorTileLayer} - The TMS layer added to the map.
*/
async addTMSLayer(url, options) {
// Check if the URL is missing
if (!url) {
console.error('TMS layer service URL is missing');
// Check if the options object is missing
} else if (!options) {
console.error('TMS layer options object is missing');
} else {
let layerTms;
// Check if the URL refers to PBF (vector tiles)
if (url.includes('pbf')) {
// Ensure that a style is provided for vector tiles
if (!options.style) {
console.error('TMS layer from PBF requires a style in the options');
} else {
// Handle PBF vector tiles
layerTms = new VectorTileLayer({
source: new VectorTileSource({
format: new MVT(), // Format for vector tiles (Mapbox Vector Tiles)
url: url.replace('{s}', '{a-c}'), // Handle subdomain pattern in the URL if present
attributions: options.attribution,
})
});
try {
// Fetch the style JSON from the provided URL
const response = await fetch(options.style);
const json = await response.json();
// Apply the fetched style to the layer (asynchronous)
await applyStyle(layerTms, json);
} catch (error) {
// Handle any errors during the fetch process
console.error('Error loading the style JSON:', error);
}
}
} else {
// Handle PNG raster tiles
layerTms = new TileLayer({
source: new XYZ({
url: url.replace('{s}', '{a-c}'), // Use the PNG TMS URL pattern
attributions: options.attribution,
})
});
}
// Set the opacity for the layer (default to 1.0 if not specified)
layerTms.setOpacity(parseFloat(options.opacity || 1.0));
// Add the TMS layer to the map
this.map.addLayer(layerTms);
// Return the TMS layer for further manipulation if needed
return layerTms;
}
},
// Remove the base layers (not the features)
removeLayers: function () {
Object.values(dictLayersToMap).forEach(element => {
this.map.removeLayer(element);
});
dictLayersToMap = {};
},
updateOpacity(layerId, opacity) {
const layer = dictLayersToMap[layerId];
if (layer) {
layer.setOpacity(parseFloat(opacity));
} else {
console.error(`Layer with id: ${layerId} couldn't be found for opacity update`);
}
},
updateOrder(layers) {
// First remove existing layers undefined
layers = layers.filter(function (x) {
return x !== undefined;
});
this.removeLayers();
// Redraw the layers
this.addLayers(layers);
},
retrieveFeatureStyle: function (featureType, properties) {
const { colors_style, customfield_set } = featureType;
let { color, opacity } = featureType;
if (colors_style && colors_style.custom_field_name && customfield_set) {
const customField = customfield_set.find((el) => el.name === colors_style.custom_field_name);
if (customField) {
const fieldType = customField.field_type;
let currentValue = properties[colors_style.custom_field_name];
if (currentValue && typeof currentValue === 'string') currentValue = currentValue.trim(); // remove leading and trailing whitespaces
switch (fieldType) {
case 'list':
if (currentValue) {
color = colors_style.colors && colors_style.colors[currentValue];
opacity = colors_style.opacities && colors_style.opacities[currentValue];
}
break;
case 'char': //* if the custom field is supposed to be a string
//* check if its current value is empty or not, to select a color | https://redmine.neogeo.fr/issues/14048
color = colors_style.value.colors && colors_style.value.colors[currentValue ? 'Non vide' : 'Vide'];
opacity = colors_style.value.opacities && colors_style.value.opacities[currentValue ? 'Non vide' : 'Vide'];
break;
case 'boolean':
color = colors_style.value.colors && colors_style.value.colors[currentValue ? 'Coché' : 'Décoché'];
opacity = colors_style.value.opacities && colors_style.value.opacities[currentValue ? 'Coché' : 'Décoché'];
break;
}
}
}
return { color, opacity };
},
addVectorTileLayer: function ({ url, project_slug, featureTypes, formFilters = {}, queryParams = {} }) {
const projectId = project_slug.split('-')[0];
const format_cfg = {/*featureClass: Feature*/ };
const mvt = new MVT(format_cfg);
function customLoader(tile, src) {
tile.setLoader(function(extent, resolution, projection) {
const token = () => {
const re = new RegExp('csrftoken=([^;]+)');
const value = re.exec(document.cookie);
return (value != null) ? unescape(value[1]) : null;
};
fetch(src, {
credentials: 'include',
headers: {
'X-CSRFToken': token()
},
}).then(function(response) {
response.arrayBuffer().then(function(data) {
const format = tile.getFormat(); // ol/format/MVT configured as source format
const features = format.readFeatures(data, {
extent: extent,
featureProjection: projection
});
tile.setFeatures(features);
});
});
});
}
const options = {
urls: [],
matrixSet: 'EPSG:3857',
tileLoadFunction: customLoader,
};
options.format = mvt;
const layerSource = new VectorTileSource(options);
layerSource.setTileUrlFunction((p0) => {
return `${url}/?tile=${p0[0]}/${p0[1]}/${p0[2]}&project_id=${projectId}`;
});
const styleFunction = (feature) => this.getStyle(feature, featureTypes, formFilters);
this.mvtLayer = new VectorTileLayer({
style: styleFunction,
source: layerSource
});
this.featureTypes = featureTypes; // store featureTypes for popups
this.projectSlug = project_slug; // store projectSlug for popups
this.queryParams = queryParams; // store queryParams for popups
this.mvtLayer.setZIndex(30);
this.map.addLayer(this.mvtLayer);
window.layerMVT = this.mvtLayer;
},
/**
* Determines the style for a given feature based on its type and applicable filters.
*
* @param {Object} feature - The feature to style.
* @param {Array} featureTypes - An array of available feature types.
* @param {Object} formFilters - Filters applied through the form.
* @returns {ol.style.Style} - The OpenLayers style for the feature.
*/
getStyle: function (feature, featureTypes, formFilters) {
const properties = feature.getProperties();
let featureType;
// Determine the feature type. Differentiate between GeoJSON and MVT sources.
if (properties && properties.feature_type) {
// Handle GeoJSON feature type
featureType = featureTypes
.find((ft) => ft.slug === (properties.feature_type.slug || properties.feature_type));
} else {
// Handle MVT feature type
featureType = featureTypes.find((x) => x.slug.split('-')[0] === '' + properties.feature_type_id);
}
if (featureType) {
// Retrieve the style (color, opacity) for the feature.
const { color, opacity } = this.retrieveFeatureStyle(featureType, properties);
let colorValue = '#000000'; // Default color
// Determine the color value based on the feature type.
if (color && color.value && color.value.length) {
colorValue = color.value;
} else if (typeof color === 'string' && color.length) {
colorValue = color;
}
// Convert the color value to RGBA and apply the opacity.
const rgbaColor = asArray(colorValue);
rgbaColor[3] = opacity || 0.5; // Default opacity
// Define the default style for the feature.
const defaultStyle = new Style({
image: new Circle({
fill: new Fill({ color: rgbaColor }),
stroke: new Stroke({ color: colorValue, width: 2 }),
radius: 5,
}),
stroke: new Stroke({ color: colorValue, width: 2 }),
fill: new Fill({ color: rgbaColor }),
});
// Define a hidden style to apply when filters are active.
const hiddenStyle = new Style();
// Apply filters based on feature type, status, and title.
if (formFilters) {
if (formFilters.type && formFilters.type.length > 0 && !formFilters.type.includes(featureType.slug)) {
return hiddenStyle;
}
if (formFilters.status && formFilters.status.length > 0 && !formFilters.status.includes(properties.status)) {
return hiddenStyle;
}
if (formFilters.title && !properties.title.toLowerCase().includes(formFilters.title.toLowerCase())) {
return hiddenStyle;
}
}
// Return the default style if no filters are applied or if the feature passes the filters.
return defaultStyle;
} else {
console.error('No corresponding featureType found.');
return new Style();
}
},
addFeatures: function ({ features, filter = {}, featureTypes, addToMap = true, project_slug, queryParams = {} }) {
console.log('addToMap', addToMap);
const drawSource = new VectorSource();
let retour;
let index = 0;
features.forEach((feature) => {
try {
if (feature.properties) {
feature.properties['index'] = index;
index += 1;
}
retour = new GeoJSON().readFeature(feature, { dataProjection: 'EPSG:4326', featureProjection: 'EPSG:3857' }, featureTypes);
drawSource.addFeature(retour);
} catch (err) {
console.error(err);
}
});
const styleFunction = (feature) => this.getStyle(feature, featureTypes, filter);
const olLayer = new VectorLayer({
source: drawSource,
style: styleFunction,
});
olLayer.setZIndex(29);
this.map.addLayer(olLayer);
this.olLayer = olLayer;
this.drawSource = drawSource;
this.featureTypes = featureTypes; // store featureTypes for popups
this.projectSlug = project_slug; // store projectSlug for popups
this.queryParams = queryParams; // store queryParams for popup routes
return drawSource;
},
removeFeatures: function () {
this.drawSource.clear();
},
addMapEventListener: function (eventName, callback) {
this.map.on(eventName, callback);
},
createCustomFiedsContent(featureType, feature) {
const { customfield_set } = featureType;
// generate html for each customField configured to be displayed
let rows = '';
for (const { label, name } of customfield_set) {
const value = feature.getProperties()[name];
// check if the value is not null nor undefined (to allow false value if boolean)
if (featureType.displayed_fields.includes(name) && value !== null && value !== undefined) {
rows += `<div class="customField-row">${label} : ${value}</div>`;
}
}
// wrap all rows into customFields container
return rows.length > 0 ?
`<div id="customFields">
<div class="ui divider"></div>
<h5>Champs personnalisés</h5>
${rows}
</div>` : '';
},
_createContentPopup: async function (feature) {
const properties = await retrieveFeatureProperties(feature, this.featureTypes, this.projectSlug);
const { feature_type, index, status, updated_on, created_on, creator, display_last_editor } = properties; // index is used to retrieve feature by query when browsing features
const { displayed_fields } = feature_type;
// generate html for each native fields
const statusHtml = `<div>Statut : ${status}</div>`;
const featureTypeHtml = `<div>Type de signalement : ${feature_type ? '<a id="goToFeatureTypeDetail" class="pointer">' + feature_type.title + '</a>' : 'Type de signalement inconnu'}</div>`;
const updatedOnHtml = `<div>Dernière mise à jour : ${updated_on}</div>`;
const createdOnHtml = `<div>Date de création : ${created_on}</div>`;
const creatorHtml = creator ? `<div>Auteur : ${creator}</div>` : '';
const lastEditorHtml = display_last_editor ? `<div>Dernier éditeur : ${display_last_editor}</div>` : '';
// wrapping up finale html to fill popup, filtering native fields to display and adding filtered customFields
const html = `<h4>
<a id="goToFeatureDetail" class="pointer">${feature.getProperties ? feature.getProperties().title : feature.title}</a>
</h4>
<div class="fields">
${displayed_fields.includes('status') ? statusHtml : ''}
${displayed_fields.includes('feature_type') ? featureTypeHtml : ''}
${displayed_fields.includes('updated_on') ? updatedOnHtml : ''}
${displayed_fields.includes('created_on') ? createdOnHtml : ''}
${displayed_fields.includes('display_creator') ? creatorHtml : ''}
${displayed_fields.includes('display_last_editor') ? lastEditorHtml : ''}
${this.createCustomFiedsContent(feature_type, feature)}
</div>`;
return { html, feature_type, index };
},
zoom(zoomlevel) {
this.map.getView().setZoom(zoomlevel);
},
zoomTo(location, zoomlevel, lon, lat) {
if (lon && lat) {
location = [+lon, +lat];
}
this.map.getView().setCenter(transform(location, 'EPSG:4326', 'EPSG:3857'));
this.zoom(zoomlevel);
},
animateTo(center, zoom) {
this.map.getView().animate({ center, zoom });
},
addOverlay(loc, zoom) {
const pos = fromLonLat(loc);
const marker = new Overlay({
position: pos,
positioning: 'center',
element: document.getElementById('marker'),
stopEvent: false,
});
this.map.addOverlay(marker);
this.animateTo(pos, zoom);
},
initGeolocation() {
this.geolocation = new Geolocation({
// enableHighAccuracy must be set to true to have the heading value.
trackingOptions: {
enableHighAccuracy: true,
},
projection: this.map.getView().getProjection(),
});
// handle this.geolocation error.
this.geolocation.on('error', (error) => {
console.error(error.message);
});
this.positionFeature = new Feature();
this.positionFeature.setStyle( geolocationStyle );
this.geolocation.on('change:position', () => {
const currentPosition = this.geolocation.getPosition();
if (!currentPosition || !isEqual(this.lastPosition, currentPosition)) {
console.log('current position: ', currentPosition); // keeping this console.log for debug purpose in case needed
}
this.lastPosition = currentPosition;
this.changeTrackerPosition();
});
this.geolocationSource = new VectorSource({
features: [this.positionFeature],
});
new VectorLayer({
map: this.map,
source: this.geolocationSource,
});
},
changeTrackerPosition() {
if (this.lastPosition) {
this.positionFeature.setGeometry(new Point(this.lastPosition));
this.animateTo(this.lastPosition, 16);
}
},
displayGeolocationPoint(isVisible) {
let features = this.geolocationSource.getFeatures();
if (!features) return;
const hiddenStyle = new Style(); // hide the feature
for (let i = 0; i < features.length; i++) {
features[i].setStyle(isVisible ? geolocationStyle : hiddenStyle);
}
},
toggleGeolocation(isTracking) {
if (this.geolocation) {
this.geolocation.setTracking(isTracking);
if (this.geolocationSource) {
this.displayGeolocationPoint(isTracking);
if (isTracking) {
this.changeTrackerPosition();
}
}
}
},
getMapCenter() {
const location = this.map.getView().getCenter();
if (location) {
return transform(location, 'EPSG:3857', 'EPSG:4326');
}
return null;
}
};
export default mapService;
\ No newline at end of file
import axios from '@/axios-client.js';
import store from '../store';
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const miscAPI = {
async getIdgoCatalog(username) {
try {
const response = await axios.get(
`${baseUrl}idgo-catalog/?user=${username}`
);
if (
response.status === 200 &&
response.data
) {
return response.data;
} else {
return null;
}
} catch (err) {
return err;
}
},
async getExternalGeojson(queryParams) {
const response = await axios.get(
`${baseUrl}external-geojson/${queryParams ? '?' + queryParams : ''}`
);
if (
response.status === 200 &&
response.data
) {
return response.data;
} else {
return null;
}
},
async getUserEvents(project_slug) {
const response = await axios.get(`${baseUrl}events/${project_slug ? '?project_slug=' + project_slug : ''}`);
if (
response.status === 200 &&
response.data
) {
return response.data;
} else {
return null;
}
},
};
export default miscAPI;
import axios from '@/axios-client.js'; import axios from '@/axios-client.js';
const projectAPI = { const projectAPI = {
async getProject( baseUrl, projectSlug ) {
const response = await axios.get(
`${baseUrl}v2/projects/${projectSlug}/`
);
if (
response.status === 200 &&
response.data
) {
return response.data;
} else {
return null;
}
},
async getProjectSubscription({ baseUrl, projectSlug }) { async getProjectSubscription({ baseUrl, projectSlug }) {
const response = await axios.get( const response = await axios.get(
`${baseUrl}projects/${projectSlug}/subscription/` `${baseUrl}projects/${projectSlug}/subscription/`
...@@ -32,21 +45,28 @@ const projectAPI = { ...@@ -32,21 +45,28 @@ const projectAPI = {
} }
}, },
async getProjects(baseUrl, filters, page) { async getProjects({ baseUrl, filters, page, projectSlug, myaccount, text }) {
let url = `${baseUrl}v2/projects/`;
if (projectSlug) {
url += `${projectSlug}/`;
}
url += `?page=${page}`;
if (myaccount) {
url += '&myaccount=true';
}
// Append search text if provided.
if (text) {
url += `&search=${encodeURIComponent(text)}`;
}
try { try {
const url = `${baseUrl}projects/?page=${page}`;
let filteredUrl;
if (Object.values(filters).some(el => el && el.length > 0)) { if (Object.values(filters).some(el => el && el.length > 0)) {
filteredUrl = url;
for (const filter in filters) { for (const filter in filters) {
if (filters[filter]) { if (filters[filter]) {
filteredUrl = filteredUrl.concat('', `&${filter}=${filters[filter]}`); url = url.concat('', `&${filter}=${filters[filter]}`);
} }
} }
} }
const response = await axios.get(url);
const response = await axios.get(filteredUrl ? filteredUrl : url);
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
return response.data; return response.data;
} }
...@@ -54,8 +74,46 @@ const projectAPI = { ...@@ -54,8 +74,46 @@ const projectAPI = {
console.error(error); console.error(error);
throw error; throw error;
} }
} },
async getProjectUsers( baseUrl, projectSlug) {
const response = await axios.get(
`${baseUrl}projects/${projectSlug}/utilisateurs/`
);
if (
response.status === 200 &&
response.data
) {
return response.data;
} else {
return null;
}
},
async getProjectTypes( baseUrl ) {
const response = await axios.get(
`${baseUrl}v2/projects/?is_project_type=true`
);
if (
response.status === 200 &&
response.data
) {
return response.data.results;
} else {
return null;
}
},
async deleteProject(baseUrl, projectSlug) {
const response = await axios.delete(
`${baseUrl}v2/projects/${projectSlug}/`
);
if ( response.status === 204 ) {
return 'success';
} else {
return null;
}
},
}; };
export default projectAPI; export default projectAPI;
import axios from '@/axios-client.js';
import store from '../store';
const baseUrl = store.state.configuration.VUE_APP_DJANGO_API_BASE;
const userAPI = {
async signup(data, url) {
try {
const response = await axios.post(url || `${baseUrl}v2/users/`, data);
return response; // Retourne directement la réponse si succès
} catch (err) {
console.error('Erreur lors de l\'inscription :', err.response || err);
return err.response || { status: 500, data: { detail: 'Erreur inconnue' } }; // 👈 Retourne la réponse d'erreur si disponible
}
},
};
export default userAPI;