New champs editor
This commit is contained in:
parent
e1a1a2b2ad
commit
0d35295d4e
9 changed files with 605 additions and 0 deletions
1
app/assets/images/icons/drag.svg
Normal file
1
app/assets/images/icons/drag.svg
Normal 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 |
|
@ -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],
|
||||
|
|
137
app/assets/stylesheets/new_design/procedure_champs_editor.scss
Normal file
137
app/assets/stylesheets/new_design/procedure_champs_editor.scss
Normal 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;
|
||||
}
|
131
app/javascript/new_design/administrateur/DraggableItem.js
Normal file
131
app/javascript/new_design/administrateur/DraggableItem.js
Normal 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;
|
165
app/javascript/new_design/administrateur/DraggableItem.vue
Normal file
165
app/javascript/new_design/administrateur/DraggableItem.vue
Normal 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>
|
12
app/javascript/new_design/administrateur/DraggableList.js
Normal file
12
app/javascript/new_design/administrateur/DraggableList.js
Normal 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: {}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
42
app/javascript/new_design/administrateur/DraggableList.vue
Normal file
42
app/javascript/new_design/administrateur/DraggableList.vue
Normal 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>
|
109
app/javascript/new_design/administrateur/champs-editor.js
Normal file
109
app/javascript/new_design/administrateur/champs-editor.js
Normal 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');
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue