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 3757 additions and 172 deletions
<template>
<div class="ui mini modal">
<i class="close icon"></i>
<i
class="close icon"
aria-hidden="true"
/>
<div class="content">
<h3>Importer une image géoréférencée</h3>
<form id="form-geo-image" class="ui form" enctype="multipart/form-data">
<form
id="form-geo-image"
class="ui form"
enctype="multipart/form-data"
>
{% csrf_token %}
<p>
Attention, si vous avez déjà saisi une géométrie, celle issue de
......@@ -13,19 +20,28 @@
<div class="field">
<label>Image (png ou jpeg)</label>
<label class="ui icon button" for="image_file">
<i class="file icon"></i>
<label
class="ui icon button"
for="image_file"
>
<i
class="file icon"
aria-hidden="true"
/>
<span class="label">Sélectionner une image ...</span>
</label>
<input
id="image_file"
type="file"
accept="image/jpeg, image/png"
style="display: none"
name="image_file"
class="image_file"
id="image_file"
>
<p
class="error-message"
style="color: red"
/>
<p class="error-message" style="color: red"></p>
</div>
<button
......@@ -34,7 +50,10 @@
class="ui positive right labeled icon button"
>
Importer
<i class="checkmark icon"></i>
<i
class="checkmark icon"
aria-hidden="true"
/>
</button>
</form>
</div>
......@@ -43,6 +62,6 @@
<script>
export default {
name: "Feature_edit_modal"
}
</script>
\ No newline at end of file
name: 'FeatureEditModal'
};
</script>
<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>
......@@ -3,20 +3,31 @@
<h4>
Liaison
<button
@click="remove_linked_formset"
class="ui small compact right floated icon button remove-formset"
type="button"
@click="remove_linked_formset"
>
<i class="ui times icon"></i>
<i
class="ui times icon"
aria-hidden="true"
/>
</button>
</h4>
<ul id="errorlist-links" class="errorlist">
<li v-for="error in form.errors" :key="error" v-html="error"></li>
<ul
id="errorlist-links"
class="errorlist"
>
<li
v-for="error in form.errors"
:key="error"
>
{{ error }}
</li>
</ul>
<div class="visible-fields">
<div class="two fields">
<div class="required field">
<label for="form.relation_type.id_for_label">{{
<label :for="form.relation_type.id_for_label">{{
form.relation_type.label
}}</label>
<Dropdown
......@@ -27,13 +38,13 @@
{{ form.relation_type.errors }}
</div>
<div class="required field">
<label for="form.feature_to.id_for_label">{{
<label :for="form.feature_to.id_for_label">{{
form.feature_to.label
}}</label>
<Dropdown
:options="featureOptions"
:selected="selected_feature_to"
:selection.sync="selected_feature_to"
<SearchFeature
:current-selection="linkedForm"
@select="selectFeatureTo"
@close="selectFeatureTo"
/>
{{ form.feature_to.errors }}
</div>
......@@ -43,15 +54,22 @@
</template>
<script>
import Dropdown from "@/components/Dropdown.vue";
import Dropdown from '@/components/Dropdown.vue';
import SearchFeature from '@/components/SearchFeature.vue';
export default {
name: "FeatureLinkedForm",
props: ["linkedForm", "features"],
name: 'FeatureLinkedForm',
components: {
Dropdown,
SearchFeature
},
props: {
linkedForm: {
type: Object,
default: null,
}
},
data() {
......@@ -60,55 +78,35 @@ export default {
errors: null,
relation_type: {
errors: null,
id_for_label: "relation_type",
html_name: "relation_type",
label: "Type de liaison",
id_for_label: 'relation_type',
html_name: 'relation_type',
label: 'Type de liaison',
value: {
name: "Doublon",
value: "doublon",
name: 'Doublon',
value: 'doublon',
},
},
feature_to: {
errors: null,
id_for_label: "feature_to",
id_for_label: 'feature_to',
field: {
max_length: 30,
},
html_name: "feature_to",
label: "Signalement lié",
value: {
name: "",
value: "",
},
html_name: 'feature_to',
label: 'Signalement lié',
value: '',
},
},
relationTypeChoices: [
{ name: "Doublon", value: "doublon" },
{ name: "Remplace", value: "remplace" },
{ name: "Est remplacé par", value: "est_remplace_par" },
{ name: "Dépend de", value: "depend_de" },
{ name: 'Doublon', value: 'doublon' },
{ name: 'Remplace', value: 'remplace' },
{ name: 'Est remplacé par', value: 'est_remplace_par' },
{ name: 'Dépend de', value: 'depend_de' },
],
};
},
computed: {
featureOptions: function () {
return this.features
.filter(
(el) =>
el.feature_type.slug === this.$route.params.slug_type_signal && //* filter only for the same feature
el.feature_id !== this.$route.params.slug_signal //* filter out current feature
)
.map((el) => {
return {
name: `${el.title} (${el.display_creator} - ${this.formatDate(
el.created_on
)})`,
value: el.feature_id,
};
});
},
selected_relation_type: {
// getter
get() {
......@@ -119,87 +117,54 @@ export default {
this.form.relation_type.value = newValue;
this.updateStore();
},
},
selected_feature_to: {
// getter
get() {
return this.form.feature_to.value.name;
},
// setter
set(newValue) {
this.form.feature_to.value = newValue;
this.updateStore();
},
},
}
},
watch: {
featureOptions(newValue) {
if (newValue) {
this.getExistingFeature_to(newValue);
}
},
mounted() {
if (this.linkedForm.relation_type) {
this.form.relation_type.value.name = this.linkedForm.relation_type_display;
this.form.relation_type.value.value = this.linkedForm.relation_type;
}
if (this.linkedForm.feature_to) {
this.form.feature_to.value = this.linkedForm.feature_to.feature_id;
}
},
methods: {
formatDate(value) {
let date = new Date(value);
date = date.toLocaleString().replace(",", "");
return date.substr(0, date.length - 3); //* quick & dirty way to remove seconds from date
remove_linked_formset() {
this.$store.commit('feature/REMOVE_LINKED_FORM', this.linkedForm.dataKey);
},
remove_linked_formset() {
this.$store.commit("feature/REMOVE_LINKED_FORM", this.linkedForm.dataKey);
selectFeatureTo(featureId) {
if (featureId) {
this.form.feature_to.value = featureId;
this.updateStore();
}
},
updateStore() {
this.$store.commit("feature/UPDATE_LINKED_FORM", {
this.$store.commit('feature/UPDATE_LINKED_FORM', {
dataKey: this.linkedForm.dataKey,
relation_type: this.form.relation_type.value.value,
feature_to: {
feature_id: this.form.feature_to.value.value,
feature_id: this.form.feature_to.value,
},
});
},
checkForm() {
if (this.form.feature_to.value === "") {
if (this.form.feature_to.value === '') {
this.form.errors = [
"<strong>Choisir un signalement lié</strong><br/> Pourriez-vous choisir un signalement pour la nouvelle liaison ?",
'Veuillez choisir un signalement pour la nouvelle liaison.',
];
document
.getElementById("errorlist-links")
.scrollIntoView({ block: "start", inline: "nearest" });
.getElementById('errorlist-links')
.scrollIntoView({ block: 'start', inline: 'nearest' });
return false;
}
this.form.errors = [];
return true;
},
getExistingFeature_to(featureOptions) {
const feature_to = featureOptions.find(
(el) => el.value === this.linkedForm.feature_to.feature_id
);
if (feature_to) {
this.form.feature_to.value = feature_to;
}
},
getExistingRelation_type() {
const relation_type = this.relationTypeChoices.find(
(el) => el.value === this.linkedForm.relation_type
);
if (relation_type) {
this.form.relation_type.value = relation_type;
}
},
},
mounted() {
if (this.linkedForm.relation_type) {
this.getExistingRelation_type();
}
},
};
</script>
\ No newline at end of file
</script>
<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="custFormId"
:class="['ui teal segment pers-field', { hasErrors }]"
>
<div class="custom-field-header">
<h4>
Champ personnalisé
</h4>
<div class="top-right">
<div
v-if="(form.label.value || form.name.value) && selectedFieldType !== 'Booléen'"
class="ui checkbox"
>
<input
v-model="form.is_mandatory.value"
type="checkbox"
:name="form.html_name"
@change="updateStore"
>
<label :for="form.html_name">Champ obligatoire
<span v-if="form.conditional_field_config.value">si actif</span>
</label>
</div>
<button
class="ui small compact right floated icon button remove-field"
type="button"
@click="removeCustomForm()"
>
<i
class="ui times icon"
aria-hidden="true"
/>
</button>
</div>
</div>
<div class="visible-fields">
<div class="two fields">
<div class="required field">
<label :for="form.label.id_for_label">{{ form.label.label }}</label>
<input
:id="form.label.id_for_label"
v-model="form.label.value"
type="text"
required
:maxlength="form.label.field.max_length"
:name="form.label.html_name"
@input="updateStore"
>
<small>{{ form.label.help_text }}</small>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.label.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label :for="form.name.id_for_label">{{ form.name.label }}</label>
<input
:id="form.name.id_for_label"
v-model="form.name.value"
type="text"
required
:maxlength="form.name.field.max_length"
:name="form.name.html_name"
@input="updateStore"
>
<small>{{ form.name.help_text }}</small>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.name.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
</div>
<div class="three fields">
<div class="required field">
<label :for="form.position.id_for_label">{{
form.position.label
}}</label>
<div class="ui input">
<input
:id="form.position.id_for_label"
v-model="form.position.value"
type="number"
:min="form.position.field.min_value"
:name="form.position.html_name"
@change="updateStore"
>
</div>
<small>{{ form.position.help_text }}</small>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.position.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div
id="field_type"
class="required field"
>
<label :for="form.field_type.id_for_label">{{
form.field_type.label
}}</label>
<Dropdown
:disabled="!form.label.value || !form.name.value"
:options="customFieldTypeChoices"
:selected="selectedFieldType"
:selection.sync="selectedFieldType"
/>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.field_type.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div
v-if="selectedFieldType === 'Liste de valeurs pré-enregistrées'"
class="field required"
data-test="prerecorded-list-option"
>
<label>{{
form.options.label
}}</label>
<Dropdown
:options="preRecordedLists"
:selected="arrayOption"
:selection.sync="arrayOption"
/>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.options.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
</div>
<div
v-if="selectedFieldType === 'Liste de valeurs' || selectedFieldType === 'Liste à choix multiples'"
class="field field-list-options required field"
>
<label :for="form.options.id_for_label">{{
form.options.label
}}</label>
<div>
<div :id="`list-options-${customForm.dataKey}`">
<div
v-for="(option, index) in form.options.value"
:id="option"
:key="`${option}-${index}`"
class="draggable-row"
>
<i
class="th icon grey"
aria-hidden="true"
/>
<input
:value="option"
type="text"
:maxlength="form.options.field.max_length"
:name="form.options.html_name"
class="options-field"
@change="updateOptionValue(index, $event)"
>
<i
class="trash icon grey"
@click="deleteOption(index)"
/>
</div>
</div>
<div class="ui buttons">
<a
class="ui compact small icon left floated button teal basic"
@click="addOption"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<span>&nbsp;Ajouter une option</span>
</a>
</div>
</div>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.options.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div class="conditional-blocks">
<div class="ui checkbox">
<input
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="form.conditional_field_config.value !== null && form.conditional_field_config.value !== undefined"
id="condition-field"
>
<h5>Condition d'apparition&nbsp;:</h5>
<CustomFormConditionalField
:custom-form="customForm"
:custom-forms="customForms"
:config="form.conditional_field_config.value"
@update:config="setConditionalFieldConfig($event)"
/>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.conditional_field_config.errors"
:key="error"
>
{{ error }}
</li>
</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: {
customForm: {
type: Object,
default: null,
},
selectedColorStyle: {
type: String,
default: null,
},
},
data() {
return {
customFieldTypeChoices,
form: {
is_mandatory: {
value: false,
html_name: 'mandatory-custom-field',
},
label: {
errors: [],
id_for_label: 'label',
label: 'Label',
help_text: 'Nom en language naturel du champ',
html_name: 'label',
field: {
max_length: 256,
},
value: null,
},
name: {
errors: [],
id_for_label: 'name',
label: 'Nom',
html_name: 'name',
help_text:
"Nom technique du champ tel qu'il apparaît dans la base de données ou dans l'export GeoJSON. Seuls les caractères alphanumériques et les traits d'union sont autorisés: a-z, A-Z, 0-9, _ et -)",
field: {
max_length: 128,
},
value: null,
},
position: {
errors: [],
id_for_label: 'position',
label: 'Position',
min_value: 0, // ! check if good values (not found)
html_name: 'position',
help_text:
"Numéro d'ordre du champ dans le formulaire de saisie du signalement",
field: {
max_length: 128, // ! check if good values (not found)
},
value: this.customForm.dataKey - 1,
},
field_type: {
errors: [],
id_for_label: 'field_type',
label: 'Type de champ',
html_name: 'field_type',
help_text: '',
field: {
max_length: 50,
},
value: 'boolean',
},
options: {
errors: [],
id_for_label: 'options',
label: 'Options',
html_name: 'options',
help_text: 'Valeurs possibles de ce champ, séparées par des virgules',
field: {
max_length: null,
},
value: [],
},
conditional_field_config: {
errors: [],
value: null,
},
forced_value_config: {
errors: [],
value: [],
}
},
hasErrors: false,
selectedPrerecordedList: null,
sortable: null
};
},
computed: {
...mapState('feature-type', [
'customForms',
'preRecordedLists'
]),
custFormId() {
return `custom_form-${this.form.position.value}`;
},
selectedFieldType: {
// getter
get() {
const currentFieldType = customFieldTypeChoices.find(
(el) => el.value === this.form.field_type.value
);
if (currentFieldType) {
this.$nextTick(() => { // to be executed after the fieldType is returned and the html generated
if (this.selectedFieldType === 'Liste de valeurs' || this.selectedFieldType === 'Liste à choix multiples') {
this.initSortable();
}
});
return currentFieldType.name;
}
return null;
},
// setter
set(newValue) {
if (newValue.value === 'pre_recorded_list') {
this.GET_PRERECORDED_LISTS();
}
this.form.field_type.value = newValue.value;
this.form = { ...this.form }; // ! quick & dirty fix for getter not updating because of Vue caveat https://vuejs.org/v2/guide/reactivity.html#For-Objects
// Vue.set(this.form.field_type, "value", newValue.value); // ? vue.set didn't work, maybe should flatten form ?
this.updateStore();
},
},
arrayOption: {
get() {
return this.form.options.value.join();
},
// * create an array, because backend expects an array
set(newValue) {
this.form.options.value = this.trimWhiteSpace(newValue).split(',');
if (!this.hasDuplicateOptions()) {
this.updateStore();
}
},
},
forcedValMaxDataKey() { // get the highest datakey value in forced value configs...
return this.form.forced_value_config.value.reduce( // ...used to increment new config datakey
(maxDataKey, currentConfig) => (maxDataKey > currentConfig.dataKey) ?
maxDataKey : currentConfig.dataKey, 1
);
},
},
mounted() {
//* add datas from store to state to avoid mutating directly store with v-model (not good practice), could have used computed with getter and setter as well
this.fillCustomFormData(this.customForm);
if (this.customForm.field_type === 'pre_recorded_list') {
this.GET_PRERECORDED_LISTS();
}
},
methods: {
...mapActions('feature-type', [
'GET_PRERECORDED_LISTS'
]),
hasDuplicateOptions() {
this.form.options.errors = [];
const isDup =
new Set(this.form.options.value).size !==
this.form.options.value.length;
if (isDup) {
this.form.options.errors = ['Veuillez saisir des valeurs différentes'];
return true;
}
return false;
},
fillCustomFormData(customFormData) {
for (const el in customFormData) {
if (el && this.form[el] && customFormData[el] !== undefined && customFormData[el] !== null) {
//* check if is an object, because data from api is a string, while import from django is an object
this.form[el].value = customFormData[el].value
? customFormData[el].value
: customFormData[el];
}
}
this.updateStore();
},
removeCustomForm() {
this.$store.commit(
'feature-type/REMOVE_CUSTOM_FORM',
this.customForm.dataKey
);
},
initSortable() {
this.sortable = new Sortable(document.getElementById(`list-options-${this.customForm.dataKey}`), {
animation: 150,
handle: '.draggable-row', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: this.updateOptionOrder,
filter: 'input', // prevent input field not clickable...
preventOnFilter: false, // ... on element inside sortable
});
},
setConditionalFieldConfig(config) {
this.form.conditional_field_config.value = config;
this.updateStore();
},
setConditionalCustomForm() {
if (this.form.conditional_field_config.value === null) {
// retrieve existing value when the user uncheck and check again, if no value defined create an empty object
this.form.conditional_field_config.value = this.customForm.conditional_field_config || {};
} else {
this.form.conditional_field_config.value = null;
}
this.updateStore();
},
addForcedValue() {
const newForcedValueConfig = {
dataKey: this.forcedValMaxDataKey + 1,
// forced value can already be set here, setting false for boolean because user won't select if he wants to set on false, thus avoiding the null value not passing form check
forcedValue: this.form.field_type.value === 'boolean' ? false : null
};
this.form.forced_value_config.value = [...this.form.forced_value_config.value, newForcedValueConfig];
},
setForcedValue(newConfig) {
this.form.forced_value_config.value = this.form.forced_value_config.value.map((config) => {
return config.dataKey === newConfig.dataKey ? newConfig : config;
});
this.updateStore();
},
deleteForcedValue(dataKey) {
this.form.forced_value_config.value = this.form.forced_value_config.value.filter(
(config) => config.dataKey !== dataKey
);
this.updateStore();
},
updateStore() {
const data = {
dataKey: this.customForm.dataKey,
is_mandatory: this.form.is_mandatory.value,
label: this.form.label.value,
name: this.form.name.value,
position: this.form.position.value,
field_type: this.form.field_type.value,
options: this.form.options.value,
conditional_field_config: this.form.conditional_field_config.value,
forced_value_config: this.form.forced_value_config.value,
};
this.$store.commit('feature-type/UPDATE_CUSTOM_FORM', data);
if (this.customForm.name === this.selectedColorStyle ) {
this.$emit('update', this.form.options.value);
}
// if there was any error, check if the update fixed it and update errors displayed
if (this.hasErrors) {
this.checkCustomForm(true);
}
},
updateOptionValue(index, e) {
this.form.options.value[index] = e.target.value;
if (!this.hasDuplicateOptions()) {
this.updateStore();
}
},
updateOptionOrder(e) {
const currentOptionsList = Array.from(e.target.childNodes).map((el) => el.id);
this.form.options.value = currentOptionsList;
this.updateStore();
},
addOption() {
this.form.options.value.push('');
},
deleteOption(index) {
this.form.options.value.splice(index, 1);
},
trimWhiteSpace(string) {
// TODO : supprimer les espaces pour chaque option au début et à la fin QUE à la validation
return string.replace(/\s*,\s*/gi, ',');
},
//* CHECKS *//
hasRegularCharacters(input) {
for (const char of input) {
if (!/[a-zA-Z0-9-_]/.test(char)) {
return false;
}
}
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.toLowerCase())
.filter((el) => el === this.form.name.value.toLowerCase());
return occurences.length === 1;
},
checkOptions() {
if (this.form.field_type.value === 'list') {
return this.form.options.value.length >= 2 && !this.form.options.value.includes('') ?
'' : 'Veuillez renseigner au moins 2 options.';
}
if (this.form.field_type.value === 'pre_recorded_list') {
return this.form.options.value.length === 1 ?
'' : 'Veuillez sélectionner une option.';
}
return '';
},
isConditionFilled(condition) {
if (condition) {
return condition.conditionField && condition.conditionValue !== null && condition.conditionValue !== undefined;
}
return true;
},
isForcedValueFilled() {
if (this.form.forced_value_config.value) {
for (const config of this.form.forced_value_config.value) {
if (!this.isConditionFilled(config) || config.forcedValue === null || config.forcedValue === undefined) {
return false;
}
}
}
return true;
},
checkCustomForm(noScroll) {
// reset errors to empty array
for (const element in this.form) {
if (this.form[element].errors) this.form[element].errors = [];
}
// check each form element
const optionError = this.checkOptions();
let isValid = true;
if (!this.form.label.value) {
//* vérifier que le label est renseigné
this.form.label.errors = ['Veuillez compléter ce champ.'];
isValid = false;
} else if (!this.form.name.value) {
//* vérifier que le nom est renseigné
this.form.name.errors = ['Veuillez compléter ce champ.'];
isValid = false;
} else if (!this.hasRegularCharacters(this.form.name.value)) {
//* vérifier qu'il n'y a pas de caractères spéciaux
this.form.name.errors = [
'Veuillez utiliser seulement les caratères autorisés.',
];
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 (optionError) {
//* s'il s'agit d'un type liste, vérifier que le champ option est bien renseigné
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();
}
return isValid;
},
},
};
</script>
<style lang="less" scoped>
.hasErrors {
border-color: red;
}
.errorlist {
margin: .5rem 0;
display: flex;
}
.custom-field-header {
display: flex;
align-items: center;
justify-content: space-between;
.top-right {
display: flex;
align-items: center;
.checkbox {
margin-right: 5rem;
}
span {
color: #666666;
}
}
}
i.icon.trash {
transition: color ease .3s;
}
i.icon.trash:hover {
cursor: pointer;
color: red !important;
}
.conditional-blocks {
margin: .5em 0;
h5 {
margin: 1em 0;
}
.inline {
display: flex;
align-items: center;
justify-content: space-between;
}
}
#condition-field {
padding-left: 2em;
}
.draggable-row {
display: flex;
align-items: baseline;
margin-bottom: 1em;
input {
margin: 0 .5em !important;
}
}
.segment { // keep custom form scrolled under the app header
scroll-margin-top: 5rem;
}
</style>
<template>
<router-link
v-if="featureType && featureType.slug"
:to="{
name: 'details-type-signalement',
params: { feature_type_slug: featureType.slug },
}"
class="feature-type-title"
:title="featureType.title"
>
<img
v-if="featureType.geom_type === 'point'"
class="list-image-type"
src="@/assets/img/marker.png"
alt="Géométrie point"
>
<img
v-if="featureType.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
alt="Géométrie ligne"
>
<img
v-if="featureType.geom_type === 'polygon'"
class="list-image-type"
src="@/assets/img/polygon.png"
alt="Géométrie polygone"
>
<img
v-if="featureType.geom_type === 'multipoint'"
class="list-image-type"
src="@/assets/img/multimarker.png"
alt="Géométrie multipoint"
>
<img
v-if="featureType.geom_type === 'multilinestring'"
class="list-image-type"
src="@/assets/img/multiline.png"
alt="Géométrie multiligne"
>
<img
v-if="featureType.geom_type === 'multipolygon'"
class="list-image-type"
src="@/assets/img/multipolygon.png"
alt="Géométrie multipolygone"
>
<span
v-if="featureType.geom_type === 'none'"
class="list-image-type"
title="Aucune géométrie"
>
<i class="ui icon large outline file" />
</span>
<span class="ellipsis">
{{ featureType.title }}
</span>
</router-link>
</template>
<script>
export default {
name: 'FeatureTypeLink',
props: {
featureType : {
type: Object,
default: () => {
return {};
},
}
},
};
</script>
<style scoped>
.feature-type-title {
display: flex;
align-items: center;
line-height: 1.5em;
width: fit-content;
}
.list-image-type {
margin-right: 5px;
height: 25px;
display: flex;
align-items: center;
}
.list-image-type > i {
color: #000000;
height: 25px;
}
</style>
<template>
<div>
<div class="three fields">
<h5 class="field">
{{ title }}
</h5>
<div class="required inline field">
<label :for="form.color.id_for_label">{{ form.color.label }}</label>
<input
:id="form.color.id_for_label"
v-model.lazy="form.color.value"
type="color"
required
:name="form.color.html_name"
>
</div>
<div
v-if="geomType === 'polygon' || geomType === 'multipolygon'"
class="field"
>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
id="opacity"
v-model="form.opacity"
type="range"
min="0"
max="1"
step="0.01"
>
<output class="range-output-bubble">
{{ getOpacity(form.opacity) }}
</output>
</div>
</div>
</div>
<div
v-if="isIconPickerModalOpen"
ref="iconsPickerModal"
class="ui dimmer modal transition active"
>
<div class="header">
Sélectionnez le symbole pour ce type de signalement :
</div>
<div class="scrolling content">
<div
v-for="icon of iconsNamesList"
:key="icon"
:class="['icon-container', { active: form.icon === icon }]"
@click="selectIcon(icon)"
>
<i
:class="`icon alt fas fa-${icon}`"
aria-hidden="true"
/>
</div>
</div>
<div class="actions">
<div
class="ui cancel button"
@click="isIconPickerModalOpen = false;"
>
Fermer
</div>
</div>
</div>
</div>
</template>
<script>
import faIconsNames from '@/assets/icons/fa-icons.js';
export default {
name: 'SymbologySelector',
props: {
title: {
type: String,
default: 'Couleur par défault :'
},
initColor: {
type: String,
default: '#000000'
},
initIcon: {
type: String,
default: 'circle'
},
initOpacity: {
type: [String, Number],
default: '0.5'
},
geomType: {
type: String,
default: 'Point'
}
},
data() {
return {
isIconPickerModalOpen: false,
iconsNamesList: faIconsNames,
form: {
icon: 'circle',
color: {
id_for_label: 'couleur',
label: 'Couleur',
field: {
max_length: 128, // ! Vérifier la valeur dans django
},
html_name: 'couleur',
value: '#000000',
},
opacity: '0.5',
}
};
},
watch: {
form: {
deep: true,
handler(newValue) {
this.$emit('set', {
name: this.isDefault ? null : this.title,
value: newValue
});
}
}
},
created() {
this.form.color.value = this.initColor;
if (this.initIcon) {
this.form.icon = this.initIcon;
}
if (this.initOpacity) {
this.form.opacity = this.initOpacity;
}
this.$emit('set', {
name: this.title,
value: this.form
});
},
methods: {
openIconSelectionModal() {
this.isIconPickerModalOpen = true;
},
selectIcon(icon) {
this.form.icon = icon;
},
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
}
};
</script>
<style lang="less" scoped>
.fields {
align-items: center;
}
#customFieldSymbology .fields {
margin-left: 1em !important;
margin-bottom: 1em !important;
}
h5 {
font-weight: initial;
font-style: italic;
}
#couleur {
width: 66%;
cursor: pointer;
box-shadow: 0 0 1px 1px rgb(189, 189, 189);
}
.picker-button {
height: 50px;
width: 50px;
border-radius: 3px;
box-shadow: 0 0 2px 1px rgb(131, 131, 131);
.icon.alt {
width: 30px;
height: 30px;
}
}
.picker-button:hover {
box-shadow: 0 0 2px 1px rgb(165, 165, 165);
}
.modal {
height: fit-content;
.content {
display: flex;
flex-flow: row wrap;
.icon-container {
padding: 7px;
.icon.alt {
color: rgb(75, 75, 75);
width: 30px;
height: 30px;
}
}
.icon-container:hover {
cursor: pointer;
background-color: rgba(130, 216, 219, 0.589);
}
.icon-container.active {
background-color: rgba(130, 216, 219, 0.589);
}
}
}
</style>
<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">
<thead>
<tr>
<th>Fichiers importés</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr :key="importFile.created_on" v-for="importFile in data">
<td>
<h4 class="ui header align-right">
<div :data-tooltip="importFile.geojson_file_name">
{{ importFile.geojson_file_name | subString }}
<div class="sub header">
ajouté le {{ importFile.created_on | setDate }}
</div>
</div>
</h4>
</td>
<td>
<span
v-if="importFile.infos"
:data-tooltip="importFile.infos"
class="ui icon margin-left"
>
<i
v-if="importFile.status == 'processing'"
class="orange hourglass half icon"
></i>
<i
v-else-if="importFile.status == 'finished'"
class="green check circle outline icon"
></i>
<i
v-else-if="importFile.status == 'failed'"
class="red x icon"
></i>
<i v-else class="red ban icon"></i>
</span>
<span
v-if="importFile.status == 'pending'"
data-tooltip="Statut en attente. Clickez pour rafraichir."
>
<i
v-on:click="fetchImports()"
:class="['orange icon', ready ? 'sync' : 'hourglass half']"
/>
</span>
</td>
</tr>
</tbody>
</table>
<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"
>
<div class="file-column">
<h4 class="ui header align-right">
<div
:data-tooltip="importFile.geojson_file_name"
class="ellipsis"
>
{{ 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"
>
<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>
export default {
data() {
return {
open: false,
ready: true,
};
},
props: ["data"],
export default {
filters: {
setDate: function (value) {
let date = new Date(value);
let d = date.toLocaleDateString("fr", {
year: "numeric",
month: "long",
day: "numeric",
formatDate: function (value) {
const date = new Date(value);
return date.toLocaleDateString('fr', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return d;
},
subString: function (value) {
return value.substring(0, 27) + "..";
return value.substring(0, 27) + '..';
},
},
watch: {
data(newValue) {
if (newValue) {
this.ready = true;
}
props: {
imports: {
type: Array,
default: null,
},
},
data() {
return {
reloading: false,
fetchCallCounter: 0,
};
},
mounted() {
this.fetchImports();
},
methods: {
fetchImports() {
this.$store.dispatch(
"feature_type/GET_IMPORTS",
this.$route.params.feature_type_slug
);
//* show that the action was triggered, could be improved with animation (doesn't work)
this.ready = false;
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: 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>
.sync {
cursor: pointer;
}
<style scoped lang="less">
#table-imports {
padding-top: 1em;
}
@keyframes rotateIn {
from {
transform: rotate3d(0, 0, 1, -200deg);
opacity: 0;
border: 1px solid lightgrey;
margin-top: 1rem;
.imports-header {
border-bottom: 1px solid lightgrey;
font-weight: bold;
}
to {
transform: translate3d(0, 0, 0);
opacity: 1;
}
}
.rotateIn {
animation-name: rotateIn;
transform-origin: center;
animation: 2s;
}
/*
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;
> div {
padding: .5em 1em;
}
/* Hide table headers (but not display: none;, for accessibility) */
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
.filerow {
background-color: #fff;
}
.imports-header, .filerow {
display: flex;
tr {
border: 1px solid #ccc;
}
td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid #eee;
position: relative;
padding-left: 50%;
.file-column {
width: 80%;
h4 {
margin-bottom: .2em;
}
}
.status-column {
width: 20%;
text-align: right;
}
}
}
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";
}
.sync {
cursor: pointer;
}
/*
Label the data
*/
td:nth-of-type(1):before {
content: "Fichiers importés";
}
td:nth-of-type(2):before {
content: "Statut";
}
i.icon {
width: 20px !important;
height: 20px !important;
}
.align-right {
text-align: right;
}
.margin-left {
margin-left: 94%;
}
.rotate {
-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;
}
</style>
\ No newline at end of file
@-moz-keyframes spin { 100% { -moz-transform: rotate(180deg); } }
@-webkit-keyframes spin { 100% { -webkit-transform: rotate(180deg); } }
@keyframes spin { 100% { -webkit-transform: rotate(180deg); transform:rotate(180deg); } }
</style>
<template>
<div class="editionToolbar">
<div class="leaflet-bar">
<a
v-if="noExistingFeature"
class="leaflet-draw-draw-polygon active"
:title="`Dessiner ${editionService.geom_type === 'linestring' ? 'une' : 'un'} ${toolbarGeomTitle}`
"
>
<img
v-if="editionService.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
alt="line"
>
<img
v-if="editionService.geom_type === '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>
<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>
<script>
import editionService from '@/services/edition-service';
export default {
name: 'EditingToolbar',
props: {
map: {
type: Object,
default: null,
},
},
data() {
return {
editionService: editionService,
isEditing: false,
isSnapEnabled: false,
};
},
computed: {
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: {
toggleEdition() {
this.isEditing = !this.isEditing;
editionService.activeUpdateFeature(this.isEditing);
},
deleteObj() {
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>
<style lang="less" scoped>
.editionToolbar{
position: absolute;
// 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 {
a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
border-bottom: none;
}
a, .leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
a {
background-color: #fff;
width: 30px;
height: 30px;
display: block;
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>
<template>
<div>
<div
id="geocoder-container"
:class="{ isExpanded }"
>
<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 }"
>
<!-- 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"
>
<template
slot="option"
slot-scope="props"
>
<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>
<script>
import Multiselect from 'vue-multiselect';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import axios from 'axios';
import mapService from '@/services/map-service';
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,
addresses: [],
resultats: [],
placeholder: 'Rechercher une adresse ...',
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){
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) {
let zoomlevel = 14;
const { type } = this.selectedAddress.properties;
if (type === 'housenumber') {
zoomlevel = 19;
} else if (type === 'street') {
zoomlevel = 16;
} else if (type === 'locality') {
zoomlevel = 16;
}
// On ajoute un point pour localiser la ville
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 lang="less">
#marker {
width: 14px;
height: 14px;
border: 2px solid #fff;
border-radius: 7px;
background-color: #3399CC;
}
#geocoder-container {
position: absolute;
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: 999;
border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box;
padding: 0;
border-radius: 4px;
display: flex;
.button-geocoder {
border: none;
padding: 0;
margin: 0;
text-align: center;
background-color: #fff;
color: rgb(39, 39, 39);
width: 30px;
height: 30px;
font: 700 18px Lucida Console,Monaco,monospace;
border-radius: 2px;
line-height: 1.15;
i {
margin: 0;
font-size: 0.9em;
}
}
.button-geocoder:hover {
cursor: pointer;
background-color: #ebebeb;
}
.expanded-geocoder {
max-width: 400px;
}
&&.isExpanded {
.button-geocoder {
/*height: 41px;*/
color: rgb(99, 99, 99);
border-radius: 2px 0 0 2px;
}
}
}
#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
<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>
<template>
<div
v-if="isOnline"
:class="['sidebar-container', { expanded }]"
>
<div
class="layers-icon"
@click="toggleSidebar()"
>
<!-- // ! svg point d'interrogation pas accepté par linter -->
<!-- <?xml version="1.0" encoding="iso-8859-1"?> -->
<svg
id="Capa_1"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 491.203 491.203"
style="enable-background: new 0 0 491.203 491.203"
xml:space="preserve"
>
<g>
<g>
<!-- eslint-disable max-len -->
<path
d="M487.298,326.733l-62.304-37.128l62.304-37.128c2.421-1.443,3.904-4.054,3.904-6.872s-1.483-5.429-3.904-6.872
l-62.304-37.128l62.304-37.128c3.795-2.262,5.038-7.172,2.776-10.968c-0.68-1.142-1.635-2.096-2.776-2.776l-237.6-141.6
c-2.524-1.504-5.669-1.504-8.192,0l-237.6,141.6c-3.795,2.262-5.038,7.172-2.776,10.968c0.68,1.142,1.635,2.096,2.776,2.776
l62.304,37.128L3.905,238.733c-3.795,2.262-5.038,7.172-2.776,10.968c0.68,1.142,1.635,2.096,2.776,2.776l62.304,37.128
L3.905,326.733c-3.795,2.262-5.038,7.172-2.776,10.968c0.68,1.142,1.635,2.096,2.776,2.776l237.6,141.6
c2.526,1.494,5.666,1.494,8.192,0l237.6-141.6c3.795-2.262,5.038-7.172,2.776-10.968
C489.393,328.368,488.439,327.414,487.298,326.733z M23.625,157.605L245.601,25.317l221.976,132.288L245.601,289.893
L23.625,157.605z M23.625,245.605l58.208-34.68l159.672,95.2c2.524,1.504,5.668,1.504,8.192,0l159.672-95.2l58.208,34.68
L245.601,377.893L23.625,245.605z M245.601,465.893L23.625,333.605l58.208-34.68l159.672,95.2c2.524,1.504,5.668,1.504,8.192,0
l159.672-95.2l58.208,34.68L245.601,465.893z"
/>
<!--eslint-enable-->
</g>
</g>
</svg>
</div>
<div class="basemaps-title">
<h4>
Fonds cartographiques
</h4>
</div>
<LayerSelector
v-for="basemap in baseMaps"
:key="`list-${basemap.id}`"
:basemap="basemap"
:selected-query-layer="selectedQueryLayer"
:active="basemap.active"
@addLayers="addLayers"
@activateGroup="activateGroup"
@onlayerMove="onlayerMove"
@onOpacityUpdate="onOpacityUpdate"
@onQueryLayerChange="onQueryLayerChange"
/>
</div>
</template>
<script>
import { mapState } from 'vuex';
import LayerSelector from '@/components/Map/LayerSelector.vue';
import mapService from '@/services/map-service';
export default {
name: 'SidebarLayers',
components: {
LayerSelector
},
data() {
return {
baseMaps: [],
expanded: false,
projectSlug: this.$route.params.slug,
selectedQueryLayer: '',
};
},
computed: {
...mapState([
'isOnline',
]),
...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.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));
// 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
);
// 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) {
// 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
);
}
},
toggleSidebar(value) {
this.expanded = value !== undefined ? value : !this.expanded;
},
// 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) {
// 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.map((b) => b.id).sort() || {};
isSameBasemaps =
idBasemapsServer.length === idBasemapsLocalstorage.length &&
idBasemapsServer.every(
(value, index) => value === idBasemapsLocalstorage[index]
);
// if basemaps changed, return that changed occured to avoid more processing
if (!isSameBasemaps) return true;
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;
}
}
}
}
// Compare basemaps titles
const titlesBasemapsServer = basemapFromServer
.map((b) => b.title)
.sort();
const titlesBasemapsLocalstorage = basemapFromLocalstorage.map((b) => b.title).sort() || {};
isSameTitles = titlesBasemapsServer.every(
(title, index) => title === titlesBasemapsLocalstorage[index]
);
if (!isSameTitles) {
break outer_block;
}
}
return !(isSameBasemaps && isSameLayers && isSameTitles);
},
addLayers(baseMap) {
baseMap.layers.forEach((layer) => {
const layerOptions = this.availableLayers.find((l) => l.id === layer.id);
layer = Object.assign(layer, layerOptions);
layer.options.basemapId = baseMap.id;
});
mapService.removeLayers();
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
mapService.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>
<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
<template>
<div style="display: flex;">
<nav style="margin: 0 auto;">
<ul class="custom-pagination">
<li
class="page-item"
:class="{ disabled: currentPage === 1 }"
>
<a
class="page-link pointer"
@click="changePage(currentPage - 1)"
>
<i
class="ui icon big angle left"
aria-hidden="true"
/>
</a>
</li>
<div
v-if="nbPages > 5"
style="display: contents;"
>
<li
v-for="index in pagination(currentPage, nbPages)"
:key="index"
class="page-item"
:class="{ active: currentPage === index }"
>
<a
class="page-link pointer"
@click="changePage(index)"
>
{{ index }}
</a>
</li>
</div>
<div
v-else
style="display: contents;"
>
<li
v-for="index in nbPages"
:key="index"
class="page-item"
:class="{ active: currentPage === index }"
>
<a
class="page-link pointer"
@click="changePage(index)"
>
{{ index }}
</a>
</li>
</div>
<li
class="page-item"
:class="{ disabled: currentPage === nbPages }"
>
<a
class="page-link pointer"
@click="changePage(currentPage + 1)"
>
<i
class="ui icon big angle right"
aria-hidden="true"
/>
</a>
</li>
</ul>
</nav>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
name: 'Pagination',
props: {
nbPages: {
type: Number,
default: 1
},
},
computed: {
...mapState('projects', ['currentPage']),
},
methods: {
...mapMutations('projects', [
'SET_CURRENT_PAGE',
'SET_PROJECTS_FILTER'
]),
pagination(c, m) {
const current = c,
last = m,
delta = 2,
left = current - delta,
right = current + delta + 1,
range = [],
rangeWithDots = [];
let l;
for (let i = 1; i <= last; i++) {
if (i === 1 || i === last || i >= left && i < right) {
range.push(i);
}
}
for (const i of range) {
if (l) {
if (i - l === 2) {
rangeWithDots.push(l + 1);
} else if (i - l !== 1) {
rangeWithDots.push('...');
}
}
rangeWithDots.push(i);
l = i;
}
return rangeWithDots;
},
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);
}
}
}
};
</script>
<template>
<div class="ui segment">
<div class="ui segment secondary">
<div class="field required">
<label for="basemap-title">Titre</label>
<input
v-model="basemap.title"
:value="basemap.title"
type="text"
name="basemap-title"
required
/>
@input="updateTitle"
>
<ul
v-if="basemap.errors && basemap.errors.length > 0"
id="errorlist-title"
......@@ -20,7 +21,10 @@
</div>
<div class="nested">
<div v-if="basemap.layers" class="ui segments layers-container">
<div
:id="`list-${basemap.id}`"
:class="[basemap.layers.length > 0 ? 'ui segments': '', 'layers-container', 'raised']"
>
<ProjectMappingContextLayer
v-for="layer in basemap.layers"
:key="'layer-' + layer.dataKey"
......@@ -28,49 +32,61 @@
:basemapid="basemap.id"
/>
</div>
<div class="ui buttons">
<a
<div class="ui bottom two attached buttons">
<button
class="ui icon button basic positive"
type="button"
@click="addLayer"
class="ui compact small icon left floated button green"
>
<i class="ui plus icon"></i>
<span>Ajouter une couche</span>
</a>
</div>
<i
class="ui plus icon"
aria-hidden="true"
/>
<span>&nbsp;Ajouter une couche</span>
</button>
<div @click="deleteBasemap" class="ui buttons">
<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"></i>
<span>Supprimer ce fond cartographique</span>
</a>
<i
class="ui trash alternate icon"
aria-hidden="true"
/>
<span>&nbsp;Supprimer ce fond cartographique</span>
</button>
</div>
</div>
</div>
</template>
<script>
import ProjectMappingContextLayer from "@/components/project/ProjectMappingContextLayer.vue";
import { mapMutations } from 'vuex';
export default {
name: "Project_mapping_basemap",
import Sortable from 'sortablejs';
import ProjectMappingContextLayer from '@/components/Project/Basemaps/ProjectMappingContextLayer.vue';
props: ["basemap"],
export default {
name: 'BasemapListItem',
components: {
ProjectMappingContextLayer,
},
props: {
basemap: {
type: Object,
default: null,
}
},
data() {
return {
sortable: null
};
},
computed: {
maxLayersCount: function () {
return this.basemap.layers.reduce((acc, curr) => {
......@@ -83,21 +99,37 @@ export default {
},
},
created() {
if (this.basemap.layers) {
//* add datakeys to layers coming from api
this.fillLayersWithDatakey(this.basemap.layers);
}
},
mounted() {
this.initSortable();
},
methods: {
...mapMutations('map', [
'UPDATE_BASEMAP',
'DELETE_BASEMAP',
'REPLACE_BASEMAP_LAYERS'
]),
deleteBasemap() {
this.$store.commit("map/DELETE_BASEMAP", this.basemap.id);
this.DELETE_BASEMAP(this.basemap.id);
},
addLayer(layer) {
const newLayer = {
dataKey: this.maxLayersCount + 1,
opacity: "1.00",
opacity: '1.00',
order: 0,
queryable: false,
title: "Open street map",
title: 'Open street map',
...layer,
};
this.$store.commit("map/UPDATE_BASEMAP", {
this.UPDATE_BASEMAP({
layers: [...this.basemap.layers, newLayer],
id: this.basemap.id,
title: this.basemap.title,
......@@ -106,10 +138,14 @@ export default {
},
updateTitle(evt) {
this.$store.commit("map/UPDATE_BASEMAP", {
let errors = '';
if(evt.target.value === '') { //* delete or add error message while typing
errors = 'Veuillez compléter ce champ.';
}
this.UPDATE_BASEMAP({
id: this.basemap.id,
title: evt.title,
errors: evt.errors,
title: evt.target.value,
errors,
});
},
......@@ -119,7 +155,7 @@ export default {
fillLayersWithDatakey(layers) {
let dataKey = 0;
this.$store.commit("map/REPLACE_BASEMAP_LAYERS", {
this.REPLACE_BASEMAP_LAYERS({
basemapId: this.basemap.id,
layers: layers.map((el) => {
dataKey += 1;
......@@ -127,45 +163,45 @@ export default {
}),
});
},
},
watch: {
"basemap.title": {
deep: true,
handler: function (newValue, oldValue) {
if (newValue !== oldValue) {
this.basemap.title = newValue;
if (newValue === "")
this.basemap.errors = "Veuillez compléter ce champ.";
else this.basemap.errors = "";
this.updateTitle(this.basemap);
//* drag & drop *//
onlayerMove() {
//* Get the names of the current layers in order.
const currentLayersNamesInOrder = Array.from(
document.getElementsByClassName(`basemap-${this.basemap.id}`)
).map((el) => el.id);
//* increment value 'order' in this.basemap.layers looping over layers from template ^
let order = 0;
const movedLayers = [];
for (const id of currentLayersNamesInOrder) {
const matchingLayer = this.basemap.layers.find(
(el) => el.dataKey === Number(id)
);
if (matchingLayer) {
matchingLayer['order'] = order;
movedLayers.push(matchingLayer);
order += 1;
}
},
}
//* update the store
this.UPDATE_BASEMAP({
layers: movedLayers,
id: this.basemap.id,
title: this.basemap.title,
errors: this.basemap.errors,
});
},
},
created() {
if (this.basemap.layers) {
//* add datakeys to layers coming from api
this.fillLayersWithDatakey(this.basemap.layers);
}
initSortable() {
this.sortable = new Sortable(document.getElementById(`list-${this.basemap.id}`), {
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),
});
},
},
// destroyed(){
// this.errors = [];
// }
// mounted() { //* not present in original
// if (!this.basemap.title) {
// this.$store.commit("map/UPDATE_BASEMAP", {
// id: this.basemap.id,
// title: newValue,
// });
// }
// },
};
</script>
<style scoped>
.button {
margin-right: 0.5em !important;
}
</style>
\ No newline at end of file
<template>
<div class="ui segment layer-item">
<div
:id="layer.dataKey"
:class="`ui segment layer-item basemap-${basemapid}`"
>
<div class="ui divided form">
<div class="field" data-type="layer-field">
<label for="form.layer.id_for_label" class="layer-handle-sort">
<i class="th icon"></i>couche
<div
class="field"
data-type="layer-field"
>
<label
:for="layer.title"
class="layer-handle-sort"
>
<i
class="th icon"
aria-hidden="true"
/>
couche
</label>
<!-- {% if is_empty %} {# TODO arranger le dropdown pour les ajout à la volée
#} {# le selecteur de couche ne s'affichant pas correctement on passe
par un field django par defaut en attendant #} -->
<!-- {{ form.layer }} -->
<!-- {% else %} -->
<Dropdown
:options="availableLayers"
:options="availableLayerOptions"
:selected="selectedLayer.name"
:selection.sync="selectedLayer"
:search="true"
:placeholder="placeholder"
/>
<!-- {{ form.layer.errors }} -->
</div>
<div class="fields">
<div class="six wide field">
<label for="opacity">Opacité</label>
<input
v-model.number="layerOpacity"
type="number"
oninput="validity.valid||(value='');"
step="0.01"
min="0"
max="1"
>
</div>
<div class="field">
<div
class="field three wide {% if form.opacity.errors %} error{% endif %}"
class="ui checkbox"
@click="updateLayer({ ...layer, queryable: !layer.queryable })"
>
<label for="opacity">Opacité</label>
<input
type="number"
v-model.number="layerOpacity"
oninput="validity.valid||(value='');"
step="0.01"
min="0"
max="1"
/>
<!-- {{ form.opacity.errors }} -->
</div>
<div class="field three wide">
<div
@click="updateLayer({ ...layer, queryable: !layer.queryable })"
class="ui checkbox"
:checked="layer.queryable"
class="hidden"
type="checkbox"
name="queryable"
>
<input type="checkbox" v-model="layer.queryable" name="queryable" />
<label for="queryable"> Requêtable</label>
</div>
<!-- {{ form.queryable.errors }} -->
<label for="queryable">&nbsp;Requêtable</label>
</div>
</div>
<div @click="removeLayer" class="field">
<div class="ui compact small icon floated button button-hover-red">
<i class="ui grey trash alternate icon"></i>
<span>Supprimer cette couche</span>
</div>
</div>
<button
type="button"
class="ui compact small icon floated button button-hover-red"
@click="removeLayer"
>
<i
class="ui grey trash alternate icon"
aria-hidden="true"
/>
<span>&nbsp;Supprimer cette couche</span>
</button>
</div>
</div>
</template>
<script>
import Dropdown from "@/components/Dropdown.vue";
import { mapState } from "vuex";
import Dropdown from '@/components/Dropdown.vue';
import { mapState } from 'vuex';
export default {
name: "ProjectMappingContextLayer",
props: ["layer", "basemapid"],
name: 'ProjectMappingContextLayer',
components: {
Dropdown,
},
props: {
layer:
{
type: Object,
default: null,
},
basemapid:
{
type: Number,
default: null,
},
},
computed: {
...mapState("map", ["layers", "availableLayers"]),
...mapState('map', ['availableLayers']),
selectedLayer: {
get() {
const matchingLayer = this.retrieveLayer(this.layer.title);
if (matchingLayer != undefined) {
return {
name: matchingLayer != undefined ? matchingLayer.service : "",
value: this.layer ? this.layer.title : "",
};
}
return [];
return this.retrieveLayer(this.layer.title) || [];
},
set(newValue) {
const matchingLayer = this.retrieveLayer(newValue.title);
if (matchingLayer != undefined) {
if (matchingLayer !== undefined) {
this.updateLayer({
...this.layer,
service: newValue.name,
......@@ -109,11 +124,11 @@ export default {
},
},
availableLayers: function () {
return this.layers.map((el) => {
availableLayerOptions: function () {
return this.availableLayers.map((el) => {
return {
id: el.id,
name: el.service,
name: `${el.title} - ${el.service}`,
value: el.title,
title: el.title,
};
......@@ -122,42 +137,42 @@ export default {
placeholder: function () {
return this.selectedLayer && this.selectedLayer.name
? this.selectedLayer.name
: "Choisissez une couche";
? ''
: 'Choisissez une couche';
},
},
mounted() {
const matchingLayer = this.retrieveLayer(this.layer.title);
if (matchingLayer !== undefined) {
this.updateLayer({
...this.layer,
service: matchingLayer.service,
title: matchingLayer.title,
id: matchingLayer.id,
});
}
},
methods: {
retrieveLayer(title) {
return this.layers.find((el) => el.title === title);
return this.availableLayerOptions.find((el) => el.title === title);
},
removeLayer() {
this.$store.commit("map/DELETE_BASEMAP_LAYER", {
this.$store.commit('map/DELETE_BASEMAP_LAYER', {
basemapId: this.basemapid,
layerId: this.layer.dataKey,
});
},
updateLayer(layer) {
this.$store.commit("map/UPDATE_BASEMAP_LAYER", {
this.$store.commit('map/UPDATE_BASEMAP_LAYER', {
basemapId: this.basemapid,
layerId: this.layer.dataKey,
layer,
});
},
},
mounted() {
const matchingLayer = this.retrieveLayer(this.layer.title);
if (matchingLayer != undefined) {
this.updateLayer({
...this.layer,
service: matchingLayer.service,
title: matchingLayer.title,
id: matchingLayer.id,
});
}
},
};
</script>
\ No newline at end of file
</script>