New champs editor

This commit is contained in:
Paul Chavard 2018-11-14 16:26:00 +01:00
parent e1a1a2b2ad
commit 0d35295d4e
9 changed files with 605 additions and 0 deletions

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20 9H4v2h16V9zM4 15h16v-2H4v2z"/></svg>

After

Width:  |  Height:  |  Size: 132 B

View file

@ -107,6 +107,12 @@
}
}
input[type=checkbox] {
&.small-margin {
margin-bottom: $default-padding / 2;
}
}
input[type=text]:not([data-address='true']),
input[type=email],
input[type=password],

View file

@ -0,0 +1,137 @@
@import "colors";
#champs-editor {
.spinner {
margin-right: auto;
margin-left: auto;
margin-top: 80px;
}
}
.draggable-item {
display: flex;
flex-direction: column;
justify-content: flex-start;
border: 1px solid $border-grey;
border-radius: 5px;
margin-bottom: 10px;
width: 100%;
.handle {
cursor: ns-resize;
margin-right: 10px;
margin-top: 8px;
}
.error-message {
text-align: center;
flex-grow: 1;
font-size: 14px;
color: $light-grey;
display: flex;
align-items: center;
flex-direction: column;
justify-content: space-around;
.content {
background-color: $medium-red;
border-radius: 8px;
padding: 4px 10px;
}
}
&.type-header-section {
background-color: $blue;
label {
color: $light-grey;
}
}
&:not(.type-header-section) {
input.error {
border: 1px solid $medium-red;
}
}
.column {
display: flex;
justify-content: flex-start;
flex-direction: column;
&.shift-left {
margin-left: 35px;
}
}
.row {
display: flex;
justify-content: flex-start;
&.section {
padding: 10px 10px 0 10px;
margin-bottom: 8px;
}
&.hr {
border-bottom: 1px solid $border-grey;
&.head {
padding-bottom: 10px;
}
}
&.shift-left {
margin-left: 35px;
}
&.head {
select {
margin-bottom: 0px;
}
}
&.delete {
flex-grow: 1;
display: flex;
justify-content: flex-end;
}
}
.cell {
margin-right: 20px;
&.small {
width: 90px;
}
&.libelle {
width: 300px;
}
label {
margin-bottom: 8px;
text-transform: uppercase;
font-size: 12px;
}
}
.carte-options {
label {
font-weight: initial;
}
}
.inline {
display: inline;
}
}
.header,
.footer {
display: flex;
justify-content: space-between;
margin-top: 30px;
margin-bottom: 30px;
}

View file

@ -0,0 +1,131 @@
export default {
props: ['state', 'update', 'index', 'item', 'prefix'],
computed: {
isDirty() {
return (
this.state.version &&
this.state.unsavedInvalidItems.size > 0 &&
this.state.unsavedItems.has(this.itemId)
);
},
isInvalid() {
if (this.deleted) {
return false;
}
if (this.libelle) {
return !this.libelle.trim();
}
return true;
},
itemId() {
return this.item.id || this.clientId;
},
changeLog() {
return [this.itemId, !this.isInvalid];
},
itemClassName() {
const classNames = [`draggable-item-${this.index}`];
if (this.isHeaderSection) {
classNames.push('type-header-section');
}
if (this.isDirty) {
if (this.isInvalid) {
classNames.push('invalid');
} else {
classNames.push('dirty');
}
}
return classNames.join(' ');
},
isDropDown() {
return [
'drop_down_list',
'multiple_drop_down_list',
'linked_drop_down_list'
].includes(this.typeChamp);
},
isFile() {
return this.typeChamp === 'piece_justificative';
},
isCarte() {
return this.typeChamp === 'carte';
},
isExplication() {
return this.typeChamp === 'explication';
},
isHeaderSection() {
return this.typeChamp === 'header_section';
},
options() {
const options = this.item.options;
for (let key of Object.keys(options)) {
options[key] = castBoolean(options[key]);
}
return options;
},
attribute() {
if (this.state.isAnnotation) {
return 'types_de_champ_private_attributes';
} else {
return 'types_de_champ_attributes';
}
}
},
data() {
return {
typeChamp: this.item.type_champ,
libelle: this.item.libelle,
mandatory: this.item.mandatory,
description: this.item.description,
dropDownList: this.item.drop_down_list.value,
deleted: false,
clientId: `id-${clientIds++}`
};
},
created() {
for (let path of PATHS_TO_WATCH) {
this.$watch(path, () => this.update(this.changeLog));
}
},
mounted() {
if (this.isInvalid) {
this.update(this.changeLog, false);
}
},
methods: {
removeChamp(item) {
if (item.id) {
this.deleted = true;
} else {
const index = this.state.typesDeChamp.indexOf(item);
this.state.typesDeChamp.splice(index, 1);
this.update([this.itemId, true]);
}
},
nameFor(name) {
return `${this.prefix}[${this.attribute}][${this.index}][${name}]`;
},
elementIdFor(name) {
return `${this.prefix}_${this.attribute}_${this.index}_${name}`;
}
}
};
const PATHS_TO_WATCH = [
'typeChamp',
'libelle',
'mandatory',
'description',
'dropDownList',
'options.quartiers_prioritaires',
'options.cadastres',
'options.parcelles_agricoles',
'index',
'deleted'
];
function castBoolean(value) {
return value && value != 0;
}
let clientIds = 0;

View file

@ -0,0 +1,165 @@
<template>
<div class="deleted" v-if="deleted">
<input type="hidden" :name="nameFor('id')" :value="item.id">
<input type="hidden" :name="nameFor('_destroy')" value="true">
</div>
<div class="draggable-item" v-else :class="itemClassName">
<div class="row section head" :class="{ hr: !isHeaderSection }">
<div class="handle">
<img :src="state.dragIconUrl" alt="">
</div>
<div class="cell">
<select :name="nameFor('type_champ')" v-model="typeChamp" class="small-margin small inline">
<option v-for="option in state.typesDeChampOptions" :key="option[1]" :value="option[1]">
{{ option[0] }}
</option>
</select>
</div>
<div class="row delete">
<div v-if="isDirty" class="error-message">
<span v-if="isInvalid" class="content">
Le libellé doit être rempli.
</span>
<span v-else class="content">
<template v-if="state.isAnnotation">
Modifications non sauvegardées. Le libellé doit être rempli sur tous les annotations.
</template>
<template v-else>
Modifications non sauvegardées. Le libellé doit être rempli sur tous les champs.
</template>
</span>
</div>
<button class="button danger" @click.prevent="removeChamp(item)">
Supprimer
</button>
</div>
</div>
<div class="row section" :class="{ hr: isDropDown || isFile || isCarte }">
<div class="column shift-left">
<div class="cell libelle">
<label :for="elementIdFor('libelle')">
Libellé
</label>
<input
type="text"
:id="elementIdFor('libelle')"
:name="nameFor('libelle')"
v-model="libelle"
class="small-margin small"
:class="{ error: isDirty && isInvalid }">
</div>
<div class="cell" v-show="!isHeaderSection && !isExplication && !state.isAnnotation">
<label :for="elementIdFor('mandatory')">
Obligatoire
</label>
<input :name="nameFor('mandatory')" type="hidden" value="0">
<input
type="checkbox"
:id="elementIdFor('mandatory')"
:name="nameFor('mandatory')"
v-model="mandatory"
class="small-margin small"
value="1">
</div>
</div>
<div class="row">
<div class="cell" v-show="!isHeaderSection">
<label :for="elementIdFor('description')">
Description
</label>
<textarea
:id="elementIdFor('description')"
:name="nameFor('description')"
v-model="description"
rows=3
cols=40
class="small-margin small">
</textarea>
</div>
</div>
</div>
<div class="row section shift-left" v-show="!isHeaderSection">
<div class="cell" v-show="isDropDown">
<label :for="elementIdFor('drop_down_list')">
Liste déroulante
</label>
<textarea
:id="elementIdFor('drop_down_list')"
:name="nameFor('drop_down_list_attributes[value]')"
v-model="dropDownList"
rows=3
cols=40
placeholder="Ecrire une valeur par ligne et --valeur-- pour un séparateur."
class="small-margin small">
</textarea>
</div>
<div class="cell" v-show="isFile">
<label :for="elementIdFor('piece_justificative_template')">
Modèle
</label>
<template v-if="item.piece_justificative_template_url">
<a :href="item.piece_justificative_template_url" target="_blank">
{{item.piece_justificative_template_filename}}
</a>
<br> Modifier :
</template>
<input
type="file"
:id="elementIdFor('piece_justificative_template')"
:name="nameFor('piece_justificative_template')"
:data-direct-upload-url="state.directUploadsUrl"
@change="update(changeLog)"
class="small-margin small">
</div>
<div class="cell" v-show="isCarte">
<label>
Utilisation de la cartographie
</label>
<div class="carte-options">
<label :for="elementIdFor('quartiers_prioritaires')">
<input :name="nameFor('quartiers_prioritaires')" type="hidden" value="0">
<input
type="checkbox"
:id="elementIdFor('quartiers_prioritaires')"
:name="nameFor('quartiers_prioritaires')"
v-model="options.quartiers_prioritaires"
class="small-margin small"
value="1">
Quartiers prioritaires
</label>
<label :for="elementIdFor('cadastres')">
<input :name="nameFor('cadastres')" type="hidden" value="0">
<input
type="checkbox"
:id="elementIdFor('cadastres')"
:name="nameFor('cadastres')"
v-model="options.cadastres"
class="small-margin small"
value="1">
Cadastres
</label>
<label :for="elementIdFor('parcelles_agricoles')">
<input :name="nameFor('parcelles_agricoles')" type="hidden" value="0">
<input
type="checkbox"
:id="elementIdFor('parcelles_agricoles')"
:name="nameFor('parcelles_agricoles')"
v-model="options.parcelles_agricoles"
class="small-margin small"
value="1">
Parcelles Agricoles
</label>
</div>
</div>
</div>
<div class="meta">
<input type="hidden" :name="nameFor('order_place')" :value="index">
<input type="hidden" :name="nameFor('id')" :value="item.id">
</div>
</div>
</template>
<script src="./DraggableItem.js"></script>

View file

@ -0,0 +1,12 @@
export default {
props: ['state', 'version', 'update', 'updateAll'],
methods: {
addChamp() {
this.state.typesDeChamp.push({
type_champ: 'text',
drop_down_list: {},
options: {}
});
}
}
};

View file

@ -0,0 +1,42 @@
<template>
<div class="champs-editor">
<div v-if="state.typesDeChamp.length > 3" class="header">
<button class="button" @click.prevent="addChamp">
<template v-if="state.isAnnotation">
Ajouter une annotation
</template>
<template v-else>
Ajouter un champ
</template>
</button>
<button class="button primary" @click.prevent="updateAll">Enregistrer</button>
</div>
<Draggable :list="state.typesDeChamp" :options="{handle:'.handle'}">
<DraggableItem
v-for="(item, index) in state.typesDeChamp"
prefix="procedure"
:state="state"
:update="update"
:index="index"
:item="item"
:key="item.id" />
</Draggable>
<div class="footer">
<button class="button" @click.prevent="addChamp">
<template v-if="state.isAnnotation">
Ajouter une annotation
</template>
<template v-else>
Ajouter un champ
</template>
</button>
<button class="button primary" @click.prevent="updateAll">Enregistrer</button>
</div>
</div>
</template>
<script src="./DraggableList.js"></script>

View file

@ -0,0 +1,109 @@
import Vue from 'vue';
import Draggable from 'vuedraggable';
import { fire, debounce } from '@utils';
import DraggableItem from './DraggableItem';
import DraggableList from './DraggableList';
Vue.component('Draggable', Draggable);
Vue.component('DraggableItem', DraggableItem);
addEventListener('DOMContentLoaded', () => {
const el = document.querySelector('#champs-editor');
const { directUploadsUrl, dragIconUrl } = el.dataset;
const state = {
typesDeChamp: JSON.parse(el.dataset.typesDeChamp),
typesDeChampOptions: JSON.parse(el.dataset.typesDeChampOptions),
directUploadsUrl,
dragIconUrl,
isAnnotation: el.dataset.type === 'annotation',
unsavedItems: new Set(),
unsavedInvalidItems: new Set(),
version: 1
};
new Vue({
el,
data: {
state,
update: null
},
render(h) {
return h(DraggableList, {
props: {
state: this.state,
update: this.update,
updateAll: this.updateAll
}
});
},
mounted() {
const [update, updateAll] = createUpdateFunctions(
this,
state.isAnnotation
);
this.update = update;
this.updateAll = updateAll;
}
});
});
function createUpdateFunctions(app, isAnnotation) {
let isSaving = false;
const form = app.$el.closest('form');
const update = ([id, isValid], refresh = true) => {
app.state.unsavedItems.add(id);
if (isValid) {
app.state.unsavedInvalidItems.delete(id);
} else {
app.state.unsavedInvalidItems.add(id);
}
if (refresh) {
app.state.version += 1;
}
updateAll();
};
const updateAll = debounce(() => {
if (isSaving) {
updateAll();
} else if (
app.state.typesDeChamp.length > 0 &&
app.state.unsavedInvalidItems.size === 0
) {
isSaving = true;
app.state.unsavedItems.clear();
app.state.version += 1;
fire(form, 'submit');
}
}, 500);
addEventListener('ProcedureUpdated', event => {
const { types_de_champ, types_de_champ_private } = event.detail;
app.state.typesDeChamp = isAnnotation
? types_de_champ_private
: types_de_champ;
isSaving = false;
updateFileInputs();
});
return [update, updateAll];
}
// This is needed du to the way ActiveStorage javascript integration works.
// It is built to be used with traditional forms. Another way would be to not use
// high level ActiveStorage abstractions (and maybe this is what we should do in the future).
function updateFileInputs() {
for (let element of document.querySelectorAll('.direct-upload')) {
let hiddenInput = element.nextElementSibling;
let fileInput = hiddenInput.nextElementSibling;
element.remove();
hiddenInput.remove();
fileInput.value = '';
fileInput.removeAttribute('disabled');
}
}

View file

@ -22,6 +22,8 @@ import '../new_design/select2';
import '../new_design/champs/carte';
import '../new_design/champs/linked-drop-down-list';
import '../new_design/administrateur/champs-editor';
import { toggleCondidentielExplanation } from '../new_design/avis';
import { scrollMessagerie } from '../new_design/messagerie';
import { showMotivation, motivationCancel } from '../new_design/state-button';