Merge pull request #10976 from demarches-simplifiees/feat-admin-can-modify-labels

ETQ instructeur, je veux pouvoir apposer un label à un dossier (part 2)
This commit is contained in:
Lisa Durand 2024-11-05 08:39:40 +00:00 committed by GitHub
commit c462425db2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 823 additions and 55 deletions

View file

@ -37,7 +37,7 @@
}
.text-right {
text-align: right;
text-align: right !important;
}
.text-sm {

View file

@ -1,34 +0,0 @@
@import "colors";
@import "constants";
.badge {
padding: 0 5px;
font-size: 14px;
font-weight: bold;
text-align: center;
white-space: nowrap;
border-radius: 100px;
background-color: rgba(0, 0, 0, 0.08);
vertical-align: top;
&.baseline {
vertical-align: baseline;
}
&.warning {
background-color: $orange;
color: #FFFFFF;
}
}
.badge-group {
display: flex;
.fr-badge {
margin-right: $default-spacer;
}
.fr-badge:last-child {
margin-right: 0;
}
}

View file

@ -262,3 +262,13 @@ button.fr-tag-bug {
.fr-badge--lowercase {
text-transform: lowercase;
}
// We don't want badge to split in two lines
.fr-tag {
white-space: nowrap;
}
// We remove the line height because it creates unharmonized spaces - most of all in table
.fr-tags-group > li {
line-height: inherit;
}

View file

@ -48,6 +48,10 @@
width: 450px;
}
.dropdown-label.dropdown-content {
min-width: 390px;
}
.print-menu {
display: none;
position: absolute;

View file

@ -0,0 +1,30 @@
@import "colors";
@import "constants";
$colors: "green-tilleul-verveine",
"green-bourgeon",
"green-emeraude",
"green-menthe",
"blue-ecume",
"purple-glycine",
"pink-macaron",
"yellow-tournesol",
"brown-cafe-creme",
"beige-gris-galet";
@each $color in $colors {
.fr-tag--#{$color},
a.fr-tag--#{$color},
button.fr-tag--#{$color},
input[type=button].fr-tag--#{$color},
input[type=image].fr-tag--#{$color},
input[type=reset].fr-tag--#{$color},
input[type=submit].fr-tag--#{$color} {
--idle: transparent;
--hover: var(--background-action-low-#{$color}-hover);
--active: var(--background-action-low-#{$color}-active);
background-color: var(--background-action-low-#{$color});
color: var(--text-action-high-#{$color});
}
}

View file

@ -53,6 +53,10 @@ class Instructeurs::ColumnFilterValueComponent < ApplicationComponent
[_1.label, _1.id]
end
end
elsif column.table == 'dossier_labels'
Procedure.find(procedure_id).labels.filter_map do
[_1.name, _1.id]
end
else
find_type_de_champ(column.column).options_for_select(column)
end

View file

@ -59,6 +59,8 @@ class Instructeurs::FilterButtonsComponent < ApplicationComponent
elsif column.groupe_instructeur?
current_instructeur.groupe_instructeurs
.find { _1.id == filter.to_i }&.label || filter
elsif column.dossier_labels?
Label.find(filter)&.name || filter
elsif column.type == :date
helpers.try_parse_format_date(filter)
else

View file

@ -9,6 +9,6 @@
= link_to expert_all_avis_path, class: 'fr-nav__link', aria: aria_current_for(:avis) do
= Avis.model_name.human(count: 10)
- if helpers.current_expert.avis_summary[:unanswered] > 0
%span.badge.warning= helpers.current_expert.avis_summary[:unanswered]
%span.fr-badge.fr-badge--new.fr-badge--no-icon= helpers.current_expert.avis_summary[:unanswered]
= render MainNavigation::AnnouncesLinkComponent.new

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class Procedure::Card::LabelsComponent < ApplicationComponent
def initialize(procedure:)
@procedure = procedure
end
end

View file

@ -0,0 +1,3 @@
---
fr:
title: Labels

View file

@ -0,0 +1,17 @@
.fr-col-6.fr-col-md-4.fr-col-lg-3
= link_to [:admin, @procedure, :labels], class: 'fr-tile fr-enlarge-link' do
.fr-tile__body.flex.column.align-center.justify-between
- if @procedure.labels.present?
%p.fr-badge.fr-badge--info
Configuré
%div
.line-count.fr-my-1w
%p.fr-tag= @procedure.labels.size
- else
%p.fr-badge
Non configuré
%h3.fr-h6
= t('.title')
%p.fr-tile-subtitle Gérer les labels utilisables par les instructeurs
%p.fr-btn.fr-btn--tertiary= t('views.shared.actions.edit')

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true
module Administrateurs
class LabelsController < AdministrateurController
before_action :retrieve_procedure
before_action :retrieve_label, only: [:edit, :update, :destroy]
before_action :set_colors_collection, only: [:edit, :new, :create, :update]
def index
@labels = @procedure.labels
end
def edit
end
def new
@label = Label.new
end
def create
@label = @procedure.labels.build(label_params)
if @label.save
flash.notice = 'Le label a bien été créé'
redirect_to [:admin, @procedure, :labels]
else
flash.alert = @label.errors.full_messages
render :new
end
end
def update
if @label.update(label_params)
flash.notice = 'Le label a bien été modifié'
redirect_to [:admin, @procedure, :labels]
else
flash.alert = @label.errors.full_messages
render :edit
end
end
def destroy
@label.destroy!
flash.notice = 'Le label a bien été supprimé'
redirect_to [:admin, @procedure, :labels]
end
private
def label_params
params.require(:label).permit(:name, :color)
end
def retrieve_label
@label = @procedure.labels.find(params[:id])
end
def set_colors_collection
@colors_collection = Label.colors.keys
end
end
end

View file

@ -108,6 +108,7 @@ module Administrateurs
flash.now.alert = @procedure.errors.full_messages
render 'new'
else
@procedure.create_generic_labels
flash.notice = 'Démarche enregistrée.'
current_administrateur.instructeur.assign_to_procedure(@procedure)

View file

@ -63,6 +63,19 @@ module Instructeurs
end
end
def dossier_labels
labels = params[:label_id]&.map(&:to_i) || []
@dossier = dossier
labels.each { |params_label| DossierLabel.find_or_create_by(dossier_id: @dossier.id, label_id: params_label) }
all_labels = DossierLabel.where(dossier_id: @dossier.id).pluck(:label_id)
(all_labels - labels).each { DossierLabel.find_by(dossier_id: @dossier.id, label_id: _1).destroy }
render :change_state
end
def messagerie
@commentaire = Commentaire.new
@messagerie_seen_at = current_instructeur.follows.find_by(dossier: dossier)&.messagerie_seen_at

View file

@ -118,6 +118,21 @@ module DossierHelper
tag.span(Dossier.human_attribute_name("pending_correction.resolved"), class: ['fr-badge fr-badge--sm fr-badge--success super', html_class], role: 'status')
end
def tags_label(tags)
if tags.count > 1
tag.ul(class: 'fr-tags-group') do
safe_join(tags.map { |t| tag.li(tag_label(t[1], t[2])) })
end
else
tag = tags.first
tag_label(tag[1], tag[2])
end
end
def tag_label(name, color)
tag.span(name, class: "fr-tag fr-tag--sm fr-tag--#{Label.class_name(color)}")
end
def demandeur_dossier(dossier)
if dossier.procedure.for_individual? && dossier.for_tiers?
return t('shared.dossiers.beneficiaire', mandataire: dossier.mandataire_full_name, beneficiaire: "#{dossier&.individual&.prenom} #{dossier&.individual&.nom}")

View file

@ -40,6 +40,7 @@ class Column
def notifications? = [table, column] == ['notifications', 'notifications']
def dossier_state? = [table, column] == ['self', 'state']
def groupe_instructeur? = [table, column] == ['groupe_instructeur', 'id']
def dossier_labels? = [table, column] == ['dossier_labels', 'label_id']
def type_de_champ? = table == TYPE_DE_CHAMP_TABLE
def self.find(h_id)

View file

@ -91,6 +91,8 @@ module ColumnsConcern
def user_france_connected_column = Columns::DossierColumn.new(procedure_id: id, table: 'self', column: 'user_from_france_connect?', filterable: false, displayable: false)
def dossier_labels_column = Columns::DossierColumn.new(procedure_id: id, table: 'dossier_labels', column: 'label_id', type: :enum)
def procedure_chorus_columns
['domaine_fonctionnel', 'referentiel_prog', 'centre_de_cout']
.map { |column| Columns::DossierColumn.new(procedure_id: id, table: 'procedure', column:, displayable: false, filterable: false) }
@ -127,7 +129,8 @@ module ColumnsConcern
followers_instructeurs_email_column,
groupe_instructeurs_id_column,
Columns::DossierColumn.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false),
user_france_connected_column
user_france_connected_column,
dossier_labels_column
]
end

View file

@ -132,6 +132,8 @@ 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 :dossier_labels, dependent: :destroy
has_many :labels, through: :dossier_labels
after_destroy_commit :log_destroy

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
class DossierLabel < ApplicationRecord
belongs_to :dossier
belongs_to :label
end

35
app/models/label.rb Normal file
View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
class Label < ApplicationRecord
belongs_to :procedure
has_many :dossier_labels, dependent: :destroy
NAME_MAX_LENGTH = 30
GENERIC_LABELS = [
{ name: 'À examiner', color: 'purple_glycine' },
{ name: 'À relancer', color: 'green_tilleul_verveine' },
{ name: 'Complet', color: 'green_emeraude' },
{ name: 'À signer', color: 'blue_ecume' },
{ name: 'Urgent', color: 'pink_macaron' }
]
enum color: {
green_tilleul_verveine: "green-tilleul-verveine",
green_bourgeon: "green-bourgeon",
green_emeraude: "green-emeraude",
green_menthe: "green-menthe",
blue_ecume: "blue-ecume",
purple_glycine: "purple-glycine",
pink_macaron: "pink-macaron",
yellow_tournesol: "yellow-tournesol",
brown_cafe_creme: "brown-cafe-creme",
beige_gris_galet: "beige-gris-galet"
}
validates :name, :color, presence: true
validates :name, length: { maximum: NAME_MAX_LENGTH }
def self.class_name(color)
Label.colors.fetch(color.underscore)
end
end

View file

@ -60,6 +60,7 @@ class Procedure < ApplicationRecord
has_and_belongs_to_many :procedure_tags
has_many :bulk_messages, dependent: :destroy
has_many :labels, dependent: :destroy
def active_dossier_submitted_message
published_dossier_submitted_message || draft_dossier_submitted_message
@ -528,6 +529,7 @@ class Procedure < ApplicationRecord
procedure.closing_notification_en_cours = false
procedure.template = false
procedure.monavis_embed = nil
procedure.labels = labels.map(&:dup)
if !procedure.valid?
procedure.errors.attribute_names.each do |attribute|
@ -935,6 +937,12 @@ class Procedure < ApplicationRecord
end
end
def create_generic_labels
Label::GENERIC_LABELS.each do |label|
Label.create(name: label[:name], color: label[:color], procedure_id: self.id)
end
end
def stable_ids_used_by_routing_rules
@stable_ids_used_by_routing_rules ||= groupe_instructeurs.flat_map { _1.routing_rule&.sources }.compact
end

View file

@ -56,6 +56,11 @@ class DossierFilterService
.order("#{sanitized_column(table, column)} #{order}")
.pluck(:id)
.uniq
when 'dossier_labels'
dossiers.includes(table)
.order("#{self.class.sanitized_column(table, column)} #{order}")
.pluck(:id)
.uniq
when 'self', 'user', 'individual', 'etablissement', 'groupe_instructeur'
(table == 'self' ? dossiers : dossiers.includes(table))
.order("#{sanitized_column(table, column)} #{order}")
@ -122,6 +127,11 @@ class DossierFilterService
dossiers
.includes(table)
.filter_ilike(table, column, values) # ilike or where column == 'value' are both valid, we opted for ilike
when 'dossier_labels'
assert_supported_column(table, column)
dossiers
.joins(:dossier_labels)
.where(dossier_labels: { label_id: values })
when 'groupe_instructeur'
assert_supported_column(table, column)

View file

@ -123,6 +123,18 @@ class DossierProjectionService
fields[0][:id_value_h] = id_value_h
when 'dossier_labels'
columns = fields.map { _1[COLUMN].to_sym }
id_value_h =
DossierLabel
.includes(:label)
.where(dossier_id: dossiers_ids)
.pluck('dossier_id, labels.name, labels.color')
.group_by { |dossier_id, _| dossier_id }
fields[0][:id_value_h] = id_value_h.transform_values { |v| { value: v, type: :label } }
when 'procedure'
Dossier
.joins(:procedure)

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Maintenance
class BackfillLabelsForProceduresTask < MaintenanceTasks::Task
# Cette tâche permet de créer un jeu de labels génériques pour les anciennes procédures
# Plus d'informations sur l'implémentation des labels ici : https://github.com/demarches-simplifiees/demarches-simplifiees.fr/issues/9787
# 2024-10-15
include RunnableOnDeployConcern
run_on_first_deploy
def collection
Procedure
.includes(:labels)
.where(labels: { id: nil })
end
def process(procedure)
Label::GENERIC_LABELS.each do |label|
Label.create(name: label[:name], color: label[:color], procedure_id: procedure.id)
end
end
end
end

View file

@ -0,0 +1,15 @@
= form_with model: label, url: [:admin, @procedure, @label], local: true do |f|
= render Dsfr::InputComponent.new(form: f, attribute: :name, input_type: :text_field, opts: { maxlength: Label::NAME_MAX_LENGTH})
%fieldset.fr-fieldset
%legend.fr-fieldset__legend.fr-fieldset__legend--regular
= t('activerecord.attributes.label.color')
= asterisk
- @colors_collection.each do |color|
.fr-fieldset__element.fr-fieldset__element--inline
.fr-radio-group
= f.radio_button :color, color, checked: (label.color == color)
= f.label :color, t("activerecord.attributes.label/color.#{color}"), value: color, class: "fr-label fr-tag fr-tag--sm fr-tag--#{Label.class_name(color)}"
= render Procedure::FixedFooterComponent.new(procedure: @procedure, form: f)

View file

@ -0,0 +1,20 @@
- content_for :title, "Modifier le label"
= render partial: 'administrateurs/breadcrumbs',
locals: { steps: [['Démarches', admin_procedures_path],
[@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)],
['gestion des labels', [:admin, @procedure, :labels]],
['Modifier le label']] }
.fr-container
.fr-mb-3w
= link_to "Liste de tous les labels",
[:admin, @procedure, :labels],
class: "fr-link fr-icon-arrow-left-line fr-link--icon-left"
%h1.fr-h2
Modifier le label
= render partial: 'form',
locals: { label: @label, procedure_id: @procedure.id }

View file

@ -0,0 +1,43 @@
- content_for :title, "Labels"
= render partial: 'administrateurs/breadcrumbs',
locals: { steps: [['Démarches', admin_procedures_path],
[@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)],
['Labels']] }
.fr-container
%h1.fr-h2 Labels
= link_to "Nouveau label",
[:new, :admin, @procedure, :label],
class: "fr-btn fr-btn--primary fr-btn--icon-left fr-icon-add-circle-line mb-3"
- if @procedure.labels.present?
.fr-table.fr-table--layout-fixed.fr-table--bordered
%table
%caption Liste des labels
%thead
%tr
%th{ scope: "col" }
Nom
%th.change{ scope: "col" }
Actions
%tbody
- @labels.each do |label|
%tr
%td
= tag_label(label.name, label.color)
%td.change
= link_to 'Modifier',
[:edit, :admin, @procedure, label],
class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-pencil-line'
= link_to 'Supprimer',
[:admin, @procedure, label],
method: :delete,
data: { confirm: "Confirmez vous la suppression de #{label.name}" },
class: 'fr-btn fr-btn--sm fr-btn--secondary fr-btn--icon-left fr-icon-delete-line fr-ml-1w'
= render Procedure::FixedFooterComponent.new(procedure: @procedure)

View file

@ -0,0 +1,20 @@
- content_for :title, "Nouveau label"
= render partial: 'administrateurs/breadcrumbs',
locals: { steps: [['Démarches', admin_procedures_path],
[@procedure.libelle.truncate_words(10), admin_procedure_path(@procedure)],
['gestion des labels', [:admin, @procedure, :labels]],
['Nouveau label']] }
.fr-container
.fr-mb-3w
= link_to "Liste de tous les labels",
[:admin, @procedure, :labels],
class: "fr-link fr-icon-arrow-left-line fr-link--icon-left"
%h1.fr-h2
Créer un nouveau label
= render partial: 'form',
locals: { label: @label, procedure_id: @procedure.id }

View file

@ -98,3 +98,4 @@
= render Procedure::Card::DossierSubmittedMessageComponent.new(procedure: @procedure)
= render Procedure::Card::ChorusComponent.new(procedure: @procedure)
= render Procedure::Card::AccuseLectureComponent.new(procedure: @procedure)
= render Procedure::Card::LabelsComponent.new(procedure: @procedure)

View file

@ -1,11 +1,11 @@
#header-top.fr-container
.flex.fr-mb-3w
.flex
%div
%h1.fr-h3.fr-mb-1w
= "Dossier nº #{dossier.id}"
= link_to dossier.procedure.libelle.truncate_words(10), instructeur_procedure_path(dossier.procedure), title: dossier.procedure.libelle, class: "fr-link"
.fr-mt-2w.badge-group
.fr-mt-2w.fr-badge-group
= procedure_badge(dossier.procedure)
= status_badge(dossier.state)
@ -16,7 +16,6 @@
= render Instructeurs::SVASVRDecisionBadgeComponent.new(projection_or_dossier: dossier, procedure: dossier.procedure, with_label: true)
.header-actions.fr-ml-auto
= render partial: 'instructeurs/dossiers/header_actions', locals: { dossier: }
= render partial: 'instructeurs/dossiers/print_and_export_actions', locals: { dossier: }
@ -26,3 +25,30 @@
- if dossier.user_deleted?
%p.fr-mb-1w
%small Lusager a supprimé son compte. Vous pouvez archiver puis supprimer le dossier.
- if dossier.procedure.labels.present?
.fr-mb-3w
- if dossier.labels.present?
- dossier.labels.each do |label|
= tag_label(label.name, label.color)
= render Dropdown::MenuComponent.new(wrapper: :span, button_options: { class: ['fr-btn--sm fr-btn--tertiary-no-outline fr-pl-1v']}, menu_options: { class: ['dropdown-label left-aligned'] }) do |menu|
- if dossier.labels.empty?
- menu.with_button_inner_html do
Ajouter un label
- menu.with_form do
= form_with(url: dossier_labels_instructeur_dossier_path(dossier_id: dossier.id, procedure_id: dossier.procedure.id), method: :post, class: 'fr-p-3w', data: { controller: 'autosubmit', turbo: 'true' }) do |f|
%fieldset.fr-fieldset.fr-mt-2w.fr-mb-0
= f.collection_check_boxes :label_id, dossier.procedure.labels, :id, :name, include_hidden: false do |b|
.fr-fieldset__element
.fr-checkbox-group.fr-checkbox-group--sm.fr-mb-1w
= b.check_box(checked: DossierLabel.find_by(dossier_id: dossier.id, label_id: b.value).present? )
= b.label(class: "fr-label fr-tag fr-tag--sm fr-tag--#{Label.colors.fetch(b.object.color)}") { b.text }
%hr
%p.fr-text--sm.fr-text-mention--grey.fr-mb-0
%b Besoin d'autres labels ?
%br
Contactez les
= link_to 'administrateurs de la démarche', administrateurs_instructeur_procedure_path(dossier.procedure), class: 'fr-link fr-link--sm', **external_link_attributes

View file

@ -132,12 +132,12 @@
%td
- if p.hidden_by_administration_at.present?
%span.cell-link
= column
= column.is_a?(Hash) ? tags_label(column[:value]) : column
- if p.hidden_by_user_at.present?
= "- #{t("views.instructeurs.dossiers.deleted_reason.#{p.hidden_by_reason}")}"
- else
%a.cell-link{ href: path }
= column
= column.is_a?(Hash) ? tags_label(column[:value]) : column
= "- #{t("views.instructeurs.dossiers.deleted_reason.#{p.hidden_by_reason}")}" if p.hidden_by_user_at.present?
%td.status-col

View file

@ -5,7 +5,7 @@
= dsfr_icon('fr-icon-user-add-fill', :sm, :mr)
- if invites.present?
= t('views.invites.dropdown.view_invited_people')
%span.badge= invites.size
%span.fr-badge.fr-ml-1v= invites.size
- else
- if dossier.read_only?
= t('views.invites.dropdown.invite_to_view')

View file

@ -153,11 +153,6 @@
%span.label.refused .label.refused
%span.label.without-continuation .label.without-continuation
%h1 Badges
%span.badge 1
%span.badge.warning 1
%h1 Cards
.card

View file

@ -3,5 +3,5 @@
%span.notifications{ 'aria-label': 'notifications' }
= link_to(url, 'aria-selected': active ? true : nil, class: 'fr-tabs__tab', role: 'tab' ) do
- if badge.present?
%span.badge.fr-mr-1w= badge
%span.fr-badge.fr-badge--blue-ecume.fr-mr-1w= badge
= label

View file

@ -0,0 +1,17 @@
fr:
activerecord:
attributes:
label:
color: Couleur
name: Nom
label/color: &color
green_tilleul_verveine: 'tilleul'
green_bourgeon: 'bourgeon'
green_emeraude: 'émeraude'
green_menthe: 'menthe'
blue_ecume: 'écume'
purple_glycine: 'glycine'
pink_macaron: 'macaron'
yellow_tournesol: 'tournesol'
brown_cafe_creme: 'café'
beige_gris_galet: 'galet'

View file

@ -81,3 +81,5 @@ en:
association_date_creation: 'Association date de création'
association_date_declaration: 'Association date de déclaration'
association_date_publication: 'Association date de publication'
dossier_labels:
label_id: Labels

View file

@ -85,3 +85,5 @@ fr:
association_date_creation: 'Association date de création'
association_date_declaration: 'Association date de déclaration'
association_date_publication: 'Association date de publication'
dossier_labels:
label_id: Labels

View file

@ -512,6 +512,7 @@ Rails.application.routes.draw do
resources :commentaires, only: [:destroy]
post 'repousser-expiration' => 'dossiers#extend_conservation'
post 'repousser-expiration-and-restore' => 'dossiers#extend_conservation_and_restore'
post 'dossier_labels' => 'dossiers#dossier_labels'
get 'geo_data'
get 'apercu_attestation'
get 'bilans_bdf'
@ -707,6 +708,8 @@ Rails.application.routes.draw do
get 'preview', on: :member
end
resources :labels, controller: 'labels'
resource :attestation_template, only: [:show, :edit, :update, :create] do
get 'preview', on: :member
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreateLabels < ActiveRecord::Migration[7.0]
def change
create_table :labels do |t|
t.string :name
t.string :color
t.references :procedure, null: false, foreign_key: true
t.timestamps
end
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreateDossierLabels < ActiveRecord::Migration[7.0]
def change
create_table :dossier_labels do |t|
t.references :dossier, null: false, foreign_key: true
t.references :label, null: false, foreign_key: true
t.timestamps
end
end
end

View file

@ -416,6 +416,15 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do
t.index ["resolved_at"], name: "index_dossier_corrections_on_resolved_at", where: "((resolved_at IS NULL) OR (resolved_at IS NOT NULL))"
end
create_table "dossier_labels", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "dossier_id", null: false
t.bigint "label_id", null: false
t.datetime "updated_at", null: false
t.index ["dossier_id"], name: "index_dossier_labels_on_dossier_id"
t.index ["label_id"], name: "index_dossier_labels_on_label_id"
end
create_table "dossier_operation_logs", force: :cascade do |t|
t.boolean "automatic_operation", default: false, null: false
t.bigint "bill_signature_id"
@ -820,6 +829,15 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do
t.index ["email", "dossier_id"], name: "index_invites_on_email_and_dossier_id", unique: true
end
create_table "labels", force: :cascade do |t|
t.string "color"
t.datetime "created_at", null: false
t.string "name"
t.bigint "procedure_id", null: false
t.datetime "updated_at", null: false
t.index ["procedure_id"], name: "index_labels_on_procedure_id"
end
create_table "maintenance_tasks_runs", force: :cascade do |t|
t.text "arguments"
t.text "backtrace"
@ -1280,6 +1298,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do
add_foreign_key "dossier_batch_operations", "dossiers"
add_foreign_key "dossier_corrections", "commentaires"
add_foreign_key "dossier_corrections", "dossiers"
add_foreign_key "dossier_labels", "dossiers"
add_foreign_key "dossier_labels", "labels"
add_foreign_key "dossier_operation_logs", "bill_signatures"
add_foreign_key "dossier_transfer_logs", "dossiers"
add_foreign_key "dossiers", "batch_operations"
@ -1300,6 +1320,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do
add_foreign_key "groupe_instructeurs", "procedures"
add_foreign_key "initiated_mails", "procedures"
add_foreign_key "instructeurs", "users"
add_foreign_key "labels", "procedures"
add_foreign_key "merge_logs", "users"
add_foreign_key "procedure_presentations", "assign_tos"
add_foreign_key "procedure_revision_types_de_champ", "procedure_revision_types_de_champ", column: "parent_id"

View file

@ -68,7 +68,7 @@ describe MainNavigation::InstructeurExpertNavigationComponent, type: :component
it 'renders a link to expert all avis with current page class' do
expect(subject).to have_link('Avis', href: component.helpers.expert_all_avis_path)
expect(subject).to have_selector('a[aria-current="true"]', text: 'Avis')
expect(subject).not_to have_selector('span.badge')
expect(subject).not_to have_selector('span.fr-badge')
end
it 'does not have Démarches link' do
@ -79,7 +79,7 @@ describe MainNavigation::InstructeurExpertNavigationComponent, type: :component
let(:unanswered) { 2 }
it 'renders an unanswered avis badge for the expert' do
expect(subject).to have_selector('span.badge.warning', text: '2')
expect(subject).to have_selector('span.fr-badge', text: '2')
end
end

View file

@ -0,0 +1,168 @@
# frozen_string_literal: true
describe Administrateurs::LabelsController, type: :controller do
let(:admin) { administrateurs(:default_admin) }
let(:procedure) { create(:procedure, administrateur: admin) }
let(:admin_2) { create(:administrateur) }
let(:procedure_2) { create(:procedure, administrateur: admin_2) }
describe '#index' do
render_views
let!(:label_1) { create(:label, procedure:) }
let!(:label_2) { create(:label, procedure:) }
let!(:label_3) { create(:label, procedure:) }
before do
sign_in(admin.user)
end
subject { get :index, params: { procedure_id: procedure.id } }
it 'displays all procedure labels' do
subject
expect(response.body).to have_link("Nouveau label")
expect(response.body).to have_link("Modifier", count: 3)
expect(response.body).to have_link("Supprimer", count: 3)
end
end
describe '#create' do
before do
sign_in(admin.user)
end
subject { post :create, params: params }
context 'when submitting a new label' do
let(:params) do
{
label: {
name: 'Nouveau label',
color: 'green-bourgeon'
},
procedure_id: procedure.id
}
end
it { expect { subject }.to change { Label.count } .by(1) }
it 'creates a new label' do
subject
expect(flash.alert).to be_nil
expect(flash.notice).to eq('Le label a bien été créé')
expect(Label.last.name).to eq('Nouveau label')
expect(Label.last.color).to eq('green_bourgeon')
expect(procedure.labels.last).to eq(Label.last)
end
end
context 'when submitting an invalid label' do
let(:params) { { label: { name: 'Nouveau label' }, procedure_id: procedure.id } }
it { expect { subject }.not_to change { Label.count } }
it 'does not create a new label' do
subject
expect(flash.alert).to eq(["Le champ « Couleur » doit être rempli"])
expect(response).to render_template(:new)
expect(assigns(:label).name).to eq('Nouveau label')
end
end
context 'when submitting a label for a not own procedure' do
let(:params) do
{
label: {
name: 'Nouveau label',
color: 'green-bourgeon'
},
procedure_id: procedure_2.id
}
end
it { expect { subject }.not_to change { Label.count } }
it 'does not create a new label' do
subject
expect(flash.alert).to eq("Démarche inexistante")
expect(response.status).to eq(404)
end
end
end
describe '#update' do
let!(:label) { create(:label, procedure:) }
let(:label_params) { { name: 'Nouveau nom' } }
let(:params) { { id: label.id, label: label_params, procedure_id: procedure.id } }
before do
sign_in(admin.user)
end
subject { patch :update, params: }
context 'when updating a label' do
it 'updates correctly' do
subject
expect(flash.alert).to be_nil
expect(flash.notice).to eq('Le label a bien été modifié')
expect(label.reload.name).to eq('Nouveau nom')
expect(label.reload.color).to eq('green_bourgeon')
expect(label.reload.updated_at).not_to eq(label.reload.created_at)
expect(response).to redirect_to(admin_procedure_labels_path(procedure_id: procedure.id))
end
end
context 'when updating a service with invalid data' do
let(:label_params) { { name: '' } }
it 'does not update' do
subject
expect(flash.alert).not_to be_nil
expect(response).to render_template(:edit)
expect(label.reload.updated_at).to eq(label.reload.created_at)
end
end
context 'when updating a label for a not own procedure' do
let(:params) { { id: label.id, label: label_params, procedure_id: procedure_2.id } }
it 'does not update' do
subject
expect(label.reload.updated_at).to eq(label.reload.created_at)
end
end
end
describe '#destroy' do
let(:label) { create(:label, procedure:) }
before do
sign_in(admin.user)
end
subject { delete :destroy, params: }
context "when deleting a label" do
let(:params) { { id: label.id, procedure_id: procedure.id } }
it "delete the label" do
subject
expect { label.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(flash.notice).to eq('Le label a bien été supprimé')
expect(response).to redirect_to(admin_procedure_labels_path(procedure_id: procedure.id))
end
end
context 'when deleting a label for a not own procedure' do
let(:params) { { id: label.id, procedure_id: procedure_2.id } }
it 'does not delete' do
subject
expect(flash.alert).to eq("Démarche inexistante")
expect(response.status).to eq(404)
expect { label.reload }.not_to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end

View file

@ -513,6 +513,11 @@ describe Administrateurs::ProceduresController, type: :controller do
expect(response).to redirect_to(champs_admin_procedure_path(Procedure.last))
expect(flash[:notice]).to be_present
end
it "create generic labels" do
expect(subject.labels.size).to eq(5)
expect(subject.labels.first.name).to eq('À examiner')
end
end
describe "procedure is saved with custom retention period" do
@ -657,7 +662,7 @@ describe Administrateurs::ProceduresController, type: :controller do
end
describe 'PUT #clone' do
let(:procedure) { create(:procedure, :with_notice, :with_deliberation, administrateur: admin) }
let(:procedure) { create(:procedure, :with_notice, :with_deliberation, :with_labels, administrateur: admin) }
let(:params) { { procedure_id: procedure.id } }
subject { put :clone, params: params }
@ -679,6 +684,10 @@ describe Administrateurs::ProceduresController, type: :controller do
expect(Procedure.last.cloned_from_library).to be_falsey
expect(Procedure.last.notice.attached?).to be_truthy
expect(Procedure.last.deliberation.attached?).to be_truthy
expect(Procedure.last.labels.present?).to be_truthy
expect(Procedure.last.labels.first.procedure_id).to eq(Procedure.last.id)
expect(procedure.labels.first.procedure_id).to eq(procedure.id)
expect(flash[:notice]).to have_content 'Démarche clonée. Pensez à vérifier la présentation et choisir le service à laquelle cette démarche est associée.'
end
@ -700,6 +709,7 @@ describe Administrateurs::ProceduresController, type: :controller do
it 'creates a new procedure and redirect to it' do
expect(response).to redirect_to admin_procedure_path(id: Procedure.last.id)
expect(Procedure.last.labels.present?).to be_truthy
expect(flash[:notice]).to have_content 'Démarche clonée. Pensez à vérifier la présentation et choisir le service à laquelle cette démarche est associée.'
end
end

View file

@ -1518,4 +1518,38 @@ describe Instructeurs::DossiersController, type: :controller do
expect([Champs::PieceJustificativeChamp, Champs::TitreIdentiteChamp, Commentaire]).to include(*assigns(:gallery_attachments).map { _1.record.class })
end
end
describe 'dossier_labels' do
let(:procedure) { create(:procedure, :with_labels, instructeurs: [instructeur]) }
let!(:dossier) { create(:dossier, :en_construction, procedure:) }
context 'it create dossier labels' do
subject { post :dossier_labels, params: { procedure_id: procedure.id, dossier_id: dossier.id, label_id: [Label.first.id] }, format: :turbo_stream }
it 'works' do
subject
dossier.reload
expect(dossier.dossier_labels.count).to eq(1)
expect(subject.body).to include('fr-tag--purple-glycine')
expect(subject.body).not_to include('Ajouter un label')
end
end
context 'it remove dossier labels' do
before do
DossierLabel.create(dossier_id: dossier.id, label_id: dossier.procedure.labels.first.id)
end
subject { post :dossier_labels, params: { procedure_id: procedure.id, dossier_id: dossier.id, label_id: [] }, format: :turbo_stream }
it 'works' do
expect(dossier.dossier_labels.count).to eq(1)
subject
dossier.reload
expect(dossier.dossier_labels.count).to eq(0)
expect(subject.body).to include('Ajouter un label')
end
end
end
end

View file

@ -637,6 +637,38 @@ describe Instructeurs::ProceduresController, type: :controller do
it { expect(assigns(:last_export)).to eq(nil) }
end
end
context 'dossier labels' do
let(:procedure) { create(:procedure, :with_labels, instructeurs: [instructeur]) }
let!(:dossier) { create(:dossier, :en_construction, procedure:, groupe_instructeur: gi_2) }
let!(:dossier_2) { create(:dossier, :en_construction, procedure:, groupe_instructeur: gi_2) }
let(:statut) { 'tous' }
let(:label_id) { procedure.find_column(label: 'Labels') }
let!(:procedure_presentation) do
ProcedurePresentation.create!(assign_to: AssignTo.first)
end
render_views
before do
DossierLabel.create(dossier_id: dossier.id, label_id: dossier.procedure.labels.first.id)
DossierLabel.create(dossier_id: dossier.id, label_id: dossier.procedure.labels.second.id)
DossierLabel.create(dossier_id: dossier_2.id, label_id: dossier.procedure.labels.last.id)
procedure_presentation.update(displayed_columns: [
label_id.id
])
subject
end
it 'displays correctly labels in instructeur table' do
expect(response.body).to include("Labels")
expect(response.body).to have_selector('ul.fr-tags-group li span.fr-tag', text: 'À examiner')
expect(response.body).to have_selector('ul.fr-tags-group li span.fr-tag', text: 'À relancer')
expect(response.body).not_to have_selector('ul li span.fr-tag', text: 'Urgent')
expect(response.body).to have_selector('span.fr-tag', text: 'Urgent')
end
end
end
end

9
spec/factories/label.rb Normal file
View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
FactoryBot.define do
factory :label do
name { 'Un label' }
color { 'green-bourgeon' }
association :procedure
end
end

View file

@ -291,6 +291,12 @@ FactoryBot.define do
trait :accuse_lecture do
accuse_lecture { true }
end
trait :with_labels do
after(:create) do |procedure, _evaluator|
procedure.create_generic_labels
end
end
end
end

View file

@ -51,6 +51,7 @@ describe ColumnsConcern do
{ label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true },
{ label: 'Avis oui/non', table: 'avis', column: 'question_answer', displayable: true, type: :text, scope: '', value_column: :value, filterable: false },
{ label: 'France connecté ?', table: 'self', column: 'user_from_france_connect?', displayable: false, type: :text, scope: '', value_column: :value, filterable: false },
{ label: "Labels", table: "dossier_labels", column: "label_id", displayable: true, scope: '', value_column: :value, filterable: true },
{ label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },
{ label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', displayable: true, type: :text, scope: '', value_column: :value, filterable: true },

View file

@ -72,7 +72,7 @@ describe 'Inviting an expert:', js: true do
expect(page).to have_text('1 avis à donner')
expect(page).to have_text('0 avis donnés')
expect(page).to have_selector('.badge', text: 1)
expect(page).to have_selector('.fr-badge', text: 1)
expect(page).to have_selector('.notifications')
click_on '1 avis à donner'
@ -93,7 +93,7 @@ describe 'Inviting an expert:', js: true do
expect(page).to have_text('0 avis à donner')
expect(page).to have_text('1 avis donné')
expect(page).not_to have_selector('.badge', text: 1)
expect(page).not_to have_selector('.fr-badge', text: 1)
expect(page).not_to have_selector('.notifications')
end

View file

@ -272,6 +272,39 @@ describe 'Instructing a dossier:', js: true do
after { DownloadHelpers.clear_downloads }
end
context 'An instructeur can add labels' do
let(:procedure) { create(:procedure, :with_labels, :published, instructeurs: [instructeur]) }
scenario 'An instructeur can add and remove labels to a dossier' do
log_in(instructeur.email, password)
visit instructeur_dossier_path(procedure, dossier)
click_on 'Ajouter un label'
check 'À relancer', allow_label_click: true
expect(page).to have_css('.fr-tag', text: "À relancer", count: 2)
expect(dossier.dossier_labels.count).to eq(1)
expect(page).not_to have_text('Ajouter un label')
find('span.dropdown button.dropdown-button').click
expect(page).to have_checked_field('À relancer')
check 'Complet', allow_label_click: true
expect(page).to have_css('.fr-tag', text: "Complet", count: 2)
expect(dossier.dossier_labels.count).to eq(2)
find('span.dropdown button.dropdown-button').click
uncheck 'À relancer', allow_label_click: true
expect(page).to have_unchecked_field('À relancer')
expect(page).to have_checked_field('Complet')
expect(page).to have_css('.fr-tag', text: "À relancer", count: 1)
expect(page).to have_css('.fr-tag', text: "Complet", count: 2)
expect(dossier.dossier_labels.count).to eq(1)
end
end
def log_in(email, password, check_email: true)
visit new_user_session_path
expect(page).to have_current_path(new_user_session_path)

View file

@ -2,7 +2,7 @@
describe "procedure filters" do
let(:instructeur) { create(:instructeur) }
let(:procedure) { create(:procedure, :published, types_de_champ_public:, instructeurs: [instructeur]) }
let(:procedure) { create(:procedure, :published, :with_labels, types_de_champ_public:, instructeurs: [instructeur]) }
let(:types_de_champ_public) { [{ type: :text }] }
let!(:type_de_champ) { procedure.active_revision.types_de_champ_public.first }
let!(:new_unfollow_dossier) { create(:dossier, procedure: procedure, state: Dossier.states.fetch(:en_instruction)) }
@ -94,6 +94,7 @@ describe "procedure filters" do
expect(page).to have_link(new_unfollow_dossier_2.user.email)
end
end
describe 'with dropdown' do
let(:types_de_champ_public) { [{ type: :drop_down_list }] }
@ -171,6 +172,15 @@ describe "procedure filters" do
end
end
describe 'dossier labels' do
scenario "should be able to filter by dossier labels", js: true do
DossierLabel.create!(dossier_id: new_unfollow_dossier.id, label_id: procedure.labels.first.id)
add_filter('Labels', procedure.labels.first.name, type: :enum)
expect(page).to have_link(new_unfollow_dossier.id.to_s)
expect(page).not_to have_link(new_unfollow_dossier_2.id.to_s)
end
end
scenario "should be able to add and remove two filters for the same field", js: true do
add_filter(type_de_champ.libelle, champ.value)
add_filter(type_de_champ.libelle, champ_2.value)

View file

@ -217,4 +217,44 @@ describe 'instructeurs/dossiers/show', type: :view do
expect(subject).to have_selector('a.fr-sidemenu__link', text: 'l1')
end
end
describe "Dossier labels" do
let(:procedure) { create(:procedure, :with_labels) }
let(:dossier) { create(:dossier, :en_construction, procedure:) }
context "Procedure without labels" do
let(:procedure_without_labels) { create(:procedure) }
let(:dossier) { create(:dossier, :en_construction, procedure: procedure_without_labels) }
it 'does not display button to add label or dropdown' do
expect(subject).not_to have_text("Ajouter un label")
expect(subject).not_to have_text("À examiner")
end
end
context "Dossier without labels" do
it 'displays button with text to add label' do
expect(subject).to have_text("Ajouter un label")
expect(subject).to have_selector("button.dropdown-button")
expect(subject).to have_text("À examiner", count: 1)
within('.dropdown') do
expect(subject).to have_text("À examiner", count: 1)
end
end
end
context "Dossier with labels" do
before do
DossierLabel.create(dossier_id: dossier.id, label_id: dossier.procedure.labels.first.id)
end
it 'displays labels and button without text to add label' do
expect(subject).not_to have_text("Ajouter un label")
expect(subject).to have_selector("button.dropdown-button")
expect(subject).to have_text("À examiner", count: 2)
within('.dropdown') do
expect(subject).to have_text("À examiner", count: 1)
end
end
end
end
end