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