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 2442 additions and 1047 deletions
...@@ -5,42 +5,63 @@ ...@@ -5,42 +5,63 @@
aria-describedby="Table des données du signalement" aria-describedby="Table des données du signalement"
> >
<tbody> <tbody>
<tr v-if="feature_type"> <tr v-if="featureType">
<td> <td>
<strong> Type de signalement </strong> <strong> Type de signalement </strong>
</td> </td>
<td> <td>
<FeatureTypeLink :feature-type="feature_type" /> <FeatureTypeLink :feature-type="featureType" />
</td> </td>
</tr> </tr>
<tr <tr
v-for="(field, index) in currentFeature.feature_data" v-for="field in featureFields"
:key="'field' + index" :key="field.name"
> >
<td> <template v-if="!field.isDeactivated">
<strong>{{ field.label }}</strong> <td>
</td> <strong :class="{ required: field.is_mandatory }">
<td> {{ field.label }}
<strong> </strong>
<i </td>
v-if="field.field_type === 'boolean'" <td>
:class="[ <strong class="ui form">
'icon', <span
field.value ? 'olive check' : 'grey times', v-if="fastEditionMode && canEditFeature && extra_forms.length > 0"
]" :id="field.label"
aria-hidden="true" >
/> <ExtraForm
<span v-else> ref="extraForm"
{{ field.value }} :field="field"
</span> />
</strong> </span>
</td> <i
v-else-if="field.field_type === 'boolean'"
:class="[
'icon',
field.value ? 'olive check' : 'grey times',
]"
aria-hidden="true"
/>
<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>
<tr> <tr>
<td> <td>
Auteur Auteur
</td> </td>
<td>{{ currentFeature.display_creator }}</td> <td v-if="currentFeature.properties">
{{ currentFeature.properties.display_creator }}
</td>
</tr> </tr>
<tr> <tr>
<td> <td>
...@@ -48,27 +69,47 @@ ...@@ -48,27 +69,47 @@
</td> </td>
<td> <td>
<i <i
v-if="currentFeature.status" v-if="currentFeature.properties && currentFeature.properties.status"
:class="['icon', statusIcon]" :class="['icon', statusIcon]"
aria-hidden="true" aria-hidden="true"
/> />
{{ statusLabel }} <FeatureEditStatusField
v-if="fastEditionMode && canEditFeature && form"
:status="form.status.value.value || form.status.value"
class="inline"
/>
<span v-else>
{{ statusLabel }}
</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> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
Date de création Date de création
</td> </td>
<td v-if="currentFeature.created_on"> <td v-if="currentFeature.properties && currentFeature.properties.created_on">
{{ currentFeature.created_on | formatDate }} {{ currentFeature.properties.created_on | formatDate }}
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td>
Date de dernière modification Date de dernière modification
</td> </td>
<td v-if="currentFeature.updated_on"> <td v-if="currentFeature.properties && currentFeature.properties.updated_on">
{{ currentFeature.updated_on | formatDate }} {{ currentFeature.properties.updated_on | formatDate }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
...@@ -93,11 +134,24 @@ ...@@ -93,11 +134,24 @@
<td <td
v-if="link.feature_to.feature_type_slug" v-if="link.feature_to.feature_type_slug"
> >
<a @click="pushNgo(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.display_creator }} -
{{ link.feature_to.created_on }}) {{ link.feature_to.created_on }})
</td> </td>
</tr> </tr>
<tr v-if="linked_features.length === 0">
<td>
<em>
Aucune liaison associée au signalement.
</em>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
...@@ -107,36 +161,60 @@ ...@@ -107,36 +161,60 @@
import { mapState, mapGetters } from 'vuex'; import { mapState, mapGetters } from 'vuex';
import FeatureTypeLink from '@/components/FeatureType/FeatureTypeLink'; import FeatureTypeLink from '@/components/FeatureType/FeatureTypeLink';
import FeatureEditStatusField from '@/components/Feature/FeatureEditStatusField';
import ExtraForm from '@/components/ExtraForm';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
import ProjectMemberSelect from '@/components/ProjectMemberSelect';
import { statusChoices, formatStringDate, checkDeactivatedValues } from '@/utils';
export default { export default {
name: 'FeatureTable', name: 'FeatureTable',
filters: {
formatDate(value) {
return formatStringDate(value);
},
},
components: { components: {
FeatureTypeLink FeatureTypeLink,
FeatureEditStatusField,
ExtraForm,
FeatureFetchOffsetRoute,
ProjectMemberSelect,
}, },
filters: { props: {
formatDate(value) { featureType: {
let date = new Date(value); type: Object,
date = date.toLocaleString().replace(',', ''); default: () => {},
return date.substr(0, date.length - 3); //* quick & dirty way to remove seconds from date },
fastEditionMode: {
type: Boolean,
default: false,
},
canEditFeature: {
type: Boolean,
default: false,
}, },
}, },
computed: { computed: {
...mapState('projects', [
'project'
]),
...mapState('feature', [ ...mapState('feature', [
'currentFeature', 'currentFeature',
'linked_features', 'linked_features',
'statusChoices' 'form',
]), 'extra_forms',
...mapGetters('feature-type', [
'feature_type',
]), ]),
...mapGetters(['usersGroupsFeatureOptions']),
statusIcon() { statusIcon() {
switch (this.currentFeature.status) { switch (this.currentFeature.properties.status.value || this.currentFeature.properties.status) {
case 'archived': case 'archived':
return 'grey archive'; return 'grey archive';
case 'pending': case 'pending':
...@@ -149,19 +227,64 @@ export default { ...@@ -149,19 +227,64 @@ export default {
return ''; return '';
} }
}, },
statusLabel() { statusLabel() {
const status = this.statusChoices.find( if (this.currentFeature.properties) {
(el) => el.value === this.currentFeature.status if (this.currentFeature.properties && this.currentFeature.properties.status.label) {
); return this.currentFeature.properties.status.label;
return status ? status.name : ''; }
const status = statusChoices.find(
(el) => el.value === this.currentFeature.properties.status
);
return status ? status.name : '';
}
return '';
},
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;
},
assignedMemberId() {
if (this.form && this.form.assigned_member) {
return this.form.assigned_member.value;
}
return this.currentFeature.properties.assigned_member;
}, },
}, },
methods: { methods: {
pushNgo(link) { usersGroupLabel(field) {
this.$emit('push-n-go', link); const usersGroup = this.usersGroupsFeatureOptions.find((group) => group.value === field.value);
return usersGroup ? usersGroup.name : field.value;
} }
} }
}; };
</script> </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_type === 'char'"
>
<label 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_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-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
:id="field.name"
:value="field.value"
type="number"
:name="field.name"
@change="updateStore_extra_form"
>
</div>
</div>
<div
v-else-if="field.field_type === 'boolean'"
>
<div class="ui checkbox">
<input
:id="field.name"
class="hidden"
type="checkbox"
:checked="field.value"
:name="field.name"
@change="updateStore_extra_form"
>
<label for="field.name">{{ field.label }}</label>
</div>
</div>
<div
v-else-if="field.field_type === 'date'"
>
<label 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_type === 'decimal'"
>
<label 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_type === 'text'"
>
<label :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> <template>
<div> <section class="ui teal segment">
<div class="ui teal segment"> <h4>
<h4> Pièce jointe
Pièce jointe <button
<button class="ui small compact right floated icon button remove-formset"
class="ui small compact right floated icon button remove-formset" type="button"
type="button" @click="removeAttachmentForm(form.dataKey)"
@click="removeAttachmentFormset(form.dataKey)" >
> <i
<i class="ui times icon"
class="ui times icon" aria-hidden="true"
aria-hidden="true" />
/> </button>
</button> </h4>
</h4> <div class="visible-fields">
<!-- {{ form.errors }} --> <div class="two fields">
<div class="visible-fields"> <div class="required field">
<div class="two fields"> <label :for="form.title.id_for_label">{{ form.title.label }}</label>
<div class="required field"> <input
<label :for="form.title.id_for_label">{{ form.title.label }}</label> :id="form.title.id_for_label"
<input v-model="form.title.value"
:id="form.title.id_for_label" type="text"
v-model="form.title.value" required
type="text" :maxlength="form.title.field.max_length"
required :name="form.title.html_name"
: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 {{ error }}
:id="form.title.id_for_error" </li>
class="errorlist" </ul>
> </div>
<li <div class="required field">
v-for="error in form.title.errors" <label>Fichier (PDF, PNG, JPEG)</label>
:key="error" <label
> class="ui icon button"
{{ error }} :for="'attachment_file' + attachmentForm.dataKey"
</li> >
</ul> <i
</div> class="file icon"
<div class="required field"> aria-hidden="true"
<label>Fichier (PDF, PNG, JPEG)</label> />
<label <span
class="ui icon button" v-if="form.attachment_file.value"
:for="'attachment_file' + attachmentForm.dataKey" class="label"
> >{{
<i form.attachment_file.value
class="file icon" }}</span>
aria-hidden="true" <span
/> v-else
<span class="label"
v-if="form.attachment_file.value" >Sélectionner un fichier ... </span>
class="label" </label>
>{{ <input
form.attachment_file.value :id="'attachment_file' + attachmentForm.dataKey"
}}</span> type="file"
<span accept="application/pdf, image/jpeg, image/png"
v-else style="display: none"
class="label" :name="form.attachment_file.html_name"
>Sélectionner un fichier ... </span> @change="onFileChange"
</label> >
<input <ul
:id="'attachment_file' + attachmentForm.dataKey" :id="form.attachment_file.id_for_error"
type="file" class="errorlist"
accept="application/pdf, image/jpeg, image/png" >
style="display: none" <li
:name="form.attachment_file.html_name" v-for="error in form.attachment_file.errors"
@change="onFileChange" :key="error"
>
<ul
:id="form.attachment_file.id_for_error"
class="errorlist"
> >
<li {{ error }}
v-for="error in form.attachment_file.errors" </li>
:key="error" </ul>
>
{{ error }}
</li>
</ul>
</div>
</div> </div>
<div class="field"> </div>
<label for="form.info.id_for_label">{{ form.info.label }}</label> <div class="field">
<textarea <label for="form.info.id_for_label">{{ form.info.label }}</label>
v-model="form.info.value" <textarea
name="form.info.html_name" v-model="form.info.value"
rows="5" name="form.info.html_name"
/> rows="5"
<!-- {{ form.info.errors }} --> />
</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>
</div> </div>
</div> </section>
</template> </template>
<script> <script>
export default { export default {
name: 'FeatureAttachmentFormset', name: 'FeatureAttachmentForm',
props: { props: {
attachmentForm: { attachmentForm: {
type: Object, type: Object,
default: null, default: null,
},
enableKeyDocNotif: {
type: Boolean,
default: false,
} }
}, },
...@@ -138,6 +157,9 @@ export default { ...@@ -138,6 +157,9 @@ export default {
errors: null, errors: null,
label: 'Info', label: 'Info',
}, },
is_key_document: {
value: false,
},
}, },
}; };
}, },
...@@ -180,7 +202,7 @@ export default { ...@@ -180,7 +202,7 @@ export default {
} }
}, },
removeAttachmentFormset() { removeAttachmentForm() {
this.$store.commit( this.$store.commit(
'feature/REMOVE_ATTACHMENT_FORM', 'feature/REMOVE_ATTACHMENT_FORM',
this.attachmentForm.dataKey this.attachmentForm.dataKey
...@@ -198,6 +220,7 @@ export default { ...@@ -198,6 +220,7 @@ export default {
attachment_file: this.form.attachment_file.value, attachment_file: this.form.attachment_file.value,
info: this.form.info.value, info: this.form.info.value,
fileToImport: this.fileToImport, fileToImport: this.fileToImport,
is_key_document: this.form.is_key_document.value
}; };
this.$store.commit('feature/UPDATE_ATTACHMENT_FORM', data); this.$store.commit('feature/UPDATE_ATTACHMENT_FORM', data);
}, },
......
<template>
<div
id="status"
class="field"
>
<Dropdown
v-if="selectedStatus"
:options="allowedStatusChoices"
:selected="selectedStatus.name"
:selection.sync="selectedStatus"
/>
</div>
</template>
<script>
import Dropdown from '@/components/Dropdown.vue';
import { statusChoices, allowedStatus2change } from '@/utils';
import { mapState } from 'vuex';
export default {
name: 'FeatureEditStatusField',
components: {
Dropdown,
},
props: {
status: {
type: String,
default: '',
},
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS',
]),
...mapState('projects', [
'project'
]),
...mapState('feature', [
'currentFeature'
]),
statusObject() {
return statusChoices.find((key) => key.value === this.status);
},
selectedStatus: {
get() {
return this.statusObject;
},
set(newValue) {
this.$store.commit('feature/UPDATE_FORM_FIELD', { name: 'status', value: newValue.value });
},
},
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];
return allowedStatus2change(this.user, isModerate, userStatus, this.isFeatureCreator);
}
return [];
},
},
};
</script>
\ No newline at end of file
<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 { ...@@ -135,9 +135,9 @@ export default {
this.$store.commit('feature/REMOVE_LINKED_FORM', this.linkedForm.dataKey); this.$store.commit('feature/REMOVE_LINKED_FORM', this.linkedForm.dataKey);
}, },
selectFeatureTo(feature) { selectFeatureTo(featureId) {
if (feature && feature.feature_id) { if (featureId) {
this.form.feature_to.value = feature.feature_id; this.form.feature_to.value = featureId;
this.updateStore(); 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> <template>
<div class="ui teal segment pers-field"> <div
<h4> :id="custFormId"
Champ personnalisé :class="['ui teal segment pers-field', { hasErrors }]"
<button >
class="ui small compact right floated icon button remove-field" <div class="custom-field-header">
type="button" <h4>
@click="removeCustomForm()" Champ personnalisé
> </h4>
<i <div class="top-right">
class="ui times icon" <div
aria-hidden="true" v-if="(form.label.value || form.name.value) && selectedFieldType !== 'Booléen'"
/> class="ui checkbox"
</button> >
</h4> <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="visible-fields">
<div class="two fields"> <div class="two fields">
<div class="required field"> <div class="required field">
...@@ -24,7 +45,7 @@ ...@@ -24,7 +45,7 @@
required required
:maxlength="form.label.field.max_length" :maxlength="form.label.field.max_length"
:name="form.label.html_name" :name="form.label.html_name"
@blur="updateStore" @input="updateStore"
> >
<small>{{ form.label.help_text }}</small> <small>{{ form.label.help_text }}</small>
<ul <ul
...@@ -49,7 +70,7 @@ ...@@ -49,7 +70,7 @@
required required
:maxlength="form.name.field.max_length" :maxlength="form.name.field.max_length"
:name="form.name.html_name" :name="form.name.html_name"
@blur="updateStore" @input="updateStore"
> >
<small>{{ form.name.help_text }}</small> <small>{{ form.name.help_text }}</small>
<ul <ul
...@@ -95,13 +116,16 @@ ...@@ -95,13 +116,16 @@
</ul> </ul>
</div> </div>
<div class="required field"> <div
id="field_type"
class="required field"
>
<label :for="form.field_type.id_for_label">{{ <label :for="form.field_type.id_for_label">{{
form.field_type.label form.field_type.label
}}</label> }}</label>
<Dropdown <Dropdown
:disabled="!form.label.value || !form.name.value" :disabled="!form.label.value || !form.name.value"
:options="fieldTypeChoices" :options="customFieldTypeChoices"
:selected="selectedFieldType" :selected="selectedFieldType"
:selection.sync="selectedFieldType" :selection.sync="selectedFieldType"
/> />
...@@ -117,29 +141,126 @@ ...@@ -117,29 +141,126 @@
</li> </li>
</ul> </ul>
</div> </div>
<div <div
v-if="selectedFieldType === 'Liste de valeurs'" v-if="selectedFieldType === 'Liste de valeurs pré-enregistrées'"
class="field field-list-options required field" class="field required"
data-test="prerecorded-list-option"
> >
<label :for="form.options.id_for_label">{{ <label>{{
form.options.label form.options.label
}}</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 <input
:id="form.options.id_for_label" type="checkbox"
v-model="arrayOption" name="conditional-custom-field"
type="text" :checked="form.conditional_field_config.value"
:maxlength="form.options.field.max_length" @change="setConditionalCustomForm"
:name="form.options.html_name"
class="options-field"
> >
<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 <ul
id="errorlist" id="errorlist"
class="errorlist" class="errorlist"
> >
<li <li
v-for="error in form.options.errors" v-for="error in form.conditional_field_config.errors"
:key="error" :key="error"
> >
{{ error }} {{ error }}
...@@ -147,20 +268,70 @@ ...@@ -147,20 +268,70 @@
</ul> </ul>
</div> </div>
</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>
</div> </div>
</template> </template>
<script> <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 Dropdown from '@/components/Dropdown.vue';
import CustomFormConditionalField from '@/components/FeatureType/CustomFormConditionalField.vue';
export default { export default {
name: 'FeatureTypeCustomForm', name: 'FeatureTypeCustomForm',
components: { components: {
Dropdown, Dropdown,
CustomFormConditionalField,
}, },
props: { props: {
...@@ -176,17 +347,12 @@ export default { ...@@ -176,17 +347,12 @@ export default {
data() { data() {
return { return {
fieldTypeChoices: [ customFieldTypeChoices,
{ 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: { form: {
dataKey: 0, is_mandatory: {
value: false,
html_name: 'mandatory-custom-field',
},
label: { label: {
errors: [], errors: [],
id_for_label: 'label', id_for_label: 'label',
...@@ -194,7 +360,7 @@ export default { ...@@ -194,7 +360,7 @@ export default {
help_text: 'Nom en language naturel du champ', help_text: 'Nom en language naturel du champ',
html_name: 'label', html_name: 'label',
field: { field: {
max_length: 128, max_length: 256,
}, },
value: null, value: null,
}, },
...@@ -241,31 +407,54 @@ export default { ...@@ -241,31 +407,54 @@ export default {
html_name: 'options', html_name: 'options',
help_text: 'Valeurs possibles de ce champ, séparées par des virgules', help_text: 'Valeurs possibles de ce champ, séparées par des virgules',
field: { field: {
max_length: 256, max_length: null,
}, },
value: [], value: [],
}, },
conditional_field_config: {
errors: [],
value: null,
},
forced_value_config: {
errors: [],
value: [],
}
}, },
hasErrors: false,
selectedPrerecordedList: null,
sortable: null
}; };
}, },
computed: { computed: {
...mapState('feature-type', [ ...mapState('feature-type', [
'customForms' 'customForms',
'preRecordedLists'
]), ]),
custFormId() {
return `custom_form-${this.form.position.value}`;
},
selectedFieldType: { selectedFieldType: {
// getter // getter
get() { get() {
const currentFieldType = this.fieldTypeChoices.find( const currentFieldType = customFieldTypeChoices.find(
(el) => el.value === this.form.field_type.value (el) => el.value === this.form.field_type.value
); );
if (currentFieldType) { 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 currentFieldType.name;
} }
return null; return null;
}, },
// setter // setter
set(newValue) { set(newValue) {
if (newValue.value === 'pre_recorded_list') {
this.GET_PRERECORDED_LISTS();
}
this.form.field_type.value = newValue.value; 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 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 ? // Vue.set(this.form.field_type, "value", newValue.value); // ? vue.set didn't work, maybe should flatten form ?
...@@ -284,14 +473,27 @@ export default { ...@@ -284,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() { 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 //* 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); this.fillCustomFormData(this.customForm);
if (this.customForm.field_type === 'pre_recorded_list') {
this.GET_PRERECORDED_LISTS();
}
}, },
methods: { methods: {
...mapActions('feature-type', [
'GET_PRERECORDED_LISTS'
]),
hasDuplicateOptions() { hasDuplicateOptions() {
this.form.options.errors = []; this.form.options.errors = [];
const isDup = const isDup =
...@@ -306,7 +508,7 @@ export default { ...@@ -306,7 +508,7 @@ export default {
fillCustomFormData(customFormData) { fillCustomFormData(customFormData) {
for (const el in 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 //* 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 this.form[el].value = customFormData[el].value
? customFormData[el].value ? customFormData[el].value
...@@ -322,20 +524,99 @@ export default { ...@@ -322,20 +524,99 @@ export default {
this.customForm.dataKey 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() { updateStore() {
const data = { const data = {
dataKey: this.customForm.dataKey, dataKey: this.customForm.dataKey,
is_mandatory: this.form.is_mandatory.value,
label: this.form.label.value, label: this.form.label.value,
name: this.form.name.value, name: this.form.name.value,
position: this.form.position.value, position: this.form.position.value,
field_type: this.form.field_type.value, field_type: this.form.field_type.value,
options: this.form.options.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); this.$store.commit('feature-type/UPDATE_CUSTOM_FORM', data);
if (this.customForm.name === this.selectedColorStyle ) { if (this.customForm.name === this.selectedColorStyle ) {
this.$emit('update', this.form.options.value); 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) { trimWhiteSpace(string) {
...@@ -343,6 +624,7 @@ export default { ...@@ -343,6 +624,7 @@ export default {
return string.replace(/\s*,\s*/gi, ','); return string.replace(/\s*,\s*/gi, ',');
}, },
//* CHECKS *//
hasRegularCharacters(input) { hasRegularCharacters(input) {
for (const char of input) { for (const char of input) {
if (!/[a-zA-Z0-9-_]/.test(char)) { if (!/[a-zA-Z0-9-_]/.test(char)) {
...@@ -352,61 +634,167 @@ export default { ...@@ -352,61 +634,167 @@ export default {
return true; 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() { checkUniqueName() {
const occurences = this.customForms const occurences = this.customForms
.map((el) => el.name) .map((el) => el.name.toLowerCase())
.filter((el) => el === this.form.name.value); .filter((el) => el === this.form.name.value.toLowerCase());
return occurences.length === 1; return occurences.length === 1;
}, },
checkFilledOptions() { checkOptions() {
if (this.form.field_type.value === 'list') { if (this.form.field_type.value === 'list') {
if (this.form.options.value.length < 1) { return this.form.options.value.length >= 2 && !this.form.options.value.includes('') ?
return false; '' : 'Veuillez renseigner au moins 2 options.';
} else if ( }
this.form.options.value.length === 1 && if (this.form.field_type.value === 'pre_recorded_list') {
this.form.options.value[0] === '' return this.form.options.value.length === 1 ?
) { '' : 'Veuillez sélectionner une option.';
return false; }
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; return true;
}, },
checkCustomForm() { checkCustomForm(noScroll) {
this.form.label.errors = []; // reset errors to empty array
this.form.name.errors = []; for (const element in this.form) {
this.form.options.errors = []; 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) { if (!this.form.label.value) {
//* vérifier que le label est renseigné //* vérifier que le label est renseigné
this.form.label.errors = ['Veuillez compléter ce champ.']; this.form.label.errors = ['Veuillez compléter ce champ.'];
return false; isValid = false;
} else if (!this.form.name.value) { } else if (!this.form.name.value) {
//* vérifier que le nom est renseigné //* vérifier que le nom est renseigné
this.form.name.errors = ['Veuillez compléter ce champ.']; this.form.name.errors = ['Veuillez compléter ce champ.'];
return false; isValid = false;
} else if (!this.hasRegularCharacters(this.form.name.value)) { } else if (!this.hasRegularCharacters(this.form.name.value)) {
//* vérifier qu'il n'y a pas de caractères spéciaux //* vérifier qu'il n'y a pas de caractères spéciaux
this.form.name.errors = [ this.form.name.errors = [
'Veuillez utiliser seulement les caratères autorisés.', '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()) { } else if (!this.checkUniqueName()) {
//* vérifier si les noms sont pas dupliqués //* vérifier si les noms sont pas dupliqués
this.form.name.errors = [ this.form.name.errors = [
'Les champs personnalisés ne peuvent pas avoir des noms similaires.', 'Les champs personnalisés ne peuvent pas avoir des noms similaires.',
]; ];
return false; isValid = false;
} else if (!this.checkFilledOptions()) { } else if (optionError) {
//* s'il s'agit d'un type liste, vérifier que le champ option est bien renseigné //* 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.']; this.form.options.errors = [optionError];
return false; isValid = false;
} else if (this.hasDuplicateOptions()) { } else if (this.hasDuplicateOptions()) {
//* pour le cas d'options dupliqués //* 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> </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>
<template> <template>
<router-link <router-link
v-if="featureType && featureType.slug"
:to="{ :to="{
name: 'details-type-signalement', name: 'details-type-signalement',
params: { feature_type_slug: featureType.slug }, params: { feature_type_slug: featureType.slug },
}" }"
class="feature-type-title" class="feature-type-title"
:title="featureType.title"
> >
<img <img
v-if="featureType.geom_type === 'point'" v-if="featureType.geom_type === 'point'"
...@@ -24,7 +26,34 @@ ...@@ -24,7 +26,34 @@
src="@/assets/img/polygon.png" src="@/assets/img/polygon.png"
alt="Géométrie polygone" 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> </router-link>
</template> </template>
...@@ -46,14 +75,19 @@ export default { ...@@ -46,14 +75,19 @@ export default {
<style scoped> <style scoped>
.feature-type-title { .feature-type-title {
overflow: hidden; display: flex;
white-space: nowrap; align-items: center;
text-overflow: ellipsis;
line-height: 1.5em; line-height: 1.5em;
width: fit-content;
} }
.list-image-type { .list-image-type {
margin-right: 5px; margin-right: 5px;
height: 25px; height: 25px;
vertical-align: bottom; display: flex;
align-items: center;
}
.list-image-type > i {
color: #000000;
height: 25px;
} }
</style> </style>
<template> <template>
<div> <div>
<div class="three fields"> <div class="three fields">
<div class="row-title"> <h5 class="field">
{{ title }} {{ title }}
</div> </h5>
<div class="required inline field"> <div class="required inline field">
<label :for="form.color.id_for_label">{{ form.color.label }}</label> <label :for="form.color.id_for_label">{{ form.color.label }}</label>
<input <input
...@@ -14,25 +14,32 @@ ...@@ -14,25 +14,32 @@
:name="form.color.html_name" :name="form.color.html_name"
> >
</div> </div>
<!-- <div class="required inline field">
<label>Symbole</label> <div
<button v-if="geomType === 'polygon' || geomType === 'multipolygon'"
class="ui icon button picker-button" class="field"
type="button" >
@click="openIconSelectionModal" <label>Opacité &nbsp;<span>(%)</span></label>
> <div class="range-container">
<font-awesome-icon <input
:icon="['fas', form.icon]" id="opacity"
:style="{ color: form.color.value || '#000000' }" v-model="form.opacity"
class="icon alt" type="range"
/> min="0"
</button> max="1"
</div> --> step="0.01"
>
<output class="range-output-bubble">
{{ getOpacity(form.opacity) }}
</output>
</div>
</div>
</div> </div>
<div <div
v-if="isIconPickerModalOpen"
ref="iconsPickerModal" ref="iconsPickerModal"
:class="isIconPickerModalOpen ? 'active' : ''" class="ui dimmer modal transition active"
class="ui dimmer modal transition"
> >
<div class="header"> <div class="header">
Sélectionnez le symbole pour ce type de signalement : Sélectionnez le symbole pour ce type de signalement :
...@@ -41,13 +48,11 @@ ...@@ -41,13 +48,11 @@
<div <div
v-for="icon of iconsNamesList" v-for="icon of iconsNamesList"
:key="icon" :key="icon"
:class="form.icon === icon ? 'active' : ''" :class="['icon-container', { active: form.icon === icon }]"
class="icon-container"
@click="selectIcon(icon)" @click="selectIcon(icon)"
> >
<i <i
:class="`fa-${icon}`" :class="`icon alt fas fa-${icon}`"
class="icon alt fas"
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>
...@@ -73,7 +78,7 @@ export default { ...@@ -73,7 +78,7 @@ export default {
props: { props: {
title: { title: {
type: String, type: String,
default: 'Symbologie par défault :' default: 'Couleur par défault :'
}, },
initColor: { initColor: {
type: String, type: String,
...@@ -83,6 +88,10 @@ export default { ...@@ -83,6 +88,10 @@ export default {
type: String, type: String,
default: 'circle' default: 'circle'
}, },
initOpacity: {
type: [String, Number],
default: '0.5'
},
geomType: { geomType: {
type: String, type: String,
default: 'Point' default: 'Point'
...@@ -104,6 +113,7 @@ export default { ...@@ -104,6 +113,7 @@ export default {
html_name: 'couleur', html_name: 'couleur',
value: '#000000', value: '#000000',
}, },
opacity: '0.5',
} }
}; };
}, },
...@@ -113,7 +123,7 @@ export default { ...@@ -113,7 +123,7 @@ export default {
deep: true, deep: true,
handler(newValue) { handler(newValue) {
this.$emit('set', { this.$emit('set', {
name: this.title === 'Symbologie par défault :' ? null : this.title, name: this.isDefault ? null : this.title,
value: newValue value: newValue
}); });
} }
...@@ -125,6 +135,9 @@ export default { ...@@ -125,6 +135,9 @@ export default {
if (this.initIcon) { if (this.initIcon) {
this.form.icon = this.initIcon; this.form.icon = this.initIcon;
} }
if (this.initOpacity) {
this.form.opacity = this.initOpacity;
}
this.$emit('set', { this.$emit('set', {
name: this.title, name: this.title,
value: this.form value: this.form
...@@ -138,7 +151,11 @@ export default { ...@@ -138,7 +151,11 @@ export default {
selectIcon(icon) { selectIcon(icon) {
this.form.icon = icon; this.form.icon = icon;
} },
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
} }
}; };
</script> </script>
...@@ -147,16 +164,16 @@ export default { ...@@ -147,16 +164,16 @@ export default {
.fields { .fields {
align-items: center; align-items: center;
justify-content: space-between;
margin-top: 3em !important;
} }
.row-title { #customFieldSymbology .fields {
display: inline; margin-left: 1em !important;
font-size: 1.4em; margin-bottom: 1em !important;
width: 33%; }
text-align: left;
margin-left: 0.5em; h5 {
font-weight: initial;
font-style: italic;
} }
#couleur { #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> <template>
<div id="table-imports"> <div id="table-imports">
<table <div class="imports-header">
class="ui collapsing celled table" <div class="file-column">
aria-describedby="Tableau des import en cours ou terminés" Fichiers importés
</div>
<div class="status-column">
Statuts
</div>
</div>
<div
v-for="importFile in imports"
:key="importFile.created_on"
class="filerow"
> >
<thead> <div class="file-column">
<tr> <h4 class="ui header align-right">
<th <div
scope="col" :data-tooltip="importFile.geojson_file_name"
class="ellipsis"
> >
Fichiers importés {{ importFile.geojson_file_name }}
</th> </div>
<th </h4>
scope="col" <div class="sub header">
> ajouté le {{ importFile.created_on | formatDate }}
Status </div>
</th> </div>
</tr>
</thead> <div class="status-column">
<tbody> <span
<tr v-if="importFile.infos"
v-for="importFile in data" :data-tooltip="importFile.infos"
:key="importFile.created_on" class="ui icon margin-left"
> >
<td> <i
<h4 class="ui header align-right"> v-if="importFile.status === 'processing'"
<div :data-tooltip="importFile.geojson_file_name"> class="orange hourglass half icon"
{{ importFile.geojson_file_name | subString }} aria-hidden="true"
<div class="sub header"> />
ajouté le {{ importFile.created_on | setDate }} <i
</div> v-else-if="importFile.status === 'finished'"
</div> class="green check circle outline icon"
</h4> aria-hidden="true"
</td> />
<i
<td> v-else-if="importFile.status === 'failed'"
<span class="red x icon"
v-if="importFile.infos" aria-hidden="true"
:data-tooltip="importFile.infos" />
class="ui icon margin-left" <i
> v-else
<i class="red ban icon"
v-if="importFile.status === 'processing'" aria-hidden="true"
class="orange hourglass half icon" />
aria-hidden="true" </span>
/> <span
<i v-if="importFile.status === 'pending'"
v-else-if="importFile.status === 'finished'" data-tooltip="Statut en attente. Cliquez pour rafraichir."
class="green check circle outline icon" >
aria-hidden="true" <i
/> :class="['orange icon', !reloading ? 'sync' : 'hourglass half rotate']"
<i aria-hidden="true"
v-else-if="importFile.status === 'failed'" @click="fetchImports()"
class="red x icon" />
aria-hidden="true" </span>
/> </div>
<i </div>
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>
</div> </div>
</template> </template>
<script> <script>
import { mapState } from 'vuex';
export default { export default {
filters: { filters: {
setDate: function (value) { formatDate: function (value) {
const date = new Date(value); const date = new Date(value);
return date.toLocaleDateString('fr', { return date.toLocaleDateString('fr', {
year: 'numeric', year: 'numeric',
...@@ -98,140 +90,102 @@ export default { ...@@ -98,140 +90,102 @@ export default {
}, },
props: { props: {
data: { imports: {
type: Array, type: Array,
default: null, default: null,
}, },
reloading: {
type: Boolean,
default: false,
}
}, },
data() { data() {
return { return {
open: false, reloading: false,
ready: true, fetchCallCounter: 0,
}; };
}, },
computed: { mounted() {
...mapState('feature', ['features']), this.fetchImports();
},
watch: {
data(newValue) {
if (newValue) {
this.ready = true;
}
},
}, },
methods: { methods: {
fetchImports() { fetchImports() {
this.$store.dispatch( this.fetchCallCounter += 1; // register each time function is programmed to be called in order to avoid redundant calls
'feature-type/GET_IMPORTS', { this.reloading = true;
feature_type: this.$route.params.feature_type_slug // fetch imports
}); this.$store.dispatch('feature-type/GET_IMPORTS', {
this.$store.dispatch('feature/GET_PROJECT_FEATURES', {
project_slug: this.$route.params.slug, project_slug: this.$route.params.slug,
feature_type__slug: this.$route.params.feature_type_slug feature_type: this.$route.params.feature_type_slug
}); })
//* show that the action was triggered, could be improved with animation (doesn't work) .then((response) => {
this.ready = false; 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> </script>
<style scoped lang="less"> <style scoped lang="less">
.sync {
cursor: pointer;
}
#table-imports { #table-imports {
padding-top: 1em; border: 1px solid lightgrey;
table { margin-top: 1rem;
width: 100%; .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 { i.icon {
width: 20px !important; width: 20px !important;
height: 20px !important; height: 20px !important;
} }
.rotate { .rotate {
-webkit-animation:spin 1s linear infinite; -webkit-animation:spin 1.5s cubic-bezier(.3,.25,.15,1) infinite;
-moz-animation:spin 1s linear infinite; -moz-animation:spin 1.5s cubic-bezier(.3,.25,.15,1) infinite;
animation:spin 1s linear infinite; animation:spin 1.5s cubic-bezier(.3,.25,.15,1) 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%;
}
} }
@-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> </style>
<template> <template>
<div class="editionToolbar"> <div class="editionToolbar">
<div v-if="showDrawTool"> <div class="leaflet-bar">
<div class="leaflet-bar"> <a
<a v-if="noExistingFeature"
class="leaflet-draw-draw-polygon" class="leaflet-draw-draw-polygon active"
:title=" :title="`Dessiner ${editionService.geom_type === 'linestring' ? 'une' : 'un'} ${toolbarGeomTitle}`
editionService.geom_type === 'polygon' ? 'Dessiner un polygone' : "
editionService.geom_type === 'linestring' ? 'Dessiner une ligne' : >
'Dessiner un point' <img
" v-if="editionService.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
alt="line"
> >
<img <img
v-if="editionService.geom_type === 'linestring'" v-if="editionService.geom_type === 'point'"
class="list-image-type" class="list-image-type"
src="@/assets/img/line.png" src="@/assets/img/marker.png"
> alt="marker"
<img >
v-if="editionService.geom_type === 'point'" <img
class="list-image-type" v-if="editionService.geom_type === 'polygon'"
src="@/assets/img/marker.png" class="list-image-type"
> src="@/assets/img/polygon.png"
<img alt="polygon"
v-if="editionService.geom_type === 'polygon'" >
class="list-image-type" </a>
src="@/assets/img/polygon.png"
> <a
</a> v-else
</div> :class="{ active: isEditing }"
</div> :title="`Modifier ${toolbarEditGeomTitle}`"
<div v-if="!showDrawTool"> @click="toggleEdition"
<div class="leaflet-bar"> >
<a @click="update"> <i class="edit outline icon" />
<i class="edit outline icon" /> <span class="sr-only">Modifier {{ toolbarEditGeomTitle }}</span>
<span class="sr-only">Modifier l'objet</span></a> </a>
<a @click="deleteObj">
<i class="trash alternate outline icon" /> <a
<span class="sr-only">Supprimer l'objet</span></a> v-if="noExistingFeature || isEditing"
</div> :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>
</div> </div>
</template> </template>
...@@ -45,81 +66,146 @@ ...@@ -45,81 +66,146 @@
import editionService from '@/services/edition-service'; import editionService from '@/services/edition-service';
export default { export default {
name: 'EditingToolbar', name: 'EditingToolbar',
props: {
map: {
type: Object,
default: null,
},
},
data() { data() {
return { return {
editionService: editionService, editionService: editionService,
isEditing: false,
isSnapEnabled: false,
}; };
}, },
computed: { computed: {
showDrawTool() { noExistingFeature() {
return this.editionService && this.editionService.editing_feature === undefined; return this.editionService?.editing_feature === undefined;
} },
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}`;
}
}, },
mounted() {
},
methods: { methods: {
update(){ toggleEdition() {
editionService.activeUpdateFeature(); this.isEditing = !this.isEditing;
editionService.activeUpdateFeature(this.isEditing);
}, },
deleteObj(){ deleteObj() {
editionService.activeDeleteFeature(); 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> </script>
<style scoped> <style lang="less" scoped>
.editionToolbar{ .editionToolbar{
position: absolute; position: absolute;
top: 80px; // each button have (more or less depends on borders) .5em space between
right: 5px; // zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
} top: calc(2em + 60px + 34px + 64px);
.leaflet-bar { right: 6px;
border: 2px solid rgba(0,0,0,.2); border: 2px solid rgba(0,0,0,.2);
border-radius: 4px;
background-clip: padding-box; background-clip: padding-box;
padding: 0; padding: 0;
border-radius: 2px; z-index: 9;
}
.leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
border-bottom: none;
}
.leaflet-bar a, .leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
} }
.leaflet-bar a { .leaflet-bar {
background-color: #fff;
width: 30px; a:first-child {
height: 30px; border-top-left-radius: 2px;
display: block; border-top-right-radius: 2px;
text-align: center; }
text-decoration: none; a:last-child {
color: black; border-bottom-left-radius: 2px;
} border-bottom-right-radius: 2px;
.leaflet-bar a > i { border-bottom: none;
margin: 0; }
vertical-align: middle;
} a, .leaflet-control-layers-toggle {
.list-image-type { background-position: 50% 50%;
height: 20px; background-repeat: no-repeat;
vertical-align: middle; display: block;
margin: 5px 0 5px 0; }
}
.leaflet-bar a:hover { a {
cursor: pointer; background-color: #fff;
background-color: #ebebeb; width: 30px;
} height: 30px;
.leaflet-bar a:focus { display: block;
background-color: #ebebeb; text-align: center;
text-decoration: none;
color: black;
i {
margin: 0;
vertical-align: middle;
&.magnet {
transform: rotate(90deg);
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
opacity: .85;
}
}
}
.active {
background-color: rgba(255, 145, 0, 0.904);
color: #fff;
i {
font-weight: bold;
}
img {
filter: invert(100%) sepia(0%) saturate(7500%) hue-rotate(282deg) brightness(105%) contrast(100%);
}
}
.list-image-type {
height: 20px;
vertical-align: middle;
margin: 5px 0 5px 0;
}
a:hover {
cursor: pointer !important;
background-color: #ebebeb !important;
}
a:focus {
background-color: #ebebeb !important;
}
} }
</style> </style>
<template> <template>
<div <div>
:class="{ expanded: isExpanded }" <div
class="geocoder-container" id="geocoder-container"
> :class="{ isExpanded }"
<button
class="button-geocoder"
@click="isExpanded = !isExpanded"
> >
<i class="search icon" /> <button
</button> class="button-geocoder"
<Multiselect title="Rechercher une adresse"
v-if="isExpanded" type="button"
v-model="selection" @click="toggleGeocoder"
class="expanded-geocoder" >
:options="addresses" <i class="search icon" />
:options-limit="5" </button>
:allow-empty="true" </div>
track-by="label" <div
label="label" id="geocoder-select-container"
:show-labels="false" :class="{ isExpanded }"
: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"
> >
<template <!-- 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 -->
slot="option" <!-- otherwise approximate results are not shown (cannot explain why though) -->
slot-scope="props" <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"> <template
<span class="option__title">{{ props.option.label }}</span> slot="option"
</div> slot-scope="props"
</template>
<template slot="clear">
<div
v-if="selection"
class="multiselect__clear"
@click.prevent.stop="selection = null"
> >
<i class="close icon" /> <div class="option__desc">
</div> <span class="option__title">{{ props.option.label }}</span>
</template> </div>
<span slot="noResult"> </template>
Aucun résultat. <template slot="clear">
</span> <div
<span slot="noOptions"> v-if="selection"
Saisissez les premiers caractères ... class="multiselect__clear"
</span> @click.prevent.stop="selection = null"
</Multiselect> >
<div style="display: none;"> <i class="close icon" />
<div </div>
id="marker" </template>
title="Marker" <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>
</div> </div>
</template> </template>
...@@ -76,14 +90,18 @@ const apiAdressAxios = axios.create({ ...@@ -76,14 +90,18 @@ const apiAdressAxios = axios.create({
baseURL: 'https://api-adresse.data.gouv.fr', baseURL: 'https://api-adresse.data.gouv.fr',
withCredentials: false, withCredentials: false,
}); });
export default { export default {
name: 'Geocoder', name: 'Geocoder',
components: { components: {
Multiselect Multiselect
}, },
data() { data() {
return { return {
loading: false, loading: false,
limit: 10,
selection: null, selection: null,
text: null, text: null,
selectedAddress: null, selectedAddress: null,
...@@ -93,19 +111,36 @@ export default { ...@@ -93,19 +111,36 @@ export default {
isExpanded: false isExpanded: false
}; };
}, },
mounted() { mounted() {
this.addressTextChange = new Subject(); this.addressTextChange = new Subject();
this.addressTextChange.pipe(debounceTime(200)).subscribe((res) => this.getAddresses(res)); this.addressTextChange.pipe(debounceTime(200)).subscribe((res) => this.getAddresses(res));
}, },
methods: { methods: {
toggleGeocoder() {
this.isExpanded = !this.isExpanded;
if (this.isExpanded) {
this.retrievePreviousPlaces();
this.$nextTick(()=> this.$refs.multiselect.activate());
}
},
getAddresses(query){ getAddresses(query){
const limit = 5; if (query.length < 3) {
apiAdressAxios.get(`https://api-adresse.data.gouv.fr/search/?q=${query}&limit=${limit}`) 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) => { .then((retour) => {
this.resultats = retour.data.features; this.resultats = retour.data.features;
this.addresses = retour.data.features.map(x=>x.properties); this.addresses = retour.data.features.map(x=>x.properties);
}); });
}, },
selectAddresse(event) { selectAddresse(event) {
this.selectedAddress = event; this.selectedAddress = event;
if (this.selectedAddress !== null && this.selectedAddress.geometry) { if (this.selectedAddress !== null && this.selectedAddress.geometry) {
...@@ -118,38 +153,80 @@ export default { ...@@ -118,38 +153,80 @@ export default {
} else if (type === 'locality') { } else if (type === 'locality') {
zoomlevel = 16; zoomlevel = 16;
} }
// On fait le zoom
mapService.zoomTo(this.selectedAddress.geometry.coordinates, zoomlevel);
// On ajoute un point pour localiser la ville // 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) { search(text) {
this.text = text; this.text = text;
this.addressTextChange.next(this.text); this.addressTextChange.next(this.text);
}, },
select(e) { select(e) {
this.selectAddresse(this.resultats.find(x=>x.properties.label === e.label)); this.selectAddresse(this.resultats.find(x=>x.properties.label === e.label));
this.$emit('select', e); this.$emit('select', e);
}, },
close() { close() {
this.$emit('close', this.selection); 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> </script>
<style scoped lang="less"> <style lang="less">
.geocoder-container { #marker {
width: 14px;
height: 14px;
border: 2px solid #fff;
border-radius: 7px;
background-color: #3399CC;
}
#geocoder-container {
position: absolute; position: absolute;
right: 0.5em; right: 6px;
top: calc(1em + 60px); // 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; pointer-events: auto;
z-index: 50000; z-index: 999;
border: 2px solid rgba(0,0,0,.2); border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box; background-clip: padding-box;
padding: 0; padding: 0;
border-radius: 2px; border-radius: 4px;
display: flex; display: flex;
.button-geocoder { .button-geocoder {
...@@ -159,8 +236,8 @@ export default { ...@@ -159,8 +236,8 @@ export default {
text-align: center; text-align: center;
background-color: #fff; background-color: #fff;
color: rgb(39, 39, 39); color: rgb(39, 39, 39);
width: 28px; width: 30px;
height: 28px; height: 30px;
font: 700 18px Lucida Console,Monaco,monospace; font: 700 18px Lucida Console,Monaco,monospace;
border-radius: 2px; border-radius: 2px;
line-height: 1.15; line-height: 1.15;
...@@ -178,21 +255,64 @@ export default { ...@@ -178,21 +255,64 @@ export default {
.expanded-geocoder { .expanded-geocoder {
max-width: 400px; max-width: 400px;
} }
}
.expanded { &&.isExpanded {
.button-geocoder { .button-geocoder {
height: 40px; /*height: 41px;*/
color: rgb(99, 99, 99); color: rgb(99, 99, 99);
border-radius: 2px 0 0 2px;
}
} }
} }
#marker { #geocoder-select-container{
width: 20px; position: absolute;
height: 20px; right: 46px;
border: 1px solid rgb(136, 66, 0); // each button have (more or less depends on borders) .5em space between
border-radius: 10px; // zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
background-color: rgb(201, 114, 15); top: calc(1.6em + 60px + 34px + 34px - 4px);
opacity: 0.7; 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> </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
<template>
<div class="basemaps-items ui accordion styled">
<div
:class="['basemap-item title', { active }]"
@click="$emit('activateGroup', basemap.id)"
>
<i
class="map outline fitted icon"
aria-hidden="true"
/>
<span>{{ basemap.title }}</span>
</div>
<div
v-if="queryableLayersOptions.length > 0 && active"
:id="`queryable-layers-selector-${basemap.id}`"
>
<strong>Couche requêtable</strong>
<Dropdown
:options="queryableLayersOptions"
:selected="selectedQueryLayer"
:search="true"
@update:selection="setQueryLayer($event)"
/>
</div>
<div
:id="`list-${basemap.id}`"
:class="['content', { active }]"
:data-basemap-index="basemap.id"
>
<div
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"
>
<!-- layer id is used for retrieving layer when changing order -->
<p class="layer-handle-sort">
<i
class="th icon"
aria-hidden="true"
/>
{{ layer.title }}
</p>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
type="range"
min="0"
max="1"
:value="layer.opacity"
step="0.01"
@change="updateOpacity($event, layer)"
><output class="range-output-bubble">{{
getOpacity(layer.opacity)
}}</output>
</div>
<div class="ui divider" />
</div>
</div>
</div>
</template>
<script>
import Sortable from 'sortablejs';
import Dropdown from '@/components/Dropdown.vue';
import mapService from '@/services/map-service';
export default {
name: 'LayerSelector',
components: {
Dropdown,
},
props: {
basemap: {
type: Object,
default: null,
},
active: {
type: Boolean,
default: false,
},
selectedQueryLayer: {
type: String,
default: ''
}
},
data() {
return {
sortable: null,
};
},
computed: {
queryableLayersOptions() {
const queryableLayers = this.basemap.layers.filter((l) => l.queryable === true);
return queryableLayers.map((x) => {
return {
name: x.title,
value: x,
};
});
},
},
mounted() {
setTimeout(this.initSortable.bind(this), 1000);
},
methods: {
isQueryable(baseMap) {
const queryableLayer = baseMap.layers.filter((l) => l.queryable === true);
return queryableLayer.length > 0;
},
initSortable() {
const element = document.getElementById(`list-${this.basemap.id}`);
if (element) {
this.sortable = new Sortable(element, {
animation: 150,
handle: '.layer-handle-sort', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: () => this.$emit('onlayerMove'),
});
} else {
console.error(`list-${this.basemap.id} not found in dom`);
}
},
setQueryLayer(layer) {
this.$emit('onQueryLayerChange', layer.name);
},
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
updateOpacity(event, layer) {
const layerId = layer.id;
const opacity = event.target.value;
mapService.updateOpacity(layerId, opacity);
this.$emit('onOpacityUpdate', { layerId, opacity });
},
}
};
</script>
<style>
.basemap-item.title > i {
margin-left: -1em !important;
}
.basemap-item.title > span {
margin-left: .5em;
}
.queryable-layers-dropdown {
margin-bottom: 1em;
}
.queryable-layers-dropdown > label {
font-weight: bold;
}
</style>
...@@ -3,10 +3,9 @@ ...@@ -3,10 +3,9 @@
v-if="isOnline" v-if="isOnline"
:class="['sidebar-container', { expanded }]" :class="['sidebar-container', { expanded }]"
> >
<!-- <div class="sidebar-layers"></div> -->
<div <div
class="layers-icon" class="layers-icon"
@click="expanded = !expanded" @click="toggleSidebar()"
> >
<!-- // ! svg point d'interrogation pas accepté par linter --> <!-- // ! svg point d'interrogation pas accepté par linter -->
<!-- <?xml version="1.0" encoding="iso-8859-1"?> --> <!-- <?xml version="1.0" encoding="iso-8859-1"?> -->
...@@ -45,97 +44,42 @@ ...@@ -45,97 +44,42 @@
<div class="basemaps-title"> <div class="basemaps-title">
<h4> <h4>
Fonds cartographiques Fonds cartographiques
<!-- <span data-tooltip="Il est possible pour chaque fond cartographique de modifier l'ordre des couches"
data-position="bottom left">
<i class="question circle outline icon"></em>
</span> -->
</h4> </h4>
</div> </div>
<LayerSelector
<div
v-for="basemap in baseMaps" v-for="basemap in baseMaps"
:key="`list-${basemap.id}`" :key="`list-${basemap.id}`"
class="basemaps-items ui accordion styled" :basemap="basemap"
> :selected-query-layer="selectedQueryLayer"
<div :active="basemap.active"
:class="{ active: isActive(basemap) }" @addLayers="addLayers"
class="basemap-item title" @activateGroup="activateGroup"
@click="activateGroup(basemap)" @onlayerMove="onlayerMove"
> @onOpacityUpdate="onOpacityUpdate"
{{ basemap.title }} @onQueryLayerChange="onQueryLayerChange"
</div> />
<div
v-if="isQueryable(basemap)"
:id="`queryable-layers-selector-${basemap.id}`"
>
<strong>Couche requêtable</strong>
<Dropdown
:options="getQueryableLayers(basemap)"
:selected="selectedQueryLayer"
:search="true"
@update:selection="onQueryLayerChange($event)"
/>
</div>
<div
:id="`list-${basemap.id}`"
:class="{ active: isActive(basemap) }"
class="content"
:data-basemap-index="basemap.id"
>
<div
v-for="(layer, index) in basemap.layers"
:key="basemap.id + '-' + layer.id + '-' + index"
class="layer-item transition visible item list-group-item"
:data-id="layer.id"
>
<p class="layer-handle-sort">
<i
class="th icon"
aria-hidden="true"
/>
{{ layer.title }}
</p>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
type="range"
min="0"
max="1"
:value="layer.opacity"
step="0.01"
@change="updateOpacity($event, layer)"
><output class="range-output-bubble">{{
getOpacity(layer.opacity)
}}</output>
</div>
<div class="ui divider" />
</div>
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import Sortable from 'sortablejs';
import Dropdown from '@/components/Dropdown.vue'; import LayerSelector from '@/components/Map/LayerSelector.vue';
import mapService from '@/services/map-service'; import mapService from '@/services/map-service';
export default { export default {
name: 'SidebarLayers', name: 'SidebarLayers',
components: { components: {
Dropdown, LayerSelector
}, },
data() { data() {
return { return {
selectedQueryLayer: null,
activeBasemap: null,
baseMaps: [], baseMaps: [],
expanded: false, expanded: false,
sortable: null projectSlug: this.$route.params.slug,
selectedQueryLayer: '',
}; };
}, },
...@@ -144,192 +88,143 @@ export default { ...@@ -144,192 +88,143 @@ export default {
'isOnline', 'isOnline',
]), ]),
...mapState('map', [ ...mapState('map', [
'availableLayers' '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() { mounted() {
this.baseMaps = this.$store.state.map.basemaps; this.initBasemaps();
const project = this.$route.params.slug;
const mapOptions =
JSON.parse(localStorage.getItem('geocontrib-map-options')) || {};
if (mapOptions && mapOptions[project]) {
// If already in the storage, we need to check if the admin did some
// modification in the basemaps on the server side. The rule is: if one layer has been added
// or deleted in the server, then we reset the localstorage.
const baseMapsFromLocalstorage = mapOptions[project]['basemaps'];
const areChanges = this.areChangesInBasemaps(
this.baseMaps,
baseMapsFromLocalstorage
);
if (areChanges) {
mapOptions[project] = {
'map-options': this.baseMaps,
'current-basemap-index': 0,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
} else {
this.baseMaps = baseMapsFromLocalstorage;
}
}
if (this.baseMaps.length > 0) {
this.baseMaps[0].active = true;
this.activeBasemap = this.baseMaps[0];
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
);
}
setTimeout(this.initSortable.bind(this), 1000);
}, },
methods: { methods: {
isActive(basemap) { /**
return basemap.active !== undefined && basemap.active; * 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));
activateGroup(basemap) { // Retrieve map options from local storage
this.baseMaps.forEach((basemap) => (basemap.active = false)); const mapOptions = JSON.parse(localStorage.getItem('geocontrib-map-options')) || {};
basemap.active = true;
this.activeBasemap = basemap;
basemap.title += ' '; //weird!! Force refresh
this.addLayers(basemap);
let mapOptions = localStorage.getItem('geocontrib-map-options') || {};
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
const project = this.$route.params.slug;
mapOptions[project] = {
...mapOptions[project],
'current-basemap-index': basemap.id,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
},
updateOpacity(event, layer) { // Check if map options exist for the current project
mapService.updateOpacity(layer.id, event.target.value); if (mapOptions && mapOptions[this.projectSlug]) {
layer.opacity = event.target.value; // 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
);
getOpacity(opacity) { // If changes are detected, update local storage with the latest basemaps
return Math.round(parseFloat(opacity) * 100); 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;
}
}
onQueryLayerChange(layer) { if (this.baseMaps.length > 0) {
this.selectedQueryLayer = layer.name; // Set the first basemap as active by default
this.baseMaps[0].layers.find((l) => l.title === layer.name).query = true; this.baseMaps[0].active = true;
layer.query = true;
},
isQueryable(baseMap) { // Check if an active layer has been set previously by the user
const queryableLayer = baseMap.layers.filter((l) => l.queryable === true); const activeBasemapId = mapOptions[this.projectSlug]
return queryableLayer.length > 0; ? mapOptions[this.projectSlug]['current-basemap-index']
}, : null;
onlayerMove() { // Ensure the active layer ID exists in the current basemaps in case id does not exist anymore or has changed
// Get the names of the current layers in order. if (activeBasemapId >= 0 && this.baseMaps.some(bm => bm.id === activeBasemapId)) {
const currentLayersNamesInOrder = Array.from( this.baseMaps.forEach((baseMap) => {
document.getElementsByClassName('layer-item transition visible') // Set the active layer by matching the ID and setting the active property to true
).map((el) => el.children[0].innerText); if (baseMap.id === mapOptions[this.projectSlug]['current-basemap-index']) {
// Create an array to put the layers in order. baseMap.active = true;
let movedLayers = []; } else {
// Reset others to false to prevent errors from incorrect mapOptions
baseMap.active = false;
}
});
}
for (const layerName of currentLayersNamesInOrder) { // Add layers for the active basemap
movedLayers.push( this.addLayers(this.activeBasemap);
this.activeBasemap.layers.filter((el) => el.title === layerName)[0] 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
); );
} }
// Remove existing layers undefined
movedLayers = movedLayers.filter(function (x) {
return x !== undefined;
});
const eventOrder = new CustomEvent('change-layers-order', {
detail: {
layers: movedLayers,
},
});
document.dispatchEvent(eventOrder);
// Save the basemaps options into the localstorage
this.setLocalstorageMapOptions(this.baseMaps);
}, },
setLocalstorageMapOptions(basemaps) { toggleSidebar(value) {
let mapOptions = localStorage.getItem('geocontrib-map-options') || {}; this.expanded = value !== undefined ? value : !this.expanded;
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
const project = this.$route.params.slug;
mapOptions[project] = {
...mapOptions[project],
basemaps: basemaps,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
}, },
initSortable() {
this.baseMaps.forEach((basemap) => {
const element = document.getElementById(`list-${basemap.id}`);
if (element) {
this. sortable = new Sortable(element, {
animation: 150,
handle: '.layer-handle-sort', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: this.onlayerMove.bind(this),
});
} else {
console.error(`list-${basemap.id} not found in dom`);
}
});
},
// Check if there are changes in the basemaps settings. Changes are detected if: // Check if there are changes in the basemaps settings. Changes are detected if:
// - one basemap has been added or deleted // - one basemap has been added or deleted
// - one layer has been added or deleted to a basemap // - 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 isSameBasemaps = false;
let isSameLayers = true; let isSameLayers = true;
let isSameTitles = true; let isSameTitles = true;
// Compare the length and the id values of the basemaps // Compare the length and the id values of the basemaps
const idBasemapsServer = basemapFromServer.map((b) => b.id).sort(); const idBasemapsServer = basemapFromServer.map((b) => b.id).sort();
const idBasemapsLocalstorage = basemapFromLocalstorage.length const idBasemapsLocalstorage = basemapFromLocalstorage.map((b) => b.id).sort() || {};
? basemapFromLocalstorage.map((b) => b.id).sort()
: {};
isSameBasemaps = isSameBasemaps =
idBasemapsServer.length === idBasemapsLocalstorage.length && idBasemapsServer.length === idBasemapsLocalstorage.length &&
idBasemapsServer.every( idBasemapsServer.every(
(value, index) => value === idBasemapsLocalstorage[index] (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: { outer_block: {
for (const basemapServer of basemapFromServer) { // For each basemap from the server, compare the length and id values of the layers
const idLayersServer = basemapServer.layers.map((b) => b.id).sort(); for (const serverBasemap of basemapFromServer) {
if (basemapFromLocalstorage.length) { // loop over basemaps from localStorage and check if layers id & queryable setting match with the layers from the server
for (const basemapLocalstorage of basemapFromLocalstorage) { // 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
if (basemapServer.id === basemapLocalstorage.id) { for (const localBasemap of basemapFromLocalstorage) {
const idLayersLocalstorage = basemapLocalstorage.layers if (serverBasemap.id === localBasemap.id) {
.map((b) => b.id) isSameLayers =
.sort(); serverBasemap.layers.length === localBasemap.layers.length &&
isSameLayers = serverBasemap.layers.every(
idLayersServer.length === idLayersLocalstorage.length && (layer, index) => layer.id === localBasemap.layers[index].id &&
idLayersServer.every( layer.queryable === localBasemap.layers[index].queryable
(value, index) => value === idLayersLocalstorage[index] );
); if (!isSameLayers) {
if (!isSameLayers) { break outer_block;
break outer_block;
}
} }
} }
} }
...@@ -338,9 +233,7 @@ export default { ...@@ -338,9 +233,7 @@ export default {
const titlesBasemapsServer = basemapFromServer const titlesBasemapsServer = basemapFromServer
.map((b) => b.title) .map((b) => b.title)
.sort(); .sort();
const titlesBasemapsLocalstorage = basemapFromLocalstorage.length const titlesBasemapsLocalstorage = basemapFromLocalstorage.map((b) => b.title).sort() || {};
? basemapFromLocalstorage.map((b) => b.title).sort()
: {};
isSameTitles = titlesBasemapsServer.every( isSameTitles = titlesBasemapsServer.every(
(title, index) => title === titlesBasemapsLocalstorage[index] (title, index) => title === titlesBasemapsLocalstorage[index]
...@@ -353,19 +246,9 @@ export default { ...@@ -353,19 +246,9 @@ export default {
return !(isSameBasemaps && isSameLayers && isSameTitles); return !(isSameBasemaps && isSameLayers && isSameTitles);
}, },
getQueryableLayers(baseMap) {
const queryableLayer = baseMap.layers.filter((l) => l.queryable === true);
return queryableLayer.map((x) => {
return {
name: x.title,
value: x,
};
});
},
addLayers(baseMap) { addLayers(baseMap) {
baseMap.layers.forEach((layer) => { baseMap.layers.forEach((layer) => {
var layerOptions = this.availableLayers.find((l) => l.id === layer.id); const layerOptions = this.availableLayers.find((l) => l.id === layer.id);
layer = Object.assign(layer, layerOptions); layer = Object.assign(layer, layerOptions);
layer.options.basemapId = baseMap.id; layer.options.basemapId = baseMap.id;
}); });
...@@ -374,16 +257,96 @@ export default { ...@@ -374,16 +257,96 @@ export default {
// Slice is done because reverse() changes the original array, so we make a copy first // Slice is done because reverse() changes the original array, so we make a copy first
mapService.addLayers(baseMap.layers.slice().reverse(), null, null, null,); mapService.addLayers(baseMap.layers.slice().reverse(), null, null, null,);
}, },
activateGroup(basemapId) {
//* 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
this.setLocalstorageMapOptions({ 'current-basemap-index': basemapId });
},
onlayerMove() {
// 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 original layers in the same order.
let movedLayers = [];
for (const layerId of currentLayersIdInOrder) {
movedLayers.push(
this.activeBasemap.layers.find((el) => el.id === layerId)
);
}
// Remove existing layers undefined
movedLayers = movedLayers.filter(function (x) {
return x !== undefined;
});
const eventOrder = new CustomEvent('change-layers-order', {
detail: {
layers: movedLayers,
},
});
document.dispatchEvent(eventOrder);
// 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) : {};
mapOptions[this.projectSlug] = {
...mapOptions[this.projectSlug],
...newOptionObj,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
},
}, },
}; };
</script> </script>
<style>
@import "../../assets/styles/sidebar-layers.css";
.queryable-layers-dropdown {
margin-bottom: 1em;
}
.queryable-layers-dropdown > label {
font-weight: bold;
}
</style>
<template>
<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="[headerIcon, 'circle icon']"
aria-hidden="true"
/>
{{ message.header || 'Informations' }}
</div>
<ul class="list">
{{
message.comment
}}
</ul>
</div>
</div>
</li>
</template>
<script>
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 scoped>
.list-container{
list-style: none;
width: 100%;
height: 0;
position: relative;
cursor: pointer;
overflow: hidden;
transition: all 0.6s ease-out;
}
.list-container.show{
height: 7.5em;
}
@media screen and (min-width: 726px) {
.list-container.show{
height: 6em;
}
}
.list-container.show:not(:first-child){
margin-top: 10px;
}
.list-container .list-item{
padding: .5rem 0;
width: 100%;
position: absolute;
opacity: 0;
top: 0;
left: 0;
transition: all 0.6s ease-out;
}
.list-container .list-item.show{
opacity: 1;
}
ul.list{
overflow: auto;
height: 3.5em;
margin-bottom: .5em !important;
}
@media screen and (min-width: 726px) {
ul.list{
height: 2.2em;
}
}
.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 {
cursor: pointer;
position: absolute;
margin: 0em;
top: 0.78575em;
right: 0.5em;
opacity: 0.7;
-webkit-transition: opacity 0.1s ease;
transition: opacity 0.1s ease;
}
</style>
\ No newline at end of file
<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