Merge pull request #5023 from betagouv/dev

2020-04-09-01
This commit is contained in:
Paul Chavard 2020-04-09 10:09:46 +02:00 committed by GitHub
commit 02e800a175
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1773 additions and 1498 deletions

View file

@ -219,13 +219,21 @@ module Users
end end
def recherche def recherche
@dossier_id = params[:dossier_id] @search_terms = params[:q]
dossier = current_user.dossiers.find_by(id: @dossier_id) return redirect_to dossiers_path if @search_terms.blank?
if dossier @dossiers = DossierSearchService.matching_dossiers_for_user(@search_terms, current_user).page(page)
redirect_to url_for_dossier(dossier)
if @dossiers.present?
# we need the page condition when accessing page n with n>1 when the page has only 1 result
# in order to avoid an unpleasant redirection when changing page
if @dossiers.count == 1 && page == 1
redirect_to url_for_dossier(@dossiers.first)
else
render :index
end
else else
flash.alert = "Vous navez pas de dossier avec le nº #{@dossier_id}." flash.alert = "Vous navez pas de dossiers contenant « #{@search_terms} »."
redirect_to dossiers_path redirect_to dossiers_path
end end
end end

View file

@ -745,6 +745,69 @@ type GroupeInstructeur {
id: ID! id: ID!
instructeurs: [Profile!]! instructeurs: [Profile!]!
label: String! label: String!
"""
Le numero du groupe instructeur.
"""
number: Int!
}
"""
Un groupe instructeur avec ces dossiers
"""
type GroupeInstructeurWithDossiers {
"""
Liste de tous les dossiers d'une démarche.
"""
dossiers(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Dossiers déposés depuis la date.
"""
createdSince: ISO8601DateTime
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
L'ordre des dossiers.
"""
order: Order = ASC
"""
Dossiers avec statut.
"""
state: DossierState
"""
Dossiers mis à jour depuis la date.
"""
updatedSince: ISO8601DateTime
): DossierConnection!
id: ID!
instructeurs: [Profile!]!
label: String!
"""
Le numero du groupe instructeur.
"""
number: Int!
} }
""" """
@ -975,6 +1038,16 @@ type Query {
""" """
number: Int! number: Int!
): Dossier! ): Dossier!
"""
Informations sur un groupe instructeur.
"""
groupeInstructeur(
"""
Numéro du groupe instructeur.
"""
number: Int!
): GroupeInstructeurWithDossiers!
} }
type RepetitionChamp implements Champ { type RepetitionChamp implements Champ {

View file

@ -3,15 +3,16 @@ module Types
description "Un groupe instructeur" description "Un groupe instructeur"
global_id_field :id global_id_field :id
field :number, Int, "Le numero du groupe instructeur.", null: false, method: :id
field :label, String, null: false field :label, String, null: false
field :instructeurs, [Types::ProfileType], null: false field :instructeurs, [Types::ProfileType], null: false
end
def instructeurs def instructeurs
Loaders::Association.for(object.class, :instructeurs).load(object) Loaders::Association.for(object.class, :instructeurs).load(object)
end end
def self.authorized?(object, context) def self.authorized?(object, context)
authorized_demarche?(object.procedure, context) authorized_demarche?(object.procedure, context)
end
end end
end end

View file

@ -0,0 +1,32 @@
module Types
class GroupeInstructeurWithDossiersType < GroupeInstructeurType
description "Un groupe instructeur avec ces dossiers"
field :dossiers, Types::DossierType.connection_type, "Liste de tous les dossiers d'une démarche.", null: false do
argument :order, Types::Order, default_value: :asc, required: false, description: "L'ordre des dossiers."
argument :created_since, GraphQL::Types::ISO8601DateTime, required: false, description: "Dossiers déposés depuis la date."
argument :updated_since, GraphQL::Types::ISO8601DateTime, required: false, description: "Dossiers mis à jour depuis la date."
argument :state, Types::DossierType::DossierState, required: false, description: "Dossiers avec statut."
end
def dossiers(updated_since: nil, created_since: nil, state: nil, order:)
dossiers = object.dossiers.state_not_brouillon.for_api_v2
if state.present?
dossiers = dossiers.where(state: state)
end
if updated_since.present?
dossiers = dossiers.updated_since(updated_since).order_by_updated_at(order)
else
if created_since.present?
dossiers = dossiers.created_since(created_since)
end
dossiers = dossiers.order_by_created_at(order)
end
dossiers
end
end
end

View file

@ -8,6 +8,10 @@ module Types
argument :number, Int, "Numéro du dossier.", required: true argument :number, Int, "Numéro du dossier.", required: true
end end
field :groupe_instructeur, GroupeInstructeurWithDossiersType, null: false, description: "Informations sur un groupe instructeur." do
argument :number, Int, "Numéro du groupe instructeur.", required: true
end
def demarche(number:) def demarche(number:)
Procedure.for_api_v2.find(number) Procedure.for_api_v2.find(number)
rescue => e rescue => e
@ -20,6 +24,12 @@ module Types
raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found }) raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found })
end end
def groupe_instructeur(number:)
GroupeInstructeur.for_api_v2.find(number)
rescue => e
raise GraphQL::ExecutionError.new(e.message, extensions: { code: :not_found })
end
def self.accessible?(context) def self.accessible?(context)
context[:token] || context[:administrateur_id] context[:token] || context[:administrateur_id]
end end

View file

@ -12,4 +12,5 @@ class GroupeInstructeur < ApplicationRecord
before_validation -> { label&.strip! } before_validation -> { label&.strip! }
scope :without_group, -> (group) { where.not(id: group) } scope :without_group, -> (group) { where.not(id: group) }
scope :for_api_v2, -> { includes(procedure: [:administrateurs]) }
end end

View file

@ -4,6 +4,11 @@ class DossierSearchService
.presence || dossier_by_full_text_for_instructeur(search_terms, instructeur) .presence || dossier_by_full_text_for_instructeur(search_terms, instructeur)
end end
def self.matching_dossiers_for_user(search_terms, user)
dossier_by_exact_id_for_user(search_terms, user)
.presence || dossier_by_full_text_for_user(search_terms, user.dossiers) || dossier_by_full_text_for_user(search_terms, user.dossiers_invites)
end
private private
def self.dossier_by_exact_id_for_instructeur(search_terms, instructeur) def self.dossier_by_exact_id_for_instructeur(search_terms, instructeur)
@ -26,6 +31,24 @@ class DossierSearchService
false false
end end
def self.dossier_by_full_text_for_user(search_terms, dossiers)
ts_vector = "to_tsvector('french', search_terms)"
ts_query = "to_tsquery('french', #{Dossier.connection.quote(to_tsquery(search_terms))})"
dossiers
.where("#{ts_vector} @@ #{ts_query}")
.order("COALESCE(ts_rank(#{ts_vector}, #{ts_query}), 0) DESC")
end
def self.dossier_by_exact_id_for_user(search_terms, user)
id = search_terms.to_i
if id != 0 && id_compatible?(id) # Sometimes user is searching dossiers with a big number (ex: SIRET), ActiveRecord can't deal with them and throws ActiveModel::RangeError. id_compatible? prevents this.
Dossier.where(id: user.dossiers.where(id: id) + user.dossiers_invites.where(id: id)).distinct
else
Dossier.none
end
end
def self.dossier_by_full_text_for_instructeur(search_terms, instructeur) def self.dossier_by_full_text_for_instructeur(search_terms, instructeur)
ts_vector = "to_tsvector('french', search_terms || private_search_terms)" ts_vector = "to_tsvector('french', search_terms || private_search_terms)"
ts_query = "to_tsquery('french', #{Dossier.connection.quote(to_tsquery(search_terms))})" ts_query = "to_tsquery('french', #{Dossier.connection.quote(to_tsquery(search_terms))})"

View file

@ -45,20 +45,11 @@
%ul.header-right-content %ul.header-right-content
- if nav_bar_profile == :instructeur && instructeur_signed_in? - if nav_bar_profile == :instructeur && instructeur_signed_in?
%li %li
.header-search{ role: 'search' } = render partial: 'layouts/search_dossiers_form', locals: { search_endpoint: instructeur_recherche_path }
= form_tag instructeur_recherche_path, method: :get, class: "form" do
= text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: "Rechercher un dossier"
%button{ title: "Rechercher" }
= image_tag "icons/search-blue.svg", alt: ''
- if nav_bar_profile == :user && user_signed_in? && current_user.dossiers.count > 2 - if nav_bar_profile == :user && user_signed_in? && current_user.dossiers.count > 2
%li %li
.header-search{ role: 'search' } = render partial: 'layouts/search_dossiers_form', locals: { search_endpoint: recherche_dossiers_path }
= form_tag recherche_dossiers_path, method: :post, class: "form" do
= label_tag :dossier_id, "Numéro de dossier", class: 'hidden'
= text_field_tag :dossier_id, "", placeholder: "Numéro de dossier"
%button{ title: "Rechercher" }
= image_tag "icons/search-blue.svg", alt: 'Rechercher', 'aria-hidden':'true'
- if instructeur_signed_in? || user_signed_in? - if instructeur_signed_in? || user_signed_in?
%li %li

View file

@ -0,0 +1,6 @@
.header-search{ role: 'search' }
= form_tag "#{search_endpoint}", method: :get, class: "form" do
= label_tag :q, "Numéro de dossier", class: 'hidden'
= text_field_tag "q", "#{@search_terms if @search_terms.present?}", placeholder: "Rechercher un dossier"
%button{ title: "Rechercher" }
= image_tag "icons/search-blue.svg", alt: 'Rechercher', 'aria-hidden':'true'

View file

@ -1,11 +1,16 @@
- content_for(:title, "Dossiers") - if @search_terms.present?
- content_for(:title, "Recherche : #{@search_terms}")
- else
- content_for(:title, "Dossiers")
- content_for :footer do - content_for :footer do
= render partial: "users/dossiers/index_footer" = render partial: "users/dossiers/index_footer"
.dossiers-headers.sub-header .dossiers-headers.sub-header
.container .container
- if @dossiers_invites.count == 0 - if @search_terms.present?
%h1.page-title Résultat de la recherche pour « #{@search_terms} »
- elsif @dossiers_invites.count == 0
%h1.page-title Mes dossiers %h1.page-title Mes dossiers
- else - else

View file

@ -275,7 +275,7 @@ Rails.application.routes.draw do
end end
collection do collection do
post 'recherche' get 'recherche'
end end
end end
resource :feedback, only: [:create] resource :feedback, only: [:create]

View file

@ -201,6 +201,7 @@ describe API::V2::GraphqlController do
} }
groupeInstructeur { groupeInstructeur {
id id
number
label label
} }
messages { messages {
@ -259,6 +260,7 @@ describe API::V2::GraphqlController do
], ],
groupeInstructeur: { groupeInstructeur: {
id: dossier.groupe_instructeur.to_typed_id, id: dossier.groupe_instructeur.to_typed_id,
number: dossier.groupe_instructeur.id,
label: dossier.groupe_instructeur.label label: dossier.groupe_instructeur.label
}, },
demandeur: { demandeur: {
@ -346,6 +348,36 @@ describe API::V2::GraphqlController do
end end
end end
context "groupeInstructeur" do
let(:groupe_instructeur) { procedure.groupe_instructeurs.first }
let(:query) do
"{
groupeInstructeur(number: #{groupe_instructeur.id}) {
id
number
label
dossiers {
nodes {
id
}
}
}
}"
end
it "should be returned" do
expect(gql_errors).to eq(nil)
expect(gql_data).to eq(groupeInstructeur: {
id: groupe_instructeur.to_typed_id,
number: groupe_instructeur.id,
label: groupe_instructeur.label,
dossiers: {
nodes: dossiers.map { |dossier| { id: dossier.to_typed_id } }
}
})
end
end
context "mutations" do context "mutations" do
describe 'dossierEnvoyerMessage' do describe 'dossierEnvoyerMessage' do
context 'success' do context 'success' do

View file

@ -1,7 +1,7 @@
describe 'user access to the list of their dossiers' do describe 'user access to the list of their dossiers' do
let(:user) { create(:user) } let(:user) { create(:user) }
let!(:dossier_brouillon) { create(:dossier, user: user) } let!(:dossier_brouillon) { create(:dossier, user: user) }
let!(:dossier_en_construction) { create(:dossier, :en_construction, user: user) } let!(:dossier_en_construction) { create(:dossier, :with_all_champs, :en_construction, user: user) }
let!(:dossier_en_instruction) { create(:dossier, :en_instruction, user: user) } let!(:dossier_en_instruction) { create(:dossier, :en_instruction, user: user) }
let!(:dossier_archived) { create(:dossier, :en_instruction, :archived, user: user) } let!(:dossier_archived) { create(:dossier, :en_instruction, :archived, user: user) }
let(:dossiers_per_page) { 25 } let(:dossiers_per_page) { 25 }
@ -85,13 +85,13 @@ describe 'user access to the list of their dossiers' do
describe "recherche" do describe "recherche" do
context "when the dossier does not exist" do context "when the dossier does not exist" do
before do before do
page.find_by_id('dossier_id').set(10000000) page.find_by_id('q').set(10000000)
click_button("Rechercher") click_button("Rechercher")
end end
it "shows an error message on the dossiers page" do it "shows an error message on the dossiers page" do
expect(current_path).to eq(dossiers_path) expect(current_path).to eq(dossiers_path)
expect(page).to have_content("Vous navez pas de dossier avec le nº 10000000.") expect(page).to have_content("Vous navez pas de dossiers contenant « 10000000 ».")
end end
end end
@ -99,19 +99,19 @@ describe 'user access to the list of their dossiers' do
let!(:dossier_other_user) { create(:dossier) } let!(:dossier_other_user) { create(:dossier) }
before do before do
page.find_by_id('dossier_id').set(dossier_other_user.id) page.find_by_id('q').set(dossier_other_user.id)
click_button("Rechercher") click_button("Rechercher")
end end
it "shows an error message on the dossiers page" do it "shows an error message on the dossiers page" do
expect(current_path).to eq(dossiers_path) expect(current_path).to eq(dossiers_path)
expect(page).to have_content("Vous navez pas de dossier avec le nº #{dossier_other_user.id}.") expect(page).to have_content("Vous navez pas de dossiers contenant « #{dossier_other_user.id} ».")
end end
end end
context "when the dossier belongs to the user" do context "when the dossier belongs to the user" do
before do before do
page.find_by_id('dossier_id').set(dossier_en_construction.id) page.find_by_id('q').set(dossier_en_construction.id)
click_button("Rechercher") click_button("Rechercher")
end end
@ -119,5 +119,34 @@ describe 'user access to the list of their dossiers' do
expect(current_path).to eq(dossier_path(dossier_en_construction)) expect(current_path).to eq(dossier_path(dossier_en_construction))
end end
end end
context "when user search for something inside the dossier" do
let(:dossier_en_construction2) { create(:dossier, :with_all_champs, :en_construction, user: user) }
before do
page.find_by_id('q').set(dossier_en_construction.champs.first.value)
end
context 'when it only matches one dossier' do
before do
click_button("Rechercher")
end
it "redirects to the dossier page" do
expect(current_path).to eq(dossier_path(dossier_en_construction))
end
end
context 'when it matches multiple dossier' do
before do
dossier_en_construction2.champs.first.update(value: dossier_en_construction.champs.first.value)
click_button("Rechercher")
end
it "redirects to the search results" do
expect(current_path).to eq(recherche_dossiers_path)
expect(page).to have_content(dossier_en_construction.id)
expect(page).to have_content(dossier_en_construction2.id)
end
end
end
end end
end end

View file

@ -95,4 +95,108 @@ describe DossierSearchService do
it { expect(subject.size).to eq(1) } it { expect(subject.size).to eq(1) }
end end
end end
describe '#matching_dossiers_for_user' do
subject { liste_dossiers }
let(:liste_dossiers) do
described_class.matching_dossiers_for_user(terms, user_1)
end
let(:user_1) { create(:user, email: 'bidou@clap.fr') }
let(:user_2) { create(:user) }
let(:procedure_1) { create(:procedure, :published) }
let(:procedure_2) { create(:procedure, :published) }
let!(:dossier_0) { create(:dossier, state: Dossier.states.fetch(:brouillon), procedure: procedure_1, user: user_1) }
let!(:dossier_0b) { create(:dossier, state: Dossier.states.fetch(:brouillon), procedure: procedure_1, user: user_2) }
let!(:etablissement_1) { create(:etablissement, entreprise_raison_sociale: 'OCTO Academy', siret: '41636169600051') }
let!(:dossier_1) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_1, user: user_1, etablissement: etablissement_1) }
let!(:etablissement_2) { create(:etablissement, entreprise_raison_sociale: 'Plop octo', siret: '41816602300012') }
let!(:dossier_2) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_1, user: user_1, etablissement: etablissement_2) }
let!(:etablissement_3) { create(:etablissement, entreprise_raison_sociale: 'OCTO Technology', siret: '41816609600051') }
let!(:dossier_3) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_2, user: user_1, etablissement: etablissement_3) }
let!(:dossier_archived) { create(:dossier, state: Dossier.states.fetch(:en_construction), procedure: procedure_1, archived: true, user: user_1) }
describe 'search is empty' do
let(:terms) { '' }
it { expect(subject.size).to eq(0) }
end
describe 'search by dossier id' do
context 'when the user owns the dossier' do
let(:terms) { dossier_0.id.to_s }
it { expect(subject.size).to eq(1) }
end
context 'when the user does not own the dossier' do
let(:terms) { dossier_0b.id.to_s }
it { expect(subject.size).to eq(0) }
end
end
describe 'search brouillon file' do
let(:terms) { 'brouillon' }
it { expect(subject.size).to eq(0) }
end
describe 'search on contact email' do
let(:terms) { 'bidou@clap.fr' }
it { expect(subject.size).to eq(5) }
end
describe 'search on contact name' do
let(:terms) { 'bidou@clap.fr' }
it { expect(subject.size).to eq(5) }
end
describe 'search on SIRET' do
context 'when is part of SIRET' do
let(:terms) { '4181' }
it { expect(subject.size).to eq(2) }
end
context 'when is a complet SIRET' do
let(:terms) { '41816602300012' }
it { expect(subject.size).to eq(1) }
end
end
describe 'search on raison social' do
let(:terms) { 'OCTO' }
it { expect(subject.size).to eq(3) }
end
describe 'search terms surrounded with spurious spaces' do
let(:terms) { ' OCTO ' }
it { expect(subject.size).to eq(3) }
end
describe 'search on multiple fields' do
let(:terms) { 'octo plop' }
it { expect(subject.size).to eq(1) }
end
describe 'search with characters disallowed by the tsquery parser' do
let(:terms) { "'?\\:&!(OCTO) <plop>" }
it { expect(subject.size).to eq(1) }
end
end
end end

2894
yarn.lock

File diff suppressed because it is too large Load diff