Merge pull request #3408 from betagouv/dev

2019-02-11-01
This commit is contained in:
Pierre de La Morinerie 2019-02-11 11:48:25 +01:00 committed by GitHub
commit 0b58bed658
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 480 additions and 336 deletions

View file

@ -37,12 +37,16 @@ Nous mettons en production au minimum une fois par semaine (et généralement pl
demarches-simplifiees.fr est **compliqué à héberger**. Parmi les problématiques que nous rencontrons :
- **Sécurité et confidentialité des données** : par nature, demarches-simplifiees.fr traite une quantité importante de données personnelles. La sécurité de linfrastructure doit être contrôlée et certifiée pour garantir la confidentialité des données. Cela implique par exemple une démarche de mise en conformité avec le [Référentiel Général de Sécurité](https://www.ssi.gouv.fr/entreprise/reglementation/confiance-numerique/le-referentiel-general-de-securite-rgs/), qui est un processus assez lourd.
- **Sécurité et confidentialité des données** : par nature, demarches-simplifiees.fr est appelé à traiter des natures de données qui peuvent présenter des caractéristiqus plus ou moins sensibles. La sécurité de linfrastructure doit être contrôlée et certifiée pour garantir la confidentialité des données. Cela implique par exemple une démarche de mise en conformité avec le [Référentiel Général de Sécurité](https://www.ssi.gouv.fr/entreprise/reglementation/confiance-numerique/le-referentiel-general-de-securite-rgs/), qui est un processus assez lourd.
Cest également valable pour le stockage des pièces-jointes, qui sont souvent des documents didentités dont la confidentialité doit être garantie.
Cest également valable pour le stockage des pièces-jointes, qui peuvent la aussi présenter des particularités et des sensibilités dont la confidentialité doit être garantie.
- **Utilisation de services externes** : demarches-simplifiees.fr sinterconnecte à de nombreux services externes : des APIs (API Entreprise, API Carto, la Base Adresse Nationale, etc.) mais aussi des services pour le stockage externe des pièces-jointes, lanalyse anti-virus ou lenvoi des emails. Le fonctionnement de demarches-simplifiees.fr dépend de la disponibilité de ces services externes.
- **Mises à jour** : le schéma de la base de données change régulièrement. Nous codons également des scripts pour harmoniser les anciennes données. Parfois des modifications ponctuelles sont effectuées sur des démarches anciennes, pour les mettre en conformité avec de nouvelles règles métiers. Nous maintenons également les dépendances logicielles utilisées notamment en réagissant rapidement lorsquune faille de sécurité est signalée. Ces mises à jour fréquentes en production sont indispensables au bon fonctionnement de loutil.
Si vous souhaitez adapter demarches-simplifiees.fr à votre besoin, nous vous recommandons de **proposer vos modifications à la base de code principale** (par exemple en créant une issue) **plutôt que dhéberger une autre instance vous-même**.
Dans le cas où vous envisagez dhéberger une instance de demarches-simplifiees.fr vous-même, nous n'avons malheureusement pas les moyens de vous accompagner, ni d'assurer de support technique concernant votre installation.
Dans le cas où vous envisagez dhéberger une instance de demarches-simplifiees.fr vous-même, nous n'avons malheureusement pas les moyens de vous accompagner, ni d'assurer de support technique concernant votre installation.
Totefois, le ministère des armées a déployé une instance au sein de leur intranet. Nous proposons aux acteurs qui sont interessés de les mettre en relation avec eux afin de disposer d'un retour d'expérience, et bénéficier de leur retour.

View file

@ -32,6 +32,10 @@
&.column {
flex-direction: column;
}
&.row-reverse {
flex-direction: row-reverse;
}
}
.flex-grow {

View file

@ -1,49 +1,13 @@
module NewAdministrateur
class ProceduresController < AdministrateurController
before_action :retrieve_procedure, only: [:champs, :annotations, :update]
before_action :procedure_locked?, only: [:champs, :annotations, :update]
TYPE_DE_CHAMP_ATTRIBUTES_BASE = [
:_destroy,
:libelle,
:description,
:order_place,
:type_champ,
:id,
:mandatory,
:piece_justificative_template,
:quartiers_prioritaires,
:cadastres,
:parcelles_agricoles,
drop_down_list_attributes: [:value]
]
TYPE_DE_CHAMP_ATTRIBUTES = TYPE_DE_CHAMP_ATTRIBUTES_BASE.dup
TYPE_DE_CHAMP_ATTRIBUTES << {
types_de_champ_attributes: TYPE_DE_CHAMP_ATTRIBUTES_BASE
}
before_action :retrieve_procedure, only: [:champs, :annotations]
before_action :procedure_locked?, only: [:champs, :annotations]
def apercu
@dossier = procedure_without_control.new_dossier
@tab = apercu_tab
end
def update
if @procedure.update(procedure_params)
flash.now.notice = if params[:procedure][:types_de_champ_attributes].present?
'Formulaire mis à jour.'
elsif params[:procedure][:types_de_champ_private_attributes].present?
'Annotations privées mises à jour.'
else
'Démarche enregistrée.'
end
reset_procedure
else
flash.now.alert = @procedure.errors.full_messages
end
end
private
def apercu_tab
@ -53,12 +17,5 @@ module NewAdministrateur
def procedure_without_control
Procedure.find(params[:id])
end
def procedure_params
params.required(:procedure).permit(
types_de_champ_attributes: TYPE_DE_CHAMP_ATTRIBUTES,
types_de_champ_private_attributes: TYPE_DE_CHAMP_ATTRIBUTES
)
end
end
end

View file

@ -0,0 +1,76 @@
module NewAdministrateur
class TypesDeChampController < AdministrateurController
before_action :retrieve_procedure, only: [:create, :update, :destroy]
before_action :procedure_locked?, only: [:create, :update, :destroy]
def create
type_de_champ = TypeDeChamp.new(type_de_champ_create_params)
if type_de_champ.save
reset_procedure
render json: serialize_type_de_champ(type_de_champ), status: :created
else
render json: { errors: type_de_champ.errors.full_messages }, status: :unprocessable_entity
end
end
def update
type_de_champ = TypeDeChamp.where(procedure: @procedure).find(params[:id])
if type_de_champ.update(type_de_champ_update_params)
reset_procedure
render json: serialize_type_de_champ(type_de_champ)
else
render json: { errors: type_de_champ.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
type_de_champ = TypeDeChamp.where(procedure: @procedure).find(params[:id])
type_de_champ.destroy!
reset_procedure
head :no_content
end
private
def serialize_type_de_champ(type_de_champ)
{
type_de_champ: type_de_champ.as_json(
except: [:created_at, :updated_at, :stable_id, :type, :parent_id, :procedure_id, :private],
methods: [:piece_justificative_template_filename, :piece_justificative_template_url, :drop_down_list_value]
)
}
end
def type_de_champ_create_params
params.required(:type_de_champ).permit(:libelle,
:description,
:order_place,
:type_champ,
:private,
:parent_id,
:mandatory,
:piece_justificative_template,
:quartiers_prioritaires,
:cadastres,
:parcelles_agricoles,
:drop_down_list_value).merge(procedure: @procedure)
end
def type_de_champ_update_params
params.required(:type_de_champ).permit(:libelle,
:description,
:order_place,
:type_champ,
:mandatory,
:piece_justificative_template,
:quartiers_prioritaires,
:cadastres,
:parcelles_agricoles,
:drop_down_list_value)
end
end
end

View file

@ -188,7 +188,7 @@ module NewUser
def ask_deletion
dossier = current_user.dossiers.includes(:user, procedure: :administrateur).find(params[:id])
if !dossier.instruction_commencee?
if dossier.can_be_deleted_by_user?
dossier.delete_and_keep_track
flash.notice = 'Votre dossier a bien été supprimé.'
redirect_to dossiers_path
@ -257,7 +257,7 @@ module NewUser
end
def ensure_dossier_can_be_updated
if !dossier.can_be_updated_by_the_user?
if !dossier.can_be_updated_by_user?
flash.alert = 'Votre dossier ne peut plus être modifié'
redirect_to dossiers_path
end

View file

@ -39,7 +39,8 @@ module ProcedureHelper
type: "champ",
types_de_champ_options: types_de_champ_options.to_json,
types_de_champ: types_de_champ_as_json(procedure.types_de_champ).to_json,
direct_uploads_url: rails_direct_uploads_url,
save_url: procedure_types_de_champ_path(procedure),
direct_upload_url: rails_direct_uploads_url,
drag_icon_url: image_url("icons/drag.svg")
}
end
@ -49,18 +50,12 @@ module ProcedureHelper
type: "annotation",
types_de_champ_options: types_de_champ_options.to_json,
types_de_champ: types_de_champ_as_json(procedure.types_de_champ_private).to_json,
direct_uploads_url: rails_direct_uploads_url,
save_url: procedure_types_de_champ_path(procedure),
direct_upload_url: rails_direct_uploads_url,
drag_icon_url: image_url("icons/drag.svg")
}
end
def procedure_data(procedure)
{
types_de_champ: types_de_champ_as_json(procedure.types_de_champ),
types_de_champ_private: types_de_champ_as_json(procedure.types_de_champ_private)
}.to_json
end
private
TOGGLES = {
@ -79,14 +74,12 @@ module ProcedureHelper
types_de_champ
end
TYPES_DE_CHAMP_INCLUDE = { drop_down_list: { only: :value } }
TYPES_DE_CHAMP_BASE = {
except: [:created_at, :updated_at, :stable_id, :type, :parent_id, :procedure_id, :private],
methods: [:piece_justificative_template_filename, :piece_justificative_template_url],
include: TYPES_DE_CHAMP_INCLUDE
methods: [:piece_justificative_template_filename, :piece_justificative_template_url, :drop_down_list_value]
}
TYPES_DE_CHAMP = TYPES_DE_CHAMP_BASE
.merge(include: TYPES_DE_CHAMP_INCLUDE.merge(types_de_champ: TYPES_DE_CHAMP_BASE))
.merge(include: { types_de_champ: TYPES_DE_CHAMP_BASE })
def types_de_champ_as_json(types_de_champ)
types_de_champ.includes(:drop_down_list,

View file

@ -1,40 +1,23 @@
import { getJSON, debounce } from '@utils';
import { DirectUpload } from 'activestorage';
export default {
props: ['state', 'update', 'index', 'item'],
props: ['state', 'index', 'item'],
computed: {
isDirty() {
return (
this.state.version &&
this.state.unsavedInvalidItems.size > 0 &&
this.state.unsavedItems.has(this.itemId)
);
},
isInvalid() {
isValid() {
if (this.deleted) {
return false;
return true;
}
if (this.libelle) {
return !this.libelle.trim();
return !!this.libelle.trim();
}
return true;
},
itemId() {
return this.item.id || this.clientId;
},
changeLog() {
return [this.itemId, !this.isInvalid];
return false;
},
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() {
@ -73,6 +56,47 @@ export default {
return 'types_de_champ_attributes';
}
},
payload() {
const payload = {
libelle: this.libelle,
type_champ: this.typeChamp,
mandatory: this.mandatory,
description: this.description,
drop_down_list_value: this.dropDownListValue,
order_place: this.index
};
if (this.pieceJustificativeTemplate) {
payload.piece_justificative_template = this.pieceJustificativeTemplate;
}
if (this.state.parentId) {
payload.parent_id = this.state.parentId;
}
if (!this.id && this.state.isAnnotation) {
payload.private = true;
}
Object.assign(payload, this.options);
return payload;
},
saveUrl() {
if (this.id) {
return `${this.state.saveUrl}/${this.id}`;
}
return this.state.saveUrl;
},
savePayload() {
if (this.deleted) {
return {};
}
return { type_de_champ: this.payload };
},
saveMethod() {
if (this.deleted) {
return 'delete';
} else if (this.id) {
return 'patch';
}
return 'post';
},
typesDeChamp() {
return this.item.types_de_champ;
},
@ -85,39 +109,46 @@ export default {
return Object.assign({}, this.state, {
typesDeChamp: this.typesDeChamp,
typesDeChampOptions: this.typesDeChampOptions,
prefix: `${this.state.prefix}[${this.attribute}][${this.index}]`
prefix: `${this.state.prefix}[${this.attribute}][${this.index}]`,
parentId: this.id
});
}
},
data() {
return {
id: this.item.id,
typeChamp: this.item.type_champ,
libelle: this.item.libelle,
mandatory: this.item.mandatory,
description: this.item.description,
dropDownList: this.item.drop_down_list && this.item.drop_down_list.value,
pieceJustificativeTemplate: null,
pieceJustificativeTemplateUrl: this.item.piece_justificative_template_url,
pieceJustificativeTemplateFilename: this.item
.piece_justificative_template_filename,
dropDownListValue: this.item.drop_down_list_value,
deleted: false,
clientId: `id-${clientIds++}`
isSaving: false,
isUploading: false,
hasChanges: false
};
},
created() {
for (let path of PATHS_TO_WATCH) {
this.$watch(path, () => this.update(this.changeLog));
watch: {
index() {
this.update();
}
},
mounted() {
if (this.isInvalid) {
this.update(this.changeLog, false);
}
created() {
this.debouncedSave = debounce(() => this.save(), 500);
this.debouncedUpload = debounce(evt => this.upload(evt), 500);
},
methods: {
removeChamp(item) {
if (item.id) {
removeChamp() {
if (this.id) {
this.deleted = true;
this.debouncedSave();
} else {
const index = this.state.typesDeChamp.indexOf(item);
const index = this.state.typesDeChamp.indexOf(this.item);
this.state.typesDeChamp.splice(index, 1);
this.update([this.itemId, true]);
}
},
nameFor(name) {
@ -132,6 +163,76 @@ export default {
type_champ: 'text',
types_de_champ: []
});
},
update() {
this.hasChanges = true;
if (this.isValid) {
if (this.state.inFlight === 0) {
this.state.flash.clear();
}
this.debouncedSave();
}
},
upload(evt) {
if (this.isUploading) {
this.debouncedUpload();
} else {
const input = evt.target;
const file = input.files[0];
if (file) {
this.isUploading = true;
uploadFile(this.state.directUploadUrl, file).then(({ signed_id }) => {
this.pieceJustificativeTemplate = signed_id;
this.isUploading = false;
this.debouncedSave();
});
}
input.value = null;
}
},
save() {
if (this.isSaving) {
this.debouncedSave();
} else {
this.isSaving = true;
this.state.inFlight++;
getJSON(this.saveUrl, this.savePayload, this.saveMethod)
.then(data => {
this.onSuccess(data);
})
.catch(xhr => {
this.onError(xhr);
});
}
},
onSuccess(data) {
if (data && data.type_de_champ) {
this.id = data.type_de_champ.id;
this.pieceJustificativeTemplateUrl =
data.type_de_champ.piece_justificative_template_url;
this.pieceJustificativeTemplateFilename =
data.type_de_champ.piece_justificative_template_filename;
this.pieceJustificativeTemplate = null;
}
this.state.inFlight--;
this.isSaving = false;
this.hasChanges = false;
if (this.state.inFlight === 0) {
this.state.flash.success();
}
},
onError(xhr) {
this.isSaving = false;
this.state.inFlight--;
try {
const {
errors: [message]
} = JSON.parse(xhr.responseText);
this.state.flash.error(message);
} catch (e) {
this.state.flash.error(xhr.responseText);
}
}
}
};
@ -143,21 +244,20 @@ const EXCLUDE_FROM_REPETITION = [
'siret'
];
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;
function uploadFile(directUploadUrl, file) {
const upload = new DirectUpload(file, directUploadUrl);
return new Promise((resolve, reject) => {
upload.create((error, blob) => {
if (error) {
reject(error);
} else {
resolve(blob);
}
});
});
}

View file

@ -1,6 +1,6 @@
<template>
<div class="deleted" v-if="deleted">
<input type="hidden" :name="nameFor('id')" :value="item.id">
<input type="hidden" :name="nameFor('id')" :value="id">
<input type="hidden" :name="nameFor('_destroy')" value="true">
</div>
@ -14,6 +14,7 @@
:id="elementIdFor('type_champ')"
:name="nameFor('type_champ')"
v-model="typeChamp"
@change="update"
class="small-margin small inline">
<option v-for="option in state.typesDeChampOptions" :key="option[1]" :value="option[1]">
{{ option[0] }}
@ -21,21 +22,7 @@
</select>
</div>
<div class="flex justify-start 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)">
<button class="button danger" @click.prevent="removeChamp">
Supprimer
</button>
</div>
@ -51,8 +38,9 @@
:id="elementIdFor('libelle')"
:name="nameFor('libelle')"
v-model="libelle"
@change="update"
class="small-margin small"
:class="{ error: isDirty && isInvalid }">
:class="{ error: hasChanges && !isValid }">
</div>
<div class="cell" v-show="!isHeaderSection && !isExplication && !state.isAnnotation">
@ -65,6 +53,7 @@
:id="elementIdFor('mandatory')"
:name="nameFor('mandatory')"
v-model="mandatory"
@change="update"
class="small-margin small"
value="1">
</div>
@ -78,6 +67,7 @@
:id="elementIdFor('description')"
:name="nameFor('description')"
v-model="description"
@change="update"
rows=3
cols=40
class="small-margin small">
@ -93,7 +83,8 @@
<textarea
:id="elementIdFor('drop_down_list')"
:name="nameFor('drop_down_list_attributes[value]')"
v-model="dropDownList"
v-model="dropDownListValue"
@change="update"
rows=3
cols=40
placeholder="Ecrire une valeur par ligne et --valeur-- pour un séparateur."
@ -104,9 +95,9 @@
<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}}
<template v-if="pieceJustificativeTemplateUrl">
<a :href="pieceJustificativeTemplateUrl" target="_blank">
{{pieceJustificativeTemplateFilename}}
</a>
<br> Modifier :
</template>
@ -114,8 +105,7 @@
type="file"
:id="elementIdFor('piece_justificative_template')"
:name="nameFor('piece_justificative_template')"
:data-direct-upload-url="state.directUploadsUrl"
@change="update(changeLog)"
@change="upload"
class="small-margin small">
</div>
<div class="cell" v-show="isCarte">
@ -130,6 +120,7 @@
:id="elementIdFor('quartiers_prioritaires')"
:name="nameFor('quartiers_prioritaires')"
v-model="options.quartiers_prioritaires"
@change="update"
class="small-margin small"
value="1">
Quartiers prioritaires
@ -141,6 +132,7 @@
:id="elementIdFor('cadastres')"
:name="nameFor('cadastres')"
v-model="options.cadastres"
@change="update"
class="small-margin small"
value="1">
Cadastres
@ -152,6 +144,7 @@
:id="elementIdFor('parcelles_agricoles')"
:name="nameFor('parcelles_agricoles')"
v-model="options.parcelles_agricoles"
@change="update"
class="small-margin small"
value="1">
Parcelles Agricoles
@ -163,7 +156,6 @@
<DraggableItem
v-for="(item, index) in typesDeChamp"
:state="stateForRepetition"
:update="update"
:index="index"
:item="item"
:key="item.id" />
@ -181,7 +173,7 @@
</div>
<div class="meta">
<input type="hidden" :name="nameFor('order_place')" :value="index">
<input type="hidden" :name="nameFor('id')" :value="item.id">
<input type="hidden" :name="nameFor('id')" :value="id">
</div>
</div>
</template>

View file

@ -1,13 +1,14 @@
export default {
props: ['state', 'version', 'update', 'updateAll'],
props: ['state', 'version'],
methods: {
addChamp() {
this.state.typesDeChamp.push({
type_champ: 'text',
drop_down_list: {},
types_de_champ: [],
options: {}
types_de_champ: []
});
},
save() {
this.state.flash.success();
}
}
};

View file

@ -10,14 +10,13 @@
</template>
</button>
<button class="button primary" @click.prevent="updateAll">Enregistrer</button>
<button class="button primary" @click.prevent="save">Enregistrer</button>
</div>
<Draggable :list="state.typesDeChamp" :options="{handle:'.handle'}">
<DraggableItem
v-for="(item, index) in state.typesDeChamp"
:state="state"
:update="update"
:index="index"
:item="item"
:key="item.id" />
@ -33,7 +32,7 @@
</template>
</button>
<button class="button primary" @click.prevent="updateAll">Enregistrer</button>
<button class="button primary" @click.prevent="save">Enregistrer</button>
</div>
</div>
</template>

View file

@ -1,6 +1,5 @@
import Vue from 'vue';
import Draggable from 'vuedraggable';
import { fire, debounce } from '@utils';
import DraggableItem from './DraggableItem';
import DraggableList from './DraggableList';
@ -16,109 +15,74 @@ addEventListener('DOMContentLoaded', () => {
});
function initEditor(el) {
const { directUploadsUrl, dragIconUrl } = el.dataset;
const { directUploadUrl, dragIconUrl, saveUrl } = el.dataset;
const state = {
typesDeChamp: JSON.parse(el.dataset.typesDeChamp),
typesDeChampOptions: JSON.parse(el.dataset.typesDeChampOptions),
directUploadsUrl,
directUploadUrl,
dragIconUrl,
saveUrl,
isAnnotation: el.dataset.type === 'annotation',
unsavedItems: new Set(),
unsavedInvalidItems: new Set(),
version: 1,
prefix: 'procedure'
prefix: 'procedure',
inFlight: 0,
flash: new Flash()
};
// We add an initial type de champ here if form is empty
if (state.typesDeChamp.length === 0) {
state.typesDeChamp.push({
type_champ: 'text',
types_de_champ: []
});
}
new Vue({
el,
data: {
state,
update: null
state
},
render(h) {
return h(DraggableList, {
props: {
state: this.state,
update: this.update,
updateAll: this.updateAll
state: this.state
}
});
},
mounted() {
const [update, updateAll] = createUpdateFunctions(
this,
state.isAnnotation
);
this.update = update;
this.updateAll = updateAll;
// We add an initial type de champ here if form is empty
if (this.state.typesDeChamp.length === 0) {
this.state.typesDeChamp.push({
type_champ: 'text',
types_de_champ: []
});
}
}
});
}
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);
class Flash {
constructor(isAnnotation) {
this.element = document.querySelector('#flash_messages');
this.isAnnotation = isAnnotation;
}
success() {
if (this.isAnnotation) {
this.add('Annotations privées enregistrées.');
} else {
app.state.unsavedInvalidItems.add(id);
this.add('Formulaire enregistré.');
}
if (refresh) {
app.state.version += 1;
}
updateAll();
};
}
error(message) {
this.add(message, true);
}
clear() {
this.element.innerHTML = '';
}
add(message, isError) {
const html = `<div id="flash_message" class="center">
<div class="alert alert-fixed ${
isError ? 'alert-danger' : 'alert-success'
}">
${message}
</div>
</div>`;
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);
this.element.innerHTML = html;
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');
setTimeout(() => {
this.clear();
}, 6000);
}
}

View file

@ -38,13 +38,26 @@ function source(url) {
}
addEventListener('turbolinks:load', function() {
autocompleteSetup();
});
addEventListener('ajax:success', function() {
autocompleteSetup();
});
function autocompleteSetup() {
for (let { type, url } of sources) {
for (let target of document.querySelectorAll(selector(type))) {
let select = autocomplete(target, options, [source(url)]);
select.on('autocomplete:selected', ({ target }, suggestion) => {
fire(target, 'autocomplete:select', suggestion);
select.autocomplete.setVal(suggestion.label);
});
for (let element of document.querySelectorAll(selector(type))) {
element.removeAttribute('data-autocomplete');
autocompleteInitializeElement(element, url);
}
}
});
}
function autocompleteInitializeElement(element, url) {
const select = autocomplete(element, options, [source(url)]);
select.on('autocomplete:selected', ({ target }, suggestion) => {
fire(target, 'autocomplete:select', suggestion);
select.autocomplete.setVal(suggestion.label);
});
}

View file

@ -15,7 +15,7 @@ class ApiCarto::API
params = geojson.to_s
RestClient.post(url, params, content_type: 'application/json')
rescue RestClient::InternalServerError, RestClient::BadGateway, RestClient::GatewayTimeout => e
rescue RestClient::InternalServerError, RestClient::BadGateway, RestClient::GatewayTimeout, RestClient::ServiceUnavailable => e
Rails.logger.error "[ApiCarto] Error on #{url}: #{e}"
raise RestClient::ResourceNotFound
end

View file

@ -120,17 +120,11 @@ class Dossier < ApplicationRecord
end
def build_default_champs
procedure.types_de_champ.each do |type_de_champ|
champ = type_de_champ.champ.build
if type_de_champ.repetition?
champ.add_row
end
procedure.build_champs.each do |champ|
champs << champ
end
procedure.types_de_champ_private.each do |type_de_champ|
champs_private << type_de_champ.champ.build
procedure.build_champs_private.each do |champ|
champs_private << champ
end
end
@ -166,7 +160,11 @@ class Dossier < ApplicationRecord
!procedure.archivee? && brouillon?
end
def can_be_updated_by_the_user?
def can_be_updated_by_user?
brouillon? || en_construction?
end
def can_be_deleted_by_user?
brouillon? || en_construction?
end

View file

@ -138,10 +138,15 @@ class Procedure < ApplicationRecord
# Warning: dossier after_save build_default_champs must be removed
# to save a dossier created from this method
def new_dossier
champs = types_de_champ.map { |tdc| tdc.champ.build }
champs_private = types_de_champ_private.map { |tdc| tdc.champ.build }
Dossier.new(procedure: self, champs: build_champs, champs_private: build_champs_private)
end
Dossier.new(procedure: self, champs: champs, champs_private: champs_private)
def build_champs
types_de_champ.map(&:build_champ)
end
def build_champs_private
types_de_champ_private.map(&:build_champ)
end
def default_path

View file

@ -120,6 +120,10 @@ class TypeDeChamp < ApplicationRecord
}
end
def build_champ
dynamic_type.build_champ
end
def self.type_de_champs_list_fr
type_champs.map { |champ| [I18n.t("activerecord.attributes.type_de_champ.type_champs.#{champ.last}"), champ.first] }
end
@ -175,6 +179,14 @@ class TypeDeChamp < ApplicationRecord
end
end
def drop_down_list_value
drop_down_list&.value
end
def drop_down_list_value=(value)
self.drop_down_list_attributes = { value: value }
end
private
def setup_procedure

View file

@ -1,2 +1,7 @@
class TypesDeChamp::RepetitionTypeDeChamp < TypesDeChamp::TypeDeChampBase
def build_champ
champ = super
champ.add_row
champ
end
end

View file

@ -19,4 +19,8 @@ class TypesDeChamp::TypeDeChampBase
}
]
end
def build_champ
@type_de_champ.champ.build
end
end

View file

@ -1,3 +0,0 @@
<%= render_flash timeout: 6000, fixed: true %>
<%= fire_event(:ProcedureUpdated, procedure_data(@procedure.reload)) %>

View file

@ -51,7 +51,7 @@
= link_to(url_for_dossier(dossier), class: 'cell-link') do
= dossier.updated_at.strftime("%d/%m/%Y")
%td.action-col.delete-col
- if dossier.brouillon?
- if dossier.can_be_deleted_by_user?
= link_to(ask_deletion_dossier_path(dossier), method: :post, class: 'button danger', data: { disable: true, confirm: "En continuant, vous allez supprimer ce dossier ainsi que les informations quil contient. Toute suppression entraine lannulation de la démarche en cours.\n\nConfirmer la suppression ?" }) do
%span.icon.delete
Supprimer

View file

@ -5,11 +5,16 @@
= form.fields_for :champs, champ do |form|
= render partial: 'shared/dossiers/editable_champs/editable_champ', locals: { champ: form.object, form: form }
= form.hidden_field :_destroy, disabled: true
%button.button.danger.remove-row
Supprimer
.flex.row-reverse
- if champ.persisted?
%button.button.danger.remove-row
Supprimer
- else
%button.button.danger{ type: :button }
Supprimer
- if champ.persisted?
= link_to "Ajouter une ligne pour « #{champ.libelle} »", champs_repetition_path(form.index), class: 'button add-row', data: { remote: true, method: 'POST', params: { champ_id: champ&.id }.to_query }
- else
%button.button.add-row{ disabled: true }
%button.button{ type: :button }
= "Ajouter une ligne pour « #{champ.libelle} »"

View file

@ -367,6 +367,8 @@ Rails.application.routes.draw do
get 'champs'
get 'annotations'
end
resources :types_de_champ, only: [:create, :update, :destroy]
end
resources :services, except: [:show] do

View file

@ -798,13 +798,13 @@ describe NewUser::DossiersController, type: :controller do
subject { post :ask_deletion, params: { id: dossier.id } }
shared_examples_for "the dossier can not be deleted" do
it do
it "doesnt notify the deletion" do
expect(DossierMailer).not_to receive(:notify_deletion_to_administration)
expect(DossierMailer).not_to receive(:notify_deletion_to_user)
subject
end
it do
it "doesnt delete the dossier" do
subject
expect(Dossier.find_by(id: dossier.id)).not_to eq(nil)
expect(dossier.procedure.deleted_dossiers.count).to eq(0)
@ -814,13 +814,13 @@ describe NewUser::DossiersController, type: :controller do
context 'when dossier is owned by signed in user' do
let(:dossier) { create(:dossier, :en_construction, user: user, autorisation_donnees: true) }
it do
it "notifies the user and the admin of the deletion" do
expect(DossierMailer).to receive(:notify_deletion_to_administration).with(kind_of(DeletedDossier), dossier.procedure.administrateur.email).and_return(double(deliver_later: nil))
expect(DossierMailer).to receive(:notify_deletion_to_user).with(kind_of(DeletedDossier), dossier.user.email).and_return(double(deliver_later: nil))
subject
end
it do
it "deletes the dossier" do
procedure = dossier.procedure
dossier_id = dossier.id
subject

View file

@ -102,7 +102,8 @@ feature 'As an administrateur I wanna create a new procedure', js: true do
expect(page).to have_selector('#procedure_types_de_champ_attributes_0_libelle')
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libelle de champ'
expect(page).to have_content('Formulaire mis à jour')
blur
expect(page).to have_content('Formulaire enregistré')
within '.footer' do
click_on 'Ajouter un champ'
@ -129,7 +130,8 @@ feature 'As an administrateur I wanna create a new procedure', js: true do
page.refresh
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libelle de champ'
expect(page).to have_content('Formulaire mis à jour')
blur
expect(page).to have_content('Formulaire enregistré')
click_on Procedure.last.libelle
click_on 'onglet-pieces'

View file

@ -1,6 +1,6 @@
require 'spec_helper'
feature 'As an administrateur I edit procedure', js: true do
feature 'As an administrateur I can edit types de champ', js: true do
let(:administrateur) { procedure.administrateur }
let(:procedure) { create(:procedure) }
@ -18,14 +18,15 @@ feature 'As an administrateur I edit procedure', js: true do
end
expect(page).to have_selector('#procedure_types_de_champ_attributes_0_libelle')
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libellé de champ'
expect(page).to have_content('Formulaire mis à jour')
blur
expect(page).to have_content('Formulaire enregistré')
page.refresh
within '.footer' do
click_on 'Enregistrer'
end
expect(page).to have_content('Formulaire mis à jour')
expect(page).to have_content('Formulaire enregistré')
end
it "Add multiple champs" do
@ -34,33 +35,29 @@ feature 'As an administrateur I edit procedure', js: true do
click_on 'Ajouter un champ'
click_on 'Ajouter un champ'
end
expect(page).not_to have_content('Le libellé doit être rempli.')
expect(page).not_to have_content('Modifications non sauvegardées.')
expect(page).not_to have_content('Formulaire mis à jour')
expect(page).not_to have_content('Formulaire enregistré')
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libellé de champ 0'
fill_in 'procedure_types_de_champ_attributes_1_libelle', with: 'libellé de champ 1'
blur
expect(page).to have_content('Formulaire enregistré')
expect(page).to have_selector('#procedure_types_de_champ_attributes_0_libelle')
expect(page).to have_selector('#procedure_types_de_champ_attributes_1_libelle')
expect(page).to have_selector('#procedure_types_de_champ_attributes_2_libelle')
expect(page).to have_selector('#procedure_types_de_champ_attributes_3_libelle')
expect(page).to have_content('Le libellé doit être rempli.')
expect(page).to have_content('Modifications non sauvegardées.')
expect(page).not_to have_content('Formulaire mis à jour')
fill_in 'procedure_types_de_champ_attributes_2_libelle', with: 'libellé de champ 2'
within '.draggable-item-3' do
within '.draggable-item-2' do
click_on 'Supprimer'
end
expect(page).to have_content('Le libellé doit être rempli.')
expect(page).to have_content('Modifications non sauvegardées.')
expect(page).not_to have_content('Formulaire mis à jour')
fill_in 'procedure_types_de_champ_attributes_1_libelle', with: 'libellé de champ 1'
expect(page).not_to have_selector('#procedure_types_de_champ_attributes_3_libelle')
fill_in 'procedure_types_de_champ_attributes_2_libelle', with: 'libellé de champ 2'
blur
expect(page).to have_content('Formulaire enregistré')
expect(page).to have_content('Supprimer', count: 3)
expect(page).not_to have_content('Le libellé doit être rempli.')
expect(page).not_to have_content('Modifications non sauvegardées.')
expect(page).to have_content('Formulaire mis à jour')
page.refresh
expect(page).to have_content('Supprimer', count: 3)
@ -68,11 +65,12 @@ feature 'As an administrateur I edit procedure', js: true do
it "Remove champs" do
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libellé de champ'
expect(page).to have_content('Formulaire mis à jour')
blur
expect(page).to have_content('Formulaire enregistré')
page.refresh
click_on 'Supprimer'
expect(page).to have_content('Formulaire mis à jour')
expect(page).to have_content('Formulaire enregistré')
expect(page).not_to have_content('Supprimer')
page.refresh
@ -82,19 +80,21 @@ feature 'As an administrateur I edit procedure', js: true do
it "Only add valid champs" do
expect(page).to have_selector('#procedure_types_de_champ_attributes_0_description')
fill_in 'procedure_types_de_champ_attributes_0_description', with: 'déscription du champ'
expect(page).to have_content('Le libellé doit être rempli.')
expect(page).not_to have_content('Formulaire mis à jour')
blur
expect(page).not_to have_content('Formulaire enregistré')
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libellé de champ'
expect(page).to have_content('Formulaire mis à jour')
blur
expect(page).to have_content('Formulaire enregistré')
end
it "Add repetition champ" do
expect(page).to have_selector('#procedure_types_de_champ_attributes_0_libelle')
select('Bloc répétable', from: 'procedure_types_de_champ_attributes_0_type_champ')
fill_in 'procedure_types_de_champ_attributes_0_libelle', with: 'libellé de champ'
blur
expect(page).to have_content('Formulaire mis à jour')
expect(page).to have_content('Formulaire enregistré')
page.refresh
within '.flex-grow' do
@ -102,8 +102,9 @@ feature 'As an administrateur I edit procedure', js: true do
end
fill_in 'procedure_types_de_champ_attributes_0_types_de_champ_attributes_0_libelle', with: 'libellé de champ 1'
blur
expect(page).to have_content('Formulaire mis à jour')
expect(page).to have_content('Formulaire enregistré')
expect(page).to have_content('Supprimer', count: 2)
within '.footer' do
@ -112,6 +113,7 @@ feature 'As an administrateur I edit procedure', js: true do
select('Bloc répétable', from: 'procedure_types_de_champ_attributes_1_type_champ')
fill_in 'procedure_types_de_champ_attributes_1_libelle', with: 'libellé de champ 2'
blur
expect(page).to have_content('Supprimer', count: 3)
end

View file

@ -1,13 +1,13 @@
require 'spec_helper'
describe 'user access to the list of his dossier' do
describe 'user access to the list of their dossiers' do
let(:user) { create(:user) }
let!(:last_updated_dossier) { create(:dossier, :with_entreprise, user: user, state: Dossier.states.fetch(:en_construction)) }
let!(:dossier1) { create(:dossier, :with_entreprise, user: user, state: Dossier.states.fetch(:en_construction)) }
let!(:dossier2) { create(:dossier, :with_entreprise) }
let!(:dossier_brouillon) { create(:dossier, :with_entreprise, user: user) }
let!(:dossier_archived) { create(:dossier, :with_entreprise, user: user, state: Dossier.states.fetch(:en_construction)) }
let!(:dossier_brouillon) { create(:dossier, user: user) }
let!(:dossier_en_construction) { create(:dossier, :en_construction, user: user) }
let!(:dossier_en_instruction) { create(:dossier, :en_instruction, user: user) }
let!(:dossier_archived) { create(:dossier, :en_instruction, :archived, user: user) }
let(:dossiers_per_page) { 25 }
let(:last_updated_dossier) { dossier_en_construction }
before do
@default_per_page = Dossier.default_per_page
@ -15,12 +15,8 @@ describe 'user access to the list of his dossier' do
last_updated_dossier.update_column(:updated_at, "19/07/2052 15:35".to_time)
visit new_user_session_path
within('#new_user') do
page.find_by_id('user_email').set user.email
page.find_by_id('user_password').set user.password
page.click_on 'Se connecter'
end
login_as user, scope: :user
visit dossiers_path
end
after do
@ -28,52 +24,59 @@ describe 'user access to the list of his dossier' do
end
it 'the list of dossier is displayed' do
expect(page).to have_content(dossier1.procedure.libelle)
expect(page).to have_content('en construction')
end
it 'dossiers belonging to other users are not displayed' do
expect(page).not_to have_content(dossier2.procedure.libelle)
end
it 'the list must be ordered by last updated' do
expect(page.body).to match(/#{last_updated_dossier.procedure.libelle}.*#{dossier1.procedure.libelle}/m)
end
it 'should list archived dossiers' do
expect(page).to have_content(dossier_brouillon.procedure.libelle)
expect(page).to have_content(dossier_en_construction.procedure.libelle)
expect(page).to have_content(dossier_en_instruction.procedure.libelle)
expect(page).to have_content(dossier_archived.procedure.libelle)
end
it 'should have link to only delete brouillon' do
expect(page).to have_link(nil, href: ask_deletion_dossier_path(dossier_brouillon))
expect(page).not_to have_link(nil, href: ask_deletion_dossier_path(dossier1))
it 'the list must be ordered by last updated' do
expect(page.body).to match(/#{last_updated_dossier.procedure.libelle}.*#{dossier_en_instruction.procedure.libelle}/m)
end
context 'when user clicks on delete brouillon', js: true do
scenario 'dossier is deleted' do
page.accept_alert('Confirmer la suppression ?') do
find(:xpath, "//a[@href='#{ask_deletion_dossier_path(dossier_brouillon)}']").click
end
context 'when there are dossiers from other users' do
let!(:dossier_other_user) { create(:dossier) }
expect(page).to have_content('Votre dossier a bien été supprimé.')
end
end
context 'when user clicks on a projet in list', js: true do
before do
page.click_on(dossier1.procedure.libelle)
end
scenario 'user is redirected to dossier page' do
expect(page).to have_current_path(dossier_path(dossier1))
it 'doesnt display dossiers belonging to other users' do
expect(page).not_to have_content(dossier_other_user.procedure.libelle)
end
end
context 'when there is more than one page' do
let(:dossiers_per_page) { 2 }
scenario 'the user can navigate through the other pages', js: true do
scenario 'the user can navigate through the other pages' do
expect(page).not_to have_content(dossier_en_instruction.procedure.libelle)
page.click_link("Suivant")
expect(page).to have_content(dossier_archived.procedure.libelle)
expect(page).to have_content(dossier_en_instruction.procedure.libelle)
end
end
context 'when user clicks on a projet in list' do
before do
page.click_on(dossier_en_construction.procedure.libelle)
end
scenario 'user is redirected to dossier page' do
expect(page).to have_current_path(dossier_path(dossier_en_construction))
end
end
describe 'deletion' do
it 'should have links to delete dossiers' do
expect(page).to have_link(nil, href: ask_deletion_dossier_path(dossier_brouillon))
expect(page).to have_link(nil, href: ask_deletion_dossier_path(dossier_en_construction))
expect(page).not_to have_link(nil, href: ask_deletion_dossier_path(dossier_en_instruction))
end
context 'when user clicks on delete button', js: true do
scenario 'the dossier is deleted' do
page.accept_alert('Confirmer la suppression ?') do
find(:xpath, "//a[@href='#{ask_deletion_dossier_path(dossier_brouillon)}']").click
end
expect(page).to have_content('Votre dossier a bien été supprimé.')
end
end
end
@ -91,25 +94,27 @@ describe 'user access to the list of his dossier' do
end
context "when the dossier does not belong to the user" do
let!(:dossier_other_user) { create(:dossier) }
before do
page.find_by_id('dossier_id').set(dossier2.id)
page.find_by_id('dossier_id').set(dossier_other_user.id)
click_button("Rechercher")
end
it "shows an error message on the dossiers page" do
expect(current_path).to eq(dossiers_path)
expect(page).to have_content("Vous navez pas de dossier avec le nº #{dossier2.id}.")
expect(page).to have_content("Vous navez pas de dossier avec le nº #{dossier_other_user.id}.")
end
end
context "when the dossier belongs to the user" do
before do
page.find_by_id('dossier_id').set(dossier1.id)
page.find_by_id('dossier_id').set(dossier_en_construction.id)
click_button("Rechercher")
end
it "redirects to the dossier page" do
expect(current_path).to eq(dossier_path(dossier1))
expect(current_path).to eq(dossier_path(dossier_en_construction))
end
end
end

View file

@ -58,6 +58,10 @@ module FeatureHelpers
# Procedure contact infos in the footer
expect(page).to have_content(procedure.service.email)
end
def blur
page.find('body').click
end
end
RSpec.configure do |config|