Merge pull request #9021 from colinux/feat-en-construction-fork

ETQ usager, je modifie et soumets à nouveau mon dossier “en construction"
This commit is contained in:
Colin Darie 2023-05-10 19:46:40 +00:00 committed by GitHub
commit 79f450a422
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 839 additions and 303 deletions

View file

@ -25,4 +25,3 @@ $blue-france-500: #000091;
$blue-france-400: #7F7FC8;
$blue-cumulus-950: #E6EEFE;
$g700: #383838;
$alt-blue-france: rgba(245, 245, 254, 1);

View file

@ -142,7 +142,7 @@ $landing-breakpoint: 1040px;
.usagers-panel,
.numbers-panel,
.cta-panel-2 {
background-color: $alt-blue-france;
background-color: var(--background-alt-blue-france);
}
.more-info {

View file

@ -1,15 +1,8 @@
@import "colors";
@import "constants";
.status-overview {
text-align: center;
margin-bottom: $default-padding * 2;
}
.status-timeline {
display: inline-block;
margin-top: $default-padding * 2;
margin-bottom: $default-padding * 2;
border: 1px solid #808080;
border-radius: 3px;
@ -46,15 +39,6 @@
}
.status-explanation {
text-align: left;
.brouillon,
.en-construction,
.en-instruction {
max-width: 650px;
margin: auto;
}
p {
margin-bottom: $default-padding;
}

View file

@ -2,7 +2,7 @@
@import "constants";
.sub-header {
background-color: $alt-blue-france;
background-color: var(--background-alt-blue-france);
padding-top: $default-padding;
margin-bottom: $sub-header-bottom-margin;
border-bottom: 1px solid $border-grey;

View file

@ -5,9 +5,9 @@ en:
confirmation: Draft saved
error: Impossible to save the draft
en_construction:
explanation: Your file is automatically saved.
confirmation: File saved
error: Impossible to save the file
explanation: Your modifications are automatically saved. Submit them when youre done.
confirmation: Modifications saved
error: Impossible to save the modifications.
annotations:
explanation: Your annotations are automatically saved.
confirmation: Annotations saved

View file

@ -5,11 +5,11 @@ fr:
confirmation: Brouillon enregistré
error: Impossible denregistrer le brouillon
en_construction:
explanation: Votre dossier est automatiquement enregistré.
confirmation: Dossier enregistré
error: Impossible denregistrer le dossier
explanation: Vos modifications sont automatiquement enregistrées. Déposez-les quand vous aurez terminé.
confirmation: Modifications enregistrées.
error: Impossible denregistrer les modifications
annotations:
explanation: Vos annotations sont automatiquement enregistrées.
confirmation: Annotations enregistré
confirmation: Annotations enregistrées
error: Impossible denregistrer les annotations
more_information: En savoir plus

View file

@ -3,10 +3,10 @@
%span.autosave-explanation-text
- if annotation?
= t('.annotations.explanation')
- elsif dossier.brouillon?
= t('.brouillon.explanation')
- else
- elsif dossier.editing_fork?
= t('.en_construction.explanation')
- else
= t('.brouillon.explanation')
- if !annotation?
= link_to t('.more_information'), t("links.common.faq.autosave_url"), class: 'autosave-more-infos fr-link fr-link--sm', **external_link_attributes
@ -15,10 +15,10 @@
%span.autosave-label
- if annotation?
= t('.annotations.confirmation')
- elsif dossier.brouillon?
= t('.brouillon.confirmation')
- else
- elsif dossier.editing_fork?
= t('.en_construction.confirmation')
- else
= t('.brouillon.confirmation')
- if !annotation?
= link_to t('.more_information'), t("links.common.faq.autosave_url"), class: 'autosave-more-infos fr-link fr-link--sm', **external_link_attributes
@ -27,11 +27,11 @@
%span.autosave-label
- if annotation?
= t('.annotations.error')
- elsif dossier.brouillon?
= t('.brouillon.error')
- else
- elsif dossier.editing_fork?
= t('.en_construction.error')
%button.button.small.autosave-retry{ type: :button, data: { action: 'autosave-status#onClickRetryButton', autosave_status_target: 'retryButton' } }
%span.autosave-retry-label réessayer
%span.autosave-retrying-label enregistrement en cours…
- else
= t('.brouillon.error')
%button.fr-btn.fr-btn--tertiary.fr-btn--sm.autosave-retry{ type: :button, data: { action: 'autosave-status#onClickRetryButton', autosave_status_target: 'retryButton' } }
%span.autosave-retry-label Réessayer
%span.autosave-retrying-label Enregistrement en cours…

View file

@ -14,7 +14,7 @@ class Dossiers::EditFooterComponent < ApplicationComponent
@annotation.present?
end
def button_options
def submit_draft_button_options
{
class: 'fr-btn fr-btn--sm',
disabled: !owner?,
@ -23,6 +23,14 @@ class Dossiers::EditFooterComponent < ApplicationComponent
}
end
def submit_en_construction_button_options
{
class: 'fr-btn fr-btn--sm',
method: :post,
data: { 'disable-with': t('.submitting'), controller: 'autosave-submit' }
}
end
def render?
!@dossier.for_procedure_preview?
end

View file

@ -1,5 +1,6 @@
---
en:
submit: Submit the file
submit_changes: Submit file changes
submitting: Submitting…
invite_notice: You are invited to make amendments to this file but only the owner themselves can submit it.

View file

@ -1,5 +1,6 @@
---
fr:
submit: Déposer le dossier
submit_changes: Déposer les modifications
submitting: Envoi en cours…
invite_notice: En tant quinvité, vous pouvez remplir ce formulaire mais le titulaire du dossier doit le déposer lui-même.

View file

@ -3,7 +3,10 @@
= render Dossiers::AutosaveFooterComponent.new(dossier: @dossier, annotation: annotation?)
- if !annotation? && @dossier.can_transition_to_en_construction?
= button_to t('.submit'), brouillon_dossier_url(@dossier), button_options
= button_to t('.submit'), brouillon_dossier_url(@dossier), submit_draft_button_options
- elsif @dossier.forked_with_changes?
= button_to t('.submit_changes'), modifier_dossier_url(@dossier.editing_fork_origin), submit_en_construction_button_options
- if @dossier.brouillon? && !owner?
.send-notice.invite-cannot-submit

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Dossiers::EnConstructionNotSubmittedComponent < ApplicationComponent
attr_reader :dossier
attr_reader :user
def initialize(dossier:, user:)
@dossier = dossier
@user = user
@fork = @dossier.find_editing_fork(user, rebase: false)
end
def render?
@fork&.forked_with_changes?
end
end

View file

@ -0,0 +1,9 @@
---
en:
title: Modifications not yet submitted
body: |
You have made changes after submitting your file. Submit them
so that the administration can take them into account.
buttons:
edit: Continue to edit
submit: Submit file changes

View file

@ -0,0 +1,9 @@
---
fr:
title: Des modifications nont pas encore été déposées
body: |
Vous avez apporté des modifications après le dépôt de votre dossier.
Déposez-les afin que ladministration les prenne en compte.
buttons:
edit: Continuer à modifier
submit: Déposer les modifications

View file

@ -0,0 +1,9 @@
= render Dsfr::CalloutComponent.new(title: t(".title"), icon: "fr-fi-information-line") do |c|
- c.body do
= t(".body")
- c.bottom do
%ul.fr-mt-2w.fr-btns-group.fr-btns-group--inline
%li= link_to t(".buttons.edit"), modifier_dossier_path(dossier), class: "fr-btn"
%li= button_to t(".buttons.submit"), modifier_dossier_path(dossier), class: "fr-btn fr-btn--secondary", method: :post

View file

@ -27,7 +27,7 @@ class Dsfr::CalloutComponent < ApplicationComponent
when :success
"fr-callout--green-emeraude"
else
# info is default theme
"fr-background-alt--blue-france"
end
end
end

View file

@ -3,7 +3,7 @@
= @form.label @champ.main_value_name, id: @champ.labelledby_id, for: @champ.input_id do
- render EditableChamp::ChampLabelContentComponent.new champ: @champ, seen_at: @seen_at
- else
.form-label.mb-4
.form-label.mb-4{ id: @champ.labelledby_id }
= render EditableChamp::ChampLabelContentComponent.new champ: @champ, seen_at: @seen_at
- if @champ.description.present?

View file

@ -0,0 +1,5 @@
---
en:
changes_to_save: "modifications to submit"
modified_at: "modified on %{datetime}"
check_content_rebased: The type of this field or its description has been modified by the administration. Check its content.

View file

@ -0,0 +1,5 @@
---
fr:
changes_to_save: "modification à déposer"
modified_at: "modifié le %{datetime}"
check_content_rebased: Le type de ce champ ou sa description ont été modifiés par l'administration. Vérifier son contenu.

View file

@ -2,10 +2,13 @@
- if @champ.mandatory?
%span.mandatory *
- if @champ.updated_at.present? && @seen_at.present?
- if @champ.forked_with_changes?
%span.updated-at.highlighted
= t('.changes_to_save')
- elsif @champ.updated_at.present? && @seen_at.present?
%span.updated-at{ class: highlight_if_unseen_class }
= "modifié le #{try_format_datetime(@champ.updated_at)}"
= t('.modified_at', datetime: try_format_datetime(@champ.updated_at))
- if @champ.rebased_at.present? && @champ.rebased_at > (@seen_at || @champ.updated_at) && current_user.owns_or_invite?(@champ.dossier)
%span.updated-at.highlighted
Le type de ce champ ou sa description ont été modifiés par l'administration. Vérifier son contenu.
= t('.check_content_rebased')

View file

@ -6,7 +6,7 @@ module TurboChampsConcern
def champs_to_turbo_update(params, champs)
champ_ids = params.keys.map(&:to_i)
to_update = champs.filter { _1.id.in?(champ_ids) && _1.refresh_after_update? }
to_update = champs.filter { _1.id.in?(champ_ids) && (_1.refresh_after_update? || _1.forked_with_changes?) }
to_show, to_hide = champs.filter(&:conditional?)
.partition(&:visible?)
.map { champs_to_one_selector(_1 - to_update) }

View file

@ -6,12 +6,12 @@ module Users
layout 'procedure_context', only: [:identite, :update_identite, :siret, :update_siret]
ACTIONS_ALLOWED_TO_ANY_USER = [:index, :recherche, :new, :transferer_all]
ACTIONS_ALLOWED_TO_OWNER_OR_INVITE = [:show, :destroy, :demande, :messagerie, :brouillon, :update_brouillon, :submit_brouillon, :modifier, :update, :create_commentaire, :papertrail, :restore]
ACTIONS_ALLOWED_TO_OWNER_OR_INVITE = [:show, :destroy, :demande, :messagerie, :brouillon, :submit_brouillon, :submit_en_construction, :modifier, :modifier_legacy, :update, :create_commentaire, :papertrail, :restore]
before_action :ensure_ownership!, except: ACTIONS_ALLOWED_TO_ANY_USER + ACTIONS_ALLOWED_TO_OWNER_OR_INVITE
before_action :ensure_ownership_or_invitation!, only: ACTIONS_ALLOWED_TO_OWNER_OR_INVITE
before_action :ensure_dossier_can_be_updated, only: [:update_identite, :update_siret, :brouillon, :update_brouillon, :submit_brouillon, :modifier, :update]
before_action :ensure_dossier_can_be_filled, only: [:brouillon, :modifier, :update_brouillon, :submit_brouillon, :update]
before_action :ensure_dossier_can_be_updated, only: [:update_identite, :update_siret, :brouillon, :submit_brouillon, :submit_en_construction, :modifier, :modifier_legacy, :update]
before_action :ensure_dossier_can_be_filled, only: [:brouillon, :modifier, :submit_brouillon, :submit_en_construction, :update]
before_action :ensure_dossier_can_be_viewed, only: [:show]
before_action :forbid_invite_submission!, only: [:submit_brouillon]
before_action :forbid_closed_submission!, only: [:submit_brouillon]
@ -174,6 +174,7 @@ module Users
errors = submit_dossier_and_compute_errors
if errors.blank?
RoutingEngine.compute(@dossier)
@dossier.passer_en_construction!
@dossier.process_declarative!
NotificationMailer.send_en_construction_notification(@dossier).deliver_later
@ -202,21 +203,47 @@ module Users
@dossier = dossier_with_champs
end
def update_brouillon
@dossier = dossier_with_champs
update_dossier_and_compute_errors
# Transition to en_construction forks,
# so users editing en_construction dossiers won't completely break their changes.
# TODO: remove me after fork en_construction feature deploy (PR #8790)
def modifier_legacy
respond_to do |format|
format.html { render :brouillon }
format.turbo_stream do
@to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_params.fetch(:champs_public_all_attributes), dossier.champs_public_all)
flash.alert = "Une mise à jour de cette page est nécessaire pour poursuivre, veuillez la recharger (touche F5). Attention: le dernier champ modifié na pas été sauvegardé, vous devrez le ressaisir."
end
end
end
render(:update, layout: false)
def submit_en_construction
@dossier = dossier.find_editing_fork(dossier.user)
@dossier = dossier_with_champs(pj_template: false)
errors = submit_dossier_and_compute_errors
if errors.blank?
editing_fork_origin = @dossier.editing_fork_origin
editing_fork_origin.merge_fork(@dossier)
RoutingEngine.compute(editing_fork_origin)
redirect_to dossier_path(editing_fork_origin)
else
flash.now.alert = errors
respond_to do |format|
format.html do
@dossier = @dossier.editing_fork_origin
render :modifier
end
format.turbo_stream do
@to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_params.fetch(:champs_public_all_attributes), dossier.champs_public_all)
render :update, layout: false
end
end
end
end
def update
@dossier = dossier.en_construction? ? dossier.find_editing_fork(dossier.user) : dossier
@dossier = dossier_with_champs(pj_template: false)
errors = update_dossier_and_compute_errors
@ -225,9 +252,9 @@ module Users
end
respond_to do |format|
format.html { render :modifier }
format.turbo_stream do
@to_show, @to_hide, @to_update = champs_to_turbo_update(champs_public_params.fetch(:champs_public_all_attributes), dossier.champs_public_all)
render :update, layout: false
end
end
end
@ -425,8 +452,8 @@ module Users
end
def dossier_scope
if action_name == 'update_brouillon'
Dossier.visible_by_user.or(Dossier.for_procedure_preview)
if action_name == 'update'
Dossier.visible_by_user.or(Dossier.for_procedure_preview).or(Dossier.for_editing_fork)
elsif action_name == 'restore'
Dossier.hidden_by_user
else
@ -484,14 +511,6 @@ module Users
@dossier.assign_to_groupe_instructeur(groupe_instructeur_from_params)
end
if @dossier.procedure.feature_enabled?(:routing_rules)
RoutingEngine.compute(@dossier)
end
if dossier.en_construction?
errors += format_errors(errors: @dossier.check_mandatory_and_visible_champs)
end
errors
end
@ -506,7 +525,7 @@ module Users
@dossier.assign_to_groupe_instructeur(defaut_groupe_instructeur)
end
if @dossier.groupe_instructeur.nil?
if !@dossier.procedure.feature_enabled?(:routing_rules) && @dossier.groupe_instructeur.nil?
errors += format_errors(errors: ["Le champ « #{@dossier.procedure.routing_criteria_name} » doit être rempli"])
end
@ -528,9 +547,12 @@ module Users
def append_anchor_link(str_error, model)
return str_error.full_message if !model.is_a?(Champ)
route_helper = @dossier.editing_fork? ? :modifier_dossier_path : :brouillon_dossier_path
[
"Le champ « #{model.libelle.truncate(200)} » #{str_error}",
helpers.link_to(t('views.users.dossiers.fix_champ'), brouillon_dossier_path(anchor: model.input_id))
helpers.link_to(t('views.users.dossiers.fix_champ'), public_send(route_helper, anchor: model.input_id))
].join(", ")
rescue # case of invalid type de champ on champ
str_error

View file

@ -11,7 +11,7 @@ module Mutations
field :errors, [Types::ValidationErrorType], null: true
def resolve(dossier:, groupe_instructeur:)
dossier.update!(groupe_instructeur:)
dossier.assign_to_groupe_instructeur(groupe_instructeur)
{ dossier: }
end

View file

@ -0,0 +1,7 @@
class DestroyRecordLaterJob < ApplicationJob
discard_on ActiveRecord::RecordNotFound
def perform(record)
record.destroy
end
end

View file

@ -0,0 +1,8 @@
class DossierUpdateSearchTermsJob < ApplicationJob
discard_on ActiveRecord::RecordNotFound
def perform(dossier)
dossier.update_search_terms
dossier.save!(touch: false)
end
end

View file

@ -5,7 +5,7 @@
# id :integer not null, primary key
# data :jsonb
# fetch_external_data_exceptions :string is an Array
# prefilled :boolean default(FALSE)
# prefilled :boolean
# private :boolean default(FALSE), not null
# rebased_at :datetime
# type :string
@ -111,6 +111,10 @@ class Champ < ApplicationRecord
parent_id.present?
end
def stable_id_with_row
[row_id, stable_id].compact
end
def sections
@sections ||= dossier.sections_for(self)
end
@ -226,10 +230,10 @@ class Champ < ApplicationRecord
update!(data: data)
end
def clone
def clone(fork = false)
champ_attributes = [:parent_id, :private, :row_id, :type, :type_de_champ_id]
value_attributes = private? ? [] : [:value, :value_json, :data, :external_id]
relationships = private? ? [] : [:etablissement, :geo_areas]
value_attributes = fork || !private? ? [:value, :value_json, :data, :external_id] : []
relationships = fork || !private? ? [:etablissement, :geo_areas] : []
deep_clone(only: champ_attributes + value_attributes, include: relationships) do |original, kopy|
PiecesJustificativesService.clone_attachments(original, kopy)
@ -240,6 +244,10 @@ class Champ < ApplicationRecord
input_id
end
def forked_with_changes?
public? && dossier.champ_forked_with_changes?(self)
end
private
def html_id

View file

@ -0,0 +1,174 @@
module DossierCloneConcern
extend ActiveSupport::Concern
included do
belongs_to :parent_dossier, class_name: 'Dossier', optional: true
has_many :cloned_dossiers, class_name: 'Dossier', foreign_key: :parent_dossier_id, dependent: :nullify, inverse_of: :parent_dossier
belongs_to :editing_fork_origin, class_name: 'Dossier', optional: true
has_many :editing_forks, -> { where(hidden_by_reason: nil) }, class_name: 'Dossier', foreign_key: :editing_fork_origin_id, dependent: :destroy, inverse_of: :editing_fork_origin
end
def find_or_create_editing_fork(user)
find_editing_fork(user) || clone(user:, fork: true)
end
def find_editing_fork(user, rebase: true)
fork = editing_forks.find_by(user:)
fork.rebase! if rebase && fork
fork
end
def owner_editing_fork
find_or_create_editing_fork(user).tap { DossierPreloader.load_one(_1) }
end
def reset_editing_fork!
if editing_fork? && forked_with_changes?
destroy_editing_fork!
end
end
def destroy_editing_fork!
if editing_fork?
update!(hidden_by_administration_at: Time.current, hidden_by_reason: :stale_fork)
DestroyRecordLaterJob.perform_later(self)
end
end
def editing_fork?
editing_fork_origin_id.present?
end
def make_diff(editing_fork)
origin_champs_index = champs_public_all.index_by(&:stable_id_with_row)
forked_champs_index = editing_fork.champs_public_all.index_by(&:stable_id_with_row)
updated_champs_index = editing_fork
.champs_public_all
.filter { _1.updated_at > editing_fork.created_at }
.index_by(&:stable_id_with_row)
added = forked_champs_index.keys - origin_champs_index.keys
removed = origin_champs_index.keys - forked_champs_index.keys
updated = updated_champs_index.keys - added
{
added: added.map { forked_champs_index[_1] },
updated: updated.map { forked_champs_index[_1] },
removed: removed.map { origin_champs_index[_1] }
}
end
def merge_fork(editing_fork)
return false if invalid? || editing_fork.invalid?
return false if revision_id > editing_fork.revision_id
diff = make_diff(editing_fork)
transaction do
apply_diff(diff)
update(revision_id: editing_fork.revision_id, last_champ_updated_at: Time.zone.now)
assign_to_groupe_instructeur(editing_fork.groupe_instructeur)
end
reload
update_search_terms_later
editing_fork.destroy_editing_fork!
end
def clone(user: nil, fork: false)
dossier_attributes = [:autorisation_donnees, :revision_id, :groupe_instructeur_id]
relationships = [:individual, :etablissement]
cloned_champs = champs
.index_by(&:id)
.transform_values { [_1, _1.clone(fork)] }
cloned_dossier = deep_clone(only: dossier_attributes, include: relationships) do |original, kopy|
PiecesJustificativesService.clone_attachments(original, kopy)
if original.is_a?(Dossier)
if fork
kopy.editing_fork_origin = original
else
kopy.parent_dossier = original
end
kopy.user = user || original.user
kopy.state = Dossier.states.fetch(:brouillon)
kopy.champs = cloned_champs.values.map do |(_, champ)|
champ.dossier = kopy
champ.parent = cloned_champs[champ.parent_id].second if champ.child?
champ
end
end
end
transaction do
cloned_dossier.save!
if fork
cloned_champs.values.each do |(original, champ)|
champ.update_columns(created_at: original.created_at, updated_at: original.updated_at)
end
cloned_dossier.rebase!
end
end
cloned_dossier.reload
end
def forked_with_changes?
if forked_diff.present?
forked_diff.values.any?(&:present?) || forked_groupe_instructeur_changed?
end
end
def champ_forked_with_changes?(champ)
if forked_diff.present?
forked_diff.values.any? { _1.include?(champ) }
end
end
private
def forked_diff
@forked_diff ||= editing_fork? ? editing_fork_origin.make_diff(self) : nil
end
def forked_groupe_instructeur_changed?
editing_fork_origin.groupe_instructeur_id != groupe_instructeur_id
end
def apply_diff(diff)
champs_index = (champs + diff[:added]).index_by(&:stable_id_with_row)
diff[:added].each do |champ|
if champ.child?
champ.update_columns(dossier_id: id, parent_id: champs_index[champ.parent.stable_id_with_row].id)
else
champ.update_column(:dossier_id, id)
end
end
champs_to_remove = []
diff[:updated].each do |champ|
old_champ = champs_index[champ.stable_id_with_row]
champs_to_remove << old_champ
if champ.child?
# we need to do that in order to avoid a foreign key constraint
old_champ.update(row_id: nil)
champ.update_columns(dossier_id: id, parent_id: champs_index[champ.parent.stable_id_with_row].id)
else
champ.update_column(:dossier_id, id)
end
end
champs_to_remove += diff[:removed]
champs_to_remove
.filter { !_1.child? || !champs_to_remove.include?(_1.parent) }
.each(&:destroy!)
end
end

View file

@ -0,0 +1,23 @@
module DossierSearchableConcern
extend ActiveSupport::Concern
included do
before_save :update_search_terms
def update_search_terms
self.search_terms = [
user&.email,
*champs_public.flat_map(&:search_terms),
*etablissement&.search_terms,
individual&.nom,
individual&.prenom
].compact_blank.join(' ')
self.private_search_terms = champs_private.flat_map(&:search_terms).compact_blank.join(' ')
end
def update_search_terms_later
DossierUpdateSearchTermsJob.perform_later(self)
end
end
end

View file

@ -40,6 +40,7 @@
# updated_at :datetime
# batch_operation_id :bigint
# dossier_transfer_id :bigint
# editing_fork_origin_id :bigint
# groupe_instructeur_id :bigint
# parent_dossier_id :bigint
# revision_id :bigint
@ -49,7 +50,9 @@ class Dossier < ApplicationRecord
include DossierFilteringConcern
include DossierPrefillableConcern
include DossierRebaseConcern
include DossierSearchableConcern
include DossierSectionsConcern
include DossierCloneConcern
enum state: {
brouillon: 'brouillon',
@ -148,7 +151,6 @@ class Dossier < ApplicationRecord
belongs_to :groupe_instructeur, optional: true
belongs_to :revision, class_name: 'ProcedureRevision', optional: false
belongs_to :user, optional: true
belongs_to :parent_dossier, class_name: 'Dossier', optional: true
belongs_to :batch_operation, optional: true
has_many :dossier_batch_operations, dependent: :destroy
has_many :batch_operations, through: :dossier_batch_operations
@ -161,7 +163,6 @@ class Dossier < ApplicationRecord
belongs_to :transfer, class_name: 'DossierTransfer', foreign_key: 'dossier_transfer_id', optional: true, inverse_of: :dossiers
has_many :transfer_logs, class_name: 'DossierTransferLog', dependent: :destroy
has_many :cloned_dossiers, class_name: 'Dossier', foreign_key: 'parent_dossier_id', dependent: :nullify, inverse_of: :parent_dossier
accepts_nested_attributes_for :champs
accepts_nested_attributes_for :champs_public
@ -236,7 +237,7 @@ class Dossier < ApplicationRecord
scope :prefilled, -> { where(prefilled: true) }
scope :hidden_by_user, -> { where.not(hidden_by_user_at: nil) }
scope :hidden_by_administration, -> { where.not(hidden_by_administration_at: nil) }
scope :visible_by_user, -> { where(for_procedure_preview: false).or(where(for_procedure_preview: nil)).where(hidden_by_user_at: nil) }
scope :visible_by_user, -> { where(for_procedure_preview: false).or(where(for_procedure_preview: nil)).where(hidden_by_user_at: nil, editing_fork_origin_id: nil) }
scope :visible_by_administration, -> {
state_not_brouillon
.where(hidden_by_administration_at: nil)
@ -247,6 +248,7 @@ class Dossier < ApplicationRecord
state_not_brouillon.hidden_by_administration.or(state_en_construction.hidden_by_user)
}
scope :for_procedure_preview, -> { where(for_procedure_preview: true) }
scope :for_editing_fork, -> { where.not(editing_fork_origin_id: nil) }
scope :order_by_updated_at, -> (order = :desc) { order(updated_at: order) }
scope :order_by_created_at, -> (order = :asc) { order(depose_at: order, created_at: order, id: order) }
@ -453,8 +455,7 @@ class Dossier < ApplicationRecord
delegate :siret, :siren, to: :etablissement, allow_nil: true
delegate :france_connect_information, to: :user, allow_nil: true
before_save :build_default_champs_for_new_dossier, if: Proc.new { revision_id_was.nil? && parent_dossier_id.nil? }
before_save :update_search_terms
before_save :build_default_champs_for_new_dossier, if: Proc.new { revision_id_was.nil? && parent_dossier_id.nil? && editing_fork_origin_id.nil? }
after_save :send_web_hook
@ -503,17 +504,6 @@ class Dossier < ApplicationRecord
end
end
def update_search_terms
self.search_terms = [
user&.email,
*champs_public.flat_map(&:search_terms),
*etablissement&.search_terms,
individual&.nom,
individual&.prenom
].compact.join(' ')
self.private_search_terms = champs_private.flat_map(&:search_terms).compact.join(' ')
end
def build_default_champs_for_new_dossier
revision.build_champs_public.each do |champ|
champs_public << champ
@ -556,7 +546,7 @@ class Dossier < ApplicationRecord
end
def can_transition_to_en_construction?
brouillon? && procedure.dossier_can_transition_to_en_construction? && !for_procedure_preview?
brouillon? && procedure.dossier_can_transition_to_en_construction? && !for_procedure_preview? && !editing_fork?
end
def can_terminer?
@ -679,20 +669,16 @@ class Dossier < ApplicationRecord
end
def assign_to_groupe_instructeur(groupe_instructeur, author = nil)
if (groupe_instructeur.nil? || groupe_instructeur.procedure == procedure) && self.groupe_instructeur != groupe_instructeur
if update(groupe_instructeur:, groupe_instructeur_updated_at: Time.zone.now)
if !brouillon?
unfollow_stale_instructeurs
return if groupe_instructeur.present? && groupe_instructeur.procedure != procedure
return if self.groupe_instructeur == groupe_instructeur
if author.present?
log_dossier_operation(author, :changer_groupe_instructeur, self)
end
end
update!(groupe_instructeur:, groupe_instructeur_updated_at: Time.zone.now)
true
if !brouillon?
unfollow_stale_instructeurs
if author.present?
log_dossier_operation(author, :changer_groupe_instructeur, self)
end
else
false
end
end
@ -1238,32 +1224,6 @@ class Dossier < ApplicationRecord
termine_expired_to_delete.find_each(&:purge_discarded)
end
def clone
dossier_attributes = [:autorisation_donnees, :user_id, :revision_id, :groupe_instructeur_id]
relationships = [:individual, :etablissement]
cloned_dossier = deep_clone(only: dossier_attributes, include: relationships) do |original, kopy|
PiecesJustificativesService.clone_attachments(original, kopy)
if original.is_a?(Dossier)
kopy.parent_dossier_id = original.id
kopy.state = Dossier.states.fetch(:brouillon)
cloned_champs = original.champs
.index_by(&:id)
.transform_values(&:clone)
kopy.champs = cloned_champs.values.map do |champ|
champ.dossier = kopy
champ.parent = cloned_champs[champ.parent_id] if champ.child?
champ
end
end
end
transaction { cloned_dossier.save! }
cloned_dossier.reload
end
def find_champs_by_stable_ids(stable_ids)
return [] if stable_ids.compact.empty?

View file

@ -1,9 +1,11 @@
module RoutingEngine
def self.compute(dossier)
return if !dossier.procedure.feature_enabled?(:routing_rules)
matching_groupe = dossier.procedure.groupe_instructeurs.active.find do |gi|
gi.routing_rule&.compute(dossier.champs)
end
matching_groupe ||= dossier.procedure.defaut_groupe_instructeur
dossier.update!(groupe_instructeur: matching_groupe)
dossier.assign_to_groupe_instructeur(matching_groupe)
end
end

View file

@ -1,17 +1,13 @@
- dossier_for_editing = dossier.en_construction? ? dossier.owner_editing_fork : dossier
- if dossier.france_connect_information.present?
- content_for(:notice_info) do
= render partial: "shared/dossiers/france_connect_informations_notice", locals: { user_information: dossier.france_connect_information }
.dossier-edit.container.counter-start-header-section
= render partial: "shared/dossiers/submit_is_over", locals: { dossier: dossier }
- if dossier.brouillon?
- form_options = { url: brouillon_dossier_url(dossier), method: :patch }
- else
- form_options = { url: modifier_dossier_url(dossier), method: :patch }
= render NestedForms::FormOwnerComponent.new
= form_for dossier, form_options.merge({ html: { id: 'dossier-edit-form', class: 'form', multipart: true, novalidate: 'novalidate' } }) do |f|
= form_for dossier_for_editing, url: brouillon_dossier_url(dossier), method: :patch, html: { id: 'dossier-edit-form', class: 'form', multipart: true, novalidate: 'novalidate' } do |f|
%header.mb-6
.fr-highlight
%p.fr-text--sm
@ -42,5 +38,7 @@
= f.select :groupe_instructeur_id,
dossier.procedure.groupe_instructeurs.active.map { |gi| [gi.label, gi.id] },
{ include_blank: dossier.brouillon? }
= render EditableChamp::SectionComponent.new(champs: dossier.champs_public)
= render Dossiers::EditFooterComponent.new(dossier: dossier, annotation: false)
= render EditableChamp::SectionComponent.new(champs: dossier_for_editing.champs_public)
= render Dossiers::EditFooterComponent.new(dossier: dossier_for_editing, annotation: false)

View file

@ -6,6 +6,12 @@
.dossier-container.mb-4
= render partial: 'users/dossiers/show/header', locals: { dossier: @dossier }
- if @dossier.en_construction?
.fr-container
.fr-grid-row.fr-grid-row--center
.fr-col-md-10.fr-col-lg-9
= render Dossiers::EnConstructionNotSubmittedComponent.new(dossier: @dossier, user: current_user)
= render partial: 'shared/dossiers/demande', locals: { dossier: @dossier, demande_seen_at: nil, profile: 'usager' }
.container

View file

@ -0,0 +1 @@

View file

@ -6,7 +6,7 @@
.dossier-container.mb-4
= render partial: 'users/dossiers/show/header', locals: { dossier: @dossier }
.container
.fr-container
= render partial: 'users/dossiers/show/status_overview', locals: { dossier: @dossier }
= render partial: 'users/dossiers/show/papertrail', locals: { dossier: @dossier }

View file

@ -1,80 +1,87 @@
.status-overview
.fr-mb-4w
- if !dossier.termine?
%ul.status-timeline
- if dossier.brouillon?
%li.brouillon{ class: dossier.brouillon? ? 'active' : nil }
= t('views.users.dossiers.show.status_overview.status_draft')
%li.en-construction{ class: dossier.en_construction? ? 'active' : nil }
= t('views.users.dossiers.show.status_overview.status_in_progress')
%li.en-instruction{ class: dossier.en_instruction? ? 'active' : nil }
= t('views.users.dossiers.show.status_overview.status_review')
%li.termine{ class: dossier.termine? ? 'active' : nil }
= t('views.users.dossiers.show.status_overview.status_completed')
.fr-grid-row.fr-grid-row--center
.fr-col-md-8.text-center
%ul.status-timeline.fr-mb-4w
- if dossier.brouillon?
%li.brouillon{ class: dossier.brouillon? ? 'active' : nil }
= t('views.users.dossiers.show.status_overview.status_draft')
%li.en-construction{ class: dossier.en_construction? ? 'active' : nil }
= t('views.users.dossiers.show.status_overview.status_in_progress')
%li.en-instruction{ class: dossier.en_instruction? ? 'active' : nil }
= t('views.users.dossiers.show.status_overview.status_review')
%li.termine{ class: dossier.termine? ? 'active' : nil }
= t('views.users.dossiers.show.status_overview.status_completed')
- if dossier.en_construction?
.fr-grid-row.fr-grid-row--center
.fr-col-md-10.fr-col-lg-9
= render Dossiers::EnConstructionNotSubmittedComponent.new(dossier: dossier, user: current_user)
.fr-grid-row.fr-grid-row--center
.fr-col-md-10.fr-col-lg-9.status-explanation
-# brouillon does not occure
- if dossier.en_construction?
.en-construction
%p{ role: 'status' }
= t('views.users.dossiers.show.status_overview.en_construction_html')
= render partial: 'users/dossiers/show/estimated_delay', locals: { procedure: dossier.procedure }
%p
= t('views.users.dossiers.show.status_overview.use_mailbox_for_questions_html', mailbox_url: messagerie_dossier_url(dossier))
- elsif dossier.en_instruction?
.en-instruction
%p{ role: 'status' }
= t('views.users.dossiers.show.status_overview.admin_review')
= render partial: 'users/dossiers/show/estimated_delay', locals: { procedure: dossier.procedure }
%p
= t('views.users.dossiers.show.status_overview.use_mailbox_for_questions_html', mailbox_url: messagerie_dossier_url(dossier))
- elsif dossier.accepte?
.accepte
%p.decision{ role: 'status' }
%span.icon.accept
= t('views.users.dossiers.show.status_overview.acceptee_html')
- if dossier.motivation.present?
%h3= t('views.users.dossiers.show.status_overview.accepte_motivation')
%blockquote= dossier.motivation
= render partial: 'users/dossiers/show/download_justificatif', locals: { dossier: dossier }
- if dossier.attestation.present?
.action
= link_to attestation_dossier_path(dossier), target: '_blank', rel: 'noopener', class: 'button primary' do
%span.icon.download-white
= t('views.users.dossiers.show.status_overview.accepte_attestation')
.status-explanation
-# brouillon does not occure
- if dossier.en_construction?
.en-construction
%p{ role: 'status' }
= t('views.users.dossiers.show.status_overview.en_construction_html')
- elsif dossier.refuse?
.refuse
%p.decision{ role: 'status' }
%span.icon.refuse
= t('views.users.dossiers.show.status_overview.refuse_html')
= render partial: 'users/dossiers/show/estimated_delay', locals: { procedure: dossier.procedure }
- if dossier.motivation.present?
%h3= t('views.users.dossiers.show.status_overview.refuse_motivation')
%blockquote= dossier.motivation
%p
= t('views.users.dossiers.show.status_overview.use_mailbox_for_questions_html', mailbox_url: messagerie_dossier_url(dossier))
- elsif dossier.en_instruction?
.en-instruction
%p{ role: 'status' }
= t('views.users.dossiers.show.status_overview.admin_review')
= render partial: 'users/dossiers/show/estimated_delay', locals: { procedure: dossier.procedure }
%p
= t('views.users.dossiers.show.status_overview.use_mailbox_for_questions_html', mailbox_url: messagerie_dossier_url(dossier))
- elsif dossier.accepte?
.accepte
%p.decision{ role: 'status' }
%span.icon.accept
= t('views.users.dossiers.show.status_overview.acceptee_html')
- if dossier.motivation.present?
%h3= t('views.users.dossiers.show.status_overview.accepte_motivation')
%blockquote= dossier.motivation
= render partial: 'users/dossiers/show/download_justificatif', locals: { dossier: dossier }
- if dossier.attestation.present?
= render partial: 'users/dossiers/show/download_justificatif', locals: { dossier: dossier }
.action
= link_to attestation_dossier_path(dossier), target: '_blank', rel: 'noopener', class: 'button primary' do
%span.icon.download-white
= t('views.users.dossiers.show.status_overview.accepte_attestation')
= link_to t('views.users.dossiers.show.status_overview.refuse_reply'), messagerie_dossier_url(dossier, anchor: 'new_commentaire'), class: 'fr-link'
- elsif dossier.sans_suite?
.sans-suite
%p.decision{ role: 'status' }
%span.icon.without-continuation
= t('views.users.dossiers.show.status_overview.sans_suite_html')
- elsif dossier.refuse?
.refuse
%p.decision{ role: 'status' }
%span.icon.refuse
= t('views.users.dossiers.show.status_overview.refuse_html')
= render partial: 'users/dossiers/show/download_justificatif', locals: { dossier: dossier }
- if dossier.motivation.present?
%h3= t('views.users.dossiers.show.status_overview.refuse_motivation')
%blockquote= dossier.motivation
= render partial: 'users/dossiers/show/download_justificatif', locals: { dossier: dossier }
.action
= link_to t('views.users.dossiers.show.status_overview.refuse_reply'), messagerie_dossier_url(dossier, anchor: 'new_commentaire'), class: 'fr-link'
- elsif dossier.sans_suite?
.sans-suite
%p.decision{ role: 'status' }
%span.icon.without-continuation
= t('views.users.dossiers.show.status_overview.sans_suite_html')
= render partial: 'users/dossiers/show/download_justificatif', locals: { dossier: dossier }
- if dossier.motivation.present?
%h3= t('views.users.dossiers.show.status_overview.sans_suite_motivation')
%blockquote= dossier.motivation
- if dossier.motivation.present?
%h3= t('views.users.dossiers.show.status_overview.sans_suite_motivation')
%blockquote= dossier.motivation

View file

@ -4,8 +4,14 @@
= turbo_stream.hide_all(@to_hide)
- @to_update.each do |champ|
= fields_for champ.input_name, champ do |form|
= turbo_stream.replace champ.input_group_id do
= render EditableChamp::EditableChampComponent.new champ:, form:
- if champ.refresh_after_update?
= turbo_stream.replace champ.input_group_id do
= render EditableChamp::EditableChampComponent.new champ:, form:
- else
= turbo_stream.update champ.labelledby_id do
= render EditableChamp::ChampLabelContentComponent.new champ:
= turbo_stream.remove_all(".editable-champ .spinner-removable");
= turbo_stream.hide_all(".editable-champ .spinner");
= turbo_stream.replace_all '.dossier-edit-sticky-footer' do
= render Dossiers::EditFooterComponent.new(dossier: @dossier, annotation: false)

View file

@ -315,10 +315,11 @@ Rails.application.routes.draw do
post 'siret', to: 'dossiers#update_siret'
get 'etablissement'
get 'brouillon'
patch 'brouillon', to: 'dossiers#update_brouillon'
patch 'brouillon', to: 'dossiers#update'
post 'brouillon', to: 'dossiers#submit_brouillon'
get 'modifier', to: 'dossiers#modifier'
patch 'modifier', to: 'dossiers#update'
post 'modifier', to: 'dossiers#submit_en_construction'
patch 'modifier', to: 'dossiers#modifier_legacy'
get 'merci'
get 'demande'
get 'messagerie'

View file

@ -0,0 +1,7 @@
class AddEditingForksToDossiers < ActiveRecord::Migration[6.1]
disable_ddl_transaction!
def change
add_belongs_to :dossiers, :editing_fork_origin, null: true, index: { algorithm: :concurrently }
end
end

View file

@ -362,6 +362,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_02_160046) do
t.string "deleted_user_email_never_send"
t.datetime "depose_at", precision: 6
t.bigint "dossier_transfer_id"
t.bigint "editing_fork_origin_id"
t.datetime "en_construction_at", precision: 6
t.datetime "en_construction_close_to_expiration_notice_sent_at", precision: 6
t.datetime "en_instruction_at", precision: 6
@ -393,6 +394,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_05_02_160046) do
t.index ["archived"], name: "index_dossiers_on_archived"
t.index ["batch_operation_id"], name: "index_dossiers_on_batch_operation_id"
t.index ["dossier_transfer_id"], name: "index_dossiers_on_dossier_transfer_id"
t.index ["editing_fork_origin_id"], name: "index_dossiers_on_editing_fork_origin_id"
t.index ["groupe_instructeur_id"], name: "index_dossiers_on_groupe_instructeur_id"
t.index ["hidden_at"], name: "index_dossiers_on_hidden_at"
t.index ["prefill_token"], name: "index_dossiers_on_prefill_token", unique: true

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Dossiers::EnConstructionNotSubmittedComponent, type: :component do
let(:dossier) { create(:dossier, :en_construction) }
subject {
render_inline(described_class.new(dossier:, user: dossier.user)).to_html
}
context "without fork" do
it { expect(subject).to be_empty }
end
context "with a fork" do
let!(:fork) { dossier.find_or_create_editing_fork(dossier.user) }
it "render nothing without changes" do
expect(subject).to be_empty
end
context "with changes" do
before { fork.champs_public.first.update(value: "new value") }
it "inform user" do
expect(subject).to include("Des modifications nont pas encore été déposées")
end
end
end
end

View file

@ -445,11 +445,80 @@ describe Users::DossiersController, type: :controller do
end
end
describe '#update_brouillon' do
describe '#submit_en_construction' do
before { sign_in(user) }
let(:procedure) { create(:procedure, :published, :with_type_de_champ, :with_piece_justificative) }
let!(:dossier) { create(:dossier, user: user, procedure: procedure) }
let!(:dossier) { create(:dossier, :en_construction, user: user) }
let(:first_champ) { dossier.owner_editing_fork.champs_public.first }
let(:anchor_to_first_champ) { controller.helpers.link_to I18n.t('views.users.dossiers.fix_champ'), modifier_dossier_path(anchor: first_champ.input_id) }
let(:value) { 'beautiful value' }
let(:now) { Time.zone.parse('01/01/2100') }
let(:payload) { { id: dossier.id } }
before { dossier.owner_editing_fork }
subject do
Timecop.freeze(now) do
post :submit_en_construction, params: payload
end
end
context 'when the dossier cannot be updated by the user' do
let!(:dossier) { create(:dossier, :en_instruction, user: user) }
it 'redirects to the dossiers list' do
subject
expect(response).to redirect_to(dossier_path(dossier))
expect(flash.alert).to eq('Votre dossier ne peut plus être modifié')
end
end
context 'when the update fails' do
render_views
before do
expect_any_instance_of(Dossier).to receive(:valid?).and_return(false)
expect_any_instance_of(Dossier).to receive(:errors).and_return(
[double(class: ActiveModel::Error, full_message: 'nop', base: first_champ)]
)
subject
end
it { expect(response).to render_template(:modifier) }
it { expect(flash.alert).to eq(["Le champ « #{first_champ.libelle} » nop, #{anchor_to_first_champ}"]) }
it { expect(response.body).to include("Dossier nº #{dossier.id}") }
end
context 'when a mandatory champ is missing' do
let(:value) { nil }
before do
first_champ.type_de_champ.update(mandatory: true, libelle: 'l')
subject
end
it { expect(response).to render_template(:modifier) }
it { expect(flash.alert).to eq(["Le champ « l » doit être rempli, #{anchor_to_first_champ}"]) }
end
context 'when dossier has no champ' do
let(:submit_payload) { { id: dossier.id } }
it 'does not raise any errors' do
subject
expect(response).to redirect_to(dossier_path(dossier))
end
end
end
describe '#update brouillon' do
before { sign_in(user) }
let(:procedure) { create(:procedure, :published, types_de_champ_public: [{}, { type: :piece_justificative }]) }
let(:dossier) { create(:dossier, user:, procedure:) }
let(:first_champ) { dossier.champs_public.first }
let(:piece_justificative_champ) { dossier.champs_public.last }
let(:value) { 'beautiful value' }
@ -461,16 +530,16 @@ describe Users::DossiersController, type: :controller do
id: dossier.id,
dossier: {
groupe_instructeur_id: dossier.groupe_instructeur_id,
champs_public_attributes: [
{
champs_public_attributes: {
first_champ.id => {
id: first_champ.id,
value: value
},
{
piece_justificative_champ.id => {
id: piece_justificative_champ.id,
piece_justificative_file: file
}
]
}
}
}
end
@ -478,12 +547,12 @@ describe Users::DossiersController, type: :controller do
subject do
Timecop.freeze(now) do
patch :update_brouillon, params: payload
patch :update, params: payload, format: :turbo_stream
end
end
context 'when the dossier cannot be updated by the user' do
let!(:dossier) { create(:dossier, :en_instruction, user: user) }
let(:dossier) { create(:dossier, :en_instruction, user:, procedure:) }
it 'redirects to the dossiers list' do
subject
@ -507,7 +576,7 @@ describe Users::DossiersController, type: :controller do
{
id: dossier.id,
dossier: {
champs_public_attributes: [{ value: '' }]
champs_public_attributes: { first_champ.id => { id: first_champ.id } }
}
}
end
@ -520,7 +589,7 @@ describe Users::DossiersController, type: :controller do
end
context 'when the user has an invitation but is not the owner' do
let(:dossier) { create(:dossier) }
let(:dossier) { create(:dossier, procedure: procedure) }
let!(:invite) { create(:invite, dossier: dossier, user: user) }
before { subject }
@ -530,11 +599,11 @@ describe Users::DossiersController, type: :controller do
end
end
describe '#update' do
describe '#update en_construction' do
before { sign_in(user) }
let(:procedure) { create(:procedure, :published, :with_type_de_champ, :with_piece_justificative) }
let!(:dossier) { create(:dossier, :en_construction, user: user, procedure: procedure) }
let(:procedure) { create(:procedure, :published, types_de_champ_public: [{}, { type: :piece_justificative }]) }
let!(:dossier) { create(:dossier, :en_construction, user:, procedure:) }
let(:first_champ) { dossier.champs_public.first }
let(:anchor_to_first_champ) { controller.helpers.link_to I18n.t('views.users.dossiers.fix_champ'), brouillon_dossier_path(anchor: first_champ.input_id) }
let(:piece_justificative_champ) { dossier.champs_public.last }
@ -547,16 +616,16 @@ describe Users::DossiersController, type: :controller do
id: dossier.id,
dossier: {
groupe_instructeur_id: dossier.groupe_instructeur_id,
champs_public_attributes: [
{
champs_public_attributes: {
first_champ.id => {
id: first_champ.id,
value: value
},
{
piece_justificative_champ.id => {
id: piece_justificative_champ.id,
piece_justificative_file: file
}
]
}
}
}
end
@ -564,12 +633,12 @@ describe Users::DossiersController, type: :controller do
subject do
Timecop.freeze(now) do
patch :update, params: payload
patch :update, params: payload, format: :turbo_stream
end
end
context 'when the dossier cannot be updated by the user' do
let!(:dossier) { create(:dossier, :en_instruction, user: user) }
let!(:dossier) { create(:dossier, :en_instruction, user:, procedure:) }
it 'redirects to the dossiers list' do
subject
@ -612,12 +681,12 @@ describe Users::DossiersController, type: :controller do
{
id: dossier.id,
dossier: {
champs_public_attributes: [
{
champs_public_attributes: {
piece_justificative_champ.id => {
id: piece_justificative_champ.id,
piece_justificative_file: file
}
]
}
}
}
end
@ -640,7 +709,7 @@ describe Users::DossiersController, type: :controller do
subject
end
it { expect(response).to render_template(:modifier) }
it { expect(response).to render_template(:update) }
it { expect(flash.alert).to eq(["Le champ « #{first_champ.libelle} » nop, #{anchor_to_first_champ}"]) }
it 'does not update the dossier timestamps' do
@ -656,18 +725,6 @@ describe Users::DossiersController, type: :controller do
end
end
context 'when a mandatory champ is missing' do
let(:value) { nil }
before do
first_champ.type_de_champ.update(mandatory: true, libelle: 'l')
subject
end
it { expect(response).to render_template(:modifier) }
it { expect(flash.alert).to eq(["Le champ « l » doit être rempli, #{anchor_to_first_champ}"]) }
end
context 'when a champ validation fails' do
let(:value) { 'abc' }
@ -682,8 +739,8 @@ describe Users::DossiersController, type: :controller do
end
context 'when the user has an invitation but is not the owner' do
let(:dossier) { create(:dossier, :en_construction) }
let!(:invite) { create(:invite, dossier: dossier, user: user) }
let(:dossier) { create(:dossier, :en_construction, procedure:) }
let!(:invite) { create(:invite, dossier:, user:) }
before { subject }
@ -692,9 +749,9 @@ describe Users::DossiersController, type: :controller do
end
context 'when the dossier is followed by an instructeur' do
let(:dossier) { create(:dossier) }
let(:dossier) { create(:dossier, procedure:) }
let(:instructeur) { create(:instructeur) }
let!(:invite) { create(:invite, dossier: dossier, user: user) }
let!(:invite) { create(:invite, dossier:, user:) }
before do
instructeur.follow(dossier)
@ -708,8 +765,8 @@ describe Users::DossiersController, type: :controller do
end
context 'when the champ is a phone number' do
let(:procedure) { create(:procedure, :published, :with_phone) }
let!(:dossier) { create(:dossier, :en_construction, user: user, procedure: procedure) }
let(:procedure) { create(:procedure, :published, types_de_champ_public: [{ type: :phone }]) }
let!(:dossier) { create(:dossier, :en_construction, user:, procedure:) }
let(:first_champ) { dossier.champs_public.first }
let(:now) { Time.zone.parse('01/01/2100') }
@ -717,12 +774,12 @@ describe Users::DossiersController, type: :controller do
{
id: dossier.id,
dossier: {
champs_public_attributes: [
{
champs_public_attributes: {
first_champ.id => {
id: first_champ.id,
value: value
}
]
}
}
}
end

View file

@ -0,0 +1,15 @@
RSpec.describe DossierUpdateSearchTermsJob, type: :job do
let(:dossier) { create(:dossier) }
let(:champ_public) { dossier.champs_public.first }
let(:champ_private) { dossier.champs_private.first }
subject(:perform_job) { described_class.perform_now(dossier) }
context 'with an update' do
before do
create(:champ_text, dossier: dossier, value: "un nouveau champ")
end
it { expect { perform_job }.to change { dossier.reload.search_terms }.to(/un nouveau champ/) }
end
end

View file

@ -0,0 +1,128 @@
RSpec.describe DossierCloneConcern do
let(:procedure) do
create(:procedure, types_de_champ_public: [
{ type: :text, libelle: "Un champ text", stable_id: 99 },
{ type: :text, libelle: "Un autre champ text", stable_id: 991 },
{ type: :yes_no, libelle: "Un champ yes no", stable_id: 992 },
{ type: :repetition, libelle: "Un champ répétable", stable_id: 993, mandatory: true, children: [{ type: :text, libelle: 'Nom', stable_id: 994 }] }
])
end
let(:dossier) { create(:dossier, procedure:) }
let(:forked_dossier) { dossier.find_or_create_editing_fork(dossier.user) }
before { procedure.publish! }
describe '#make_diff' do
subject { dossier.make_diff(forked_dossier) }
context 'with no changes' do
it { is_expected.to eq(added: [], updated: [], removed: []) }
end
context 'with updated groupe instructeur' do
before {
dossier.update(groupe_instructeur: nil)
forked_dossier.assign_to_groupe_instructeur(dossier.procedure.defaut_groupe_instructeur)
}
it { is_expected.to eq(added: [], updated: [], removed: []) }
it { expect(forked_dossier.forked_with_changes?).to be_truthy }
end
context 'with updated champ' do
let(:updated_champ) { forked_dossier.champs.find { _1.stable_id == 99 } }
before { updated_champ.update(value: 'new value') }
it { is_expected.to eq(added: [], updated: [updated_champ], removed: []) }
it 'forked_with_changes? should reflect dossier state' do
expect(dossier.forked_with_changes?).to be_falsey
expect(forked_dossier.forked_with_changes?).to be_truthy
expect(updated_champ.forked_with_changes?).to be_truthy
end
end
context 'with new revision' do
let(:added_champ) { forked_dossier.champs.find { _1.libelle == "Un nouveau champ text" } }
let(:removed_champ) { dossier.champs.find { _1.stable_id == 99 } }
before do
procedure.draft_revision.add_type_de_champ({
type_champ: TypeDeChamp.type_champs.fetch(:text),
libelle: "Un nouveau champ text"
})
procedure.draft_revision.remove_type_de_champ(removed_champ.stable_id)
procedure.publish_revision!
end
it {
expect(dossier.revision_id).to eq(procedure.revisions.first.id)
expect(forked_dossier.revision_id).to eq(procedure.published_revision_id)
is_expected.to eq(added: [added_champ], updated: [], removed: [removed_champ])
}
end
end
describe '#merge_fork' do
subject { dossier.merge_fork(forked_dossier) }
context 'with updated champ' do
let(:updated_champ) { forked_dossier.champs.find { _1.stable_id == 99 } }
let(:updated_repetition_champ) { forked_dossier.champs.find { _1.stable_id == 994 } }
before do
dossier.champs.each do |champ|
champ.update(value: 'old value')
end
updated_champ.update(value: 'new value')
updated_repetition_champ.update(value: 'new value in repetition')
end
it { expect { subject }.to change { dossier.reload.champs.size }.by(0) }
it { expect { subject }.not_to change { dossier.reload.champs.order(:created_at).reject { _1.stable_id.in?([99, 994]) }.map(&:value) } }
it { expect { subject }.to change { dossier.reload.champs.find { _1.stable_id == 99 }.value }.from('old value').to('new value') }
it { expect { subject }.to change { dossier.reload.champs.find { _1.stable_id == 994 }.value }.from('old value').to('new value in repetition') }
it 'update dossier search terms' do
expect { subject }.to have_enqueued_job(DossierUpdateSearchTermsJob).with(dossier)
end
it 'fork is hidden after merge' do
subject
expect(forked_dossier.reload.hidden_by_reason).to eq("stale_fork")
expect(dossier.reload.editing_forks).to be_empty
end
end
context 'with new revision' do
let(:added_champ) { forked_dossier.champs.find { _1.libelle == "Un nouveau champ text" } }
let(:removed_champ) { dossier.champs.find { _1.stable_id == 99 } }
before do
dossier.champs.each do |champ|
champ.update(value: 'old value')
end
procedure.draft_revision.add_type_de_champ({
type_champ: TypeDeChamp.type_champs.fetch(:text),
libelle: "Un nouveau champ text"
})
procedure.draft_revision.remove_type_de_champ(removed_champ.stable_id)
procedure.publish_revision!
end
it { expect { subject }.to change { dossier.reload.champs.size }.by(0) }
it { expect { subject }.to change { dossier.reload.champs.order(:created_at).map(&:to_s) }.from(['old value', 'old value', 'Non', 'old value', 'old value']).to(['old value', 'Non', 'old value', 'old value', '']) }
it "dossier after merge should be on last published revision" do
expect(dossier.revision_id).to eq(procedure.revisions.first.id)
expect(forked_dossier.revision_id).to eq(procedure.published_revision_id)
subject
perform_enqueued_jobs only: DestroyRecordLaterJob
expect(dossier.revision_id).to eq(procedure.published_revision_id)
expect(Dossier.exists?(forked_dossier.id)).to be_falsey
end
end
end
end

View file

@ -0,0 +1,41 @@
describe DossierSearchableConcern do
let(:champ_public) { dossier.champs_public.first }
let(:champ_private) { dossier.champs_private.first }
subject { dossier }
describe '#update_search_terms' do
let(:etablissement) { dossier.etablissement }
let(:dossier) { create(:dossier, :with_entreprise, user: user) }
let(:etablissement) { build(:etablissement, entreprise_nom: 'Dupont', entreprise_prenom: 'Thomas', association_rna: '12345', association_titre: 'asso de test', association_objet: 'tests unitaires') }
let(:procedure) { create(:procedure, :with_type_de_champ, :with_type_de_champ_private) }
let(:dossier) { create(:dossier, etablissement: etablissement, user: user, procedure: procedure) }
let(:france_connect_information) { build(:france_connect_information, given_name: 'Chris', family_name: 'Harrisson') }
let(:user) { build(:user, france_connect_information: france_connect_information) }
before do
champ_public.update_attribute(:value, "champ public")
champ_private.update_attribute(:value, "champ privé")
dossier.update_search_terms
end
it { expect(dossier.search_terms).to eq("#{user.email} champ public #{etablissement.entreprise_siren} #{etablissement.entreprise_numero_tva_intracommunautaire} #{etablissement.entreprise_forme_juridique} #{etablissement.entreprise_forme_juridique_code} #{etablissement.entreprise_nom_commercial} #{etablissement.entreprise_raison_sociale} #{etablissement.entreprise_siret_siege_social} #{etablissement.entreprise_nom} #{etablissement.entreprise_prenom} #{etablissement.association_rna} #{etablissement.association_titre} #{etablissement.association_objet} #{etablissement.siret} #{etablissement.naf} #{etablissement.libelle_naf} #{etablissement.adresse} #{etablissement.code_postal} #{etablissement.localite} #{etablissement.code_insee_localite}") }
it { expect(dossier.private_search_terms).to eq('champ privé') }
context 'with an update' do
before do
dossier.update(
champs_public_attributes: [{ id: champ_public.id, value: 'nouvelle valeur publique' }],
champs_private_attributes: [{ id: champ_private.id, value: 'nouvelle valeur privee' }]
)
perform_enqueued_jobs(only: DossierUpdateSearchTermsJob)
dossier.reload
end
it { expect(dossier.search_terms).to include('nouvelle valeur publique') }
it { expect(dossier.private_search_terms).to include('nouvelle valeur privee') }
end
end
end

View file

@ -279,38 +279,6 @@ describe Dossier do
subject { dossier }
describe '#update_search_terms' do
let(:etablissement) { build(:etablissement, entreprise_nom: 'Dupont', entreprise_prenom: 'Thomas', association_rna: '12345', association_titre: 'asso de test', association_objet: 'tests unitaires') }
let(:procedure) { create(:procedure, :with_type_de_champ, :with_type_de_champ_private) }
let(:dossier) { create(:dossier, etablissement: etablissement, user: user, procedure: procedure) }
let(:france_connect_information) { build(:france_connect_information, given_name: 'Chris', family_name: 'Harrisson') }
let(:user) { build(:user, france_connect_information: france_connect_information) }
let(:champ_public) { dossier.champs_public.first }
let(:champ_private) { dossier.champs_private.first }
before do
champ_public.update_attribute(:value, "champ public")
champ_private.update_attribute(:value, "champ privé")
dossier.update_search_terms
end
it { expect(dossier.search_terms).to eq("#{user.email} champ public #{etablissement.entreprise_siren} #{etablissement.entreprise_numero_tva_intracommunautaire} #{etablissement.entreprise_forme_juridique} #{etablissement.entreprise_forme_juridique_code} #{etablissement.entreprise_nom_commercial} #{etablissement.entreprise_raison_sociale} #{etablissement.entreprise_siret_siege_social} #{etablissement.entreprise_nom} #{etablissement.entreprise_prenom} #{etablissement.association_rna} #{etablissement.association_titre} #{etablissement.association_objet} #{etablissement.siret} #{etablissement.naf} #{etablissement.libelle_naf} #{etablissement.adresse} #{etablissement.code_postal} #{etablissement.localite} #{etablissement.code_insee_localite}") }
it { expect(dossier.private_search_terms).to eq('champ privé') }
context 'with an update' do
before do
dossier.update(
champs_public_attributes: [{ id: champ_public.id, value: 'nouvelle valeur publique' }],
champs_private_attributes: [{ id: champ_private.id, value: 'nouvelle valeur privee' }]
)
end
it { expect(dossier.search_terms).to include('nouvelle valeur publique') }
it { expect(dossier.private_search_terms).to include('nouvelle valeur privee') }
end
end
describe '#create' do
let(:procedure) { create(:procedure, :with_type_de_champ, :with_type_de_champ_private) }
let(:dossier) { create(:dossier, procedure: procedure, user: user) }
@ -612,12 +580,12 @@ describe Dossier do
let(:dossier) { create(:dossier, :en_construction, procedure: procedure) }
it "can change groupe instructeur" do
expect(dossier.assign_to_groupe_instructeur(new_groupe_instructeur_new_procedure)).to be_falsey
dossier.assign_to_groupe_instructeur(new_groupe_instructeur_new_procedure)
expect(dossier.groupe_instructeur).not_to eq(new_groupe_instructeur_new_procedure)
end
it "can not change groupe instructeur if new groupe is from another procedure" do
expect(dossier.assign_to_groupe_instructeur(new_groupe_instructeur)).to be_truthy
dossier.assign_to_groupe_instructeur(new_groupe_instructeur)
expect(dossier.groupe_instructeur).to eq(new_groupe_instructeur)
end
end

View file

@ -1,6 +1,8 @@
describe RoutingEngine, type: :model do
include Logic
before { Flipper.enable(:routing_rules, procedure) }
describe '.compute' do
let(:procedure) do
create(:procedure).tap do |p|

View file

@ -119,6 +119,8 @@ describe 'The routing', js: true do
fill_in litteraire_user.dossiers.first.champs_public.first.libelle, with: 'some value'
wait_for_autosave(false)
click_on 'Déposer les modifications'
log_out
# the litteraires instructeurs should have a notification
@ -217,6 +219,8 @@ describe 'The routing', js: true do
expect(page).to have_text(new_group)
click_on 'Déposer les modifications'
log_out
end

View file

@ -146,6 +146,8 @@ describe 'The routing with rules', js: true do
fill_in litteraire_user.dossiers.first.champs_public.first.libelle, with: 'some value'
wait_for_autosave(false)
click_on 'Déposer les modifications'
log_out
# the litteraires instructeurs should have a notification
@ -245,6 +247,8 @@ describe 'The routing with rules', js: true do
expect(page).to have_text(new_group)
click_on 'Déposer les modifications'
log_out
end

View file

@ -483,14 +483,14 @@ describe 'The user' do
fill_individual
# Test autosave failure
allow_any_instance_of(Users::DossiersController).to receive(:update_brouillon).and_raise("Server is busy")
allow_any_instance_of(Users::DossiersController).to receive(:update).and_raise("Server is busy")
fill_in('texte obligatoire', with: 'a valid user input')
blur
expect(page).to have_css('span', text: 'Impossible denregistrer le brouillon', visible: true)
# Test that retrying after a failure works
allow_any_instance_of(Users::DossiersController).to receive(:update_brouillon).and_call_original
click_on 'réessayer'
allow_any_instance_of(Users::DossiersController).to receive(:update).and_call_original
click_on 'Réessayer'
wait_for_autosave
visit current_path

View file

@ -12,6 +12,7 @@ RSpec.shared_examples 'the user can edit the submitted demande' do
fill_in('Texte obligatoire', with: 'Nouveau texte')
wait_for_autosave(false)
click_on 'Déposer les modifications'
click_on 'Demande'
expect(page).to have_current_path(demande_dossier_path(dossier))

View file

@ -8,7 +8,7 @@ describe "Dossier en_construction" do
}
let(:champ) {
dossier.champs_public.find { _1.type_de_champ_id == tdc.id }
dossier.find_editing_fork(dossier.user).champs_public.find { _1.type_de_champ_id == tdc.id }
}
scenario 'delete a non mandatory piece justificative', js: true do
@ -29,7 +29,7 @@ describe "Dossier en_construction" do
scenario 'remplace a mandatory piece justificative', js: true do
visit_dossier(dossier)
click_on "Remplacer le fichier toto.txt"
click_on "Supprimer le fichier toto.txt"
input_selector = "#attachment-multiple-empty-#{champ.id}"
expect(page).to have_selector(input_selector)
@ -53,9 +53,9 @@ describe "Dossier en_construction" do
scenario 'remplace a mandatory titre identite', js: true do
visit_dossier(dossier)
click_on "Remplacer le fichier toto.png"
click_on "Supprimer le fichier toto.png"
input_selector = ".attachment-input-#{champ.piece_justificative_file.attachments.first.id}"
input_selector = "##{champ.input_id}"
expect(page).to have_selector(input_selector)
find(input_selector).attach_file(Rails.root.join('spec/fixtures/files/file.pdf'))

View file

@ -115,8 +115,8 @@ describe 'shared/dossiers/edit', type: :view do
let(:dossier) { create(:dossier, :en_construction) }
before { dossier.champs_public << champ }
it 'cannot delete a piece justificative' do
expect(subject).not_to have_selector("[title='Supprimer le fichier #{champ.piece_justificative_file.attachments[0].filename}']")
it 'can delete a piece justificative' do
expect(subject).to have_selector("[title='Supprimer le fichier #{champ.piece_justificative_file.attachments[0].filename}']")
end
end

View file

@ -10,7 +10,7 @@ describe 'users/dossiers/show', type: :view do
it 'renders a summary of the dossier state' do
expect(rendered).to have_text("Dossier nº #{dossier.id}")
expect(rendered).to have_selector('.status-overview')
expect(rendered).to have_text('dossier est en construction')
end
context 'with messages' do