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 4634 additions and 155 deletions
<template>
<div>
<div
id="geocoder-container"
:class="{ isExpanded }"
>
<button
class="button-geocoder"
title="Rechercher une adresse"
type="button"
@click="toggleGeocoder"
>
<i class="search icon" />
</button>
</div>
<div
id="geocoder-select-container"
:class="{ isExpanded }"
>
<!-- internal-search should be disabled to avoid filtering options, which is done by the api calls anyway https://stackoverflow.com/questions/57813170/vue-multi-select-not-showing-all-the-options -->
<!-- otherwise approximate results are not shown (cannot explain why though) -->
<Multiselect
v-if="isExpanded"
ref="multiselect"
v-model="selection"
class="expanded-geocoder"
:options="addresses"
:options-limit="limit"
:allow-empty="true"
:internal-search="false"
track-by="id"
label="label"
:show-labels="false"
:reset-after="true"
select-label=""
selected-label=""
deselect-label=""
:searchable="true"
:placeholder="placeholder"
:show-no-results="true"
:loading="loading"
:clear-on-select="false"
:preserve-search="true"
@search-change="search"
@select="select"
@open="retrievePreviousPlaces"
@close="close"
>
<template
slot="option"
slot-scope="props"
>
<div class="option__desc">
<span class="option__title">{{ props.option.label }}</span>
</div>
</template>
<template slot="clear">
<div
v-if="selection"
class="multiselect__clear"
@click.prevent.stop="selection = null"
>
<i class="close icon" />
</div>
</template>
<span slot="noResult">
Aucun résultat.
</span>
<span slot="noOptions">
Saisissez les premiers caractères ...
</span>
</Multiselect>
<div style="display: none;">
<div
id="marker"
title="Marker"
/>
</div>
</div>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import axios from 'axios';
import mapService from '@/services/map-service';
const apiAdressAxios = axios.create({
baseURL: 'https://api-adresse.data.gouv.fr',
withCredentials: false,
});
export default {
name: 'Geocoder',
components: {
Multiselect
},
data() {
return {
loading: false,
limit: 10,
selection: null,
text: null,
selectedAddress: null,
addresses: [],
resultats: [],
placeholder: 'Rechercher une adresse ...',
isExpanded: false
};
},
mounted() {
this.addressTextChange = new Subject();
this.addressTextChange.pipe(debounceTime(200)).subscribe((res) => this.getAddresses(res));
},
methods: {
toggleGeocoder() {
this.isExpanded = !this.isExpanded;
if (this.isExpanded) {
this.retrievePreviousPlaces();
this.$nextTick(()=> this.$refs.multiselect.activate());
}
},
getAddresses(query){
if (query.length < 3) {
this.addresses = [];
return;
}
const coords = mapService.getMapCenter();
let url = `https://api-adresse.data.gouv.fr/search/?q=${query}&limit=${this.limit}`;
if (coords) url += `&lon=${coords[0]}&lat=${coords[1]}`;
apiAdressAxios.get(url)
.then((retour) => {
this.resultats = retour.data.features;
this.addresses = retour.data.features.map(x=>x.properties);
});
},
selectAddresse(event) {
this.selectedAddress = event;
if (this.selectedAddress !== null && this.selectedAddress.geometry) {
let zoomlevel = 14;
const { type } = this.selectedAddress.properties;
if (type === 'housenumber') {
zoomlevel = 19;
} else if (type === 'street') {
zoomlevel = 16;
} else if (type === 'locality') {
zoomlevel = 16;
}
// On ajoute un point pour localiser la ville
mapService.addOverlay(this.selectedAddress.geometry.coordinates, zoomlevel);
// On enregistre l'adresse sélectionné pour le proposer à la prochaine recherche
this.setLocalstorageSelectedAdress(this.selectedAddress);
this.toggleGeocoder();
}
},
search(text) {
this.text = text;
this.addressTextChange.next(this.text);
},
select(e) {
this.selectAddresse(this.resultats.find(x=>x.properties.label === e.label));
this.$emit('select', e);
},
close() {
this.$emit('close', this.selection);
},
setLocalstorageSelectedAdress(newAdress) {
let selectedAdresses = JSON.parse(localStorage.getItem('geocontrib-selected-adresses'));
selectedAdresses = Array.isArray(selectedAdresses) ? selectedAdresses : [];
selectedAdresses = [ newAdress, ...selectedAdresses ];
const uniqueLabels = [...new Set(selectedAdresses.map(el => el.properties.label))];
const uniqueAdresses = uniqueLabels.map((label) => {
return selectedAdresses.find(adress => adress.properties.label === label);
});
localStorage.setItem(
'geocontrib-selected-adresses',
JSON.stringify(uniqueAdresses.slice(0, 5))
);
},
getLocalstorageSelectedAdress() {
return JSON.parse(localStorage.getItem('geocontrib-selected-adresses')) || [];
},
retrievePreviousPlaces() {
const previousAdresses = this.getLocalstorageSelectedAdress();
if (previousAdresses.length > 0) {
this.addresses = previousAdresses.map(x=>x.properties);
this.resultats = previousAdresses;
}
},
}
};
</script>
<style lang="less">
#marker {
width: 14px;
height: 14px;
border: 2px solid #fff;
border-radius: 7px;
background-color: #3399CC;
}
#geocoder-container {
position: absolute;
right: 6px;
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(1.6em + 60px + 34px + 34px);
pointer-events: auto;
z-index: 999;
border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box;
padding: 0;
border-radius: 4px;
display: flex;
.button-geocoder {
border: none;
padding: 0;
margin: 0;
text-align: center;
background-color: #fff;
color: rgb(39, 39, 39);
width: 30px;
height: 30px;
font: 700 18px Lucida Console,Monaco,monospace;
border-radius: 2px;
line-height: 1.15;
i {
margin: 0;
font-size: 0.9em;
}
}
.button-geocoder:hover {
cursor: pointer;
background-color: #ebebeb;
}
.expanded-geocoder {
max-width: 400px;
}
&&.isExpanded {
.button-geocoder {
/*height: 41px;*/
color: rgb(99, 99, 99);
border-radius: 2px 0 0 2px;
}
}
}
#geocoder-select-container{
position: absolute;
right: 46px;
// each button have (more or less depends on borders) .5em space between
// zoom buttons are 60px high, geolocation and full screen button is 34px high with borders
top: calc(1.6em + 60px + 34px + 34px - 4px);
pointer-events: auto;
z-index: 999;
border: 2px solid rgba(0,0,0,.2);
background-clip: padding-box;
padding: 0;
border-radius: 4px;
display: flex;
// /* keep placeholder width when opening dropdown */
.multiselect {
min-width: 208px;
}
/* keep font-weight from overide of semantic classes */
.multiselect__placeholder, .multiselect__content, .multiselect__tags {
font-weight: initial !important;
}
/* keep placeholder eigth */
.multiselect .multiselect__placeholder {
margin-bottom: 9px !important;
padding-top: 1px;
}
/* keep placeholder height when opening dropdown without selection */
input.multiselect__input {
padding: 3px 0 0 0 !important;
}
/* keep placeholder height when opening dropdown with already a value selected */
.multiselect__tags .multiselect__single {
padding: 1px 0 0 0 !important;
margin-bottom: 9px;
}
.multiselect__tags {
border: 0 !important;
min-height: 41px !important;
padding: 10px 40px 0 8px;
}
.multiselect input {
line-height: 1em !important;
padding: 0 !important;
}
.multiselect__content-wrapper {
border: 2px solid rgba(0,0,0,.2);
}
}
</style>
\ No newline at end of file
<template>
<div class="geolocation-container">
<button
:class="['button-geolocation', { tracking }]"
title="Me localiser"
@click.prevent="toggleTracking"
>
<i class="icon" />
</button>
</div>
</template>
<script>
import mapService from '@/services/map-service';
export default {
name: 'Geolocation',
data() {
return {
tracking: false,
};
},
methods: {
toggleTracking() {
this.tracking = !this.tracking;
mapService.toggleGeolocation(this.tracking);
},
}
};
</script>
\ No newline at end of file
<template>
<div class="basemaps-items ui accordion styled">
<div
:class="['basemap-item title', { active }]"
@click="$emit('activateGroup', basemap.id)"
>
<i
class="map outline fitted icon"
aria-hidden="true"
/>
<span>{{ basemap.title }}</span>
</div>
<div
v-if="queryableLayersOptions.length > 0 && active"
:id="`queryable-layers-selector-${basemap.id}`"
>
<strong>Couche requêtable</strong>
<Dropdown
:options="queryableLayersOptions"
:selected="selectedQueryLayer"
:search="true"
@update:selection="setQueryLayer($event)"
/>
</div>
<div
:id="`list-${basemap.id}`"
:class="['content', { active }]"
:data-basemap-index="basemap.id"
>
<div
v-for="layer in basemap.layers"
:key="basemap.id + '-' + layer.id + Math.random()"
class="layer-item transition visible item list-group-item"
:data-id="layer.id"
>
<!-- layer id is used for retrieving layer when changing order -->
<p class="layer-handle-sort">
<i
class="th icon"
aria-hidden="true"
/>
{{ layer.title }}
</p>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
type="range"
min="0"
max="1"
:value="layer.opacity"
step="0.01"
@change="updateOpacity($event, layer)"
><output class="range-output-bubble">{{
getOpacity(layer.opacity)
}}</output>
</div>
<div class="ui divider" />
</div>
</div>
</div>
</template>
<script>
import Sortable from 'sortablejs';
import Dropdown from '@/components/Dropdown.vue';
import mapService from '@/services/map-service';
export default {
name: 'LayerSelector',
components: {
Dropdown,
},
props: {
basemap: {
type: Object,
default: null,
},
active: {
type: Boolean,
default: false,
},
selectedQueryLayer: {
type: String,
default: ''
}
},
data() {
return {
sortable: null,
};
},
computed: {
queryableLayersOptions() {
const queryableLayers = this.basemap.layers.filter((l) => l.queryable === true);
return queryableLayers.map((x) => {
return {
name: x.title,
value: x,
};
});
},
},
mounted() {
setTimeout(this.initSortable.bind(this), 1000);
},
methods: {
isQueryable(baseMap) {
const queryableLayer = baseMap.layers.filter((l) => l.queryable === true);
return queryableLayer.length > 0;
},
initSortable() {
const element = document.getElementById(`list-${this.basemap.id}`);
if (element) {
this.sortable = new Sortable(element, {
animation: 150,
handle: '.layer-handle-sort', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: () => this.$emit('onlayerMove'),
});
} else {
console.error(`list-${this.basemap.id} not found in dom`);
}
},
setQueryLayer(layer) {
this.$emit('onQueryLayerChange', layer.name);
},
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
updateOpacity(event, layer) {
const layerId = layer.id;
const opacity = event.target.value;
mapService.updateOpacity(layerId, opacity);
this.$emit('onOpacityUpdate', { layerId, opacity });
},
}
};
</script>
<style>
.basemap-item.title > i {
margin-left: -1em !important;
}
.basemap-item.title > span {
margin-left: .5em;
}
.queryable-layers-dropdown {
margin-bottom: 1em;
}
.queryable-layers-dropdown > label {
font-weight: bold;
}
</style>
<template>
<div :class="['sidebar-container', { expanded }]">
<!-- <div class="sidebar-layers"></div> -->
<div @click="expanded = !expanded" class="layers-icon">
<div
v-if="isOnline"
:class="['sidebar-container', { expanded }]"
>
<div
class="layers-icon"
@click="toggleSidebar()"
>
<!-- // ! svg point d'interrogation pas accepté par linter -->
<!-- <?xml version="1.0" encoding="iso-8859-1"?> -->
<svg
version="1.1"
id="Capa_1"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
......@@ -17,6 +22,7 @@
>
<g>
<g>
<!-- eslint-disable max-len -->
<path
d="M487.298,326.733l-62.304-37.128l62.304-37.128c2.421-1.443,3.904-4.054,3.904-6.872s-1.483-5.429-3.904-6.872
l-62.304-37.128l62.304-37.128c3.795-2.262,5.038-7.172,2.776-10.968c-0.68-1.142-1.635-2.096-2.776-2.776l-237.6-141.6
......@@ -29,6 +35,7 @@
L245.601,377.893L23.625,245.605z M245.601,465.893L23.625,333.605l58.208-34.68l159.672,95.2c2.524,1.504,5.668,1.504,8.192,0
l159.672-95.2l58.208,34.68L245.601,465.893z"
/>
<!--eslint-enable-->
</g>
</g>
</svg>
......@@ -37,233 +44,187 @@
<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
:id="`queryable-layers-selector-${basemap.id}`"
v-if="isQueryable(basemap)"
>
<b>Couche requêtable</b>
<Dropdown
@update:selection="onQueryLayerChange($event)"
:options="getQueryableLayers(basemap)"
:selected="selectedQueryLayer"
:search="true"
/>
</div>
<div
:class="{ active: isActive(basemap) }"
class="content"
:id="`list-${basemap.id}`"
: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"></i>{{ layer.title }}
</p>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
@change="updateOpacity($event, layer)"
type="range"
min="0"
max="1"
:value="layer.opacity"
step="0.01"
/><output class="range-output-bubble">{{
getOpacity(layer.opacity)
}}</output>
</div>
<div class="ui divider"></div>
</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 { mapState } from 'vuex';
import LayerSelector from '@/components/Map/LayerSelector.vue';
import mapService from '@/services/map-service';
export default {
name: "SidebarLayers",
name: 'SidebarLayers',
components: {
Dropdown,
LayerSelector
},
data() {
return {
selectedQueryLayer: null,
activeBasemap: null,
baseMaps: [],
expanded: false,
projectSlug: this.$route.params.slug,
selectedQueryLayer: '',
};
},
computed: {
...mapState("map", ["availableLayers"]),
...mapState([
'isOnline',
]),
...mapState('map', [
'availableLayers',
'basemaps'
]),
activeBasemap() {
return this.baseMaps.find((baseMap) => baseMap.active);
},
activeBasemapIndex() {
return this.baseMaps.findIndex((el) => el.id === this.activeBasemap.id);
},
activeQueryableLayers() {
return this.baseMaps[this.activeBasemapIndex].layers.filter((layer) => layer.queryable);
},
},
mounted() {
this.initBasemaps();
},
methods: {
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) {
console.log(event.target.value, 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) {
console.log(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(event) {
console.log(event);
// 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
console.log(this.baseMaps);
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) => {
new Sortable(document.getElementById(`list-${basemap.id}`), {
animation: 150,
handle: ".layer-handle-sort", // The element that is active to drag
ghostClass: "blue-background-class",
dragClass: "white-opacity-background-class",
onEnd: this.onlayerMove.bind(this),
});
});
},
// 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;
}
}
}
......@@ -272,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]
......@@ -287,81 +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);
console.log(layerOptions);
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);
mapService.addLayers(baseMap.layers.slice().reverse(), null, null, null,);
},
},
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
);
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 = [];
if (areChanges) {
mapOptions[project] = {
"map-options": this.baseMaps,
"current-basemap-index": 0,
};
localStorage.setItem(
"geocontrib-map-options",
JSON.stringify(mapOptions)
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 {
this.baseMaps = baseMapsFromLocalstorage;
console.error('No such param \'query\' found among basemap[0].layers');
}
}
},
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
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)
);
}
setTimeout(this.initSortable.bind(this), 1000);
},
},
};
</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
<template>
<div style="display: flex;">
<nav style="margin: 0 auto;">
<ul class="custom-pagination">
<li
class="page-item"
:class="{ disabled: currentPage === 1 }"
>
<a
class="page-link pointer"
@click="changePage(currentPage - 1)"
>
<i
class="ui icon big angle left"
aria-hidden="true"
/>
</a>
</li>
<div
v-if="nbPages > 5"
style="display: contents;"
>
<li
v-for="index in pagination(currentPage, nbPages)"
:key="index"
class="page-item"
:class="{ active: currentPage === index }"
>
<a
class="page-link pointer"
@click="changePage(index)"
>
{{ index }}
</a>
</li>
</div>
<div
v-else
style="display: contents;"
>
<li
v-for="index in nbPages"
:key="index"
class="page-item"
:class="{ active: currentPage === index }"
>
<a
class="page-link pointer"
@click="changePage(index)"
>
{{ index }}
</a>
</li>
</div>
<li
class="page-item"
:class="{ disabled: currentPage === nbPages }"
>
<a
class="page-link pointer"
@click="changePage(currentPage + 1)"
>
<i
class="ui icon big angle right"
aria-hidden="true"
/>
</a>
</li>
</ul>
</nav>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
name: 'Pagination',
props: {
nbPages: {
type: Number,
default: 1
},
},
computed: {
...mapState('projects', ['currentPage']),
},
methods: {
...mapMutations('projects', [
'SET_CURRENT_PAGE',
'SET_PROJECTS_FILTER'
]),
pagination(c, m) {
const current = c,
last = m,
delta = 2,
left = current - delta,
right = current + delta + 1,
range = [],
rangeWithDots = [];
let l;
for (let i = 1; i <= last; i++) {
if (i === 1 || i === last || i >= left && i < right) {
range.push(i);
}
}
for (const i of range) {
if (l) {
if (i - l === 2) {
rangeWithDots.push(l + 1);
} else if (i - l !== 1) {
rangeWithDots.push('...');
}
}
rangeWithDots.push(i);
l = i;
}
return rangeWithDots;
},
changePage(pageNumber) {
if (typeof pageNumber === 'number') {
this.SET_CURRENT_PAGE(pageNumber);
// Scroll back to the first results on top of page
window.scrollTo({ top: 0, behavior: 'smooth' });
// emit event for parent component to fetch new page data
this.$emit('page-update', pageNumber);
}
}
}
};
</script>
<template>
<div class="ui segment">
<div class="ui segment secondary">
<div class="field required">
<label for="basemap-title">Titre</label>
<input
v-model="basemap.title"
:value="basemap.title"
type="text"
name="basemap-title"
required
/>
@input="updateTitle"
>
<ul
v-if="basemap.errors && basemap.errors.length > 0"
id="errorlist-title"
......@@ -22,8 +23,7 @@
<div class="nested">
<div
:id="`list-${basemap.id}`"
v-if="basemap.layers"
class="ui segments layers-container"
:class="[basemap.layers.length > 0 ? 'ui segments': '', 'layers-container', 'raised']"
>
<ProjectMappingContextLayer
v-for="layer in basemap.layers"
......@@ -32,50 +32,61 @@
:basemapid="basemap.id"
/>
</div>
<div class="ui buttons">
<a
<div class="ui bottom two attached buttons">
<button
class="ui icon button basic positive"
type="button"
@click="addLayer"
class="ui compact small icon left floated button green"
>
<i class="ui plus icon"></i>
<span>Ajouter une couche</span>
</a>
</div>
<div @click="deleteBasemap" class="ui buttons">
<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"></i>
<span>Supprimer ce fond cartographique</span>
</a>
<i
class="ui trash alternate icon"
aria-hidden="true"
/>
<span>&nbsp;Supprimer ce fond cartographique</span>
</button>
</div>
</div>
</div>
</template>
<script>
import Sortable from "sortablejs";
import ProjectMappingContextLayer from "@/components/project/ProjectMappingContextLayer.vue";
import { mapMutations } from 'vuex';
export default {
name: "Project_mapping_basemap",
import Sortable from 'sortablejs';
import ProjectMappingContextLayer from '@/components/Project/Basemaps/ProjectMappingContextLayer.vue';
props: ["basemap"],
export default {
name: 'BasemapListItem',
components: {
ProjectMappingContextLayer,
},
props: {
basemap: {
type: Object,
default: null,
}
},
data() {
return {
sortable: null
};
},
computed: {
maxLayersCount: function () {
return this.basemap.layers.reduce((acc, curr) => {
......@@ -88,21 +99,37 @@ export default {
},
},
created() {
if (this.basemap.layers) {
//* add datakeys to layers coming from api
this.fillLayersWithDatakey(this.basemap.layers);
}
},
mounted() {
this.initSortable();
},
methods: {
...mapMutations('map', [
'UPDATE_BASEMAP',
'DELETE_BASEMAP',
'REPLACE_BASEMAP_LAYERS'
]),
deleteBasemap() {
this.$store.commit("map/DELETE_BASEMAP", this.basemap.id);
this.DELETE_BASEMAP(this.basemap.id);
},
addLayer(layer) {
const newLayer = {
dataKey: this.maxLayersCount + 1,
opacity: "1.00",
opacity: '1.00',
order: 0,
queryable: false,
title: "Open street map",
title: 'Open street map',
...layer,
};
this.$store.commit("map/UPDATE_BASEMAP", {
this.UPDATE_BASEMAP({
layers: [...this.basemap.layers, newLayer],
id: this.basemap.id,
title: this.basemap.title,
......@@ -111,10 +138,14 @@ export default {
},
updateTitle(evt) {
this.$store.commit("map/UPDATE_BASEMAP", {
let errors = '';
if(evt.target.value === '') { //* delete or add error message while typing
errors = 'Veuillez compléter ce champ.';
}
this.UPDATE_BASEMAP({
id: this.basemap.id,
title: evt.title,
errors: evt.errors,
title: evt.target.value,
errors,
});
},
......@@ -124,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;
......@@ -134,29 +165,27 @@ export default {
},
//* drag & drop *//
onlayerMove(event) {
console.log(event);
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) {
matchingLayer["order"] = order;
matchingLayer['order'] = order;
movedLayers.push(matchingLayer);
order += 1;
}
}
//* update the store
this.$store.commit("map/UPDATE_BASEMAP", {
this.UPDATE_BASEMAP({
layers: movedLayers,
id: this.basemap.id,
title: this.basemap.title,
......@@ -165,56 +194,14 @@ 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",
dragClass: "white-opacity-background-class",
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),
});
},
},
watch: {
"basemap.title": {
deep: true,
handler: function (newValue, oldValue) {
if (newValue !== oldValue) {
this.basemap.title = newValue;
if (newValue === "")
this.basemap.errors = "Veuillez compléter ce champ.";
else this.basemap.errors = "";
this.updateTitle(this.basemap);
}
},
},
},
created() {
if (this.basemap.layers) {
//* add datakeys to layers coming from api
this.fillLayersWithDatakey(this.basemap.layers);
}
},
// destroyed(){
// this.errors = [];
// }
mounted() {
//* not present in original
this.initSortable();
// if (!this.basemap.title) {
// this.$store.commit("map/UPDATE_BASEMAP", {
// id: this.basemap.id,
// title: newValue,
// });
// }
},
};
</script>
<style scoped>
.button {
margin-right: 0.5em !important;
}
</style>
\ No newline at end of file
<template>
<div :id="layer.dataKey" class="ui segment layer-item">
<div
:id="layer.dataKey"
:class="`ui segment layer-item basemap-${basemapid}`"
>
<div class="ui divided form">
<div class="field" data-type="layer-field">
<label for="form.layer.id_for_label" class="layer-handle-sort">
<i class="th icon"></i>couche
<div
class="field"
data-type="layer-field"
>
<label
:for="layer.title"
class="layer-handle-sort"
>
<i
class="th icon"
aria-hidden="true"
/>
couche
</label>
<!-- {% if is_empty %} {# TODO arranger le dropdown pour les ajout à la volée
#} {# le selecteur de couche ne s'affichant pas correctement on passe
par un field django par defaut en attendant #} -->
<!-- {{ form.layer }} -->
<!-- {% else %} -->
<Dropdown
:options="availableLayerOptions"
:selected="selectedLayer.name"
......@@ -17,60 +25,74 @@
:search="true"
:placeholder="placeholder"
/>
<!-- {{ form.layer.errors }} -->
</div>
<div class="fields">
<div class="six wide field">
<label for="opacity">Opacité</label>
<input
v-model.number="layerOpacity"
type="number"
oninput="validity.valid||(value='');"
step="0.01"
min="0"
max="1"
>
</div>
<div class="field">
<div
class="field three wide {% if form.opacity.errors %} error{% endif %}"
class="ui checkbox"
@click="updateLayer({ ...layer, queryable: !layer.queryable })"
>
<label for="opacity">Opacité</label>
<input
type="number"
v-model.number="layerOpacity"
oninput="validity.valid||(value='');"
step="0.01"
min="0"
max="1"
/>
<!-- {{ form.opacity.errors }} -->
</div>
<div class="field three wide">
<div
@click="updateLayer({ ...layer, queryable: !layer.queryable })"
class="ui checkbox"
:checked="layer.queryable"
class="hidden"
type="checkbox"
name="queryable"
>
<input type="checkbox" v-model="layer.queryable" name="queryable" />
<label for="queryable"> Requêtable</label>
</div>
<!-- {{ form.queryable.errors }} -->
<label for="queryable">&nbsp;Requêtable</label>
</div>
</div>
<div @click="removeLayer" class="field">
<div class="ui compact small icon floated button button-hover-red">
<i class="ui grey trash alternate icon"></i>
<span>Supprimer cette couche</span>
</div>
</div>
<button
type="button"
class="ui compact small icon floated button button-hover-red"
@click="removeLayer"
>
<i
class="ui grey trash alternate icon"
aria-hidden="true"
/>
<span>&nbsp;Supprimer cette couche</span>
</button>
</div>
</div>
</template>
<script>
import Dropdown from "@/components/Dropdown.vue";
import { mapState } from "vuex";
import Dropdown from '@/components/Dropdown.vue';
import { mapState } from 'vuex';
export default {
name: "ProjectMappingContextLayer",
props: ["layer", "basemapid"],
name: 'ProjectMappingContextLayer',
components: {
Dropdown,
},
props: {
layer:
{
type: Object,
default: null,
},
basemapid:
{
type: Number,
default: null,
},
},
computed: {
...mapState("map", ["availableLayers"]),
...mapState('map', ['availableLayers']),
selectedLayer: {
get() {
......@@ -115,42 +137,42 @@ export default {
placeholder: function () {
return this.selectedLayer && this.selectedLayer.name
? ""
: "Choisissez une couche";
? ''
: 'Choisissez une couche';
},
},
mounted() {
const matchingLayer = this.retrieveLayer(this.layer.title);
if (matchingLayer !== undefined) {
this.updateLayer({
...this.layer,
service: matchingLayer.service,
title: matchingLayer.title,
id: matchingLayer.id,
});
}
},
methods: {
retrieveLayer(title) {
return this.availableLayerOptions.find((el) => el.title === title);
},
removeLayer() {
this.$store.commit("map/DELETE_BASEMAP_LAYER", {
this.$store.commit('map/DELETE_BASEMAP_LAYER', {
basemapId: this.basemapid,
layerId: this.layer.dataKey,
});
},
updateLayer(layer) {
this.$store.commit("map/UPDATE_BASEMAP_LAYER", {
this.$store.commit('map/UPDATE_BASEMAP_LAYER', {
basemapId: this.basemapid,
layerId: this.layer.dataKey,
layer,
});
},
},
mounted() {
const matchingLayer = this.retrieveLayer(this.layer.title);
if (matchingLayer !== undefined) {
this.updateLayer({
...this.layer,
service: matchingLayer.service,
title: matchingLayer.title,
id: matchingLayer.id,
});
}
},
};
</script>
\ No newline at end of file
</script>
<template>
<div>
<h3 class="ui header">
Types de signalements
</h3>
<div
id="feature_type-list"
class="ui middle aligned divided list"
>
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des types de signalements en cours...
</div>
</div>
<div
:class="{ active: importing }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Traitement du fichier en cours ...
</div>
</div>
<div
v-for="(type, index) in feature_types"
:id="type.title"
:key="type.title + '-' + index"
class="item"
>
<div class="feature-type-container">
<FeatureTypeLink :feature-type="type" />
<div class="middle aligned content">
<div
v-if="isImporting(type)"
class="import-message"
>
<i
class="info circle icon"
aria-hidden="true"
/>
Import en cours
</div>
<template v-else>
<router-link
v-if="project && type.is_editable && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'editer-type-signalement',
params: { slug_type_signal: type.slug },
}"
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 pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<router-link
v-if="project && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'editer-affichage-signalement',
params: { slug_type_signal: type.slug },
}"
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 paint brush alternate icon"
aria-hidden="true"
/>
</router-link>
<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">
<em> Le projet ne contient pas encore de type de signalements. </em>
</div>
</div>
<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 && isOnline"
:to="{
name: 'ajouter-type-signalement',
params: { slug },
}"
class="ui compact basic button"
data-test="add-feature-type"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label class="ui pointer">
Créer un nouveau type de signalement
</label>
</router-link>
<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 && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-json"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui pointer"
for="json_file"
>
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"
style="display: none"
name="json_file"
@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>
<router-link
v-if="IDGO && permissions && permissions.can_update_project && isOnline"
:to="{
name: 'catalog-import',
params: {
slug,
feature_type_slug: 'create'
},
}"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-catalog"
>
<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="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="csvFileToImport.size === 0"
class="ui fluid teal icon button"
data-test="start-csv-file-import"
@click="toNewCsvFeatureType"
>
<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 inverted"
>
<div
:class="isFileSizeModalOpen ? 'active' : ''"
class="ui modal tiny"
style="top: 20%"
>
<div class="header">
Fichier trop grand!
</div>
<div class="content">
<p>
Impossible de créer un type de signalement à partir d'un fichier
de plus de 100Mo (celui importé fait {{ geojsonFileSize > 0 ? geojsonFileSize : csvFileSize }} Mo).
</p>
</div>
<div class="actions">
<div
class="ui button teal"
@click="closeFileSizeModal"
>
Fermer
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { csv } from 'csvtojson';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import { fileConvertSizeToMo, determineDelimiter } from '@/assets/js/utils';
import FeatureTypeLink from '@/components/FeatureType/FeatureTypeLink';
export default {
name: 'ProjectFeatureTypes',
components: {
FeatureTypeLink
},
props: {
loading: {
type: Boolean,
default: false
},
project: {
type: Object,
default: () => {
return {};
},
}
},
data() {
return {
importing: false,
slug: this.$route.params.slug,
isFileSizeModalOpen: false,
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',
'importFeatureTypeData'
]),
...mapGetters([
'permissions'
]),
CATALOG_NAME() {
return this.configuration.VUE_APP_CATALOG_NAME;
},
IDGO() {
return this.$store.state.configuration.VUE_APP_IDGO;
},
isProjectAdmin() {
return this.user_permissions && this.user_permissions[this.slug] &&
this.user_permissions[this.slug].is_project_administrator;
},
geojsonFileSize() {
return fileConvertSizeToMo(this.geojsonFileToImport.size);
},
csvFileSize() {
return fileConvertSizeToMo(this.csvFileToImport.size);
},
},
mounted() {
this.fetchImports();
},
methods: {
...mapMutations('feature-type', [
'SET_FILE_TO_IMPORT'
]),
...mapActions('feature-type', [
'GET_IMPORTS',
]),
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(
(el) => el.feature_type_title === type.slug
);
return singleImportData && singleImportData.status === 'pending';
}
return false;
},
goToDocumentation() {
window.open(this.configuration.VUE_APP_URL_DOCUMENTATION);
},
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: {
csv: this.csvImport,
fileToImport: this.csvFileToImport,
},
});
this.importing = false;
},
onGeoJSONFileChange(e) {
this.importing = true;
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.geojsonFileToImport.size)) > 100) {
this.isFileSizeModalOpen = true;
} else if (this.geojsonFileToImport.size > 0) {
const fr = new FileReader();
try {
fr.onload = (ev) => {
this.geojsonImport = JSON.parse(ev.target.result);
this.importing = false;
};
fr.readAsText(this.geojsonFileToImport);
//* stock filename to import features afterward
this.SET_FILE_TO_IMPORT(this.geojsonFileToImport);
} catch (err) {
console.error(err);
this.importing = false;
}
} else {
this.importing = false;
}
},
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.geojsonFileToImport = { name: '', size: 0 };
this.csvFileToImport = { name: '', size: 0 };
this.importing = false;
this.isFileSizeModalOpen = false;
},
toggleDeleteFeatureType(featureType) {
this.$emit('delete', featureType);
},
}
};
</script>
<style>
/* // ! missing style in semantic.min.css */
.ui.right.floated.button {
float: right;
}
</style>
<style lang="less" scoped>
.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;
}
}
#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 {
margin-top: 0.5em;
}
.button-align-left {
display: flex;
align-items: center;
text-align: left;
width: fit-content;
}
.import-message {
color: var(--primary-highlight-color, #008c86);
white-space: nowrap;
padding: .25em;
display: flex;
align-items: center;
& > i {
height: auto;
}
}
</style>
<template>
<div class="project-header ui grid stackable">
<div class="row">
<div class="three wide middle aligned column">
<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="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 margin-bottom">
{{ project.title }}
</h1>
<div class="sub header">
<!-- {{ project.description }} -->
<div id="preview" />
<textarea
id="editor"
v-model="project.description"
data-preview="#preview"
hidden
/>
</div>
</div>
<div class="four wide column right-column">
<div class="ui icon right compact buttons">
<a
v-if="
user &&
permissions &&
permissions.can_view_project &&
isOnline
"
id="subscribe-button"
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"
aria-hidden="true"
/>
</a>
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOnline
"
id="edit-project"
:to="{ name: 'project_edit', params: { slug } }"
class="ui button button-hover-orange tiny-margin"
data-tooltip="Modifier le projet"
data-position="bottom center"
data-variation="mini"
>
<i
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<a
v-if="isProjectAdmin && isOnline"
id="delete-button"
class="ui button button-hover-red tiny-margin"
data-tooltip="Supprimer le projet"
data-position="bottom right"
data-variation="mini"
@click="OPEN_PROJECT_MODAL('deleteProject')"
>
<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>
<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"
aria-hidden="true"
@click="confirmMsg = false"
/>
</div>
</Transition>
</div>
<div
v-if="arraysOffline.length > 0"
class="centered"
>
{{ arraysOffline.length }} modification<span v-if="arraysOffline.length > 1">s</span> en attente
<button
:disabled="!isOnline"
class="ui fluid labeled teal icon button"
@click="sendOfflineFeatures"
>
<i
class="upload icon"
aria-hidden="true"
/>
Envoyer au serveur
</button>
</div>
</div>
</div>
</template>
<script>
import TextareaMarkdown from 'textarea-markdown';
import { mapState, mapGetters, mapMutations } from 'vuex';
import featureAPI from '@/services/feature-api';
export default {
name: 'ProjectHeader',
props: {
arraysOffline: {
type: Array,
default: () => {
return [];
}
}
},
data() {
return {
slug: this.$route.params.slug,
confirmMsg: false,
showShareOptions: false,
};
},
computed: {
...mapState('projects', [
'project'
]),
...mapState([
'configuration',
]),
...mapState([
'user',
'user_permissions',
'isOnline',
]),
...mapGetters([
'permissions'
]),
DJANGO_BASE_URL() {
return this.configuration.VUE_APP_DJANGO_BASE;
},
isProjectAdmin() {
return this.user_permissions && this.user_permissions[this.slug] &&
this.user_permissions[this.slug].is_project_administrator;
},
isSharedProject() {
return this.$route.path.includes('projet-partage');
},
},
mounted() {
let textarea = document.querySelector('textarea');
new TextareaMarkdown(textarea);
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
},
methods: {
...mapMutations('modals', [
'OPEN_PROJECT_MODAL'
]),
refreshId() {
const crypto = window.crypto || window.msCrypto;
var array = new Uint32Array(1);
return '?ver=' + crypto.getRandomValues(array); // Compliant for security-sensitive use cases
},
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(()=> {
this.confirmMsg = true;
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() {
this.arraysOfflineErrors = [];
const promises = this.arraysOffline.map((feature) => featureAPI.postOrPutFeature({
data: feature.geojson,
feature_id: feature.featureId,
project__slug: feature.project,
feature_type__slug: feature.geojson.properties.feature_type,
method: feature.type.toUpperCase(),
})
.then((response) => {
if (!response) {
this.arraysOfflineErrors.push(feature);
}
})
.catch((error) => {
console.error(error);
this.arraysOfflineErrors.push(feature);
})
);
this.$store.commit('DISPLAY_LOADER', 'Envoi des signalements en cours.');
Promise.all(promises).then(() => {
this.$emit('updateLocalStorage');
this.$emit('retrieveInfo');
this.$store.commit('DISCARD_LOADER');
});
},
}
};
</script>
<style lang="less" scoped>
.project-header {
.row .right-column {
display: flex;
flex-direction: column;
.ui.buttons {
justify-content: flex-end;
.ui.button {
flex-grow: 0; /* avoid stretching buttons */
}
}
}
.centered {
margin: auto;
text-align: center;
}
.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>
<template>
<div class="orange card">
<div class="content">
<div class="center aligned header">
Derniers commentaires
</div>
<div class="center aligned description">
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des commentaires en cours...
</div>
</div>
<div class="ui relaxed list">
<div
v-for="(item, index) in last_comments"
:key="'comment ' + index"
class="item"
>
<div class="content">
<FeatureFetchOffsetRoute
:feature-id="item.related_feature.feature_id"
:properties="{
title: item.comment,
feature_type: { slug: item.related_feature.feature_type_slug }
}"
/>
<div class="description">
<em>[ {{ item.created_on
}}<span
v-if="user && item.display_author"
>, par {{ item.display_author }}
</span>
]</em>
</div>
</div>
</div>
<em
v-if="!last_comments || last_comments.length === 0"
>Aucun commentaire pour le moment.</em>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectsLastComments',
components: {
FeatureFetchOffsetRoute,
},
props: {
loading: {
type: Boolean,
default: false
}
},
computed: {
...mapState([
'user'
]),
...mapState('projects', [
'last_comments',
]),
},
};
</script>
<template>
<div class="red card">
<div class="content">
<div class="center aligned header">
Derniers signalements
</div>
<div class="center aligned description">
<div
:class="{ active: loading }"
class="ui inverted dimmer"
>
<div class="ui text loader">
Récupération des signalements en cours...
</div>
</div>
<div class="ui relaxed list">
<div
v-for="(item, index) in features.slice(0,5)"
:key="item.properties.title + index"
class="item"
>
<div class="content">
<div>
<FeatureFetchOffsetRoute
:feature-id="item.id"
:properties="item.properties"
/>
</div>
<div class="description">
<em>
[{{ item.properties.created_on }}
<span v-if="user && item.properties.creator">
, par
{{
item.properties.creator.full_name
? item.properties.creator.full_name
: item.properties.creator.username
}}
</span>
]
</em>
</div>
</div>
</div>
<em
v-if="features.length === 0 && !loading"
>Aucun signalement pour le moment.</em>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectLastFeatures',
components: {
FeatureFetchOffsetRoute,
},
data() {
return {
loading: true,
};
},
computed: {
...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>
<template>
<div
v-if="isProjectModalOpen"
class="ui dimmer modals page transition visible active"
style="display: flex !important"
>
<div
:class="[
'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']"
aria-hidden="true"
/>
{{
projectModalType === 'subscribe' ? 'Notifications' : 'Suppression'
}} du {{
projectModalType === 'deleteFeatureType' ? 'type de signalement ' + featureTypeToDelete.title : 'projet'
}}
</div>
<div class="content">
<div v-if="projectModalType !== 'subscribe'">
<p class="centered-text">
Confirmez vous la suppression du {{
projectModalType === 'deleteProject' ?
'projet, ainsi que les types de signalements' :
'type de signalement'
}} et tous les signalements associés&nbsp;?
</p>
<p class="centered-text alert">
Attention cette action est irreversible !
</p>
</div>
<button
id="validate-modal"
:class="['ui compact fluid button', projectModalType === 'subscribe' && !isSubscriber ? 'green' : 'red']"
@click="handleModalAction"
>
<span v-if="projectModalType === 'subscribe'">
{{
isSubscriber
? "Se désabonner de ce projet"
: "S'abonner à ce projet"
}}
</span>
<span v-else>
Supprimer le
{{
projectModalType === 'deleteProject'
? 'projet'
: 'type de signalement'
}}
</span>
</button>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
name: 'ProjectModal',
props: {
isSubscriber: {
type: Boolean,
default: false
},
featureTypeToDelete: {
type: Object,
default: () => {
return {};
}
}
},
computed: {
...mapState('modals', [
'isProjectModalOpen',
'projectModalType'
])
},
methods: {
...mapMutations('modals', [
'CLOSE_PROJECT_MODAL'
]),
handleModalAction() {
this.$emit('action', this.projectModalType);
}
}
};
</script>
<style scoped>
.alert {
color: red;
}
.centered-text {
text-align: center;
}
</style>
<template>
<div class="ui grey segment">
<h3 class="ui header">
Paramètres du projet
</h3>
<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"
aria-hidden="true"
/>
<div class="content">
Visibilité des signalements publiés
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.access_level_pub_feature }}
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i
class="disabled grey eye icon"
aria-hidden="true"
/>
<div class="content">
Visibilité des signalements archivés
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.access_level_arch_feature }}
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i
class="disabled grey cogs icon"
aria-hidden="true"
/>
<div class="content">
Modération
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.moderation ? "Oui" : "Non" }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ProjectParameters',
props: {
project: {
type: Object,
default: () => {
return {};
}
}
}
};
</script>
<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="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"
>
<table
id="table-features"
class="ui compact table unstackable dataTable"
aria-describedby="Liste des signalements du projet"
>
<thead>
<tr>
<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
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('status')"
>
Statut
<i
:class="{
down: isSortedAsc('status'),
up: isSortedDesc('status'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('feature_type')"
>
Type
<i
:class="{
down: isSortedAsc('feature_type'),
up: isSortedDesc('feature_type'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('title')"
>
Nom
<i
:class="{
down: isSortedAsc('title'),
up: isSortedDesc('title'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
scope="col"
class="dt-center"
>
<div
: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
<i
:class="{
down: isSortedAsc('updated_on'),
up: isSortedDesc('updated_on'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
v-if="user"
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('display_creator')"
>
Auteur
<i
:class="{
down: isSortedAsc('display_creator'),
up: isSortedDesc('display_creator'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
<th
v-if="user"
scope="col"
class="dt-center"
>
<div
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('display_last_editor')"
>
Dernier éditeur
<i
:class="{
down: isSortedAsc('display_last_editor'),
up: isSortedDesc('display_last_editor'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(feature, index) in paginatedFeatures"
:key="index"
>
<td
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
id="select"
class="dt-center"
>
<div
:class="['ui checkbox', { disabled: isAllSelected || !checkRights(feature) }]"
>
<input
:id="feature.feature_id"
type="checkbox"
:value="feature.feature_id"
:checked="isFeatureSelected(feature)"
:disabled="isAllSelected || !checkRights(feature)"
name="select"
@input="handleFeatureSelection($event, feature)"
>
<label :for="feature.feature_id" />
</div>
</td>
<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.status === 'pending'"
data-tooltip="En attente de publication"
>
<i
class="teal hourglass outline icon"
aria-hidden="true"
/>
</div>
<div
v-else-if="feature.status === 'published'"
data-tooltip="Publié"
>
<i
class="olive check icon"
aria-hidden="true"
/>
</div>
<div
v-else-if="feature.status === 'draft'"
data-tooltip="Brouillon"
>
<i
class="orange pencil alternate icon"
aria-hidden="true"
/>
</div>
</td>
<td
id="type"
class="dt-center"
>
<router-link
:to="{
name: 'details-type-signalement',
params: {
feature_type_slug: feature.feature_type.slug,
},
}"
class="ellipsis space-left"
>
{{ feature.feature_type.title }}
</router-link>
</td>
<td
id="name"
class="dt-center"
>
<router-link
:to="{
name: 'details-signalement-filtre',
query: { ...queryparams, offset: queryparams.offset + index }
}"
class="ellipsis space-left"
>
{{ feature.title || feature.feature_id }}
</router-link>
</td>
<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"
>
{{ feature.display_creator || ' ---- ' }}
</td>
<td
v-if="user"
id="last_editor"
class="dt-center"
>
{{ feature.display_last_editor || ' ---- ' }}
</td>
</tr>
<tr
v-if="featuresCount === 0"
class="odd"
>
<td
colspan="5"
class="dataTables_empty"
valign="top"
>
Aucune donnée disponible
</td>
</tr>
</tbody>
</table>
<div
v-if="pageNumbers.length > 1"
id="table-features_info"
class="dataTables_info"
role="status"
aria-live="polite"
>
Affichage de l'élément {{ pagination.start + 1 }} à
{{ displayedPageEnd }}
sur {{ featuresCount }} éléments
</div>
<div
v-if="pageNumbers.length > 1 && isOnline"
id="table-features_paginate"
class="dataTables_paginate paging_simple_numbers"
>
<a
id="table-features_previous"
:class="[
'paginate_button previous',
{ disabled: pagination.currentPage === 1 },
]"
aria-controls="table-features"
data-dt-idx="0"
tabindex="0"
@click="$emit('update:page', 'previous')"
>Précédent</a>
<span>
<span v-if="pagination.currentPage > 5">
<a
key="page1"
class="paginate_button"
aria-controls="table-features"
data-dt-idx="1"
tabindex="0"
@click="$emit('update:page', 1)"
>1</a>
<span class="ellipsis"></span>
</span>
<a
v-for="pageNumber in displayedPageNumbers"
:key="'page' + pageNumber"
:class="[
'paginate_button',
{ current: pageNumber === pagination.currentPage },
]"
aria-controls="table-features"
data-dt-idx="1"
tabindex="0"
@click="$emit('update:page', pageNumber)"
>{{ pageNumber }}</a>
<span v-if="(lastPageNumber - pagination.currentPage) > 4">
<span class="ellipsis"></span>
<a
:key="'page' + lastPageNumber"
class="paginate_button"
aria-controls="table-features"
data-dt-idx="1"
tabindex="0"
@click="$emit('update:page', lastPageNumber)"
>{{ lastPageNumber }}</a>
</span>
</span>
<a
id="table-features_next"
:class="[
'paginate_button next',
{ disabled: pagination.currentPage === pageNumbers.length },
]"
aria-controls="table-features"
data-dt-idx="7"
tabindex="0"
@click="$emit('update:page', 'next')"
>Suivant</a>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapGetters, mapMutations } from 'vuex';
import { formatStringDate } from '@/utils';
export default {
name: 'FeatureListTable',
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,
default: null,
},
pageNumbers: {
type: Array,
default: null,
},
allSelected: {
type: Boolean,
default: false,
},
checkedFeatures: {
type: Array,
default: null,
},
featuresCount: {
type: Number,
default: 0,
},
pagination: {
type: Object,
default: null,
},
sort: {
type: Object,
default: null,
},
queryparams: {
type: Object,
default: null,
},
editAttributesFeatureType: {
type: String,
default: null,
},
},
computed: {
...mapGetters(['permissions']),
...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.USER_LEVEL_PROJECTS[this.$route.params.slug] : '';
},
checkedFeaturesSet() {
return new Set(this.checkedFeatures); // Set améliore la performance sur la recherche
},
isAllSelected: {
get() {
return this.allSelected;
},
set(isChecked) {
this.$emit('update:allSelected', isChecked);
},
},
displayedPageEnd() {
return this.featuresCount <= this.pagination.end
? this.featuresCount
: this.pagination.end;
},
lastPageNumber() {
return this.pageNumbers.slice(-1)[0];
},
displayedPageNumbers() {
//* s'il y a moins de 5 pages, renvoyer toutes les pages
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;
//* à partir de la 5ième page et jusqu'à la 4ième page avant la fin : n'afficher que 3 page entre les ellipses et la page courante doit être au milieu
if (this.pagination.currentPage >= 5 && !((this.lastPageNumber - this.pagination.currentPage) < 4)) {
firstPageInList = this.pagination.currentPage - 2;
pagesQuantity = 3;
}
//* à partir de 4 résultat avant la fin afficher seulement les 5 derniers résultats
if ((this.lastPageNumber - this.pagination.currentPage) < 4) {
firstPageInList = this.lastPageNumber - 5;
}
return this.pageNumbers.slice(firstPageInList, firstPageInList + pagesQuantity);
},
},
methods: {
...mapMutations('feature', [
'UPDATE_CLICKED_FEATURES',
'UPDATE_CHECKED_FEATURES',
'TOGGLE_MASS_MODE',
]),
/**
* 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.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' || 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'],
Modérateur : ['draft', 'pending', 'published'],
'Super Contributeur' : ['draft', 'pending', 'published'],
Contributeur : ['draft', 'pending', 'published'],
};
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.status);
} else {
return false;
}
},
checkRights(feature) {
if (this.massMode.includes('edit')) {
return this.canEditFeature(feature);
} else if (this.massMode === 'delete-features') {
return this.canDeleteFeature(feature);
}
},
isSortedAsc(column) {
return this.sort.column === column && this.sort.ascending;
},
isSortedDesc(column) {
return this.sort.column === column && !this.sort.ascending;
},
changeSort(column) {
if (!this.isOnline) return;
if (this.sort.column === column) {
//changer only order
this.$emit('update:sort', {
column: this.sort.column,
ascending: !this.sort.ascending,
});
} else { // change column and reset order
this.$emit('update:sort', { column, ascending: true });
}
},
},
};
</script>
<style scoped>
/* datatables */
.dataTables_wrapper {
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;
}
.dataTables_wrapper .dataTables_length,
.dataTables_wrapper .dataTables_filter,
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_processing,
.dataTables_wrapper .dataTables_paginate {
color: #333;
}
.dataTables_wrapper .dataTables_info {
clear: both;
float: left;
padding-top: 0.755em;
}
.dataTables_wrapper .dataTables_paginate {
float: right;
text-align: right;
padding-top: 0.25em;
}
.dataTables_wrapper .dataTables_paginate .paginate_button.current,
.dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
color: #333 !important;
border: 1px solid #979797;
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0%, #fff),
color-stop(100%, #dcdcdc)
);
background: -webkit-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -moz-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -ms-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: -o-linear-gradient(top, #fff 0%, #dcdcdc 100%);
background: linear-gradient(to bottom, #fff 0%, #dcdcdc 100%);
}
.dataTables_wrapper .dataTables_paginate .paginate_button {
box-sizing: border-box;
display: inline-block;
min-width: 1.5em;
padding: 0.5em 1em;
margin-left: 2px;
text-align: center;
text-decoration: none !important;
cursor: pointer;
color: #333 !important;
border: 1px solid transparent;
border-radius: 2px;
}
.dataTables_wrapper .dataTables_paginate .paginate_button:hover {
color: white !important;
border: 1px solid #111;
background: -webkit-gradient(
linear,
left top,
left bottom,
color-stop(0%, #585858),
color-stop(100%, #111)
);
background: -webkit-linear-gradient(top, #585858 0%, #111 100%);
background: -moz-linear-gradient(top, #585858 0%, #111 100%);
background: -ms-linear-gradient(top, #585858 0%, #111 100%);
background: -o-linear-gradient(top, #585858 0%, #111 100%);
background: linear-gradient(to bottom, #585858 0%, #111 100%);
}
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled,
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:hover,
.dataTables_wrapper .dataTables_paginate .paginate_button.disabled:active {
cursor: default;
color: #666 !important;
border: 1px solid transparent;
background: transparent;
box-shadow: none;
}
.dataTables_wrapper .dataTables_paginate .ellipsis {
padding: 0 1em;
}
i.icon.sort:not(.down):not(.up) {
color: rgb(220, 220, 220);
}
.grey {
color: #bbbbbb;
}
.ui.dropdown .menu .left.menu, .ui.dropdown > .left.menu .menu {
margin-right: 0 !important;
}
.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.
*/
@media only screen and (max-width: 760px) {
.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,
thead,
tbody,
th,
td,
tr {
display: block;
}
/* Hide table headers (but not display: none;, for accessibility) */
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
tr { /* style as a card */
border: 1px solid #ccc;
border-radius: 7px;
margin-bottom: 3vh;
padding: 0 2vw .5em 2vw;
box-shadow: rgba(50, 50, 50, 0.1) 2px 5px 10px ;
}
td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid #eee;
position: relative;
padding-left: 50%;
}
.ui.table tr td {
border-top: none;
}
.ui.compact.table td {
padding: .2em;
}
td:nth-of-type(1) {
border: none !important;
padding: .25em !important;
}
td:nth-of-type(8) {
border-bottom: none !important;
}
td:before {
/* Now like a table header */
position: absolute;
/* Top/left values mimic padding */
left: 6px;
padding-right: 10px;
white-space: nowrap;
}
/*
Label the data
*/
td#select:before {
content: "";
}
td#status:before {
content: "Statut";
}
td#type:before {
content: "Type";
}
td#name:before {
content: "Nom";
}
td#create:before {
content: "Date de création";
}
td#update:before {
content: "Dernière modification";
}
td#author:before {
content: "Auteur";
}
td#last_editor:before {
content: "Dernier éditeur";
}
table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable {
text-align: right;
}
#select .ui.checkbox {
position: absolute;
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 {
width: 100%;
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>
<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>
<div class="field">
<Dropdown
v-if="!disabled"
:options="projectMemberOptions"
:selected="selectedMember ? selectedMember.name : ''"
:selection.sync="selectedMember"
:search="true"
:clearable="true"
/>
<div v-else-if="selectedMember && selectedMember.name && Array.isArray(selectedMember.name)">
<span> {{ selectedMember.name[0] || selectedMember.name[1] }}</span>
</div>
</div>
</template>
<script>
import Dropdown from '@/components/Dropdown.vue';
import { formatUserOption } from '@/utils';
import { mapState } from 'vuex';
export default {
name: 'ProjectMemberSelect',
components: {
Dropdown,
},
props: {
selectedUserId: {
type: Number,
default: null,
},
disabled: {
type: Boolean,
default: false,
}
},
computed: {
...mapState('projects', [
'projectUsers'
]),
projectMemberOptions: function () {
return this.projectUsers
.filter((el) => el.level.codename !== 'logged_user') // Filter out user not member of the project (with level lower than contributor)
.map((el) => formatUserOption(el.user)); // Format user data to fit dropdown option structure
},
selectedMember: {
get() {
return this.projectMemberOptions.find(el => el.value === this.selectedUserId);
},
set(newValue) {
/**
* If the user delete previous assigned_member the value is undefined
* We replace it by null in order to allow empty field to be sent with the request
* & to comply with UPDATE_FORM_FIELD mutation logic
* TODO: If refactoring the app one day -> merge together both featureEdit form and feature store form to work the same way
*/
this.$emit('update:user', newValue.value || null);
},
}
},
created() {
this.$store.dispatch('projects/GET_PROJECT_USERS', this.$route.params.slug);
},
};
</script>
\ No newline at end of file
<template>
<Multiselect
v-model="selection"
:class="{ multiple }"
:options="options"
:allow-empty="true"
track-by="label"
label="label"
:reset-after="false"
select-label=""
selected-label=""
deselect-label=""
:searchable="false"
:placeholder="placeholder"
:clear-on-select="false"
:preserve-search="true"
:multiple="multiple"
:disabled="loading"
@select="select"
@remove="remove"
@close="close"
>
<template
slot="option"
slot-scope="props"
>
<span :title="props.option.label">{{ props.option.label }}</span>
</template>
<template
v-if="multiple"
slot="selection"
slot-scope="{ values }"
>
<span
v-if="values && values.length > 1"
class="multiselect__single"
>
{{ values.length }} options sélectionnées
</span>
<span
v-else
class="multiselect__single"
>{{ currentSelectionLabel || selection.label }}</span>
</template>
</Multiselect>
</template>
<script>
import Multiselect from 'vue-multiselect';
export default {
name: 'DropdownMenuItem',
components: {
Multiselect
},
props: {
placeholder: {
type: String,
default: 'Sélectionnez une valeur'
},
options: {
type: Array,
default: () => {
return [];
}
},
loading: {
type: Boolean,
default: false
},
multiple: {
type: Boolean,
default: false
},
currentSelection: {
type: [String, Array, Boolean],
default: null,
},
defaultFilter: {
type: [String, Array, Boolean],
default: null,
}
},
data() {
return {
selection: null,
};
},
computed: {
/**
* Get the label of an option to work with project attributes options as JSON
*/
currentSelectionLabel() {
const option = this.options.find(opt => opt.value === this.currentSelection);
return option ? option.label : '';
}
},
watch: {
selection: {
deep: true,
handler(newValue) {
if (!newValue) {
this.selection = this.options[0];
this.$emit('filter', this.selection);
}
}
},
currentSelection(newValue) {
this.updateSelection(newValue);
},
},
created() {
if (this.currentSelection !== null) {
this.selection = this.options.find(opt => opt.value === this.currentSelection);
} else {
this.selection = this.options[0];
}
},
methods: {
select(e) {
this.$emit('filter', e);
},
remove(e) {
this.$emit('remove', e);
},
close() {
this.$emit('close', this.selection);
},
/**
* Normalizes the input value(s) to an array of strings.
* This handles both single string inputs and comma-separated strings, converting them into an array.
*
* @param {String|Array} value - The input value to normalize, can be a string or an array of strings.
* @return {Array} An array of strings representing the input values.
*/
normalizeValues(value) {
// If the value is a string and contains commas, split it into an array; otherwise, wrap it in an array.
return typeof value === 'string' ? (value.includes(',') ? value.split(',') : [value]) : value;
},
/**
* Updates the current selection based on new value, ensuring compatibility with multiselect.
* This method processes the new selection value, accommodating both single and multiple selections,
* and updates the internal `selection` state with the corresponding option objects from `options`.
*
* @param {String|Array} value - The new selection value(s), can be a string or an array of strings.
*/
// Check if the component is in multiple selection mode and the new value is provided.
updateSelection(value) {
if (this.multiple && value) {
// Normalize the value to an array format, accommodating both single and comma-separated values.
const normalizedValues = this.normalizeValues(value);
// Map each value to its corresponding option object based on the 'value' field.
this.selection = normalizedValues.map(value =>
this.options.find(option => option.value === value)
);
} else {
// For single selection mode or null value, find the option object that matches the value.
this.selection = this.options.find(option => option.value === value);
}
}
}
};
</script>
<style>
#filters-container .multiple .multiselect__option--selected:not(:hover) {
background-color: #e8e8e8 !important;
}
#filters-container .multiselect--disabled .multiselect__select {
background: 0, 0 !important;
}
</style>
\ No newline at end of file