<template> <div class="fourteen wide column"> <div id="feature-list-container" class="ui grid mobile-column" > <div class="four wide column mobile-fullwidth"> <h1>Signalements</h1> </div> <div class="twelve-wide column no-padding-mobile mobile-fullwidth"> <div class="ui large text loader"> Chargement </div> <div class="ui secondary menu no-margin"> <a :class="['item no-margin', { active: showMap }]" data-tab="map" data-tooltip="Carte" @click="showMap = true" ><i class="map fitted icon" /></a> <a :class="['item no-margin', { active: !showMap }]" data-tab="list" data-tooltip="Liste" @click="showMap = false" ><i class="list fitted icon" /></a> <div class="item"> <h4> {{ featuresCount }} signalement{{ featuresCount > 1 ? "s" : "" }} </h4> </div> <div v-if=" project && feature_types.length > 0 && permissions.can_create_feature " id="button-dropdown" class="item right" > <div class="ui dropdown button compact button-hover-green" data-tooltip="Ajouter un signalement" data-position="bottom right" @click="toggleAddFeature" > <i class="plus fitted icon" /> <div v-if="showAddFeature" class="menu left transition visible" style="z-index: 9999" > <div class="header"> Ajouter un signalement du type </div> <div class="scrolling menu text-wrap"> <router-link v-for="(type, index) in feature_types" :key="type.slug + index" :to="{ name: 'ajouter-signalement', params: { slug_type_signal: type.slug }, }" class="item" > {{ type.title }} </router-link> </div> </div> </div> <div v-if="checkedFeatures.length > 0 && massMode === 'modify'" class="ui dropdown button compact button-hover-green margin-left-25" data-tooltip="Modifier le statut des Signalements" data-position="bottom right" @click="toggleModifyStatus" > <i class="pencil fitted icon" /> <div v-if="showModifyStatus" class="menu left transition visible" style="z-index: 9999" > <div class="header"> Modifier le statut des Signalements </div> <div class="scrolling menu text-wrap"> <span v-for="status in availableStatus" :key="status.value" class="item" @click="modifyStatus(status.value)" > {{ status.name }} </span> </div> </div> </div> <div v-if="checkedFeatures.length > 0 && massMode === 'delete'" class="ui button compact button-hover-red margin-left-25" data-tooltip="Supprimer tous les signalements sélectionnés" data-position="bottom right" @click="modalAllDelete" > <i class="grey trash fitted icon" /> </div> </div> </div> </div> </div> <section id="form-filters" class="ui form grid" > <div class="field wide four column no-margin-mobile"> <label>Type</label> <Dropdown :options="featureTypeChoices" :selected="form.type.selected" :selection.sync="form.type.selected" :search="true" :clearable="true" /> </div> <div class="field wide four column no-padding-mobile no-margin-mobile"> <label>Statut</label> <!-- //* giving an object mapped on key name --> <Dropdown :options="filteredStatusChoices" :selected="form.status.selected.name" :selection.sync="form.status.selected" :search="true" :clearable="true" /> </div> <div class="field wide four column"> <label>Nom</label> <div class="ui icon input"> <i class="search icon" /> <div class="ui action input"> <input v-model="form.title" type="text" name="title" @keyup.enter="resetPaginationNfetchFeatures" > <button id="submit-search" class="ui teal icon button" @click="resetPaginationNfetchFeatures" > <i class="search icon" /> </button> </div> </div> </div> <!-- map params, updated on map move --> <input v-model="zoom" type="hidden" name="zoom" > <input v-model="lat" type="hidden" name="lat" > <input v-model="lng" type="hidden" name="lng" > </section> <div :class="['ui tab active map-container', {visible: showMap}]" data-tab="map" > <div id="map" ref="map" /> <SidebarLayers v-if="basemaps && map" /> </div> <FeatureListTable v-show="!showMap" :paginated-features="paginatedFeatures" :checked-features.sync="checkedFeatures" :features-count="featuresCount" :pagination="pagination" :sort="sort" @update:page="handlePageChange" @update:sort="handleSortChange" /> <!-- MODAL ALL DELETE FEATURE TYPE --> <div v-if="modalAllDeleteOpen" class="ui dimmer modals page transition visible active" style="display: flex !important" > <div :class="[ 'ui mini modal subscription', { 'active visible': modalAllDeleteOpen }, ]" > <i class="close icon" @click="modalAllDeleteOpen = false" /> <div class="ui icon header"> <i class="trash alternate icon" /> Êtes-vous sûr de vouloir effacer <span v-if="checkedFeatures.length === 1"> un signalement ? </span> <span v-else> ces {{ checkedFeatures.length }} signalements ? </span> </div> <div class="actions"> <button type="button" class="ui red compact fluid button" @click="deleteAllFeatureSelection" > Confirmer la suppression </button> </div> </div> </div> </div> </template> <script> import { mapGetters, mapState, mapActions, mapMutations } from 'vuex'; import { mapUtil } from '@/assets/js/map-util.js'; import featureAPI from '@/services/feature-api'; import SidebarLayers from '@/components/map-layers/SidebarLayers'; import FeatureListTable from '@/components/feature/FeatureListTable'; import Dropdown from '@/components/Dropdown.vue'; import axios from '@/axios-client.js'; import { allowedStatus2change } from '@/utils'; export default { name: 'FeatureList', components: { SidebarLayers, Dropdown, FeatureListTable, }, data() { return { currentLayer: null, featuresCount: 0, form: { type: { selected: '', }, status: { selected: '', }, title: null, }, lat: null, lng: null, map: null, modalAllDeleteOpen: false, next: null, paginatedFeatures: [], pagination: { currentPage: 1, pagesize: 15, start: 0, end: 15, }, previous: null, projectSlug: this.$route.params.slug, showMap: true, showAddFeature: false, showModifyStatus: false, sort: { column: '', ascending: true, }, zoom: null, }; }, computed: { ...mapState(['user', 'USER_LEVEL_PROJECTS']), ...mapGetters([ 'permissions', ]), ...mapState('projects', [ 'project', ]), ...mapState('feature', [ 'checkedFeatures', 'clickedFeatures', 'statusChoices', 'massMode', ]), ...mapState('feature_type', [ 'feature_types', ]), ...mapState('map', [ 'basemaps', ]), API_BASE_URL() { return this.$store.state.configuration.VUE_APP_DJANGO_API_BASE; }, filteredStatusChoices() { //* if project is not moderate, remove pending status return this.statusChoices.filter((el) => this.project && this.project.moderation ? true : el.value !== 'pending' ); }, availableStatus() { if (this.project && this.user) { const isModerate = this.project.moderation; const userStatus = this.USER_LEVEL_PROJECTS[this.project.slug]; const isOwnFeature = true; //* dans ce cas le contributeur est toujours l'auteur des signalements qu'il peut modifier return allowedStatus2change(this.statusChoices, isModerate, userStatus, isOwnFeature); } return []; }, featureTypeChoices() { return this.feature_types.map((el) => el.title); }, }, watch: { 'form.type.selected'() { this.resetPaginationNfetchFeatures(); }, 'form.status.selected.value'() { this.resetPaginationNfetchFeatures(); }, map(newValue) { if (newValue && this.paginatedFeatures && this.paginatedFeatures.length) { if (this.currentLayer) { this.map.removeLayer(this.currentLayer); } this.currentLayer = mapUtil.addFeatures( this.paginatedFeatures, {}, true, this.feature_types ); } }, paginatedFeatures: { deep: true, handler(newValue, oldValue) { if (newValue && newValue.length && newValue !== oldValue && this.map) { if (this.currentLayer) { this.map.removeLayer(this.currentLayer); this.currentLayer = null; } this.currentLayer = mapUtil.addFeatures( newValue, {}, true, this.feature_types ); } else if (newValue && newValue.length === 0) { if (this.currentLayer) { this.map.removeLayer(this.currentLayer); this.currentLayer = null; } } } }, }, mounted() { if (!this.project) { // Chargements des features et infos projet en cas d'arrivée directe sur la page ou de refresh this.$store.dispatch('projects/GET_PROJECT', this.projectSlug); this.$store .dispatch('projects/GET_PROJECT_INFO', this.projectSlug) .then(() => this.initMap()); } else { this.initMap(); } this.fetchPagedFeatures(); window.addEventListener('mousedown', this.clickOutsideDropdown); }, destroyed() { window.removeEventListener('mousedown', this.clickOutsideDropdown); //* allow user to change page if ever stuck on loader this.$store.commit('DISCARD_LOADER'); }, methods: { ...mapActions('feature', [ 'GET_PROJECT_FEATURES', 'SEND_FEATURE' ]), ...mapMutations('feature', [ 'UPDATE_CHECKED_FEATURES' ]), toggleAddFeature() { this.showAddFeature = !this.showAddFeature; this.showModifyStatus = false; }, toggleModifyStatus() { this.showModifyStatus = !this.showModifyStatus; this.showAddFeature = false; }, modalAllDelete() { this.modalAllDeleteOpen = !this.modalAllDeleteOpen; }, clickOutsideDropdown(e) { if (!e.target.closest('#button-dropdown')) { this.showModifyStatus = false; setTimeout(() => { //* timout necessary to give time to click on link to add feature this.showAddFeature = false; }, 500); } }, async modifyStatus(newStatus) { if (this.checkedFeatures.length > 0) { const feature_id = this.checkedFeatures[0]; let feature = this.clickedFeatures.find((el) => el.feature_id === feature_id); if (feature) { featureAPI.updateFeature({ feature_id, feature_type__slug: feature.feature_type, project__slug: this.projectSlug, newStatus }).then((response) => { if (response && response.data && response.status === 200) { let newCheckedFeatures = [...this.checkedFeatures]; newCheckedFeatures.splice(this.checkedFeatures.indexOf(response.data.id), 1); this.UPDATE_CHECKED_FEATURES(newCheckedFeatures); this.modifyStatus(newStatus); } else { this.$store.commit('DISPLAY_MESSAGE', { comment: `Le signalement ${feature.title} n'a pas pu être modifié`, level: 'negative' }); this.fetchPagedFeatures(); } }); } } else { this.fetchPagedFeatures(); this.$store.commit('DISPLAY_MESSAGE', { comment: 'Tous les signalements ont été modifié avec succès.', level: 'positive' }); } }, deleteFeature(feature_id) { const url = `${this.API_BASE_URL}features/${feature_id}/?project__slug=${this.projectSlug}`; axios //TODO: REFACTO -> Delete function already exist in store .delete(url, {}) .then(() => { if (!this.modalAllDeleteOpen) { this.GET_PROJECT_FEATURES({ project_slug: this.projectSlug, }) .then(() => { this.fetchPagedFeatures(); this.checkedFeatures.splice(feature_id); }); } }) .catch(() => { return false; }); }, deleteAllFeatureSelection() { let feature = {}; this.checkedFeatures.forEach((feature_id) => { feature = { feature_id: feature_id }; // ? Is this usefull ? this.deleteFeature(feature.feature_id); //? since property feature_id is directly used after... }); this.modalAllDelete(); }, onFilterChange() { if (mapUtil.getMap()) { mapUtil.getMap().invalidateSize(); mapUtil.getMap()._onResize(); // force refresh for vector tiles if (window.layerMVT) { window.layerMVT.redraw(); } } }, initMap() { this.zoom = this.$route.query.zoom || ''; this.lat = this.$route.query.lat || ''; this.lng = this.$route.query.lng || ''; var mapDefaultViewCenter = this.$store.state.configuration.DEFAULT_MAP_VIEW.center; var mapDefaultViewZoom = this.$store.state.configuration.DEFAULT_MAP_VIEW.zoom; this.map = mapUtil.createMap(this.$refs.map, { zoom: this.zoom, lat: this.lat, lng: this.lng, mapDefaultViewCenter, mapDefaultViewZoom, }); this.fetchBboxNfit(); document.addEventListener('change-layers-order', (event) => { // Reverse is done because the first layer in order has to be added in the map in last. // Slice is done because reverse() changes the original array, so we make a copy first mapUtil.updateOrder(event.detail.layers.slice().reverse()); }); // --------- End sidebar events ---------- setTimeout(() => { const project_id = this.projectSlug.split('-')[0]; const mvtUrl = `${this.API_BASE_URL}features.mvt/?tile={z}/{x}/{y}&project_id=${project_id}`; mapUtil.addVectorTileLayer( mvtUrl, this.projectSlug, this.feature_types, this.form ); mapUtil.addGeocoders(this.$store.state.configuration); }, 1000); }, fetchBboxNfit(queryParams) { featureAPI .getFeaturesBbox(this.projectSlug, queryParams) .then((bbox) => { if (bbox) { mapUtil.getMap().fitBounds(bbox, { padding: [25, 25] }); } }); }, //* Paginated Features for table *// getFeatureTypeSlug(title) { const featureType = this.feature_types.find((el) => el.title === title); return featureType ? featureType.slug : null; }, getAvalaibleField(orderField) { let result = orderField; if (orderField === 'display_creator') { result = 'creator'; } else if (orderField === 'display_last_editor') { result = 'last_editor'; } return result; }, buildQueryString() { let urlParams = ''; let typeFilter = this.getFeatureTypeSlug(this.form.type.selected); let statusFilter = this.form.status.selected.value; if (typeFilter) urlParams += `&feature_type_slug=${typeFilter}`; if (statusFilter) urlParams += `&status__value=${statusFilter}`; if (this.form.title) urlParams += `&title=${this.form.title}`; if (this.sort.column) { urlParams += `&ordering=${ this.sort.ascending ? '-' : '' }${this.getAvalaibleField(this.sort.column)}`; } return urlParams; }, fetchPagedFeatures(newUrl) { let url = `${this.API_BASE_URL}projects/${this.projectSlug}/feature-paginated/?output=geojson&limit=${this.pagination.pagesize}&offset=${this.pagination.start}`; //* if receiving next & previous url if (newUrl && typeof newUrl === 'string') { //newUrl = newUrl.replace("8000", "8010"); //* for dev uncomment to use proxy link url = newUrl; } const queryString = this.buildQueryString(); url += queryString; this.$store.commit( 'DISPLAY_LOADER', 'Récupération des signalements en cours...' ); featureAPI.getPaginatedFeatures(url) .then((data) => { if (data) { this.featuresCount = data.count; this.previous = data.previous; this.next = data.next; this.paginatedFeatures = data.results.features; } //* bbox needs to be updated with the same filters if (this.paginatedFeatures.length) { this.fetchBboxNfit(queryString); this.onFilterChange(); //* use paginated event to watch change in filters and modify features on map } this.$store.commit('DISCARD_LOADER'); }); }, resetPaginationNfetchFeatures() { this.pagination = { currentPage: 1, pagesize: 15, start: 0, end: 15, }, this.fetchPagedFeatures(); }, //* Pagination for table *// handlePageChange(page) { if (page === 'next') { this.toNextPage(); } else if (page === 'previous') { this.toPreviousPage(); } else if (typeof page === 'number') { //* update limit and offset this.toPage(page); } }, handleSortChange(sort) { this.sort = sort; this.fetchPagedFeatures({ filterType: undefined, filterValue: undefined, }); }, toPage(pageNumber) { const toAddOrRemove = (pageNumber - this.pagination.currentPage) * this.pagination.pagesize; this.pagination.start += toAddOrRemove; this.pagination.end += toAddOrRemove; this.pagination.currentPage = pageNumber; this.fetchPagedFeatures(); }, toPreviousPage() { if (this.pagination.currentPage !== 1) { if (this.pagination.start > 0) { this.pagination.start -= this.pagination.pagesize; this.pagination.end -= this.pagination.pagesize; this.pagination.currentPage -= 1; } this.fetchPagedFeatures(this.previous); } }, toNextPage() { if (this.pagination.currentPage !== this.pageNumbers.length) { if (this.pagination.end < this.featuresCount) { this.pagination.start += this.pagination.pagesize; this.pagination.end += this.pagination.pagesize; this.pagination.currentPage += 1; } this.fetchPagedFeatures(this.next); } }, }, }; </script> <style scoped> #map { width: 100%; min-height: 300px; height: calc(100vh - 300px); border: 1px solid grey; /* To not hide the filters */ z-index: 1; } #feature-list-container { justify-content: flex-start; } #feature-list-container .ui.menu:not(.vertical) .right.item { padding-right: 0; } .map-container { width: 80vw; transform: translateX(-50%); margin-left: 50%; visibility: hidden; position: absolute; } .map-container.visible { visibility: visible; position: initial; } .margin-left-25 { margin-left: 0.25em !important; } .no-padding { padding: 0 !important; } .ui.dropdown .menu .left.menu, .ui.dropdown > .left.menu .menu { margin-right: 0 !important; } #button-dropdown { z-index: 1; } @media screen and (min-width: 767px) { .twelve-wide { width: 75% !important; } } @media screen and (max-width: 767px) { #feature-list-container > .mobile-fullwidth { width: 100% !important; } .no-margin-mobile { margin: 0 !important; } .no-padding-mobile { padding-top: 0 !important; padding-bottom: 0 !important; } .mobile-column { flex-direction: column !important; } #button-dropdown { transform: translate(-50px, -60px); } #form-filters > .field.column { width: 100% !important; } .map-container { width: 100%; } } </style>