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 1974 additions and 959 deletions
......@@ -14,41 +14,54 @@
</td>
</tr>
<tr
v-for="(field, index) in currentFeature.feature_data"
:key="'field' + index"
v-for="field in featureFields"
:key="field.name"
>
<td>
<strong>{{ field.label }}</strong>
</td>
<td>
<strong class="ui form">
<span
v-if="fastEditionMode && canEditFeature && extra_forms.length > 0"
:id="field.label"
>
<FeatureExtraForm
:field="getExtraForm(field)"
<template v-if="!field.isDeactivated">
<td>
<strong :class="{ required: field.is_mandatory }">
{{ field.label }}
</strong>
</td>
<td>
<strong class="ui form">
<span
v-if="fastEditionMode && canEditFeature && extra_forms.length > 0"
:id="field.label"
>
<ExtraForm
ref="extraForm"
:field="field"
/>
</span>
<i
v-else-if="field.field_type === 'boolean'"
:class="[
'icon',
field.value ? 'olive check' : 'grey times',
]"
aria-hidden="true"
/>
</span>
<i
v-else-if="field.field_type === 'boolean'"
:class="[
'icon',
field.value ? 'olive check' : 'grey times',
]"
aria-hidden="true"
/>
<span v-else>
{{ field.value }}
</span>
</strong>
</td>
<span v-else-if="field.value && field.field_type === 'multi_choices_list'">
{{ field.value.join(', ') }}
</span>
<span v-else-if="field.value && field.field_type === 'notif_group'">
{{ usersGroupLabel(field) }}
</span>
<span v-else>
{{ field.value && field.value.label ? field.value.label : field.value }}
</span>
</strong>
</td>
</template>
</tr>
<tr>
<td>
Auteur
</td>
<td>{{ currentFeature.display_creator }}</td>
<td v-if="currentFeature.properties">
{{ currentFeature.properties.display_creator }}
</td>
</tr>
<tr>
<td>
......@@ -56,13 +69,13 @@
</td>
<td>
<i
v-if="currentFeature.status"
v-if="currentFeature.properties && currentFeature.properties.status"
:class="['icon', statusIcon]"
aria-hidden="true"
/>
<FeatureEditStatusField
v-if="fastEditionMode && canEditFeature && form"
:status="form.status.value"
:status="form.status.value.value || form.status.value"
class="inline"
/>
<span v-else>
......@@ -70,20 +83,33 @@
</span>
</td>
</tr>
<tr v-if="project && project.feature_assignement">
<td>
Membre assigné
</td>
<td>
<ProjectMemberSelect
:selected-user-id="assignedMemberId"
:disabled="!fastEditionMode || !canEditFeature"
class="inline"
@update:user="$store.commit('feature/UPDATE_FORM_FIELD', { name: 'assigned_member', value: $event })"
/>
</td>
</tr>
<tr>
<td>
Date de création
</td>
<td v-if="currentFeature.created_on">
{{ currentFeature.created_on | formatDate }}
<td v-if="currentFeature.properties && currentFeature.properties.created_on">
{{ currentFeature.properties.created_on | formatDate }}
</td>
</tr>
<tr>
<td>
Date de dernière modification
</td>
<td v-if="currentFeature.updated_on">
{{ currentFeature.updated_on | formatDate }}
<td v-if="currentFeature.properties && currentFeature.properties.updated_on">
{{ currentFeature.properties.updated_on | formatDate }}
</td>
</tr>
</tbody>
......@@ -108,10 +134,13 @@
<td
v-if="link.feature_to.feature_type_slug"
>
<a
class="pointer"
@click="toFeature(link)"
>{{ link.feature_to.title }} </a>
<FeatureFetchOffsetRoute
:feature-id="link.feature_to.feature_id"
:properties="{
title: link.feature_to.title,
feature_type: { slug: link.feature_to.feature_type_slug }
}"
/>
({{ link.feature_to.display_creator }} -
{{ link.feature_to.created_on }})
</td>
......@@ -130,30 +159,33 @@
<script>
import { mapState } from 'vuex';
import { mapState, mapGetters } from 'vuex';
import FeatureTypeLink from '@/components/FeatureType/FeatureTypeLink';
import FeatureEditStatusField from '@/components/Feature/FeatureEditStatusField';
import FeatureExtraForm from '@/components/Feature/Edit/FeatureExtraForm';
import { statusChoices } from '@/utils';
import ExtraForm from '@/components/ExtraForm';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
import ProjectMemberSelect from '@/components/ProjectMemberSelect';
import { statusChoices, formatStringDate, checkDeactivatedValues } from '@/utils';
export default {
name: 'FeatureTable',
components: {
FeatureTypeLink,
FeatureEditStatusField,
FeatureExtraForm,
},
filters: {
formatDate(value) {
let date = new Date(value);
date = date.toLocaleString().replace(',', '');
return date.substr(0, date.length - 3); //* quick & dirty way to remove seconds from date
return formatStringDate(value);
},
},
components: {
FeatureTypeLink,
FeatureEditStatusField,
ExtraForm,
FeatureFetchOffsetRoute,
ProjectMemberSelect,
},
props: {
featureType: {
type: Object,
......@@ -170,15 +202,19 @@ export default {
},
computed: {
...mapState('projects', [
'project'
]),
...mapState('feature', [
'currentFeature',
'linked_features',
'form',
'extra_forms',
]),
...mapGetters(['usersGroupsFeatureOptions']),
statusIcon() {
switch (this.currentFeature.status) {
switch (this.currentFeature.properties.status.value || this.currentFeature.properties.status) {
case 'archived':
return 'grey archive';
case 'pending':
......@@ -191,29 +227,64 @@ export default {
return '';
}
},
statusLabel() {
const status = statusChoices.find(
(el) => el.value === this.currentFeature.status
);
return status ? status.name : '';
if (this.currentFeature.properties) {
if (this.currentFeature.properties && this.currentFeature.properties.status.label) {
return this.currentFeature.properties.status.label;
}
const status = statusChoices.find(
(el) => el.value === this.currentFeature.properties.status
);
return status ? status.name : '';
}
return '';
},
},
methods: {
toFeature(link) {
this.$emit('tofeature', {
name: 'details-signalement',
params: {
slug_type_signal: link.feature_to.feature_type_slug,
slug_signal: link.feature_to.feature_id,
},
});
featureData() {
if (this.currentFeature.properties && this.featureType) {
// retrieve value for each feature type custom field within feature data
const extraFieldsWithValue = this.featureType.customfield_set.map((xtraField) => {
return {
...xtraField,
value: this.currentFeature.properties[xtraField.name]
};
});
// filter out fields not meeting condition to be activated
return checkDeactivatedValues(extraFieldsWithValue);
}
return [];
},
featureFields() {
return this.fastEditionMode ? this.extra_forms : this.featureData;
},
getExtraForm(field) {
return this.extra_forms.find(exF => exF.label === field.label);
assignedMemberId() {
if (this.form && this.form.assigned_member) {
return this.form.assigned_member.value;
}
return this.currentFeature.properties.assigned_member;
},
},
methods: {
usersGroupLabel(field) {
const usersGroup = this.usersGroupsFeatureOptions.find((group) => group.value === field.value);
return usersGroup ? usersGroup.name : field.value;
}
}
};
</script>
<style lang="less" scoped>
td {
strong.required:after {
margin: -0.2em 0em 0em 0.2em;
content: '*';
color: #ee2e24;
}
}
</style>
<template>
<div
v-if="field && field.field_type === 'char'"
>
<label
v-if="$route.name !== 'details-signalement'"
:for="field.name"
>
{{ field.label }}
</label>
<input
:id="field.name"
:value="field.value"
type="text"
:name="field.name"
@blur="updateStore_extra_form"
>
</div>
<div
v-else-if="field && field.field_type === 'list'"
>
<label
v-if="$route.name !== 'details-signalement'"
:for="field.name"
>
{{ field.label }}
</label>
<Dropdown
:options="field.options"
:selected="selected_extra_form_list"
:selection.sync="selected_extra_form_list"
/>
</div>
<div
v-else-if="field && field.field_type === 'integer'"
>
<label
v-if="$route.name !== 'details-signalement'"
: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
:id="field.name"
:value="field.value"
type="number"
:name="field.name"
@change="updateStore_extra_form"
>
</div>
</div>
<div
v-else-if="field && field.field_type === 'boolean'"
>
<div class="ui checkbox">
<input
:id="field.name"
type="checkbox"
:checked="field.value"
:name="field.name"
@change="updateStore_extra_form"
>
<label :for="field.name">
{{ $route.name !== 'details-signalement' ? field.label : '' }}
</label>
</div>
</div>
<div
v-else-if="field && field.field_type === 'date'"
>
<label
v-if="$route.name !== 'details-signalement'"
:for="field.name"
>
{{ field.label }}
</label>
<input
:id="field.name"
:value="field.value"
type="date"
:name="field.name"
@blur="updateStore_extra_form"
>
</div>
<div
v-else-if="field && field.field_type === 'decimal'"
>
<label
v-if="$route.name !== 'details-signalement'"
:for="field.name"
>
{{ field.label }}
</label>
<div class="ui input">
<input
:id="field.name"
:value="field.value"
type="number"
step=".01"
:name="field.name"
@change="updateStore_extra_form"
>
</div>
</div>
<div
v-else-if="field && field.field_type === 'text'"
>
<label
v-if="$route.name !== 'details-signalement'"
:for="field.name"
>
{{ field.label }}
</label>
<textarea
:value="field.value"
:name="field.name"
rows="3"
@blur="updateStore_extra_form"
/>
</div>
</template>
<script>
import Dropdown from '@/components/Dropdown.vue';
export default {
name: 'FeatureExtraForm',
components: {
Dropdown,
},
props: {
field: {
type: Object,
default: null,
}
},
computed: {
selected_extra_form_list: {
get() {
return this.field.value || '';
},
set(newValue) {
//* set the value selected in the dropdown
const newExtraForm = this.field;
newExtraForm['value'] = newValue;
this.$store.commit('feature/UPDATE_EXTRA_FORM', newExtraForm);
},
},
},
methods: {
updateStore_extra_form(evt) {
const 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>
<template>
<div>
<div class="ui teal segment">
<h4>
Pièce jointe
<button
class="ui small compact right floated icon button remove-formset"
type="button"
@click="removeAttachmentFormset(form.dataKey)"
>
<i
class="ui times icon"
aria-hidden="true"
/>
</button>
</h4>
<!-- {{ form.errors }} -->
<div class="visible-fields">
<div class="two fields">
<div class="required field">
<label :for="form.title.id_for_label">{{ form.title.label }}</label>
<input
:id="form.title.id_for_label"
v-model="form.title.value"
type="text"
required
:maxlength="form.title.field.max_length"
:name="form.title.html_name"
<section class="ui teal segment">
<h4>
Pièce jointe
<button
class="ui small compact right floated icon button remove-formset"
type="button"
@click="removeAttachmentForm(form.dataKey)"
>
<i
class="ui times icon"
aria-hidden="true"
/>
</button>
</h4>
<div class="visible-fields">
<div class="two fields">
<div class="required field">
<label :for="form.title.id_for_label">{{ form.title.label }}</label>
<input
:id="form.title.id_for_label"
v-model="form.title.value"
type="text"
required
:maxlength="form.title.field.max_length"
:name="form.title.html_name"
>
<ul
:id="form.title.id_for_error"
class="errorlist"
>
<li
v-for="error in form.title.errors"
:key="error"
>
<ul
:id="form.title.id_for_error"
class="errorlist"
>
<li
v-for="error in form.title.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label>Fichier (PDF, PNG, JPEG)</label>
<label
class="ui icon button"
:for="'attachment_file' + attachmentForm.dataKey"
>
<i
class="file icon"
aria-hidden="true"
/>
<span
v-if="form.attachment_file.value"
class="label"
>{{
form.attachment_file.value
}}</span>
<span
v-else
class="label"
>Sélectionner un fichier ... </span>
</label>
<input
:id="'attachment_file' + attachmentForm.dataKey"
type="file"
accept="application/pdf, image/jpeg, image/png"
style="display: none"
:name="form.attachment_file.html_name"
@change="onFileChange"
>
<ul
:id="form.attachment_file.id_for_error"
class="errorlist"
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label>Fichier (PDF, PNG, JPEG)</label>
<label
class="ui icon button"
:for="'attachment_file' + attachmentForm.dataKey"
>
<i
class="file icon"
aria-hidden="true"
/>
<span
v-if="form.attachment_file.value"
class="label"
>{{
form.attachment_file.value
}}</span>
<span
v-else
class="label"
>Sélectionner un fichier ... </span>
</label>
<input
:id="'attachment_file' + attachmentForm.dataKey"
type="file"
accept="application/pdf, image/jpeg, image/png"
style="display: none"
:name="form.attachment_file.html_name"
@change="onFileChange"
>
<ul
:id="form.attachment_file.id_for_error"
class="errorlist"
>
<li
v-for="error in form.attachment_file.errors"
:key="error"
>
<li
v-for="error in form.attachment_file.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
{{ error }}
</li>
</ul>
</div>
<div class="field">
<label for="form.info.id_for_label">{{ form.info.label }}</label>
<textarea
v-model="form.info.value"
name="form.info.html_name"
rows="5"
/>
<!-- {{ form.info.errors }} -->
</div>
<div class="field">
<label for="form.info.id_for_label">{{ form.info.label }}</label>
<textarea
v-model="form.info.value"
name="form.info.html_name"
rows="5"
/>
</div>
<div
v-if="enableKeyDocNotif"
class="field"
>
<div class="ui checkbox">
<input
:id="'is_key_document-' + attachmentForm.dataKey"
v-model="form.is_key_document.value"
:name="'is_key_document-' + attachmentForm.dataKey"
class="hidden"
type="checkbox"
@change="updateStore"
>
<label :for="'is_key_document-' + attachmentForm.dataKey">
Envoyer une notification de publication aux abonnés du projet
</label>
</div>
</div>
</div>
</div>
</section>
</template>
<script>
export default {
name: 'FeatureAttachmentFormset',
name: 'FeatureAttachmentForm',
props: {
attachmentForm: {
type: Object,
default: null,
},
enableKeyDocNotif: {
type: Boolean,
default: false,
}
},
......@@ -138,6 +157,9 @@ export default {
errors: null,
label: 'Info',
},
is_key_document: {
value: false,
},
},
};
},
......@@ -180,7 +202,7 @@ export default {
}
},
removeAttachmentFormset() {
removeAttachmentForm() {
this.$store.commit(
'feature/REMOVE_ATTACHMENT_FORM',
this.attachmentForm.dataKey
......@@ -198,6 +220,7 @@ export default {
attachment_file: this.form.attachment_file.value,
info: this.form.info.value,
fileToImport: this.fileToImport,
is_key_document: this.form.is_key_document.value
};
this.$store.commit('feature/UPDATE_ATTACHMENT_FORM', data);
},
......
......@@ -56,12 +56,19 @@ export default {
},
},
isFeatureCreator() {
if (this.currentFeature && this.currentFeature.properties && this.user) {
return this.currentFeature.properties.creator === this.user.id ||
this.currentFeature.properties.creator.username === this.user.username;
}
return false;
},
allowedStatusChoices() {
if (this.project && this.currentFeature && this.user) {
const isModerate = this.project.moderation;
const userStatus = this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.project.slug];
const isOwnFeature = this.currentFeature.creator === this.user.id; //* si le contributeur est l'auteur du signalement
return allowedStatus2change(this.user, isModerate, userStatus, isOwnFeature, /* this.currentRouteName */);
return allowedStatus2change(this.user, isModerate, userStatus, this.isFeatureCreator);
}
return [];
},
......
<template>
<router-link
:is="query && Number.isInteger(query.offset) ? 'router-link' : 'span'"
:to="{
name: 'details-signalement-filtre',
params: { slug },
query,
}"
>
{{ properties.title || featureId }}
</router-link>
</template>
<script>
import { mapState } from 'vuex';
import axios from '@/axios-client.js';
import projectAPI from '@/services/project-api';
export default {
name: 'FeatureFetchOffsetRoute',
props: {
featureId: {
type: String,
default: '',
},
properties: {
type: Object,
default: () => {},
},
},
data() {
return {
position: null,
slug: this.$route.params.slug || this.properties.project_slug,
ordering: null,
filter: null,
};
},
computed: {
...mapState('projects', [
'project'
]),
query() {
if (this.ordering) {
const searchParams = { ordering: this.ordering };
if (this.filter === 'feature_type_slug') { // when feature_type is the default filter of the project,
searchParams['feature_type_slug'] = this.properties.feature_type.slug; // get its slug for the current feature
}
if (Number.isInteger(this.position)) {
searchParams['offset'] = this.position; // get its slug for the current feature
}
return searchParams;
}
return null;
},
},
watch: {
featureId() {
this.initData();
}
},
created() {
this.initData();
},
methods: {
async initData() {
if (this.project) {
this.setProjectParams(this.project);
} else {
await this.getProjectFilterAndSort();
}
this.getFeaturePosition(this.featureId)
.then((position) => {
if (Number.isInteger(position)) {
this.position = position;
}
})
.catch((error) => {
console.error(error);
});
},
setProjectParams(project) {
this.ordering = project.feature_browsing_default_sort;
if (project.feature_browsing_default_filter === 'feature_type_slug') { // when feature_type is the default filter of the project,
this.filter = this.properties.feature_type.slug;
}
},
async getFeaturePosition(featureId) {
const searchParams = new URLSearchParams(this.query);
const response = await axios.get(`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature/${featureId}/position-in-list/?${searchParams.toString()}`);
if (response && response.status === 200) {
return response.data;
}
return null;
},
async getProjectFilterAndSort() {
const project = await projectAPI.getProject(this.$store.state.configuration.VUE_APP_DJANGO_API_BASE, this.slug);
if (project) this.setProjectParams(project);
}
}
};
</script>
......@@ -135,9 +135,9 @@ export default {
this.$store.commit('feature/REMOVE_LINKED_FORM', this.linkedForm.dataKey);
},
selectFeatureTo(feature) {
if (feature && feature.feature_id) {
this.form.feature_to.value = feature.feature_id;
selectFeatureTo(featureId) {
if (featureId) {
this.form.feature_to.value = featureId;
this.updateStore();
}
},
......
<template>
<div
class="switch-buttons pointer"
:data-tooltip="`Passer en mode ${massMode === 'modify' ? 'suppression':'édition'}`"
@click="switchMode"
>
<div>
<i
:class="['icon pencil', {disabled: massMode !== 'modify'}]"
aria-hidden="true"
/>
</div>
<span class="grey">|&nbsp;</span>
<div>
<i
:class="['icon trash', {disabled: massMode !== 'delete'}]"
aria-hidden="true"
/>
</div>
</div>
</template>
<script>
import { mapMutations, mapState } from 'vuex';
export default {
name: 'FeatureListMassToggle',
computed: {
...mapState('feature', ['massMode'])
},
methods: {
...mapMutations('feature', [
'TOGGLE_MASS_MODE',
'UPDATE_CHECKED_FEATURES',
'UPDATE_CLICKED_FEATURES']),
switchMode() {
this.TOGGLE_MASS_MODE(this.massMode === 'modify' ? 'delete' : 'modify');
this.UPDATE_CLICKED_FEATURES([]);
this.UPDATE_CHECKED_FEATURES([]);
}
},
};
</script>
<style scoped>
.switch-buttons {
display: flex;
justify-content: center;
align-items: baseline;
}
.grey {
color: #bbbbbb;
}
</style>
<template>
<div class="condition-row">
<div>
<span>Si&nbsp;:</span>
</div>
<div class="field required">
<label for="conditioning-field">
Champ conditionnel
</label>
<div>
<Dropdown
:options="customFormOptions"
:selected="conditionField"
:selection.sync="conditionField"
:search="true"
:clearable="true"
name="conditioning-field"
/>
</div>
</div>
<div>
<span>=</span>
</div>
<div class="field required">
<label for="conditioning-value">
Valeur conditionnelle
</label>
<div>
<ExtraForm
v-if="conditioningCustForm"
:id="conditioningCustForm.label"
ref="extraForm"
class="full-width"
name="conditioning-value"
:field="{...conditioningCustForm, value: config.conditionValue}"
:use-value-only="true"
@update:value="updateConditionValue($event)"
/>
</div>
</div>
<div>
<span>
Alors
<span v-if="isForcedValue">&nbsp;:</span>
<span v-else>&nbsp;le champ est activé</span>
</span>
</div>
<div
v-if="isForcedValue"
class="field required"
>
<label for="forced-value">
Valeur forcée
</label>
<div>
<ExtraForm
:id="`forced-value-for-${customForm.name}`"
ref="extraForm"
class="full-width"
name="forced-value"
:field="{...customForm, value: config.forcedValue}"
:use-value-only="true"
@update:value="updateForcedValue($event)"
/>
</div>
</div>
</div>
</template>
<script>
import Dropdown from '@/components/Dropdown.vue';
import ExtraForm from '@/components/ExtraForm';
export default {
name: 'CustomFormConditionalField',
components: {
Dropdown,
ExtraForm,
},
props: {
config: {
type: Object,
default: () => {}
},
form: {
type: Object,
default: () => {}
},
storedCustomForm: {
type: Object,
default: () => {}
},
customForms: {
type: Array,
default: () => []
},
isForcedValue: {
type: Boolean,
default: false
},
},
data() {
return {
customFormOptions: [],
unsubscribe: null,
};
},
computed: {
customForm () {
// if the customForm has been updated in the store return it
if (this.storedCustomForm && this.storedCustomForm.name) return this.storedCustomForm;
// else if the custom for is not yet in store, build the same structure as a stored one to pass it to ExtraForm component
let customFormInCreation = {};
for (const el in this.form) {
customFormInCreation[el] = this.form[el].value;
}
return customFormInCreation;
},
conditioningCustForm() {
return this.customForms.find((custForm) => custForm.name === this.config.conditionField) || null;
},
isListField() {
return ['list', 'pre_recorded_list', 'multi_choices_list'].includes(this.conditioningCustForm.field_type);
},
conditionField: {
get() {
return this.conditioningCustForm ? this.formatOptionLabel(this.conditioningCustForm) : '';
},
set(newValue) {
//* set the value selected in the dropdown
const newConfig = newValue ? { conditionField: newValue.value } : {};
if (this.config.dataKey) { // forced value being a list, a consistent identifier is needed by Vue to track component thus we use a dataKey
newConfig['dataKey'] = this.config.dataKey; // and this dataKey need to be kept across modification
}
if (this.config.forcedValue !== undefined) {
// forced value depend on customForm type and won't change here, since it is set when adding the condition, we neet to keep it
newConfig['forcedValue'] = this.config.forcedValue;
}
this.$emit('update:config', newConfig);
},
},
},
mounted() { // listening to store mutation, since changes of property nested in customForms are not detected
this.updateCustomFormOptions();
this.unsubscribe = this.$store.subscribe(({ type }) => {
if (type === 'feature-type/UPDATE_CUSTOM_FORM') {
this.updateCustomFormOptions();
}
});
},
beforeDestroy() {
this.unsubscribe();
},
methods: {
formatOptionLabel: (custForm) => `${custForm.label} (${custForm.name})`,
updateCustomFormOptions() {
this.customFormOptions = this.customForms
.filter((custForm) => (
custForm.label && custForm.name && // check that customForm has defined properties
custForm.name !== this.customForm.name && // filter out this customForm itself
custForm.field_type !== 'char' && custForm.field_type !== 'text'
))
.map((custForm) => {
return {
name: this.formatOptionLabel(custForm),
value: custForm.name,
};
});
},
updateConditionValue(conditionValue) {
this.$emit('update:config', { ...this.config, conditionValue });
},
updateForcedValue(forcedValue) {
this.$emit('update:config', { ...this.config, forcedValue });
}
}
};
</script>
<style scoped>
.condition-row {
display: flex;
}
.condition-row > div {
margin-right: .75em !important;
}
.condition-row > div.field > div {
display: flex;
align-items: center;
min-height: 2.8em;
}
.condition-row > div:not(.field) {
transform: translateY(2.5em);
}
</style>
\ No newline at end of file
<template>
<div
:id="`custom_form-${form.position.value}`"
class="ui teal segment pers-field"
:id="custFormId"
:class="['ui teal segment pers-field', { hasErrors }]"
>
<h4>
Champ personnalisé
<button
class="ui small compact right floated icon button remove-field"
type="button"
@click="removeCustomForm()"
>
<i
class="ui times icon"
aria-hidden="true"
/>
</button>
</h4>
<div class="custom-field-header">
<h4>
Champ personnalisé
</h4>
<div class="top-right">
<div
v-if="(form.label.value || form.name.value) && selectedFieldType !== 'Booléen'"
class="ui checkbox"
>
<input
v-model="form.is_mandatory.value"
type="checkbox"
:name="form.html_name"
@change="updateStore"
>
<label :for="form.html_name">Champ obligatoire
<span v-if="form.conditional_field_config.value">si actif</span>
</label>
</div>
<button
class="ui small compact right floated icon button remove-field"
type="button"
@click="removeCustomForm()"
>
<i
class="ui times icon"
aria-hidden="true"
/>
</button>
</div>
</div>
<div class="visible-fields">
<div class="two fields">
<div class="required field">
......@@ -27,7 +45,7 @@
required
:maxlength="form.label.field.max_length"
:name="form.label.html_name"
@blur="updateStore"
@input="updateStore"
>
<small>{{ form.label.help_text }}</small>
<ul
......@@ -52,7 +70,7 @@
required
:maxlength="form.name.field.max_length"
:name="form.name.html_name"
@blur="updateStore"
@input="updateStore"
>
<small>{{ form.name.help_text }}</small>
<ul
......@@ -107,7 +125,7 @@
}}</label>
<Dropdown
:disabled="!form.label.value || !form.name.value"
:options="fieldTypeChoices"
:options="customFieldTypeChoices"
:selected="selectedFieldType"
:selection.sync="selectedFieldType"
/>
......@@ -123,29 +141,126 @@
</li>
</ul>
</div>
<div
v-if="selectedFieldType === 'Liste de valeurs'"
class="field field-list-options required field"
v-if="selectedFieldType === 'Liste de valeurs pré-enregistrées'"
class="field required"
data-test="prerecorded-list-option"
>
<label :for="form.options.id_for_label">{{
<label>{{
form.options.label
}}</label>
<Dropdown
:options="preRecordedLists"
:selected="arrayOption"
:selection.sync="arrayOption"
/>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.options.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
</div>
<div
v-if="selectedFieldType === 'Liste de valeurs' || selectedFieldType === 'Liste à choix multiples'"
class="field field-list-options required field"
>
<label :for="form.options.id_for_label">{{
form.options.label
}}</label>
<div>
<div :id="`list-options-${customForm.dataKey}`">
<div
v-for="(option, index) in form.options.value"
:id="option"
:key="`${option}-${index}`"
class="draggable-row"
>
<i
class="th icon grey"
aria-hidden="true"
/>
<input
:value="option"
type="text"
:maxlength="form.options.field.max_length"
:name="form.options.html_name"
class="options-field"
@change="updateOptionValue(index, $event)"
>
<i
class="trash icon grey"
@click="deleteOption(index)"
/>
</div>
</div>
<div class="ui buttons">
<a
class="ui compact small icon left floated button teal basic"
@click="addOption"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<span>&nbsp;Ajouter une option</span>
</a>
</div>
</div>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.options.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div class="conditional-blocks">
<div class="ui checkbox">
<input
:id="form.options.id_for_label"
v-model="arrayOption"
type="text"
:maxlength="form.options.field.max_length"
:name="form.options.html_name"
class="options-field"
type="checkbox"
name="conditional-custom-field"
:checked="form.conditional_field_config.value"
@change="setConditionalCustomForm"
>
<small>{{ form.options.help_text }}</small>
<label
class="pointer"
for="conditional-custom-field"
@click="setConditionalCustomForm"
>
Activation conditionnelle
</label>
</div>
<div
v-if="form.conditional_field_config.value !== null && form.conditional_field_config.value !== undefined"
id="condition-field"
>
<h5>Condition d'apparition&nbsp;:</h5>
<CustomFormConditionalField
:custom-form="customForm"
:custom-forms="customForms"
:config="form.conditional_field_config.value"
@update:config="setConditionalFieldConfig($event)"
/>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.options.errors"
v-for="error in form.conditional_field_config.errors"
:key="error"
>
{{ error }}
......@@ -153,20 +268,70 @@
</ul>
</div>
</div>
<div class="conditional-blocks">
<div
v-for="(config, index) in form.forced_value_config.value"
:id="`forced-value-${index}`"
:key="`forced-value-${config.dataKey}`"
>
<h5>Condition à valeur forcée&nbsp;{{ index + 1 }}&nbsp;:</h5>
<div class="inline">
<CustomFormConditionalField
:form="form"
:stored-custom-form="customForm"
:custom-forms="customForms"
:config="config"
:is-forced-value="true"
@update:config="setForcedValue($event)"
/>
<i
class="trash icon grey"
@click="deleteForcedValue(config.dataKey)"
/>
</div>
</div>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.forced_value_config.errors"
:key="error"
>
{{ error }}
</li>
</ul>
<button
id="add-forced-value"
class="ui compact basic button"
@click.prevent="addForcedValue"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
Ajouter une valeur forcée selon la valeur d'un autre champ
</button>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import { mapState, mapActions } from 'vuex';
import Sortable from 'sortablejs';
import { customFieldTypeChoices, reservedKeywords } from '@/utils';
import Dropdown from '@/components/Dropdown.vue';
import CustomFormConditionalField from '@/components/FeatureType/CustomFormConditionalField.vue';
export default {
name: 'FeatureTypeCustomForm',
components: {
Dropdown,
CustomFormConditionalField,
},
props: {
......@@ -182,17 +347,12 @@ export default {
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' },
],
customFieldTypeChoices,
form: {
dataKey: 0,
is_mandatory: {
value: false,
html_name: 'mandatory-custom-field',
},
label: {
errors: [],
id_for_label: 'label',
......@@ -200,7 +360,7 @@ export default {
help_text: 'Nom en language naturel du champ',
html_name: 'label',
field: {
max_length: 128,
max_length: 256,
},
value: null,
},
......@@ -247,31 +407,54 @@ export default {
html_name: 'options',
help_text: 'Valeurs possibles de ce champ, séparées par des virgules',
field: {
max_length: 256,
max_length: null,
},
value: [],
},
conditional_field_config: {
errors: [],
value: null,
},
forced_value_config: {
errors: [],
value: [],
}
},
hasErrors: false,
selectedPrerecordedList: null,
sortable: null
};
},
computed: {
...mapState('feature-type', [
'customForms'
'customForms',
'preRecordedLists'
]),
custFormId() {
return `custom_form-${this.form.position.value}`;
},
selectedFieldType: {
// getter
get() {
const currentFieldType = this.fieldTypeChoices.find(
const currentFieldType = customFieldTypeChoices.find(
(el) => el.value === this.form.field_type.value
);
if (currentFieldType) {
this.$nextTick(() => { // to be executed after the fieldType is returned and the html generated
if (this.selectedFieldType === 'Liste de valeurs' || this.selectedFieldType === 'Liste à choix multiples') {
this.initSortable();
}
});
return currentFieldType.name;
}
return null;
},
// setter
set(newValue) {
if (newValue.value === 'pre_recorded_list') {
this.GET_PRERECORDED_LISTS();
}
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 ?
......@@ -290,14 +473,27 @@ export default {
}
},
},
forcedValMaxDataKey() { // get the highest datakey value in forced value configs...
return this.form.forced_value_config.value.reduce( // ...used to increment new config datakey
(maxDataKey, currentConfig) => (maxDataKey > currentConfig.dataKey) ?
maxDataKey : currentConfig.dataKey, 1
);
},
},
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);
if (this.customForm.field_type === 'pre_recorded_list') {
this.GET_PRERECORDED_LISTS();
}
},
methods: {
...mapActions('feature-type', [
'GET_PRERECORDED_LISTS'
]),
hasDuplicateOptions() {
this.form.options.errors = [];
const isDup =
......@@ -312,7 +508,7 @@ export default {
fillCustomFormData(customFormData) {
for (const el in customFormData) {
if (el && this.form[el] && customFormData[el]) {
if (el && this.form[el] && customFormData[el] !== undefined && customFormData[el] !== null) {
//* 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
......@@ -328,20 +524,99 @@ export default {
this.customForm.dataKey
);
},
initSortable() {
this.sortable = new Sortable(document.getElementById(`list-options-${this.customForm.dataKey}`), {
animation: 150,
handle: '.draggable-row', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: this.updateOptionOrder,
filter: 'input', // prevent input field not clickable...
preventOnFilter: false, // ... on element inside sortable
});
},
setConditionalFieldConfig(config) {
this.form.conditional_field_config.value = config;
this.updateStore();
},
setConditionalCustomForm() {
if (this.form.conditional_field_config.value === null) {
// retrieve existing value when the user uncheck and check again, if no value defined create an empty object
this.form.conditional_field_config.value = this.customForm.conditional_field_config || {};
} else {
this.form.conditional_field_config.value = null;
}
this.updateStore();
},
addForcedValue() {
const newForcedValueConfig = {
dataKey: this.forcedValMaxDataKey + 1,
// forced value can already be set here, setting false for boolean because user won't select if he wants to set on false, thus avoiding the null value not passing form check
forcedValue: this.form.field_type.value === 'boolean' ? false : null
};
this.form.forced_value_config.value = [...this.form.forced_value_config.value, newForcedValueConfig];
},
setForcedValue(newConfig) {
this.form.forced_value_config.value = this.form.forced_value_config.value.map((config) => {
return config.dataKey === newConfig.dataKey ? newConfig : config;
});
this.updateStore();
},
deleteForcedValue(dataKey) {
this.form.forced_value_config.value = this.form.forced_value_config.value.filter(
(config) => config.dataKey !== dataKey
);
this.updateStore();
},
updateStore() {
const data = {
dataKey: this.customForm.dataKey,
is_mandatory: this.form.is_mandatory.value,
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,
conditional_field_config: this.form.conditional_field_config.value,
forced_value_config: this.form.forced_value_config.value,
};
this.$store.commit('feature-type/UPDATE_CUSTOM_FORM', data);
if (this.customForm.name === this.selectedColorStyle ) {
this.$emit('update', this.form.options.value);
}
// if there was any error, check if the update fixed it and update errors displayed
if (this.hasErrors) {
this.checkCustomForm(true);
}
},
updateOptionValue(index, e) {
this.form.options.value[index] = e.target.value;
if (!this.hasDuplicateOptions()) {
this.updateStore();
}
},
updateOptionOrder(e) {
const currentOptionsList = Array.from(e.target.childNodes).map((el) => el.id);
this.form.options.value = currentOptionsList;
this.updateStore();
},
addOption() {
this.form.options.value.push('');
},
deleteOption(index) {
this.form.options.value.splice(index, 1);
},
trimWhiteSpace(string) {
......@@ -349,6 +624,7 @@ export default {
return string.replace(/\s*,\s*/gi, ',');
},
//* CHECKS *//
hasRegularCharacters(input) {
for (const char of input) {
if (!/[a-zA-Z0-9-_]/.test(char)) {
......@@ -358,61 +634,167 @@ export default {
return true;
},
/**
* Ensures the name entered in the form is unique among all custom field names.
* This function prevents duplicate names, including those with only case differences,
* to avoid conflicts during automatic view generation where names are slugified.
*
* @returns {boolean} - Returns true if the name is unique (case insensitive), false otherwise.
*/
checkUniqueName() {
const occurences = this.customForms
.map((el) => el.name)
.filter((el) => el === this.form.name.value);
.map((el) => el.name.toLowerCase())
.filter((el) => el === this.form.name.value.toLowerCase());
return occurences.length === 1;
},
checkFilledOptions() {
checkOptions() {
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 this.form.options.value.length >= 2 && !this.form.options.value.includes('') ?
'' : 'Veuillez renseigner au moins 2 options.';
}
if (this.form.field_type.value === 'pre_recorded_list') {
return this.form.options.value.length === 1 ?
'' : 'Veuillez sélectionner une option.';
}
return '';
},
isConditionFilled(condition) {
if (condition) {
return condition.conditionField && condition.conditionValue !== null && condition.conditionValue !== undefined;
}
return true;
},
isForcedValueFilled() {
if (this.form.forced_value_config.value) {
for (const config of this.form.forced_value_config.value) {
if (!this.isConditionFilled(config) || config.forcedValue === null || config.forcedValue === undefined) {
return false;
}
}
}
return true;
},
checkCustomForm() {
this.form.label.errors = [];
this.form.name.errors = [];
this.form.options.errors = [];
checkCustomForm(noScroll) {
// reset errors to empty array
for (const element in this.form) {
if (this.form[element].errors) this.form[element].errors = [];
}
// check each form element
const optionError = this.checkOptions();
let isValid = true;
if (!this.form.label.value) {
//* vérifier que le label est renseigné
this.form.label.errors = ['Veuillez compléter ce champ.'];
return false;
isValid = 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;
isValid = 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;
isValid = false;
} else if (reservedKeywords.includes(this.form.name.value)) {
//* vérifier que le nom du champs ne soit pas un nom réservé pour les propriétés standards d'un signalement
this.form.name.errors = [
'Ce nom est réservé, il ne peut pas être utilisé',
];
isValid = 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()) {
isValid = false;
} else if (optionError) {
//* 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;
this.form.options.errors = [optionError];
isValid = false;
} else if (this.hasDuplicateOptions()) {
//* pour le cas d'options dupliqués
return false;
isValid = false;
} else if (!this.isConditionFilled(this.form.conditional_field_config.value)) {
//* vérifier si les deux formulaires de l'activation conditionnelle sont bien renseignés
isValid = false;
this.form.conditional_field_config.errors = ['Veuillez renseigner tous les champs de la condition ou la désactiver'];
} else if (!this.isForcedValueFilled()) {
//* vérifier si les trois formulaires de chaque valeur forcée sont bien renseignés
isValid = false;
this.form.forced_value_config.errors = ['Veuillez renseigner tous les champs de chaque condition à valeur forcée ou supprimer celle(s) incomplète(s)'];
}
return true;
this.hasErrors = !isValid;
if (!isValid && !noScroll) { // if errors were found: scroll to display this customForm
document.getElementById(this.custFormId).scrollIntoView();
}
return isValid;
},
},
};
</script>
<style lang="less" scoped>
.hasErrors {
border-color: red;
}
.errorlist {
margin: .5rem 0;
display: flex;
}
.custom-field-header {
display: flex;
align-items: center;
justify-content: space-between;
.top-right {
display: flex;
align-items: center;
.checkbox {
margin-right: 5rem;
}
span {
color: #666666;
}
}
}
i.icon.trash {
transition: color ease .3s;
}
i.icon.trash:hover {
cursor: pointer;
color: red !important;
}
.conditional-blocks {
margin: .5em 0;
h5 {
margin: 1em 0;
}
.inline {
display: flex;
align-items: center;
justify-content: space-between;
}
}
#condition-field {
padding-left: 2em;
}
.draggable-row {
display: flex;
align-items: baseline;
margin-bottom: 1em;
input {
margin: 0 .5em !important;
}
}
.segment { // keep custom form scrolled under the app header
scroll-margin-top: 5rem;
}
</style>
......@@ -6,6 +6,7 @@
params: { feature_type_slug: featureType.slug },
}"
class="feature-type-title"
:title="featureType.title"
>
<img
v-if="featureType.geom_type === 'point'"
......@@ -25,7 +26,34 @@
src="@/assets/img/polygon.png"
alt="Géométrie polygone"
>
{{ featureType.title }}
<img
v-if="featureType.geom_type === 'multipoint'"
class="list-image-type"
src="@/assets/img/multimarker.png"
alt="Géométrie multipoint"
>
<img
v-if="featureType.geom_type === 'multilinestring'"
class="list-image-type"
src="@/assets/img/multiline.png"
alt="Géométrie multiligne"
>
<img
v-if="featureType.geom_type === 'multipolygon'"
class="list-image-type"
src="@/assets/img/multipolygon.png"
alt="Géométrie multipolygone"
>
<span
v-if="featureType.geom_type === 'none'"
class="list-image-type"
title="Aucune géométrie"
>
<i class="ui icon large outline file" />
</span>
<span class="ellipsis">
{{ featureType.title }}
</span>
</router-link>
</template>
......@@ -47,14 +75,19 @@ export default {
<style scoped>
.feature-type-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: flex;
align-items: center;
line-height: 1.5em;
width: fit-content;
}
.list-image-type {
margin-right: 5px;
height: 25px;
vertical-align: bottom;
display: flex;
align-items: center;
}
.list-image-type > i {
color: #000000;
height: 25px;
}
</style>
<template>
<div>
<div class="three fields">
<h4 :class="['field', {'row-title' : isDefault}]">
<h5 class="field">
{{ title }}
</h4>
</h5>
<div class="required inline field">
<label :for="form.color.id_for_label">{{ form.color.label }}</label>
<input
......@@ -15,7 +15,10 @@
>
</div>
<div v-if="geomType === 'polygon'">
<div
v-if="geomType === 'polygon' || geomType === 'multipolygon'"
class="field"
>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
......@@ -75,7 +78,7 @@ export default {
props: {
title: {
type: String,
default: 'Symbologie par défault :'
default: 'Couleur par défault :'
},
initColor: {
type: String,
......@@ -115,12 +118,6 @@ export default {
};
},
computed: {
isDefault() {
return this.title === 'Symbologie par défault :';
}
},
watch: {
form: {
deep: true,
......@@ -167,21 +164,16 @@ export default {
.fields {
align-items: center;
justify-content: space-between;
margin-top: 3em !important;
}
.row-title {
display: inline;
font-size: 1.4em;
font-weight: normal;
width: 33%;
text-align: left;
margin-left: 0.5em;
#customFieldSymbology .fields {
margin-left: 1em !important;
margin-bottom: 1em !important;
}
.default {
margin-bottom: 2rem;
h5 {
font-weight: initial;
font-style: italic;
}
#couleur {
......
<template>
<div>
<geor-header
:legacy-header="legacyHeader"
:legacy-url="legacyUrl"
:style="customStyle"
:logo-url="logo"
:stylesheet="customStylesheet"
:active-app="activeApp"
/>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'GeorchestraHeader',
computed: {
...mapState([
'configuration'
]),
headerConfig() {
return this.configuration.GEORCHESTRA_INTEGRATION?.HEADER;
},
activeApp() {
return this.$route.path.includes('my_account') ? 'geocontrib-account' : 'geocontrib';
},
logo() {
return this.configuration.VUE_APP_LOGO_PATH;
},
legacyHeader() {
return this.headerConfig.LEGACY_HEADER;
},
legacyUrl() {
return this.headerConfig.LEGACY_URL;
},
customStyle() {
return this.headerConfig.STYLE;
},
customStylesheet() {
return this.headerConfig.STYLESHEET;
},
headerScript() {
return this.headerConfig.HEADER_SCRIPT;
},
},
mounted() {
const headerScript = document.createElement('script');
headerScript.setAttribute('src', this.headerScript ? this.headerScript : 'https://cdn.jsdelivr.net/gh/georchestra/header@dist/header.js');
document.head.appendChild(headerScript);
},
};
</script>
<template>
<div id="table-imports">
<table
class="ui collapsing celled table"
aria-describedby="Tableau des import en cours ou terminés"
<div class="imports-header">
<div class="file-column">
Fichiers importés
</div>
<div class="status-column">
Statuts
</div>
</div>
<div
v-for="importFile in imports"
:key="importFile.created_on"
class="filerow"
>
<thead>
<tr>
<th
scope="col"
<div class="file-column">
<h4 class="ui header align-right">
<div
:data-tooltip="importFile.geojson_file_name"
class="ellipsis"
>
Fichiers importés
</th>
<th
scope="col"
>
Status
</th>
</tr>
</thead>
<tbody>
<tr
v-for="importFile in data"
:key="importFile.created_on"
{{ importFile.geojson_file_name }}
</div>
</h4>
<div class="sub header">
ajouté le {{ importFile.created_on | formatDate }}
</div>
</div>
<div class="status-column">
<span
v-if="importFile.infos"
:data-tooltip="importFile.infos"
class="ui icon margin-left"
>
<td>
<h4 class="ui header align-right">
<div :data-tooltip="importFile.geojson_file_name">
{{ importFile.geojson_file_name | subString }}
<div class="sub header">
ajouté le {{ importFile.created_on | setDate }}
</div>
</div>
</h4>
</td>
<td>
<span
v-if="importFile.infos"
:data-tooltip="importFile.infos"
class="ui icon margin-left"
>
<i
v-if="importFile.status === 'processing'"
class="orange hourglass half icon"
aria-hidden="true"
/>
<i
v-else-if="importFile.status === 'finished'"
class="green check circle outline icon"
aria-hidden="true"
/>
<i
v-else-if="importFile.status === 'failed'"
class="red x icon"
aria-hidden="true"
/>
<i
v-else
class="red ban icon"
aria-hidden="true"
/>
</span>
<span
v-if="importFile.status === 'pending'"
data-tooltip="Statut en attente. Clickez pour rafraichir."
>
<i
:class="['orange icon', ready && !reloading ? 'sync' : 'hourglass half rotate']"
aria-hidden="true"
@click="fetchImports()"
/>
</span>
</td>
</tr>
</tbody>
</table>
<i
v-if="importFile.status === 'processing'"
class="orange hourglass half icon"
aria-hidden="true"
/>
<i
v-else-if="importFile.status === 'finished'"
class="green check circle outline icon"
aria-hidden="true"
/>
<i
v-else-if="importFile.status === 'failed'"
class="red x icon"
aria-hidden="true"
/>
<i
v-else
class="red ban icon"
aria-hidden="true"
/>
</span>
<span
v-if="importFile.status === 'pending'"
data-tooltip="Statut en attente. Cliquez pour rafraichir."
>
<i
:class="['orange icon', !reloading ? 'sync' : 'hourglass half rotate']"
aria-hidden="true"
@click="fetchImports()"
/>
</span>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
filters: {
setDate: function (value) {
formatDate: function (value) {
const date = new Date(value);
return date.toLocaleDateString('fr', {
year: 'numeric',
......@@ -98,141 +90,102 @@ export default {
},
props: {
data: {
imports: {
type: Array,
default: null,
},
reloading: {
type: Boolean,
default: false,
}
},
data() {
return {
open: false,
ready: true,
reloading: false,
fetchCallCounter: 0,
};
},
computed: {
...mapState('feature', ['features']),
},
watch: {
data(newValue) {
if (newValue) {
this.ready = true;
}
},
mounted() {
this.fetchImports();
},
methods: {
fetchImports() {
this.$store.dispatch(
'feature-type/GET_IMPORTS', {
project_slug: this.$route.params.slug,
feature_type: this.$route.params.feature_type_slug
});
this.$store.dispatch('feature/GET_PROJECT_FEATURES', {
this.fetchCallCounter += 1; // register each time function is programmed to be called in order to avoid redundant calls
this.reloading = true;
// fetch imports
this.$store.dispatch('feature-type/GET_IMPORTS', {
project_slug: this.$route.params.slug,
feature_type__slug: this.$route.params.feature_type_slug
});
//* show that the action was triggered, could be improved with animation (doesn't work)
this.ready = false;
feature_type: this.$route.params.feature_type_slug
})
.then((response) => {
if (response.data && response.data.some(el => el.status === 'pending')) {
// if there is still some pending imports re-fetch imports by calling this function again
setTimeout(() => {
if (this.fetchCallCounter <= 1 ) {
// if the function wasn't called more than once in the reload interval, then call it again
this.fetchImports();
}
this.fetchCallCounter -= 1; // decrease function counter
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL);
// give a bit time for loader to be seen by user if response was fast
setTimeout(() => {
this.reloading = false;
}, 1500);
} else {
// if no pending import, get last features
this.$emit('reloadFeatureType');
}
});
},
},
};
</script>
<style scoped lang="less">
.sync {
cursor: pointer;
}
#table-imports {
padding-top: 1em;
table {
width: 100%;
border: 1px solid lightgrey;
margin-top: 1rem;
.imports-header {
border-bottom: 1px solid lightgrey;
font-weight: bold;
}
> div {
padding: .5em 1em;
}
.filerow {
background-color: #fff;
}
.imports-header, .filerow {
display: flex;
.file-column {
width: 80%;
h4 {
margin-bottom: .2em;
}
}
.status-column {
width: 20%;
text-align: right;
}
}
}
.sync {
cursor: pointer;
}
i.icon {
width: 20px !important;
height: 20px !important;
}
.rotate {
-webkit-animation:spin 1s linear infinite;
-moz-animation:spin 1s linear infinite;
animation:spin 1s linear infinite;
}
@-moz-keyframes spin { 100% { -moz-transform: rotate(360deg); } }
@-webkit-keyframes spin { 100% { -webkit-transform: rotate(360deg); } }
@keyframes spin { 100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); } }
/*
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: 25%;
padding-right: 10px;
white-space: "break-spaces";
}
/*
Label the data
*/
td:nth-of-type(1):before {
content: "Fichiers importés";
}
td:nth-of-type(2):before {
content: "Statut";
}
.align-right {
text-align: right;
}
.margin-left {
margin-left: 94%;
}
-webkit-animation:spin 1.5s cubic-bezier(.3,.25,.15,1) infinite;
-moz-animation:spin 1.5s cubic-bezier(.3,.25,.15,1) infinite;
animation:spin 1.5s cubic-bezier(.3,.25,.15,1) infinite;
}
@-moz-keyframes spin { 100% { -moz-transform: rotate(180deg); } }
@-webkit-keyframes spin { 100% { -webkit-transform: rotate(180deg); } }
@keyframes spin { 100% { -webkit-transform: rotate(180deg); transform:rotate(180deg); } }
</style>
<template>
<div class="editionToolbar">
<div v-if="showDrawTool">
<div class="leaflet-bar">
<a
class="leaflet-draw-draw-polygon active"
:title="
editionService.geom_type === 'polygon' ? 'Dessiner un polygone' :
editionService.geom_type === 'linestring' ? 'Dessiner une ligne' :
'Dessiner un point'
"
<div class="leaflet-bar">
<a
v-if="noExistingFeature"
class="leaflet-draw-draw-polygon active"
:title="`Dessiner ${editionService.geom_type === 'linestring' ? 'une' : 'un'} ${toolbarGeomTitle}`
"
>
<img
v-if="editionService.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
alt="line"
>
<img
v-if="editionService.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
>
<img
v-if="editionService.geom_type === 'point'"
class="list-image-type"
src="@/assets/img/marker.png"
>
<img
v-if="editionService.geom_type === 'polygon'"
class="list-image-type"
src="@/assets/img/polygon.png"
>
</a>
</div>
</div>
<div v-if="!showDrawTool">
<div class="leaflet-bar">
<a
:class="{ active: isEditing }"
@click="update"
<img
v-if="editionService.geom_type === 'point'"
class="list-image-type"
src="@/assets/img/marker.png"
alt="marker"
>
<i class="edit outline icon" />
<span class="sr-only">Modifier l'objet</span>
</a>
<a
:class="{ active: isDeleting }"
@click="deleteObj"
<img
v-if="editionService.geom_type === 'polygon'"
class="list-image-type"
src="@/assets/img/polygon.png"
alt="polygon"
>
<i class="trash alternate outline icon" />
<span class="sr-only">Supprimer l'objet</span>
</a>
</div>
</a>
<a
v-else
:class="{ active: isEditing }"
:title="`Modifier ${toolbarEditGeomTitle}`"
@click="toggleEdition"
>
<i class="edit outline icon" />
<span class="sr-only">Modifier {{ toolbarEditGeomTitle }}</span>
</a>
<a
v-if="noExistingFeature || isEditing"
:class="{ active: isSnapEnabled }"
:title="`${ isSnapEnabled ? 'Désactiver' : 'Activer' } l'accrochage aux points`"
@click="toggleSnap"
>
<i
class="magnet icon"
aria-hidden="true"
/>
<span class="sr-only">{{ isSnapEnabled ? 'Désactiver' : 'Activer' }} l'accrochage aux points</span>
</a>
<a
v-if="!noExistingFeature"
:title="`Supprimer ${toolbarEditGeomTitle}`"
@click="deleteObj"
>
<i class="trash alternate outline icon" />
<span class="sr-only">Supprimer {{ toolbarEditGeomTitle }}</span>
</a>
</div>
</div>
</template>
......@@ -56,40 +69,59 @@ export default {
name: 'EditingToolbar',
props: {
map: {
type: Object,
default: null,
},
},
data() {
return {
editionService: editionService,
isEditing: false,
isDeleting: false
isSnapEnabled: false,
};
},
computed: {
showDrawTool() {
return this.editionService && this.editionService.editing_feature === undefined;
noExistingFeature() {
return this.editionService?.editing_feature === undefined;
},
},
watch: {
showDrawTool(newValue) {
if (!newValue && !this.isEditing && !this.isDeleting) {
this.isEditing = true;
toolbarGeomTitle() {
switch (this.editionService.geom_type) {
case 'polygon':
return 'polygone';
case 'linestring':
return 'ligne';
}
}
return 'point';
},
toolbarEditGeomTitle() {
return `${this.editionService.geom_type === 'linestring' ? 'la' : 'le'} ${this.toolbarGeomTitle}`;
}
},
methods: {
update() {
editionService.activeUpdateFeature();
this.isDeleting = false;
this.isEditing = true;
toggleEdition() {
this.isEditing = !this.isEditing;
editionService.activeUpdateFeature(this.isEditing);
},
deleteObj() {
editionService.activeDeleteFeature();
this.isEditing = false;
this.isDeleting = true;
const hasBeenRemoved = editionService.removeFeatureFromMap();
// Vérifie que l'utilisateur a bien confirmé la suppression avant de réinitialiser le bouton d'édition
if (this.isEditing && hasBeenRemoved) {
this.isEditing = false;
}
},
toggleSnap() {
if (this.isSnapEnabled) {
editionService.removeSnapInteraction(this.map);
} else {
editionService.addSnapInteraction(this.map);
}
this.isSnapEnabled = !this.isSnapEnabled;
}
}
};
</script>
......@@ -98,12 +130,15 @@ export default {
.editionToolbar{
position: absolute;
top: 80px;
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(2em + 60px + 34px + 64px);
right: 6px;
border: 2px solid rgba(0,0,0,.2);
border-radius: 4px;
background-clip: padding-box;
padding: 0;
z-index: 9;
}
.leaflet-bar {
......@@ -135,6 +170,15 @@ export default {
i {
margin: 0;
vertical-align: middle;
&.magnet {
transform: rotate(90deg);
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
opacity: .85;
}
}
}
......
<template>
<div
:class="{ expanded: isExpanded }"
class="geocoder-container"
>
<button
class="button-geocoder"
@click="isExpanded = !isExpanded"
<div>
<div
id="geocoder-container"
:class="{ isExpanded }"
>
<i class="search icon" />
</button>
<Multiselect
v-if="isExpanded"
v-model="selection"
class="expanded-geocoder"
:options="addresses"
:options-limit="5"
:allow-empty="true"
track-by="label"
label="label"
:show-labels="false"
:reset-after="true"
select-label=""
selected-label=""
deselect-label=""
:searchable="true"
:placeholder="placeholder"
:show-no-results="true"
:loading="loading"
:clear-on-select="false"
:preserve-search="true"
@search-change="search"
@select="select"
@close="close"
<button
class="button-geocoder"
title="Rechercher une adresse"
type="button"
@click="toggleGeocoder"
>
<i class="search icon" />
</button>
</div>
<div
id="geocoder-select-container"
:class="{ isExpanded }"
>
<template
slot="option"
slot-scope="props"
<!-- internal-search should be disabled to avoid filtering options, which is done by the api calls anyway https://stackoverflow.com/questions/57813170/vue-multi-select-not-showing-all-the-options -->
<!-- otherwise approximate results are not shown (cannot explain why though) -->
<Multiselect
v-if="isExpanded"
ref="multiselect"
v-model="selection"
class="expanded-geocoder"
:options="addresses"
:options-limit="limit"
:allow-empty="true"
:internal-search="false"
track-by="id"
label="label"
:show-labels="false"
:reset-after="true"
select-label=""
selected-label=""
deselect-label=""
:searchable="true"
:placeholder="placeholder"
:show-no-results="true"
:loading="loading"
:clear-on-select="false"
:preserve-search="true"
@search-change="search"
@select="select"
@open="retrievePreviousPlaces"
@close="close"
>
<div class="option__desc">
<span class="option__title">{{ props.option.label }}</span>
</div>
</template>
<template slot="clear">
<div
v-if="selection"
class="multiselect__clear"
@click.prevent.stop="selection = null"
<template
slot="option"
slot-scope="props"
>
<i class="close icon" />
</div>
</template>
<span slot="noResult">
Aucun résultat.
</span>
<span slot="noOptions">
Saisissez les premiers caractères ...
</span>
</Multiselect>
<div style="display: none;">
<div
id="marker"
title="Marker"
/>
<div class="option__desc">
<span class="option__title">{{ props.option.label }}</span>
</div>
</template>
<template slot="clear">
<div
v-if="selection"
class="multiselect__clear"
@click.prevent.stop="selection = null"
>
<i class="close icon" />
</div>
</template>
<span slot="noResult">
Aucun résultat.
</span>
<span slot="noOptions">
Saisissez les premiers caractères ...
</span>
</Multiselect>
<div style="display: none;">
<div
id="marker"
title="Marker"
/>
</div>
</div>
</div>
</template>
......@@ -76,14 +90,18 @@ const apiAdressAxios = axios.create({
baseURL: 'https://api-adresse.data.gouv.fr',
withCredentials: false,
});
export default {
name: 'Geocoder',
components: {
Multiselect
},
data() {
return {
loading: false,
limit: 10,
selection: null,
text: null,
selectedAddress: null,
......@@ -93,19 +111,36 @@ export default {
isExpanded: false
};
},
mounted() {
this.addressTextChange = new Subject();
this.addressTextChange.pipe(debounceTime(200)).subscribe((res) => this.getAddresses(res));
},
methods: {
toggleGeocoder() {
this.isExpanded = !this.isExpanded;
if (this.isExpanded) {
this.retrievePreviousPlaces();
this.$nextTick(()=> this.$refs.multiselect.activate());
}
},
getAddresses(query){
const limit = 5;
apiAdressAxios.get(`https://api-adresse.data.gouv.fr/search/?q=${query}&limit=${limit}`)
if (query.length < 3) {
this.addresses = [];
return;
}
const coords = mapService.getMapCenter();
let url = `https://api-adresse.data.gouv.fr/search/?q=${query}&limit=${this.limit}`;
if (coords) url += `&lon=${coords[0]}&lat=${coords[1]}`;
apiAdressAxios.get(url)
.then((retour) => {
this.resultats = retour.data.features;
this.addresses = retour.data.features.map(x=>x.properties);
});
},
selectAddresse(event) {
this.selectedAddress = event;
if (this.selectedAddress !== null && this.selectedAddress.geometry) {
......@@ -118,38 +153,80 @@ export default {
} else if (type === 'locality') {
zoomlevel = 16;
}
// On fait le zoom
mapService.zoomTo(this.selectedAddress.geometry.coordinates, zoomlevel);
// On ajoute un point pour localiser la ville
mapService.addOverlay(this.selectedAddress.geometry.coordinates);
mapService.addOverlay(this.selectedAddress.geometry.coordinates, zoomlevel);
// On enregistre l'adresse sélectionné pour le proposer à la prochaine recherche
this.setLocalstorageSelectedAdress(this.selectedAddress);
this.toggleGeocoder();
}
},
search(text) {
this.text = text;
this.addressTextChange.next(this.text);
},
select(e) {
this.selectAddresse(this.resultats.find(x=>x.properties.label === e.label));
this.$emit('select', e);
},
close() {
this.$emit('close', this.selection);
}
},
setLocalstorageSelectedAdress(newAdress) {
let selectedAdresses = JSON.parse(localStorage.getItem('geocontrib-selected-adresses'));
selectedAdresses = Array.isArray(selectedAdresses) ? selectedAdresses : [];
selectedAdresses = [ newAdress, ...selectedAdresses ];
const uniqueLabels = [...new Set(selectedAdresses.map(el => el.properties.label))];
const uniqueAdresses = uniqueLabels.map((label) => {
return selectedAdresses.find(adress => adress.properties.label === label);
});
localStorage.setItem(
'geocontrib-selected-adresses',
JSON.stringify(uniqueAdresses.slice(0, 5))
);
},
getLocalstorageSelectedAdress() {
return JSON.parse(localStorage.getItem('geocontrib-selected-adresses')) || [];
},
retrievePreviousPlaces() {
const previousAdresses = this.getLocalstorageSelectedAdress();
if (previousAdresses.length > 0) {
this.addresses = previousAdresses.map(x=>x.properties);
this.resultats = previousAdresses;
}
},
}
};
</script>
<style scoped lang="less">
.geocoder-container {
<style lang="less">
#marker {
width: 14px;
height: 14px;
border: 2px solid #fff;
border-radius: 7px;
background-color: #3399CC;
}
#geocoder-container {
position: absolute;
right: 0.5em;
top: calc(1em + 60px);
right: 6px;
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(1.6em + 60px + 34px + 34px);
pointer-events: auto;
z-index: 1000;
z-index: 999;
border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box;
padding: 0;
border-radius: 2px;
border-radius: 4px;
display: flex;
.button-geocoder {
......@@ -159,8 +236,8 @@ export default {
text-align: center;
background-color: #fff;
color: rgb(39, 39, 39);
width: 28px;
height: 28px;
width: 30px;
height: 30px;
font: 700 18px Lucida Console,Monaco,monospace;
border-radius: 2px;
line-height: 1.15;
......@@ -178,21 +255,64 @@ export default {
.expanded-geocoder {
max-width: 400px;
}
}
.expanded {
.button-geocoder {
height: 40px;
color: rgb(99, 99, 99);
&&.isExpanded {
.button-geocoder {
/*height: 41px;*/
color: rgb(99, 99, 99);
border-radius: 2px 0 0 2px;
}
}
}
#marker {
width: 20px;
height: 20px;
border: 1px solid rgb(136, 66, 0);
border-radius: 10px;
background-color: rgb(201, 114, 15);
opacity: 0.7;
#geocoder-select-container{
position: absolute;
right: 46px;
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(1.6em + 60px + 34px + 34px - 4px);
pointer-events: auto;
z-index: 999;
border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box;
padding: 0;
border-radius: 4px;
display: flex;
// /* keep placeholder width when opening dropdown */
.multiselect {
min-width: 208px;
}
/* keep font-weight from overide of semantic classes */
.multiselect__placeholder, .multiselect__content, .multiselect__tags {
font-weight: initial !important;
}
/* keep placeholder eigth */
.multiselect .multiselect__placeholder {
margin-bottom: 9px !important;
padding-top: 1px;
}
/* keep placeholder height when opening dropdown without selection */
input.multiselect__input {
padding: 3px 0 0 0 !important;
}
/* keep placeholder height when opening dropdown with already a value selected */
.multiselect__tags .multiselect__single {
padding: 1px 0 0 0 !important;
margin-bottom: 9px;
}
.multiselect__tags {
border: 0 !important;
min-height: 41px !important;
padding: 10px 40px 0 8px;
}
.multiselect input {
line-height: 1em !important;
padding: 0 !important;
}
.multiselect__content-wrapper {
border: 2px solid rgba(0,0,0,.2);
}
}
</style>
\ No newline at end of file
<template>
<div class="geolocation-container">
<button
:class="['button-geolocation', { tracking }]"
title="Me localiser"
@click.prevent="toggleTracking"
>
<i class="icon" />
</button>
</div>
</template>
<script>
import mapService from '@/services/map-service';
export default {
name: 'Geolocation',
data() {
return {
tracking: false,
};
},
methods: {
toggleTracking() {
this.tracking = !this.tracking;
mapService.toggleGeolocation(this.tracking);
},
}
};
</script>
\ No newline at end of file
......@@ -19,7 +19,7 @@
:options="queryableLayersOptions"
:selected="selectedQueryLayer"
:search="true"
@update:selection="onQueryLayerChange($event)"
@update:selection="setQueryLayer($event)"
/>
</div>
<div
......@@ -28,8 +28,8 @@
:data-basemap-index="basemap.id"
>
<div
v-for="(layer, index) in basemap.layers"
:key="basemap.id + '-' + layer.id + '-' + index"
v-for="layer in basemap.layers"
:key="basemap.id + '-' + layer.id + Math.random()"
class="layer-item transition visible item list-group-item"
:data-id="layer.id"
>
......@@ -79,20 +79,18 @@ export default {
type: Object,
default: null,
},
baseMaps: {
type: Array,
default: () => [],
},
active: {
type: Boolean,
default: false,
},
selectedQueryLayer: {
type: String,
default: ''
}
},
data() {
return {
baseMap: {},
selectedQueryLayer: null,
sortable: null,
};
},
......@@ -106,13 +104,10 @@ export default {
value: x,
};
});
}
},
},
mounted() {
if (this.queryableLayersOptions.length > 0) {
this.selectedQueryLayer = this.queryableLayersOptions[0].name;
}
setTimeout(this.initSortable.bind(this), 1000);
},
......@@ -137,15 +132,8 @@ export default {
}
},
onQueryLayerChange(layer) {
this.selectedQueryLayer = layer.name;
const baseMap = this.baseMaps[0].layers.find((l) => l.title === layer.name);
if (baseMap) {
baseMap.query = true;
} else {
console.error('No such param \'query\' found among basemap[0].layers');
}
layer.query = true;
setQueryLayer(layer) {
this.$emit('onQueryLayerChange', layer.name);
},
getOpacity(opacity) {
......@@ -153,8 +141,10 @@ export default {
},
updateOpacity(event, layer) {
mapService.updateOpacity(layer.id, event.target.value);
layer.opacity = event.target.value;
const layerId = layer.id;
const opacity = event.target.value;
mapService.updateOpacity(layerId, opacity);
this.$emit('onOpacityUpdate', { layerId, opacity });
},
}
};
......
......@@ -50,11 +50,13 @@
v-for="basemap in baseMaps"
:key="`list-${basemap.id}`"
:basemap="basemap"
:base-maps="baseMaps"
:active="activeBasemap.id === basemap.id"
:selected-query-layer="selectedQueryLayer"
:active="basemap.active"
@addLayers="addLayers"
@activateGroup="activateGroup"
@onlayerMove="onlayerMove"
@onOpacityUpdate="onOpacityUpdate"
@onQueryLayerChange="onQueryLayerChange"
/>
</div>
</template>
......@@ -76,7 +78,8 @@ export default {
return {
baseMaps: [],
expanded: false,
projectSlug: this.$route.params.slug
projectSlug: this.$route.params.slug,
selectedQueryLayer: '',
};
},
......@@ -86,54 +89,103 @@ export default {
]),
...mapState('map', [
'availableLayers',
'basemaps'
]),
activeBasemap() {
return this.baseMaps.find((baseMap) => baseMap.active);
},
activeBasemapIndex() {
return this.baseMaps.findIndex((el) => el.id === this.activeBasemap.id);
},
activeQueryableLayers() {
return this.baseMaps[this.activeBasemapIndex].layers.filter((layer) => layer.queryable);
},
},
mounted() {
this.baseMaps = this.$store.state.map.basemaps;
const mapOptions =
JSON.parse(localStorage.getItem('geocontrib-map-options')) || {};
if (mapOptions && mapOptions[this.projectSlug]) {
// If already in the storage, we need to check if the admin did some
// modification in the basemaps on the server side. The rule is: if one layer has been added
// or deleted in the server, then we reset the localstorage.
const baseMapsFromLocalstorage = mapOptions[this.projectSlug]['basemaps'];
const areChanges = this.areChangesInBasemaps(
this.baseMaps,
baseMapsFromLocalstorage
);
this.initBasemaps();
},
methods: {
/**
* Initializes the basemaps and handles their state.
* This function checks if the basemaps stored in the local storage match those fetched from the server.
* If changes are detected, it resets the local storage with updated basemaps.
* It also sets the first basemap as active by default and adds the corresponding layers to the map.
*/
initBasemaps() {
// Clone object to not modify data in store, using JSON parse instead of spread operator
// that modifies data, for instance when navigating to basemap administration page
this.baseMaps = JSON.parse(JSON.stringify(this.basemaps));
if (areChanges) {
mapOptions[this.projectSlug] = {
'map-options': this.baseMaps,
'current-basemap-index': 0,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
// Retrieve map options from local storage
const mapOptions = JSON.parse(localStorage.getItem('geocontrib-map-options')) || {};
// Check if map options exist for the current project
if (mapOptions && mapOptions[this.projectSlug]) {
// If already in the storage, we need to check if the admin did some
// modification in the basemaps on the server side.
// The rule is: if one layer has been added or deleted on the server,
// then we reset the local storage.
const baseMapsFromLocalstorage = mapOptions[this.projectSlug]['basemaps'];
const areChanges = this.areChangesInBasemaps(
this.baseMaps,
baseMapsFromLocalstorage
);
} else {
this.baseMaps = baseMapsFromLocalstorage;
// If changes are detected, update local storage with the latest basemaps
if (areChanges) {
mapOptions[this.projectSlug] = {
basemaps: this.baseMaps,
'current-basemap-index': 0,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
} else if (baseMapsFromLocalstorage) {
// If no changes, use the basemaps from local storage
this.baseMaps = baseMapsFromLocalstorage;
}
}
}
if (this.baseMaps.length > 0) {
this.baseMaps[0].active = true;
this.addLayers(this.baseMaps[0]);
} else {
mapService.addLayers(
null,
this.$store.state.configuration.DEFAULT_BASE_MAP_SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS,
this.$store.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE
);
}
},
if (this.baseMaps.length > 0) {
// Set the first basemap as active by default
this.baseMaps[0].active = true;
// Check if an active layer has been set previously by the user
const activeBasemapId = mapOptions[this.projectSlug]
? mapOptions[this.projectSlug]['current-basemap-index']
: null;
// Ensure the active layer ID exists in the current basemaps in case id does not exist anymore or has changed
if (activeBasemapId >= 0 && this.baseMaps.some(bm => bm.id === activeBasemapId)) {
this.baseMaps.forEach((baseMap) => {
// Set the active layer by matching the ID and setting the active property to true
if (baseMap.id === mapOptions[this.projectSlug]['current-basemap-index']) {
baseMap.active = true;
} else {
// Reset others to false to prevent errors from incorrect mapOptions
baseMap.active = false;
}
});
}
// Add layers for the active basemap
this.addLayers(this.activeBasemap);
this.setSelectedQueryLayer();
} else {
// If no basemaps are available, add the default base map layers from the configuration
mapService.addLayers(
null,
this.$store.state.configuration.DEFAULT_BASE_MAP_SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS,
this.$store.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE
);
}
},
methods: {
toggleSidebar(value) {
this.expanded = value !== undefined ? value : !this.expanded;
},
......@@ -141,38 +193,38 @@ export default {
// Check if there are changes in the basemaps settings. Changes are detected if:
// - one basemap has been added or deleted
// - one layer has been added or deleted to a basemap
areChangesInBasemaps(basemapFromServer, basemapFromLocalstorage = {}) {
areChangesInBasemaps(basemapFromServer, basemapFromLocalstorage) {
// prevent undefined later even if in this case the function is not called anyway
if (!basemapFromLocalstorage) return false;
let isSameBasemaps = false;
let isSameLayers = true;
let isSameTitles = true;
// Compare the length and the id values of the basemaps
const idBasemapsServer = basemapFromServer.map((b) => b.id).sort();
const idBasemapsLocalstorage = basemapFromLocalstorage.length
? basemapFromLocalstorage.map((b) => b.id).sort()
: {};
const idBasemapsLocalstorage = basemapFromLocalstorage.map((b) => b.id).sort() || {};
isSameBasemaps =
idBasemapsServer.length === idBasemapsLocalstorage.length &&
idBasemapsServer.every(
(value, index) => value === idBasemapsLocalstorage[index]
);
// For each basemap, compare the length and id values of the layers
// if basemaps changed, return that changed occured to avoid more processing
if (!isSameBasemaps) return true;
outer_block: {
for (const basemapServer of basemapFromServer) {
const idLayersServer = basemapServer.layers.map((b) => b.id).sort();
if (basemapFromLocalstorage.length) {
for (const basemapLocalstorage of basemapFromLocalstorage) {
if (basemapServer.id === basemapLocalstorage.id) {
const idLayersLocalstorage = basemapLocalstorage.layers
.map((b) => b.id)
.sort();
isSameLayers =
idLayersServer.length === idLayersLocalstorage.length &&
idLayersServer.every(
(value, index) => value === idLayersLocalstorage[index]
);
if (!isSameLayers) {
break outer_block;
}
// For each basemap from the server, compare the length and id values of the layers
for (const serverBasemap of basemapFromServer) {
// loop over basemaps from localStorage and check if layers id & queryable setting match with the layers from the server
// we don't check opacity since it would detect a change and reinit each time the user set it. It would need to be stored separatly like current basemap index
for (const localBasemap of basemapFromLocalstorage) {
if (serverBasemap.id === localBasemap.id) {
isSameLayers =
serverBasemap.layers.length === localBasemap.layers.length &&
serverBasemap.layers.every(
(layer, index) => layer.id === localBasemap.layers[index].id &&
layer.queryable === localBasemap.layers[index].queryable
);
if (!isSameLayers) {
break outer_block;
}
}
}
......@@ -181,9 +233,7 @@ export default {
const titlesBasemapsServer = basemapFromServer
.map((b) => b.title)
.sort();
const titlesBasemapsLocalstorage = basemapFromLocalstorage.length
? basemapFromLocalstorage.map((b) => b.title).sort()
: {};
const titlesBasemapsLocalstorage = basemapFromLocalstorage.map((b) => b.title).sort() || {};
isSameTitles = titlesBasemapsServer.every(
(title, index) => title === titlesBasemapsLocalstorage[index]
......@@ -209,10 +259,11 @@ export default {
},
activateGroup(basemapId) {
//* to activate a basemap, we need to set the active property to true et set the others to false
//* to activate a basemap, we need to set the active property to true and set the others to false
this.baseMaps = this.baseMaps.map((bm) => {
return { ...bm, active: bm.id === basemapId ? true : false };
});
this.setSelectedQueryLayer();
//* add the basemap that was clicked to the map
this.addLayers(this.baseMaps.find((bm) => bm.id === basemapId));
//* to persist the settings, we set the localStorage mapOptions per project
......@@ -220,16 +271,16 @@ export default {
},
onlayerMove() {
// Get the names of the current layers in order.
const currentLayersNamesInOrder = Array.from(
document.getElementsByClassName('layer-item transition visible')
// Get ids of the draggable layers in its current order.
const currentLayersIdInOrder = Array.from(
document.querySelectorAll('.content.active .layer-item.transition.visible')
).map((el) => parseInt(el.attributes['data-id'].value));
// Create an array to put the layers in order.
// Create an array to put the original layers in the same order.
let movedLayers = [];
for (const layerName of currentLayersNamesInOrder) {
for (const layerId of currentLayersIdInOrder) {
movedLayers.push(
this.activeBasemap.layers.find((el) => el.id === layerName)
this.activeBasemap.layers.find((el) => el.id === layerId)
);
}
// Remove existing layers undefined
......@@ -242,10 +293,48 @@ export default {
},
});
document.dispatchEvent(eventOrder);
// report layers order change in the basemaps object before saving them
this.baseMaps[this.activeBasemapIndex].layers = movedLayers;
// Save the basemaps options into the localstorage
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
onOpacityUpdate(data) {
const { layerId, opacity } = data;
// retrieve layer to update opacity
this.baseMaps[this.activeBasemapIndex].layers.find((layer) => layer.id === layerId).opacity = opacity;
// Save the basemaps options into the localstorage
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
activateQueryLayer(layerTitle) {
const baseMapLayer = this.baseMaps[this.activeBasemapIndex].layers.find((l) => l.title === layerTitle);
if (baseMapLayer) {
// remove any query property in all layers and set query property at true for selected layer
this.baseMaps[this.activeBasemapIndex].layers.forEach((l) => delete l.query);
baseMapLayer['query'] = true;
// update selected query layer
this.setSelectedQueryLayer();
} else {
console.error('No such param \'query\' found among basemap[0].layers');
}
},
onQueryLayerChange(layerTitle) {
this.activateQueryLayer(layerTitle);
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
// retrieve selected query layer in active basemap (to be called when mounting and when changing active basemap)
setSelectedQueryLayer() {
const currentQueryLayer = this.baseMaps[this.activeBasemapIndex].layers.find((l) => l.query === true);
if (currentQueryLayer) {
this.selectedQueryLayer = currentQueryLayer.title;
} else if (this.activeQueryableLayers[0]) { // if no current query layer previously selected by user
this.activateQueryLayer(this.activeQueryableLayers[0].title); // then activate the first available query layer of the active basemap
}
},
setLocalstorageMapOptions(newOptionObj) {
let mapOptions = localStorage.getItem('geocontrib-map-options') || {};
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
......
<template>
<transition name="fadeDownUp">
<div
v-if="messages && messages.length > 0"
class="row over-content"
>
<div class="fourteen wide column">
<div
v-for="(message, index) in messages"
:key="'message-' + index"
:class="['ui', message.level ? message.level : 'info', 'message']"
>
<li
:ref="'message-' + message.counter"
:class="['list-container', { show }]"
>
<div :class="['list-item', { show}]">
<div :class="['ui', message.level ? message.level : 'info', 'message']">
<i
class="close icon"
aria-hidden="true"
@click="removeListItem"
/>
<div class="header">
<i
class="close icon"
:class="[headerIcon, 'circle icon']"
aria-hidden="true"
@click="DISCARD_MESSAGE(message)"
/>
<div class="header">
<i
class="info circle icon"
aria-hidden="true"
/>
Informations
</div>
<ul class="list">
{{
message.comment
}}
</ul>
{{ message.header || 'Informations' }}
</div>
<ul class="list">
{{
message.comment
}}
</ul>
</div>
</div>
</transition>
</li>
</template>
<script>
......@@ -39,54 +33,121 @@ import { mapState, mapMutations } from 'vuex';
export default {
name: 'MessageInfo',
props: {
message: {
type: Object,
default: () => {},
},
},
data() {
return {
listMessages: [],
show: false,
};
},
computed: {
...mapState(['messages']),
headerIcon() {
switch (this.message.level) {
case 'positive':
return 'check';
case 'negative':
return 'times';
default:
return 'info';
}
}
},
mounted() {
setTimeout(() => {
this.show = true;
}, 15);
},
methods: {
...mapMutations(['DISCARD_MESSAGE']),
removeListItem(){
const container = this.$refs['message-' + this.message.counter];
container.ontransitionend = () => {
this.DISCARD_MESSAGE(this.message.counter);
};
this.show = false;
},
},
};
</script>
<style>
.row.over-content {
position: absolute; /* to display message info over page content */
z-index: 99;
opacity: 0.95;
width: calc(100% - 4em); /* 4em is #content left + right paddings */
top: calc(61px + 1em); /* 61px is #app-header height */
right: 2em; /* 2em is #content left paddings */
}
.fadeDownUp-enter-active {
animation: fadeInDown .5s;
<style scoped>
.list-container{
list-style: none;
width: 100%;
height: 0;
position: relative;
cursor: pointer;
overflow: hidden;
transition: all 0.6s ease-out;
}
.fadeDownUp-leave-active {
animation: fadeOutUp .5s;
.list-container.show{
height: 7.5em;
}
@keyframes fadeOutUp {
0% {
opacity: 1;
@media screen and (min-width: 726px) {
.list-container.show{
height: 6em;
}
100% {
}
.list-container.show:not(:first-child){
margin-top: 10px;
}
.list-container .list-item{
padding: .5rem 0;
width: 100%;
position: absolute;
opacity: 0;
transform: translate3d(0, -100%, 0);
}
top: 0;
left: 0;
transition: all 0.6s ease-out;
}
.list-container .list-item.show{
opacity: 1;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translate3d(0, -100%, 0);
ul.list{
overflow: auto;
height: 3.5em;
margin-bottom: .5em !important;
}
@media screen and (min-width: 726px) {
ul.list{
height: 2.2em;
}
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.ui.message {
overflow: hidden;
padding-bottom: 0 !important;
}
.ui.message::after {
content: "";
position: absolute;
bottom: 0;
left: 1em;
right: 0;
width: calc(100% - 2em);
}
.ui.info.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.positive.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.negative.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.message > .close.icon {
......
<template>
<div
v-if="messages && messages.length > 0"
class="row over-content"
>
<div class="fourteen wide column">
<ul
class="message-list"
aria-live="assertive"
>
<MessageInfo
v-for="message in messages"
:key="'message-' + message.counter"
:message="message"
/>
</ul>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import MessageInfo from '@/components/MessageInfo';
export default {
name: 'MessageInfoList',
components: {
MessageInfo,
},
computed: {
...mapState(['messages']),
},
};
</script>
<style scoped>
.row.over-content {
position: absolute; /* to display message info over page content */
z-index: 99;
opacity: 0.95;
width: calc(100% - 4em); /* 4em is #content left + right paddings */
top: calc(40px + 1em); /* 40px is #app-header height */
right: 2em; /* 2em is #content left paddings */
}
.message-list{
list-style: none;
padding-left: 0;
margin-top: 0;
}
@media screen and (max-width: 725px) {
.row.over-content {
top: calc(80px + 1em); /* 90px is #app-header height in mobile display */
width: calc(100% - 2em);
right: 1em;
}
}
</style>
\ No newline at end of file