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