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:
commit
b8481796c7
13 changed files with 287 additions and 11 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
6
app/models/instructeurs_procedure.rb
Normal file
6
app/models/instructeurs_procedure.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class InstructeursProcedure < ApplicationRecord
|
||||
belongs_to :instructeur
|
||||
belongs_to :procedure
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
.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,
|
||||
|
|
24
app/views/instructeurs/procedures/order_positions.html.haml
Normal file
24
app/views/instructeurs/procedures/order_positions.html.haml
Normal 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
|
31
app/views/layouts/empty_layout.html.haml
Normal file
31
app/views/layouts/empty_layout.html.haml
Normal 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
|
|
@ -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
|
||||
|
|
15
db/migrate/20241119142129_create_instructeurs_procedures.rb
Normal file
15
db/migrate/20241119142129_create_instructeurs_procedures.rb
Normal 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
|
13
db/schema.rb
13
db/schema.rb
|
@ -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"
|
||||
|
|
8
spec/factories/instructeurs_procedure.rb
Normal file
8
spec/factories/instructeurs_procedure.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :instructeurs_procedure do
|
||||
association :instructeur
|
||||
association :procedure
|
||||
end
|
||||
end
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue