Merge pull request #11084 from demarches-simplifiees/etq-instructeur-ordonner-procedures

ETQ Instructeur : je veux pouvoir ordonner ma liste de démarches
This commit is contained in:
Benoit Queyron 2024-12-09 12:54:59 +00:00 committed by GitHub
commit b8481796c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 287 additions and 11 deletions

View file

@ -2,7 +2,7 @@
module Instructeurs
class ProceduresController < InstructeurController
before_action :ensure_ownership!, except: [:index]
before_action :ensure_ownership!, except: [:index, :order_positions, :update_order_positions]
before_action :ensure_not_super_admin!, only: [:download_export, :exports]
ITEMS_PER_PAGE = 100
@ -25,7 +25,8 @@ module Instructeurs
@procedures = all_procedures.order(closed_at: :desc, unpublished_at: :desc, published_at: :desc, created_at: :desc)
publiees_or_closes_with_dossiers_en_cours = all_procedures_for_listing.publiees.or(all_procedures.closes.where(id: procedures_dossiers_en_cours))
@procedures_en_cours = publiees_or_closes_with_dossiers_en_cours.order(published_at: :desc).page(params[:page]).per(ITEMS_PER_PAGE)
current_instructeur.ensure_instructeur_procedures_for(publiees_or_closes_with_dossiers_en_cours)
@procedures_en_cours = publiees_or_closes_with_dossiers_en_cours.order_by_position_for(current_instructeur).page(params[:page]).per(ITEMS_PER_PAGE)
closes_with_no_dossier_en_cours = all_procedures.closes.excluding(all_procedures.closes.where(id: procedures_dossiers_en_cours))
@procedures_closes = closes_with_no_dossier_en_cours.order(created_at: :desc).page(params[:page]).per(ITEMS_PER_PAGE)
@procedures_draft = all_procedures_for_listing.brouillons.order(created_at: :desc).page(params[:page]).per(ITEMS_PER_PAGE)
@ -67,6 +68,16 @@ module Instructeurs
@statut.blank? ? @statut = 'en-cours' : @statut = params[:statut]
end
def order_positions
@procedures = Procedure.where(id: params[:collection_ids]).order_by_position_for(current_instructeur)
render layout: "empty_layout"
end
def update_order_positions
current_instructeur.update_instructeur_procedures_positions(ordered_procedure_ids_params)
redirect_to instructeur_procedures_path, notice: "L'ordre des démarches a été mis à jour."
end
def show
@procedure = procedure
# Technically, procedure_presentation already sets the attribute.
@ -377,5 +388,9 @@ module Instructeurs
def cookies_export_key
"exports_#{@procedure.id}_seen_at"
end
def ordered_procedure_ids_params
params.require(:ordered_procedure_ids)
end
end
end

View file

@ -0,0 +1,62 @@
import { ApplicationController } from './application_controller';
export class MoveProceduresPositionController extends ApplicationController {
connect() {
this.updateButtonsStates();
}
async moveUp(event: Event) {
event.preventDefault();
const button = event.currentTarget as HTMLButtonElement;
const upCard = button.closest('.fr-card');
await this.switchCards(upCard!, upCard!.previousElementSibling!);
upCard!.parentNode!.insertBefore(upCard!, upCard!.previousElementSibling);
this.updateButtonsStates();
}
async moveDown(event: Event) {
event.preventDefault();
const button = event.currentTarget as HTMLButtonElement;
const downCard = button.closest('.fr-card');
await this.switchCards(downCard!.nextElementSibling!, downCard!);
downCard!.parentNode!.insertBefore(downCard!.nextElementSibling!, downCard);
this.updateButtonsStates();
}
private async switchCards(upCard: Element, downCard: Element): Promise<void> {
const upCardRect = upCard.getBoundingClientRect();
const downCardRect = downCard.getBoundingClientRect();
const upAnimation = upCard.animate(
[
{ transform: `translateY(0)` },
{ transform: `translateY(${downCardRect.top - upCardRect.top}px)` }
],
{ duration: 300, easing: 'ease-in-out' }
);
const downAnimation = downCard.animate(
[
{ transform: `translateY(0)` },
{ transform: `translateY(${upCardRect.top - downCardRect.top}px)` }
],
{ duration: 300, easing: 'ease-in-out' }
);
await Promise.all([upAnimation.finished, downAnimation.finished]);
}
private updateButtonsStates() {
const buttons = [
...this.element.querySelectorAll('button')
] as HTMLButtonElement[];
buttons.forEach(
(button, index) =>
(button.disabled = index == 0 || index == buttons.length - 1)
);
}
}

View file

@ -27,6 +27,8 @@ class Instructeur < ApplicationRecord
has_many :exports, as: :user_profile
has_many :archives, as: :user_profile
has_many :instructeurs_procedures, dependent: :destroy
belongs_to :user
scope :with_instant_email_message_notifications, -> {
@ -105,6 +107,26 @@ class Instructeur < ApplicationRecord
end
end
def ensure_instructeur_procedures_for(procedures)
current_instructeur_procedures = instructeurs_procedures.where(procedure_id: procedures.map(&:id))
top_position = current_instructeur_procedures.map(&:position).max || 0
missing_instructeur_procedures = procedures.sort_by(&:published_at).map(&:id).filter_map do |procedure_id|
if !procedure_id.in?(current_instructeur_procedures.map(&:procedure_id))
{ instructeur_id: id, procedure_id:, position: top_position += 1 }
end
end
InstructeursProcedure.insert_all(missing_instructeur_procedures) if missing_instructeur_procedures.size.positive?
end
def update_instructeur_procedures_positions(ordered_procedure_ids)
procedure_id_position = ordered_procedure_ids.reverse.each.with_index.to_h
InstructeursProcedure.transaction do
procedure_id_position.each do |procedure_id, position|
InstructeursProcedure.where(procedure_id:, instructeur_id: id).update(position:)
end
end
end
def procedure_presentation_and_errors_for_procedure_id(procedure_id)
assign_to
.joins(:groupe_instructeur)

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
class InstructeursProcedure < ApplicationRecord
belongs_to :instructeur
belongs_to :procedure
end

View file

@ -64,6 +64,8 @@ class Procedure < ApplicationRecord
has_many :bulk_messages, dependent: :destroy
has_many :labels, dependent: :destroy
has_many :instructeurs_procedures, dependent: :destroy
def active_dossier_submitted_message
published_dossier_submitted_message || draft_dossier_submitted_message
end
@ -192,6 +194,17 @@ class Procedure < ApplicationRecord
)
}
scope :for_api_v2, -> {
includes(:draft_revision, :published_revision, administrateurs: :user)
}
scope :order_by_position_for, -> (instructeur) {
joins(:instructeurs_procedures)
.select('procedures.*, instructeurs_procedures.position AS position')
.where(instructeurs_procedures: { instructeur_id: instructeur.id })
.order('position DESC')
}
enum declarative_with_state: {
en_instruction: 'en_instruction',
accepte: 'accepte'
@ -202,10 +215,6 @@ class Procedure < ApplicationRecord
other: 'other'
}, _prefix: true
scope :for_api_v2, -> {
includes(:draft_revision, :published_revision, administrateurs: :user)
}
validates :libelle, presence: true, allow_blank: false, allow_nil: false
validates :description, presence: true, allow_blank: false, allow_nil: false
validates :administrateurs, presence: true

View file

@ -12,17 +12,17 @@
= tab_item(t('pluralize.closed', count: @procedures_closes_count), instructeur_procedures_path(statut: 'archivees'), active: @statut == 'archivees', badge: number_with_html_delimiter(@procedures_closes_count))
.fr-container
- if @statut.in? ["publiees", "en-cours"] # FIX ME: @statut === "en-cours" à partir du 1/11/2023
- if @statut == "en-cours"
= render Dsfr::CalloutComponent.new(title: nil) do |c|
- c.with_body do
= t(".procedure_en_cours_description")
- collection = @procedures_en_cours
- if @statut === "brouillons"
- if @statut == "brouillons"
= render Dsfr::CalloutComponent.new(title: nil) do |c|
- c.with_body do
= t(".procedure_en_test_description")
- collection = @procedures_draft
- if @statut === "archivees"
- if @statut == "archivees"
= render Dsfr::CalloutComponent.new(title: nil) do |c|
- c.with_body do
= t(".procedure_close_description")
@ -30,8 +30,11 @@
- if collection.present?
%h2.fr-h6
= page_entries_info collection
.fr-container.flex.justify-between.fr-mb-6w
%h2.fr-h6.fr-m-0
= page_entries_info collection
- if (@statut == "en-cours" && collection.size > 1)
= link_to "Personnaliser l'ordre", order_positions_instructeur_procedures_path(collection_ids: collection.map(&:id)), class: 'fr-btn fr-btn--sm fr-btn--tertiary fr-btn--icon-left fr-icon-settings-5-line'
%ul.procedure-list.fr-pl-0
= render partial: 'instructeurs/procedures/list',
collection: collection,

View file

@ -0,0 +1,24 @@
.fr-container.fr-mt-6w.fr-mb-15w
= link_to " Liste des démarches", instructeur_procedures_path, class: 'fr-link fr-icon-arrow-left-line fr-link--icon--left fr-icon--sm'
%h3.fr-my-3w
Personnaliser l'ordre des #{@procedures.size} démarches « en cours »
%p Déplacez les démarches dans la liste pour les classer en fonction de vos préférences :
%fr-container{ data: { controller: 'move-procedures-position' } }
= form_tag update_order_positions_instructeur_procedures_path, method: :patch do
- @procedures.each do |procedure|
.fr-card.fr-mb-1w.fr-py-1w.fr-px-2w
.flex.align-center
%button.fr-btn.fr-icon-arrow-up-line.fr-btn--secondary.fr-col-1{ data: { action: "move-procedures-position#moveUp" } }
%button.fr-btn.fr-icon-arrow-down-line.fr-btn--secondary.fr-col-1.fr-mx-2w{ data: { action: "move-procedures-position#moveDown" } }
- if procedure.close?
%span.fr-badge.fr-mr-2w Close
- elsif procedure.depubliee?
%span.fr-badge.fr-mr-2w Dépubliée
= "#{procedure.libelle} - n°#{procedure.id}"
= hidden_field_tag "ordered_procedure_ids[]", procedure.id
.fixed-footer.fr-py-1w
.fr-btns-group.fr-btns-group--center.fr-btns-group--inline.fr-btns-group--inline-lg
= link_to "Annuler", instructeur_procedures_path, class: 'fr-btn fr-btn--secondary fr-my-1w'
%button.fr-btn.fr-my-1w{ type: "submit", form: 'order-instructeur-procedures-form' } Valider

View file

@ -0,0 +1,31 @@
!!! 5
%html{ lang: html_lang, data: { fr_scheme: 'system' }, class: yield(:root_class) }
%head
%meta{ "http-equiv": "Content-Type", content: "text/html; charset=UTF-8" }
%meta{ "http-equiv": "X-UA-Compatible", content: "IE=edge" }
%meta{ name: "viewport", content: "width=device-width, initial-scale=1" }
%meta{ name: "application-name", content: Current.application_name }
%meta{ name: "apple-mobile-web-app-title", content: Current.application_name }
= csrf_meta_tags
%title
= content_for?(:title) ? "#{yield(:title)} · #{Current.application_name}" : Current.application_name
= render partial: "layouts/favicons"
= vite_client_tag
= vite_react_refresh_tag
= vite_javascript_tag 'application'
= preload_link_tag(asset_url("Marianne-Regular.woff2"))
= preload_link_tag(asset_url("Spectral-Regular.ttf"))
= vite_stylesheet_tag 'main', media: 'all'
= stylesheet_link_tag 'application', media: 'all'
= render partial: 'layouts/setup_theme'
%body{ class: browser.platform.ios? ? 'ios' : nil, data: { controller: 'turbo' } }
.page-wrapper
%main
= content_for?(:content) ? yield(:content) : yield

View file

@ -460,6 +460,11 @@ Rails.application.routes.draw do
put 'preview'
end
end
collection do
get 'order_positions'
patch 'update_order_positions'
end
end
resources :procedure_presentation, only: [:update] do

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateInstructeursProcedures < ActiveRecord::Migration[7.0]
def change
create_table :instructeurs_procedures do |t|
t.references :instructeur, null: false, foreign_key: true
t.references :procedure, null: false, foreign_key: true
t.integer :position
t.timestamps
end
add_index :instructeurs_procedures, [:instructeur_id, :procedure_id], unique: true, name: 'index_instructeurs_procedures_on_instructeur_and_procedure'
end
end

View file

@ -821,6 +821,17 @@ ActiveRecord::Schema[7.0].define(version: 2024_11_26_145420) do
t.index ["user_id"], name: "index_instructeurs_on_user_id"
end
create_table "instructeurs_procedures", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "instructeur_id", null: false
t.integer "position"
t.bigint "procedure_id", null: false
t.datetime "updated_at", null: false
t.index ["instructeur_id", "procedure_id"], name: "index_instructeurs_procedures_on_instructeur_and_procedure", unique: true
t.index ["instructeur_id"], name: "index_instructeurs_procedures_on_instructeur_id"
t.index ["procedure_id"], name: "index_instructeurs_procedures_on_procedure_id"
end
create_table "invites", id: :serial, force: :cascade do |t|
t.datetime "created_at", precision: nil
t.integer "dossier_id"
@ -1325,6 +1336,8 @@ ActiveRecord::Schema[7.0].define(version: 2024_11_26_145420) do
add_foreign_key "groupe_instructeurs", "procedures"
add_foreign_key "initiated_mails", "procedures"
add_foreign_key "instructeurs", "users"
add_foreign_key "instructeurs_procedures", "instructeurs"
add_foreign_key "instructeurs_procedures", "procedures"
add_foreign_key "labels", "procedures"
add_foreign_key "merge_logs", "users"
add_foreign_key "procedure_presentations", "assign_tos"

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :instructeurs_procedure do
association :instructeur
association :procedure
end
end

View file

@ -862,6 +862,69 @@ describe Instructeur, type: :model do
end
end
describe '.ensure_instructeur_procedures_for' do
let(:instructeur) { create(:instructeur) }
let!(:procedures) { create_list(:procedure, 5, published_at: Time.current) }
context 'when some procedures are missing for the instructeur' do
before do
create(:instructeurs_procedure, instructeur: instructeur, procedure: procedures.first, position: 0)
end
it 'creates missing instructeurs_procedures with correct positions' do
expect {
instructeur.ensure_instructeur_procedures_for(procedures)
}.to change { InstructeursProcedure.count }.by(4)
instructeur_procedures = InstructeursProcedure.where(instructeur: instructeur)
expect(instructeur_procedures.pluck(:procedure_id)).to match_array(procedures.map(&:id))
expect(instructeur_procedures.pluck(:position)).to eq([0, 1, 2, 3, 4])
end
end
context 'when all procedures already exist for the instructeur' do
before do
procedures.each_with_index do |procedure, index|
create(:instructeurs_procedure, instructeur: instructeur, procedure: procedure, position: index + 1)
end
end
it 'does not create any new instructeurs_procedures' do
expect {
instructeur.ensure_instructeur_procedures_for(procedures)
}.not_to change { InstructeursProcedure.count }
end
end
end
describe '.update_instructeur_procedures_positions' do
let(:instructeur) { create(:instructeur) }
let!(:procedures) { create_list(:procedure, 5, published_at: Time.current) }
before do
procedures.each_with_index do |procedure, index|
create(:instructeurs_procedure, instructeur: instructeur, procedure: procedure, position: index + 1)
end
end
it 'updates the positions of the specified instructeurs_procedures' do
instructeur.update_instructeur_procedures_positions(procedures.map(&:id))
updated_positions = InstructeursProcedure
.where(instructeur:)
.order(:procedure_id)
.pluck(:procedure_id, :position)
expect(updated_positions).to match_array([
[procedures[0].id, 4],
[procedures[1].id, 3],
[procedures[2].id, 2],
[procedures[3].id, 1],
[procedures[4].id, 0]
])
end
end
private
def assign(procedure_to_assign, instructeur_assigne: instructeur)