diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index d05bade9f..a2ca49e60 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -42,7 +42,7 @@ trix-editor.fr-input { display: flex; flex-wrap: wrap; gap: 0.3rem; - margin-bottom: 0.3rem; + margin-bottom: 0.5rem; } } diff --git a/app/controllers/administrateurs/procedures_controller.rb b/app/controllers/administrateurs/procedures_controller.rb index 7660194c1..d2db31b2b 100644 --- a/app/controllers/administrateurs/procedures_controller.rb +++ b/app/controllers/administrateurs/procedures_controller.rb @@ -476,7 +476,16 @@ module Administrateurs procedures_result = procedures_result.where(procedures_zones: { zone_id: filter.zone_ids }) if filter.zone_ids.present? procedures_result = procedures_result.where(hidden_at_as_template: nil) procedures_result = procedures_result.where(aasm_state: filter.statuses) if filter.statuses.present? - procedures_result = procedures_result.where("tags @> ARRAY[?]::text[]", filter.tags) if filter.tags.present? + if filter.tags.present? + tag_ids = ProcedureTag.where(name: filter.tags).pluck(:id).flatten + + if tag_ids.any? + procedures_result = procedures_result + .joins(:procedure_tags) + .where(procedure_tags: { id: tag_ids }) + .distinct + end + end procedures_result = procedures_result.where(template: true) if filter.template? procedures_result = procedures_result.where(published_at: filter.from_publication_date..) if filter.from_publication_date.present? procedures_result = procedures_result.where(service: service) if filter.service_siret.present? @@ -532,7 +541,7 @@ module Administrateurs :lien_dpo, :opendata, :procedure_expires_when_termine_enabled, - { zone_ids: [], tags: [] } + { zone_ids: [], procedure_tag_names: [] } ] editable_params << :piece_justificative_multiple if @procedure && !@procedure.piece_justificative_multiple? @@ -545,6 +554,12 @@ module Administrateurs if permited_params[:auto_archive_on].present? permited_params[:auto_archive_on] = Date.parse(permited_params[:auto_archive_on]) + 1.day end + + if permited_params[:procedure_tag_names].present? + tag_ids = ProcedureTag.where(name: permited_params[:procedure_tag_names]).pluck(:id) + permited_params[:procedure_tag_ids] = tag_ids + permited_params.delete(:procedure_tag_names) + end permited_params end diff --git a/app/controllers/manager/procedure_tags_controller.rb b/app/controllers/manager/procedure_tags_controller.rb new file mode 100644 index 000000000..b314bd71d --- /dev/null +++ b/app/controllers/manager/procedure_tags_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Manager + class ProcedureTagsController < Manager::ApplicationController + end +end diff --git a/app/dashboards/procedure_tag_dashboard.rb b/app/dashboards/procedure_tag_dashboard.rb new file mode 100644 index 000000000..0d898a750 --- /dev/null +++ b/app/dashboards/procedure_tag_dashboard.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "administrate/base_dashboard" + +class ProcedureTagDashboard < Administrate::BaseDashboard + # ATTRIBUTE_TYPES + # a hash that describes the type of each of the model's fields. + # + # Each different type represents an Administrate::Field object, + # which determines how the attribute is displayed + # on pages throughout the dashboard. + ATTRIBUTE_TYPES = { + id: Field::Number, + name: Field::String, + created_at: Field::DateTime, + updated_at: Field::DateTime + }.freeze + + # COLLECTION_ATTRIBUTES + # an array of attributes that will be displayed on the model's index page. + # + # By default, it's limited to four items to reduce clutter on index pages. + # Feel free to add, remove, or rearrange items. + COLLECTION_ATTRIBUTES = [ + :id, + :name + ].freeze + + # SHOW_PAGE_ATTRIBUTES + # an array of attributes that will be displayed on the model's show page. + SHOW_PAGE_ATTRIBUTES = [ + :id, + :name, + :created_at, + :updated_at + ].freeze + + # FORM_ATTRIBUTES + # an array of attributes that will be displayed + # on the model's form (`new` and `edit`) pages. + FORM_ATTRIBUTES = [ + :name + ].freeze + + # COLLECTION_FILTERS + # a hash that defines filters that can be used while searching via the search + # field of the dashboard. + # + # For example to add an option to search for open resources by typing "open:" + # in the search field: + # + # COLLECTION_FILTERS = { + # open: ->(resources) { resources.where(open: true) } + # }.freeze + COLLECTION_FILTERS = {}.freeze + + # Overwrite this method to customize how procedure tags are displayed + # across all pages of the admin dashboard. + # + def display_resource(procedure_tag) + "ProcedureTag ##{procedure_tag.id}" + end +end diff --git a/app/models/procedure.rb b/app/models/procedure.rb index 10377d4ff..d959e4b39 100644 --- a/app/models/procedure.rb +++ b/app/models/procedure.rb @@ -56,6 +56,7 @@ class Procedure < ApplicationRecord belongs_to :service, optional: true belongs_to :zone, optional: true has_and_belongs_to_many :zones + has_and_belongs_to_many :procedure_tags has_many :bulk_messages, dependent: :destroy diff --git a/app/models/procedure_tag.rb b/app/models/procedure_tag.rb new file mode 100644 index 000000000..37d770b43 --- /dev/null +++ b/app/models/procedure_tag.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ProcedureTag < ApplicationRecord + has_and_belongs_to_many :procedures + + validates :name, presence: true, uniqueness: { case_sensitive: false } +end diff --git a/app/tasks/maintenance/create_procedure_tags_task.rb b/app/tasks/maintenance/create_procedure_tags_task.rb new file mode 100644 index 000000000..7b8c7c1ab --- /dev/null +++ b/app/tasks/maintenance/create_procedure_tags_task.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +# this task is used to create the procedure_tags and backfill the procedures that have the tag in their tags array + +module Maintenance + class CreateProcedureTagsTask < MaintenanceTasks::Task + include RunnableOnDeployConcern + include StatementsHelpersConcern + run_on_first_deploy + + def collection + [ + "Aap", + "Accompagnement", + "Action sociale", + "Adeli", + "Affectation", + "Agrément", + "Agriculture", + "agroécologie", + "Aide aux entreprises", + "Aide financière", + "Appel à manifestation d'intérêt", + "AMI", + "Animaux", + "Appel à projets", + "Association", + "Auto-école", + "Autorisation", + "Autorisation d'exercer", + "Bilan", + "Biodiversité", + "Candidature", + "Cerfa", + "Chasse", + "Cinéma", + "Cmg", + "Collectivé territoriale", + "Collège", + "Convention", + "Covid", + "Culture", + "Dérogation", + "Diplôme", + "Drone", + "DSDEN", + "Eau", + "Ecoles", + "Education", + "Elections", + "Energie", + "Enseignant", + "ENT", + "Environnement", + "Étrangers", + "Formation", + "FPRNM", + "Funéraire", + "Handicap", + "Hygiène", + "Industrie", + "innovation", + "Inscription", + "Logement", + "Lycée", + "Manifestation", + "Médicament", + "Micro-crèche", + "MODELE DS", + "Numérique", + "Permis", + "Pompiers", + "Préfecture", + "Professionels de santé", + "Recrutement", + "Rh", + "Santé", + "Scolaire", + "SDIS", + "Sécurité", + "Sécurité routière", + "Sécurité sociale", + "Séjour", + "Service civique", + "Subvention", + "Supérieur", + "Taxi", + "Télétravail", + "Tirs", + "Transition écologique", + "Transport", + "Travail", + "Université", + "Urbanisme" + ] + end + + def process(tag) + procedure_tag = ProcedureTag.find_or_create_by(name: tag) + + Procedure.where("? ILIKE ANY(tags)", tag).find_each(batch_size: 500) do |procedure| + procedure.procedure_tags << procedure_tag unless procedure.procedure_tags.include?(procedure_tag) + end + end + + def count + collection.size + end + end +end diff --git a/app/views/administrateurs/procedures/_informations.html.haml b/app/views/administrateurs/procedures/_informations.html.haml index 2700aec4f..3fbb1b39e 100644 --- a/app/views/administrateurs/procedures/_informations.html.haml +++ b/app/views/administrateurs/procedures/_informations.html.haml @@ -121,15 +121,17 @@ .fr-fieldset__element = f.label :tags, 'Associez des thématiques à la démarche', class: 'fr-label' - %p.fr-hint-text Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. Les tags sont partagés avec la communauté, ce qui vous permet de voir les tags attribués aux démarches créées par les autres administrateurs. + %p.fr-hint-text + Par des mots ou des expressions que vous attribuez aux démarches pour décrire leur contenu et pour les retrouver. + Les thèmes sont partagées avec la communauté, ce qui vous permet de voir les thèmes attribués aux démarches créées par les autres administrateurs. %react-fragment = render ReactComponent.new "ComboBox/MultiComboBox", id: "procedure_tags_combo", - items: Procedure.tags, - selected_keys: @procedure.tags, - name: 'procedure[tags][]', + items: ProcedureTag.order(:name).pluck(:name), + selected_keys: @procedure.procedure_tags.pluck(:name), + name: 'procedure[procedure_tag_names][]', value_separator: ',|;', - allows_custom_value: true, + allows_custom_value: false, 'aria-label': 'Tags', 'aria-describedby': 'procedure-tags' diff --git a/app/views/layouts/all.html.haml b/app/views/layouts/all.html.haml index 44d69dacf..b7809a62b 100644 --- a/app/views/layouts/all.html.haml +++ b/app/views/layouts/all.html.haml @@ -25,6 +25,21 @@ %span.fr-icon-arrow-go-back-line Réinitialiser %ul + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } + .fr-mb-1w + %button{ 'data-action': 'expand#toggle' } + %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } + Thématique + .fr-ml-1w.hidden{ 'data-expand-target': 'content' } + %div + = f.search_field :tags, placeholder: 'Choisissez un thème', list: 'tags_list', class: 'fr-input', data: { no_autosubmit: 'input', turbo_force: :server }, multiple: true + %datalist#tags_list + - ProcedureTag.order(:name).each do |tag| + %option{ value: tag.name, data: { id: tag.id } } + - if @filter.tags.present? + - @filter.tags.each do |tag| + = f.hidden_field :tags, value: tag, multiple: true, id: "tag-#{tag.tr(' ', '_')}" + %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } .fr-mb-1w %button{ 'data-action': 'expand#toggle' } @@ -105,22 +120,6 @@ = b.check_box(checked: @filter.status_filtered?(b.value)) = b.label(class: 'fr-label') { t b.text, scope: 'activerecord.attributes.procedure.aasm_state' } - - %li.fr-py-2w.fr-pl-2w{ 'data-controller': "expand" } - .fr-mb-1w - %button{ 'data-action': 'expand#toggle' } - %span.fr-icon-add-line.fr-icon--sm.fr-mr-1w.fr-text-action-high--blue-france{ 'aria-hidden': 'true', 'data-expand-target': 'icon' } - Thématique - .fr-ml-1w.hidden{ 'data-expand-target': 'content' } - %div - = f.search_field :tags, placeholder: 'Choisissez un thème', list: 'tags_list', class: 'fr-input', data: { no_autosubmit: 'input', turbo_force: :server }, multiple: true - %datalist#tags_list - - Procedure.tags.each do |tag| - %option{ value: tag } - - if @filter.tags.present? - - @filter.tags.each do |tag| - = f.hidden_field :tags, value: tag, multiple: true, id: "tag-#{tag.tr(' ', '_')}" - .fr-col-9 = yield(:results) = render template: 'layouts/application' diff --git a/config/routes.rb b/config/routes.rb index d414b1cd5..fc64cd041 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -33,6 +33,8 @@ Rails.application.routes.draw do resources :administrateur_confirmations, only: [:new, :create] end + resources :procedure_tags, only: [:index, :show, :new, :create, :edit, :update, :destroy] + resources :archives, only: [:index, :show] resources :dossiers, only: [:index, :show] do diff --git a/db/migrate/20240929124802_create_procedure_tags.rb b/db/migrate/20240929124802_create_procedure_tags.rb new file mode 100644 index 000000000..96466ba45 --- /dev/null +++ b/db/migrate/20240929124802_create_procedure_tags.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateProcedureTags < ActiveRecord::Migration[7.0] + def change + create_table :procedure_tags do |t| + t.string :name, null: false + t.timestamps + end + + add_index :procedure_tags, :name, unique: true + end +end diff --git a/db/migrate/20240929141825_create_join_table_procedures_procedure_tags.rb b/db/migrate/20240929141825_create_join_table_procedures_procedure_tags.rb new file mode 100644 index 000000000..5cd970db0 --- /dev/null +++ b/db/migrate/20240929141825_create_join_table_procedures_procedure_tags.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class CreateJoinTableProceduresProcedureTags < ActiveRecord::Migration[7.0] + def change + create_join_table :procedures, :procedure_tags do |t| + t.index [:procedure_id, :procedure_tag_id], name: 'index_procedures_tags_on_procedure_id_and_tag_id' + t.index [:procedure_tag_id, :procedure_id], name: 'index_procedures_tags_on_tag_id_and_procedure_id' + end + end +end diff --git a/db/schema.rb b/db/schema.rb index e55fcb86b..f8f7dca89 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_09_23_125619) do +ActiveRecord::Schema[7.0].define(version: 2024_09_29_141825) do # These are extensions that must be enabled in order to support this database enable_extension "pg_buffercache" enable_extension "pg_stat_statements" @@ -913,6 +913,21 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_23_125619) do t.index ["procedure_id"], name: "index_procedure_revisions_on_procedure_id" end + create_table "procedure_tags", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.string "name", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_procedure_tags_on_name", unique: true + end + + create_table "procedure_tags_procedures", id: false, force: :cascade do |t| + t.bigint "procedure_id", null: false + t.bigint "procedure_tag_id", null: false + t.index ["procedure_id", "procedure_tag_id"], name: "index_procedures_tags_on_procedure_id_and_tag_id" + t.index ["procedure_tag_id", "procedure_id"], name: "index_procedures_tags_on_tag_id_and_procedure_id" + end + create_table "procedures", id: :serial, force: :cascade do |t| t.string "aasm_state", default: "brouillon" t.boolean "accuse_lecture", default: false, null: false diff --git a/spec/controllers/administrateurs/procedures_controller_spec.rb b/spec/controllers/administrateurs/procedures_controller_spec.rb index 3658a0dd6..72b097a5d 100644 --- a/spec/controllers/administrateurs/procedures_controller_spec.rb +++ b/spec/controllers/administrateurs/procedures_controller_spec.rb @@ -15,7 +15,8 @@ describe Administrateurs::ProceduresController, type: :controller do let(:lien_site_web) { 'http://mon-site.gouv.fr' } let(:zone) { create(:zone) } let(:zone_ids) { [zone.id] } - let(:tags) { ["planete", "environnement"] } + let!(:tag1) { ProcedureTag.create(name: 'Aao') } + let!(:tag2) { ProcedureTag.create(name: 'Accompagnement') } describe '#apercu' do subject { get :apercu, params: { id: procedure.id } } @@ -64,7 +65,7 @@ describe Administrateurs::ProceduresController, type: :controller do monavis_embed: monavis_embed, zone_ids: zone_ids, lien_site_web: lien_site_web, - tags: tags + procedure_tag_names: ['Aao', 'Accompagnement'] } } @@ -278,21 +279,34 @@ describe Administrateurs::ProceduresController, type: :controller do end context 'with specific tag' do - let!(:tags_procedure) { create(:procedure, :published, tags: ['environnement', 'diplomatie']) } + let!(:tag_environnement) { ProcedureTag.create(name: 'environnement') } + let!(:tag_diplomatie) { ProcedureTag.create(name: 'diplomatie') } + let!(:tag_football) { ProcedureTag.create(name: 'football') } + + let!(:procedure) do + procedure = create(:procedure, :published) + procedure.procedure_tags << [tag_environnement, tag_diplomatie] + procedure + end it 'returns procedure who contains at least one tag included in params' do get :all, params: { tags: ['environnement'] } - expect(assigns(:procedures).any? { |p| p.id == tags_procedure.id }).to be_truthy + expect(assigns(:procedures).find { |p| p.id == procedure.id }).to be_present end it 'returns procedures who contains all tags included in params' do get :all, params: { tags: ['environnement', 'diplomatie'] } - expect(assigns(:procedures).any? { |p| p.id == tags_procedure.id }).to be_truthy + expect(assigns(:procedures).find { |p| p.id == procedure.id }).to be_present end - it 'does not returns the procedure' do + it 'returns the procedure when at least one tag is include' do get :all, params: { tags: ['environnement', 'diplomatie', 'football'] } - expect(assigns(:procedures).any? { |p| p.id == tags_procedure.id }).to be_falsey + expect(assigns(:procedures).find { |p| p.id == procedure.id }).to be_present + end + + it 'does not return procedure not having the queried tag' do + get :all, params: { tags: ['football'] } + expect(assigns(:procedures)).to be_empty end end @@ -495,8 +509,7 @@ describe Administrateurs::ProceduresController, type: :controller do expect(subject.organisation).to eq(organisation) expect(subject.administrateurs).to eq([admin]) expect(subject.duree_conservation_dossiers_dans_ds).to eq(duree_conservation_dossiers_dans_ds) - expect(subject.tags).to eq(["planete", "environnement"]) - + expect(subject.procedure_tags.pluck(:name)).to match_array(['Aao', 'Accompagnement']) expect(response).to redirect_to(champs_admin_procedure_path(Procedure.last)) expect(flash[:notice]).to be_present end diff --git a/spec/system/administrateurs/procedure_update_spec.rb b/spec/system/administrateurs/procedure_update_spec.rb index d4f2319cc..bf6dae878 100644 --- a/spec/system/administrateurs/procedure_update_spec.rb +++ b/spec/system/administrateurs/procedure_update_spec.rb @@ -58,23 +58,16 @@ describe 'Administrateurs can edit procedures', js: true do end context 'when we associate tags' do - scenario 'the administrator can edit and persist the tags' do - procedure.update!(tags: ['social']) + let!(:social_tag) { ProcedureTag.create(name: 'social') } + let!(:planete_tag) { ProcedureTag.create(name: 'planete') } + + scenario 'the tags are persisted when not interacting with the tags combobox' do + procedure.procedure_tags << social_tag visit edit_admin_procedure_path(procedure) - select_combobox('procedure_tags_combo', 'planete', custom_value: true) + click_on 'Enregistrer' - - expect(procedure.reload.tags).to eq(['social', 'planete']) - end - - scenario 'the tags are persisted when non interacting with the tags combobox' do - procedure.update!(tags: ['social']) - - visit edit_admin_procedure_path(procedure) - click_on 'Enregistrer' - - expect(procedure.reload.tags).to eq(['social']) + expect(procedure.procedure_tags.pluck(:name)).to match_array(['social']) end end diff --git a/spec/tasks/maintenance/create_procedure_tags_task_spec.rb b/spec/tasks/maintenance/create_procedure_tags_task_spec.rb new file mode 100644 index 000000000..1672cf398 --- /dev/null +++ b/spec/tasks/maintenance/create_procedure_tags_task_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +module Maintenance + RSpec.describe CreateProcedureTagsTask do + describe "#process" do + subject(:process) { described_class.new.process(tag) } + + let(:tag) { "Accompagnement" } + let!(:procedure) { create(:procedure, tags: ["Accompagnement"]) } + + it "creates the ProcedureTag if it does not exist" do + expect { process }.to change { ProcedureTag.count }.by(1) + expect(ProcedureTag.last.name).to eq(tag) + end + + context "when the ProcedureTag already exists" do + let!(:procedure_tag) { ProcedureTag.create(name: tag) } + + it "does not create a duplicate ProcedureTag" do + expect { process }.not_to change { ProcedureTag.count } + end + end + + it "associates procedures with the ProcedureTag" do + process + expect(procedure.reload.procedure_tags.map(&:name)).to include(tag) + end + end + end +end