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 3113 additions and 697 deletions
<template>
<div class="editionToolbar">
<div class="leaflet-bar">
<a
v-if="noExistingFeature"
class="leaflet-draw-draw-polygon active"
:title="`Dessiner ${editionService.geom_type === 'linestring' ? 'une' : 'un'} ${toolbarGeomTitle}`
"
>
<img
v-if="editionService.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
alt="line"
>
<img
v-if="editionService.geom_type === 'point'"
class="list-image-type"
src="@/assets/img/marker.png"
alt="marker"
>
<img
v-if="editionService.geom_type === 'polygon'"
class="list-image-type"
src="@/assets/img/polygon.png"
alt="polygon"
>
</a>
<a
v-else
:class="{ active: isEditing }"
:title="`Modifier ${toolbarEditGeomTitle}`"
@click="toggleEdition"
>
<i class="edit outline icon" />
<span class="sr-only">Modifier {{ toolbarEditGeomTitle }}</span>
</a>
<a
v-if="noExistingFeature || isEditing"
:class="{ active: isSnapEnabled }"
:title="`${ isSnapEnabled ? 'Désactiver' : 'Activer' } l'accrochage aux points`"
@click="toggleSnap"
>
<i
class="magnet icon"
aria-hidden="true"
/>
<span class="sr-only">{{ isSnapEnabled ? 'Désactiver' : 'Activer' }} l'accrochage aux points</span>
</a>
<a
v-if="!noExistingFeature"
:title="`Supprimer ${toolbarEditGeomTitle}`"
@click="deleteObj"
>
<i class="trash alternate outline icon" />
<span class="sr-only">Supprimer {{ toolbarEditGeomTitle }}</span>
</a>
</div>
</div>
</template>
<script>
import editionService from '@/services/edition-service';
export default {
name: 'EditingToolbar',
props: {
map: {
type: Object,
default: null,
},
},
data() {
return {
editionService: editionService,
isEditing: false,
isSnapEnabled: false,
};
},
computed: {
noExistingFeature() {
return this.editionService?.editing_feature === undefined;
},
toolbarGeomTitle() {
switch (this.editionService.geom_type) {
case 'polygon':
return 'polygone';
case 'linestring':
return 'ligne';
}
return 'point';
},
toolbarEditGeomTitle() {
return `${this.editionService.geom_type === 'linestring' ? 'la' : 'le'} ${this.toolbarGeomTitle}`;
}
},
methods: {
toggleEdition() {
this.isEditing = !this.isEditing;
editionService.activeUpdateFeature(this.isEditing);
},
deleteObj() {
const hasBeenRemoved = editionService.removeFeatureFromMap();
// Vérifie que l'utilisateur a bien confirmé la suppression avant de réinitialiser le bouton d'édition
if (this.isEditing && hasBeenRemoved) {
this.isEditing = false;
}
},
toggleSnap() {
if (this.isSnapEnabled) {
editionService.removeSnapInteraction(this.map);
} else {
editionService.addSnapInteraction(this.map);
}
this.isSnapEnabled = !this.isSnapEnabled;
}
}
};
</script>
<style lang="less" scoped>
.editionToolbar{
position: absolute;
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(2em + 60px + 34px + 64px);
right: 6px;
border: 2px solid rgba(0,0,0,.2);
border-radius: 4px;
background-clip: padding-box;
padding: 0;
z-index: 9;
}
.leaflet-bar {
a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
border-bottom: none;
}
a, .leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
a {
background-color: #fff;
width: 30px;
height: 30px;
display: block;
text-align: center;
text-decoration: none;
color: black;
i {
margin: 0;
vertical-align: middle;
&.magnet {
transform: rotate(90deg);
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
opacity: .85;
}
}
}
.active {
background-color: rgba(255, 145, 0, 0.904);
color: #fff;
i {
font-weight: bold;
}
img {
filter: invert(100%) sepia(0%) saturate(7500%) hue-rotate(282deg) brightness(105%) contrast(100%);
}
}
.list-image-type {
height: 20px;
vertical-align: middle;
margin: 5px 0 5px 0;
}
a:hover {
cursor: pointer !important;
background-color: #ebebeb !important;
}
a:focus {
background-color: #ebebeb !important;
}
}
</style>
<template>
<div>
<div
id="geocoder-container"
:class="{ isExpanded }"
>
<button
class="button-geocoder"
title="Rechercher une adresse"
type="button"
@click="toggleGeocoder"
>
<i class="search icon" />
</button>
</div>
<div
id="geocoder-select-container"
:class="{ isExpanded }"
>
<!-- internal-search should be disabled to avoid filtering options, which is done by the api calls anyway https://stackoverflow.com/questions/57813170/vue-multi-select-not-showing-all-the-options -->
<!-- otherwise approximate results are not shown (cannot explain why though) -->
<Multiselect
v-if="isExpanded"
ref="multiselect"
v-model="selection"
class="expanded-geocoder"
:options="addresses"
:options-limit="limit"
:allow-empty="true"
:internal-search="false"
track-by="id"
label="label"
:show-labels="false"
:reset-after="true"
select-label=""
selected-label=""
deselect-label=""
:searchable="true"
:placeholder="placeholder"
:show-no-results="true"
:loading="loading"
:clear-on-select="false"
:preserve-search="true"
@search-change="search"
@select="select"
@open="retrievePreviousPlaces"
@close="close"
>
<template
slot="option"
slot-scope="props"
>
<div class="option__desc">
<span class="option__title">{{ props.option.label }}</span>
</div>
</template>
<template slot="clear">
<div
v-if="selection"
class="multiselect__clear"
@click.prevent.stop="selection = null"
>
<i class="close icon" />
</div>
</template>
<span slot="noResult">
Aucun résultat.
</span>
<span slot="noOptions">
Saisissez les premiers caractères ...
</span>
</Multiselect>
<div style="display: none;">
<div
id="marker"
title="Marker"
/>
</div>
</div>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import axios from 'axios';
import mapService from '@/services/map-service';
const apiAdressAxios = axios.create({
baseURL: 'https://api-adresse.data.gouv.fr',
withCredentials: false,
});
export default {
name: 'Geocoder',
components: {
Multiselect
},
data() {
return {
loading: false,
limit: 10,
selection: null,
text: null,
selectedAddress: null,
addresses: [],
resultats: [],
placeholder: 'Rechercher une adresse ...',
isExpanded: false
};
},
mounted() {
this.addressTextChange = new Subject();
this.addressTextChange.pipe(debounceTime(200)).subscribe((res) => this.getAddresses(res));
},
methods: {
toggleGeocoder() {
this.isExpanded = !this.isExpanded;
if (this.isExpanded) {
this.retrievePreviousPlaces();
this.$nextTick(()=> this.$refs.multiselect.activate());
}
},
getAddresses(query){
if (query.length < 3) {
this.addresses = [];
return;
}
const coords = mapService.getMapCenter();
let url = `https://api-adresse.data.gouv.fr/search/?q=${query}&limit=${this.limit}`;
if (coords) url += `&lon=${coords[0]}&lat=${coords[1]}`;
apiAdressAxios.get(url)
.then((retour) => {
this.resultats = retour.data.features;
this.addresses = retour.data.features.map(x=>x.properties);
});
},
selectAddresse(event) {
this.selectedAddress = event;
if (this.selectedAddress !== null && this.selectedAddress.geometry) {
let zoomlevel = 14;
const { type } = this.selectedAddress.properties;
if (type === 'housenumber') {
zoomlevel = 19;
} else if (type === 'street') {
zoomlevel = 16;
} else if (type === 'locality') {
zoomlevel = 16;
}
// On ajoute un point pour localiser la ville
mapService.addOverlay(this.selectedAddress.geometry.coordinates, zoomlevel);
// On enregistre l'adresse sélectionné pour le proposer à la prochaine recherche
this.setLocalstorageSelectedAdress(this.selectedAddress);
this.toggleGeocoder();
}
},
search(text) {
this.text = text;
this.addressTextChange.next(this.text);
},
select(e) {
this.selectAddresse(this.resultats.find(x=>x.properties.label === e.label));
this.$emit('select', e);
},
close() {
this.$emit('close', this.selection);
},
setLocalstorageSelectedAdress(newAdress) {
let selectedAdresses = JSON.parse(localStorage.getItem('geocontrib-selected-adresses'));
selectedAdresses = Array.isArray(selectedAdresses) ? selectedAdresses : [];
selectedAdresses = [ newAdress, ...selectedAdresses ];
const uniqueLabels = [...new Set(selectedAdresses.map(el => el.properties.label))];
const uniqueAdresses = uniqueLabels.map((label) => {
return selectedAdresses.find(adress => adress.properties.label === label);
});
localStorage.setItem(
'geocontrib-selected-adresses',
JSON.stringify(uniqueAdresses.slice(0, 5))
);
},
getLocalstorageSelectedAdress() {
return JSON.parse(localStorage.getItem('geocontrib-selected-adresses')) || [];
},
retrievePreviousPlaces() {
const previousAdresses = this.getLocalstorageSelectedAdress();
if (previousAdresses.length > 0) {
this.addresses = previousAdresses.map(x=>x.properties);
this.resultats = previousAdresses;
}
},
}
};
</script>
<style lang="less">
#marker {
width: 14px;
height: 14px;
border: 2px solid #fff;
border-radius: 7px;
background-color: #3399CC;
}
#geocoder-container {
position: absolute;
right: 6px;
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(1.6em + 60px + 34px + 34px);
pointer-events: auto;
z-index: 999;
border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box;
padding: 0;
border-radius: 4px;
display: flex;
.button-geocoder {
border: none;
padding: 0;
margin: 0;
text-align: center;
background-color: #fff;
color: rgb(39, 39, 39);
width: 30px;
height: 30px;
font: 700 18px Lucida Console,Monaco,monospace;
border-radius: 2px;
line-height: 1.15;
i {
margin: 0;
font-size: 0.9em;
}
}
.button-geocoder:hover {
cursor: pointer;
background-color: #ebebeb;
}
.expanded-geocoder {
max-width: 400px;
}
&&.isExpanded {
.button-geocoder {
/*height: 41px;*/
color: rgb(99, 99, 99);
border-radius: 2px 0 0 2px;
}
}
}
#geocoder-select-container{
position: absolute;
right: 46px;
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(1.6em + 60px + 34px + 34px - 4px);
pointer-events: auto;
z-index: 999;
border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box;
padding: 0;
border-radius: 4px;
display: flex;
// /* keep placeholder width when opening dropdown */
.multiselect {
min-width: 208px;
}
/* keep font-weight from overide of semantic classes */
.multiselect__placeholder, .multiselect__content, .multiselect__tags {
font-weight: initial !important;
}
/* keep placeholder eigth */
.multiselect .multiselect__placeholder {
margin-bottom: 9px !important;
padding-top: 1px;
}
/* keep placeholder height when opening dropdown without selection */
input.multiselect__input {
padding: 3px 0 0 0 !important;
}
/* keep placeholder height when opening dropdown with already a value selected */
.multiselect__tags .multiselect__single {
padding: 1px 0 0 0 !important;
margin-bottom: 9px;
}
.multiselect__tags {
border: 0 !important;
min-height: 41px !important;
padding: 10px 40px 0 8px;
}
.multiselect input {
line-height: 1em !important;
padding: 0 !important;
}
.multiselect__content-wrapper {
border: 2px solid rgba(0,0,0,.2);
}
}
</style>
\ No newline at end of file
<template>
<div class="geolocation-container">
<button
:class="['button-geolocation', { tracking }]"
title="Me localiser"
@click.prevent="toggleTracking"
>
<i class="icon" />
</button>
</div>
</template>
<script>
import mapService from '@/services/map-service';
export default {
name: 'Geolocation',
data() {
return {
tracking: false,
};
},
methods: {
toggleTracking() {
this.tracking = !this.tracking;
mapService.toggleGeolocation(this.tracking);
},
}
};
</script>
\ No newline at end of file
<template>
<div class="basemaps-items ui accordion styled">
<div
:class="['basemap-item title', { active }]"
@click="$emit('activateGroup', basemap.id)"
>
<i
class="map outline fitted icon"
aria-hidden="true"
/>
<span>{{ basemap.title }}</span>
</div>
<div
v-if="queryableLayersOptions.length > 0 && active"
:id="`queryable-layers-selector-${basemap.id}`"
>
<strong>Couche requêtable</strong>
<Dropdown
:options="queryableLayersOptions"
:selected="selectedQueryLayer"
:search="true"
@update:selection="setQueryLayer($event)"
/>
</div>
<div
:id="`list-${basemap.id}`"
:class="['content', { active }]"
:data-basemap-index="basemap.id"
>
<div
v-for="layer in basemap.layers"
:key="basemap.id + '-' + layer.id + Math.random()"
class="layer-item transition visible item list-group-item"
:data-id="layer.id"
>
<!-- layer id is used for retrieving layer when changing order -->
<p class="layer-handle-sort">
<i
class="th icon"
aria-hidden="true"
/>
{{ layer.title }}
</p>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
type="range"
min="0"
max="1"
:value="layer.opacity"
step="0.01"
@change="updateOpacity($event, layer)"
><output class="range-output-bubble">{{
getOpacity(layer.opacity)
}}</output>
</div>
<div class="ui divider" />
</div>
</div>
</div>
</template>
<script>
import Sortable from 'sortablejs';
import Dropdown from '@/components/Dropdown.vue';
import mapService from '@/services/map-service';
export default {
name: 'LayerSelector',
components: {
Dropdown,
},
props: {
basemap: {
type: Object,
default: null,
},
active: {
type: Boolean,
default: false,
},
selectedQueryLayer: {
type: String,
default: ''
}
},
data() {
return {
sortable: null,
};
},
computed: {
queryableLayersOptions() {
const queryableLayers = this.basemap.layers.filter((l) => l.queryable === true);
return queryableLayers.map((x) => {
return {
name: x.title,
value: x,
};
});
},
},
mounted() {
setTimeout(this.initSortable.bind(this), 1000);
},
methods: {
isQueryable(baseMap) {
const queryableLayer = baseMap.layers.filter((l) => l.queryable === true);
return queryableLayer.length > 0;
},
initSortable() {
const element = document.getElementById(`list-${this.basemap.id}`);
if (element) {
this.sortable = new Sortable(element, {
animation: 150,
handle: '.layer-handle-sort', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: () => this.$emit('onlayerMove'),
});
} else {
console.error(`list-${this.basemap.id} not found in dom`);
}
},
setQueryLayer(layer) {
this.$emit('onQueryLayerChange', layer.name);
},
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
updateOpacity(event, layer) {
const layerId = layer.id;
const opacity = event.target.value;
mapService.updateOpacity(layerId, opacity);
this.$emit('onOpacityUpdate', { layerId, opacity });
},
}
};
</script>
<style>
.basemap-item.title > i {
margin-left: -1em !important;
}
.basemap-item.title > span {
margin-left: .5em;
}
.queryable-layers-dropdown {
margin-bottom: 1em;
}
.queryable-layers-dropdown > label {
font-weight: bold;
}
</style>
......@@ -3,10 +3,9 @@
v-if="isOnline"
:class="['sidebar-container', { expanded }]"
>
<!-- <div class="sidebar-layers"></div> -->
<div
class="layers-icon"
@click="expanded = !expanded"
@click="toggleSidebar()"
>
<!-- // ! svg point d'interrogation pas accepté par linter -->
<!-- <?xml version="1.0" encoding="iso-8859-1"?> -->
......@@ -45,92 +44,42 @@
<div class="basemaps-title">
<h4>
Fonds cartographiques
<!-- <span data-tooltip="Il est possible pour chaque fond cartographique de modifier l'ordre des couches"
data-position="bottom left">
<i class="question circle outline icon"></i>
</span> -->
</h4>
</div>
<div
<LayerSelector
v-for="basemap in baseMaps"
:key="`list-${basemap.id}`"
class="basemaps-items ui accordion styled"
>
<div
:class="{ active: isActive(basemap) }"
class="basemap-item title"
@click="activateGroup(basemap)"
>
{{ basemap.title }}
</div>
<div
v-if="isQueryable(basemap)"
:id="`queryable-layers-selector-${basemap.id}`"
>
<b>Couche requêtable</b>
<Dropdown
:options="getQueryableLayers(basemap)"
:selected="selectedQueryLayer"
:search="true"
@update:selection="onQueryLayerChange($event)"
/>
</div>
<div
:id="`list-${basemap.id}`"
:class="{ active: isActive(basemap) }"
class="content"
:data-basemap-index="basemap.id"
>
<div
v-for="(layer, index) in basemap.layers"
:key="basemap.id + '-' + layer.id + '-' + index"
class="layer-item transition visible item list-group-item"
:data-id="layer.id"
>
<p class="layer-handle-sort">
<i class="th icon" />{{ layer.title }}
</p>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
type="range"
min="0"
max="1"
:value="layer.opacity"
step="0.01"
@change="updateOpacity($event, layer)"
><output class="range-output-bubble">{{
getOpacity(layer.opacity)
}}</output>
</div>
<div class="ui divider" />
</div>
</div>
</div>
:basemap="basemap"
:selected-query-layer="selectedQueryLayer"
:active="basemap.active"
@addLayers="addLayers"
@activateGroup="activateGroup"
@onlayerMove="onlayerMove"
@onOpacityUpdate="onOpacityUpdate"
@onQueryLayerChange="onQueryLayerChange"
/>
</div>
</template>
<script>
import { mapState } from 'vuex';
import Sortable from 'sortablejs';
import Dropdown from '@/components/Dropdown.vue';
import { mapUtil } from '@/assets/js/map-util.js';
import LayerSelector from '@/components/Map/LayerSelector.vue';
import mapService from '@/services/map-service';
export default {
name: 'SidebarLayers',
components: {
Dropdown,
LayerSelector
},
data() {
return {
selectedQueryLayer: null,
activeBasemap: null,
baseMaps: [],
expanded: false,
projectSlug: this.$route.params.slug,
selectedQueryLayer: '',
};
},
......@@ -139,191 +88,143 @@ export default {
'isOnline',
]),
...mapState('map', [
'availableLayers'
'availableLayers',
'basemaps'
]),
activeBasemap() {
return this.baseMaps.find((baseMap) => baseMap.active);
},
activeBasemapIndex() {
return this.baseMaps.findIndex((el) => el.id === this.activeBasemap.id);
},
activeQueryableLayers() {
return this.baseMaps[this.activeBasemapIndex].layers.filter((layer) => layer.queryable);
},
},
mounted() {
this.baseMaps = this.$store.state.map.basemaps;
let project = this.$route.params.slug;
const mapOptions =
JSON.parse(localStorage.getItem('geocontrib-map-options')) || {};
if (mapOptions && mapOptions[project]) {
// If already in the storage, we need to check if the admin did some
// modification in the basemaps on the server side. The rule is: if one layer has been added
// or deleted in the server, then we reset the localstorage.
const baseMapsFromLocalstorage = mapOptions[project]['basemaps'];
const areChanges = this.areChangesInBasemaps(
this.baseMaps,
baseMapsFromLocalstorage
);
if (areChanges) {
mapOptions[project] = {
'map-options': this.baseMaps,
'current-basemap-index': 0,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
} else {
this.baseMaps = baseMapsFromLocalstorage;
}
}
if (this.baseMaps.length > 0) {
this.baseMaps[0].active = true;
this.activeBasemap = this.baseMaps[0];
this.addLayers(this.baseMaps[0]);
} else {
mapUtil.addLayers(
null,
this.$store.state.configuration.DEFAULT_BASE_MAP_SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS,
this.$store.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE
);
}
setTimeout(this.initSortable.bind(this), 1000);
this.initBasemaps();
},
methods: {
isActive(basemap) {
return basemap.active !== undefined && basemap.active;
},
activateGroup(basemap) {
this.baseMaps.forEach((basemap) => (basemap.active = false));
basemap.active = true;
this.activeBasemap = basemap;
basemap.title += ' '; //weird!! Force refresh
this.addLayers(basemap);
/**
* Initializes the basemaps and handles their state.
* This function checks if the basemaps stored in the local storage match those fetched from the server.
* If changes are detected, it resets the local storage with updated basemaps.
* It also sets the first basemap as active by default and adds the corresponding layers to the map.
*/
initBasemaps() {
// Clone object to not modify data in store, using JSON parse instead of spread operator
// that modifies data, for instance when navigating to basemap administration page
this.baseMaps = JSON.parse(JSON.stringify(this.basemaps));
let mapOptions = localStorage.getItem('geocontrib-map-options') || {};
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
let project = this.$route.params.slug;
mapOptions[project] = {
...mapOptions[project],
'current-basemap-index': basemap.id,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
},
// Retrieve map options from local storage
const mapOptions = JSON.parse(localStorage.getItem('geocontrib-map-options')) || {};
updateOpacity(event, layer) {
mapUtil.updateOpacity(layer.id, event.target.value);
layer.opacity = event.target.value;
},
// Check if map options exist for the current project
if (mapOptions && mapOptions[this.projectSlug]) {
// If already in the storage, we need to check if the admin did some
// modification in the basemaps on the server side.
// The rule is: if one layer has been added or deleted on the server,
// then we reset the local storage.
const baseMapsFromLocalstorage = mapOptions[this.projectSlug]['basemaps'];
const areChanges = this.areChangesInBasemaps(
this.baseMaps,
baseMapsFromLocalstorage
);
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
// 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;
}
}
onQueryLayerChange(layer) {
this.selectedQueryLayer = layer.name;
},
if (this.baseMaps.length > 0) {
// Set the first basemap as active by default
this.baseMaps[0].active = true;
isQueryable(baseMap) {
let queryableLayer = baseMap.layers.filter((l) => l.queryable === true);
return queryableLayer.length > 0;
},
// Check if an active layer has been set previously by the user
const activeBasemapId = mapOptions[this.projectSlug]
? mapOptions[this.projectSlug]['current-basemap-index']
: null;
onlayerMove() {
// Get the names of the current layers in order.
const currentLayersNamesInOrder = Array.from(
document.getElementsByClassName('layer-item transition visible')
).map((el) => el.children[0].innerText);
// Create an array to put the layers in order.
let movedLayers = [];
// 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;
}
});
}
for (const layerName of currentLayersNamesInOrder) {
movedLayers.push(
this.activeBasemap.layers.filter((el) => el.title === layerName)[0]
// 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
);
}
// Remove existing layers undefined
movedLayers = movedLayers.filter(function (x) {
return x !== undefined;
});
const eventOrder = new CustomEvent('change-layers-order', {
detail: {
layers: movedLayers,
},
});
document.dispatchEvent(eventOrder);
// Save the basemaps options into the localstorage
this.setLocalstorageMapOptions(this.baseMaps);
},
setLocalstorageMapOptions(basemaps) {
let mapOptions = localStorage.getItem('geocontrib-map-options') || {};
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
let project = this.$route.params.slug;
mapOptions[project] = {
...mapOptions[project],
basemaps: basemaps,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
toggleSidebar(value) {
this.expanded = value !== undefined ? value : !this.expanded;
},
initSortable() {
this.baseMaps.forEach((basemap) => {
let element=document.getElementById(`list-${basemap.id}`);
if(element) {
new Sortable(element, {
animation: 150,
handle: '.layer-handle-sort', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: this.onlayerMove.bind(this),
});
}
else{
console.error(`list-${basemap.id} not found in dom`);
}
});
},
// Check if there are changes in the basemaps settings. Changes are detected if:
// - one basemap has been added or deleted
// - one layer has been added or deleted to a basemap
areChangesInBasemaps(basemapFromServer, basemapFromLocalstorage = {}) {
areChangesInBasemaps(basemapFromServer, basemapFromLocalstorage) {
// prevent undefined later even if in this case the function is not called anyway
if (!basemapFromLocalstorage) return false;
let isSameBasemaps = false;
let isSameLayers = true;
let isSameTitles = true;
// Compare the length and the id values of the basemaps
const idBasemapsServer = basemapFromServer.map((b) => b.id).sort();
const idBasemapsLocalstorage = basemapFromLocalstorage.length
? basemapFromLocalstorage.map((b) => b.id).sort()
: {};
const idBasemapsLocalstorage = basemapFromLocalstorage.map((b) => b.id).sort() || {};
isSameBasemaps =
idBasemapsServer.length === idBasemapsLocalstorage.length &&
idBasemapsServer.every(
(value, index) => value === idBasemapsLocalstorage[index]
);
// For each basemap, compare the length and id values of the layers
// if basemaps changed, return that changed occured to avoid more processing
if (!isSameBasemaps) return true;
outer_block: {
for (let basemapServer of basemapFromServer) {
let idLayersServer = basemapServer.layers.map((b) => b.id).sort();
if (basemapFromLocalstorage.length) {
for (let basemapLocalstorage of basemapFromLocalstorage) {
if (basemapServer.id === basemapLocalstorage.id) {
let idLayersLocalstorage = basemapLocalstorage.layers
.map((b) => b.id)
.sort();
isSameLayers =
idLayersServer.length === idLayersLocalstorage.length &&
idLayersServer.every(
(value, index) => value === idLayersLocalstorage[index]
);
if (!isSameLayers) {
break outer_block;
}
// For each basemap from the server, compare the length and id values of the layers
for (const serverBasemap of basemapFromServer) {
// loop over basemaps from localStorage and check if layers id & queryable setting match with the layers from the server
// we don't check opacity since it would detect a change and reinit each time the user set it. It would need to be stored separatly like current basemap index
for (const localBasemap of basemapFromLocalstorage) {
if (serverBasemap.id === localBasemap.id) {
isSameLayers =
serverBasemap.layers.length === localBasemap.layers.length &&
serverBasemap.layers.every(
(layer, index) => layer.id === localBasemap.layers[index].id &&
layer.queryable === localBasemap.layers[index].queryable
);
if (!isSameLayers) {
break outer_block;
}
}
}
......@@ -332,9 +233,7 @@ export default {
const titlesBasemapsServer = basemapFromServer
.map((b) => b.title)
.sort();
const titlesBasemapsLocalstorage = basemapFromLocalstorage.length
? basemapFromLocalstorage.map((b) => b.title).sort()
: {};
const titlesBasemapsLocalstorage = basemapFromLocalstorage.map((b) => b.title).sort() || {};
isSameTitles = titlesBasemapsServer.every(
(title, index) => title === titlesBasemapsLocalstorage[index]
......@@ -347,37 +246,107 @@ export default {
return !(isSameBasemaps && isSameLayers && isSameTitles);
},
getQueryableLayers(baseMap) {
let queryableLayer = baseMap.layers.filter((l) => l.queryable === true);
return queryableLayer.map((x) => {
return {
name: x.title,
value: x,
};
});
},
addLayers(baseMap) {
baseMap.layers.forEach((layer) => {
var layerOptions = this.availableLayers.find((l) => l.id === layer.id);
const layerOptions = this.availableLayers.find((l) => l.id === layer.id);
layer = Object.assign(layer, layerOptions);
layer.options.basemapId = baseMap.id;
});
mapUtil.removeLayers(mapUtil.getMap());
mapService.removeLayers();
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
mapUtil.addLayers(baseMap.layers.slice().reverse(), null, null, null,);
mapService.addLayers(baseMap.layers.slice().reverse(), null, null, null,);
},
activateGroup(basemapId) {
//* to activate a basemap, we need to set the active property to true and set the others to false
this.baseMaps = this.baseMaps.map((bm) => {
return { ...bm, active: bm.id === basemapId ? true : false };
});
this.setSelectedQueryLayer();
//* add the basemap that was clicked to the map
this.addLayers(this.baseMaps.find((bm) => bm.id === basemapId));
//* to persist the settings, we set the localStorage mapOptions per project
this.setLocalstorageMapOptions({ 'current-basemap-index': basemapId });
},
onlayerMove() {
// Get ids of the draggable layers in its current order.
const currentLayersIdInOrder = Array.from(
document.querySelectorAll('.content.active .layer-item.transition.visible')
).map((el) => parseInt(el.attributes['data-id'].value));
// Create an array to put the original layers in the same order.
let movedLayers = [];
for (const layerId of currentLayersIdInOrder) {
movedLayers.push(
this.activeBasemap.layers.find((el) => el.id === layerId)
);
}
// Remove existing layers undefined
movedLayers = movedLayers.filter(function (x) {
return x !== undefined;
});
const eventOrder = new CustomEvent('change-layers-order', {
detail: {
layers: movedLayers,
},
});
document.dispatchEvent(eventOrder);
// report layers order change in the basemaps object before saving them
this.baseMaps[this.activeBasemapIndex].layers = movedLayers;
// Save the basemaps options into the localstorage
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
onOpacityUpdate(data) {
const { layerId, opacity } = data;
// retrieve layer to update opacity
this.baseMaps[this.activeBasemapIndex].layers.find((layer) => layer.id === layerId).opacity = opacity;
// Save the basemaps options into the localstorage
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
activateQueryLayer(layerTitle) {
const baseMapLayer = this.baseMaps[this.activeBasemapIndex].layers.find((l) => l.title === layerTitle);
if (baseMapLayer) {
// remove any query property in all layers and set query property at true for selected layer
this.baseMaps[this.activeBasemapIndex].layers.forEach((l) => delete l.query);
baseMapLayer['query'] = true;
// update selected query layer
this.setSelectedQueryLayer();
} else {
console.error('No such param \'query\' found among basemap[0].layers');
}
},
onQueryLayerChange(layerTitle) {
this.activateQueryLayer(layerTitle);
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
// retrieve selected query layer in active basemap (to be called when mounting and when changing active basemap)
setSelectedQueryLayer() {
const currentQueryLayer = this.baseMaps[this.activeBasemapIndex].layers.find((l) => l.query === true);
if (currentQueryLayer) {
this.selectedQueryLayer = currentQueryLayer.title;
} else if (this.activeQueryableLayers[0]) { // if no current query layer previously selected by user
this.activateQueryLayer(this.activeQueryableLayers[0].title); // then activate the first available query layer of the active basemap
}
},
setLocalstorageMapOptions(newOptionObj) {
let mapOptions = localStorage.getItem('geocontrib-map-options') || {};
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
mapOptions[this.projectSlug] = {
...mapOptions[this.projectSlug],
...newOptionObj,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
},
},
};
</script>
<style>
@import "../assets/styles/sidebar-layers.css";
.queryable-layers-dropdown {
margin-bottom: 1em;
}
.queryable-layers-dropdown > label {
font-weight: bold;
}
</style>
\ No newline at end of file
<template>
<li
:ref="'message-' + message.counter"
:class="['list-container', { show }]"
>
<div :class="['list-item', { show}]">
<div :class="['ui', message.level ? message.level : 'info', 'message']">
<i
class="close icon"
aria-hidden="true"
@click="removeListItem"
/>
<div class="header">
<i
:class="[headerIcon, 'circle icon']"
aria-hidden="true"
/>
{{ message.header || 'Informations' }}
</div>
<ul class="list">
{{
message.comment
}}
</ul>
</div>
</div>
</li>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
name: 'MessageInfo',
props: {
message: {
type: Object,
default: () => {},
},
},
data() {
return {
listMessages: [],
show: false,
};
},
computed: {
...mapState(['messages']),
headerIcon() {
switch (this.message.level) {
case 'positive':
return 'check';
case 'negative':
return 'times';
default:
return 'info';
}
}
},
mounted() {
setTimeout(() => {
this.show = true;
}, 15);
},
methods: {
...mapMutations(['DISCARD_MESSAGE']),
removeListItem(){
const container = this.$refs['message-' + this.message.counter];
container.ontransitionend = () => {
this.DISCARD_MESSAGE(this.message.counter);
};
this.show = false;
},
},
};
</script>
<style scoped>
.list-container{
list-style: none;
width: 100%;
height: 0;
position: relative;
cursor: pointer;
overflow: hidden;
transition: all 0.6s ease-out;
}
.list-container.show{
height: 7.5em;
}
@media screen and (min-width: 726px) {
.list-container.show{
height: 6em;
}
}
.list-container.show:not(:first-child){
margin-top: 10px;
}
.list-container .list-item{
padding: .5rem 0;
width: 100%;
position: absolute;
opacity: 0;
top: 0;
left: 0;
transition: all 0.6s ease-out;
}
.list-container .list-item.show{
opacity: 1;
}
ul.list{
overflow: auto;
height: 3.5em;
margin-bottom: .5em !important;
}
@media screen and (min-width: 726px) {
ul.list{
height: 2.2em;
}
}
.ui.message {
overflow: hidden;
padding-bottom: 0 !important;
}
.ui.message::after {
content: "";
position: absolute;
bottom: 0;
left: 1em;
right: 0;
width: calc(100% - 2em);
}
.ui.info.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.positive.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.negative.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.message > .close.icon {
cursor: pointer;
position: absolute;
margin: 0em;
top: 0.78575em;
right: 0.5em;
opacity: 0.7;
-webkit-transition: opacity 0.1s ease;
transition: opacity 0.1s ease;
}
</style>
\ No newline at end of file
<template>
<div
v-if="messages && messages.length > 0"
class="row over-content"
>
<div class="fourteen wide column">
<ul
class="message-list"
aria-live="assertive"
>
<MessageInfo
v-for="message in messages"
:key="'message-' + message.counter"
:message="message"
/>
</ul>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import MessageInfo from '@/components/MessageInfo';
export default {
name: 'MessageInfoList',
components: {
MessageInfo,
},
computed: {
...mapState(['messages']),
},
};
</script>
<style scoped>
.row.over-content {
position: absolute; /* to display message info over page content */
z-index: 99;
opacity: 0.95;
width: calc(100% - 4em); /* 4em is #content left + right paddings */
top: calc(40px + 1em); /* 40px is #app-header height */
right: 2em; /* 2em is #content left paddings */
}
.message-list{
list-style: none;
padding-left: 0;
margin-top: 0;
}
@media screen and (max-width: 725px) {
.row.over-content {
top: calc(80px + 1em); /* 90px is #app-header height in mobile display */
width: calc(100% - 2em);
right: 1em;
}
}
</style>
\ No newline at end of file
......@@ -4,14 +4,16 @@
<ul class="custom-pagination">
<li
class="page-item"
:class="{ disabled: page === 1 }"
:class="{ disabled: currentPage === 1 }"
>
<a
class="page-link"
:href="currentLocation"
@click="page -= 1"
class="page-link pointer"
@click="changePage(currentPage - 1)"
>
<i class="ui icon big angle left" />
<i
class="ui icon big angle left"
aria-hidden="true"
/>
</a>
</li>
<div
......@@ -19,14 +21,13 @@
style="display: contents;"
>
<li
v-for="index in pagination(page, nbPages)"
v-for="index in pagination(currentPage, nbPages)"
:key="index"
class="page-item"
:class="{ active: page === index }"
:class="{ active: currentPage === index }"
>
<a
class="page-link"
:href="currentLocation"
class="page-link pointer"
@click="changePage(index)"
>
{{ index }}
......@@ -41,12 +42,11 @@
v-for="index in nbPages"
:key="index"
class="page-item"
:class="{ active: page === index }"
:class="{ active: currentPage === index }"
>
<a
class="page-link"
:href="currentLocation"
@click="page = index"
class="page-link pointer"
@click="changePage(index)"
>
{{ index }}
</a>
......@@ -54,14 +54,16 @@
</div>
<li
class="page-item"
:class="{ disabled: page === nbPages }"
:class="{ disabled: currentPage === nbPages }"
>
<a
class="page-link"
:href="currentLocation"
@click="page += 1"
class="page-link pointer"
@click="changePage(currentPage + 1)"
>
<i class="ui icon big angle right" />
<i
class="ui icon big angle right"
aria-hidden="true"
/>
</a>
</li>
</ul>
......@@ -70,6 +72,8 @@
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
name: 'Pagination',
......@@ -78,32 +82,18 @@ export default {
type: Number,
default: 1
},
onPageChange: {
type: Function,
default: () => {
return () => 1;
}
}
},
data() {
return {
page: 1,
currentLocation: window.location.origin + window.location.pathname + '#',
};
},
watch: {
page: function(newValue, oldValue) {
if (newValue !== oldValue) {
this.onPageChange(newValue);
this.$emit('change-page', newValue);
}
}
computed: {
...mapState('projects', ['currentPage']),
},
methods: {
...mapMutations('projects', [
'SET_CURRENT_PAGE',
'SET_PROJECTS_FILTER'
]),
pagination(c, m) {
const current = c,
last = m,
......@@ -134,9 +124,13 @@ export default {
return rangeWithDots;
},
changePage(num) {
if (typeof num === 'number') {
this.page = num;
changePage(pageNumber) {
if (typeof pageNumber === 'number') {
this.SET_CURRENT_PAGE(pageNumber);
// Scroll back to the first results on top of page
window.scrollTo({ top: 0, behavior: 'smooth' });
// emit event for parent component to fetch new page data
this.$emit('page-update', pageNumber);
}
}
}
......
<template>
<div class="ui segment">
<div class="ui segment secondary">
<div class="field required">
<label for="basemap-title">Titre</label>
<input
......@@ -23,7 +23,7 @@
<div class="nested">
<div
:id="`list-${basemap.id}`"
:class="[basemap.layers.length > 0 ? 'ui segments': '', 'layers-container']"
:class="[basemap.layers.length > 0 ? 'ui segments': '', 'layers-container', 'raised']"
>
<ProjectMappingContextLayer
v-for="layer in basemap.layers"
......@@ -32,41 +32,38 @@
:basemapid="basemap.id"
/>
</div>
<div class="ui buttons">
<a
class="ui compact small icon left floated button green"
<div class="ui bottom two attached buttons">
<button
class="ui icon button basic positive"
type="button"
@click="addLayer"
>
<i class="ui plus icon" />
<span>Ajouter une couche</span>
</a>
</div>
<div
class="ui buttons"
@click="deleteBasemap"
>
<a
class="
ui
compact
red
small
icon
right
floated
button button-hover-green
"
<i
class="ui plus icon"
aria-hidden="true"
/>
<span>&nbsp;Ajouter une couche</span>
</button>
<button
class=" ui icon button basic negative"
type="button"
@click="deleteBasemap"
>
<i class="ui trash alternate icon" />
<span>Supprimer ce fond cartographique</span>
</a>
<i
class="ui trash alternate icon"
aria-hidden="true"
/>
<span>&nbsp;Supprimer ce fond cartographique</span>
</button>
</div>
</div>
</div>
</template>
<script>
import { mapMutations } from 'vuex';
import Sortable from 'sortablejs';
import ProjectMappingContextLayer from '@/components/Project/Basemaps/ProjectMappingContextLayer.vue';
......@@ -84,6 +81,12 @@ export default {
}
},
data() {
return {
sortable: null
};
},
computed: {
maxLayersCount: function () {
return this.basemap.layers.reduce((acc, curr) => {
......@@ -108,8 +111,13 @@ export default {
},
methods: {
...mapMutations('map', [
'UPDATE_BASEMAP',
'DELETE_BASEMAP',
'REPLACE_BASEMAP_LAYERS'
]),
deleteBasemap() {
this.$store.commit('map/DELETE_BASEMAP', this.basemap.id);
this.DELETE_BASEMAP(this.basemap.id);
},
addLayer(layer) {
......@@ -121,7 +129,7 @@ export default {
title: 'Open street map',
...layer,
};
this.$store.commit('map/UPDATE_BASEMAP', {
this.UPDATE_BASEMAP({
layers: [...this.basemap.layers, newLayer],
id: this.basemap.id,
title: this.basemap.title,
......@@ -134,14 +142,11 @@ export default {
if(evt.target.value === '') { //* delete or add error message while typing
errors = 'Veuillez compléter ce champ.';
}
this.$store.commit('map/UPDATE_BASEMAP', {
this.UPDATE_BASEMAP({
id: this.basemap.id,
title: evt.target.value,
errors,
});
},
removeLayer(dataKey) {
......@@ -150,7 +155,7 @@ export default {
fillLayersWithDatakey(layers) {
let dataKey = 0;
this.$store.commit('map/REPLACE_BASEMAP_LAYERS', {
this.REPLACE_BASEMAP_LAYERS({
basemapId: this.basemap.id,
layers: layers.map((el) => {
dataKey += 1;
......@@ -163,14 +168,14 @@ export default {
onlayerMove() {
//* Get the names of the current layers in order.
const currentLayersNamesInOrder = Array.from(
document.getElementsByClassName('layer-item')
document.getElementsByClassName(`basemap-${this.basemap.id}`)
).map((el) => el.id);
//* increment value 'order' in this.basemap.layers looping over layers from template ^
let order = 0;
let movedLayers = [];
for (let id of currentLayersNamesInOrder) {
let matchingLayer = this.basemap.layers.find(
const movedLayers = [];
for (const id of currentLayersNamesInOrder) {
const matchingLayer = this.basemap.layers.find(
(el) => el.dataKey === Number(id)
);
if (matchingLayer) {
......@@ -180,7 +185,7 @@ export default {
}
}
//* update the store
this.$store.commit('map/UPDATE_BASEMAP', {
this.UPDATE_BASEMAP({
layers: movedLayers,
id: this.basemap.id,
title: this.basemap.title,
......@@ -189,7 +194,7 @@ export default {
},
initSortable() {
new Sortable(document.getElementById(`list-${this.basemap.id}`), {
this.sortable = new Sortable(document.getElementById(`list-${this.basemap.id}`), {
animation: 150,
handle: '.layer-handle-sort', // The element that is active to drag
ghostClass: 'blue-background-class',
......@@ -200,9 +205,3 @@ export default {
},
};
</script>
<style scoped>
.button {
margin-right: 0.5em !important;
}
</style>
\ No newline at end of file
<template>
<div
:id="layer.dataKey"
class="ui segment layer-item"
:class="`ui segment layer-item basemap-${basemapid}`"
>
<div class="ui divided form">
<div
......@@ -9,10 +9,14 @@
data-type="layer-field"
>
<label
for="form.layer.id_for_label"
:for="layer.title"
class="layer-handle-sort"
>
<i class="th icon" />couche
<i
class="th icon"
aria-hidden="true"
/>
couche
</label>
<Dropdown
:options="availableLayerOptions"
......@@ -22,45 +26,43 @@
:placeholder="placeholder"
/>
</div>
<div class="fields">
<div class="six wide field">
<label for="opacity">Opacité</label>
<input
v-model.number="layerOpacity"
type="number"
oninput="validity.valid||(value='');"
step="0.01"
min="0"
max="1"
>
</div>
<div class="field">
<div
class="field three wide {% if form.opacity.errors %} error{% endif %}"
class="ui checkbox"
@click="updateLayer({ ...layer, queryable: !layer.queryable })"
>
<label for="opacity">Opacité</label>
<input
v-model.number="layerOpacity"
type="number"
oninput="validity.valid||(value='');"
step="0.01"
min="0"
max="1"
:checked="layer.queryable"
class="hidden"
type="checkbox"
name="queryable"
>
</div>
<div class="field three wide">
<div
class="ui checkbox"
@click="updateLayer({ ...layer, queryable: !layer.queryable })"
>
<input
:checked="layer.queryable"
class="hidden"
type="checkbox"
name="queryable"
>
<label for="queryable"> Requêtable</label>
</div>
<label for="queryable">&nbsp;Requêtable</label>
</div>
</div>
<div
class="field"
<button
type="button"
class="ui compact small icon floated button button-hover-red"
@click="removeLayer"
>
<div class="ui compact small icon floated button button-hover-red">
<i class="ui grey trash alternate icon" />
<span>Supprimer cette couche</span>
</div>
</div>
<i
class="ui grey trash alternate icon"
aria-hidden="true"
/>
<span>&nbsp;Supprimer cette couche</span>
</button>
</div>
</div>
</template>
......@@ -173,4 +175,4 @@ export default {
},
},
};
</script>
\ No newline at end of file
</script>
......@@ -3,7 +3,10 @@
<h3 class="ui header">
Types de signalements
</h3>
<div class="ui middle aligned divided list">
<div
id="feature_type-list"
class="ui middle aligned divided list"
>
<div
:class="{ active: loading }"
class="ui inverted dimmer"
......@@ -22,235 +25,228 @@
</div>
<div
v-for="(type, index) in feature_types"
:id="type.title"
:key="type.title + '-' + index"
class="item"
>
<div class="feature-type-container">
<router-link
:to="{
name: 'details-type-signalement',
params: { feature_type_slug: type.slug },
}"
class="feature-type-title"
>
<img
v-if="type.geom_type === 'point'"
class="list-image-type"
src="@/assets/img/marker.png"
>
<img
v-if="type.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
>
<img
v-if="type.geom_type === 'polygon'"
class="list-image-type"
src="@/assets/img/polygon.png"
>
{{ type.title }}
</router-link>
<FeatureTypeLink :feature-type="type" />
<div class="middle aligned content">
<router-link
v-if="
project && permissions && permissions.can_create_feature
"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-green
"
data-tooltip="Ajouter un signalement"
data-position="top right"
data-variation="mini"
>
<i class="ui plus icon" />
</router-link>
<router-link
v-if="
project &&
permissions &&
permissions.can_create_feature_type &&
isOffline() !== true
"
:to="{
name: 'dupliquer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-green
"
data-tooltip="Dupliquer un type de signalement"
data-position="top right"
data-variation="mini"
>
<i class="inverted grey copy alternate icon" />
</router-link>
<div
v-if="isImporting(type)"
class="import-message"
>
<i class="info circle icon" />
<i
class="info circle icon"
aria-hidden="true"
/>
Import en cours
</div>
<div
v-else
>
<a
v-if="isProjectAdmin && isOffline() !== true"
class="
ui
compact
small
icon
right
floated
button button-hover-red
"
data-tooltip="Supprimer le type de signalement"
data-position="top center"
data-variation="mini"
@click="toggleDeleteFeatureType(type)"
>
<i class="inverted grey trash alternate icon" />
</a>
<template v-else>
<router-link
v-if="
project &&
permissions &&
permissions.can_create_feature_type &&
isOffline() !== true
"
v-if="project && type.is_editable && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'editer-symbologie-signalement',
name: 'editer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-orange
"
data-tooltip="Éditer la symbologie du type de signalement"
class="ui compact small icon button button-hover-orange tiny-margin"
data-tooltip="Éditer le type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`edit-feature-type-for-${type.title}`"
>
<i class="inverted grey paint brush alternate icon" />
<i
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<router-link
v-if="
project &&
type.is_editable &&
permissions &&
permissions.can_create_feature_type &&
isOffline() !== true
"
v-if="project && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'editer-type-signalement',
name: 'editer-affichage-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-orange
"
data-tooltip="Éditer le type de signalement"
class="ui compact small icon button button-hover-orange tiny-margin"
data-tooltip="Éditer l'affichage du type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`edit-feature-type-display-for-${type.title}`"
>
<i class="inverted grey pencil alternate icon" />
<i
class="inverted grey paint brush alternate icon"
aria-hidden="true"
/>
</router-link>
</div>
<a
v-if="isProjectAdmin && isOnline"
class="ui compact small icon button button-hover-red tiny-margin"
data-tooltip="Supprimer le type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`delete-feature-type-for-${type.title}`"
@click="toggleDeleteFeatureType(type)"
>
<i
class="inverted grey trash alternate icon"
aria-hidden="true"
/>
</a>
</template>
<router-link
v-if="project && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'dupliquer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-green tiny-margin"
data-tooltip="Dupliquer un type de signalement"
data-position="top right"
data-variation="mini"
:data-test="`duplicate-feature-type-for-${type.title}`"
>
<i
class="inverted grey copy alternate icon"
aria-hidden="true"
/>
</router-link>
<router-link
v-if="project && permissions && permissions.can_create_feature && !type.geom_type.includes('multi')"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-green tiny-margin"
data-tooltip="Ajouter un signalement"
data-position="top right"
data-variation="mini"
:data-test="`add-feature-for-${type.title}`"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
</router-link>
</div>
</div>
</div>
<div v-if="feature_types.length === 0">
<i> Le projet ne contient pas encore de type de signalements. </i>
<em> Le projet ne contient pas encore de type de signalements. </em>
</div>
</div>
<div id="nouveau-type-signalement">
<div id="new-feature-type-container">
<div
class="ui small button circular compact floated right icon teal help"
data-tooltip="Consulter la documentation"
data-position="bottom right"
data-variation="mini"
data-test="read-doc"
>
<i
class="question icon"
@click="goToDocumentation"
/>
</div>
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
v-if="permissions && permissions.can_update_project && isOnline"
:to="{
name: 'ajouter-type-signalement',
params: { slug },
}"
class="ui compact basic button"
data-test="add-feature-type"
>
<i class="ui plus icon" />Créer un nouveau type de signalement
<i
class="ui plus icon"
aria-hidden="true"
/>
<label class="ui pointer">
Créer un nouveau type de signalement
</label>
</router-link>
</div>
<div class="nouveau-type-signalement">
<div
v-if="permissions && permissions.can_update_project && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-geojson"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui pointer"
for="geojson_file"
>
Créer un nouveau type de signalement à partir d'un GeoJSON
</label>
<input
id="geojson_file"
type="file"
accept=".geojson"
style="display: none"
name="geojson_file"
@change="onGeoJSONFileChange"
>
</div>
<div
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
class="
ui
compact
basic
button
button-align-left
"
v-if="permissions && permissions.can_update_project && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-json"
>
<i class="ui plus icon" />
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui"
class="ui pointer"
for="json_file"
>
<span
class="label"
>Créer un nouveau type de signalement à partir d'un
GeoJSON</span>
Créer un nouveau type de signalement à partir d'un JSON (non-géographique)
</label>
<input
id="json_file"
type="file"
accept="application/json, .json, .geojson"
accept="application/json, .json"
style="display: none"
name="json_file"
@change="onFileChange"
@change="onGeoJSONFileChange"
>
</div>
<div
v-if="permissions && permissions.can_update_project && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-csv"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui pointer"
for="csv_file"
>
Créer un nouveau type de signalement à partir d'un CSV
</label>
<input
id="csv_file"
type="file"
accept="application/csv, .csv"
style="display: none"
name="csv_file"
@change="onCSVFileChange"
>
</div>
</div>
<div class="nouveau-type-signalement">
<router-link
v-if="
IDGO &&
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
v-if="IDGO && permissions && permissions.can_update_project && isOnline"
:to="{
name: 'catalog-import',
params: {
......@@ -259,30 +255,67 @@
},
}"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-catalog"
>
<i class="ui plus icon" />
Créer un nouveau type de signalement à partir du catalogue {{ CATALOG_NAME|| 'IDGO' }}
<i
class="ui plus icon"
aria-hidden="true"
/>
Créer un nouveau type de signalement à partir du catalogue {{ CATALOG_NAME || 'IDGO' }}
</router-link>
</div>
<div
v-if="fileToImport.size > 0"
v-if="geojsonFileToImport.size > 0"
id="button-import"
>
<button
:disabled="geojsonFileToImport.size === 0"
class="ui fluid teal icon button"
data-test="start-geojson-file-import"
@click="toNewGeojsonFeatureType"
>
<i
class="upload icon"
aria-hidden="true"
/> Lancer l'import avec le fichier
{{ geojsonFileToImport.name }}
</button>
</div>
<div
v-if="csvFileToImport.size > 0 && !csvError"
id="button-import"
>
<button
:disabled="fileToImport.size === 0"
:disabled="csvFileToImport.size === 0"
class="ui fluid teal icon button"
@click="toNewFeatureType"
data-test="start-csv-file-import"
@click="toNewCsvFeatureType"
>
<i class="upload icon" /> Lancer l'import avec le fichier
{{ fileToImport.name }}
<i
class="upload icon"
aria-hidden="true"
/> Lancer l'import avec le fichier
{{ csvFileToImport.name }}
</button>
</div>
<div
v-if="csvError"
class="ui negative message"
>
<i
class="close icon"
aria-hidden="true"
@click="csvError = null; csvFileToImport = { name: '', size: 0 }"
/>
{{ csvError }}
</div>
<!-- MODALE FILESIZE -->
<div
:class="isFileSizeModalOpen ? 'active' : ''"
class="ui dimmer"
class="ui dimmer inverted"
>
<div
:class="isFileSizeModalOpen ? 'active' : ''"
......@@ -295,7 +328,7 @@
<div class="content">
<p>
Impossible de créer un type de signalement à partir d'un fichier
GeoJSON de plus de 10Mo (celui importé fait {{ fileSize }} Mo).
de plus de 100Mo (celui importé fait {{ geojsonFileSize > 0 ? geojsonFileSize : csvFileSize }} Mo).
</p>
</div>
<div class="actions">
......@@ -312,15 +345,21 @@
</template>
<script>
import { csv } from 'csvtojson';
import { mapState, mapGetters, mapActions } from 'vuex';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import { fileConvertSizeToMo } from '@/assets/js/utils';
import { fileConvertSizeToMo, determineDelimiter } from '@/assets/js/utils';
import FeatureTypeLink from '@/components/FeatureType/FeatureTypeLink';
export default {
name: 'ProjectFeatureTypes',
components: {
FeatureTypeLink
},
props: {
loading: {
type: Boolean,
......@@ -330,7 +369,7 @@ export default {
type: Object,
default: () => {
return {};
}
},
}
},
......@@ -339,14 +378,21 @@ export default {
importing: false,
slug: this.$route.params.slug,
isFileSizeModalOpen: false,
fileToImport: { name: '', size: 0 },
geojsonImport: [],
csvImport: null,
csvError: null,
geojsonFileToImport: { name: '', size: 0 },
csvFileToImport: { name: '', size: 0 },
fetchCallCounter: 0,
hadPending: false
};
},
computed: {
...mapState([
'configuration',
'isOnline',
'user_permissions',
]),
...mapState('feature-type', [
'feature_types',
......@@ -366,55 +412,49 @@ export default {
return this.user_permissions && this.user_permissions[this.slug] &&
this.user_permissions[this.slug].is_project_administrator;
},
fileSize() {
return fileConvertSizeToMo(this.fileToImport.size);
geojsonFileSize() {
return fileConvertSizeToMo(this.geojsonFileToImport.size);
},
},
watch: {
feature_types: {
deep: true,
handler(newValue, oldValue) {
if (newValue && newValue !== oldValue) {
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
});
}
},
csvFileSize() {
return fileConvertSizeToMo(this.csvFileToImport.size);
},
},
importFeatureTypeData: {
deep: true,
handler(newValue) {
if (
newValue &&
newValue.some((el) => el.status === 'pending') &&
!this.reloadIntervalId
) {
this.SET_RELOAD_INTERVAL_ID(
setInterval(() => {
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
});
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL)
);
} else if (
newValue &&
!newValue.some((el) => el.status === 'pending') &&
this.reloadIntervalId
) {
this.GET_PROJECT_FEATURE_TYPES(this.slug);
this.CLEAR_RELOAD_INTERVAL_ID();
}
},
},
mounted() {
this.fetchImports();
},
methods: {
...mapMutations('feature-type', [
'SET_FILE_TO_IMPORT'
]),
...mapActions('feature-type', [
'GET_IMPORTS',
'GET_PROJECT_FEATURE_TYPES'
]),
fetchImports() {
this.fetchCallCounter += 1; // register each time function is programmed to be called in order to avoid redundant calls
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
})
.then((response) => {
if (response.data && response.data.some(el => el.status === 'pending')) {
this.hadPending = true; // store pending import to know if project need to be updated, after mounted
// 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);
} else if (this.hadPending) {
// if no more pending import, get last features
this.$emit('update');
}
});
},
isImporting(type) {
if (this.importFeatureTypeData) {
const singleImportData = this.importFeatureTypeData.find(
......@@ -425,43 +465,67 @@ export default {
return false;
},
isOffline() {
return navigator.onLine === false;
goToDocumentation() {
window.open(this.configuration.VUE_APP_URL_DOCUMENTATION);
},
toNewFeatureType() {
toNewGeojsonFeatureType() {
this.importing = true;
if(typeof this.geojsonImport == 'object'){
if(!Array.isArray(this.geojsonImport)){
this.$router.push({
name: 'ajouter-type-signalement',
params: {
geojson: this.geojsonImport,
fileToImport: this.geojsonFileToImport,
},
});
}else{
this.$router.push({
name: 'ajouter-type-signalement',
params: {
json: this.geojsonImport,
fileToImport: this.geojsonFileToImport,
},
});
}
}
this.importing = false;
},
toNewCsvFeatureType() {
this.importing = true;
this.$router.push({
name: 'ajouter-type-signalement',
params: {
geojson: this.geojsonImport,
fileToImport: this.fileToImport,
csv: this.csvImport,
fileToImport: this.csvFileToImport,
},
});
this.importing = false;
},
onFileChange(e) {
onGeoJSONFileChange(e) {
this.importing = true;
var files = e.target.files || e.dataTransfer.files;
if (!files.length) return;
this.fileToImport = files[0];
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.geojsonFileToImport = files[0];
// TODO : VALIDATION IF FILE IS JSON
if (parseFloat(fileConvertSizeToMo(this.fileToImport.size)) > 10) {
if (parseFloat(fileConvertSizeToMo(this.geojsonFileToImport.size)) > 100) {
this.isFileSizeModalOpen = true;
} else if (this.fileToImport.size > 0) {
} else if (this.geojsonFileToImport.size > 0) {
const fr = new FileReader();
try {
fr.onload = (e) => {
this.geojsonImport = JSON.parse(e.target.result);
fr.onload = (ev) => {
this.geojsonImport = JSON.parse(ev.target.result);
this.importing = false;
};
fr.readAsText(this.fileToImport);
fr.readAsText(this.geojsonFileToImport);
//* stock filename to import features afterward
this.$store.commit(
'feature-type/SET_FILE_TO_IMPORT',
this.fileToImport
);
this.SET_FILE_TO_IMPORT(this.geojsonFileToImport);
} catch (err) {
console.error(err);
this.importing = false;
......@@ -471,15 +535,55 @@ export default {
}
},
onCSVFileChange(e) {
this.featureTypeImporting = true;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.csvFileToImport = files[0];
if (parseFloat(fileConvertSizeToMo(this.csvFileToImport.size)) > 100) {
this.isFileSizeModalOpen = true;
} else if (this.csvFileToImport.size > 0) {
const fr = new FileReader();
try {
fr.readAsText(this.csvFileToImport);
fr.onloadend = () => {
// Find csv delimiter
const delimiter = determineDelimiter(fr.result);
if (!delimiter) {
this.csvError = `Le fichier ${this.csvFileToImport.name} n'est pas formaté correctement`;
this.featureTypeImporting = false;
return;
}
this.csvError = null;
csv({ delimiter })
.fromString(fr.result)
.then((jsonObj)=>{
this.csvImport = jsonObj;
});
this.featureTypeImporting = false;
//* stock filename to import features afterward
this.SET_FILE_TO_IMPORT(this.csvFileToImport);
};
} catch (err) {
console.error(err);
this.featureTypeImporting = false;
}
} else {
this.featureTypeImporting = false;
}
},
closeFileSizeModal() {
this.fileToImport = { name: '', size: 0 };
this.geojsonFileToImport = { name: '', size: 0 };
this.csvFileToImport = { name: '', size: 0 };
this.importing = false;
this.isFileSizeModalOpen = false;
},
toggleDeleteFeatureType(featureType) {
this.featureTypeToDelete = featureType;
this.$emit('modal', 'deleteFeatureType');
this.$emit('delete', featureType);
},
}
......@@ -487,43 +591,41 @@ export default {
</script>
<style>
/* // ! missing style in semantic.min.css, je ne comprends pas comment... */
/* // ! missing style in semantic.min.css */
.ui.right.floated.button {
float: right;
}
</style>
<style lang="less" scoped>
.list-image-type {
margin-right: 5px;
height: 25px;
vertical-align: bottom;
}
.feature-type-container {
display: flex;
justify-content: space-between;
align-items: center;
.feature-type-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.5em;
}
& > .middle.aligned.content {
display: flex;
}
}
.feature-type-container > .middle.aligned.content {
width: 50%;
}
.feature-type-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.5em;
}
.nouveau-type-signalement {
margin-top: 1em;
}
.nouveau-type-signalement .label{
cursor: pointer;
#new-feature-type-container {
& > div {
margin: 1em 0;
}
& > div.help {
margin-top: 0;
}
.button:not(.help) {
line-height: 1.25em;
.icon {
height: auto;
}
}
}
#button-import {
......@@ -538,9 +640,13 @@ export default {
}
.import-message {
width: fit-content;
line-height: 2em;
color: teal;
color: var(--primary-highlight-color, #008c86);
white-space: nowrap;
padding: .25em;
display: flex;
align-items: center;
& > i {
height: auto;
}
}
</style>
\ No newline at end of file
</style>
<template>
<div class="project-header ui grid">
<div class="project-header ui grid stackable">
<div class="row">
<div class="three wide middle aligned column">
<img
class="ui small spaced image"
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
>
<div class="ui hidden divider" />
<div
class="ui basic teal label"
data-tooltip="Membres"
>
<i class="user icon" />{{ project.nb_contributors }}
</div>
<div
class="ui basic teal label"
data-tooltip="Signalements publiés"
>
<i class="map marker icon" />{{ project.nb_published_features }}
<div class="margin-bottom">
<img
class="ui small centered image"
alt="Thumbnail du projet"
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
>
</div>
<div
class="ui basic teal label"
data-tooltip="Commentaires"
>
<i class="comment icon" />{{
project.nb_published_features_comments
}}
<div class="centered">
<div
class="ui basic teal label tiny-margin"
data-tooltip="Membres"
>
<i
class="user icon"
aria-hidden="true"
/>{{ project.nb_contributors }}
</div>
<div
class="ui basic teal label tiny-margin"
data-tooltip="Signalements publiés"
>
<i
class="map marker icon"
aria-hidden="true"
/>{{ project.nb_published_features }}
</div>
<div
class="ui basic teal label tiny-margin"
data-tooltip="Commentaires"
>
<i
class="comment icon"
aria-hidden="true"
/>{{
project.nb_published_features_comments
}}
</div>
</div>
</div>
<div class="nine wide column">
<h1 class="ui header">
<h1 class="ui header margin-bottom">
{{ project.title }}
</h1>
<div class="ui hidden divider" />
<div class="sub header">
{{ project.description }}
<!-- {{ project.description }} -->
<div id="preview" />
<textarea
id="editor"
v-model="project.description"
data-preview="#preview"
hidden
/>
</div>
</div>
......@@ -50,75 +69,117 @@
user &&
permissions &&
permissions.can_view_project &&
isOffline() !== true
isOnline
"
id="subscribe-button"
class="ui button button-hover-green"
data-tooltip="S'abonner au projet"
data-position="top center"
class="ui button button-hover-green tiny-margin"
data-tooltip="Gérer mon abonnement au projet"
data-position="bottom center"
data-variation="mini"
@click="OPEN_PROJECT_MODAL('subscribe')"
>
<i class="inverted grey envelope icon" />
<i
class="inverted grey envelope icon"
aria-hidden="true"
/>
</a>
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
isOnline
"
id="edit-project"
:to="{ name: 'project_edit', params: { slug } }"
class="ui button button-hover-orange"
class="ui button button-hover-orange tiny-margin"
data-tooltip="Modifier le projet"
data-position="top center"
data-position="bottom center"
data-variation="mini"
>
<i class="inverted grey pencil alternate icon" />
<i
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<a
v-if="isProjectAdmin && isOffline() !== true"
v-if="isProjectAdmin && isOnline"
id="delete-button"
class="ui button button-hover-red"
class="ui button button-hover-red tiny-margin"
data-tooltip="Supprimer le projet"
data-position="top center"
data-position="bottom right"
data-variation="mini"
@click="OPEN_PROJECT_MODAL('deleteProject')"
>
<i class="inverted grey trash icon" />
<i
class="inverted grey trash icon"
aria-hidden="true"
/>
</a>
<div
v-if="isProjectAdmin && !isSharedProject"
id="share-button"
class="ui dropdown button compact tiny-margin"
data-tooltip="Partager le projet"
data-position="bottom right"
data-variation="mini"
@click="toggleShareOptions"
>
<i
class="inverted grey share icon"
aria-hidden="true"
/>
<div
:class="['menu left transition', {'visible': showShareOptions}]"
style="z-index: 9999"
>
<div
v-if="project.generate_share_link"
class="item"
@click="copyLink"
>
Copier le lien de partage
</div>
<div
class="item"
@click="copyCode"
>
Copier le code du Web Component
</div>
</div>
</div>
</div>
<button
v-if="isProjectAdmin && !isSharedProject && project.generate_share_link"
class="ui teal left labeled icon button share-button"
@click="copyLink"
>
<i class="left icon share square" />
Copier le lien de partage
</button>
<div v-if="confirmMsg">
<div class="ui positive tiny-margin message">
<Transition>
<div
v-if="confirmMsg"
class="ui positive tiny-margin message"
>
<span>
Le lien a été copié dans le presse-papier
</span>
&nbsp;
<i
class="close icon"
@click="confirmMsg = ''"
aria-hidden="true"
@click="confirmMsg = false"
/>
</div>
</div>
</Transition>
</div>
<div v-if="arraysOffline.length > 0">
{{ arraysOffline.length }} modification<span v-if="arraysOffline.length>1">s</span> en attente
<div
v-if="arraysOffline.length > 0"
class="centered"
>
{{ arraysOffline.length }} modification<span v-if="arraysOffline.length > 1">s</span> en attente
<button
:disabled="isOffline"
:disabled="!isOnline"
class="ui fluid labeled teal icon button"
@click="sendOfflineFeatures"
>
<i class="upload icon" />
<i
class="upload icon"
aria-hidden="true"
/>
Envoyer au serveur
</button>
</div>
......@@ -127,13 +188,14 @@
</template>
<script>
import TextareaMarkdown from 'textarea-markdown';
import { mapState, mapGetters, mapMutations } from 'vuex';
import featureAPI from '@/services/feature-api';
export default {
name: 'ProjectHeader',
props: {
......@@ -149,6 +211,7 @@ export default {
return {
slug: this.$route.params.slug,
confirmMsg: false,
showShareOptions: false,
};
},
......@@ -162,7 +225,8 @@ export default {
]),
...mapState([
'user',
'user_permissions'
'user_permissions',
'isOnline',
]),
...mapGetters([
'permissions'
......@@ -181,31 +245,71 @@ export default {
},
mounted() {
let textarea = document.querySelector('textarea');
new TextareaMarkdown(textarea);
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
},
methods: {
...mapState([
'isOnline'
]),
...mapMutations('modals', [
'OPEN_PROJECT_MODAL'
]),
refreshId() {
return '?ver=' + Math.random();
const crypto = window.crypto || window.msCrypto;
var array = new Uint32Array(1);
return '?ver=' + crypto.getRandomValues(array); // Compliant for security-sensitive use cases
},
isOffline() {
return navigator.onLine === false;
toggleShareOptions() {
this.confirmMsg = false;
this.showShareOptions = !this.showShareOptions;
},
clickOutsideDropdown(e) {
// If the user click outside of the dropdown, close it
if (!e.target.closest('#share-button')) {
this.showShareOptions = false;
}
},
copyLink() {
const sharedLink = window.location.href.replace('projet', 'projet-partage');
navigator.clipboard.writeText(sharedLink).then(()=> {
console.log('success');
this.confirmMsg = true;
}, () => {
console.log('failed');
}
);
setTimeout(() => {
this.confirmMsg = false;
}, 15000);
}, (e) => console.error('Failed to copy link: ', e));
},
copyCode() {
// Including <script> directly within template literals cause the JavaScript parser to raise syntax errors.
// The only working workaround, but ugly, is to split and concatenate the <script> tag.
const webComponent = `
<!-- Pour modifier la police, ajoutez l'attribut "font" avec le nom de la police souhaitée (par exemple: font="'Roboto Condensed', Lato, 'Helvetica Neue'"). -->
<!-- Dans le cas où la police souhaitée ne serait pas déjà disponible dans la page affichant le web component, incluez également une balise <style> pour l'importer. -->
<style>@import url('https://fonts.googleapis.com/css?family=Roboto Condensed:400,700,400italic,700italic&subset=latin');</style>
<scr` + `ipt src="${this.configuration.VUE_APP_DJANGO_BASE}/geocontrib/static/wc/project-preview.js"></scr` + `ipt>
<project-preview
domain="${this.configuration.VUE_APP_DJANGO_BASE}"
project-slug="${this.project.slug}"
color="${this.configuration.VUE_APP_PRIMARY_COLOR}"
font="${this.configuration.VUE_APP_FONT_FAMILY}"
width=""
></project-preview>`;
navigator.clipboard.writeText(webComponent).then(()=> {
this.confirmMsg = true;
setTimeout(() => {
this.confirmMsg = false;
}, 15000);
}, (e) => console.error('Failed to copy link: ', e));
},
sendOfflineFeatures() {
......@@ -219,18 +323,21 @@ export default {
method: feature.type.toUpperCase(),
})
.then((response) => {
if (!response) this.arraysOfflineErrors.push(feature);
if (!response) {
this.arraysOfflineErrors.push(feature);
}
})
.catch((error) => {
console.error(error);
this.arraysOfflineErrors.push(feature);
})
);
this.DISPLAY_LOADER('Envoi des signalements en cours.');
this.$store.commit('DISPLAY_LOADER', 'Envoi des signalements en cours.');
Promise.all(promises).then(() => {
this.$emit('update-local-storage');
this.$emit('retrieve-info');
this.$emit('updateLocalStorage');
this.$emit('retrieveInfo');
this.$store.commit('DISCARD_LOADER');
});
},
......@@ -242,22 +349,62 @@ export default {
<style lang="less" scoped>
.project-header {
.row .right-column {
display: flex;
flex-direction: column;
.row {
.right-column {
display: flex;
flex-direction: column;
.ui.button, .ui.button .button, .tiny-margin {
margin: 0.1rem 0 0.1rem 0.1rem !important;
.ui.buttons {
justify-content: flex-end;
.ui.button {
flex-grow: 0; /* avoid stretching buttons */
}
}
}
.centered {
margin: auto;
text-align: center;
}
.share-button {
margin: 1em 0 0 0;
.ui.dropdown > .left.menu {
display: block;
overflow: hidden;
opacity: 0;
max-height: 0;
&.transition {
transition: all .5s ease;
}
&.visible {
opacity: 1;
max-height: 6em;
}
.menu {
margin-right: 0 !important;
.item {
white-space: nowrap;
}
}
}
.v-enter-active,
.v-leave-active {
transition: opacity .5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
}
#preview {
max-height: 10em;
overflow-y: scroll;
}
@media screen and (max-width: 767px) {
.middle.aligned.column {
text-align: center;
}
}
</style>
......@@ -20,26 +20,26 @@
class="item"
>
<div class="content">
<div>
<router-link
:to="getRouteUrl(item.related_feature.feature_url)"
>
"{{ item.comment }}"
</router-link>
</div>
<FeatureFetchOffsetRoute
:feature-id="item.related_feature.feature_id"
:properties="{
title: item.comment,
feature_type: { slug: item.related_feature.feature_type_slug }
}"
/>
<div class="description">
<i>[ {{ item.created_on
<em>[ {{ item.created_on
}}<span
v-if="user && item.display_author"
>, par {{ item.display_author }}
</span>
]</i>
]</em>
</div>
</div>
</div>
<i
<em
v-if="!last_comments || last_comments.length === 0"
>Aucun commentaire pour le moment.</i>
>Aucun commentaire pour le moment.</em>
</div>
</div>
</div>
......@@ -49,10 +49,14 @@
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectsLastComments',
components: {
FeatureFetchOffsetRoute,
},
props: {
loading: {
......@@ -65,10 +69,9 @@ export default {
...mapState([
'user'
]),
...mapState([
...mapState('projects', [
'last_comments',
]),
}
},
};
</script>
......@@ -15,28 +15,19 @@
</div>
<div class="ui relaxed list">
<div
v-for="(item, index) in features.slice(-5)"
v-for="(item, index) in features.slice(0,5)"
:key="item.properties.title + index"
class="item"
>
<div class="content">
<div>
<router-link
:to="{
name: 'details-signalement',
params: {
slug,
slug_type_signal:
item.properties.feature_type.slug,
slug_signal: item.id,
},
}"
>
{{ item.properties.title || item.id }}
</router-link>
<FeatureFetchOffsetRoute
:feature-id="item.id"
:properties="item.properties"
/>
</div>
<div class="description">
<i>
<em>
[{{ item.properties.created_on }}
<span v-if="user && item.properties.creator">
, par
......@@ -47,13 +38,13 @@
}}
</span>
]
</i>
</em>
</div>
</div>
</div>
<i
<em
v-if="features.length === 0 && !loading"
>Aucun signalement pour le moment.</i>
>Aucun signalement pour le moment.</em>
</div>
</div>
</div>
......@@ -63,32 +54,52 @@
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectLastFeatures',
props: {
loading: {
type: Boolean,
default: false
}
components: {
FeatureFetchOffsetRoute,
},
data() {
return {
slug: this.$route.params.slug,
loading: true,
};
},
computed: {
...mapState([
'user'
]),
...mapState('feature', [
'features'
]),
}
...mapState([
'user'
]),
},
mounted() {
this.fetchLastFeatures();
},
methods: {
fetchLastFeatures() {
this.loading = true;
this.$store.dispatch('feature/GET_PROJECT_FEATURES', {
project_slug: this.$route.params.slug,
ordering: '-created_on',
limit: 5,
})
.then(() => {
this.loading = false;
})
.catch((err) => {
console.error(err);
this.loading = false;
});
}
}
};
</script>
......@@ -6,16 +6,20 @@
>
<div
:class="[
'ui mini modal subscription',
'ui mini modal',
{ 'transition visible active': projectModalType },
]"
>
<i
class="close icon"
aria-hidden="true"
@click="CLOSE_PROJECT_MODAL"
/>
<div class="ui icon header">
<i :class="[projectModalType === 'subscribe' ? 'envelope' : 'trash', 'icon']" />
<i
:class="[projectModalType === 'subscribe' ? 'envelope' : 'trash', 'icon']"
aria-hidden="true"
/>
{{
projectModalType === 'subscribe' ? 'Notifications' : 'Suppression'
}} du {{
......@@ -36,6 +40,7 @@
</p>
</div>
<button
id="validate-modal"
:class="['ui compact fluid button', projectModalType === 'subscribe' && !isSubscriber ? 'green' : 'red']"
@click="handleModalAction"
>
......@@ -47,7 +52,7 @@
}}
</span>
<span v-else>
Supprimer le
Supprimer le
{{
projectModalType === 'deleteProject'
? 'projet'
......@@ -64,13 +69,19 @@
import { mapState, mapMutations } from 'vuex';
export default {
name: 'ProjectModal',
props: {
isSubscriber: {
type: Boolean,
default: false
},
featureTypeToDelete: {
type: Object,
default: () => {
return {};
}
}
},
......@@ -87,20 +98,18 @@ export default {
]),
handleModalAction() {
this.$emit('action');
this.$emit('action', this.projectModalType);
}
}
};
</script>
<style lang="less" scoped>
<style scoped>
.alert {
color: red;
}
.centered-text {
text-align: center;
}
</style>
\ No newline at end of file
</style>
......@@ -3,37 +3,14 @@
<h3 class="ui header">
Paramètres du projet
</h3>
<div class="ui five stackable cards">
<div class="card">
<div class="center aligned content">
<h4 class="ui center aligned icon header">
<i class="disabled grey archive icon" />
<div class="content">
Délai avant archivage automatique
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.archive_feature }} jours
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey trash alternate icon" />
<div class="content">
Délai avant suppression automatique
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.delete_feature }} jours
</div>
</div>
<div class="ui three stackable cards">
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey eye icon" />
<i
class="disabled grey eye icon"
aria-hidden="true"
/>
<div class="content">
Visibilité des signalements publiés
</div>
......@@ -46,7 +23,10 @@
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey eye icon" />
<i
class="disabled grey eye icon"
aria-hidden="true"
/>
<div class="content">
Visibilité des signalements archivés
</div>
......@@ -59,7 +39,10 @@
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey cogs icon" />
<i
class="disabled grey cogs icon"
aria-hidden="true"
/>
<div class="content">
Modération
</div>
......@@ -75,7 +58,7 @@
<script>
export default {
name: 'ProjectParameters',
props: {
......
<template>
<div class="field">
<label for="attribute-value">
{{ attribute.label }}
</label>
<div>
<ExtraForm
:id="`attribute-value-for-${attribute.name}`"
ref="extraForm"
name="attribute-value"
:field="{ ...attribute, value }"
:use-value-only="true"
@update:value="updateValue($event.toString(), attribute.id)"
/>
</div>
</div>
</template>
<script>
import ExtraForm from '@/components/ExtraForm';
export default {
name: 'ProjectAttributeForm',
components: {
ExtraForm,
},
props: {
attribute: {
type: Object,
default: () => {
return {};
}
},
formProjectAttributes: {
type: Array,
default: () => {
return [];
}
}
},
computed: {
/**
* Retrieves the current value of a specific project attribute.
* This computed property checks the array of project attributes to find the one that matches
* the current attribute's ID. If the attribute is found, its value is returned.
* Otherwise, null is returned to indicate that the attribute is not set for the current project.
*
* @returns {String|null} The value of the attribute if it exists in the project's attributes; otherwise, null.
*/
value() {
// Searches for the attribute within the array of attributes associated with the project.
const projectAttribute = this.formProjectAttributes.find(el => el.attribute_id === this.attribute.id);
// Returns the value of the attribute if it exists, or null if the attribute is not found.
return projectAttribute ? projectAttribute.value : null;
},
},
created() {
// Checks if the component is being used in the context of creating a new project and attribute's default value is set
if (this.$route.name === 'project_create' && this.attribute.default_value !== null) {
// If so, initializes the attribute's value with its default value as defined in the attribute's settings.
this.updateValue(this.attribute.default_value, this.attribute.id);
}
},
methods: {
/**
* Updates or adds a value for a specific attribute in the project.
* This method emits an event to update the project's attributes with a new value for a given attribute ID.
* It is typically called when the user changes the value of an attribute in the UI.
*
* @param {String} value - The new value for the attribute.
* @param {Number} attributeId - The unique ID of the attribute being updated or added to the project.
*/
updateValue(value, attributeId) {
// Emits an event to the parent component, requesting an update to the project's attributes.
this.$emit('update:project_attributes', { value, attributeId });
}
}
};
</script>
<template>
<div>
<div class="table-mobile-buttons left-align">
<FeatureListMassToggle />
<div class="ui form">
<div
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
class="inline fields"
>
<label
data-tooltip="Choisir un type de sélection de signalements pour effectuer une action"
data-position="bottom left"
>Mode de sélection :</label>
<div class="field">
<div class="ui radio checkbox">
<input
id="edit-status"
v-model="mode"
type="radio"
name="mode"
value="edit-status"
>
<label for="edit-status">Édition de statut</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input
id="edit-attributes"
v-model="mode"
type="radio"
name="mode"
value="edit-attributes"
>
<label for="edit-attributes">Édition d'attribut</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input
id="delete-features"
v-model="mode"
type="radio"
name="mode"
value="delete-features"
>
<label for="delete-features">Suppression de signalement</label>
</div>
</div>
</div>
</div>
<div
data-tab="list"
class="dataTables_wrapper no-footer"
......@@ -10,16 +55,43 @@
<table
id="table-features"
class="ui compact table unstackable dataTable"
aria-describedby="Liste des signalements du projet"
>
<thead>
<tr>
<th class="dt-center">
<FeatureListMassToggle />
<th
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
scope="col"
class="dt-center"
>
<div
v-if="massMode === 'edit-status' || massMode === 'delete-features'"
class="ui checkbox"
>
<input
id="select-all"
v-model="isAllSelected"
type="checkbox"
name="select-all"
>
<label for="select-all">
<span v-if="!isAllSelected">
Tout sélectionner
</span>
<span v-else>
Tout désélectionner
</span>
</label>
</div>
<span v-else>Sélection</span>
</th>
<th class="dt-center">
<th
scope="col"
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('status')"
>
Statut
......@@ -29,12 +101,16 @@
up: isSortedDesc('status'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th class="dt-center">
<th
scope="col"
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('feature_type')"
>
Type
......@@ -44,12 +120,16 @@
up: isSortedDesc('feature_type'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th class="dt-center">
<th
scope="col"
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('title')"
>
Nom
......@@ -59,12 +139,35 @@
up: isSortedDesc('title'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th class="dt-center">
<th
scope="col"
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('created_on')"
>
Date de création
<i
:class="{
down: isSortedAsc('created_on'),
up: isSortedDesc('created_on'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('updated_on')"
>
Dernière modification
......@@ -74,15 +177,17 @@
up: isSortedDesc('updated_on'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
v-if="user"
scope="col"
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('display_creator')"
>
Auteur
......@@ -92,15 +197,17 @@
up: isSortedDesc('display_creator'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
v-if="user"
scope="col"
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('display_last_editor')"
>
Dernier éditeur
......@@ -110,6 +217,7 @@
up: isSortedDesc('display_last_editor'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
......@@ -120,84 +228,123 @@
v-for="(feature, index) in paginatedFeatures"
:key="index"
>
<td class="dt-center">
<td
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
id="select"
class="dt-center"
>
<div
:class="['ui checkbox', {disabled: !checkRights(feature)}]"
:class="['ui checkbox', { disabled: isAllSelected || !checkRights(feature) }]"
>
<input
:id="feature.id"
v-model="checked"
:id="feature.feature_id"
type="checkbox"
:value="feature.id"
:disabled="!checkRights(feature)"
:value="feature.feature_id"
:checked="isFeatureSelected(feature)"
:disabled="isAllSelected || !checkRights(feature)"
name="select"
@input="storeClickedFeature(feature)"
@input="handleFeatureSelection($event, feature)"
>
<label for="select" />
<label :for="feature.feature_id" />
</div>
</td>
<td class="dt-center">
<div v-if="feature.properties.status.value === 'archived'">
<span data-tooltip="Archivé">
<i class="grey archive icon" />
</span>
<td
id="status"
class="dt-center"
>
<div
v-if="feature.status === 'archived'"
data-tooltip="Archivé"
>
<i
class="grey archive icon"
aria-hidden="true"
/>
</div>
<div v-else-if="feature.properties.status.value === 'pending'">
<span data-tooltip="En attente de publication">
<i class="teal hourglass outline icon" />
</span>
<div
v-else-if="feature.status === 'pending'"
data-tooltip="En attente de publication"
>
<i
class="teal hourglass outline icon"
aria-hidden="true"
/>
</div>
<div v-else-if="feature.properties.status.value === 'published'">
<span data-tooltip="Publié">
<i class="olive check icon" />
</span>
<div
v-else-if="feature.status === 'published'"
data-tooltip="Publié"
>
<i
class="olive check icon"
aria-hidden="true"
/>
</div>
<div v-else-if="feature.properties.status.value === 'draft'">
<span data-tooltip="Brouillon">
<i class="orange pencil alternate icon" />
</span>
<div
v-else-if="feature.status === 'draft'"
data-tooltip="Brouillon"
>
<i
class="orange pencil alternate icon"
aria-hidden="true"
/>
</div>
</td>
<td class="dt-center">
<td
id="type"
class="dt-center"
>
<router-link
:to="{
name: 'details-type-signalement',
params: {
feature_type_slug: feature.properties.feature_type.slug,
feature_type_slug: feature.feature_type.slug,
},
}"
class="ellipsis space-left"
>
{{ feature.properties.feature_type.title }}
{{ feature.feature_type.title }}
</router-link>
</td>
<td class="dt-center">
<td
id="name"
class="dt-center"
>
<router-link
:to="{
name: 'details-signalement',
params: {
slug_type_signal: feature.properties.feature_type.slug,
slug_signal: feature.properties.slug || feature.id,
},
name: 'details-signalement-filtre',
query: { ...queryparams, offset: queryparams.offset + index }
}"
class="ellipsis space-left"
>
{{ getFeatureDisplayName(feature) }}
{{ feature.title || feature.feature_id }}
</router-link>
</td>
<td class="dt-center">
{{ feature.properties.updated_on }}
<td
id="create"
class="dt-center"
>
{{ feature.created_on | formatDate }}
</td>
<td
id="update"
class="dt-center"
>
{{ feature.updated_on | formatDate }}
</td>
<td
v-if="user"
id="author"
class="dt-center"
>
{{ getUserName(feature) }}
{{ feature.display_creator || ' ---- ' }}
</td>
<td
v-if="user"
id="last_editor"
class="dt-center"
>
{{ feature.properties.display_last_editor }}
{{ feature.display_last_editor || ' ---- ' }}
</td>
</tr>
<tr
......@@ -226,7 +373,7 @@
sur {{ featuresCount }} éléments
</div>
<div
v-if="pageNumbers.length > 1"
v-if="pageNumbers.length > 1 && isOnline"
id="table-features_paginate"
class="dataTables_paginate paging_simple_numbers"
>
......@@ -242,7 +389,7 @@
@click="$emit('update:page', 'previous')"
>Précédent</a>
<span>
<span v-if="pagination.currentPage >= 5">
<span v-if="pagination.currentPage > 5">
<a
key="page1"
class="paginate_button"
......@@ -250,7 +397,7 @@
data-dt-idx="1"
tabindex="0"
@click="$emit('update:page', 1)"
>{{ 1 }}</a>
>1</a>
<span class="ellipsis"></span>
</span>
<a
......@@ -265,7 +412,7 @@
tabindex="0"
@click="$emit('update:page', pageNumber)"
>{{ pageNumber }}</a>
<span v-if="(lastPageNumber - pagination.currentPage) >= 4">
<span v-if="(lastPageNumber - pagination.currentPage) > 4">
<span class="ellipsis"></span>
<a
:key="'page' + lastPageNumber"
......@@ -295,16 +442,24 @@
<script>
import { mapState, mapGetters, mapMutations } from 'vuex';
import FeatureListMassToggle from '@/components/Feature/FeatureListMassToggle';
import { formatStringDate } from '@/utils';
export default {
name: 'FeatureListTable',
components: {
FeatureListMassToggle,
filters: {
formatDate(value) {
return formatStringDate(value);
},
},
beforeRouteLeave (to, from, next) {
if (to.name !== 'editer-attribut-signalement') {
this.UPDATE_CHECKED_FEATURES([]); // empty if not needed anymore
}
next(); // continue navigation
},
props: {
paginatedFeatures: {
type: Array,
......@@ -314,6 +469,10 @@ export default {
type: Array,
default: null,
},
allSelected: {
type: Boolean,
default: false,
},
checkedFeatures: {
type: Array,
default: null,
......@@ -330,24 +489,53 @@ export default {
type: Object,
default: null,
},
queryparams: {
type: Object,
default: null,
},
editAttributesFeatureType: {
type: String,
default: null,
},
},
computed: {
...mapGetters(['permissions']),
...mapState(['user', 'USER_LEVEL_PROJECTS']),
...mapState([
'user',
'USER_LEVEL_PROJECTS',
'isOnline'
]),
...mapState('projects', ['project']),
...mapState('feature', ['clickedFeatures', 'massMode']),
mode: {
get() {
return this.massMode;
},
set(newMode) {
this.TOGGLE_MASS_MODE(newMode);
// Reset all selections
this.isAllSelected = false;
this.UPDATE_CLICKED_FEATURES([]);
this.UPDATE_CHECKED_FEATURES([]);
},
},
userStatus() {
return this.USER_LEVEL_PROJECTS[this.$route.params.slug];
return this.USER_LEVEL_PROJECTS ? this.USER_LEVEL_PROJECTS[this.$route.params.slug] : '';
},
checkedFeaturesSet() {
return new Set(this.checkedFeatures); // Set améliore la performance sur la recherche
},
checked: {
isAllSelected: {
get() {
return this.checkedFeatures;
return this.allSelected;
},
set(newChecked) {
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', newChecked);
set(isChecked) {
this.$emit('update:allSelected', isChecked);
},
},
......@@ -363,7 +551,9 @@ export default {
displayedPageNumbers() {
//* s'il y a moins de 5 pages, renvoyer toutes les pages
if (this.lastPageNumber < 5) return this.pageNumbers;
if (this.lastPageNumber < 5) {
return this.pageNumbers;
}
//* si la page courante est inférieur à 5, la liste commence à l'index 0 et on retourne 5 pages
let firstPageInList = 0;
let pagesQuantity = 5;
......@@ -380,29 +570,90 @@ export default {
},
},
destroyed() {
this.UPDATE_CHECKED_FEATURES([]);
},
methods: {
...mapMutations('feature', [
'UPDATE_CLICKED_FEATURES',
'UPDATE_CHECKED_FEATURES',
'TOGGLE_MASS_MODE',
]),
storeClickedFeature(feature) {
/**
* Vérifie si une feature doit être cochée en fonction de la sélection globale et des droits d'édition.
* @param {Object} feature - L'objet représentant la feature.
* @returns {Boolean} - `true` si la feature doit être cochée.
*/
isFeatureSelected(feature) {
if (this.isAllSelected) {
return this.checkRights(feature); // Si tout doit être sélectionné, on vérifie les droits
}
return this.checkedFeaturesSet.has(feature.feature_id);
},
/**
* Ajoute ou supprime une feature de la sélection en fonction de l'état de la checkbox.
* Met également à jour les features cliquées et les restrictions d'édition d'attributs.
* @param {Event} event - L'événement de changement de l'input checkbox.
* @param {Object} feature - La feature associée.
*/
handleFeatureSelection(event, feature) {
const isChecked = event.target.checked;
const updatedFeatures = isChecked
? [...this.checkedFeatures, feature.feature_id]
: this.checkedFeatures.filter(id => id !== feature.feature_id);
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', updatedFeatures);
this.trackClickedFeature(feature);
this.manageAttributeEdition(feature, updatedFeatures.length);
},
/**
* Gère les restrictions sur la modification des attributs en fonction de la sélection en masse.
* @param {Object} feature - La feature actuellement sélectionnée.
* @param {Number} checkedCount - Nombre total de features actuellement cochées.
*/
manageAttributeEdition(feature, checkedCount) {
if (this.massMode !== 'edit-attributes') return;
if (checkedCount === 1) {
// Premier élément sélectionné, on stocke son type pour restreindre la sélection
this.$emit('update:editAttributesFeatureType', feature.feature_type.slug);
} else if (checkedCount === 0) {
// Dernière feature désélectionnée -> on réinitialise la restriction
this.$emit('update:editAttributesFeatureType', null);
}
},
/**
* Ajoute une feature cliquée à la liste pour conserver son historique de sélection.
* Permet de gérer la sélection de plusieurs features sur différentes pages sans surcharger la mémoire.
* @param {Object} feature - La feature cliquée.
*/
trackClickedFeature(feature) {
this.UPDATE_CLICKED_FEATURES([
...this.clickedFeatures,
{ feature_id: feature.id, feature_type: feature.properties.feature_type.slug }
{ feature_id: feature.feature_id, feature_type: feature.feature_type.slug }
]);
},
/**
* Vérifie si l'utilisateur a le droit de supprimer une feature.
* @param {Object} feature - La feature à vérifier.
* @returns {Boolean} - `true` si l'utilisateur peut supprimer la feature.
*/
canDeleteFeature(feature) {
if (this.userStatus === 'Administrateur projet') return true; //* can delete all
//* others can delete only their own features
return feature.properties.creator.username === this.user.username;
if (this.userStatus === 'Administrateur projet' || this.user.is_superuser) {
return true; // Un administrateur ou super utilisateur peut tout supprimer
}
// Sinon, on ne peut supprimer que ses propres features
return feature.creator === this.user.id;
},
/**
* Vérifie si l'utilisateur a le droit de modifier une feature.
* @param {Object} feature - La feature à vérifier.
* @returns {Boolean} - `true` si l'utilisateur peut modifier la feature.
*/
canEditFeature(feature) {
const permissions = {
'Administrateur projet' : ['draft', 'pending', 'published', 'archived'],
......@@ -411,32 +662,27 @@ export default {
Contributeur : ['draft', 'pending', 'published'],
};
if (this.userStatus === 'Contributeur' && feature.properties.creator.username !== this.user.username) {
if (this.checkedFeatures.length > 0 && // check if selection should be restricted to a specific feature type, for attributes modification
feature.feature_type.slug !== this.editAttributesFeatureType &&
this.massMode === 'edit-attributes') {
return false;
} else if (this.user.is_superuser) {
return true;
} else if (this.userStatus === 'Contributeur' && feature.creator !== this.user.id) {
return false;
} else if (permissions[this.userStatus]) {
return permissions[this.userStatus].includes(feature.properties.status.value);
return permissions[this.userStatus].includes(feature.status);
} else {
return false;
}
},
checkRights(feature) {
switch (this.massMode) {
case 'modify':
if (this.massMode.includes('edit')) {
return this.canEditFeature(feature);
case 'delete':
} else if (this.massMode === 'delete-features') {
return this.canDeleteFeature(feature);
}
},
getUserName(feature) {
if (!feature.properties.creator) {
return ' ---- ';
}
return feature.properties.creator.username || ' ---- ';
},
getFeatureDisplayName(feature) {
return feature.properties.title || feature.id;
},
isSortedAsc(column) {
......@@ -447,6 +693,7 @@ export default {
},
changeSort(column) {
if (!this.isOnline) return;
if (this.sort.column === column) {
//changer only order
this.$emit('update:sort', {
......@@ -467,6 +714,9 @@ export default {
position: relative;
clear: both;
}
.dataTables_paginate {
margin-bottom: 1rem;
}
table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable td.dataTables_empty {
text-align: center;
}
......@@ -491,7 +741,6 @@ table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable td.d
.dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
color: #333 !important;
border: 1px solid #979797;
background-color: white;
background: -webkit-gradient(
linear,
left top,
......@@ -521,7 +770,6 @@ table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable td.d
.dataTables_wrapper .dataTables_paginate .paginate_button:hover {
color: white !important;
border: 1px solid #111;
background-color: #585858;
background: -webkit-gradient(
linear,
left top,
......@@ -564,12 +812,25 @@ i.icon.sort:not(.down):not(.up) {
.table-mobile-buttons {
margin-bottom: 1em;
}
/* increase contrast between available checkboxes and disabled ones */
#table-features .ui.disabled.checkbox label::before {
background-color: #fbf5f5;;
}
#select-all + label {
text-align: left;
&:hover {
cursor: pointer;
}
}
@media only screen and (min-width: 761px) {
.table-mobile-buttons {
display: none !important;
}
}
/*
/*
Max width before this PARTICULAR table gets nasty
This query will take effect for any screen smaller than 760px
and also iPads specifically.
......@@ -578,9 +839,13 @@ and also iPads specifically.
.table-mobile-buttons {
display: flex !important;
}
.inline.fields label {
width: 100% !important;
}
/* hide table border */
.ui.table {
border: none !important;
margin-top: 2em;
}
/* Force table to not be like tables anymore */
table,
......@@ -603,12 +868,12 @@ and also iPads specifically.
border: 1px solid #ccc;
border-radius: 7px;
margin-bottom: 3vh;
padding: 0 1vw .5em 1vw;
padding: 0 2vw .5em 2vw;
box-shadow: rgba(50, 50, 50, 0.1) 2px 5px 10px ;
}
td {
/* Behave like a "row" */
/* Behave like a "row" */
border: none;
border-bottom: 1px solid #eee;
position: relative;
......@@ -624,52 +889,81 @@ and also iPads specifically.
border: none !important;
padding: .25em !important;
}
td:nth-of-type(7) {
td:nth-of-type(8) {
border-bottom: none !important;
}
td:before {
/* Now like a table header */
position: absolute;
/* Top/left values mimic padding */
/* top: 6px; */
left: 6px;
/* width: 45%; */
padding-right: 10px;
white-space: nowrap;
}
/*
Label the data
*/
td:nth-of-type(1):before {
td#select:before {
content: "";
}
td:nth-of-type(2):before {
td#status:before {
content: "Statut";
}
td:nth-of-type(3):before {
td#type:before {
content: "Type";
}
td:nth-of-type(4):before {
td#name:before {
content: "Nom";
}
td:nth-of-type(5):before {
td#create:before {
content: "Date de création";
}
td#update:before {
content: "Dernière modification";
}
td:nth-of-type(6):before {
td#author:before {
content: "Auteur";
}
td:nth-of-type(7):before {
td#last_editor:before {
content: "Dernier éditeur";
}
table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable td.dataTables_empty {
table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable {
text-align: right;
}
.ui.checkbox {
#select .ui.checkbox {
position: absolute;
left: calc(-1vw - .75em);
top: -.75em;
left: calc(-1vw - 20px);
top: -12px;
min-height: 24px;
font-size: 1rem;
line-height: 24px;
min-width: 24px;
}
#select .ui.checkbox .box::before, #select .ui.checkbox label::before,
#select .ui.checkbox .box::after, #select .ui.checkbox label::after {
width: 24px;
height: 24px;
}
/* cover all the card to ease selection by user */
#select .ui.checkbox {
width: 100%;
}
#select .ui.checkbox input[type="checkbox"] {
width: calc(100% + 1vw + 20px + 4vw);
height: calc(14em + 12px);
}
/* keep the links above the checkbox input to receive the click event */
table a {
z-index: 4;
position: sticky;
}
#select .ui.checkbox .box::before, #select .ui.checkbox label::before {
border-radius: 12px;
}
#select .ui.checkbox .box::after, #select .ui.checkbox label::after {
font-size: 18px;
}
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_paginate {
......@@ -677,10 +971,14 @@ and also iPads specifically.
text-align: center;
margin: .5em 0;
}
/* preserve space to not overlap column label */
.space-left {
margin-left: 2.5em;
}
}
@media only screen and (max-width: 410px) {
.ui.table tr td {
border: none;
}
}
</style>
\ No newline at end of file
</style>
<template>
<div>
<div
id="feature-list-container"
class="ui mobile-column"
>
<div class="mobile-fullwidth">
<h1>Signalements</h1>
</div>
<div class="no-padding-mobile mobile-fullwidth">
<div class="ui large text loader">
Chargement
</div>
<div class="ui secondary menu no-margin">
<a
id="show-map"
:class="['item no-margin', { active: showMap }]"
data-tab="map"
data-tooltip="Carte"
data-position="bottom left"
@click="$emit('show-map', true)"
>
<i
class="map fitted icon"
aria-hidden="true"
/>
</a>
<a
id="show-list"
:class="['item no-margin', { active: !showMap }]"
data-tab="list"
data-tooltip="Liste"
data-position="bottom left"
@click="$emit('show-map', false)"
>
<i
class="list fitted icon"
aria-hidden="true"
/>
</a>
<div class="item">
<h4>
{{ featuresCount }} signalement{{ featuresCount > 1 ? "s" : "" }}
</h4>
</div>
<div
v-if="
project &&
filteredFeatureTypeChoices.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"
aria-hidden="true"
/>
<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 filteredFeatureTypeChoices"
: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="(allSelected || checkedFeatures.length > 0) && massMode.includes('edit') && isOnline"
id="edit-button"
class="ui dropdown button compact button-hover-green tiny-margin-left"
:data-tooltip="`Modifier le${massMode.includes('status') ? ' statut' : 's attributs'} des signalements`"
data-position="bottom right"
@click="editFeatures"
>
<i
class="pencil fitted icon"
aria-hidden="true"
/>
<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="$emit('edit-status', status.value)"
>
{{ status.name }}
</span>
</div>
</div>
</div>
<div
v-if="(allSelected || checkedFeatures.length > 0) && massMode === 'delete-features' && isOnline"
class="ui button compact button-hover-red tiny-margin-left"
data-tooltip="Supprimer tous les signalements sélectionnés"
data-position="bottom right"
@click="$emit('toggle-delete-modal')"
>
<i
class="grey trash fitted icon"
aria-hidden="true"
/>
</div>
</div>
</div>
</div>
</div>
<section
id="form-filters"
class="ui form grid equal width"
>
<div
id="type"
:class="['field column', { 'disabled': !isOnline }]"
>
<label>Type</label>
<Multiselect
v-model="form.type"
:options="featureTypeOptions"
:multiple="true"
:searchable="false"
:close-on-select="false"
:show-labels="false"
placeholder="Sélectionner un type"
track-by="value"
label="name"
/>
</div>
<div
id="statut"
:class="['field column', { 'disabled': !isOnline }]"
>
<label>Statut</label>
<Multiselect
v-model="form.status"
:options="statusOptions"
:multiple="true"
:searchable="false"
:close-on-select="false"
:show-labels="false"
placeholder="Sélectionner un statut"
track-by="value"
label="name"
/>
</div>
<div
id="name"
:class="['field column', { 'disabled': !isOnline }]"
>
<label>Nom</label>
<div class="ui icon input">
<i
class="search icon"
aria-hidden="true"
/>
<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"
aria-hidden="true"
/>
</button>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import Multiselect from 'vue-multiselect';
import { statusChoices, allowedStatus2change } from '@/utils';
const initialPagination = {
currentPage: 1,
pagesize: 15,
start: 0,
end: 15,
};
export default {
name: 'FeaturesListAndMapFilters',
components: {
Multiselect
},
props: {
showMap: {
type: Boolean,
default: true
},
featuresCount: {
type: Number,
default: 0
},
pagination: {
type: Object,
default: () => {
return {
...initialPagination
};
}
},
allSelected: {
type: Boolean,
default: false,
},
editAttributesFeatureType: {
type: String,
default: null,
},
},
data() {
return {
form: {
type: [],
status: [],
title: null,
},
lat: null,
lng: null,
showAddFeature: false,
showModifyStatus: false,
zoom: null,
};
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS',
'isOnline'
]),
...mapState('feature', [
'checkedFeatures',
'massMode',
]),
...mapState('feature-type', [
'feature_types',
]),
...mapState('projects', [
'project',
]),
...mapGetters([
'permissions',
]),
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.user, isModerate, userStatus, isOwnFeature);
}
return [];
},
featureTypeTitles() {
return this.feature_types.map((el) => el.title);
},
featureTypeOptions() {
return this.feature_types.map((el) => ({ name: el.title, value: el.slug }));
},
statusOptions() {
//* if project is not moderate, remove pending status
return statusChoices.filter((el) =>
this.project && this.project.moderation ? true : el.value !== 'pending'
);
},
filteredFeatureTypeChoices() {
return this.feature_types.filter((fType) =>
!fType.geom_type.includes('multi')
);
},
},
watch: {
'form.type'(newValue) {
this.$emit('set-filter', { type: newValue });
this.resetPaginationNfetchFeatures();
},
'form.status': {
deep: true,
handler(newValue) {
this.$emit('set-filter', { status: newValue });
this.resetPaginationNfetchFeatures();
}
},
'form.title'(newValue) {
this.$emit('set-filter', { title: newValue });
this.resetPaginationNfetchFeatures();
},
},
mounted() {
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
},
methods: {
resetPaginationNfetchFeatures() {
this.$emit('reset-pagination');
this.$emit('fetch-features');
},
toggleAddFeature() {
this.showAddFeature = !this.showAddFeature;
this.showModifyStatus = false;
},
editFeatures() {
switch (this.massMode) {
case 'edit-status':
this.toggleModifyStatus();
break;
case 'edit-attributes':
this.displayAttributesForm();
break;
}
},
toggleModifyStatus() {
this.showModifyStatus = !this.showModifyStatus;
this.showAddFeature = false;
},
displayAttributesForm() {
if (this.checkedFeatures.length > 1) {
this.$router.push({
name: 'editer-attribut-signalement',
params: {
slug_type_signal: this.editAttributesFeatureType,
},
});
} else {
this.$store.commit('DISPLAY_MESSAGE', {
comment: 'Veuillez sélectionner au moins 2 signalements pour l\'édition multiple d\'attributs'
});
}
},
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);
}
},
}
};
</script>
<style lang="less" scoped>
#feature-list-container {
display: flex;
justify-content: space-between;
.no-padding-mobile {
width: 100%;
margin-left: 25%;
.secondary.menu #button-dropdown {
z-index: 10;
margin-right: 0;
padding-right: 0;
}
}
}
#form-filters {
margin: 0;
label + div {
min-height: 42px;
}
}
.ui.dropdown .menu .left.menu, .ui.dropdown > .left.menu .menu {
margin-right: 0 !important;
}
@media screen and (min-width: 767px) {
#form-filters {
div.field:first-child {
padding-left: 0;
}
div.field:last-child {
padding-right: 0;
}
}
}
@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;
margin-left: 0 !important;
}
.mobile-column {
flex-direction: column;
}
#form-filters > .field.column {
width: 100%;
padding: 0;
}
#form-filters > .field.column:first-child {
margin-top: 1rem;
}
#form-filters > .field.column:last-child {
margin-bottom: 1rem;
}
.map-container {
width: 100%;
}
}
</style>
<style>
#form-filters .multiselect__tags {
white-space: normal !important;
}
#form-filters .multiselect__tag {
background: var(--primary-color, #008c86) !important;
}
#form-filters .multiselect__tag-icon:focus, #form-filters .multiselect__tag-icon:hover{
background: var(--primary-highlight-color, #006f6a) !important;
}
#form-filters .multiselect__tag-icon:not(:hover)::after {
color: var(--primary-highlight-color, #006f6a);
filter: brightness(0.6);
}
#form-filters .multiselect__option--selected:not(:hover) {
background-color: #e8e8e8 !important;
}
</style>
\ No newline at end of file
<template>
</template>
<script>
export default {
name: 'FeaturesMap',
}
</script>
\ No newline at end of file