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 4504 additions and 270 deletions
<template>
<div>
<h4 class="ui horizontal divider header">
MES PROJETS
</h4>
<div class="ui divided items">
<div
:class="['ui inverted dimmer', { active: projectsLoading }]"
>
<div class="ui text loader">
Récupération des projets en cours...
</div>
</div>
<div
v-for="project in projectsArray"
:key="project.slug"
class="item"
>
<div
v-if="user_permissions[project.slug].can_view_project"
class="item-content-wrapper"
>
<div class="ui tiny image">
<img
v-if="project.thumbnail"
class="ui small image"
alt="Thumbnail projet"
:src="
project.thumbnail.includes('default')
? require('@/assets/img/default.png')
: DJANGO_BASE_URL + project.thumbnail + refreshId()
"
height="200"
>
</div>
<div class="middle aligned content">
<router-link
:to="{
name: 'project_detail',
params: { slug: project.slug },
}"
class="header"
>
{{ project.title }}
</router-link>
<div class="description">
<p>{{ project.description }}</p>
</div>
<div class="meta top">
<span
class="right floated"
>
<strong>Projet {{ project.moderation ? "" : "non" }} modéré</strong>
</span>
<span>
Niveau d'autorisation requis : {{ project.access_level_pub_feature }}
</span><br>
<span>
Mon niveau d'autorisation :
<span v-if="USER_LEVEL_PROJECTS && project">
{{ USER_LEVEL_PROJECTS[project.slug] }}
</span>
<span v-if="user && user.is_administrator">
{{ "+ Gestionnaire métier" }}
</span>
</span>
</div>
<div class="meta">
<span
class="right floated"
:data-tooltip="`Projet créé le ${project.created_on}`"
>
<i
class="calendar icon"
aria-hidden="true"
/>
&nbsp;{{ project.created_on }}
</span>
<span data-tooltip="Membres">
{{ project.nb_contributors }}&nbsp;
<i
class="user icon"
aria-hidden="true"
/>
</span>
<span data-tooltip="Signalements publiés">
{{ project.nb_published_features }}&nbsp;
<i
class="map marker icon"
aria-hidden="true"
/>
</span>
<span data-tooltip="Commentaires">
{{ project.nb_published_features_comments }}&nbsp;
<i
class="comment icon"
aria-hidden="true"
/>
</span>
</div>
</div>
</div>
</div>
<!-- PAGINATION -->
<Pagination
v-if="count"
:nb-pages="Math.ceil(count/10)"
@page-update="changePage"
/>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions } from 'vuex';
import Pagination from '@/components/Pagination.vue';
export default {
name: 'UserProjectList',
components: {
Pagination,
},
data() {
return {
projectsLoading: true,
};
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS',
'user_permissions',
]),
// todo : filter projects to user
...mapState('projects', [
'projects',
'count',
]),
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
isSharedProject() {
return this.$route.path.includes('projet-partage');
},
availableProjects() {
if (this.isSharedProject) {
return this.projects.filter((el) => el.slug === this.$route.params.slug);
}
return this.projects;
},
projectsArray() { //* if only one project, only project object is returned
return Array.isArray(this.projects) ? this.projects : [this.projects];
}
},
created(){
this.SET_PROJECTS([]); //* empty previous project to avoid undefined user_permissions[project.slug]
this.getData();
},
methods: {
...mapMutations('projects', [
'SET_CURRENT_PAGE',
'SET_PROJECTS',
]),
...mapActions('projects', [
'GET_PROJECTS',
]),
refreshId() {
const crypto = window.crypto || window.msCrypto;
var array = new Uint32Array(1);
return '?ver=' + crypto.getRandomValues(array); // Compliant for security-sensitive use cases
},
getData(page) {
this.loading = true;
this.GET_PROJECTS({ ismyaccount: true, projectSlug: this.$route.params.slug, page })
.then(() => this.projectsLoading = false)
.catch(() => this.projectsLoading = false);
},
changePage(e) {
this.getData(e);
},
}
};
</script>
<style lang="less" scoped>
.ui.divided.items {
.item {
.item-content-wrapper {
width: 100%;
margin: 0;
padding: 1em 0;
display: flex;
.middle.aligned.content {
.header {
font-size: 1.28571429em;
font-weight: 600;
color: rgb(31, 31, 31)
}
}
}
}
> .item:nth-child(2) {
border: none !important;
}
}
.description {
p {
text-align: justify;
}
}
@media only screen and (min-width: 767px) {
.item-content-wrapper {
align-items: flex-start;
.middle.aligned.content {
width: 100%;
padding: 0 0 0 1.5em;
.meta.top {
span {
line-height: 1.2em;
}
}
}
}
}
@media only screen and (max-width: 767px) {
.item-content-wrapper {
flex-direction: column;
align-items: center;
.middle.aligned.content {
width: 80%;
padding: 1.5em 0 0;
.meta.top {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
.right.floated {
float: none !important;
margin-left: 0 !important;
margin-bottom: 0.5em;
}
span {
margin: 0.15em 0;
}
}
}
}
}
</style>
<template>
<div id="app-footer">
<div class="ui compact text menu">
<router-link
:to="{name: 'mentions'}"
class="item"
>
Mentions légales
</router-link>
<router-link
:to="{name: 'aide'}"
class="item"
>
Aide
</router-link>
<p class="item">
Version {{ PACKAGE_VERSION }}
</p>
</div>
</div>
</template>
<script>
export default {
name: 'AppFooter',
computed: {
PACKAGE_VERSION: () => process.env.PACKAGE_VERSION || '0',
}
};
</script>
<template>
<div
id="app-header"
:class="$route.name"
>
<div class="menu container">
<div class="ui inverted icon menu">
<router-link
:is="isSharedProject ? 'span' : 'router-link'"
:to="isSharedProject ? '' : '/'"
:class="['header item', {disable: isSharedProject}]"
>
<img
class="ui right spaced image"
alt="Logo de l'application"
:src="logo"
>
<span class="desktop">
{{ APPLICATION_NAME }}
</span>
</router-link>
<div
v-if="width <= 560 || (width > 560 && project)"
id="menu-dropdown"
:class="['ui dropdown item', { 'active visible': menuIsOpen }]"
@click="menuIsOpen = !menuIsOpen"
>
<div
v-if="!isOnline"
class="crossed-out mobile"
>
<i
class="wifi icon"
aria-hidden="true"
/>
</div>
<span class="expand-center">
<span v-if="project"> Projet : {{ project.title }} </span>
</span>
<i
class="dropdown icon"
aria-hidden="true"
/>
<div
:class="[
'menu dropdown-list transition',
{ 'visible': menuIsOpen },
]"
style="z-index: 401"
>
<router-link
v-if="project"
:to="{
name: 'project_detail',
params: { slug: project.slug },
}"
class="item"
>
<i
class="home icon"
aria-hidden="true"
/>Accueil
</router-link>
<router-link
v-if="project"
:to="{
name: 'liste-signalements',
params: { slug: project.slug },
}"
class="item"
>
<i
class="list icon"
aria-hidden="true"
/>Liste & Carte
</router-link>
<router-link
v-if="
project && isOnline && hasAdminRights"
:to="{
name: 'project_mapping',
params: { slug: project.slug },
}"
class="item"
>
<i
class="map icon"
aria-hidden="true"
/>Fonds cartographiques
</router-link>
<router-link
v-if="
project && isOnline && hasAdminRights"
:to="{
name: 'project_members',
params: { slug: project.slug },
}"
class="item"
>
<i
class="users icon"
aria-hidden="true"
/>Membres
</router-link>
<div class="mobile">
<router-link
:is="isOnline ? 'router-link' : 'span'"
v-if="user"
:to="{
name: 'my_account',
params: { slug: isSharedProject && $route.params.slug ? $route.params.slug : null }
}"
class="item"
>
{{ userFullname || user.username || "Utilisateur inconnu" }}
</router-link>
<div
v-if="USER_LEVEL_PROJECTS && project"
class="item ui label vertical no-hover"
>
<!-- super user rights are higher than others -->
{{ user && user.is_superuser ? 'Administrateur' : USER_LEVEL_PROJECTS[project.slug] }}
<br>
</div>
<div
v-if="user && user.is_administrator"
class="item ui label vertical no-hover"
>
Gestionnaire métier
</div>
<div
v-if="!DISABLE_LOGIN_BUTTON"
>
<a
v-if="user"
class="item"
@click="logout"
><i
class="ui logout icon"
aria-hidden="true"
/>
</a>
<router-link
v-else-if="!user && !SSO_LOGIN_URL"
:to="{ name : 'login' }"
class="item"
>
Se connecter
</router-link>
<a
v-else
class="item"
:href="SSO_LOGIN_URL"
target="_self"
>Se connecter</a>
</div>
</div>
</div>
</div>
<div class="desktop flex push-right-desktop item title abstract">
<span>
{{ APPLICATION_ABSTRACT }}
</span>
</div>
<div class="desktop flex push-right-desktop">
<div
v-if="!isOnline"
class="item network-icon"
>
<span
data-tooltip="Vous êtes hors-ligne,
vos changements pourront être envoyés au serveur au retour de la connexion"
data-position="bottom right"
>
<div class="crossed-out">
<i
class="wifi icon"
aria-hidden="true"
/>
</div>
</span>
</div>
<router-link
:is="isOnline ? 'router-link' : 'span'"
v-if="user"
:to="{
name: 'my_account',
params: { slug: isSharedProject && $route.params.slug ? $route.params.slug : null }
}"
class="item"
>
{{ userFullname || user.username || "Utilisateur inconnu" }}
</router-link>
<div
v-if="USER_LEVEL_PROJECTS && project"
class="item ui label vertical no-hover"
>
<!-- super user rights are higher than others -->
{{ user && user.is_superuser ? 'Administrateur' : USER_LEVEL_PROJECTS[project.slug] }}
<br>
</div>
<div
v-if="user && user.is_administrator"
class="item ui label vertical no-hover"
>
Gestionnaire métier
</div>
<div
v-if="!DISABLE_LOGIN_BUTTON"
>
<a
v-if="user"
class="item log-item"
@click="logout"
><i
class="ui logout icon"
aria-hidden="true"
/>
</a>
<router-link
v-else-if="!user && !SSO_LOGIN_URL"
:to="{ name : 'login' }"
class="item log-item"
>
Se Connecter
</router-link>
<a
v-else
class="item log-item"
:href="SSO_LOGIN_URL"
target="_self"
>Se connecter</a>
</div>
</div>
</div>
<MessageInfoList />
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import MessageInfoList from '@/components/MessageInfoList';
export default {
name: 'AppHeader',
components: {
MessageInfoList
},
data() {
return {
menuIsOpen: false,
width: window.innerWidth > 0 ? window.innerWidth : screen.width,
};
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS',
'configuration',
'loader',
'isOnline'
]),
...mapState('projects', [
'projects',
'project',
]),
APPLICATION_NAME() {
return this.configuration.VUE_APP_APPLICATION_NAME;
},
APPLICATION_ABSTRACT() {
return this.configuration.VUE_APP_APPLICATION_ABSTRACT;
},
DISABLE_LOGIN_BUTTON() {
return this.configuration.VUE_APP_DISABLE_LOGIN_BUTTON;
},
SSO_LOGIN_URL() {
if (this.configuration.VUE_APP_LOGIN_URL) {
// add a next parameter with the pathname as expected by OGS to redirect after login
return `${this.configuration.VUE_APP_LOGIN_URL}/?next=${encodeURIComponent(window.location.pathname)}`;
}
return null;
},
logo() {
return this.configuration.VUE_APP_LOGO_PATH;
},
userFullname() {
if (this.user.first_name || this.user.last_name) {
return `${this.user.first_name} ${this.user.last_name}`;
}
return null;
},
isAdmin() {
return this.USER_LEVEL_PROJECTS &&
this.USER_LEVEL_PROJECTS[this.project.slug] === 'Administrateur projet'
? true
: false;
},
hasAdminRights() {
return this.user && (this.user.is_administrator || this.user.is_superuser) || this.isAdmin;
},
isSharedProject() {
return this.$route.path.includes('projet-partage');
}
},
created() {
window.addEventListener('mousedown', this.clickOutsideMenu);
},
beforeDestroy() {
window.removeEventListener('mousedown', this.clickOutsideMenu);
},
methods: {
logout() {
this.$store.dispatch('LOGOUT');
},
clickOutsideMenu(e) {
if (e.target.closest && !e.target.closest('#menu-dropdown')) {
this.menuIsOpen = false;
}
},
}
};
</script>
<style lang="less" scoped>
.menu.container .header {
padding-top: 5px !important;
padding-bottom: 5px !important;
&> img {
max-height: 30px;
}
}
.vertical {
flex-direction: column;
justify-content: center;
}
.flex {
display: flex;
}
/* keep above loader */
#menu-dropdown {
z-index: 1001;
}
.expand-center {
width: 100%;
text-align: center;
}
.network-icon {
padding: .5rem !important;
}
.crossed-out {
position: relative;
padding: .2em;
&::before {
content: "";
position: absolute;
top: 45%;
left: -8%;
width: 100%;
border-top: 2px solid #ee2e24;
transform: rotate(45deg);
box-shadow: 0px 0px 0px 1px #373636;
border-radius: 3px;
}
}
@media screen and (max-width: 1110px) {
.abstract{
display: none !important;
}
}
@media screen and (min-width: 726px) {
.mobile {
display: none !important;
}
#app-header {
min-width: 560px;
}
.menu.container {
width: auto !important;
}
.push-right-desktop {
margin-left: auto;
}
}
@media screen and (max-width: 725px) {
.desktop {
display: none !important;
}
div.dropdown-list {
width: 100vw;
}
.menu.container .header {
//width: 70px;
width: 100%;
}
#app-header:not(.index) {
/* make the logo disappear on scroll */
position: sticky;
top: -90px;
height: 80px;
.menu.container {
/* make the logo disappear on scroll */
height: 30px;
position: sticky;
top: 0;
}
}
.menu.container .header > img {
margin: 0;
margin: auto;
max-width: 100%;
}
#menu-dropdown {
width: 100%;
}
#menu-dropdown > span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
.menu.container {
position: relative;
}
.disable:hover {
cursor: default !important;
background-color: #373636 !important;
}
/* keep above map controls or buttons */
#app-header {
z-index: 9999;
.menu.container .ui.inverted.icon.menu { /* avoid adding space when messages are displayed */
margin: 0;
display: flex;
flex-wrap: wrap;
}
}
.ui.menu .ui.dropdown .menu {
.item.no-hover:hover {
cursor: auto !important;
background: white !important;
}
}
/* copy style to apply inside nested div */
.ui.menu .ui.dropdown .menu .item {
margin: 0;
text-align: left;
font-size: 1em !important;
padding: 0.78571429em 1.14285714em !important;
background: 0 0 !important;
color: #252525 !important;
text-transform: none !important;
font-weight: 400 !important;
box-shadow: none !important;
transition: none !important;
}
.item.title::before {
background: none !important;
}
.log-item {
height: 100% !important;
}
</style>
\ No newline at end of file
......@@ -10,37 +10,43 @@
>
<input
v-if="search"
ref="input"
v-model="input"
@input="isOpen = true"
v-on:keyup.enter="select(0)"
v-on:keyup.esc="toggleDropdown(false)"
class="search"
autocomplete="off"
tabindex="0"
:placeholder="placehold"
ref="input"
/>
<div v-if="!input" class="default text">
<!-- {{ selected }} -->
@input="isOpen = true"
@keyup.enter="select(0)"
@keyup.esc="toggleDropdown(false)"
>
<div
v-if="!input"
class="default text"
>
<div v-if="Array.isArray(selected)">
<span v-if="selected[0]"> {{ selected[0] }} - </span>
<span class="italic">{{ selected[1] }}</span>
</div>
<div v-else>{{ selected }}</div>
<div v-else>
{{ selectedDisplay }}
</div>
</div>
<i
:class="['dropdown icon', { clear: clearable && selected }]"
aria-hidden="true"
@click="clear"
></i>
/>
<div :class="['menu', { 'visible transition': isOpen }]">
<div
v-for="(option, index) in filteredOptions || ['No results found.']"
@click="select(index)"
:id="option.name && Array.isArray(option.name) ? option.name[0] : option.name || option"
:key="option + index"
:class="[
filteredOptions ? 'item' : 'message',
{ 'active selected': option.name === selected },
{ 'active selected': option.name === selected || option.id === selected },
]"
@click="select(index)"
>
<div v-if="option.name && Array.isArray(option.name)">
<span v-if="option.name[0]"> {{ option.name[0] }} - </span>
......@@ -59,52 +65,88 @@
<script>
export default {
name: "Dropdown",
name: 'Dropdown',
props: [
"options",
"selected",
"disabled",
"search",
"placeholder",
"clearable",
],
props: {
clearable: {
type: Boolean,
default: null,
},
disabled: {
type: Boolean,
default: null,
},
options: {
type: Array,
default: null,
},
placeholder: {
type: String,
default: null,
},
selected: {
type: [String, Array],
default: null,
},
search: {
type: Boolean,
default: null,
},
},
data() {
return {
isOpen: false,
input: '',
identifier: 0,
};
},
computed: {
filteredOptions: function () {
let options = this.options;
if (this.search && this.input !== "") {
if (this.search && this.input !== '') {
options = this.options.filter(this.matchInput);
}
return options.length > 0 ? options : null;
},
placehold() {
return this.input ? "" : this.placeholder;
return this.input ? '' : this.placeholder;
},
selectedDisplay() { // for project attributes, option are object and selected is an id
if (this.options[0] && this.options[0].name) {
const option = this.options.find(opt => opt.id === this.selected);
if (option) return option.name;
}
return this.selected;
}
},
data() {
return {
isOpen: false,
input: "",
identifier: 0,
};
created() {
const crypto = window.crypto || window.msCrypto;
const array = new Uint32Array(1);
this.identifier = Math.floor(crypto.getRandomValues(array) * 10000);
window.addEventListener('mousedown', this.clickOutsideDropdown);
},
beforeDestroy() {
window.removeEventListener('mousedown', this.clickOutsideDropdown);
},
methods: {
toggleDropdown(val) {
if (this.isOpen) {
this.input = ""; // * clear input field when closing dropdown
} else if (this.search) {
//* focus on input if is a search dropdown
this.$refs.input.focus({
if (this.isOpen) { //* if dropdown is open :
this.input = ''; // * -> clear input field when closing dropdown
} else if (this.search) { //* if dropdown is closed is a search dropdown:
this.$refs.input.focus({ //* -> focus on input field
preventScroll: true,
});
} else if (this.clearable && val.target && this.selected) {
this.clear(); //* clear selected and input
}
this.isOpen = typeof val === "boolean" ? val : !this.isOpen;
this.isOpen = typeof val === 'boolean' ? val : !this.isOpen;
},
select(index) {
......@@ -113,9 +155,9 @@ export default {
this.isOpen = false;
}, 0);
if (this.filteredOptions) {
this.$emit("update:selection", this.filteredOptions[index]);
this.$emit('update:selection', this.filteredOptions[index]);
}
this.input = "";
this.input = '';
},
matchInput(el) {
......@@ -133,27 +175,21 @@ export default {
},
clear() {
if (this.clearable) {
this.input = "";
this.$emit("update:selection", "");
if (this.isOpen) this.toggleDropdown(false);
if (this.clearable && this.selected) {
this.input = '';
this.$emit('update:selection', '');
if (this.isOpen) {
this.toggleDropdown(false);
}
}
},
clickOutsideDropdown(e) {
if (!e.target.closest(`#custom-dropdown${this.identifier}`))
if (!e.target.closest(`#custom-dropdown${this.identifier}`) && this.isOpen) {
this.toggleDropdown(false);
}
},
},
created() {
this.identifier = Math.floor(Math.random() * 10000);
window.addEventListener("mousedown", this.clickOutsideDropdown);
},
beforeDestroy() {
window.removeEventListener("mousedown", this.clickOutsideDropdown);
},
};
</script>
......@@ -164,4 +200,4 @@ export default {
.italic {
font-style: italic;
}
</style>
\ No newline at end of file
</style>
<template>
<div
:class="['field', { 'disabled': field.disabled }]"
:data-field_type="field.field_type"
data-test="extra-form"
name="extra-form"
>
<div
v-if="field && field.field_type === 'boolean'"
:class="['ui checkbox', { 'disabled': field.disabled }]"
>
<!-- JSON.parse is used in case of receiving a string 'true' or 'false'-->
<input
:id="field.name"
type="checkbox"
:checked="JSON.parse(field.value)"
:name="field.name"
@change="updateStore_extra_form"
>
<label :for="field.name">
{{ displayLabels ? field.label : '' }}
</label>
</div>
<template v-else>
<label
v-if="displayLabels"
:for="field.name"
:class="{ required: field.is_mandatory }"
>
{{ field.label }}
</label>
<input
v-if="field && field.field_type === 'char'"
:id="field.name"
:value="field.value"
type="text"
:name="field.name"
:required="field.is_mandatory"
@blur="updateStore_extra_form"
>
<textarea
v-else-if="field && field.field_type === 'text'"
:value="field.value"
:name="field.name"
:required="field.is_mandatory"
rows="3"
@blur="updateStore_extra_form"
/>
<input
v-else-if="field && field.field_type === 'integer'"
:id="field.name"
:value="field.value"
type="number"
:name="field.name"
:required="field.is_mandatory"
@change="updateStore_extra_form"
>
<input
v-else-if="field && field.field_type === 'decimal'"
:id="field.name"
:value="field.value"
type="number"
step=".01"
:name="field.name"
:required="field.is_mandatory"
@change="updateStore_extra_form"
>
<input
v-else-if="field && field.field_type === 'date'"
:id="field.name"
:value="field.value"
type="date"
:name="field.name"
:required="field.is_mandatory"
@change="updateStore_extra_form"
>
<Dropdown
v-else-if="field && (field.field_type === 'list' || field.field_type === 'notif_group')"
:options="field.field_type === 'notif_group' ? usersGroupsFeatureOptions : field.options"
:selected="selected_extra_form_list"
:selection.sync="selected_extra_form_list"
:required="field.is_mandatory"
:search="true"
:clearable="true"
/>
<div
v-else-if="field && field.field_type === 'multi_choices_list'"
class="checkbox_list"
>
<div
v-for="option in field.options"
:key="option.id || option"
class="ui checkbox"
>
<input
:id="option.id || option"
type="checkbox"
:checked="field.value && field.value.includes(option.id || option)"
:name="option.id || option"
@change="selectMultipleCheckbox"
>
<label :for="option.id || option">
{{ option.name || option }}
</label>
</div>
</div>
<Multiselect
v-else-if="field && field.field_type === 'pre_recorded_list'"
v-model="selectedPrerecordedValue"
:options="selectedPrerecordedListValues[field.options[0]] || []"
:options-limit="10"
:allow-empty="!field.is_mandatory"
track-by="label"
label="label"
:reset-after="false"
select-label=""
selected-label=""
deselect-label=""
:searchable="true"
:placeholder="'Recherchez une valeur de la liste pré-définie ...'"
:show-no-results="true"
:loading="loadingPrerecordedListValues"
:clear-on-select="false"
:preserve-search="false"
@search-change="search"
@select="selectPrerecordedValue"
>
<template slot="clear">
<div
v-if="selectedPrerecordedValue"
class="multiselect__clear"
@click.prevent.stop="clearPrerecordedValue"
>
<i
class="close icon"
aria-hidden="true"
/>
</div>
</template>
<span slot="noResult">
Aucun résultat.
</span>
<span slot="noOptions">
Saisissez les premiers caractères ...
</span>
</Multiselect>
</template>
</div>
</template>
<script>
import { mapState, mapActions, mapMutations, mapGetters } from 'vuex';
import Multiselect from 'vue-multiselect';
import Dropdown from '@/components/Dropdown.vue';
export default {
name: 'ExtraForm',
components: {
Dropdown,
Multiselect
},
props: {
field: {
type: Object,
default: null,
},
useValueOnly: {
type: Boolean,
default: false,
}
},
data() {
return {
error: null,
prerecordedListSearchQuery: null,
loadingPrerecordedListValues: false,
selectedPrerecordedValue: null,
selectedMultipleCheckbox: [],
};
},
computed: {
...mapState('feature-type', [
'selectedPrerecordedListValues'
]),
...mapState('feature', [
'extra_forms',
]),
...mapGetters(['usersGroupsFeatureOptions']),
selected_extra_form_list: {
get() {
if (this.field.field_type === 'notif_group') {
const usersGroup = this.usersGroupsFeatureOptions.find((group) => group.value === this.field.value);
return usersGroup ? usersGroup.name : '';
}
return this.field.value || '';
},
set(newValue) {
//* set the value selected in the dropdown
if (this.useValueOnly) {
this.$emit('update:value', newValue.id || newValue);
} else {
const newExtraForm = this.field;
newExtraForm['value'] = this.field.field_type === 'notif_group' ? newValue.value : newValue;
this.$store.commit('feature/UPDATE_EXTRA_FORM', newExtraForm);
}
},
},
displayLabels() {
return this.$route.name === 'editer-signalement' || this.$route.name === 'ajouter-signalement' || this.$route.name === 'editer-attribut-signalement';
},
},
watch: {
/**
* Watches for changes in the 'field.value' and updates the form state accordingly.
* This watcher handles specific field types, ensuring their values are correctly initialized
* and updated in scenarios like fast edition mode where certain values might not be auto-refreshed.
*
* @param {*} newValue - The new value of the field.
* @param {*} oldValue - The previous value of the field before the change.
*/
'field.value': function(newValue, oldValue) {
// Check if the field object exists.
if (this.field) {
// Handle pre-recorded list fields specifically.
if (this.field.field_type === 'pre_recorded_list') {
// Update the form value if both new and old values are defined and different,
// or if either value is undefined, indicating a change.
if ((newValue && oldValue && (newValue.label !== oldValue.label || newValue !== oldValue))
|| !newValue || !oldValue) {
this.initPrerecordedXform(); // Reinitialize the field to reflect the updated value.
}
} else if (this.field.field_type === 'multi_choices_list') {
// For multi-choice lists, reinitialize the field if the array values have changed.
// This is crucial in fast edition modes to prevent overriding the current value with a stale value.
this.initMultipleCheckboxXform();
}
// Reset any error states for the field.
this.error = null;
}
},
prerecordedListSearchQuery(newValue) {
this.loadingPrerecordedListValues = true;
this.GET_SELECTED_PRERECORDED_LIST_VALUES({
name: this.field.options[0],
pattern: newValue
})
.then(() => {
this.loadingPrerecordedListValues = false;
})
.catch(() => {
this.loadingPrerecordedListValues = false;
});
}
},
created() {
if (this.field) {
if (this.field.field_type === 'pre_recorded_list') {
this.initPrerecordedXform();
} else if (this.field.field_type === 'multi_choices_list') {
this.initMultipleCheckboxXform();
}
}
},
mounted() {
// autoset field to false if is a boolean, since user doesn't need to select it, when false value is expected
if (this.field.field_type === 'boolean' && (this.field.value === undefined || this.field.value === null)) {
this.updateStore_extra_form(false);
}
},
methods: {
...mapActions('feature-type', [
'GET_SELECTED_PRERECORDED_LIST_VALUES'
]),
...mapMutations('feature', [
'UPDATE_EXTRA_FORM',
'SET_EXTRA_FORMS',
]),
initMultipleCheckboxXform() {
this.selectedMultipleCheckbox = typeof this.field.value === 'string' ? this.field.value.split(',') : this.field.value || [];
},
initPrerecordedXform() {
const { options, value } = this.field;
this.loadingPrerecordedListValues = true;
this.GET_SELECTED_PRERECORDED_LIST_VALUES({
name: options[0],
pattern: ''
})
.then(() => {
this.loadingPrerecordedListValues = false;
})
.catch(() => {
this.loadingPrerecordedListValues = false;
});
if (value) {
this.selectedPrerecordedValue = { label: value.label ? value.label : value };
} else {
this.selectedPrerecordedValue = null;
}
},
/**
* Updates the Vuex store or component state with the new value for a form field.
* This function handles different types of form fields including boolean, multi-choice lists, and others.
*
* @param {Event|*} val - The new value or an event object.
*/
updateStore_extra_form(val) {
// Check if the field object is defined.
if (this.field) {
let newValue;
// If the function is triggered by an event from a template input.
if (val && val.target) {
// For boolean fields (like checkboxes), use the 'checked' property.
if (this.field.field_type === 'boolean') {
newValue = val.target.checked;
} else {
// For other input types, use the 'value' property.
newValue = val.target.value;
}
} else if (this.field.field_type === 'multi_choices_list') {
// For multi-choice lists, the value is stored in component state.
newValue = this.selectedMultipleCheckbox;
} else {
// If the function is called directly with a value (not from an event).
newValue = val;
}
// Set the new value for the field.
if (this.useValueOnly) {
// If the component is used to update directly a returned value, emit an event with the new value.
this.$emit('update:value', newValue);
} else {
// Otherwise, update the Vuex store with the new value for the extra form field.
this.UPDATE_EXTRA_FORM({ ...this.field, value: newValue });
}
}
},
checkForm() {
let isValid = true;
if (this.field && this.field.is_mandatory && !this.field.value) {
isValid = false;
this.error = 'Ce champ est obligatoire';
} else {
this.error = null;
}
return isValid;
},
search(text) {
this.prerecordedListSearchQuery = text;
},
selectPrerecordedValue(e) {
this.selectedPrerecordedValue = e;
this.prerecordedListSearchQuery = null;
this.updateStore_extra_form({ target: { value: this.selectedPrerecordedValue.label } });
},
clearPrerecordedValue() {
this.selectedPrerecordedValue = null;
this.prerecordedListSearchQuery = null;
this.updateStore_extra_form({ target: { value: null } });
},
/**
* Handles the selection and deselection of checkboxes in a form.
* This function updates an array to track the selected checkboxes by their names.
* It's typically called on the change event of each checkbox.
*
* @param {Event} e - The event object from the checkbox input.
*/
selectMultipleCheckbox(e) {
// Destructure the 'checked' status and 'name' of the checkbox from the event target.
const { checked, name } = e.target;
// If the checkbox is checked, add its name to the array of selected checkboxes.
// Cloning the array to allow unsaved changes detection (it wasn't working with Array.push)
if (checked) {
this.selectedMultipleCheckbox = [...this.selectedMultipleCheckbox, name];
} else {
// If the checkbox is unchecked, remove its name from the array.
this.selectedMultipleCheckbox = this.selectedMultipleCheckbox.filter((el) => el !== name);
}
// Call a method to update the Vuex store or component state with the latest selection.
this.updateStore_extra_form();
},
},
};
</script>
<style lang="less" scoped>
label.required:after {
content: ' *';
color: rgb(209, 0, 0);
}
.checkbox_list {
display: flex;
flex-direction: column;
.ui.checkbox {
margin: .25rem 0;
font-weight: normal;
}
}
</style>
<style>
.multiselect__placeholder {
position: absolute;
width: calc(100% - 48px);
overflow: hidden;
text-overflow: ellipsis;
}
.multiselect__tags {
position: relative;
}
/* keep font-weight from overide of semantic classes */
.multiselect__placeholder, .multiselect__content, .multiselect__tags {
font-weight: initial !important;
}
/* keep placeholder eigth */
.multiselect .multiselect__placeholder {
margin-bottom: 9px !important;
padding-top: 1px;
}
/* keep placeholder height when opening dropdown without selection */
input.multiselect__input {
padding: 3px 0 0 0 !important;
}
/* keep placeholder height when opening dropdown with already a value selected */
.multiselect__tags .multiselect__single {
padding: 1px 0 0 0 !important;
margin-bottom: 9px;
}
</style>
<template>
<div>
<h2 class="ui header">
Pièces jointes
</h2>
<div
v-for="pj in attachments"
:key="pj.id"
class="ui divided items"
>
<div class="item">
<a
class="ui tiny image"
target="_blank"
:href="pj.attachment_file"
>
<img
:src="
pj.extension === '.pdf'
? require('@/assets/img/pdf.png')
: pj.attachment_file
"
alt="Pièce jointe au signalement"
>
</a>
<div class="middle aligned content">
<a
class="header"
target="_blank"
:href="pj.attachment_file"
>{{
pj.title
}}</a>
<div class="description">
{{ pj.info }}
</div>
</div>
</div>
</div>
<em v-if="attachments.length === 0">
Aucune pièce jointe associée au signalement.
</em>
</div>
</template>
<script>
export default {
name: 'FeatureAttachements',
props: {
attachments: {
type: Array,
default: () => {
return [];
}
}
}
};
</script>
<template>
<div>
<h2 class="ui header">
Activité et commentaires
</h2>
<div
id="feed-event"
class="ui feed"
>
<div
v-for="(event, index) in events"
:key="'event' + index"
>
<div
v-if="event.event_type === 'create'"
>
<div
v-if="event.object_type === 'feature'"
class="event"
>
<div class="content">
<div class="summary">
<div class="date">
{{ event.created_on }}
</div>
Création du signalement
<span v-if="user">par {{ event.display_user }}</span>
</div>
</div>
</div>
<div
v-else-if="event.object_type === 'comment'"
class="event"
>
<div class="content">
<div class="summary">
<div class="date">
{{ event.created_on }}
</div>
Commentaire
<span v-if="user">par {{ event.display_user }}</span>
</div>
<div class="extra text">
{{ event.related_comment.comment }}
<div
v-if="event.related_comment.attachment"
>
<br>
<a
:href="
DJANGO_BASE_URL +
event.related_comment.attachment.url
"
target="_blank"
>
<i
class="paperclip fitted icon"
aria-hidden="true"
/>
{{ event.related_comment.attachment.title }}
</a>
</div>
</div>
</div>
</div>
</div>
<div
v-else-if="event.event_type === 'update'"
class="event"
>
<div class="content">
<div class="summary">
<div class="date">
{{ event.created_on }}
</div>
Signalement mis à jour
<span v-if="user">par {{ event.display_user }}</span>
</div>
</div>
</div>
</div>
</div>
<div
v-if="permissions && permissions.can_create_feature && isOnline"
class="ui segment"
>
<form
id="form-comment"
class="ui form"
>
<div class="required field">
<label
:for="comment_form.comment.id_for_label"
>Ajouter un commentaire</label>
<ul
v-if="comment_form.comment.errors"
class="errorlist"
>
<li>
{{ comment_form.comment.errors }}
</li>
</ul>
<textarea
v-model="comment_form.comment.value"
:name="comment_form.comment.html_name"
rows="2"
/>
</div>
<label>Pièce jointe (facultative)</label>
<div class="two fields">
<div class="field">
<label
class="ui icon button"
for="attachment_file"
>
<i
class="paperclip icon"
aria-hidden="true"
/>
<span class="label">{{
comment_form.attachment_file.value
? comment_form.attachment_file.value
: "Sélectionner un fichier ..."
}}</span>
</label>
<input
id="attachment_file"
type="file"
accept="application/pdf, image/jpeg, image/png"
style="display: none"
name="attachment_file"
@change="onFileChange"
>
</div>
<div class="field">
<input
id="title"
v-model="comment_form.attachment_file.title"
type="text"
name="title"
>
{{ comment_form.attachment_file.errors }}
</div>
</div>
<div
v-if="enableKeyDocNotif"
class="field"
>
<div class="ui checkbox">
<input
id="is_key_document"
v-model="comment_form.attachment_file.isKeyDocument"
class="hidden"
name="is_key_document"
type="checkbox"
>
<label for="is_key_document">Envoyer une notification de publication aux abonnés du projet</label>
</div>
</div>
<ul
v-if="comment_form.attachment_file.errors"
class="errorlist"
>
<li>
{{ comment_form.attachment_file.errors }}
</li>
</ul>
<button
type="button"
class="ui compact green icon button"
@click="postComment"
>
<i
class="plus icon"
aria-hidden="true"
/> Poster le commentaire
</button>
</form>
</div>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import featureAPI from '@/services/feature-api';
export default {
name: 'FeatureComments',
props: {
events: {
type: Array,
default: () => {
return [];
}
},
enableKeyDocNotif: {
type: Boolean,
default: false,
}
},
data() {
return {
comment_form: {
attachment_file: {
errors: null,
title: '',
file: null,
isKeyDocument: false
},
comment: {
id_for_label: 'add-comment',
html_name: 'add-comment',
errors: '',
value: null,
},
},
};
},
computed: {
...mapState([
'user',
'isOnline',
]),
...mapGetters([
'permissions',
]),
...mapState('feature', [
'currentFeature',
]),
DJANGO_BASE_URL() {
return this.$store.state.configuration.VUE_APP_DJANGO_BASE;
},
},
methods: {
validateForm() {
this.comment_form.comment.errors = '';
if (!this.comment_form.comment.value) {
this.comment_form.comment.errors = 'Le commentaire ne peut pas être vide';
return false;
}
return true;
},
postComment() {
if (this.validateForm()) {
featureAPI
.postComment({
featureId: this.currentFeature.feature_id || this.currentFeature.id,
comment: this.comment_form.comment.value,
})
.then((response) => {
if (response && this.comment_form.attachment_file.file) {
featureAPI
.postCommentAttachment({
featureId: this.currentFeature.feature_id || this.currentFeature.id,
file: this.comment_form.attachment_file.file,
fileName: this.comment_form.attachment_file.fileName,
title: this.comment_form.attachment_file.title,
isKeyDocument: this.comment_form.attachment_file.isKeyDocument,
commentId: response.data.id,
})
.then(() => {
// Reset isKeyDocument to default
this.comment_form.attachment_file.isKeyDocument = false;
this.confirmComment();
});
} else {
this.confirmComment();
}
});
}
},
confirmComment() {
this.$store.commit('DISPLAY_MESSAGE', { comment: 'Ajout du commentaire confirmé', level: 'positive' });
this.$emit('fetchEvents'); //* display new comment on the page
this.comment_form.attachment_file.file = null; //* empty all inputs
this.comment_form.attachment_file.fileName = '';
this.comment_form.attachment_file.title = '';
this.comment_form.comment.value = null;
},
onFileChange(e) {
// * read image file
const files = e.target.files || e.dataTransfer.files;
const handleFile = (isValid) => {
if (isValid) {
this.comment_form.attachment_file.file = files[0]; //* store the file to post afterwards
let title = files[0].name;
this.comment_form.attachment_file.fileName = title; //* name of the file
const fileExtension = title.substring(title.lastIndexOf('.') + 1);
if ((title.length - fileExtension.length) > 11) {
title = `${title.slice(0, 10)}[...].${fileExtension}`;
}
this.comment_form.attachment_file.title = title; //* title for display
this.comment_form.attachment_file.errors = null;
} else {
this.comment_form.attachment_file.errors =
"Transférez une image valide. Le fichier que vous avez transféré n'est pas une image, ou il est corrompu.";
}
};
if (files.length) {
//* exception for pdf
if (files[0].type === 'application/pdf') {
handleFile(true);
} else {
this.comment_form.attachment_file.errors = null;
//* check if file is an image and pass callback to handle file
this.validateImgFile(files[0], handleFile);
}
}
},
validateImgFile(files, handleFile) {
const url = window.URL || window.webkitURL;
const image = new Image();
image.onload = function () {
handleFile(true);
URL.revokeObjectURL(image.src);
};
image.onerror = function () {
handleFile(false);
URL.revokeObjectURL(image.src);
};
image.src = url.createObjectURL(files);
},
}
};
</script>
<style lang="less" scoped>
.event {
margin-bottom: 1em;
.content {
display: block;
flex: 1 1 auto;
align-self: stretch;
text-align: left;
word-wrap: break-word;
.summary {
margin: 0;
font-size: 1em;
font-weight: 700;
color: #252525;
.date {
display: inline-block;
float: none;
font-weight: 400;
font-size: .85714286em;
font-style: normal;
margin: 0 1em 0 .5em;
padding: 0;
color: rgba(0,0,0,.4);
}
}
.extra.text {
margin-left: 107px;
margin-top: 0;
}
}
}
</style>
\ No newline at end of file
<template>
<div>
<h1 class="ui header">
<div class="content">
<div class="two-block">
<div
v-if="fastEditionMode && form && canEditFeature"
class="form ui half-block"
>
<input
id="feature_detail_title_input"
:value="form.title"
type="text"
required
maxlength="128"
name="title"
@blur="updateTitle"
>
</div>
<div
v-else
class="ellipsis"
>
{{ currentFeature.properties ?
currentFeature.properties.title : currentFeature.id }}
</div>
<div
id="feature-actions"
class="ui icon compact buttons"
>
<div
v-if="queryparams"
class="fast_browsing"
>
<div>
<div>
<strong>
Tri en cours:
</strong>
<span>
par&nbsp;{{ currentSort }}
</span>
</div>
<div>
<strong>
Filtre en cours:
</strong>
<span>
{{ currentFilters }}
</span>
</div>
</div>
<span
class="feature-count"
>
{{ parseInt($route.query.offset) + 1 }} sur {{ featuresCount }}
</span>
<button
id="previous-feature"
:class="['ui button button-hover-green tiny-margin', { disabled: queryparams.previous < 0 }]"
data-tooltip="Voir le précédent signalement"
data-position="bottom center"
@click="toFeature('previous')"
>
<i
class="angle left fitted icon"
aria-hidden="true"
/>
</button>
<button
id="next-feature"
:class="[
'ui button button-hover-green tiny-margin',
{ disabled: queryparams.next >= featuresCount }
]"
data-tooltip="Voir le prochain signalement"
data-position="bottom center"
@click="toFeature('next')"
>
<i
class="angle right fitted icon"
aria-hidden="true"
/>
</button>
</div>
<div>
<button
v-if="fastEditionMode && canEditFeature"
id="save-fast-edit"
:class="['ui button button-hover-orange tiny-margin', { disabled: false }]"
data-tooltip="Enregistrer les modifications"
data-position="bottom center"
@click="fastEditFeature"
>
<i
class="save fitted icon"
aria-hidden="true"
/>
</button>
<router-link
v-if="permissions && permissions.can_create_feature
&& (featureType && !featureType.geom_type.includes('multi'))"
id="add-feature"
:to="{
name: 'ajouter-signalement',
params: {
slug_type_signal: $route.params.slug_type_signal || featureType ? featureType.slug : '',
},
}"
class="ui button button-hover-green tiny-margin"
data-tooltip="Ajouter un signalement"
data-position="bottom center"
>
<i
class="plus icon"
aria-hidden="true"
/>
</router-link>
<router-link
v-if="slugSignal && canEditFeature"
id="edit-feature"
:to="{
name: 'editer-signalement',
params: {
slug_signal: slugSignal,
slug_type_signal: $route.params.slug_type_signal || featureType ? featureType.slug : '',
},
query: $route.query
}"
class="ui button button-hover-orange tiny-margin"
data-tooltip="Éditer le signalement"
data-position="bottom center"
>
<i
class="inverted grey pencil alternate icon"
aria-hidden="true"
/>
</router-link>
<a
v-if="canDeleteFeature && isOnline"
id="currentFeature-delete"
class="ui button button-hover-red tiny-margin"
data-tooltip="Supprimer le signalement"
data-position="bottom right"
@click="$emit('setIsDeleting')"
>
<i
class="inverted grey trash alternate icon"
aria-hidden="true"
/>
</a>
</div>
</div>
</div>
<!-- <div class="ui hidden divider" /> -->
<div class="sub header prewrap">
<span
v-if="fastEditionMode && canEditFeature && form"
class="form ui half-block"
>
<textarea
:value="form.description.value"
name="description"
rows="5"
@blur="updateDescription"
/>
</span>
<span v-else-if="currentFeature && currentFeature.properties">
{{ currentFeature.properties.description }}
</span>
</div>
</div>
</h1>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
export default {
name: 'FeatureHeader',
props: {
featuresCount : {
type: Number,
default: null,
},
slugSignal: {
type: String,
default: '',
},
featureType: {
type: Object,
default: () => {},
},
fastEditionMode: {
type: Boolean,
default: false,
},
isFeatureCreator: {
type: Boolean,
default: false,
},
canEditFeature: {
type: Boolean,
default: false,
},
canDeleteFeature: {
type: Boolean,
default: false,
},
},
computed: {
...mapState([
'user',
'isOnline',
]),
...mapState('feature', [
'currentFeature',
'form',
]),
...mapState('projects', [
'project'
]),
...mapGetters([
'permissions',
]),
queryparams() {
return this.$route.query.offset >= 0 ? {
previous: parseInt(this.$route.query.offset) - 1,
next: parseInt(this.$route.query.offset) + 1
} : null;
},
currentSort() {
const sort = this.$route.query.ordering;
if (sort) {
if (sort.includes('status')) {
return 'statut';
} else if (sort.includes('feature_type')) {
return 'type de signalement';
} else if (sort.includes('title')) {
return 'nom';
} else if (sort.includes('updated_on')) {
return 'date de modification';
} else if (sort.includes('creator')) {
return 'auteur';
} else if (sort.includes('last_editor')) {
return 'dernier éditeur';
}
}
return 'date de création';
},
currentFilters() {
let filters = [];
if (this.$route.query.feature_type_slug) filters.push('type de signalement');
if (this.$route.query.status__value) filters.push('statut');
if (this.$route.query.title) filters.push('titre');
if (filters.length > 0) {
return `par ${filters.join(', ')}`;
} else {
return 'désactivé';
}
}
},
methods: {
toFeature(direction) {
this.$emit('tofeature', {
name: 'details-signalement-filtre',
params: {
slug_type_signal: this.currentFeature.properties.feature_type.slug,
},
query: {
...this.$route.query,
offset: this.queryparams[direction]
}
});
},
updateTitle(e) {
this.$store.commit('feature/UPDATE_FORM_FIELD', { name: 'title', value: e.target.value });
},
updateDescription(e) {
this.$store.commit('feature/UPDATE_FORM_FIELD', { name: 'description', value: e.target.value });
},
fastEditFeature() {
this.$emit('fastEditFeature');
}
}
};
</script>
<style lang="less">
#feature_detail_title_input {
font-weight: bold;
font-size: 2em;
padding: .25em;
}
.two-block {
display: flex;
justify-content: space-between;
margin-bottom: .5em;
}
#feature-actions {
flex-direction: column;
> div {
line-height: initial;
&:last-of-type {
text-align: right;
}
}
> .fast_browsing {
display: flex;
align-items: center;
margin-bottom: .25rem;
span {
margin-left: .1em;
}
span, div {
font-size: 1rem;
color: #666666;
margin-right: 1rem;
font-weight: normal;
opacity: 1 !important;
}
}
}
@media screen and (max-width: 700px) {
.two-block {
flex-direction: column-reverse;
}
#feature-actions.ui.buttons {
flex-direction: column;
align-items: flex-end;
}
}
</style>
\ No newline at end of file
<template>
<div>
<table
class="ui very basic table"
aria-describedby="Table des données du signalement"
>
<tbody>
<tr v-if="featureType">
<td>
<strong> Type de signalement </strong>
</td>
<td>
<FeatureTypeLink :feature-type="featureType" />
</td>
</tr>
<tr
v-for="field in featureFields"
:key="field.name"
>
<template v-if="!field.isDeactivated">
<td>
<strong :class="{ required: field.is_mandatory }">
{{ field.label }}
</strong>
</td>
<td>
<strong class="ui form">
<span
v-if="fastEditionMode && canEditFeature && extra_forms.length > 0"
:id="field.label"
>
<ExtraForm
ref="extraForm"
:field="field"
/>
</span>
<i
v-else-if="field.field_type === 'boolean'"
:class="[
'icon',
field.value ? 'olive check' : 'grey times',
]"
aria-hidden="true"
/>
<span v-else-if="field.value && field.field_type === 'multi_choices_list'">
{{ field.value.join(', ') }}
</span>
<span v-else-if="field.value && field.field_type === 'notif_group'">
{{ usersGroupLabel(field) }}
</span>
<span v-else>
{{ field.value && field.value.label ? field.value.label : field.value }}
</span>
</strong>
</td>
</template>
</tr>
<tr>
<td>
Auteur
</td>
<td v-if="currentFeature.properties">
{{ currentFeature.properties.display_creator }}
</td>
</tr>
<tr>
<td>
Statut
</td>
<td>
<i
v-if="currentFeature.properties && currentFeature.properties.status"
:class="['icon', statusIcon]"
aria-hidden="true"
/>
<FeatureEditStatusField
v-if="fastEditionMode && canEditFeature && form"
:status="form.status.value.value || form.status.value"
class="inline"
/>
<span v-else>
{{ statusLabel }}
</span>
</td>
</tr>
<tr v-if="project && project.feature_assignement">
<td>
Membre assigné
</td>
<td>
<ProjectMemberSelect
:selected-user-id="assignedMemberId"
:disabled="!fastEditionMode || !canEditFeature"
class="inline"
@update:user="$store.commit('feature/UPDATE_FORM_FIELD', { name: 'assigned_member', value: $event })"
/>
</td>
</tr>
<tr>
<td>
Date de création
</td>
<td v-if="currentFeature.properties && currentFeature.properties.created_on">
{{ currentFeature.properties.created_on | formatDate }}
</td>
</tr>
<tr>
<td>
Date de dernière modification
</td>
<td v-if="currentFeature.properties && currentFeature.properties.updated_on">
{{ currentFeature.properties.updated_on | formatDate }}
</td>
</tr>
</tbody>
</table>
<h3>Liaison entre signalements</h3>
<table
class="ui very basic table"
aria-describedby="Table des signalements lié à ce signalement"
>
<tbody>
<tr
v-for="(link, index) in linked_features"
:key="link.feature_to.title + index"
>
<th
v-if="link.feature_to.feature_type_slug"
scope="row"
>
{{ link.relation_type_display }}
</th>
<td
v-if="link.feature_to.feature_type_slug"
>
<FeatureFetchOffsetRoute
:feature-id="link.feature_to.feature_id"
:properties="{
title: link.feature_to.title,
feature_type: { slug: link.feature_to.feature_type_slug }
}"
/>
({{ link.feature_to.display_creator }} -
{{ link.feature_to.created_on }})
</td>
</tr>
<tr v-if="linked_features.length === 0">
<td>
<em>
Aucune liaison associée au signalement.
</em>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import FeatureTypeLink from '@/components/FeatureType/FeatureTypeLink';
import FeatureEditStatusField from '@/components/Feature/FeatureEditStatusField';
import ExtraForm from '@/components/ExtraForm';
import FeatureFetchOffsetRoute from '@/components/Feature/FeatureFetchOffsetRoute';
import ProjectMemberSelect from '@/components/ProjectMemberSelect';
import { statusChoices, formatStringDate, checkDeactivatedValues } from '@/utils';
export default {
name: 'FeatureTable',
filters: {
formatDate(value) {
return formatStringDate(value);
},
},
components: {
FeatureTypeLink,
FeatureEditStatusField,
ExtraForm,
FeatureFetchOffsetRoute,
ProjectMemberSelect,
},
props: {
featureType: {
type: Object,
default: () => {},
},
fastEditionMode: {
type: Boolean,
default: false,
},
canEditFeature: {
type: Boolean,
default: false,
},
},
computed: {
...mapState('projects', [
'project'
]),
...mapState('feature', [
'currentFeature',
'linked_features',
'form',
'extra_forms',
]),
...mapGetters(['usersGroupsFeatureOptions']),
statusIcon() {
switch (this.currentFeature.properties.status.value || this.currentFeature.properties.status) {
case 'archived':
return 'grey archive';
case 'pending':
return 'teal hourglass outline';
case 'published':
return 'olive check';
case 'draft':
return 'orange pencil alternate';
default:
return '';
}
},
statusLabel() {
if (this.currentFeature.properties) {
if (this.currentFeature.properties && this.currentFeature.properties.status.label) {
return this.currentFeature.properties.status.label;
}
const status = statusChoices.find(
(el) => el.value === this.currentFeature.properties.status
);
return status ? status.name : '';
}
return '';
},
featureData() {
if (this.currentFeature.properties && this.featureType) {
// retrieve value for each feature type custom field within feature data
const extraFieldsWithValue = this.featureType.customfield_set.map((xtraField) => {
return {
...xtraField,
value: this.currentFeature.properties[xtraField.name]
};
});
// filter out fields not meeting condition to be activated
return checkDeactivatedValues(extraFieldsWithValue);
}
return [];
},
featureFields() {
return this.fastEditionMode ? this.extra_forms : this.featureData;
},
assignedMemberId() {
if (this.form && this.form.assigned_member) {
return this.form.assigned_member.value;
}
return this.currentFeature.properties.assigned_member;
},
},
methods: {
usersGroupLabel(field) {
const usersGroup = this.usersGroupsFeatureOptions.find((group) => group.value === field.value);
return usersGroup ? usersGroup.name : field.value;
}
}
};
</script>
<style lang="less" scoped>
td {
strong.required:after {
margin: -0.2em 0em 0em 0.2em;
content: '*';
color: #ee2e24;
}
}
</style>
<template>
<div>
<div class="ui teal segment">
<h4>
Pièce jointe
<button
@click="removeAttachmentFormset(form.dataKey)"
class="ui small compact right floated icon button remove-formset"
type="button"
>
<i class="ui times icon"></i>
</button>
</h4>
<!-- {{ form.errors }} -->
<div class="visible-fields">
<div class="two fields">
<div class="required field">
<label :for="form.title.id_for_label">{{ form.title.label }}</label>
<input
type="text"
required
:maxlength="form.title.field.max_length"
:name="form.title.html_name"
:id="form.title.id_for_label"
v-model="form.title.value"
/>
<ul :id="form.title.id_for_error" class="errorlist">
<li v-for="error in form.title.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label>Fichier (PDF, PNG, JPEG)</label>
<label
class="ui icon button"
:for="'attachment_file' + attachmentForm.dataKey"
<section class="ui teal segment">
<h4>
Pièce jointe
<button
class="ui small compact right floated icon button remove-formset"
type="button"
@click="removeAttachmentForm(form.dataKey)"
>
<i
class="ui times icon"
aria-hidden="true"
/>
</button>
</h4>
<div class="visible-fields">
<div class="two fields">
<div class="required field">
<label :for="form.title.id_for_label">{{ form.title.label }}</label>
<input
:id="form.title.id_for_label"
v-model="form.title.value"
type="text"
required
:maxlength="form.title.field.max_length"
:name="form.title.html_name"
>
<ul
:id="form.title.id_for_error"
class="errorlist"
>
<li
v-for="error in form.title.errors"
:key="error"
>
<i class="file icon"></i>
<span v-if="form.attachment_file.value" class="label">{{
form.attachment_file.value
}}</span>
<span v-else class="label">Sélectionner un fichier ... </span>
</label>
<input
@change="onFileChange"
type="file"
accept="application/pdf, image/jpeg, image/png"
style="display: none"
:name="form.attachment_file.html_name"
:id="'attachment_file' + attachmentForm.dataKey"
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label>Fichier (PDF, PNG, JPEG)</label>
<label
class="ui icon button"
:for="'attachment_file' + attachmentForm.dataKey"
>
<i
class="file icon"
aria-hidden="true"
/>
<ul :id="form.attachment_file.id_for_error" class="errorlist">
<li v-for="error in form.attachment_file.errors" :key="error">
{{ error }}
</li>
</ul>
</div>
<span
v-if="form.attachment_file.value"
class="label"
>{{
form.attachment_file.value
}}</span>
<span
v-else
class="label"
>Sélectionner un fichier ... </span>
</label>
<input
:id="'attachment_file' + attachmentForm.dataKey"
type="file"
accept="application/pdf, image/jpeg, image/png"
style="display: none"
:name="form.attachment_file.html_name"
@change="onFileChange"
>
<ul
:id="form.attachment_file.id_for_error"
class="errorlist"
>
<li
v-for="error in form.attachment_file.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div class="field">
<label for="form.info.id_for_label">{{ form.info.label }}</label>
<textarea
name="form.info.html_name"
rows="5"
v-model="form.info.value"
></textarea>
<!-- {{ form.info.errors }} -->
</div>
<div class="field">
<label for="form.info.id_for_label">{{ form.info.label }}</label>
<textarea
v-model="form.info.value"
name="form.info.html_name"
rows="5"
/>
</div>
<div
v-if="enableKeyDocNotif"
class="field"
>
<div class="ui checkbox">
<input
:id="'is_key_document-' + attachmentForm.dataKey"
v-model="form.is_key_document.value"
:name="'is_key_document-' + attachmentForm.dataKey"
class="hidden"
type="checkbox"
@change="updateStore"
>
<label :for="'is_key_document-' + attachmentForm.dataKey">
Envoyer une notification de publication aux abonnés du projet
</label>
</div>
</div>
</div>
</div>
</section>
</template>
<script>
export default {
name: "FeatureAttachmentFormset",
name: 'FeatureAttachmentForm',
props: ["attachmentForm"],
props: {
attachmentForm: {
type: Object,
default: null,
},
enableKeyDocNotif: {
type: Boolean,
default: false,
}
},
data() {
return {
fileToImport: null,
form: {
id: {
value: "",
value: '',
errors: null,
label: "ID",
label: 'ID',
},
title: {
errors: [],
id_for_error: `errorlist-title-${this.attachmentForm.dataKey}`,
id_for_label: "titre",
id_for_label: 'titre',
field: {
max_length: 30, // todo : vérifier dans django
},
html_name: "titre",
label: "Titre",
value: "",
html_name: 'titre',
label: 'Titre',
value: '',
},
attachment_file: {
errors: [],
id_for_error: `errorlist-file-${this.attachmentForm.dataKey}`,
html_name: "titre",
label: "Titre",
html_name: 'titre',
label: 'Titre',
value: null,
},
info: {
value: "",
value: '',
errors: null,
label: "Info",
label: 'Info',
},
is_key_document: {
value: false,
},
},
};
......@@ -118,15 +169,15 @@ export default {
this.initForm(newValue);
},
//* utilisation de watcher, car @change aurait un délai
"form.title.value": function (newValue, oldValue) {
if (oldValue !== "") {
'form.title.value': function (newValue, oldValue) {
if (oldValue !== '') {
if (newValue !== oldValue) {
this.updateStore();
}
}
},
"form.info.value": function (newValue, oldValue) {
if (oldValue !== "") {
'form.info.value': function (newValue, oldValue) {
if (oldValue !== '') {
if (newValue !== oldValue) {
this.updateStore();
}
......@@ -134,12 +185,16 @@ export default {
},
},
mounted() {
this.initForm(this.attachmentForm);
},
methods: {
initForm(attachmentForm) {
for (let el in attachmentForm) {
for (const el in attachmentForm) {
if (el && this.form[el]) {
if (el === "attachment_file" && attachmentForm[el]) {
this.form[el].value = attachmentForm[el].split("/").pop(); //* keep only the file name, not the path
if (el === 'attachment_file' && attachmentForm[el]) {
this.form[el].value = attachmentForm[el].split('/').pop(); //* keep only the file name, not the path
} else {
this.form[el].value = attachmentForm[el];
}
......@@ -147,13 +202,14 @@ export default {
}
},
removeAttachmentFormset() {
removeAttachmentForm() {
this.$store.commit(
"feature/REMOVE_ATTACHMENT_FORM",
'feature/REMOVE_ATTACHMENT_FORM',
this.attachmentForm.dataKey
);
if (this.form.id.value)
this.$store.commit("feature/ADD_ATTACHMENT_TO_DELETE", this.form.id.value);
if (this.form.id.value) {
this.$store.commit('feature/ADD_ATTACHMENT_TO_DELETE', this.form.id.value);
}
},
updateStore() {
......@@ -164,13 +220,14 @@ export default {
attachment_file: this.form.attachment_file.value,
info: this.form.info.value,
fileToImport: this.fileToImport,
is_key_document: this.form.is_key_document.value
};
this.$store.commit("feature/UPDATE_ATTACHMENT_FORM", data);
this.$store.commit('feature/UPDATE_ATTACHMENT_FORM', data);
},
validateImgFile(files, handleFile) {
let url = window.URL || window.webkitURL;
let image = new Image();
const url = window.URL || window.webkitURL;
const image = new Image();
image.onload = function () {
handleFile(true);
URL.revokeObjectURL(image.src);
......@@ -202,7 +259,7 @@ export default {
if (files.length) {
//* exception for pdf
if (files[0].type === "application/pdf") {
if (files[0].type === 'application/pdf') {
handleFile(true);
} else {
this.form.attachment_file.errors = [];
......@@ -214,18 +271,18 @@ export default {
checkForm() {
let isValid = true;
if (this.form.title.value === "") {
this.form.title.errors = ["Veuillez compléter ce champ."];
if (this.form.title.value === '') {
this.form.title.errors = ['Veuillez compléter ce champ.'];
document
.getElementById(this.form.title.id_for_error)
.scrollIntoView({ block: "start", inline: "nearest" });
.scrollIntoView({ block: 'start', inline: 'nearest' });
isValid = false;
} else if (this.form.attachment_file.value === null) {
this.form.attachment_file.errors = ["Veuillez compléter ce champ."];
this.form.attachment_file.errors = ['Veuillez compléter ce champ.'];
this.form.title.errors = [];
document
.getElementById(this.form.attachment_file.id_for_error)
.scrollIntoView({ block: "start", inline: "nearest" });
.scrollIntoView({ block: 'start', inline: 'nearest' });
isValid = false;
} else {
this.form.title.errors = [];
......@@ -234,9 +291,5 @@ export default {
return isValid;
},
},
mounted() {
this.initForm(this.attachmentForm);
},
};
</script>
\ No newline at end of file
</script>
<template>
<div class="ui mini modal">
<i class="close icon"></i>
<i
class="close icon"
aria-hidden="true"
/>
<div class="content">
<h3>Importer une image géoréférencée</h3>
<form id="form-geo-image" class="ui form" enctype="multipart/form-data">
<form
id="form-geo-image"
class="ui form"
enctype="multipart/form-data"
>
{% csrf_token %}
<p>
Attention, si vous avez déjà saisi une géométrie, celle issue de
......@@ -13,19 +20,28 @@
<div class="field">
<label>Image (png ou jpeg)</label>
<label class="ui icon button" for="image_file">
<i class="file icon"></i>
<label
class="ui icon button"
for="image_file"
>
<i
class="file icon"
aria-hidden="true"
/>
<span class="label">Sélectionner une image ...</span>
</label>
<input
id="image_file"
type="file"
accept="image/jpeg, image/png"
style="display: none"
name="image_file"
class="image_file"
id="image_file"
>
<p
class="error-message"
style="color: red"
/>
<p class="error-message" style="color: red"></p>
</div>
<button
......@@ -34,7 +50,10 @@
class="ui positive right labeled icon button"
>
Importer
<i class="checkmark icon"></i>
<i
class="checkmark icon"
aria-hidden="true"
/>
</button>
</form>
</div>
......@@ -43,6 +62,6 @@
<script>
export default {
name: "Feature_edit_modal"
}
</script>
\ No newline at end of file
name: 'FeatureEditModal'
};
</script>
<template>
<div
id="status"
class="field"
>
<Dropdown
v-if="selectedStatus"
:options="allowedStatusChoices"
:selected="selectedStatus.name"
:selection.sync="selectedStatus"
/>
</div>
</template>
<script>
import Dropdown from '@/components/Dropdown.vue';
import { statusChoices, allowedStatus2change } from '@/utils';
import { mapState } from 'vuex';
export default {
name: 'FeatureEditStatusField',
components: {
Dropdown,
},
props: {
status: {
type: String,
default: '',
},
},
computed: {
...mapState([
'user',
'USER_LEVEL_PROJECTS',
]),
...mapState('projects', [
'project'
]),
...mapState('feature', [
'currentFeature'
]),
statusObject() {
return statusChoices.find((key) => key.value === this.status);
},
selectedStatus: {
get() {
return this.statusObject;
},
set(newValue) {
this.$store.commit('feature/UPDATE_FORM_FIELD', { name: 'status', value: newValue.value });
},
},
isFeatureCreator() {
if (this.currentFeature && this.currentFeature.properties && this.user) {
return this.currentFeature.properties.creator === this.user.id ||
this.currentFeature.properties.creator.username === this.user.username;
}
return false;
},
allowedStatusChoices() {
if (this.project && this.currentFeature && this.user) {
const isModerate = this.project.moderation;
const userStatus = this.USER_LEVEL_PROJECTS && this.USER_LEVEL_PROJECTS[this.project.slug];
return allowedStatus2change(this.user, isModerate, userStatus, this.isFeatureCreator);
}
return [];
},
},
};
</script>
\ No newline at end of file
<template>
<router-link
:is="query && Number.isInteger(query.offset) ? 'router-link' : 'span'"
:to="{
name: 'details-signalement-filtre',
params: { slug },
query,
}"
>
{{ properties.title || featureId }}
</router-link>
</template>
<script>
import { mapState } from 'vuex';
import axios from '@/axios-client.js';
import projectAPI from '@/services/project-api';
export default {
name: 'FeatureFetchOffsetRoute',
props: {
featureId: {
type: String,
default: '',
},
properties: {
type: Object,
default: () => {},
},
},
data() {
return {
position: null,
slug: this.$route.params.slug || this.properties.project_slug,
ordering: null,
filter: null,
};
},
computed: {
...mapState('projects', [
'project'
]),
query() {
if (this.ordering) {
const searchParams = { ordering: this.ordering };
if (this.filter === 'feature_type_slug') { // when feature_type is the default filter of the project,
searchParams['feature_type_slug'] = this.properties.feature_type.slug; // get its slug for the current feature
}
if (Number.isInteger(this.position)) {
searchParams['offset'] = this.position; // get its slug for the current feature
}
return searchParams;
}
return null;
},
},
watch: {
featureId() {
this.initData();
}
},
created() {
this.initData();
},
methods: {
async initData() {
if (this.project) {
this.setProjectParams(this.project);
} else {
await this.getProjectFilterAndSort();
}
this.getFeaturePosition(this.featureId)
.then((position) => {
if (Number.isInteger(position)) {
this.position = position;
}
})
.catch((error) => {
console.error(error);
});
},
setProjectParams(project) {
this.ordering = project.feature_browsing_default_sort;
if (project.feature_browsing_default_filter === 'feature_type_slug') { // when feature_type is the default filter of the project,
this.filter = this.properties.feature_type.slug;
}
},
async getFeaturePosition(featureId) {
const searchParams = new URLSearchParams(this.query);
const response = await axios.get(`${this.$store.state.configuration.VUE_APP_DJANGO_API_BASE}projects/${this.slug}/feature/${featureId}/position-in-list/?${searchParams.toString()}`);
if (response && response.status === 200) {
return response.data;
}
return null;
},
async getProjectFilterAndSort() {
const project = await projectAPI.getProject(this.$store.state.configuration.VUE_APP_DJANGO_API_BASE, this.slug);
if (project) this.setProjectParams(project);
}
}
};
</script>
......@@ -3,20 +3,31 @@
<h4>
Liaison
<button
@click="remove_linked_formset"
class="ui small compact right floated icon button remove-formset"
type="button"
@click="remove_linked_formset"
>
<i class="ui times icon"></i>
<i
class="ui times icon"
aria-hidden="true"
/>
</button>
</h4>
<ul id="errorlist-links" class="errorlist">
<li v-for="error in form.errors" :key="error" v-html="error"></li>
<ul
id="errorlist-links"
class="errorlist"
>
<li
v-for="error in form.errors"
:key="error"
>
{{ error }}
</li>
</ul>
<div class="visible-fields">
<div class="two fields">
<div class="required field">
<label for="form.relation_type.id_for_label">{{
<label :for="form.relation_type.id_for_label">{{
form.relation_type.label
}}</label>
<Dropdown
......@@ -27,10 +38,11 @@
{{ form.relation_type.errors }}
</div>
<div class="required field">
<label for="form.feature_to.id_for_label">{{
<label :for="form.feature_to.id_for_label">{{
form.feature_to.label
}}</label>
<SearchFeature
:current-selection="linkedForm"
@select="selectFeatureTo"
@close="selectFeatureTo"
/>
......@@ -42,58 +54,59 @@
</template>
<script>
import Dropdown from "@/components/Dropdown.vue";
import Dropdown from '@/components/Dropdown.vue';
import SearchFeature from '@/components/SearchFeature.vue';
export default {
name: "FeatureLinkedForm",
props: ["linkedForm", "features"],
name: 'FeatureLinkedForm',
components: {
Dropdown,
SearchFeature
},
props: {
linkedForm: {
type: Object,
default: null,
}
},
data() {
return {
form: {
errors: null,
relation_type: {
errors: null,
id_for_label: "relation_type",
html_name: "relation_type",
label: "Type de liaison",
id_for_label: 'relation_type',
html_name: 'relation_type',
label: 'Type de liaison',
value: {
name: "Doublon",
value: "doublon",
name: 'Doublon',
value: 'doublon',
},
},
feature_to: {
errors: null,
id_for_label: "feature_to",
id_for_label: 'feature_to',
field: {
max_length: 30,
},
html_name: "feature_to",
label: "Signalement lié",
value: {
name: "",
value: "",
},
html_name: 'feature_to',
label: 'Signalement lié',
value: '',
},
},
relationTypeChoices: [
{ name: "Doublon", value: "doublon" },
{ name: "Remplace", value: "remplace" },
{ name: "Est remplacé par", value: "est_remplace_par" },
{ name: "Dépend de", value: "depend_de" },
{ name: 'Doublon', value: 'doublon' },
{ name: 'Remplace', value: 'remplace' },
{ name: 'Est remplacé par', value: 'est_remplace_par' },
{ name: 'Dépend de', value: 'depend_de' },
],
};
},
computed: {
selected_relation_type: {
// getter
get() {
......@@ -107,68 +120,51 @@ export default {
}
},
methods: {
formatDate(value) {
let date = new Date(value);
date = date.toLocaleString().replace(",", "");
return date.substr(0, date.length - 3); //* quick & dirty way to remove seconds from date
},
mounted() {
if (this.linkedForm.relation_type) {
this.form.relation_type.value.name = this.linkedForm.relation_type_display;
this.form.relation_type.value.value = this.linkedForm.relation_type;
}
if (this.linkedForm.feature_to) {
this.form.feature_to.value = this.linkedForm.feature_to.feature_id;
}
},
methods: {
remove_linked_formset() {
this.$store.commit("feature/REMOVE_LINKED_FORM", this.linkedForm.dataKey);
this.$store.commit('feature/REMOVE_LINKED_FORM', this.linkedForm.dataKey);
},
selectFeatureTo(e) {
this.form.feature_to.value = e;
selectFeatureTo(featureId) {
if (featureId) {
this.form.feature_to.value = featureId;
this.updateStore();
}
},
updateStore() {
this.$store.commit("feature/UPDATE_LINKED_FORM", {
this.$store.commit('feature/UPDATE_LINKED_FORM', {
dataKey: this.linkedForm.dataKey,
relation_type: this.form.relation_type.value.value,
feature_to: {
feature_id: this.form.feature_to.value.value,
feature_id: this.form.feature_to.value,
},
});
},
checkForm() {
if (this.form.feature_to.value === "") {
if (this.form.feature_to.value === '') {
this.form.errors = [
"<strong>Choisir un signalement lié</strong><br/> Pourriez-vous choisir un signalement pour la nouvelle liaison ?",
'Veuillez choisir un signalement pour la nouvelle liaison.',
];
document
.getElementById("errorlist-links")
.scrollIntoView({ block: "start", inline: "nearest" });
.getElementById('errorlist-links')
.scrollIntoView({ block: 'start', inline: 'nearest' });
return false;
}
this.form.errors = [];
return true;
},
getExistingFeature_to(featureOptions) {
const feature_to = featureOptions.find(
(el) => el.value === this.linkedForm.feature_to.feature_id
);
if (feature_to) {
this.form.feature_to.value = feature_to;
}
},
getExistingRelation_type() {
const relation_type = this.relationTypeChoices.find(
(el) => el.value === this.linkedForm.relation_type
);
if (relation_type) {
this.form.relation_type.value = relation_type;
}
},
},
mounted() {
if (this.linkedForm.relation_type) {
this.getExistingRelation_type();
}
},
};
</script>
\ No newline at end of file
</script>
<template>
<div class="condition-row">
<div>
<span>Si&nbsp;:</span>
</div>
<div class="field required">
<label for="conditioning-field">
Champ conditionnel
</label>
<div>
<Dropdown
:options="customFormOptions"
:selected="conditionField"
:selection.sync="conditionField"
:search="true"
:clearable="true"
name="conditioning-field"
/>
</div>
</div>
<div>
<span>=</span>
</div>
<div class="field required">
<label for="conditioning-value">
Valeur conditionnelle
</label>
<div>
<ExtraForm
v-if="conditioningCustForm"
:id="conditioningCustForm.label"
ref="extraForm"
class="full-width"
name="conditioning-value"
:field="{...conditioningCustForm, value: config.conditionValue}"
:use-value-only="true"
@update:value="updateConditionValue($event)"
/>
</div>
</div>
<div>
<span>
Alors
<span v-if="isForcedValue">&nbsp;:</span>
<span v-else>&nbsp;le champ est activé</span>
</span>
</div>
<div
v-if="isForcedValue"
class="field required"
>
<label for="forced-value">
Valeur forcée
</label>
<div>
<ExtraForm
:id="`forced-value-for-${customForm.name}`"
ref="extraForm"
class="full-width"
name="forced-value"
:field="{...customForm, value: config.forcedValue}"
:use-value-only="true"
@update:value="updateForcedValue($event)"
/>
</div>
</div>
</div>
</template>
<script>
import Dropdown from '@/components/Dropdown.vue';
import ExtraForm from '@/components/ExtraForm';
export default {
name: 'CustomFormConditionalField',
components: {
Dropdown,
ExtraForm,
},
props: {
config: {
type: Object,
default: () => {}
},
form: {
type: Object,
default: () => {}
},
storedCustomForm: {
type: Object,
default: () => {}
},
customForms: {
type: Array,
default: () => []
},
isForcedValue: {
type: Boolean,
default: false
},
},
data() {
return {
customFormOptions: [],
unsubscribe: null,
};
},
computed: {
customForm () {
// if the customForm has been updated in the store return it
if (this.storedCustomForm && this.storedCustomForm.name) return this.storedCustomForm;
// else if the custom for is not yet in store, build the same structure as a stored one to pass it to ExtraForm component
let customFormInCreation = {};
for (const el in this.form) {
customFormInCreation[el] = this.form[el].value;
}
return customFormInCreation;
},
conditioningCustForm() {
return this.customForms.find((custForm) => custForm.name === this.config.conditionField) || null;
},
isListField() {
return ['list', 'pre_recorded_list', 'multi_choices_list'].includes(this.conditioningCustForm.field_type);
},
conditionField: {
get() {
return this.conditioningCustForm ? this.formatOptionLabel(this.conditioningCustForm) : '';
},
set(newValue) {
//* set the value selected in the dropdown
const newConfig = newValue ? { conditionField: newValue.value } : {};
if (this.config.dataKey) { // forced value being a list, a consistent identifier is needed by Vue to track component thus we use a dataKey
newConfig['dataKey'] = this.config.dataKey; // and this dataKey need to be kept across modification
}
if (this.config.forcedValue !== undefined) {
// forced value depend on customForm type and won't change here, since it is set when adding the condition, we neet to keep it
newConfig['forcedValue'] = this.config.forcedValue;
}
this.$emit('update:config', newConfig);
},
},
},
mounted() { // listening to store mutation, since changes of property nested in customForms are not detected
this.updateCustomFormOptions();
this.unsubscribe = this.$store.subscribe(({ type }) => {
if (type === 'feature-type/UPDATE_CUSTOM_FORM') {
this.updateCustomFormOptions();
}
});
},
beforeDestroy() {
this.unsubscribe();
},
methods: {
formatOptionLabel: (custForm) => `${custForm.label} (${custForm.name})`,
updateCustomFormOptions() {
this.customFormOptions = this.customForms
.filter((custForm) => (
custForm.label && custForm.name && // check that customForm has defined properties
custForm.name !== this.customForm.name && // filter out this customForm itself
custForm.field_type !== 'char' && custForm.field_type !== 'text'
))
.map((custForm) => {
return {
name: this.formatOptionLabel(custForm),
value: custForm.name,
};
});
},
updateConditionValue(conditionValue) {
this.$emit('update:config', { ...this.config, conditionValue });
},
updateForcedValue(forcedValue) {
this.$emit('update:config', { ...this.config, forcedValue });
}
}
};
</script>
<style scoped>
.condition-row {
display: flex;
}
.condition-row > div {
margin-right: .75em !important;
}
.condition-row > div.field > div {
display: flex;
align-items: center;
min-height: 2.8em;
}
.condition-row > div:not(.field) {
transform: translateY(2.5em);
}
</style>
\ No newline at end of file
<template>
<div
:id="custFormId"
:class="['ui teal segment pers-field', { hasErrors }]"
>
<div class="custom-field-header">
<h4>
Champ personnalisé
</h4>
<div class="top-right">
<div
v-if="(form.label.value || form.name.value) && selectedFieldType !== 'Booléen'"
class="ui checkbox"
>
<input
v-model="form.is_mandatory.value"
type="checkbox"
:name="form.html_name"
@change="updateStore"
>
<label :for="form.html_name">Champ obligatoire
<span v-if="form.conditional_field_config.value">si actif</span>
</label>
</div>
<button
class="ui small compact right floated icon button remove-field"
type="button"
@click="removeCustomForm()"
>
<i
class="ui times icon"
aria-hidden="true"
/>
</button>
</div>
</div>
<div class="visible-fields">
<div class="two fields">
<div class="required field">
<label :for="form.label.id_for_label">{{ form.label.label }}</label>
<input
:id="form.label.id_for_label"
v-model="form.label.value"
type="text"
required
:maxlength="form.label.field.max_length"
:name="form.label.html_name"
@input="updateStore"
>
<small>{{ form.label.help_text }}</small>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.label.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div class="required field">
<label :for="form.name.id_for_label">{{ form.name.label }}</label>
<input
:id="form.name.id_for_label"
v-model="form.name.value"
type="text"
required
:maxlength="form.name.field.max_length"
:name="form.name.html_name"
@input="updateStore"
>
<small>{{ form.name.help_text }}</small>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.name.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
</div>
<div class="three fields">
<div class="required field">
<label :for="form.position.id_for_label">{{
form.position.label
}}</label>
<div class="ui input">
<input
:id="form.position.id_for_label"
v-model="form.position.value"
type="number"
:min="form.position.field.min_value"
:name="form.position.html_name"
@change="updateStore"
>
</div>
<small>{{ form.position.help_text }}</small>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.position.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div
id="field_type"
class="required field"
>
<label :for="form.field_type.id_for_label">{{
form.field_type.label
}}</label>
<Dropdown
:disabled="!form.label.value || !form.name.value"
:options="customFieldTypeChoices"
:selected="selectedFieldType"
:selection.sync="selectedFieldType"
/>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.field_type.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div
v-if="selectedFieldType === 'Liste de valeurs pré-enregistrées'"
class="field required"
data-test="prerecorded-list-option"
>
<label>{{
form.options.label
}}</label>
<Dropdown
:options="preRecordedLists"
:selected="arrayOption"
:selection.sync="arrayOption"
/>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.options.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
</div>
<div
v-if="selectedFieldType === 'Liste de valeurs' || selectedFieldType === 'Liste à choix multiples'"
class="field field-list-options required field"
>
<label :for="form.options.id_for_label">{{
form.options.label
}}</label>
<div>
<div :id="`list-options-${customForm.dataKey}`">
<div
v-for="(option, index) in form.options.value"
:id="option"
:key="`${option}-${index}`"
class="draggable-row"
>
<i
class="th icon grey"
aria-hidden="true"
/>
<input
:value="option"
type="text"
:maxlength="form.options.field.max_length"
:name="form.options.html_name"
class="options-field"
@change="updateOptionValue(index, $event)"
>
<i
class="trash icon grey"
@click="deleteOption(index)"
/>
</div>
</div>
<div class="ui buttons">
<a
class="ui compact small icon left floated button teal basic"
@click="addOption"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
<span>&nbsp;Ajouter une option</span>
</a>
</div>
</div>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.options.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
<div class="conditional-blocks">
<div class="ui checkbox">
<input
type="checkbox"
name="conditional-custom-field"
:checked="form.conditional_field_config.value"
@change="setConditionalCustomForm"
>
<label
class="pointer"
for="conditional-custom-field"
@click="setConditionalCustomForm"
>
Activation conditionnelle
</label>
</div>
<div
v-if="form.conditional_field_config.value !== null && form.conditional_field_config.value !== undefined"
id="condition-field"
>
<h5>Condition d'apparition&nbsp;:</h5>
<CustomFormConditionalField
:custom-form="customForm"
:custom-forms="customForms"
:config="form.conditional_field_config.value"
@update:config="setConditionalFieldConfig($event)"
/>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.conditional_field_config.errors"
:key="error"
>
{{ error }}
</li>
</ul>
</div>
</div>
<div class="conditional-blocks">
<div
v-for="(config, index) in form.forced_value_config.value"
:id="`forced-value-${index}`"
:key="`forced-value-${config.dataKey}`"
>
<h5>Condition à valeur forcée&nbsp;{{ index + 1 }}&nbsp;:</h5>
<div class="inline">
<CustomFormConditionalField
:form="form"
:stored-custom-form="customForm"
:custom-forms="customForms"
:config="config"
:is-forced-value="true"
@update:config="setForcedValue($event)"
/>
<i
class="trash icon grey"
@click="deleteForcedValue(config.dataKey)"
/>
</div>
</div>
<ul
id="errorlist"
class="errorlist"
>
<li
v-for="error in form.forced_value_config.errors"
:key="error"
>
{{ error }}
</li>
</ul>
<button
id="add-forced-value"
class="ui compact basic button"
@click.prevent="addForcedValue"
>
<i
class="ui plus icon"
aria-hidden="true"
/>
Ajouter une valeur forcée selon la valeur d'un autre champ
</button>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import Sortable from 'sortablejs';
import { customFieldTypeChoices, reservedKeywords } from '@/utils';
import Dropdown from '@/components/Dropdown.vue';
import CustomFormConditionalField from '@/components/FeatureType/CustomFormConditionalField.vue';
export default {
name: 'FeatureTypeCustomForm',
components: {
Dropdown,
CustomFormConditionalField,
},
props: {
customForm: {
type: Object,
default: null,
},
selectedColorStyle: {
type: String,
default: null,
},
},
data() {
return {
customFieldTypeChoices,
form: {
is_mandatory: {
value: false,
html_name: 'mandatory-custom-field',
},
label: {
errors: [],
id_for_label: 'label',
label: 'Label',
help_text: 'Nom en language naturel du champ',
html_name: 'label',
field: {
max_length: 256,
},
value: null,
},
name: {
errors: [],
id_for_label: 'name',
label: 'Nom',
html_name: 'name',
help_text:
"Nom technique du champ tel qu'il apparaît dans la base de données ou dans l'export GeoJSON. Seuls les caractères alphanumériques et les traits d'union sont autorisés: a-z, A-Z, 0-9, _ et -)",
field: {
max_length: 128,
},
value: null,
},
position: {
errors: [],
id_for_label: 'position',
label: 'Position',
min_value: 0, // ! check if good values (not found)
html_name: 'position',
help_text:
"Numéro d'ordre du champ dans le formulaire de saisie du signalement",
field: {
max_length: 128, // ! check if good values (not found)
},
value: this.customForm.dataKey - 1,
},
field_type: {
errors: [],
id_for_label: 'field_type',
label: 'Type de champ',
html_name: 'field_type',
help_text: '',
field: {
max_length: 50,
},
value: 'boolean',
},
options: {
errors: [],
id_for_label: 'options',
label: 'Options',
html_name: 'options',
help_text: 'Valeurs possibles de ce champ, séparées par des virgules',
field: {
max_length: null,
},
value: [],
},
conditional_field_config: {
errors: [],
value: null,
},
forced_value_config: {
errors: [],
value: [],
}
},
hasErrors: false,
selectedPrerecordedList: null,
sortable: null
};
},
computed: {
...mapState('feature-type', [
'customForms',
'preRecordedLists'
]),
custFormId() {
return `custom_form-${this.form.position.value}`;
},
selectedFieldType: {
// getter
get() {
const currentFieldType = customFieldTypeChoices.find(
(el) => el.value === this.form.field_type.value
);
if (currentFieldType) {
this.$nextTick(() => { // to be executed after the fieldType is returned and the html generated
if (this.selectedFieldType === 'Liste de valeurs' || this.selectedFieldType === 'Liste à choix multiples') {
this.initSortable();
}
});
return currentFieldType.name;
}
return null;
},
// setter
set(newValue) {
if (newValue.value === 'pre_recorded_list') {
this.GET_PRERECORDED_LISTS();
}
this.form.field_type.value = newValue.value;
this.form = { ...this.form }; // ! quick & dirty fix for getter not updating because of Vue caveat https://vuejs.org/v2/guide/reactivity.html#For-Objects
// Vue.set(this.form.field_type, "value", newValue.value); // ? vue.set didn't work, maybe should flatten form ?
this.updateStore();
},
},
arrayOption: {
get() {
return this.form.options.value.join();
},
// * create an array, because backend expects an array
set(newValue) {
this.form.options.value = this.trimWhiteSpace(newValue).split(',');
if (!this.hasDuplicateOptions()) {
this.updateStore();
}
},
},
forcedValMaxDataKey() { // get the highest datakey value in forced value configs...
return this.form.forced_value_config.value.reduce( // ...used to increment new config datakey
(maxDataKey, currentConfig) => (maxDataKey > currentConfig.dataKey) ?
maxDataKey : currentConfig.dataKey, 1
);
},
},
mounted() {
//* add datas from store to state to avoid mutating directly store with v-model (not good practice), could have used computed with getter and setter as well
this.fillCustomFormData(this.customForm);
if (this.customForm.field_type === 'pre_recorded_list') {
this.GET_PRERECORDED_LISTS();
}
},
methods: {
...mapActions('feature-type', [
'GET_PRERECORDED_LISTS'
]),
hasDuplicateOptions() {
this.form.options.errors = [];
const isDup =
new Set(this.form.options.value).size !==
this.form.options.value.length;
if (isDup) {
this.form.options.errors = ['Veuillez saisir des valeurs différentes'];
return true;
}
return false;
},
fillCustomFormData(customFormData) {
for (const el in customFormData) {
if (el && this.form[el] && customFormData[el] !== undefined && customFormData[el] !== null) {
//* check if is an object, because data from api is a string, while import from django is an object
this.form[el].value = customFormData[el].value
? customFormData[el].value
: customFormData[el];
}
}
this.updateStore();
},
removeCustomForm() {
this.$store.commit(
'feature-type/REMOVE_CUSTOM_FORM',
this.customForm.dataKey
);
},
initSortable() {
this.sortable = new Sortable(document.getElementById(`list-options-${this.customForm.dataKey}`), {
animation: 150,
handle: '.draggable-row', // The element that is active to drag
ghostClass: 'blue-background-class',
dragClass: 'white-opacity-background-class',
onEnd: this.updateOptionOrder,
filter: 'input', // prevent input field not clickable...
preventOnFilter: false, // ... on element inside sortable
});
},
setConditionalFieldConfig(config) {
this.form.conditional_field_config.value = config;
this.updateStore();
},
setConditionalCustomForm() {
if (this.form.conditional_field_config.value === null) {
// retrieve existing value when the user uncheck and check again, if no value defined create an empty object
this.form.conditional_field_config.value = this.customForm.conditional_field_config || {};
} else {
this.form.conditional_field_config.value = null;
}
this.updateStore();
},
addForcedValue() {
const newForcedValueConfig = {
dataKey: this.forcedValMaxDataKey + 1,
// forced value can already be set here, setting false for boolean because user won't select if he wants to set on false, thus avoiding the null value not passing form check
forcedValue: this.form.field_type.value === 'boolean' ? false : null
};
this.form.forced_value_config.value = [...this.form.forced_value_config.value, newForcedValueConfig];
},
setForcedValue(newConfig) {
this.form.forced_value_config.value = this.form.forced_value_config.value.map((config) => {
return config.dataKey === newConfig.dataKey ? newConfig : config;
});
this.updateStore();
},
deleteForcedValue(dataKey) {
this.form.forced_value_config.value = this.form.forced_value_config.value.filter(
(config) => config.dataKey !== dataKey
);
this.updateStore();
},
updateStore() {
const data = {
dataKey: this.customForm.dataKey,
is_mandatory: this.form.is_mandatory.value,
label: this.form.label.value,
name: this.form.name.value,
position: this.form.position.value,
field_type: this.form.field_type.value,
options: this.form.options.value,
conditional_field_config: this.form.conditional_field_config.value,
forced_value_config: this.form.forced_value_config.value,
};
this.$store.commit('feature-type/UPDATE_CUSTOM_FORM', data);
if (this.customForm.name === this.selectedColorStyle ) {
this.$emit('update', this.form.options.value);
}
// if there was any error, check if the update fixed it and update errors displayed
if (this.hasErrors) {
this.checkCustomForm(true);
}
},
updateOptionValue(index, e) {
this.form.options.value[index] = e.target.value;
if (!this.hasDuplicateOptions()) {
this.updateStore();
}
},
updateOptionOrder(e) {
const currentOptionsList = Array.from(e.target.childNodes).map((el) => el.id);
this.form.options.value = currentOptionsList;
this.updateStore();
},
addOption() {
this.form.options.value.push('');
},
deleteOption(index) {
this.form.options.value.splice(index, 1);
},
trimWhiteSpace(string) {
// TODO : supprimer les espaces pour chaque option au début et à la fin QUE à la validation
return string.replace(/\s*,\s*/gi, ',');
},
//* CHECKS *//
hasRegularCharacters(input) {
for (const char of input) {
if (!/[a-zA-Z0-9-_]/.test(char)) {
return false;
}
}
return true;
},
/**
* Ensures the name entered in the form is unique among all custom field names.
* This function prevents duplicate names, including those with only case differences,
* to avoid conflicts during automatic view generation where names are slugified.
*
* @returns {boolean} - Returns true if the name is unique (case insensitive), false otherwise.
*/
checkUniqueName() {
const occurences = this.customForms
.map((el) => el.name.toLowerCase())
.filter((el) => el === this.form.name.value.toLowerCase());
return occurences.length === 1;
},
checkOptions() {
if (this.form.field_type.value === 'list') {
return this.form.options.value.length >= 2 && !this.form.options.value.includes('') ?
'' : 'Veuillez renseigner au moins 2 options.';
}
if (this.form.field_type.value === 'pre_recorded_list') {
return this.form.options.value.length === 1 ?
'' : 'Veuillez sélectionner une option.';
}
return '';
},
isConditionFilled(condition) {
if (condition) {
return condition.conditionField && condition.conditionValue !== null && condition.conditionValue !== undefined;
}
return true;
},
isForcedValueFilled() {
if (this.form.forced_value_config.value) {
for (const config of this.form.forced_value_config.value) {
if (!this.isConditionFilled(config) || config.forcedValue === null || config.forcedValue === undefined) {
return false;
}
}
}
return true;
},
checkCustomForm(noScroll) {
// reset errors to empty array
for (const element in this.form) {
if (this.form[element].errors) this.form[element].errors = [];
}
// check each form element
const optionError = this.checkOptions();
let isValid = true;
if (!this.form.label.value) {
//* vérifier que le label est renseigné
this.form.label.errors = ['Veuillez compléter ce champ.'];
isValid = false;
} else if (!this.form.name.value) {
//* vérifier que le nom est renseigné
this.form.name.errors = ['Veuillez compléter ce champ.'];
isValid = false;
} else if (!this.hasRegularCharacters(this.form.name.value)) {
//* vérifier qu'il n'y a pas de caractères spéciaux
this.form.name.errors = [
'Veuillez utiliser seulement les caratères autorisés.',
];
isValid = false;
} else if (reservedKeywords.includes(this.form.name.value)) {
//* vérifier que le nom du champs ne soit pas un nom réservé pour les propriétés standards d'un signalement
this.form.name.errors = [
'Ce nom est réservé, il ne peut pas être utilisé',
];
isValid = false;
} else if (!this.checkUniqueName()) {
//* vérifier si les noms sont pas dupliqués
this.form.name.errors = [
'Les champs personnalisés ne peuvent pas avoir des noms similaires.',
];
isValid = false;
} else if (optionError) {
//* s'il s'agit d'un type liste, vérifier que le champ option est bien renseigné
this.form.options.errors = [optionError];
isValid = false;
} else if (this.hasDuplicateOptions()) {
//* pour le cas d'options dupliqués
isValid = false;
} else if (!this.isConditionFilled(this.form.conditional_field_config.value)) {
//* vérifier si les deux formulaires de l'activation conditionnelle sont bien renseignés
isValid = false;
this.form.conditional_field_config.errors = ['Veuillez renseigner tous les champs de la condition ou la désactiver'];
} else if (!this.isForcedValueFilled()) {
//* vérifier si les trois formulaires de chaque valeur forcée sont bien renseignés
isValid = false;
this.form.forced_value_config.errors = ['Veuillez renseigner tous les champs de chaque condition à valeur forcée ou supprimer celle(s) incomplète(s)'];
}
this.hasErrors = !isValid;
if (!isValid && !noScroll) { // if errors were found: scroll to display this customForm
document.getElementById(this.custFormId).scrollIntoView();
}
return isValid;
},
},
};
</script>
<style lang="less" scoped>
.hasErrors {
border-color: red;
}
.errorlist {
margin: .5rem 0;
display: flex;
}
.custom-field-header {
display: flex;
align-items: center;
justify-content: space-between;
.top-right {
display: flex;
align-items: center;
.checkbox {
margin-right: 5rem;
}
span {
color: #666666;
}
}
}
i.icon.trash {
transition: color ease .3s;
}
i.icon.trash:hover {
cursor: pointer;
color: red !important;
}
.conditional-blocks {
margin: .5em 0;
h5 {
margin: 1em 0;
}
.inline {
display: flex;
align-items: center;
justify-content: space-between;
}
}
#condition-field {
padding-left: 2em;
}
.draggable-row {
display: flex;
align-items: baseline;
margin-bottom: 1em;
input {
margin: 0 .5em !important;
}
}
.segment { // keep custom form scrolled under the app header
scroll-margin-top: 5rem;
}
</style>
<template>
<router-link
v-if="featureType && featureType.slug"
:to="{
name: 'details-type-signalement',
params: { feature_type_slug: featureType.slug },
}"
class="feature-type-title"
:title="featureType.title"
>
<img
v-if="featureType.geom_type === 'point'"
class="list-image-type"
src="@/assets/img/marker.png"
alt="Géométrie point"
>
<img
v-if="featureType.geom_type === 'linestring'"
class="list-image-type"
src="@/assets/img/line.png"
alt="Géométrie ligne"
>
<img
v-if="featureType.geom_type === 'polygon'"
class="list-image-type"
src="@/assets/img/polygon.png"
alt="Géométrie polygone"
>
<img
v-if="featureType.geom_type === 'multipoint'"
class="list-image-type"
src="@/assets/img/multimarker.png"
alt="Géométrie multipoint"
>
<img
v-if="featureType.geom_type === 'multilinestring'"
class="list-image-type"
src="@/assets/img/multiline.png"
alt="Géométrie multiligne"
>
<img
v-if="featureType.geom_type === 'multipolygon'"
class="list-image-type"
src="@/assets/img/multipolygon.png"
alt="Géométrie multipolygone"
>
<span
v-if="featureType.geom_type === 'none'"
class="list-image-type"
title="Aucune géométrie"
>
<i class="ui icon large outline file" />
</span>
<span class="ellipsis">
{{ featureType.title }}
</span>
</router-link>
</template>
<script>
export default {
name: 'FeatureTypeLink',
props: {
featureType : {
type: Object,
default: () => {
return {};
},
}
},
};
</script>
<style scoped>
.feature-type-title {
display: flex;
align-items: center;
line-height: 1.5em;
width: fit-content;
}
.list-image-type {
margin-right: 5px;
height: 25px;
display: flex;
align-items: center;
}
.list-image-type > i {
color: #000000;
height: 25px;
}
</style>
<template>
<div>
<div class="three fields">
<div class="row-title">
<h5 class="field">
{{ title }}
</div>
</h5>
<div class="required inline field">
<label :for="form.color.id_for_label">{{ form.color.label }}</label>
<input
:id="form.color.id_for_label"
v-model.lazy="form.color.value"
type="color"
required
:name="form.color.html_name"
:id="form.color.id_for_label"
v-model.lazy="form.color.value"
/>
</div>
<!-- <div class="required inline field">
<label>Symbole</label>
<button
class="ui icon button picker-button"
type="button"
@click="openIconSelectionModal"
>
<font-awesome-icon
:icon="['fas', form.icon]"
:style="{ color: form.color.value || '#000000' }"
class="icon alt"
/>
</button>
</div> -->
</div>
<div
v-if="geomType === 'polygon' || geomType === 'multipolygon'"
class="field"
>
<label>Opacité &nbsp;<span>(%)</span></label>
<div class="range-container">
<input
id="opacity"
v-model="form.opacity"
type="range"
min="0"
max="1"
step="0.01"
>
<output class="range-output-bubble">
{{ getOpacity(form.opacity) }}
</output>
</div>
</div>
</div>
<div
:class="isIconPickerModalOpen ? 'active' : ''"
class="ui dimmer modal transition"
v-if="isIconPickerModalOpen"
ref="iconsPickerModal"
class="ui dimmer modal transition active"
>
<div class="header">Sélectionnez le symbole pour ce type de signalement :</div>
<div class="header">
Sélectionnez le symbole pour ce type de signalement :
</div>
<div class="scrolling content">
<div
v-for="icon of iconsNamesList"
:key="icon"
:class="form.icon === icon ? 'active' : ''"
class="icon-container"
:class="['icon-container', { active: form.icon === icon }]"
@click="selectIcon(icon)"
>
<font-awesome-icon
:icon="['fas', icon]"
class="icon alt"
<i
:class="`icon alt fas fa-${icon}`"
aria-hidden="true"
/>
</div>
</div>
<div class="actions">
<div class="ui cancel button" @click="isIconPickerModalOpen = false;">Fermer</div>
<div
class="ui cancel button"
@click="isIconPickerModalOpen = false;"
>
Fermer
</div>
</div>
</div>
</div>
......@@ -65,7 +78,7 @@ export default {
props: {
title: {
type: String,
default: 'Symbologie par défault :'
default: 'Couleur par défault :'
},
initColor: {
type: String,
......@@ -75,6 +88,10 @@ export default {
type: String,
default: 'circle'
},
initOpacity: {
type: [String, Number],
default: '0.5'
},
geomType: {
type: String,
default: 'Point'
......@@ -96,8 +113,9 @@ export default {
html_name: 'couleur',
value: '#000000',
},
opacity: '0.5',
}
}
};
},
watch: {
......@@ -105,7 +123,7 @@ export default {
deep: true,
handler(newValue) {
this.$emit('set', {
name: this.title === 'Symbologie par défault :' ? null : this.title,
name: this.isDefault ? null : this.title,
value: newValue
});
}
......@@ -114,7 +132,12 @@ export default {
created() {
this.form.color.value = this.initColor;
if (this.initIcon) this.form.icon = this.initIcon;
if (this.initIcon) {
this.form.icon = this.initIcon;
}
if (this.initOpacity) {
this.form.opacity = this.initOpacity;
}
this.$emit('set', {
name: this.title,
value: this.form
......@@ -128,25 +151,29 @@ export default {
selectIcon(icon) {
this.form.icon = icon;
}
},
getOpacity(opacity) {
return Math.round(parseFloat(opacity) * 100);
},
}
}
};
</script>
<style lang="less" scoped>
.fields {
align-items: center;
justify-content: space-between;
margin-top: 3em !important;
}
.row-title {
display: inline;
font-size: 1.4em;
width: 33%;
text-align: left;
margin-left: 0.5em;
#customFieldSymbology .fields {
margin-left: 1em !important;
margin-bottom: 1em !important;
}
h5 {
font-weight: initial;
font-style: italic;
}
#couleur {
......
<template>
<div>
<geor-header
:legacy-header="legacyHeader"
:legacy-url="legacyUrl"
:style="customStyle"
:logo-url="logo"
:stylesheet="customStylesheet"
:active-app="activeApp"
/>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'GeorchestraHeader',
computed: {
...mapState([
'configuration'
]),
headerConfig() {
return this.configuration.GEORCHESTRA_INTEGRATION?.HEADER;
},
activeApp() {
return this.$route.path.includes('my_account') ? 'geocontrib-account' : 'geocontrib';
},
logo() {
return this.configuration.VUE_APP_LOGO_PATH;
},
legacyHeader() {
return this.headerConfig.LEGACY_HEADER;
},
legacyUrl() {
return this.headerConfig.LEGACY_URL;
},
customStyle() {
return this.headerConfig.STYLE;
},
customStylesheet() {
return this.headerConfig.STYLESHEET;
},
headerScript() {
return this.headerConfig.HEADER_SCRIPT;
},
},
mounted() {
const headerScript = document.createElement('script');
headerScript.setAttribute('src', this.headerScript ? this.headerScript : 'https://cdn.jsdelivr.net/gh/georchestra/header@dist/header.js');
document.head.appendChild(headerScript);
},
};
</script>
<template>
<div id="table-imports">
<table class="ui collapsing celled table">
<thead>
<tr>
<th>Fichiers importés</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr :key="importFile.created_on" v-for="importFile in data">
<td>
<h4 class="ui header align-right">
<div :data-tooltip="importFile.geojson_file_name">
{{ importFile.geojson_file_name | subString }}
<div class="sub header">
ajouté le {{ importFile.created_on | setDate }}
</div>
</div>
</h4>
</td>
<td>
<span
v-if="importFile.infos"
:data-tooltip="importFile.infos"
class="ui icon margin-left"
>
<i
v-if="importFile.status === 'processing'"
class="orange hourglass half icon"
></i>
<i
v-else-if="importFile.status === 'finished'"
class="green check circle outline icon"
></i>
<i
v-else-if="importFile.status === 'failed'"
class="red x icon"
></i>
<i v-else class="red ban icon"></i>
</span>
<span
v-if="importFile.status === 'pending'"
data-tooltip="Statut en attente. Clickez pour rafraichir."
>
<i
v-on:click="fetchImports()"
:class="['orange icon', ready ? 'sync' : 'hourglass half']"
/>
</span>
</td>
</tr>
</tbody>
</table>
<div class="imports-header">
<div class="file-column">
Fichiers importés
</div>
<div class="status-column">
Statuts
</div>
</div>
<div
v-for="importFile in imports"
:key="importFile.created_on"
class="filerow"
>
<div class="file-column">
<h4 class="ui header align-right">
<div
:data-tooltip="importFile.geojson_file_name"
class="ellipsis"
>
{{ importFile.geojson_file_name }}
</div>
</h4>
<div class="sub header">
ajouté le {{ importFile.created_on | formatDate }}
</div>
</div>
<div class="status-column">
<span
v-if="importFile.infos"
:data-tooltip="importFile.infos"
class="ui icon margin-left"
>
<i
v-if="importFile.status === 'processing'"
class="orange hourglass half icon"
aria-hidden="true"
/>
<i
v-else-if="importFile.status === 'finished'"
class="green check circle outline icon"
aria-hidden="true"
/>
<i
v-else-if="importFile.status === 'failed'"
class="red x icon"
aria-hidden="true"
/>
<i
v-else
class="red ban icon"
aria-hidden="true"
/>
</span>
<span
v-if="importFile.status === 'pending'"
data-tooltip="Statut en attente. Cliquez pour rafraichir."
>
<i
:class="['orange icon', !reloading ? 'sync' : 'hourglass half rotate']"
aria-hidden="true"
@click="fetchImports()"
/>
</span>
</div>
</div>
</div>
</template>
<script>
import { mapState } from "vuex";
export default {
data() {
return {
open: false,
ready: true,
};
},
props: ["data"],
filters: {
setDate: function (value) {
let date = new Date(value);
let d = date.toLocaleDateString("fr", {
year: "numeric",
month: "long",
day: "numeric",
formatDate: function (value) {
const date = new Date(value);
return date.toLocaleDateString('fr', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return d;
},
subString: function (value) {
return value.substring(0, 27) + "..";
return value.substring(0, 27) + '..';
},
},
watch: {
data(newValue) {
if (newValue) {
this.ready = true;
}
props: {
imports: {
type: Array,
default: null,
},
},
computed: {
...mapState("feature", ["features"]),
data() {
return {
reloading: false,
fetchCallCounter: 0,
};
},
mounted() {
this.fetchImports();
},
methods: {
fetchImports() {
this.$store.dispatch(
"feature_type/GET_IMPORTS", {
feature_type: this.$route.params.feature_type_slug
});
this.$store.dispatch('feature/GET_PROJECT_FEATURES', {
this.fetchCallCounter += 1; // register each time function is programmed to be called in order to avoid redundant calls
this.reloading = true;
// fetch imports
this.$store.dispatch('feature-type/GET_IMPORTS', {
project_slug: this.$route.params.slug,
feature_type__slug: this.$route.params.feature_type_slug
feature_type: this.$route.params.feature_type_slug
})
//* show that the action was triggered, could be improved with animation (doesn't work)
this.ready = false;
.then((response) => {
if (response.data && response.data.some(el => el.status === 'pending')) {
// if there is still some pending imports re-fetch imports by calling this function again
setTimeout(() => {
if (this.fetchCallCounter <= 1 ) {
// if the function wasn't called more than once in the reload interval, then call it again
this.fetchImports();
}
this.fetchCallCounter -= 1; // decrease function counter
}, this.$store.state.configuration.VUE_APP_RELOAD_INTERVAL);
// give a bit time for loader to be seen by user if response was fast
setTimeout(() => {
this.reloading = false;
}, 1500);
} else {
// if no pending import, get last features
this.$emit('reloadFeatureType');
}
});
},
},
};
</script>
<style scoped>
.sync {
cursor: pointer;
}
<style scoped lang="less">
#table-imports {
padding-top: 1em;
}
@keyframes rotateIn {
from {
transform: rotate3d(0, 0, 1, -200deg);
opacity: 0;
border: 1px solid lightgrey;
margin-top: 1rem;
.imports-header {
border-bottom: 1px solid lightgrey;
font-weight: bold;
}
to {
transform: translate3d(0, 0, 0);
opacity: 1;
> div {
padding: .5em 1em;
}
}
.rotateIn {
animation-name: rotateIn;
transform-origin: center;
animation: 2s;
}
/*
Max width before this PARTICULAR table gets nasty
This query will take effect for any screen smaller than 760px
and also iPads specifically.
*/
@media only screen and (max-width: 760px),
(min-device-width: 768px) and (max-device-width: 1024px) {
/* Force table to not be like tables anymore */
table,
thead,
tbody,
th,
td,
tr {
display: block;
.filerow {
background-color: #fff;
}
.imports-header, .filerow {
display: flex;
/* Hide table headers (but not display: none;, for accessibility) */
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
tr {
border: 1px solid #ccc;
}
td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid #eee;
position: relative;
padding-left: 50%;
.file-column {
width: 80%;
h4 {
margin-bottom: .2em;
}
}
.status-column {
width: 20%;
text-align: right;
}
}
}
td:before {
/* Now like a table header */
position: absolute;
/* Top/left values mimic padding */
/* top: 6px; */
left: 6px;
width: 25%;
padding-right: 10px;
white-space: "break-spaces";
}
.sync {
cursor: pointer;
}
/*
Label the data
*/
td:nth-of-type(1):before {
content: "Fichiers importés";
}
td:nth-of-type(2):before {
content: "Statut";
}
i.icon {
width: 20px !important;
height: 20px !important;
}
.align-right {
text-align: right;
}
.margin-left {
margin-left: 94%;
}
.rotate {
-webkit-animation:spin 1.5s cubic-bezier(.3,.25,.15,1) infinite;
-moz-animation:spin 1.5s cubic-bezier(.3,.25,.15,1) infinite;
animation:spin 1.5s cubic-bezier(.3,.25,.15,1) infinite;
}
</style>
\ No newline at end of file
@-moz-keyframes spin { 100% { -moz-transform: rotate(180deg); } }
@-webkit-keyframes spin { 100% { -webkit-transform: rotate(180deg); } }
@keyframes spin { 100% { -webkit-transform: rotate(180deg); transform:rotate(180deg); } }
</style>