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 2573 additions and 690 deletions
<template>
<div class="geolocation-container">
<button
:class="['button-geolocation', { tracking }]"
title="Me localiser"
@click.prevent="toggleTracking"
>
<i class="icon" />
</button>
</div>
</template>
<script>
import mapService from '@/services/map-service';
export default {
name: 'Geolocation',
data() {
return {
tracking: false,
};
},
methods: {
toggleTracking() {
this.tracking = !this.tracking;
mapService.toggleGeolocation(this.tracking);
},
}
};
</script>
\ No newline at end of file
<template>
<div class="basemaps-items ui accordion styled">
<div
:class="['basemap-item title', { active }]"
@click="$emit('activateGroup', basemap.id)"
>
<i
class="map outline fitted icon"
aria-hidden="true"
/>
<span>{{ basemap.title }}</span>
</div>
<div
v-if="queryableLayersOptions.length > 0 && active"
:id="`queryable-layers-selector-${basemap.id}`"
>
<strong>Couche requêtable</strong>
<Dropdown
:options="queryableLayersOptions"
:selected="selectedQueryLayer"
:search="true"
@update:selection="setQueryLayer($event)"
/>
</div>
<div
:id="`list-${basemap.id}`"
:class="['content', { active }]"
:data-basemap-index="basemap.id"
>
<div
v-for="layer in basemap.layers"
:key="basemap.id + '-' + layer.id + Math.random()"
class="layer-item transition visible item list-group-item"
:data-id="layer.id"
>
<!-- layer id is used for retrieving layer when changing order -->
<p class="layer-handle-sort">
<i
class="th icon"
aria-hidden="true"
/>
{{ layer.title }}
</p>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
type="range"
min="0"
max="1"
:value="layer.opacity"
step="0.01"
@change="updateOpacity($event, layer)"
><output class="range-output-bubble">{{
getOpacity(layer.opacity)
}}</output>
</div>
<div class="ui divider" />
</div>
</div>
</div>
</template>
<script>
import Sortable from 'sortablejs';
import Dropdown from '@/components/Dropdown.vue';
import mapService from '@/services/map-service';
export default {
name: 'LayerSelector',
components: {
Dropdown,
},
props: {
basemap: {
type: Object,
default: null,
},
active: {
type: Boolean,
default: false,
},
selectedQueryLayer: {
type: String,
default: ''
}
},
data() {
return {
sortable: null,
};
},
computed: {
queryableLayersOptions() {
const queryableLayers = this.basemap.layers.filter((l) => l.queryable === true);
return queryableLayers.map((x) => {
return {
name: x.title,
value: x,
};
});
},
},
mounted() {
setTimeout(this.initSortable.bind(this), 1000);
},
methods: {
isQueryable(baseMap) {
const queryableLayer = baseMap.layers.filter((l) => l.queryable === true);
return queryableLayer.length > 0;
},
initSortable() {
const element = document.getElementById(`list-${this.basemap.id}`);
if (element) {
this.sortable = new Sortable(element, {
animation: 150,
handle: '.layer-handle-sort', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: () => this.$emit('onlayerMove'),
});
} else {
console.error(`list-${this.basemap.id} not found in dom`);
}
},
setQueryLayer(layer) {
this.$emit('onQueryLayerChange', layer.name);
},
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
updateOpacity(event, layer) {
const layerId = layer.id;
const opacity = event.target.value;
mapService.updateOpacity(layerId, opacity);
this.$emit('onOpacityUpdate', { layerId, opacity });
},
}
};
</script>
<style>
.basemap-item.title > i {
margin-left: -1em !important;
}
.basemap-item.title > span {
margin-left: .5em;
}
.queryable-layers-dropdown {
margin-bottom: 1em;
}
.queryable-layers-dropdown > label {
font-weight: bold;
}
</style>
......@@ -3,10 +3,9 @@
v-if="isOnline"
:class="['sidebar-container', { expanded }]"
>
<!-- <div class="sidebar-layers"></div> -->
<div
class="layers-icon"
@click="expanded = !expanded"
@click="toggleSidebar()"
>
<!-- // ! svg point d'interrogation pas accepté par linter -->
<!-- <?xml version="1.0" encoding="iso-8859-1"?> -->
......@@ -45,92 +44,42 @@
<div class="basemaps-title">
<h4>
Fonds cartographiques
<!-- <span data-tooltip="Il est possible pour chaque fond cartographique de modifier l'ordre des couches"
data-position="bottom left">
<i class="question circle outline icon"></em>
</span> -->
</h4>
</div>
<div
<LayerSelector
v-for="basemap in baseMaps"
:key="`list-${basemap.id}`"
class="basemaps-items ui accordion styled"
>
<div
:class="{ active: isActive(basemap) }"
class="basemap-item title"
@click="activateGroup(basemap)"
>
{{ basemap.title }}
</div>
<div
v-if="isQueryable(basemap)"
:id="`queryable-layers-selector-${basemap.id}`"
>
<b>Couche requêtable</b>
<Dropdown
:options="getQueryableLayers(basemap)"
:selected="selectedQueryLayer"
:search="true"
@update:selection="onQueryLayerChange($event)"
/>
</div>
<div
:id="`list-${basemap.id}`"
:class="{ active: isActive(basemap) }"
class="content"
:data-basemap-index="basemap.id"
>
<div
v-for="(layer, index) in basemap.layers"
:key="basemap.id + '-' + layer.id + '-' + index"
class="layer-item transition visible item list-group-item"
:data-id="layer.id"
>
<p class="layer-handle-sort">
<i class="th icon" />{{ layer.title }}
</p>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
type="range"
min="0"
max="1"
:value="layer.opacity"
step="0.01"
@change="updateOpacity($event, layer)"
><output class="range-output-bubble">{{
getOpacity(layer.opacity)
}}</output>
</div>
<div class="ui divider" />
</div>
</div>
</div>
:basemap="basemap"
:selected-query-layer="selectedQueryLayer"
:active="basemap.active"
@addLayers="addLayers"
@activateGroup="activateGroup"
@onlayerMove="onlayerMove"
@onOpacityUpdate="onOpacityUpdate"
@onQueryLayerChange="onQueryLayerChange"
/>
</div>
</template>
<script>
import { mapState } from 'vuex';
import Sortable from 'sortablejs';
import Dropdown from '@/components/Dropdown.vue';
import { mapUtil } from '@/assets/js/map-util.js';
import LayerSelector from '@/components/Map/LayerSelector.vue';
import mapService from '@/services/map-service';
export default {
name: 'SidebarLayers',
components: {
Dropdown,
LayerSelector
},
data() {
return {
selectedQueryLayer: null,
activeBasemap: null,
baseMaps: [],
expanded: false,
projectSlug: this.$route.params.slug,
selectedQueryLayer: '',
};
},
......@@ -139,191 +88,143 @@ export default {
'isOnline',
]),
...mapState('map', [
'availableLayers'
'availableLayers',
'basemaps'
]),
activeBasemap() {
return this.baseMaps.find((baseMap) => baseMap.active);
},
activeBasemapIndex() {
return this.baseMaps.findIndex((el) => el.id === this.activeBasemap.id);
},
activeQueryableLayers() {
return this.baseMaps[this.activeBasemapIndex].layers.filter((layer) => layer.queryable);
},
},
mounted() {
this.baseMaps = this.$store.state.map.basemaps;
const project = this.$route.params.slug;
const mapOptions =
JSON.parse(localStorage.getItem('geocontrib-map-options')) || {};
if (mapOptions && mapOptions[project]) {
// If already in the storage, we need to check if the admin did some
// modification in the basemaps on the server side. The rule is: if one layer has been added
// or deleted in the server, then we reset the localstorage.
const baseMapsFromLocalstorage = mapOptions[project]['basemaps'];
const areChanges = this.areChangesInBasemaps(
this.baseMaps,
baseMapsFromLocalstorage
);
if (areChanges) {
mapOptions[project] = {
'map-options': this.baseMaps,
'current-basemap-index': 0,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
} else {
this.baseMaps = baseMapsFromLocalstorage;
}
}
if (this.baseMaps.length > 0) {
this.baseMaps[0].active = true;
this.activeBasemap = this.baseMaps[0];
this.addLayers(this.baseMaps[0]);
} else {
mapUtil.addLayers(
null,
this.$store.state.configuration.DEFAULT_BASE_MAP_SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS,
this.$store.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE
);
}
setTimeout(this.initSortable.bind(this), 1000);
this.initBasemaps();
},
methods: {
isActive(basemap) {
return basemap.active !== undefined && basemap.active;
},
activateGroup(basemap) {
this.baseMaps.forEach((basemap) => (basemap.active = false));
basemap.active = true;
this.activeBasemap = basemap;
basemap.title += ' '; //weird!! Force refresh
this.addLayers(basemap);
/**
* Initializes the basemaps and handles their state.
* This function checks if the basemaps stored in the local storage match those fetched from the server.
* If changes are detected, it resets the local storage with updated basemaps.
* It also sets the first basemap as active by default and adds the corresponding layers to the map.
*/
initBasemaps() {
// Clone object to not modify data in store, using JSON parse instead of spread operator
// that modifies data, for instance when navigating to basemap administration page
this.baseMaps = JSON.parse(JSON.stringify(this.basemaps));
let mapOptions = localStorage.getItem('geocontrib-map-options') || {};
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
const project = this.$route.params.slug;
mapOptions[project] = {
...mapOptions[project],
'current-basemap-index': basemap.id,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
},
// Retrieve map options from local storage
const mapOptions = JSON.parse(localStorage.getItem('geocontrib-map-options')) || {};
updateOpacity(event, layer) {
mapUtil.updateOpacity(layer.id, event.target.value);
layer.opacity = event.target.value;
},
// Check if map options exist for the current project
if (mapOptions && mapOptions[this.projectSlug]) {
// If already in the storage, we need to check if the admin did some
// modification in the basemaps on the server side.
// The rule is: if one layer has been added or deleted on the server,
// then we reset the local storage.
const baseMapsFromLocalstorage = mapOptions[this.projectSlug]['basemaps'];
const areChanges = this.areChangesInBasemaps(
this.baseMaps,
baseMapsFromLocalstorage
);
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
// If changes are detected, update local storage with the latest basemaps
if (areChanges) {
mapOptions[this.projectSlug] = {
basemaps: this.baseMaps,
'current-basemap-index': 0,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
} else if (baseMapsFromLocalstorage) {
// If no changes, use the basemaps from local storage
this.baseMaps = baseMapsFromLocalstorage;
}
}
onQueryLayerChange(layer) {
this.selectedQueryLayer = layer.name;
},
if (this.baseMaps.length > 0) {
// Set the first basemap as active by default
this.baseMaps[0].active = true;
isQueryable(baseMap) {
const queryableLayer = baseMap.layers.filter((l) => l.queryable === true);
return queryableLayer.length > 0;
},
// Check if an active layer has been set previously by the user
const activeBasemapId = mapOptions[this.projectSlug]
? mapOptions[this.projectSlug]['current-basemap-index']
: null;
onlayerMove() {
// Get the names of the current layers in order.
const currentLayersNamesInOrder = Array.from(
document.getElementsByClassName('layer-item transition visible')
).map((el) => el.children[0].innerText);
// Create an array to put the layers in order.
let movedLayers = [];
// Ensure the active layer ID exists in the current basemaps in case id does not exist anymore or has changed
if (activeBasemapId >= 0 && this.baseMaps.some(bm => bm.id === activeBasemapId)) {
this.baseMaps.forEach((baseMap) => {
// Set the active layer by matching the ID and setting the active property to true
if (baseMap.id === mapOptions[this.projectSlug]['current-basemap-index']) {
baseMap.active = true;
} else {
// Reset others to false to prevent errors from incorrect mapOptions
baseMap.active = false;
}
});
}
for (const layerName of currentLayersNamesInOrder) {
movedLayers.push(
this.activeBasemap.layers.filter((el) => el.title === layerName)[0]
// Add layers for the active basemap
this.addLayers(this.activeBasemap);
this.setSelectedQueryLayer();
} else {
// If no basemaps are available, add the default base map layers from the configuration
mapService.addLayers(
null,
this.$store.state.configuration.DEFAULT_BASE_MAP_SERVICE,
this.$store.state.configuration.DEFAULT_BASE_MAP_OPTIONS,
this.$store.state.configuration.DEFAULT_BASE_MAP_SCHEMA_TYPE
);
}
// Remove existing layers undefined
movedLayers = movedLayers.filter(function (x) {
return x !== undefined;
});
const eventOrder = new CustomEvent('change-layers-order', {
detail: {
layers: movedLayers,
},
});
document.dispatchEvent(eventOrder);
// Save the basemaps options into the localstorage
this.setLocalstorageMapOptions(this.baseMaps);
},
setLocalstorageMapOptions(basemaps) {
let mapOptions = localStorage.getItem('geocontrib-map-options') || {};
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
const 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) => {
const element=document.getElementById(`list-${basemap.id}`);
if(element) {
new Sortable(element, {
animation: 150,
handle: '.layer-handle-sort', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: this.onlayerMove.bind(this),
});
}
else{
console.error(`list-${basemap.id} not found in dom`);
}
});
},
// Check if there are changes in the basemaps settings. Changes are detected if:
// - one basemap has been added or deleted
// - one layer has been added or deleted to a basemap
areChangesInBasemaps(basemapFromServer, basemapFromLocalstorage = {}) {
areChangesInBasemaps(basemapFromServer, basemapFromLocalstorage) {
// prevent undefined later even if in this case the function is not called anyway
if (!basemapFromLocalstorage) return false;
let isSameBasemaps = false;
let isSameLayers = true;
let isSameTitles = true;
// Compare the length and the id values of the basemaps
const idBasemapsServer = basemapFromServer.map((b) => b.id).sort();
const idBasemapsLocalstorage = basemapFromLocalstorage.length
? basemapFromLocalstorage.map((b) => b.id).sort()
: {};
const idBasemapsLocalstorage = basemapFromLocalstorage.map((b) => b.id).sort() || {};
isSameBasemaps =
idBasemapsServer.length === idBasemapsLocalstorage.length &&
idBasemapsServer.every(
(value, index) => value === idBasemapsLocalstorage[index]
);
// For each basemap, compare the length and id values of the layers
// if basemaps changed, return that changed occured to avoid more processing
if (!isSameBasemaps) return true;
outer_block: {
for (const basemapServer of basemapFromServer) {
const idLayersServer = basemapServer.layers.map((b) => b.id).sort();
if (basemapFromLocalstorage.length) {
for (const basemapLocalstorage of basemapFromLocalstorage) {
if (basemapServer.id === basemapLocalstorage.id) {
const idLayersLocalstorage = basemapLocalstorage.layers
.map((b) => b.id)
.sort();
isSameLayers =
idLayersServer.length === idLayersLocalstorage.length &&
idLayersServer.every(
(value, index) => value === idLayersLocalstorage[index]
);
if (!isSameLayers) {
break outer_block;
}
// For each basemap from the server, compare the length and id values of the layers
for (const serverBasemap of basemapFromServer) {
// loop over basemaps from localStorage and check if layers id & queryable setting match with the layers from the server
// we don't check opacity since it would detect a change and reinit each time the user set it. It would need to be stored separatly like current basemap index
for (const localBasemap of basemapFromLocalstorage) {
if (serverBasemap.id === localBasemap.id) {
isSameLayers =
serverBasemap.layers.length === localBasemap.layers.length &&
serverBasemap.layers.every(
(layer, index) => layer.id === localBasemap.layers[index].id &&
layer.queryable === localBasemap.layers[index].queryable
);
if (!isSameLayers) {
break outer_block;
}
}
}
......@@ -332,9 +233,7 @@ export default {
const titlesBasemapsServer = basemapFromServer
.map((b) => b.title)
.sort();
const titlesBasemapsLocalstorage = basemapFromLocalstorage.length
? basemapFromLocalstorage.map((b) => b.title).sort()
: {};
const titlesBasemapsLocalstorage = basemapFromLocalstorage.map((b) => b.title).sort() || {};
isSameTitles = titlesBasemapsServer.every(
(title, index) => title === titlesBasemapsLocalstorage[index]
......@@ -347,37 +246,107 @@ export default {
return !(isSameBasemaps && isSameLayers && isSameTitles);
},
getQueryableLayers(baseMap) {
const queryableLayer = baseMap.layers.filter((l) => l.queryable === true);
return queryableLayer.map((x) => {
return {
name: x.title,
value: x,
};
});
},
addLayers(baseMap) {
baseMap.layers.forEach((layer) => {
var layerOptions = this.availableLayers.find((l) => l.id === layer.id);
const layerOptions = this.availableLayers.find((l) => l.id === layer.id);
layer = Object.assign(layer, layerOptions);
layer.options.basemapId = baseMap.id;
});
mapUtil.removeLayers(mapUtil.getMap());
mapService.removeLayers();
// Reverse is done because the first layer in order has to be added in the map in last.
// Slice is done because reverse() changes the original array, so we make a copy first
mapUtil.addLayers(baseMap.layers.slice().reverse(), null, null, null,);
mapService.addLayers(baseMap.layers.slice().reverse(), null, null, null,);
},
activateGroup(basemapId) {
//* to activate a basemap, we need to set the active property to true and set the others to false
this.baseMaps = this.baseMaps.map((bm) => {
return { ...bm, active: bm.id === basemapId ? true : false };
});
this.setSelectedQueryLayer();
//* add the basemap that was clicked to the map
this.addLayers(this.baseMaps.find((bm) => bm.id === basemapId));
//* to persist the settings, we set the localStorage mapOptions per project
this.setLocalstorageMapOptions({ 'current-basemap-index': basemapId });
},
onlayerMove() {
// Get ids of the draggable layers in its current order.
const currentLayersIdInOrder = Array.from(
document.querySelectorAll('.content.active .layer-item.transition.visible')
).map((el) => parseInt(el.attributes['data-id'].value));
// Create an array to put the original layers in the same order.
let movedLayers = [];
for (const layerId of currentLayersIdInOrder) {
movedLayers.push(
this.activeBasemap.layers.find((el) => el.id === layerId)
);
}
// Remove existing layers undefined
movedLayers = movedLayers.filter(function (x) {
return x !== undefined;
});
const eventOrder = new CustomEvent('change-layers-order', {
detail: {
layers: movedLayers,
},
});
document.dispatchEvent(eventOrder);
// report layers order change in the basemaps object before saving them
this.baseMaps[this.activeBasemapIndex].layers = movedLayers;
// Save the basemaps options into the localstorage
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
onOpacityUpdate(data) {
const { layerId, opacity } = data;
// retrieve layer to update opacity
this.baseMaps[this.activeBasemapIndex].layers.find((layer) => layer.id === layerId).opacity = opacity;
// Save the basemaps options into the localstorage
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
activateQueryLayer(layerTitle) {
const baseMapLayer = this.baseMaps[this.activeBasemapIndex].layers.find((l) => l.title === layerTitle);
if (baseMapLayer) {
// remove any query property in all layers and set query property at true for selected layer
this.baseMaps[this.activeBasemapIndex].layers.forEach((l) => delete l.query);
baseMapLayer['query'] = true;
// update selected query layer
this.setSelectedQueryLayer();
} else {
console.error('No such param \'query\' found among basemap[0].layers');
}
},
onQueryLayerChange(layerTitle) {
this.activateQueryLayer(layerTitle);
this.setLocalstorageMapOptions({ basemaps: this.baseMaps });
},
// retrieve selected query layer in active basemap (to be called when mounting and when changing active basemap)
setSelectedQueryLayer() {
const currentQueryLayer = this.baseMaps[this.activeBasemapIndex].layers.find((l) => l.query === true);
if (currentQueryLayer) {
this.selectedQueryLayer = currentQueryLayer.title;
} else if (this.activeQueryableLayers[0]) { // if no current query layer previously selected by user
this.activateQueryLayer(this.activeQueryableLayers[0].title); // then activate the first available query layer of the active basemap
}
},
setLocalstorageMapOptions(newOptionObj) {
let mapOptions = localStorage.getItem('geocontrib-map-options') || {};
mapOptions = mapOptions.length ? JSON.parse(mapOptions) : {};
mapOptions[this.projectSlug] = {
...mapOptions[this.projectSlug],
...newOptionObj,
};
localStorage.setItem(
'geocontrib-map-options',
JSON.stringify(mapOptions)
);
},
},
};
</script>
<style>
@import "../assets/styles/sidebar-layers.css";
.queryable-layers-dropdown {
margin-bottom: 1em;
}
.queryable-layers-dropdown > label {
font-weight: bold;
}
</style>
<template>
<li
:ref="'message-' + message.counter"
:class="['list-container', { show }]"
>
<div :class="['list-item', { show}]">
<div :class="['ui', message.level ? message.level : 'info', 'message']">
<i
class="close icon"
aria-hidden="true"
@click="removeListItem"
/>
<div class="header">
<i
:class="[headerIcon, 'circle icon']"
aria-hidden="true"
/>
{{ message.header || 'Informations' }}
</div>
<ul class="list">
{{
message.comment
}}
</ul>
</div>
</div>
</li>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
name: 'MessageInfo',
props: {
message: {
type: Object,
default: () => {},
},
},
data() {
return {
listMessages: [],
show: false,
};
},
computed: {
...mapState(['messages']),
headerIcon() {
switch (this.message.level) {
case 'positive':
return 'check';
case 'negative':
return 'times';
default:
return 'info';
}
}
},
mounted() {
setTimeout(() => {
this.show = true;
}, 15);
},
methods: {
...mapMutations(['DISCARD_MESSAGE']),
removeListItem(){
const container = this.$refs['message-' + this.message.counter];
container.ontransitionend = () => {
this.DISCARD_MESSAGE(this.message.counter);
};
this.show = false;
},
},
};
</script>
<style scoped>
.list-container{
list-style: none;
width: 100%;
height: 0;
position: relative;
cursor: pointer;
overflow: hidden;
transition: all 0.6s ease-out;
}
.list-container.show{
height: 7.5em;
}
@media screen and (min-width: 726px) {
.list-container.show{
height: 6em;
}
}
.list-container.show:not(:first-child){
margin-top: 10px;
}
.list-container .list-item{
padding: .5rem 0;
width: 100%;
position: absolute;
opacity: 0;
top: 0;
left: 0;
transition: all 0.6s ease-out;
}
.list-container .list-item.show{
opacity: 1;
}
ul.list{
overflow: auto;
height: 3.5em;
margin-bottom: .5em !important;
}
@media screen and (min-width: 726px) {
ul.list{
height: 2.2em;
}
}
.ui.message {
overflow: hidden;
padding-bottom: 0 !important;
}
.ui.message::after {
content: "";
position: absolute;
bottom: 0;
left: 1em;
right: 0;
width: calc(100% - 2em);
}
.ui.info.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.positive.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.negative.message::after {
box-shadow: 0px -8px 5px 3px rgb(248, 255, 255);
}
.ui.message > .close.icon {
cursor: pointer;
position: absolute;
margin: 0em;
top: 0.78575em;
right: 0.5em;
opacity: 0.7;
-webkit-transition: opacity 0.1s ease;
transition: opacity 0.1s ease;
}
</style>
\ No newline at end of file
<template>
<div
v-if="messages && messages.length > 0"
class="row over-content"
>
<div class="fourteen wide column">
<ul
class="message-list"
aria-live="assertive"
>
<MessageInfo
v-for="message in messages"
:key="'message-' + message.counter"
:message="message"
/>
</ul>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import MessageInfo from '@/components/MessageInfo';
export default {
name: 'MessageInfoList',
components: {
MessageInfo,
},
computed: {
...mapState(['messages']),
},
};
</script>
<style scoped>
.row.over-content {
position: absolute; /* to display message info over page content */
z-index: 99;
opacity: 0.95;
width: calc(100% - 4em); /* 4em is #content left + right paddings */
top: calc(40px + 1em); /* 40px is #app-header height */
right: 2em; /* 2em is #content left paddings */
}
.message-list{
list-style: none;
padding-left: 0;
margin-top: 0;
}
@media screen and (max-width: 725px) {
.row.over-content {
top: calc(80px + 1em); /* 90px is #app-header height in mobile display */
width: calc(100% - 2em);
right: 1em;
}
}
</style>
\ No newline at end of file
......@@ -4,14 +4,16 @@
<ul class="custom-pagination">
<li
class="page-item"
:class="{ disabled: page === 1 }"
:class="{ disabled: currentPage === 1 }"
>
<a
class="page-link"
:href="currentLocation"
@click="page -= 1"
class="page-link pointer"
@click="changePage(currentPage - 1)"
>
<i class="ui icon big angle left" />
<i
class="ui icon big angle left"
aria-hidden="true"
/>
</a>
</li>
<div
......@@ -19,14 +21,13 @@
style="display: contents;"
>
<li
v-for="index in pagination(page, nbPages)"
v-for="index in pagination(currentPage, nbPages)"
:key="index"
class="page-item"
:class="{ active: page === index }"
:class="{ active: currentPage === index }"
>
<a
class="page-link"
:href="currentLocation"
class="page-link pointer"
@click="changePage(index)"
>
{{ index }}
......@@ -41,12 +42,11 @@
v-for="index in nbPages"
:key="index"
class="page-item"
:class="{ active: page === index }"
:class="{ active: currentPage === index }"
>
<a
class="page-link"
:href="currentLocation"
@click="page = index"
class="page-link pointer"
@click="changePage(index)"
>
{{ index }}
</a>
......@@ -54,14 +54,16 @@
</div>
<li
class="page-item"
:class="{ disabled: page === nbPages }"
:class="{ disabled: currentPage === nbPages }"
>
<a
class="page-link"
:href="currentLocation"
@click="page += 1"
class="page-link pointer"
@click="changePage(currentPage + 1)"
>
<i class="ui icon big angle right" />
<i
class="ui icon big angle right"
aria-hidden="true"
/>
</a>
</li>
</ul>
......@@ -70,6 +72,8 @@
</template>
<script>
import { mapState, mapMutations } from 'vuex';
export default {
name: 'Pagination',
......@@ -78,32 +82,18 @@ export default {
type: Number,
default: 1
},
onPageChange: {
type: Function,
default: () => {
return () => 1;
}
}
},
data() {
return {
page: 1,
currentLocation: `${window.location.origin}${window.location.pathname}#`,
};
},
watch: {
page: function(newValue, oldValue) {
if (newValue !== oldValue) {
this.onPageChange(newValue);
this.$emit('change-page', newValue);
}
}
computed: {
...mapState('projects', ['currentPage']),
},
methods: {
...mapMutations('projects', [
'SET_CURRENT_PAGE',
'SET_PROJECTS_FILTER'
]),
pagination(c, m) {
const current = c,
last = m,
......@@ -134,9 +124,13 @@ export default {
return rangeWithDots;
},
changePage(num) {
if (typeof num === 'number') {
this.page = num;
changePage(pageNumber) {
if (typeof pageNumber === 'number') {
this.SET_CURRENT_PAGE(pageNumber);
// Scroll back to the first results on top of page
window.scrollTo({ top: 0, behavior: 'smooth' });
// emit event for parent component to fetch new page data
this.$emit('page-update', pageNumber);
}
}
}
......
<template>
<div class="ui segment">
<div class="ui segment secondary">
<div class="field required">
<label for="basemap-title">Titre</label>
<input
......@@ -23,7 +23,7 @@
<div class="nested">
<div
:id="`list-${basemap.id}`"
:class="[basemap.layers.length > 0 ? 'ui segments': '', 'layers-container']"
:class="[basemap.layers.length > 0 ? 'ui segments': '', 'layers-container', 'raised']"
>
<ProjectMappingContextLayer
v-for="layer in basemap.layers"
......@@ -32,42 +32,37 @@
:basemapid="basemap.id"
/>
</div>
<div class="ui buttons">
<a
class="ui compact small icon left floated button green"
<div class="ui bottom two attached buttons">
<button
class="ui icon button basic positive"
type="button"
@click="addLayer"
>
<i class="ui plus icon" />
<span>Ajouter une couche</span>
</a>
</div>
<div
class="ui buttons"
@click="deleteBasemap"
>
<a
class="
ui
compact
red
small
icon
right
floated
button button-hover-green
"
<i
class="ui plus icon"
aria-hidden="true"
/>
<span>&nbsp;Ajouter une couche</span>
</button>
<button
class=" ui icon button basic negative"
type="button"
@click="deleteBasemap"
>
<i class="ui trash alternate icon" />
<span>Supprimer ce fond cartographique</span>
</a>
<i
class="ui trash alternate icon"
aria-hidden="true"
/>
<span>&nbsp;Supprimer ce fond cartographique</span>
</button>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import { mapMutations } from 'vuex';
import Sortable from 'sortablejs';
import ProjectMappingContextLayer from '@/components/Project/Basemaps/ProjectMappingContextLayer.vue';
......@@ -88,16 +83,11 @@ export default {
data() {
return {
sortableElement: null
sortable: null
};
},
computed: {
...mapState('map', [
'UPDATE_BASEMAP',
'DELETE_BASEMAP',
'REPLACE_BASEMAP_LAYERS'
]),
maxLayersCount: function () {
return this.basemap.layers.reduce((acc, curr) => {
if (curr.dataKey > acc) {
......@@ -121,6 +111,11 @@ export default {
},
methods: {
...mapMutations('map', [
'UPDATE_BASEMAP',
'DELETE_BASEMAP',
'REPLACE_BASEMAP_LAYERS'
]),
deleteBasemap() {
this.DELETE_BASEMAP(this.basemap.id);
},
......@@ -173,7 +168,7 @@ export default {
onlayerMove() {
//* Get the names of the current layers in order.
const currentLayersNamesInOrder = Array.from(
document.getElementsByClassName('layer-item')
document.getElementsByClassName(`basemap-${this.basemap.id}`)
).map((el) => el.id);
//* increment value 'order' in this.basemap.layers looping over layers from template ^
......@@ -190,7 +185,7 @@ export default {
}
}
//* update the store
this.$store.commit('map/UPDATE_BASEMAP', {
this.UPDATE_BASEMAP({
layers: movedLayers,
id: this.basemap.id,
title: this.basemap.title,
......@@ -199,7 +194,7 @@ export default {
},
initSortable() {
this.sortableElement = 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',
......@@ -210,9 +205,3 @@ export default {
},
};
</script>
<style scoped>
.button {
margin-right: 0.5em !important;
}
</style>
<template>
<div
:id="layer.dataKey"
class="ui segment layer-item"
:class="`ui segment layer-item basemap-${basemapid}`"
>
<div class="ui divided form">
<div
......@@ -9,10 +9,14 @@
data-type="layer-field"
>
<label
for="form.layer.id_for_label"
:for="layer.title"
class="layer-handle-sort"
>
<i class="th icon" />couche
<i
class="th icon"
aria-hidden="true"
/>
couche
</label>
<Dropdown
:options="availableLayerOptions"
......@@ -22,45 +26,43 @@
:placeholder="placeholder"
/>
</div>
<div class="fields">
<div class="six wide field">
<label for="opacity">Opacité</label>
<input
v-model.number="layerOpacity"
type="number"
oninput="validity.valid||(value='');"
step="0.01"
min="0"
max="1"
>
</div>
<div class="field">
<div
class="field three wide {% if form.opacity.errors %} error{% endif %}"
class="ui checkbox"
@click="updateLayer({ ...layer, queryable: !layer.queryable })"
>
<label for="opacity">Opacité</label>
<input
v-model.number="layerOpacity"
type="number"
oninput="validity.valid||(value='');"
step="0.01"
min="0"
max="1"
:checked="layer.queryable"
class="hidden"
type="checkbox"
name="queryable"
>
</div>
<div class="field three wide">
<div
class="ui checkbox"
@click="updateLayer({ ...layer, queryable: !layer.queryable })"
>
<input
:checked="layer.queryable"
class="hidden"
type="checkbox"
name="queryable"
>
<label for="queryable"> Requêtable</label>
</div>
<label for="queryable">&nbsp;Requêtable</label>
</div>
</div>
<div
class="field"
<button
type="button"
class="ui compact small icon floated button button-hover-red"
@click="removeLayer"
>
<div class="ui compact small icon floated button button-hover-red">
<i class="ui grey trash alternate icon" />
<span>Supprimer cette couche</span>
</div>
</div>
<i
class="ui grey trash alternate icon"
aria-hidden="true"
/>
<span>&nbsp;Supprimer cette couche</span>
</button>
</div>
</div>
</template>
......
......@@ -3,7 +3,10 @@
<h3 class="ui header">
Types de signalements
</h3>
<div class="ui middle aligned divided list">
<div
id="feature_type-list"
class="ui middle aligned divided list"
>
<div
:class="{ active: loading }"
class="ui inverted dimmer"
......@@ -22,167 +25,111 @@
</div>
<div
v-for="(type, index) in feature_types"
:id="type.title"
:key="type.title + '-' + index"
class="item"
>
<div class="feature-type-container">
<router-link
:to="{
name: 'details-type-signalement',
params: { feature_type_slug: type.slug },
}"
class="feature-type-title"
>
<img
v-if="type.geom_type === 'point'"
class="list-image-type"
src="@/assets/img/marker.png"
>
<img
v-if="type.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
>
<img
v-if="type.geom_type === 'polygon'"
class="list-image-type"
src="@/assets/img/polygon.png"
>
{{ type.title }}
</router-link>
<FeatureTypeLink :feature-type="type" />
<div class="middle aligned content">
<router-link
v-if="
project && permissions && permissions.can_create_feature
"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-green
"
data-tooltip="Ajouter un signalement"
data-position="top right"
data-variation="mini"
>
<i class="ui plus icon" />
</router-link>
<router-link
v-if="
project &&
permissions &&
permissions.can_create_feature_type &&
isOffline() !== true
"
:to="{
name: 'dupliquer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-green
"
data-tooltip="Dupliquer un type de signalement"
data-position="top right"
data-variation="mini"
>
<i class="inverted grey copy alternate icon" />
</router-link>
<div
v-if="isImporting(type)"
class="import-message"
>
<i class="info circle icon" />
<i
class="info circle icon"
aria-hidden="true"
/>
Import en cours
</div>
<div
v-else
>
<a
v-if="isProjectAdmin && isOffline() !== true"
class="
ui
compact
small
icon
right
floated
button button-hover-red
"
data-tooltip="Supprimer le type de signalement"
data-position="top center"
data-variation="mini"
@click="toggleDeleteFeatureType(type)"
>
<i class="inverted grey trash alternate icon" />
</a>
<template v-else>
<router-link
v-if="
project &&
permissions &&
permissions.can_create_feature_type &&
isOffline() !== true
"
v-if="project && type.is_editable && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'editer-symbologie-signalement',
name: 'editer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-orange
"
data-tooltip="Éditer la symbologie du type de signalement"
class="ui compact small icon button button-hover-orange tiny-margin"
data-tooltip="Éditer le type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`edit-feature-type-for-${type.title}`"
>
<i class="inverted grey paint brush alternate icon" />
<i
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<router-link
v-if="
project &&
type.is_editable &&
permissions &&
permissions.can_create_feature_type &&
isOffline() !== true
"
v-if="project && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'editer-type-signalement',
name: 'editer-affichage-signalement',
params: { slug_type_signal: type.slug },
}"
class="
ui
compact
small
icon
right
floated
button button-hover-orange
"
data-tooltip="Éditer le type de signalement"
class="ui compact small icon button button-hover-orange tiny-margin"
data-tooltip="Éditer l'affichage du type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`edit-feature-type-display-for-${type.title}`"
>
<i class="inverted grey pencil alternate icon" />
<i
class="inverted grey paint brush alternate icon"
aria-hidden="true"
/>
</router-link>
</div>
<a
v-if="isProjectAdmin && isOnline"
class="ui compact small icon button button-hover-red tiny-margin"
data-tooltip="Supprimer le type de signalement"
data-position="top center"
data-variation="mini"
:data-test="`delete-feature-type-for-${type.title}`"
@click="toggleDeleteFeatureType(type)"
>
<i
class="inverted grey trash alternate icon"
aria-hidden="true"
/>
</a>
</template>
<router-link
v-if="project && permissions && permissions.can_create_feature_type && isOnline"
:to="{
name: 'dupliquer-type-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-green tiny-margin"
data-tooltip="Dupliquer un type de signalement"
data-position="top right"
data-variation="mini"
:data-test="`duplicate-feature-type-for-${type.title}`"
>
<i
class="inverted grey copy alternate icon"
aria-hidden="true"
/>
</router-link>
<router-link
v-if="project && permissions && permissions.can_create_feature && !type.geom_type.includes('multi')"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: type.slug },
}"
class="ui compact small icon button button-hover-green tiny-margin"
data-tooltip="Ajouter un signalement"
data-position="top right"
data-variation="mini"
:data-test="`add-feature-for-${type.title}`"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
</router-link>
</div>
</div>
</div>
......@@ -191,82 +138,102 @@
</div>
</div>
<div id="nouveau-type-signalement">
<div id="new-feature-type-container">
<div
class="ui small button circular compact floated right icon teal help"
data-tooltip="Consulter la documentation"
data-position="bottom right"
data-variation="mini"
data-test="read-doc"
>
<i
class="question icon"
@click="goToDocumentation"
/>
</div>
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
v-if="permissions && permissions.can_update_project && isOnline"
:to="{
name: 'ajouter-type-signalement',
params: { slug },
}"
class="ui compact basic button"
data-test="add-feature-type"
>
<i class="ui plus icon" />Créer un nouveau type de signalement
<i
class="ui plus icon"
aria-hidden="true"
/>
<label class="ui pointer">
Créer un nouveau type de signalement
</label>
</router-link>
</div>
<div class="nouveau-type-signalement">
<div
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
class="
ui
compact
basic
button
button-align-left
"
v-if="permissions && permissions.can_update_project && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-geojson"
>
<i class="ui plus icon" />
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui"
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"
>
<span
class="label"
>Créer un nouveau type de signalement à partir d'un
GeoJSON</span>
Créer un nouveau type de signalement à partir d'un JSON (non-géographique)
</label>
<input
id="json_file"
type="file"
accept="application/json, .json, .geojson"
accept="application/json, .json"
style="display: none"
name="json_file"
@change="onGeoJSONFileChange"
>
</div>
</div>
<div class="nouveau-type-signalement">
<div
v-if="
permissions &&
permissions.can_update_project &&
isOnline
"
class="
ui
compact
basic
button
button-align-left
"
v-if="permissions && permissions.can_update_project && isOnline"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-csv"
>
<i class="ui plus icon" />
<i
class="ui plus icon"
aria-hidden="true"
/>
<label
class="ui"
class="ui pointer"
for="csv_file"
>
<span
class="label"
>Créer un nouveau type de signalement à partir d'un
CSV</span>
Créer un nouveau type de signalement à partir d'un CSV
</label>
<input
id="csv_file"
......@@ -277,16 +244,9 @@
@change="onCSVFileChange"
>
</div>
</div>
<div class="nouveau-type-signalement">
<router-link
v-if="
IDGO &&
permissions &&
permissions.can_update_project &&
isOffline() !== true
"
v-if="IDGO && permissions && permissions.can_update_project && isOnline"
:to="{
name: 'catalog-import',
params: {
......@@ -295,9 +255,13 @@
},
}"
class="ui compact basic button button-align-left"
data-test="add-feature-type-from-catalog"
>
<i class="ui plus icon" />
Créer un nouveau type de signalement à partir du catalogue {{ CATALOG_NAME|| 'IDGO' }}
<i
class="ui plus icon"
aria-hidden="true"
/>
Créer un nouveau type de signalement à partir du catalogue {{ CATALOG_NAME || 'IDGO' }}
</router-link>
</div>
......@@ -308,9 +272,13 @@
<button
:disabled="geojsonFileToImport.size === 0"
class="ui fluid teal icon button"
data-test="start-geojson-file-import"
@click="toNewGeojsonFeatureType"
>
<i class="upload icon" /> Lancer l'import avec le fichier
<i
class="upload icon"
aria-hidden="true"
/> Lancer l'import avec le fichier
{{ geojsonFileToImport.name }}
</button>
</div>
......@@ -322,9 +290,13 @@
<button
:disabled="csvFileToImport.size === 0"
class="ui fluid teal icon button"
data-test="start-csv-file-import"
@click="toNewCsvFeatureType"
>
<i class="upload icon" /> Lancer l'import avec le fichier
<i
class="upload icon"
aria-hidden="true"
/> Lancer l'import avec le fichier
{{ csvFileToImport.name }}
</button>
</div>
......@@ -334,7 +306,8 @@
>
<i
class="close icon"
@click="csvError = null"
aria-hidden="true"
@click="csvError = null; csvFileToImport = { name: '', size: 0 }"
/>
{{ csvError }}
</div>
......@@ -342,7 +315,7 @@
<!-- MODALE FILESIZE -->
<div
:class="isFileSizeModalOpen ? 'active' : ''"
class="ui dimmer"
class="ui dimmer inverted"
>
<div
:class="isFileSizeModalOpen ? 'active' : ''"
......@@ -355,7 +328,7 @@
<div class="content">
<p>
Impossible de créer un type de signalement à partir d'un fichier
GeoJSON de plus de 10Mo (celui importé fait {{ geojsonFileSize > 0 ? geojsonFileSize : csvFileSize }} Mo).
de plus de 100Mo (celui importé fait {{ geojsonFileSize > 0 ? geojsonFileSize : csvFileSize }} Mo).
</p>
</div>
<div class="actions">
......@@ -372,15 +345,21 @@
</template>
<script>
import { csv } from 'csvtojson';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import { fileConvertSizeToMo, csvToJson } from '@/assets/js/utils';
import { fileConvertSizeToMo, determineDelimiter } from '@/assets/js/utils';
import FeatureTypeLink from '@/components/FeatureType/FeatureTypeLink';
export default {
name: 'ProjectFeatureTypes',
components: {
FeatureTypeLink
},
props: {
loading: {
type: Boolean,
......@@ -390,7 +369,7 @@ export default {
type: Object,
default: () => {
return {};
}
},
}
},
......@@ -404,14 +383,16 @@ export default {
csvError: null,
geojsonFileToImport: { name: '', size: 0 },
csvFileToImport: { name: '', size: 0 },
fetchCallCounter: 0,
hadPending: false
};
},
computed: {
...mapState([
'configuration',
'isOnline'
'isOnline',
'user_permissions',
]),
...mapState('feature-type', [
'feature_types',
......@@ -437,46 +418,10 @@ export default {
csvFileSize() {
return fileConvertSizeToMo(this.csvFileToImport.size);
},
},
watch: {
feature_types: {
deep: true,
handler(newValue, oldValue) {
if (newValue && newValue !== oldValue) {
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
});
}
},
},
importFeatureTypeData: {
deep: true,
handler(newValue) {
if (
newValue &&
newValue.some((el) => el.status === 'pending') &&
!this.reloadIntervalId
) {
this.SET_RELOAD_INTERVAL_ID(
setInterval(() => {
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
});
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL)
);
} else if (
newValue &&
!newValue.some((el) => el.status === 'pending') &&
this.reloadIntervalId
) {
this.GET_PROJECT_FEATURE_TYPES(this.slug);
this.CLEAR_RELOAD_INTERVAL_ID();
}
},
},
mounted() {
this.fetchImports();
},
methods: {
......@@ -485,8 +430,31 @@ export default {
]),
...mapActions('feature-type', [
'GET_IMPORTS',
'GET_PROJECT_FEATURE_TYPES'
]),
fetchImports() {
this.fetchCallCounter += 1; // register each time function is programmed to be called in order to avoid redundant calls
this.GET_IMPORTS({
project_slug: this.$route.params.slug,
})
.then((response) => {
if (response.data && response.data.some(el => el.status === 'pending')) {
this.hadPending = true; // store pending import to know if project need to be updated, after mounted
// if there is still some pending imports re-fetch imports by calling this function again
setTimeout(() => {
if (this.fetchCallCounter <= 1 ) {
// if the function wasn't called more than once in the reload interval, then call it again
this.fetchImports();
}
this.fetchCallCounter -= 1; // decrease function counter
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL);
} else if (this.hadPending) {
// if no more pending import, get last features
this.$emit('update');
}
});
},
isImporting(type) {
if (this.importFeatureTypeData) {
const singleImportData = this.importFeatureTypeData.find(
......@@ -497,19 +465,31 @@ export default {
return false;
},
isOffline() {
return navigator.onLine === false;
goToDocumentation() {
window.open(this.configuration.VUE_APP_URL_DOCUMENTATION);
},
toNewGeojsonFeatureType() {
this.importing = true;
this.$router.push({
name: 'ajouter-type-signalement',
params: {
geojson: this.geojsonImport,
fileToImport: this.geojsonFileToImport,
},
});
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;
},
......@@ -528,19 +508,19 @@ export default {
onGeoJSONFileChange(e) {
this.importing = true;
var files = e.target.files || e.dataTransfer.files;
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)) > 10) {
if (parseFloat(fileConvertSizeToMo(this.geojsonFileToImport.size)) > 100) {
this.isFileSizeModalOpen = true;
} else if (this.geojsonFileToImport.size > 0) {
const fr = new FileReader();
try {
fr.onload = (e) => {
this.geojsonImport = JSON.parse(e.target.result);
fr.onload = (ev) => {
this.geojsonImport = JSON.parse(ev.target.result);
this.importing = false;
};
fr.readAsText(this.geojsonFileToImport);
......@@ -557,50 +537,34 @@ export default {
onCSVFileChange(e) {
this.featureTypeImporting = true;
var files = e.target.files || e.dataTransfer.files;
const files = e.target.files || e.dataTransfer.files;
if (!files.length) {
return;
}
this.csvFileToImport = files[0];
if (parseFloat(fileConvertSizeToMo(this.csvFileToImport.size)) > 10) {
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 = () => {
// Check if file contains 'lat' and 'long' fields
const headersLine =
fr.result
.split('\n')[0]
.split(',')
.filter(el => {
return el === 'lat' || el === 'lon';
});
// Look for 2 decimal fields in first line of csv
// corresponding to lon and lat
const sampleLine =
fr.result
.split('\n')[1]
.split(',')
.map(el => {
return !isNaN(el) && el.indexOf('.') != -1;
})
.filter(Boolean);
if (sampleLine.length > 1 && headersLine.length === 2) {
this.csvError = null;
this.csvImport = csvToJson(fr.result);
this.featureTypeImporting = false;
//* stock filename to import features afterward
this.SET_FILE_TO_IMPORT(this.csvFileToImport);
} else {
// File doesn't seem to contain coords
this.csvError = `Le fichier ${this.csvFileToImport.name} ne semble pas contenir de coordonnées`;
// 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);
......@@ -619,8 +583,7 @@ export default {
},
toggleDeleteFeatureType(featureType) {
this.featureTypeToDelete = featureType;
this.$emit('modal', 'deleteFeatureType');
this.$emit('delete', featureType);
},
}
......@@ -628,43 +591,41 @@ export default {
</script>
<style>
/* // ! missing style in semantic.min.css, je ne comprends pas comment... */
/* // ! missing style in semantic.min.css */
.ui.right.floated.button {
float: right;
}
</style>
<style lang="less" scoped>
.list-image-type {
margin-right: 5px;
height: 25px;
vertical-align: bottom;
}
.feature-type-container {
display: flex;
justify-content: space-between;
align-items: center;
.feature-type-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.5em;
}
& > .middle.aligned.content {
display: flex;
}
}
.feature-type-container > .middle.aligned.content {
width: 50%;
}
.feature-type-title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.5em;
}
.nouveau-type-signalement {
margin-top: 1em;
}
.nouveau-type-signalement .label{
cursor: pointer;
#new-feature-type-container {
& > div {
margin: 1em 0;
}
& > div.help {
margin-top: 0;
}
.button:not(.help) {
line-height: 1.25em;
.icon {
height: auto;
}
}
}
#button-import {
......@@ -679,9 +640,13 @@ export default {
}
.import-message {
width: fit-content;
line-height: 2em;
color: teal;
color: var(--primary-highlight-color, #008c86);
white-space: nowrap;
padding: .25em;
display: flex;
align-items: center;
& > i {
height: auto;
}
}
</style>
<template>
<div class="project-header ui grid">
<div class="project-header ui grid stackable">
<div class="row">
<div class="three wide middle aligned column">
<img
class="ui small spaced image"
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
>
<div class="ui hidden divider" />
<div
class="ui basic teal label"
data-tooltip="Membres"
>
<i class="user icon" />{{ project.nb_contributors }}
</div>
<div
class="ui basic teal label"
data-tooltip="Signalements publiés"
>
<i class="map marker icon" />{{ project.nb_published_features }}
<div class="margin-bottom">
<img
class="ui small centered image"
alt="Thumbnail du projet"
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
>
</div>
<div
class="ui basic teal label"
data-tooltip="Commentaires"
>
<i class="comment icon" />{{
project.nb_published_features_comments
}}
<div class="centered">
<div
class="ui basic teal label tiny-margin"
data-tooltip="Membres"
>
<i
class="user icon"
aria-hidden="true"
/>{{ project.nb_contributors }}
</div>
<div
class="ui basic teal label tiny-margin"
data-tooltip="Signalements publiés"
>
<i
class="map marker icon"
aria-hidden="true"
/>{{ project.nb_published_features }}
</div>
<div
class="ui basic teal label tiny-margin"
data-tooltip="Commentaires"
>
<i
class="comment icon"
aria-hidden="true"
/>{{
project.nb_published_features_comments
}}
</div>
</div>
</div>
<div class="nine wide column">
<h1 class="ui header">
<h1 class="ui header margin-bottom">
{{ project.title }}
</h1>
<div class="ui hidden divider" />
<div class="sub header">
{{ project.description }}
<!-- {{ project.description }} -->
<div id="preview" />
<textarea
id="editor"
v-model="project.description"
data-preview="#preview"
hidden
/>
</div>
</div>
......@@ -50,75 +69,117 @@
user &&
permissions &&
permissions.can_view_project &&
isOffline() !== true
isOnline
"
id="subscribe-button"
class="ui button button-hover-green"
data-tooltip="S'abonner au projet"
data-position="top center"
class="ui button button-hover-green tiny-margin"
data-tooltip="Gérer mon abonnement au projet"
data-position="bottom center"
data-variation="mini"
@click="OPEN_PROJECT_MODAL('subscribe')"
>
<i class="inverted grey envelope icon" />
<i
class="inverted grey envelope icon"
aria-hidden="true"
/>
</a>
<router-link
v-if="
permissions &&
permissions.can_update_project &&
isOffline() !== true
isOnline
"
id="edit-project"
:to="{ name: 'project_edit', params: { slug } }"
class="ui button button-hover-orange"
class="ui button button-hover-orange tiny-margin"
data-tooltip="Modifier le projet"
data-position="top center"
data-position="bottom center"
data-variation="mini"
>
<i class="inverted grey pencil alternate icon" />
<i
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<a
v-if="isProjectAdmin && isOffline() !== true"
v-if="isProjectAdmin && isOnline"
id="delete-button"
class="ui button button-hover-red"
class="ui button button-hover-red tiny-margin"
data-tooltip="Supprimer le projet"
data-position="top center"
data-position="bottom right"
data-variation="mini"
@click="OPEN_PROJECT_MODAL('deleteProject')"
>
<i class="inverted grey trash icon" />
<i
class="inverted grey trash icon"
aria-hidden="true"
/>
</a>
<div
v-if="isProjectAdmin && !isSharedProject"
id="share-button"
class="ui dropdown button compact tiny-margin"
data-tooltip="Partager le projet"
data-position="bottom right"
data-variation="mini"
@click="toggleShareOptions"
>
<i
class="inverted grey share icon"
aria-hidden="true"
/>
<div
:class="['menu left transition', {'visible': showShareOptions}]"
style="z-index: 9999"
>
<div
v-if="project.generate_share_link"
class="item"
@click="copyLink"
>
Copier le lien de partage
</div>
<div
class="item"
@click="copyCode"
>
Copier le code du Web Component
</div>
</div>
</div>
</div>
<button
v-if="isProjectAdmin && !isSharedProject && project.generate_share_link"
class="ui teal left labeled icon button share-button"
@click="copyLink"
>
<i class="left icon share square" />
Copier le lien de partage
</button>
<div v-if="confirmMsg">
<div class="ui positive tiny-margin message">
<Transition>
<div
v-if="confirmMsg"
class="ui positive tiny-margin message"
>
<span>
Le lien a été copié dans le presse-papier
</span>
&nbsp;
<i
class="close icon"
@click="confirmMsg = ''"
aria-hidden="true"
@click="confirmMsg = false"
/>
</div>
</div>
</Transition>
</div>
<div v-if="arraysOffline.length > 0">
{{ arraysOffline.length }} modification<span v-if="arraysOffline.length>1">s</span> en attente
<div
v-if="arraysOffline.length > 0"
class="centered"
>
{{ arraysOffline.length }} modification<span v-if="arraysOffline.length > 1">s</span> en attente
<button
:disabled="isOffline"
:disabled="!isOnline"
class="ui fluid labeled teal icon button"
@click="sendOfflineFeatures"
>
<i class="upload icon" />
<i
class="upload icon"
aria-hidden="true"
/>
Envoyer au serveur
</button>
</div>
......@@ -127,6 +188,7 @@
</template>
<script>
import TextareaMarkdown from 'textarea-markdown';
import { mapState, mapGetters, mapMutations } from 'vuex';
......@@ -149,6 +211,7 @@ export default {
return {
slug: this.$route.params.slug,
confirmMsg: false,
showShareOptions: false,
};
},
......@@ -162,7 +225,8 @@ export default {
]),
...mapState([
'user',
'user_permissions'
'user_permissions',
'isOnline',
]),
...mapGetters([
'permissions'
......@@ -181,10 +245,17 @@ export default {
},
mounted() {
let textarea = document.querySelector('textarea');
new TextareaMarkdown(textarea);
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
},
methods: {
...mapState([
'isOnline'
]),
...mapMutations('modals', [
'OPEN_PROJECT_MODAL'
]),
......@@ -195,19 +266,50 @@ export default {
return '?ver=' + crypto.getRandomValues(array); // Compliant for security-sensitive use cases
},
isOffline() {
return navigator.onLine === false;
toggleShareOptions() {
this.confirmMsg = false;
this.showShareOptions = !this.showShareOptions;
},
clickOutsideDropdown(e) {
// If the user click outside of the dropdown, close it
if (!e.target.closest('#share-button')) {
this.showShareOptions = false;
}
},
copyLink() {
const sharedLink = window.location.href.replace('projet', 'projet-partage');
navigator.clipboard.writeText(sharedLink).then(()=> {
console.log('success');
this.confirmMsg = true;
}, () => {
console.log('failed');
}
);
setTimeout(() => {
this.confirmMsg = false;
}, 15000);
}, (e) => console.error('Failed to copy link: ', e));
},
copyCode() {
// Including <script> directly within template literals cause the JavaScript parser to raise syntax errors.
// The only working workaround, but ugly, is to split and concatenate the <script> tag.
const webComponent = `
<!-- Pour modifier la police, ajoutez l'attribut "font" avec le nom de la police souhaitée (par exemple: font="'Roboto Condensed', Lato, 'Helvetica Neue'"). -->
<!-- Dans le cas où la police souhaitée ne serait pas déjà disponible dans la page affichant le web component, incluez également une balise <style> pour l'importer. -->
<style>@import url('https://fonts.googleapis.com/css?family=Roboto Condensed:400,700,400italic,700italic&subset=latin');</style>
<scr` + `ipt src="${this.configuration.VUE_APP_DJANGO_BASE}/geocontrib/static/wc/project-preview.js"></scr` + `ipt>
<project-preview
domain="${this.configuration.VUE_APP_DJANGO_BASE}"
project-slug="${this.project.slug}"
color="${this.configuration.VUE_APP_PRIMARY_COLOR}"
font="${this.configuration.VUE_APP_FONT_FAMILY}"
width=""
></project-preview>`;
navigator.clipboard.writeText(webComponent).then(()=> {
this.confirmMsg = true;
setTimeout(() => {
this.confirmMsg = false;
}, 15000);
}, (e) => console.error('Failed to copy link: ', e));
},
sendOfflineFeatures() {
......@@ -230,11 +332,12 @@ export default {
this.arraysOfflineErrors.push(feature);
})
);
this.DISPLAY_LOADER('Envoi des signalements en cours.');
this.$store.commit('DISPLAY_LOADER', 'Envoi des signalements en cours.');
Promise.all(promises).then(() => {
this.$emit('update-local-storage');
this.$emit('retrieve-info');
this.$emit('updateLocalStorage');
this.$emit('retrieveInfo');
this.$store.commit('DISCARD_LOADER');
});
},
......@@ -246,22 +349,62 @@ export default {
<style lang="less" scoped>
.project-header {
.row {
.right-column {
display: flex;
flex-direction: column;
.ui.button, .ui.button .button, .tiny-margin {
margin: 0.1rem 0 0.1rem 0.1rem !important;
.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;
}
.share-button {
margin: 1em 0 0 0;
.ui.dropdown > .left.menu {
display: block;
overflow: hidden;
opacity: 0;
max-height: 0;
&.transition {
transition: all .5s ease;
}
&.visible {
opacity: 1;
max-height: 6em;
}
.menu {
margin-right: 0 !important;
.item {
white-space: nowrap;
}
}
}
.v-enter-active,
.v-leave-active {
transition: opacity .5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
}
#preview {
max-height: 10em;
overflow-y: scroll;
}
@media screen and (max-width: 767px) {
.middle.aligned.column {
text-align: center;
}
}
</style>
......@@ -20,13 +20,13 @@
class="item"
>
<div class="content">
<div>
<router-link
:to="getRouteUrl(item.related_feature.feature_url)"
>
"{{ item.comment }}"
</router-link>
</div>
<FeatureFetchOffsetRoute
:feature-id="item.related_feature.feature_id"
:properties="{
title: item.comment,
feature_type: { slug: item.related_feature.feature_type_slug }
}"
/>
<div class="description">
<em>[ {{ item.created_on
}}<span
......@@ -49,10 +49,14 @@
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectsLastComments',
components: {
FeatureFetchOffsetRoute,
},
props: {
loading: {
......@@ -65,10 +69,9 @@ export default {
...mapState([
'user'
]),
...mapState([
...mapState('projects', [
'last_comments',
]),
}
},
};
</script>
......@@ -15,25 +15,16 @@
</div>
<div class="ui relaxed list">
<div
v-for="(item, index) in features.slice(-5)"
v-for="(item, index) in features.slice(0,5)"
:key="item.properties.title + index"
class="item"
>
<div class="content">
<div>
<router-link
:to="{
name: 'details-signalement',
params: {
slug,
slug_type_signal:
item.properties.feature_type.slug,
slug_signal: item.id,
},
}"
>
{{ item.properties.title || item.id }}
</router-link>
<FeatureFetchOffsetRoute
:feature-id="item.id"
:properties="item.properties"
/>
</div>
<div class="description">
<em>
......@@ -63,32 +54,52 @@
<script>
import { mapState } from 'vuex';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
export default {
name: 'ProjectLastFeatures',
props: {
loading: {
type: Boolean,
default: false
}
components: {
FeatureFetchOffsetRoute,
},
data() {
return {
slug: this.$route.params.slug,
loading: true,
};
},
computed: {
...mapState([
'user'
]),
...mapState('feature', [
'features'
]),
}
...mapState([
'user'
]),
},
mounted() {
this.fetchLastFeatures();
},
methods: {
fetchLastFeatures() {
this.loading = true;
this.$store.dispatch('feature/GET_PROJECT_FEATURES', {
project_slug: this.$route.params.slug,
ordering: '-created_on',
limit: 5,
})
.then(() => {
this.loading = false;
})
.catch((err) => {
console.error(err);
this.loading = false;
});
}
}
};
</script>
......@@ -6,16 +6,20 @@
>
<div
:class="[
'ui mini modal subscription',
'ui mini modal',
{ 'transition visible active': projectModalType },
]"
>
<i
class="close icon"
aria-hidden="true"
@click="CLOSE_PROJECT_MODAL"
/>
<div class="ui icon header">
<i :class="[projectModalType === 'subscribe' ? 'envelope' : 'trash', 'icon']" />
<i
:class="[projectModalType === 'subscribe' ? 'envelope' : 'trash', 'icon']"
aria-hidden="true"
/>
{{
projectModalType === 'subscribe' ? 'Notifications' : 'Suppression'
}} du {{
......@@ -36,6 +40,7 @@
</p>
</div>
<button
id="validate-modal"
:class="['ui compact fluid button', projectModalType === 'subscribe' && !isSubscriber ? 'green' : 'red']"
@click="handleModalAction"
>
......@@ -47,7 +52,7 @@
}}
</span>
<span v-else>
Supprimer le
Supprimer le
{{
projectModalType === 'deleteProject'
? 'projet'
......@@ -71,6 +76,12 @@ export default {
isSubscriber: {
type: Boolean,
default: false
},
featureTypeToDelete: {
type: Object,
default: () => {
return {};
}
}
},
......@@ -87,20 +98,18 @@ export default {
]),
handleModalAction() {
this.$emit('action');
this.$emit('action', this.projectModalType);
}
}
};
</script>
<style lang="less" scoped>
<style scoped>
.alert {
color: red;
}
.centered-text {
text-align: center;
}
</style>
......@@ -3,37 +3,14 @@
<h3 class="ui header">
Paramètres du projet
</h3>
<div class="ui five stackable cards">
<div class="card">
<div class="center aligned content">
<h4 class="ui center aligned icon header">
<i class="disabled grey archive icon" />
<div class="content">
Délai avant archivage automatique
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.archive_feature }} jours
</div>
</div>
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey trash alternate icon" />
<div class="content">
Délai avant suppression automatique
</div>
</h4>
</div>
<div class="center aligned extra content">
{{ project.delete_feature }} jours
</div>
</div>
<div class="ui three stackable cards">
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey eye icon" />
<i
class="disabled grey eye icon"
aria-hidden="true"
/>
<div class="content">
Visibilité des signalements publiés
</div>
......@@ -46,7 +23,10 @@
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey eye icon" />
<i
class="disabled grey eye icon"
aria-hidden="true"
/>
<div class="content">
Visibilité des signalements archivés
</div>
......@@ -59,7 +39,10 @@
<div class="card">
<div class="content">
<h4 class="ui center aligned icon header">
<i class="disabled grey cogs icon" />
<i
class="disabled grey cogs icon"
aria-hidden="true"
/>
<div class="content">
Modération
</div>
......
<template>
<div class="field">
<label for="attribute-value">
{{ attribute.label }}
</label>
<div>
<ExtraForm
:id="`attribute-value-for-${attribute.name}`"
ref="extraForm"
name="attribute-value"
:field="{ ...attribute, value }"
:use-value-only="true"
@update:value="updateValue($event.toString(), attribute.id)"
/>
</div>
</div>
</template>
<script>
import ExtraForm from '@/components/ExtraForm';
export default {
name: 'ProjectAttributeForm',
components: {
ExtraForm,
},
props: {
attribute: {
type: Object,
default: () => {
return {};
}
},
formProjectAttributes: {
type: Array,
default: () => {
return [];
}
}
},
computed: {
/**
* Retrieves the current value of a specific project attribute.
* This computed property checks the array of project attributes to find the one that matches
* the current attribute's ID. If the attribute is found, its value is returned.
* Otherwise, null is returned to indicate that the attribute is not set for the current project.
*
* @returns {String|null} The value of the attribute if it exists in the project's attributes; otherwise, null.
*/
value() {
// Searches for the attribute within the array of attributes associated with the project.
const projectAttribute = this.formProjectAttributes.find(el => el.attribute_id === this.attribute.id);
// Returns the value of the attribute if it exists, or null if the attribute is not found.
return projectAttribute ? projectAttribute.value : null;
},
},
created() {
// Checks if the component is being used in the context of creating a new project and attribute's default value is set
if (this.$route.name === 'project_create' && this.attribute.default_value !== null) {
// If so, initializes the attribute's value with its default value as defined in the attribute's settings.
this.updateValue(this.attribute.default_value, this.attribute.id);
}
},
methods: {
/**
* Updates or adds a value for a specific attribute in the project.
* This method emits an event to update the project's attributes with a new value for a given attribute ID.
* It is typically called when the user changes the value of an attribute in the UI.
*
* @param {String} value - The new value for the attribute.
* @param {Number} attributeId - The unique ID of the attribute being updated or added to the project.
*/
updateValue(value, attributeId) {
// Emits an event to the parent component, requesting an update to the project's attributes.
this.$emit('update:project_attributes', { value, attributeId });
}
}
};
</script>
<template>
<div>
<div class="table-mobile-buttons left-align">
<FeatureListMassToggle />
<div class="ui form">
<div
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
class="inline fields"
>
<label
data-tooltip="Choisir un type de sélection de signalements pour effectuer une action"
data-position="bottom left"
>Mode de sélection :</label>
<div class="field">
<div class="ui radio checkbox">
<input
id="edit-status"
v-model="mode"
type="radio"
name="mode"
value="edit-status"
>
<label for="edit-status">Édition de statut</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input
id="edit-attributes"
v-model="mode"
type="radio"
name="mode"
value="edit-attributes"
>
<label for="edit-attributes">Édition d'attribut</label>
</div>
</div>
<div class="field">
<div class="ui radio checkbox">
<input
id="delete-features"
v-model="mode"
type="radio"
name="mode"
value="delete-features"
>
<label for="delete-features">Suppression de signalement</label>
</div>
</div>
</div>
</div>
<div
data-tab="list"
class="dataTables_wrapper no-footer"
......@@ -10,14 +55,35 @@
<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"
>
<FeatureListMassToggle />
<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
......@@ -25,7 +91,7 @@
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('status')"
>
Statut
......@@ -35,6 +101,7 @@
up: isSortedDesc('status'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
......@@ -43,7 +110,7 @@
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('feature_type')"
>
Type
......@@ -53,6 +120,7 @@
up: isSortedDesc('feature_type'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
......@@ -61,7 +129,7 @@
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('title')"
>
Nom
......@@ -71,6 +139,26 @@
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>
......@@ -79,7 +167,7 @@
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('updated_on')"
>
Dernière modification
......@@ -89,6 +177,7 @@
up: isSortedDesc('updated_on'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
......@@ -98,7 +187,7 @@
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('display_creator')"
>
Auteur
......@@ -108,6 +197,7 @@
up: isSortedDesc('display_creator'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
......@@ -117,7 +207,7 @@
class="dt-center"
>
<div
class="pointer"
:class="isOnline ? 'pointer' : 'disabled'"
@click="changeSort('display_last_editor')"
>
Dernier éditeur
......@@ -127,6 +217,7 @@
up: isSortedDesc('display_last_editor'),
}"
class="icon sort"
aria-hidden="true"
/>
</div>
</th>
......@@ -137,50 +228,72 @@
v-for="(feature, index) in paginatedFeatures"
:key="index"
>
<td class="dt-center">
<td
v-if="(permissions.can_update_feature || permissions.can_delete_feature) && isOnline"
id="select"
class="dt-center"
>
<div
:class="['ui checkbox', {disabled: !checkRights(feature)}]"
:class="['ui checkbox', { disabled: isAllSelected || !checkRights(feature) }]"
>
<input
:id="feature.feature_id"
v-model="checked"
type="checkbox"
:value="feature.feature_id"
:disabled="!checkRights(feature)"
:checked="isFeatureSelected(feature)"
:disabled="isAllSelected || !checkRights(feature)"
name="select"
@input="storeClickedFeature(feature)"
@input="handleFeatureSelection($event, feature)"
>
<label for="select" />
<label :for="feature.feature_id" />
</div>
</td>
<td class="dt-center">
<td
id="status"
class="dt-center"
>
<div
v-if="feature.status === 'archived'"
data-tooltip="Archivé"
>
<i class="grey archive icon" />
<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" />
<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" />
<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" />
<i
class="orange pencil alternate icon"
aria-hidden="true"
/>
</div>
</td>
<td class="dt-center">
<td
id="type"
class="dt-center"
>
<router-link
:to="{
name: 'details-type-signalement',
......@@ -188,34 +301,47 @@
feature_type_slug: feature.feature_type.slug,
},
}"
class="ellipsis space-left"
>
{{ feature.feature_type.title }}
</router-link>
</td>
<td class="dt-center">
<td
id="name"
class="dt-center"
>
<router-link
:to="{
name: 'details-signalement',
params: {
slug_type_signal: feature.feature_type.slug,
slug_signal: feature.slug || feature.feature_id,
},
name: 'details-signalement-filtre',
query: { ...queryparams, offset: queryparams.offset + index }
}"
class="ellipsis space-left"
>
{{ feature.title || feature.feature_id }}
</router-link>
</td>
<td class="dt-center">
<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 || ' ---- ' }}
......@@ -247,7 +373,7 @@
sur {{ featuresCount }} éléments
</div>
<div
v-if="pageNumbers.length > 1"
v-if="pageNumbers.length > 1 && isOnline"
id="table-features_paginate"
class="dataTables_paginate paging_simple_numbers"
>
......@@ -263,7 +389,7 @@
@click="$emit('update:page', 'previous')"
>Précédent</a>
<span>
<span v-if="pagination.currentPage >= 5">
<span v-if="pagination.currentPage > 5">
<a
key="page1"
class="paginate_button"
......@@ -271,7 +397,7 @@
data-dt-idx="1"
tabindex="0"
@click="$emit('update:page', 1)"
>{{ 1 }}</a>
>1</a>
<span class="ellipsis"></span>
</span>
<a
......@@ -286,7 +412,7 @@
tabindex="0"
@click="$emit('update:page', pageNumber)"
>{{ pageNumber }}</a>
<span v-if="(lastPageNumber - pagination.currentPage) >= 4">
<span v-if="(lastPageNumber - pagination.currentPage) > 4">
<span class="ellipsis"></span>
<a
:key="'page' + lastPageNumber"
......@@ -316,7 +442,6 @@
<script>
import { mapState, mapGetters, mapMutations } from 'vuex';
import FeatureListMassToggle from '@/components/Feature/FeatureListMassToggle';
import { formatStringDate } from '@/utils';
export default {
......@@ -328,10 +453,13 @@ export default {
},
},
components: {
FeatureListMassToggle,
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,
......@@ -341,6 +469,10 @@ export default {
type: Array,
default: null,
},
allSelected: {
type: Boolean,
default: false,
},
checkedFeatures: {
type: Array,
default: null,
......@@ -357,24 +489,53 @@ export default {
type: Object,
default: null,
},
queryparams: {
type: Object,
default: null,
},
editAttributesFeatureType: {
type: String,
default: null,
},
},
computed: {
...mapGetters(['permissions']),
...mapState(['user', 'USER_LEVEL_PROJECTS']),
...mapState([
'user',
'USER_LEVEL_PROJECTS',
'isOnline'
]),
...mapState('projects', ['project']),
...mapState('feature', ['clickedFeatures', 'massMode']),
mode: {
get() {
return this.massMode;
},
set(newMode) {
this.TOGGLE_MASS_MODE(newMode);
// Reset all selections
this.isAllSelected = false;
this.UPDATE_CLICKED_FEATURES([]);
this.UPDATE_CHECKED_FEATURES([]);
},
},
userStatus() {
return this.USER_LEVEL_PROJECTS[this.$route.params.slug];
return this.USER_LEVEL_PROJECTS ? this.USER_LEVEL_PROJECTS[this.$route.params.slug] : '';
},
checkedFeaturesSet() {
return new Set(this.checkedFeatures); // Set améliore la performance sur la recherche
},
checked: {
isAllSelected: {
get() {
return this.checkedFeatures;
return this.allSelected;
},
set(newChecked) {
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', newChecked);
set(isChecked) {
this.$emit('update:allSelected', isChecked);
},
},
......@@ -409,31 +570,90 @@ export default {
},
},
destroyed() {
this.UPDATE_CHECKED_FEATURES([]);
},
methods: {
...mapMutations('feature', [
'UPDATE_CLICKED_FEATURES',
'UPDATE_CHECKED_FEATURES',
'TOGGLE_MASS_MODE',
]),
storeClickedFeature(feature) {
/**
* Vérifie si une feature doit être cochée en fonction de la sélection globale et des droits d'édition.
* @param {Object} feature - L'objet représentant la feature.
* @returns {Boolean} - `true` si la feature doit être cochée.
*/
isFeatureSelected(feature) {
if (this.isAllSelected) {
return this.checkRights(feature); // Si tout doit être sélectionné, on vérifie les droits
}
return this.checkedFeaturesSet.has(feature.feature_id);
},
/**
* Ajoute ou supprime une feature de la sélection en fonction de l'état de la checkbox.
* Met également à jour les features cliquées et les restrictions d'édition d'attributs.
* @param {Event} event - L'événement de changement de l'input checkbox.
* @param {Object} feature - La feature associée.
*/
handleFeatureSelection(event, feature) {
const isChecked = event.target.checked;
const updatedFeatures = isChecked
? [...this.checkedFeatures, feature.feature_id]
: this.checkedFeatures.filter(id => id !== feature.feature_id);
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', updatedFeatures);
this.trackClickedFeature(feature);
this.manageAttributeEdition(feature, updatedFeatures.length);
},
/**
* Gère les restrictions sur la modification des attributs en fonction de la sélection en masse.
* @param {Object} feature - La feature actuellement sélectionnée.
* @param {Number} checkedCount - Nombre total de features actuellement cochées.
*/
manageAttributeEdition(feature, checkedCount) {
if (this.massMode !== 'edit-attributes') return;
if (checkedCount === 1) {
// Premier élément sélectionné, on stocke son type pour restreindre la sélection
this.$emit('update:editAttributesFeatureType', feature.feature_type.slug);
} else if (checkedCount === 0) {
// Dernière feature désélectionnée -> on réinitialise la restriction
this.$emit('update:editAttributesFeatureType', null);
}
},
/**
* Ajoute une feature cliquée à la liste pour conserver son historique de sélection.
* Permet de gérer la sélection de plusieurs features sur différentes pages sans surcharger la mémoire.
* @param {Object} feature - La feature cliquée.
*/
trackClickedFeature(feature) {
this.UPDATE_CLICKED_FEATURES([
...this.clickedFeatures,
{ feature_id: feature.feature_id, feature_type: feature.feature_type.slug }
]);
},
/**
* Vérifie si l'utilisateur a le droit de supprimer une feature.
* @param {Object} feature - La feature à vérifier.
* @returns {Boolean} - `true` si l'utilisateur peut supprimer la feature.
*/
canDeleteFeature(feature) {
if (this.userStatus === 'Administrateur projet') {
return true; //* can delete all
if (this.userStatus === 'Administrateur projet' || this.user.is_superuser) {
return true; // Un administrateur ou super utilisateur peut tout supprimer
}
//* others can delete only their own features
return feature.display_creator === this.user.username;
// 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'],
......@@ -442,7 +662,13 @@ export default {
Contributeur : ['draft', 'pending', 'published'],
};
if (this.userStatus === 'Contributeur' && feature.display_creator !== this.user.username) {
if (this.checkedFeatures.length > 0 && // check if selection should be restricted to a specific feature type, for attributes modification
feature.feature_type.slug !== this.editAttributesFeatureType &&
this.massMode === 'edit-attributes') {
return false;
} else if (this.user.is_superuser) {
return true;
} else if (this.userStatus === 'Contributeur' && feature.creator !== this.user.id) {
return false;
} else if (permissions[this.userStatus]) {
return permissions[this.userStatus].includes(feature.status);
......@@ -452,18 +678,11 @@ export default {
},
checkRights(feature) {
switch (this.massMode) {
case 'modify':
if (this.massMode.includes('edit')) {
return this.canEditFeature(feature);
case 'delete':
} else if (this.massMode === 'delete-features') {
return this.canDeleteFeature(feature);
}
},
switchMode() {
this.$emit('update:mode', this.mode === 'modify' ? 'delete' : 'modify');
this.UPDATE_CLICKED_FEATURES([]);
this.$store.commit('feature/UPDATE_CHECKED_FEATURES', []);
}
},
isSortedAsc(column) {
......@@ -474,6 +693,7 @@ export default {
},
changeSort(column) {
if (!this.isOnline) return;
if (this.sort.column === column) {
//changer only order
this.$emit('update:sort', {
......@@ -494,6 +714,9 @@ export default {
position: relative;
clear: both;
}
.dataTables_paginate {
margin-bottom: 1rem;
}
table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable td.dataTables_empty {
text-align: center;
}
......@@ -589,12 +812,25 @@ i.icon.sort:not(.down):not(.up) {
.table-mobile-buttons {
margin-bottom: 1em;
}
/* increase contrast between available checkboxes and disabled ones */
#table-features .ui.disabled.checkbox label::before {
background-color: #fbf5f5;;
}
#select-all + label {
text-align: left;
&:hover {
cursor: pointer;
}
}
@media only screen and (min-width: 761px) {
.table-mobile-buttons {
display: none !important;
}
}
/*
/*
Max width before this PARTICULAR table gets nasty
This query will take effect for any screen smaller than 760px
and also iPads specifically.
......@@ -603,9 +839,13 @@ and also iPads specifically.
.table-mobile-buttons {
display: flex !important;
}
.inline.fields label {
width: 100% !important;
}
/* hide table border */
.ui.table {
border: none !important;
margin-top: 2em;
}
/* Force table to not be like tables anymore */
table,
......@@ -628,12 +868,12 @@ and also iPads specifically.
border: 1px solid #ccc;
border-radius: 7px;
margin-bottom: 3vh;
padding: 0 1vw .5em 1vw;
padding: 0 2vw .5em 2vw;
box-shadow: rgba(50, 50, 50, 0.1) 2px 5px 10px ;
}
td {
/* Behave like a "row" */
/* Behave like a "row" */
border: none;
border-bottom: 1px solid #eee;
position: relative;
......@@ -649,52 +889,81 @@ and also iPads specifically.
border: none !important;
padding: .25em !important;
}
td:nth-of-type(7) {
td:nth-of-type(8) {
border-bottom: none !important;
}
td:before {
/* Now like a table header */
position: absolute;
/* Top/left values mimic padding */
/* top: 6px; */
left: 6px;
/* width: 45%; */
padding-right: 10px;
white-space: nowrap;
}
/*
Label the data
*/
td:nth-of-type(1):before {
td#select:before {
content: "";
}
td:nth-of-type(2):before {
td#status:before {
content: "Statut";
}
td:nth-of-type(3):before {
td#type:before {
content: "Type";
}
td:nth-of-type(4):before {
td#name:before {
content: "Nom";
}
td:nth-of-type(5):before {
td#create:before {
content: "Date de création";
}
td#update:before {
content: "Dernière modification";
}
td:nth-of-type(6):before {
td#author:before {
content: "Auteur";
}
td:nth-of-type(7):before {
td#last_editor:before {
content: "Dernier éditeur";
}
table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable td.dataTables_empty {
table.dataTable th.dt-center, table.dataTable td.dt-center, table.dataTable {
text-align: right;
}
.ui.checkbox {
#select .ui.checkbox {
position: absolute;
left: calc(-1vw - .75em);
top: -.75em;
left: calc(-1vw - 20px);
top: -12px;
min-height: 24px;
font-size: 1rem;
line-height: 24px;
min-width: 24px;
}
#select .ui.checkbox .box::before, #select .ui.checkbox label::before,
#select .ui.checkbox .box::after, #select .ui.checkbox label::after {
width: 24px;
height: 24px;
}
/* cover all the card to ease selection by user */
#select .ui.checkbox {
width: 100%;
}
#select .ui.checkbox input[type="checkbox"] {
width: calc(100% + 1vw + 20px + 4vw);
height: calc(14em + 12px);
}
/* keep the links above the checkbox input to receive the click event */
table a {
z-index: 4;
position: sticky;
}
#select .ui.checkbox .box::before, #select .ui.checkbox label::before {
border-radius: 12px;
}
#select .ui.checkbox .box::after, #select .ui.checkbox label::after {
font-size: 18px;
}
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_paginate {
......@@ -702,10 +971,14 @@ and also iPads specifically.
text-align: center;
margin: .5em 0;
}
/* preserve space to not overlap column label */
.space-left {
margin-left: 2.5em;
}
}
@media only screen and (max-width: 410px) {
.ui.table tr td {
border: none;
}
}
</style>
\ No newline at end of file
</style>
<template>
<div>
<div
id="feature-list-container"
class="ui mobile-column"
>
<div class="mobile-fullwidth">
<h1>Signalements</h1>
</div>
<div class="no-padding-mobile mobile-fullwidth">
<div class="ui large text loader">
Chargement
</div>
<div class="ui secondary menu no-margin">
<a
id="show-map"
:class="['item no-margin', { active: showMap }]"
data-tab="map"
data-tooltip="Carte"
data-position="bottom left"
@click="$emit('show-map', true)"
>
<i
class="map fitted icon"
aria-hidden="true"
/>
</a>
<a
id="show-list"
:class="['item no-margin', { active: !showMap }]"
data-tab="list"
data-tooltip="Liste"
data-position="bottom left"
@click="$emit('show-map', false)"
>
<i
class="list fitted icon"
aria-hidden="true"
/>
</a>
<div class="item">
<h4>
{{ featuresCount }} signalement{{ featuresCount > 1 ? "s" : "" }}
</h4>
</div>
<div
v-if="
project &&
filteredFeatureTypeChoices.length > 0 &&
permissions.can_create_feature
"
id="button-dropdown"
class="item right"
>
<div
class="ui dropdown button compact button-hover-green"
data-tooltip="Ajouter un signalement"
data-position="bottom right"
@click="toggleAddFeature"
>
<i
class="plus fitted icon"
aria-hidden="true"
/>
<div
v-if="showAddFeature"
class="menu left transition visible"
style="z-index: 9999"
>
<div class="header">
Ajouter un signalement du type
</div>
<div class="scrolling menu text-wrap">
<router-link
v-for="(type, index) in filteredFeatureTypeChoices"
:key="type.slug + index"
:to="{
name: 'ajouter-signalement',
params: { slug_type_signal: type.slug },
}"
class="item"
>
{{ type.title }}
</router-link>
</div>
</div>
</div>
<div
v-if="(allSelected || checkedFeatures.length > 0) && massMode.includes('edit') && isOnline"
id="edit-button"
class="ui dropdown button compact button-hover-green tiny-margin-left"
:data-tooltip="`Modifier le${massMode.includes('status') ? ' statut' : 's attributs'} des signalements`"
data-position="bottom right"
@click="editFeatures"
>
<i
class="pencil fitted icon"
aria-hidden="true"
/>
<div
v-if="showModifyStatus"
class="menu left transition visible"
style="z-index: 9999"
>
<div class="header">
Modifier le statut des Signalements
</div>
<div class="scrolling menu text-wrap">
<span
v-for="status in availableStatus"
:key="status.value"
class="item"
@click="$emit('edit-status', status.value)"
>
{{ status.name }}
</span>
</div>
</div>
</div>
<div
v-if="(allSelected || checkedFeatures.length > 0) && massMode === 'delete-features' && isOnline"
class="ui button compact button-hover-red tiny-margin-left"
data-tooltip="Supprimer tous les signalements sélectionnés"
data-position="bottom right"
@click="$emit('toggle-delete-modal')"
>
<i
class="grey trash fitted icon"
aria-hidden="true"
/>
</div>
</div>
</div>
</div>
</div>
<section
id="form-filters"
class="ui form grid equal width"
>
<div
id="type"
:class="['field column', { 'disabled': !isOnline }]"
>
<label>Type</label>
<Multiselect
v-model="form.type"
:options="featureTypeOptions"
:multiple="true"
:searchable="false"
:close-on-select="false"
:show-labels="false"
placeholder="Sélectionner un type"
track-by="value"
label="name"
/>
</div>
<div
id="statut"
:class="['field column', { 'disabled': !isOnline }]"
>
<label>Statut</label>
<Multiselect
v-model="form.status"
:options="statusOptions"
:multiple="true"
:searchable="false"
:close-on-select="false"
:show-labels="false"
placeholder="Sélectionner un statut"
track-by="value"
label="name"
/>
</div>
<div
id="name"
:class="['field column', { 'disabled': !isOnline }]"
>
<label>Nom</label>
<div class="ui icon input">
<i
class="search icon"
aria-hidden="true"
/>
<div class="ui action input">
<input
v-model="form.title"
type="text"
name="title"
@keyup.enter="resetPaginationNfetchFeatures"
>
<button
id="submit-search"
class="ui teal icon button"
@click="resetPaginationNfetchFeatures"
>
<i
class="search icon"
aria-hidden="true"
/>
</button>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import Multiselect from 'vue-multiselect';
import { statusChoices, allowedStatus2change } from '@/utils';
const initialPagination = {
currentPage: 1,
pagesize: 15,
start: 0,
end: 15,
};
export default {
name: 'FeaturesListAndMapFilters',
components: {
Multiselect
},
props: {
showMap: {
type: Boolean,
default: true
},
featuresCount: {
type: Number,
default: 0
},
pagination: {
type: Object,
default: () => {
return {
...initialPagination
};
}
},
allSelected: {
type: Boolean,
default: false,
},
editAttributesFeatureType: {
type: String,
default: null,
},
},
data() {
return {
form: {
type: [],
status: [],
title: null,
},
lat: null,
lng: null,
showAddFeature: false,
showModifyStatus: false,
zoom: null,
};
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS',
'isOnline'
]),
...mapState('feature', [
'checkedFeatures',
'massMode',
]),
...mapState('feature-type', [
'feature_types',
]),
...mapState('projects', [
'project',
]),
...mapGetters([
'permissions',
]),
availableStatus() {
if (this.project && this.user) {
const isModerate = this.project.moderation;
const userStatus = this.USER_LEVEL_PROJECTS[this.project.slug];
const isOwnFeature = true; //* dans ce cas le contributeur est toujours l'auteur des signalements qu'il peut modifier
return allowedStatus2change(this.user, isModerate, userStatus, isOwnFeature);
}
return [];
},
featureTypeTitles() {
return this.feature_types.map((el) => el.title);
},
featureTypeOptions() {
return this.feature_types.map((el) => ({ name: el.title, value: el.slug }));
},
statusOptions() {
//* if project is not moderate, remove pending status
return statusChoices.filter((el) =>
this.project && this.project.moderation ? true : el.value !== 'pending'
);
},
filteredFeatureTypeChoices() {
return this.feature_types.filter((fType) =>
!fType.geom_type.includes('multi')
);
},
},
watch: {
'form.type'(newValue) {
this.$emit('set-filter', { type: newValue });
this.resetPaginationNfetchFeatures();
},
'form.status': {
deep: true,
handler(newValue) {
this.$emit('set-filter', { status: newValue });
this.resetPaginationNfetchFeatures();
}
},
'form.title'(newValue) {
this.$emit('set-filter', { title: newValue });
this.resetPaginationNfetchFeatures();
},
},
mounted() {
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
destroyed() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
},
methods: {
resetPaginationNfetchFeatures() {
this.$emit('reset-pagination');
this.$emit('fetch-features');
},
toggleAddFeature() {
this.showAddFeature = !this.showAddFeature;
this.showModifyStatus = false;
},
editFeatures() {
switch (this.massMode) {
case 'edit-status':
this.toggleModifyStatus();
break;
case 'edit-attributes':
this.displayAttributesForm();
break;
}
},
toggleModifyStatus() {
this.showModifyStatus = !this.showModifyStatus;
this.showAddFeature = false;
},
displayAttributesForm() {
if (this.checkedFeatures.length > 1) {
this.$router.push({
name: 'editer-attribut-signalement',
params: {
slug_type_signal: this.editAttributesFeatureType,
},
});
} else {
this.$store.commit('DISPLAY_MESSAGE', {
comment: 'Veuillez sélectionner au moins 2 signalements pour l\'édition multiple d\'attributs'
});
}
},
clickOutsideDropdown(e) {
if (!e.target.closest('#button-dropdown')) {
this.showModifyStatus = false;
setTimeout(() => { //* timout necessary to give time to click on link to add feature
this.showAddFeature = false;
}, 500);
}
},
}
};
</script>
<style lang="less" scoped>
#feature-list-container {
display: flex;
justify-content: space-between;
.no-padding-mobile {
width: 100%;
margin-left: 25%;
.secondary.menu #button-dropdown {
z-index: 10;
margin-right: 0;
padding-right: 0;
}
}
}
#form-filters {
margin: 0;
label + div {
min-height: 42px;
}
}
.ui.dropdown .menu .left.menu, .ui.dropdown > .left.menu .menu {
margin-right: 0 !important;
}
@media screen and (min-width: 767px) {
#form-filters {
div.field:first-child {
padding-left: 0;
}
div.field:last-child {
padding-right: 0;
}
}
}
@media screen and (max-width: 767px) {
#feature-list-container > .mobile-fullwidth {
width: 100% !important;
}
.no-margin-mobile {
margin: 0 !important;
}
.no-padding-mobile {
padding-top: 0 !important;
padding-bottom: 0 !important;
margin-left: 0 !important;
}
.mobile-column {
flex-direction: column;
}
#form-filters > .field.column {
width: 100%;
padding: 0;
}
#form-filters > .field.column:first-child {
margin-top: 1rem;
}
#form-filters > .field.column:last-child {
margin-bottom: 1rem;
}
.map-container {
width: 100%;
}
}
</style>
<style>
#form-filters .multiselect__tags {
white-space: normal !important;
}
#form-filters .multiselect__tag {
background: var(--primary-color, #008c86) !important;
}
#form-filters .multiselect__tag-icon:focus, #form-filters .multiselect__tag-icon:hover{
background: var(--primary-highlight-color, #006f6a) !important;
}
#form-filters .multiselect__tag-icon:not(:hover)::after {
color: var(--primary-highlight-color, #006f6a);
filter: brightness(0.6);
}
#form-filters .multiselect__option--selected:not(:hover) {
background-color: #e8e8e8 !important;
}
</style>
\ No newline at end of file
<template>
</template>
<script>
export default {
name: 'FeaturesMap',
}
</script>
<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"
......@@ -13,9 +14,35 @@
: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>
......@@ -38,6 +65,22 @@ export default {
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,
}
},
......@@ -47,6 +90,16 @@ export default {
};
},
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,
......@@ -56,20 +109,74 @@ export default {
this.$emit('filter', this.selection);
}
}
}
},
currentSelection(newValue) {
this.updateSelection(newValue);
},
},
created() {
this.selection = this.options[0];
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