diff --git a/app/components/dossiers/export_link_component.rb b/app/components/dossiers/export_link_component.rb index 78a997f71..f3ce43e34 100644 --- a/app/components/dossiers/export_link_component.rb +++ b/app/components/dossiers/export_link_component.rb @@ -30,7 +30,7 @@ class Dossiers::ExportLinkComponent < ApplicationComponent end def export_title(export) - if export.procedure_presentation_id.nil? + if !export.built_from_procedure_presentation? t(".export_title_everything", export_format: export.format) elsif export.tous? t(".export_title", export_format: export.format, count: export.count) diff --git a/app/components/instructeurs/column_filter_component.rb b/app/components/instructeurs/column_filter_component.rb index de1826037..7843bcbb1 100644 --- a/app/components/instructeurs/column_filter_component.rb +++ b/app/components/instructeurs/column_filter_component.rb @@ -30,7 +30,7 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent { selected_key: column.present? ? column.id : '', items: filterable_columns_options, - name: :column, + name: "#{prefix}[id]", id: 'search-filter', 'aria-describedby': 'instructeur-filter-combo-label', form: 'filter-component', @@ -39,13 +39,20 @@ class Instructeurs::ColumnFilterComponent < ApplicationComponent end def filterable_columns_options - procedure.columns.filter_map do |column| - next if column.filterable == false - - [column.label, column.id] - end + @procedure.columns.filter(&:filterable).map { [_1.label, _1.id] } end + def current_filter_tags + @procedure_presentation.filters_for(@statut).flat_map do + [ + hidden_field_tag("#{prefix}[id]", _1.column.id, id: nil), + hidden_field_tag("#{prefix}[filter]", _1.filter, id: nil) + ] + end.reduce(&:concat) + end + + def prefix = "#{procedure_presentation.filters_name_for(@statut)}[]" + private def find_type_de_champ(column) diff --git a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml index 6f43592d3..eaa41be42 100644 --- a/app/components/instructeurs/column_filter_component/column_filter_component.html.haml +++ b/app/components/instructeurs/column_filter_component/column_filter_component.html.haml @@ -1,4 +1,6 @@ = form_tag add_filter_instructeur_procedure_path(procedure), method: :post, class: 'dropdown-form large', id: 'filter-component', data: { turbo: true, controller: 'autosubmit' } do + = current_filter_tags + .fr-select-group = label_tag :column, t('.column'), class: 'fr-label fr-m-0', id: 'instructeur-filter-combo-label', for: 'search-filter' %react-fragment @@ -8,9 +10,9 @@ = label_tag :value, t('.value'), for: 'value', class: 'fr-label' - if column_type == :enum - = select_tag :value, options_for_select(options_for_select_of_column), id: 'value', name: 'value', class: 'fr-select', data: { no_autosubmit: true } + = select_tag :filter, options_for_select(options_for_select_of_column), id: 'value', name: "#{prefix}[filter]", class: 'fr-select', data: { no_autosubmit: true } - else - %input#value.fr-input{ type: column_type, name: :value, maxlength: ProcedurePresentation::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, data: { no_autosubmit: true } } + %input#value.fr-input{ type: column_type, name: "#{prefix}[filter]", maxlength: FilteredColumn::FILTERS_VALUE_MAX_LENGTH, disabled: column.nil? ? true : false, data: { no_autosubmit: true } } = hidden_field_tag :statut, statut = submit_tag t('.add_filter'), class: 'fr-btn fr-btn--secondary fr-mt-2w' diff --git a/app/components/instructeurs/column_picker_component.rb b/app/components/instructeurs/column_picker_component.rb index 92cc524da..f8c1b0fba 100644 --- a/app/components/instructeurs/column_picker_component.rb +++ b/app/components/instructeurs/column_picker_component.rb @@ -12,7 +12,7 @@ class Instructeurs::ColumnPickerComponent < ApplicationComponent def displayable_columns_for_select [ procedure.columns.filter(&:displayable).map { |column| [column.label, column.id] }, - procedure_presentation.displayed_fields.map { Column.new(**_1.deep_symbolize_keys.merge(procedure_id: procedure.id)).id } + procedure_presentation.displayed_columns.map(&:id) ] end end diff --git a/app/components/instructeurs/column_table_header_component.rb b/app/components/instructeurs/column_table_header_component.rb index 2ecb8f1df..004ef3138 100644 --- a/app/components/instructeurs/column_table_header_component.rb +++ b/app/components/instructeurs/column_table_header_component.rb @@ -9,6 +9,12 @@ class Instructeurs::ColumnTableHeaderComponent < ApplicationComponent private + def classname(column) + return 'status-col' if column.dossier_state? + return 'number-col' if column.type == :number + return 'sva-col' if column.column == 'sva_svr_decision_on' + end + def update_sort_path(column) id = column.id order = opposite_order_for(column) diff --git a/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml index e7cfa0eac..1e2857ec6 100644 --- a/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml +++ b/app/components/instructeurs/column_table_header_component/column_table_header_component.html.haml @@ -1,3 +1,3 @@ - @columns.each do |column| - %th{ aria_sort(column), scope: "col", class: column.classname } + %th{ aria_sort(column), scope: "col", class: classname(column) } = link_to label_and_arrow(column), update_sort_path(column) diff --git a/app/controllers/administrateurs/archives_controller.rb b/app/controllers/administrateurs/archives_controller.rb index 2157ee99e..0e265f84f 100644 --- a/app/controllers/administrateurs/archives_controller.rb +++ b/app/controllers/administrateurs/archives_controller.rb @@ -8,7 +8,7 @@ module Administrateurs helper_method :create_archive_url def index - @exports = Export.ante_chronological.by_key(all_groupe_instructeurs.map(&:id), nil) + @exports = Export.ante_chronological.by_key(all_groupe_instructeurs.map(&:id)) @average_dossier_weight = @procedure.average_dossier_weight @count_dossiers_termines_by_month = @procedure.dossiers.processed_by_month(all_groupe_instructeurs).count @archives = Archive.for_groupe_instructeur(all_groupe_instructeurs).to_a diff --git a/app/controllers/instructeurs/procedures_controller.rb b/app/controllers/instructeurs/procedures_controller.rb index 91f685dd7..2d9b4b26f 100644 --- a/app/controllers/instructeurs/procedures_controller.rb +++ b/app/controllers/instructeurs/procedures_controller.rb @@ -73,7 +73,7 @@ module Instructeurs # Setting it here to make clear that it is used by the view @procedure_presentation = procedure_presentation - @current_filters = current_filters + @current_filters = procedure_presentation.filters_for(statut) @counts = current_instructeur .dossiers_count_summary(groupe_instructeur_ids) .symbolize_keys @@ -95,7 +95,7 @@ module Instructeurs @has_export_notification = notify_exports? @last_export = last_export_for(statut) - @filtered_sorted_ids = procedure_presentation.filtered_sorted_ids(dossiers, statut, count: dossiers_count) + @filtered_sorted_ids = DossierFilterService.filtered_sorted_ids(dossiers, statut, procedure_presentation.filters_for(statut), procedure_presentation.sorted_column, current_instructeur, count: dossiers_count) page = params[:page].presence || 1 @dossiers_count = @filtered_sorted_ids.size @@ -104,7 +104,7 @@ module Instructeurs .page(page) .per(ITEMS_PER_PAGE) - @projected_dossiers = DossierProjectionService.project(@filtered_sorted_paginated_ids, procedure_presentation.displayed_fields) + @projected_dossiers = DossierProjectionService.project(@filtered_sorted_paginated_ids, procedure_presentation.displayed_columns) @disable_checkbox_all = @projected_dossiers.all? { _1.batch_operation_id.present? } @batch_operations = BatchOperation.joins(:groupe_instructeurs) @@ -133,9 +133,9 @@ module Instructeurs end def update_displayed_fields - values = (params['values'].presence || []).reject(&:empty?) + ids = (params['values'].presence || []).reject(&:empty?) - procedure_presentation.update_displayed_fields(values) + procedure_presentation.update!(displayed_columns: ids) redirect_back(fallback_location: instructeur_procedure_url(procedure)) end @@ -147,8 +147,10 @@ module Instructeurs end def add_filter - if !procedure_presentation.add_filter(statut, params[:column], params[:value]) - flash.alert = procedure_presentation.errors.full_messages + if !procedure_presentation.update(filter_params) + # complicated way to display inner error messages + flash.alert = procedure_presentation.errors + .flat_map { _1.detail[:value].flat_map { |c| c.errors.full_messages } } end redirect_back(fallback_location: instructeur_procedure_url(procedure)) @@ -158,13 +160,10 @@ module Instructeurs @statut = statut @procedure = procedure @procedure_presentation = procedure_presentation - @column = procedure.find_column(h_id: JSON.parse(params[:column], symbolize_names: true)) - end - - def remove_filter - procedure_presentation.remove_filter(statut, params[:column], params[:value]) - - redirect_back(fallback_location: instructeur_procedure_url(procedure)) + current_filter = procedure_presentation.filters_name_for(@statut) + # According to the html, the selected column is the last one + h_id = JSON.parse(params[current_filter].last[:id], symbolize_names: true) + @column = procedure.find_column(h_id:) end def download_export @@ -383,10 +382,6 @@ module Instructeurs end end - def current_filters - @current_filters ||= procedure_presentation.filters.fetch(statut, []) - end - def bulk_message_params params.require(:bulk_message).permit(:body) end @@ -415,5 +410,11 @@ module Instructeurs def sorted_column_params params.permit(sorted_column: [:order, :id]) end + + def filter_params + keys = [:tous_filters, :a_suivre_filters, :suivis_filters, :traites_filters, :expirant_filters, :archives_filters, :supprimes_filters] + h = keys.index_with { [:id, :filter] } + params.permit(h) + end end end diff --git a/app/controllers/recherche_controller.rb b/app/controllers/recherche_controller.rb index 98d684792..63772f839 100644 --- a/app/controllers/recherche_controller.rb +++ b/app/controllers/recherche_controller.rb @@ -3,10 +3,14 @@ class RechercheController < ApplicationController before_action :authenticate_logged_user! ITEMS_PER_PAGE = 25 + + # the columns are generally procedure specific + # but in the search context, we are looking for dossiers from multiple procedures + # so we are faking the columns with a random procedure_id PROJECTIONS = [ - { "table" => 'procedure', "column" => 'libelle' }, - { "table" => 'user', "column" => 'email' }, - { "table" => 'procedure', "column" => 'procedure_id' } + Column.new(procedure_id: 666, table: 'procedure', column: 'libelle'), + Column.new(procedure_id: 666, table: 'user', column: 'email'), + Column.new(procedure_id: 666, table: 'procedure', column: 'procedure_id') ] def nav_bar_profile diff --git a/app/dashboards/export_dashboard.rb b/app/dashboards/export_dashboard.rb index bab3021d2..ee50290a5 100644 --- a/app/dashboards/export_dashboard.rb +++ b/app/dashboards/export_dashboard.rb @@ -17,7 +17,6 @@ class ExportDashboard < Administrate::BaseDashboard job_status: Field::Select.with_options(searchable: false, collection: -> (field) { field.resource.class.send(field.attribute.to_s.pluralize).keys }), key: Field::Text, procedure_presentation: IdField, - procedure_presentation_snapshot: Field::String.with_options(searchable: false), statut: Field::Select.with_options(searchable: false, collection: -> (field) { field.resource.class.send(field.attribute.to_s.pluralize).keys }), time_span_type: Field::Select.with_options(searchable: false, collection: -> (field) { field.resource.class.send(field.attribute.to_s.pluralize).keys }), created_at: Field::DateTime.with_options(format: "%d/%m %H:%M:%S"), @@ -34,7 +33,7 @@ class ExportDashboard < Administrate::BaseDashboard # SHOW_PAGE_ATTRIBUTES # an array of attributes that will be displayed on the model's show page. - SHOW_PAGE_ATTRIBUTES = [:id, :procedure, :job_status, :format, :statut, :file, :groupe_instructeurs, :key, :procedure_presentation, :procedure_presentation_snapshot, :time_span_type, :created_at, :updated_at].freeze + SHOW_PAGE_ATTRIBUTES = [:id, :procedure, :job_status, :format, :statut, :file, :groupe_instructeurs, :key, :procedure_presentation, :time_span_type, :created_at, :updated_at].freeze # FORM_ATTRIBUTES # an array of attributes that will be displayed diff --git a/app/models/assign_to.rb b/app/models/assign_to.rb index bfd7990e0..d9f6071c5 100644 --- a/app/models/assign_to.rb +++ b/app/models/assign_to.rb @@ -10,28 +10,31 @@ class AssignTo < ApplicationRecord def procedure_presentation_or_default_and_errors errors = reset_procedure_presentation_if_invalid + if self.procedure_presentation.nil? - self.procedure_presentation = build_procedure_presentation - self.procedure_presentation.save if procedure_presentation.valid? && !procedure_presentation.persisted? + self.procedure_presentation = create_procedure_presentation! end + [self.procedure_presentation, errors] end private def reset_procedure_presentation_if_invalid - if procedure_presentation&.invalid? - # This is a last defense against invalid `ProcedurePresentation`s persistently - # hindering instructeurs. Whenever this gets triggered, it means that there is - # a bug somewhere else that we need to fix. + errors = begin + procedure_presentation.errors if procedure_presentation&.invalid? + rescue ActiveRecord::RecordNotFound => e + [e.message] + end - errors = procedure_presentation.errors + if errors.present? Sentry.capture_message( "Destroying invalid ProcedurePresentation", - extra: { procedure_presentation: procedure_presentation.as_json } + extra: { procedure_presentation_id: procedure_presentation.id, errors: } ) self.procedure_presentation = nil - errors end + + errors end end diff --git a/app/models/column.rb b/app/models/column.rb index 662bfda76..2e95a9c3e 100644 --- a/app/models/column.rb +++ b/app/models/column.rb @@ -1,16 +1,20 @@ # frozen_string_literal: true class Column + # include validations to enable procedure_presentation.validate_associate, + # which enforces the deserialization of columns in the displayed_columns attribute + # and raises an error if a column is not found + include ActiveModel::Validations + TYPE_DE_CHAMP_TABLE = 'type_de_champ' - attr_reader :table, :column, :label, :classname, :type, :scope, :value_column, :filterable, :displayable + attr_reader :table, :column, :label, :type, :scope, :value_column, :filterable, :displayable - def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, classname: '', scope: '') + def initialize(procedure_id:, table:, column:, label: nil, type: :text, value_column: :value, filterable: true, displayable: true, scope: '') @procedure_id = procedure_id @table = table @column = column @label = label || I18n.t(column, scope: [:activerecord, :attributes, :procedure_presentation, :fields, table]) - @classname = classname @type = type @scope = scope @value_column = value_column @@ -29,15 +33,21 @@ class Column def to_json { - table:, column:, label:, classname:, type:, scope:, value_column:, filterable:, displayable: + table:, column:, label:, type:, scope:, value_column:, filterable:, displayable: } end - def notifications? - table == 'notifications' && column == 'notifications' - end + def notifications? = [table, column] == ['notifications', 'notifications'] + + def dossier_state? = [table, column] == ['self', 'state'] def self.find(h_id) - Procedure.with_discarded.find(h_id[:procedure_id]).find_column(h_id:) + begin + procedure = Procedure.with_discarded.find(h_id[:procedure_id]) + rescue ActiveRecord::RecordNotFound + raise ActiveRecord::RecordNotFound.new("Column: unable to find procedure #{h_id[:procedure_id]} from h_id #{h_id}") + end + + procedure.find_column(h_id: h_id) end end diff --git a/app/models/concerns/columns_concern.rb b/app/models/concerns/columns_concern.rb index d21cb1cba..0d1b9a984 100644 --- a/app/models/concerns/columns_concern.rb +++ b/app/models/concerns/columns_concern.rb @@ -12,7 +12,7 @@ module ColumnsConcern column = columns.find { _1.h_id == h_id } if h_id.present? column = columns.find { _1.label == label } if label.present? - raise ActiveRecord::RecordNotFound if column.nil? + raise ActiveRecord::RecordNotFound.new("Column: unable to find h_id: #{h_id} or label: #{label} for procedure_id #{id}") if column.nil? column end @@ -30,7 +30,11 @@ module ColumnsConcern end def dossier_id_column - Column.new(procedure_id: id, table: 'self', column: 'id', classname: 'number-col', type: :number) + Column.new(procedure_id: id, table: 'self', column: 'id', type: :number) + end + + def dossier_state_column + Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false) end def notifications_column @@ -46,25 +50,23 @@ module ColumnsConcern non_displayable_dates = ['updated_since', 'depose_since', 'en_construction_since', 'en_instruction_since', 'processed_since'] .map { |column| Column.new(procedure_id: id, table: 'self', column:, type: :date, displayable: false) } - states = [Column.new(procedure_id: id, table: 'self', column: 'state', type: :enum, scope: 'instructeurs.dossiers.filterable_state', displayable: false)] + states = [dossier_state_column] - [common, dates, sva_svr_columns(for_filters: true), non_displayable_dates, states].flatten.compact + [common, dates, sva_svr_columns, non_displayable_dates, states].flatten.compact end - def sva_svr_columns(for_filters: false) + def sva_svr_columns return if !sva_svr_enabled? scope = [:activerecord, :attributes, :procedure_presentation, :fields, :self] columns = [ Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_on', type: :date, - label: I18n.t("#{sva_svr_decision}_decision_on", scope:), classname: for_filters ? '' : 'sva-col') + label: I18n.t("#{sva_svr_decision}_decision_on", scope:)) ] - if for_filters - columns << Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, - label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) - end + columns << Column.new(procedure_id: id, table: 'self', column: 'sva_svr_decision_before', type: :date, displayable: false, + label: I18n.t("#{sva_svr_decision}_decision_before", scope:)) columns end @@ -73,11 +75,17 @@ module ColumnsConcern SortedColumn.new(column: notifications_column, order: 'desc') end + def default_displayed_columns = [email_column] + private + def email_column + Column.new(procedure_id: id, table: 'user', column: 'email') + end + def standard_columns [ - Column.new(procedure_id: id, table: 'user', column: 'email'), + email_column, Column.new(procedure_id: id, table: 'followers_instructeurs', column: 'email'), Column.new(procedure_id: id, table: 'groupe_instructeur', column: 'id', type: :enum), Column.new(procedure_id: id, table: 'avis', column: 'question_answer', filterable: false) # not filterable ? diff --git a/app/models/concerns/dossier_filtering_concern.rb b/app/models/concerns/dossier_filtering_concern.rb index e102c1239..a1470bbda 100644 --- a/app/models/concerns/dossier_filtering_concern.rb +++ b/app/models/concerns/dossier_filtering_concern.rb @@ -29,13 +29,13 @@ module DossierFilteringConcern } scope :filter_ilike, lambda { |table, column, values| - table_column = ProcedurePresentation.sanitized_column(table, column) + table_column = DossierFilterService.sanitized_column(table, column) q = Array.new(values.count, "(#{table_column} ILIKE ?)").join(' OR ') where(q, *(values.map { |value| "%#{value}%" })) } scope :filter_enum, lambda { |table, column, values| - table_column = ProcedurePresentation.sanitized_column(table, column) + table_column = DossierFilterService.sanitized_column(table, column) q = Array.new(values.count, "(#{table_column} = ?)").join(' OR ') where(q, *(values)) } diff --git a/app/models/export.rb b/app/models/export.rb index 0eeff606a..7cce92ca9 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -3,6 +3,8 @@ class Export < ApplicationRecord include TransientModelsWithPurgeableJobConcern + self.ignored_columns += ["procedure_presentation_snapshot"] + MAX_DUREE_CONSERVATION_EXPORT = 32.hours MAX_DUREE_GENERATION = 16.hours @@ -37,6 +39,9 @@ class Export < ApplicationRecord has_one_attached :file + attribute :sorted_column, :sorted_column + attribute :filtered_columns, :filtered_column, array: true + validates :format, :groupe_instructeurs, :key, presence: true scope :ante_chronological, -> { order(updated_at: :desc) } @@ -56,7 +61,6 @@ class Export < ApplicationRecord def compute self.dossiers_count = dossiers_for_export.count - load_snapshot! file.attach(blob.signed_id) # attaching a blob directly might run identify/virus scanner and wipe it end @@ -65,17 +69,16 @@ class Export < ApplicationRecord time_span_type == Export.time_span_types.fetch(:monthly) ? 30.days.ago : nil end - def filtered? - procedure_presentation_id.present? - end - def self.find_or_create_fresh_export(format, groupe_instructeurs, user_profile, time_span_type: time_span_types.fetch(:everything), statut: statuts.fetch(:tous), procedure_presentation: nil, export_template: nil) + filtered_columns = Array.wrap(procedure_presentation&.filters_for(statut)) + sorted_column = procedure_presentation&.sorted_column + attributes = { format:, export_template:, time_span_type:, statut:, - key: generate_cache_key(groupe_instructeurs.map(&:id), procedure_presentation) + key: generate_cache_key(groupe_instructeurs.map(&:id), filtered_columns, sorted_column) } recent_export = pending @@ -87,36 +90,30 @@ class Export < ApplicationRecord create!(**attributes, groupe_instructeurs:, user_profile:, - procedure_presentation:, - procedure_presentation_snapshot: procedure_presentation&.snapshot) + filtered_columns:, + sorted_column:) end def self.for_groupe_instructeurs(groupe_instructeurs_ids) joins(:groupe_instructeurs).where(groupe_instructeurs: groupe_instructeurs_ids).distinct(:id) end - def self.by_key(groupe_instructeurs_ids, procedure_presentation) - where(key: [ - generate_cache_key(groupe_instructeurs_ids), - generate_cache_key(groupe_instructeurs_ids, procedure_presentation) - ]) + def self.by_key(groupe_instructeurs_ids) + where(key: generate_cache_key(groupe_instructeurs_ids)) end - def self.generate_cache_key(groupe_instructeurs_ids, procedure_presentation = nil) - if procedure_presentation.present? - [ - groupe_instructeurs_ids.sort.join('-'), - procedure_presentation.id, - Digest::MD5.hexdigest(procedure_presentation.snapshot.slice(:filters, :sort).to_s) - ].join('--') - else - groupe_instructeurs_ids.sort.join('-') - end + def self.generate_cache_key(groupe_instructeurs_ids, filtered_columns = [], sorted_column = nil) + columns_key = ([sorted_column] + filtered_columns).compact.map(&:id).sort.join + + [ + groupe_instructeurs_ids.sort.join('-'), + Digest::MD5.hexdigest(columns_key) + ].join('--') end def count return dossiers_count if !dossiers_count.nil? # export generated - return dossiers_for_export.count if procedure_presentation_id.present? + return dossiers_for_export.count if built_from_procedure_presentation? nil end @@ -125,23 +122,21 @@ class Export < ApplicationRecord groupe_instructeurs.first.procedure end - private - - def load_snapshot! - if procedure_presentation_snapshot.present? - procedure_presentation.attributes = procedure_presentation_snapshot - end + def built_from_procedure_presentation? + sorted_column.present? # hack has we know that procedure_presentation always has a sorted_column end + private + def dossiers_for_export @dossiers_for_export ||= begin dossiers = Dossier.where(groupe_instructeur: groupe_instructeurs) if since.present? dossiers.visible_by_administration.where('dossiers.depose_at > ?', since) - elsif procedure_presentation.present? - filtered_sorted_ids = procedure_presentation - .filtered_sorted_ids(dossiers, statut) + elsif filtered_columns.present? || sorted_column.present? + instructeur = instructeur_from(user_profile) + filtered_sorted_ids = DossierFilterService.filtered_sorted_ids(dossiers, statut, filtered_columns, sorted_column, instructeur) dossiers.where(id: filtered_sorted_ids) else @@ -150,6 +145,15 @@ class Export < ApplicationRecord end end + def instructeur_from(user_profile) + case user_profile + when Administrateur + user_profile.instructeur + when Instructeur + user_profile + end + end + def blob service = ProcedureExportService.new(procedure, dossiers_for_export, user_profile, export_template) diff --git a/app/models/filtered_column.rb b/app/models/filtered_column.rb new file mode 100644 index 000000000..c348e60fd --- /dev/null +++ b/app/models/filtered_column.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class FilteredColumn + include ActiveModel::Validations + + FILTERS_VALUE_MAX_LENGTH = 4048 + # https://www.postgresql.org/docs/current/datatype-numeric.html + PG_INTEGER_MAX_VALUE = 2147483647 + + attr_reader :column, :filter + + delegate :label, to: :column + + validate :check_filter_max_length + validate :check_filter_max_integer + + def initialize(column:, filter:) + @column = column + @filter = filter + end + + def ==(other) + other&.column == column && other.filter == filter + end + + def id + column.h_id.merge(filter:).sort.to_json + end + + private + + def check_filter_max_length + if @filter.present? && @filter.length.to_i > FILTERS_VALUE_MAX_LENGTH + errors.add( + :base, + "Le filtre #{label} est trop long (maximum: #{FILTERS_VALUE_MAX_LENGTH} caractères)" + ) + end + end + + def check_filter_max_integer + if @column.column == 'id' && @filter.to_i > PG_INTEGER_MAX_VALUE + errors.add(:base, "Le filtre #{label} n'est pas un numéro de dossier possible") + end + end +end diff --git a/app/models/procedure_presentation.rb b/app/models/procedure_presentation.rb index d1fb52ceb..07365e9b7 100644 --- a/app/models/procedure_presentation.rb +++ b/app/models/procedure_presentation.rb @@ -1,38 +1,31 @@ # frozen_string_literal: true class ProcedurePresentation < ApplicationRecord - TABLE = 'table' - COLUMN = 'column' - ORDER = 'order' - - SLASH = '/' TYPE_DE_CHAMP = 'type_de_champ' - FILTERS_VALUE_MAX_LENGTH = 4048 - # https://www.postgresql.org/docs/current/datatype-numeric.html - PG_INTEGER_MAX_VALUE = 2147483647 - belongs_to :assign_to, optional: false has_many :exports, dependent: :destroy delegate :procedure, :instructeur, to: :assign_to - validate :check_allowed_displayed_fields - validate :check_allowed_filter_columns - validate :check_filters_max_length - validate :check_filters_max_integer + attribute :displayed_columns, :column, array: true attribute :sorted_column, :sorted_column def sorted_column = super || procedure.default_sorted_column # Dummy override to set default value - attribute :a_suivre_filters, :jsonb, array: true - attribute :suivis_filters, :jsonb, array: true - attribute :traites_filters, :jsonb, array: true - attribute :tous_filters, :jsonb, array: true - attribute :supprimes_filters, :jsonb, array: true - attribute :supprimes_recemment_filters, :jsonb, array: true - attribute :expirant_filters, :jsonb, array: true - attribute :archives_filters, :jsonb, array: true + attribute :a_suivre_filters, :filtered_column, array: true + attribute :suivis_filters, :filtered_column, array: true + attribute :traites_filters, :filtered_column, array: true + attribute :tous_filters, :filtered_column, array: true + attribute :supprimes_filters, :filtered_column, array: true + attribute :supprimes_recemment_filters, :filtered_column, array: true + attribute :expirant_filters, :filtered_column, array: true + attribute :archives_filters, :filtered_column, array: true + + before_create { self.displayed_columns = procedure.default_displayed_columns } + + validates_associated :displayed_columns, :sorted_column, :a_suivre_filters, :suivis_filters, + :traites_filters, :tous_filters, :supprimes_filters, :expirant_filters, :archives_filters def filters_for(statut) send(filters_name_for(statut)) @@ -42,46 +35,35 @@ class ProcedurePresentation < ApplicationRecord def displayed_fields_for_headers [ - Column.new(procedure_id: procedure.id, table: 'self', column: 'id', classname: 'number-col'), - *displayed_fields.map { Column.new(**_1.deep_symbolize_keys.merge(procedure_id: procedure.id)) }, - Column.new(procedure_id: procedure.id, table: 'self', column: 'state', classname: 'state-col'), + procedure.dossier_id_column, + *displayed_columns, + procedure.dossier_state_column, *procedure.sva_svr_columns ] end - def filtered_sorted_ids(dossiers, statut, count: nil) - dossiers_by_statut = dossiers.by_statut(statut, instructeur) - dossiers_sorted_ids = self.sorted_ids(dossiers_by_statut, count || dossiers_by_statut.size) - - if filters[statut].present? - dossiers_sorted_ids.intersection(filtered_ids(dossiers_by_statut, statut)) - else - dossiers_sorted_ids - end - end - - def human_value_for_filter(filter) - if filter[TABLE] == TYPE_DE_CHAMP - find_type_de_champ(filter[COLUMN]).dynamic_type.filter_to_human(filter['value']) - elsif filter['column'] == 'state' - if filter['value'] == 'pending_correction' + def human_value_for_filter(filtered_column) + if filtered_column.column.table == TYPE_DE_CHAMP + find_type_de_champ(filtered_column.column.column).dynamic_type.filter_to_human(filtered_column.filter) + elsif filtered_column.column.column == 'state' + if filtered_column.filter == 'pending_correction' Dossier.human_attribute_name("pending_correction.for_instructeur") else - Dossier.human_attribute_name("state.#{filter['value']}") + Dossier.human_attribute_name("state.#{filtered_column.filter}") end - elsif filter['table'] == 'groupe_instructeur' && filter['column'] == 'id' + elsif filtered_column.column.table == 'groupe_instructeur' && filtered_column.column.column == 'id' instructeur.groupe_instructeurs - .find { _1.id == filter['value'].to_i }&.label || filter['value'] + .find { _1.id == filtered_column.filter.to_i }&.label || filtered_column.filter else - column = procedure.columns.find { _1.table == filter[TABLE] && _1.column == filter[COLUMN] } + column = procedure.columns.find { _1.table == filtered_column.column.table && _1.column == filtered_column.column.column } if column.type == :date - parsed_date = safe_parse_date(filter['value']) + parsed_date = safe_parse_date(filtered_column.filter) return parsed_date.present? ? I18n.l(parsed_date) : nil end - filter['value'] + filtered_column.filter end end @@ -91,179 +73,8 @@ class ProcedurePresentation < ApplicationRecord nil end - def add_filter(statut, column_id, value) - h_id = JSON.parse(column_id, symbolize_names: true) - - if value.present? - column = procedure.find_column(h_id:) - - case column.table - when TYPE_DE_CHAMP - value = find_type_de_champ(column.column).dynamic_type.human_to_filter(value) - end - - updated_filters = filters.dup - updated_filters[statut] << { - 'label' => column.label, - TABLE => column.table, - COLUMN => column.column, - 'value_column' => column.value_column, - 'value' => value - } - - filters_for(statut) << { id: h_id, filter: value } - update(filters: updated_filters) - end - end - - def remove_filter(statut, column_id, value) - h_id = JSON.parse(column_id, symbolize_names: true) - column = procedure.find_column(h_id:) - updated_filters = filters.dup - - updated_filters[statut] = filters[statut].reject do |filter| - filter.values_at(TABLE, COLUMN, 'value') == [column.table, column.column, value] - end - - collection = filters_for(statut) - collection.delete(collection.find { sym_h = _1.deep_symbolize_keys; sym_h[:id] == h_id && sym_h[:filter] == value }) - - update!(filters: updated_filters) - end - - def update_displayed_fields(column_ids) - h_ids = Array.wrap(column_ids).map { |id| JSON.parse(id, symbolize_names: true) } - columns = h_ids.map { |h_id| procedure.find_column(h_id:) } - - update!( - displayed_fields: columns, - displayed_columns: columns.map(&:h_id) - ) - - if !sorted_column.column.in?(columns) - update(sorted_column: nil) - end - end - - def snapshot - slice(:filters, :sort, :displayed_fields) - end - private - def sorted_ids(dossiers, count) - table = sorted_column.column.table - column = sorted_column.column.column - order = sorted_column.order - - case table - when 'notifications' - dossiers_id_with_notification = dossiers.merge(instructeur.followed_dossiers).with_notifications.ids - if order == 'desc' - dossiers_id_with_notification + - (dossiers.order('dossiers.updated_at desc').ids - dossiers_id_with_notification) - else - (dossiers.order('dossiers.updated_at asc').ids - dossiers_id_with_notification) + - dossiers_id_with_notification - end - when TYPE_DE_CHAMP - ids = dossiers - .with_type_de_champ(column) - .order("champs.value #{order}") - .pluck(:id) - if ids.size != count - rest = dossiers.where.not(id: ids).order(id: order).pluck(:id) - order == 'asc' ? ids + rest : rest + ids - else - ids - end - when 'followers_instructeurs' - assert_supported_column(table, column) - # LEFT OUTER JOIN allows to keep dossiers without assigned instructeurs yet - dossiers - .includes(:followers_instructeurs) - .joins('LEFT OUTER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') - .order("instructeurs_users.email #{order}") - .pluck(:id) - .uniq - when 'avis' - 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("#{self.class.sanitized_column(table, column)} #{order}") - .pluck(:id) - end - end - - def filtered_ids(dossiers, statut) - filters.fetch(statut) - .group_by { |filter| filter.values_at(TABLE, COLUMN) } - .map do |(table, column), filters| - values = filters.pluck('value') - value_column = filters.pluck('value_column').compact.first || :value - dossier_column = procedure.find_column(h_id: { procedure_id: procedure.id, column_id: "#{table}/#{column}" }) # hack to find json path columns - if dossier_column.is_a?(Columns::JSONPathColumn) - dossier_column.filtered_ids(dossiers, values) - else - case table - when 'self' - if dossier_column.type == :date - dates = values - .filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil } - - dossiers.filter_by_datetimes(column, dates) - elsif dossier_column.column == "state" && values.include?("pending_correction") - dossiers.joins(:corrections).where(corrections: DossierCorrection.pending) - elsif dossier_column.column == "state" && values.include?("en_construction") - dossiers.where("dossiers.#{column} IN (?)", values).includes(:corrections).where.not(corrections: DossierCorrection.pending) - else - dossiers.where("dossiers.#{column} IN (?)", values) - end - when TYPE_DE_CHAMP - if dossier_column.type == :enum - dossiers.with_type_de_champ(column) - .filter_enum(:champs, value_column, values) - else - dossiers.with_type_de_champ(column) - .filter_ilike(:champs, value_column, values) - end - when 'etablissement' - if column == 'entreprise_date_creation' - dates = values - .filter_map { |v| v.to_date rescue nil } - - dossiers - .includes(table) - .where(table.pluralize => { column => dates }) - else - dossiers - .includes(table) - .filter_ilike(table, column, values) - end - when 'followers_instructeurs' - assert_supported_column(table, column) - dossiers - .includes(:followers_instructeurs) - .joins('INNER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') - .filter_ilike('instructeurs_users', :email, values) # ilike OK, user may want to search by *@domain - when 'user', 'individual' # user_columns: [email], individual_columns: ['nom', 'prenom', 'gender'] - dossiers - .includes(table) - .filter_ilike(table, column, values) # ilike or where column == 'value' are both valid, we opted for ilike - when 'groupe_instructeur' - assert_supported_column(table, column) - - dossiers - .joins(:groupe_instructeur) - .where(groupe_instructeur_id: values) - end.pluck(:id) - end - end.reduce(:&) - end - def find_type_de_champ(column) TypeDeChamp .joins(:revision_types_de_champ) @@ -271,83 +82,4 @@ class ProcedurePresentation < ApplicationRecord .order(created_at: :desc) .find_by(stable_id: column) end - - def check_allowed_displayed_fields - displayed_fields.each do |field| - check_allowed_field(:displayed_fields, field) - end - end - - def check_allowed_filter_columns - filters.each do |key, columns| - return true if key == 'migrated' - columns.each do |column| - check_allowed_field(:filters, column) - end - end - end - - def check_allowed_field(kind, field, extra_columns = {}) - table, column = field.values_at(TABLE, COLUMN) - if !valid_column?(table, column, extra_columns) - errors.add(kind, "#{table}.#{column} n’est pas une colonne permise") - end - end - - def check_filters_max_length - filters.values.flatten.each do |filter| - next if !filter.is_a?(Hash) - next if filter['value']&.length.to_i <= FILTERS_VALUE_MAX_LENGTH - - errors.add(:base, "Le filtre #{filter['label']} est trop long (maximum: #{FILTERS_VALUE_MAX_LENGTH} caractères)") - end - end - - def check_filters_max_integer - filters.values.flatten.each do |filter| - next if !filter.is_a?(Hash) - next if filter['column'] != 'id' - next if filter['value']&.to_i&. < PG_INTEGER_MAX_VALUE - - errors.add(:base, "Le filtre #{filter['label']} n'est pas un numéro de dossier possible") - end - end - - def valid_column?(table, column, extra_columns = {}) - valid_columns_for_table(table).include?(column) || - extra_columns[table]&.include?(column) - end - - def valid_columns_for_table(table) - @column_whitelist ||= procedure.columns - .group_by(&:table) - .transform_values { |columns| Set.new(columns.map(&:column)) } - - @column_whitelist[table] || [] - end - - def self.sanitized_column(association, column) - table = if association == 'self' - Dossier.table_name - elsif (association_reflection = Dossier.reflect_on_association(association)) - association_reflection.klass.table_name - else - # Allow filtering on a joined table alias (which doesn’t exist - # in the ActiveRecord domain). - association - end - - [table, column] - .map { |name| ActiveRecord::Base.connection.quote_column_name(name) } - .join('.') - end - - def assert_supported_column(table, column) - if table == 'followers_instructeurs' && column != 'email' - raise ArgumentError, 'Table `followers_instructeurs` only supports the `email` column.' - end - if table == 'groupe_instructeur' && (column != 'label' && column != 'id') - raise ArgumentError, 'Table `groupe_instructeur` only supports the `label` or `id` column.' - end - end end diff --git a/app/models/sorted_column.rb b/app/models/sorted_column.rb index d254405d7..469c07559 100644 --- a/app/models/sorted_column.rb +++ b/app/models/sorted_column.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true class SortedColumn + # include validations to enable procedure_presentation.validate_associate, + # which enforces the deserialization of columns in the sorted_column attribute + # and raises an error if a column is not found + include ActiveModel::Validations + attr_reader :column, :order def initialize(column:, order:) @@ -19,4 +24,8 @@ class SortedColumn def sort_by_notifications? @column.notifications? && @order == 'desc' end + + def id + column.h_id.merge(order:).sort.to_json + end end diff --git a/app/models/types_de_champ/type_de_champ_base.rb b/app/models/types_de_champ/type_de_champ_base.rb index 19d37df0e..c01ad1d25 100644 --- a/app/models/types_de_champ/type_de_champ_base.rb +++ b/app/models/types_de_champ/type_de_champ_base.rb @@ -54,10 +54,6 @@ class TypesDeChamp::TypeDeChampBase filter_value end - def human_to_filter(human_value) - human_value - end - class << self def champ_value(champ) champ.value.present? ? champ.value.to_s : champ_default_value diff --git a/app/models/types_de_champ/yes_no_type_de_champ.rb b/app/models/types_de_champ/yes_no_type_de_champ.rb index 4e500a6fd..c34ba1dcf 100644 --- a/app/models/types_de_champ/yes_no_type_de_champ.rb +++ b/app/models/types_de_champ/yes_no_type_de_champ.rb @@ -11,17 +11,6 @@ class TypesDeChamp::YesNoTypeDeChamp < TypesDeChamp::CheckboxTypeDeChamp end end - def human_to_filter(human_value) - downcased = human_value.downcase - if downcased == "oui" - "true" - elsif downcased == "non" - "false" - else - downcased - end - end - class << self def champ_value(champ) champ_formatted_value(champ) diff --git a/app/services/dossier_filter_service.rb b/app/services/dossier_filter_service.rb new file mode 100644 index 000000000..586213451 --- /dev/null +++ b/app/services/dossier_filter_service.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +class DossierFilterService + TYPE_DE_CHAMP = 'type_de_champ' + + def self.filtered_sorted_ids(dossiers, statut, filters, sorted_column, instructeur, count: nil) + dossiers_by_statut = dossiers.by_statut(statut, instructeur) + dossiers_sorted_ids = self.sorted_ids(dossiers_by_statut, sorted_column, instructeur, count || dossiers_by_statut.size) + + if filters.present? + dossiers_sorted_ids.intersection(filtered_ids(dossiers_by_statut, filters)) + else + dossiers_sorted_ids + end + end + + private + + def self.sorted_ids(dossiers, sorted_column, instructeur, count) + table = sorted_column.column.table + column = sorted_column.column.column + order = sorted_column.order + + case table + when 'notifications' + dossiers_id_with_notification = dossiers.merge(instructeur.followed_dossiers).with_notifications.ids + if order == 'desc' + dossiers_id_with_notification + + (dossiers.order('dossiers.updated_at desc').ids - dossiers_id_with_notification) + else + (dossiers.order('dossiers.updated_at asc').ids - dossiers_id_with_notification) + + dossiers_id_with_notification + end + when TYPE_DE_CHAMP + ids = dossiers + .with_type_de_champ(column) + .order("champs.value #{order}") + .pluck(:id) + if ids.size != count + rest = dossiers.where.not(id: ids).order(id: order).pluck(:id) + order == 'asc' ? ids + rest : rest + ids + else + ids + end + when 'followers_instructeurs' + assert_supported_column(table, column) + # LEFT OUTER JOIN allows to keep dossiers without assigned instructeurs yet + dossiers + .includes(:followers_instructeurs) + .joins('LEFT OUTER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') + .order("instructeurs_users.email #{order}") + .pluck(:id) + .uniq + when 'avis' + dossiers.includes(table) + .order("#{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}") + .pluck(:id) + end + end + + def self.filtered_ids(dossiers, filters) + filters + .group_by { |filter| filter.column.then { [_1.table, _1.column] } } + .map do |(table, column), filters_for_column| + values = filters_for_column.map(&:filter) + filtered_column = filters_for_column.first.column + value_column = filtered_column.value_column + + if filtered_column.is_a?(Columns::JSONPathColumn) + filtered_column.filtered_ids(dossiers, values) + else + case table + when 'self' + if filtered_column.type == :date + dates = values + .filter_map { |v| Time.zone.parse(v).beginning_of_day rescue nil } + + dossiers.filter_by_datetimes(column, dates) + elsif filtered_column.column == "state" && values.include?("pending_correction") + dossiers.joins(:corrections).where(corrections: DossierCorrection.pending) + elsif filtered_column.column == "state" && values.include?("en_construction") + dossiers.where("dossiers.#{column} IN (?)", values).includes(:corrections).where.not(corrections: DossierCorrection.pending) + else + dossiers.where("dossiers.#{column} IN (?)", values) + end + when TYPE_DE_CHAMP + if filtered_column.type == :enum + dossiers.with_type_de_champ(column) + .filter_enum(:champs, value_column, values) + else + dossiers.with_type_de_champ(column) + .filter_ilike(:champs, value_column, values) + end + when 'etablissement' + if column == 'entreprise_date_creation' + dates = values + .filter_map { |v| v.to_date rescue nil } + + dossiers + .includes(table) + .where(table.pluralize => { column => dates }) + else + dossiers + .includes(table) + .filter_ilike(table, column, values) + end + when 'followers_instructeurs' + assert_supported_column(table, column) + dossiers + .includes(:followers_instructeurs) + .joins('INNER JOIN users instructeurs_users ON instructeurs_users.id = instructeurs.user_id') + .filter_ilike('instructeurs_users', :email, values) # ilike OK, user may want to search by *@domain + when 'user', 'individual' # user_columns: [email], individual_columns: ['nom', 'prenom', 'gender'] + dossiers + .includes(table) + .filter_ilike(table, column, values) # ilike or where column == 'value' are both valid, we opted for ilike + when 'groupe_instructeur' + assert_supported_column(table, column) + + dossiers + .joins(:groupe_instructeur) + .where(groupe_instructeur_id: values) + end.pluck(:id) + end + end.reduce(:&) + end + + def self.sanitized_column(association, column) + table = if association == 'self' + Dossier.table_name + elsif (association_reflection = Dossier.reflect_on_association(association)) + association_reflection.klass.table_name + else + # Allow filtering on a joined table alias (which doesn’t exist + # in the ActiveRecord domain). + association + end + + [table, column] + .map { |name| ActiveRecord::Base.connection.quote_column_name(name) } + .join('.') + end + + def self.assert_supported_column(table, column) + if table == 'followers_instructeurs' && column != 'email' + raise ArgumentError, 'Table `followers_instructeurs` only supports the `email` column.' + end + if table == 'groupe_instructeur' && (column != 'label' && column != 'id') + raise ArgumentError, 'Table `groupe_instructeur` only supports the `label` or `id` column.' + end + end +end diff --git a/app/services/dossier_projection_service.rb b/app/services/dossier_projection_service.rb index fbbe64da8..041969489 100644 --- a/app/services/dossier_projection_service.rb +++ b/app/services/dossier_projection_service.rb @@ -40,8 +40,10 @@ class DossierProjectionService # Those hashes are needed because: # - the order of the intermediary query results are unknown # - some values can be missing (if a revision added or removed them) - def self.project(dossiers_ids, fields) - fields = fields.deep_dup + def self.project(dossiers_ids, columns) + fields = columns.map { |c| { TABLE => c.table, COLUMN => c.column } } + champ_value = champ_value_formatter(dossiers_ids, fields) + state_field = { TABLE => 'self', COLUMN => 'state' } archived_field = { TABLE => 'self', COLUMN => 'archived' } batch_operation_field = { TABLE => 'self', COLUMN => 'batch_operation_id' } @@ -53,7 +55,7 @@ class DossierProjectionService individual_last_name = { TABLE => 'individual', COLUMN => 'nom' } sva_svr_decision_on_field = { TABLE => 'self', COLUMN => 'sva_svr_decision_on' } dossier_corrections = { TABLE => 'dossier_corrections', COLUMN => 'resolved_at' } - champ_value = champ_value_formatter(dossiers_ids, fields) + ([state_field, archived_field, sva_svr_decision_on_field, hidden_by_user_at_field, hidden_by_administration_at_field, hidden_by_reason_field, for_tiers_field, individual_first_name, individual_last_name, batch_operation_field, dossier_corrections] + fields) .each { |f| f[:id_value_h] = {} } .group_by { |f| f[TABLE] } # one query per table diff --git a/app/tasks/maintenance/clean_invalid_procedure_presentation_task.rb b/app/tasks/maintenance/clean_invalid_procedure_presentation_task.rb index 9da038ac4..669c6d3bf 100644 --- a/app/tasks/maintenance/clean_invalid_procedure_presentation_task.rb +++ b/app/tasks/maintenance/clean_invalid_procedure_presentation_task.rb @@ -2,7 +2,7 @@ module Maintenance # PR: 10774 - # why: postgres does not support integer greater than ProcedurePresentation::PG_INTEGER_MAX_VALUE) + # why: postgres does not support integer greater than FilteredColumn::PG_INTEGER_MAX_VALUE) # it occures when user copypaste the dossier id twice (like missed copy paste,paste) # once this huge integer is saved on procedure presentation, page with this filter can't be loaded # when: run this migration when it appears in your maintenance tasks list, this file fix the data and we added some validations too @@ -16,7 +16,7 @@ module Maintenance filters_by_status.reject do |filter| filter.is_a?(Hash) && filter['column'] == 'id' && - (filter['value']&.to_i&. >= ProcedurePresentation::PG_INTEGER_MAX_VALUE) + (filter['value']&.to_i&. >= FilteredColumn::PG_INTEGER_MAX_VALUE) end end element.save diff --git a/app/types/filtered_column_type.rb b/app/types/filtered_column_type.rb new file mode 100644 index 000000000..ca097e4d5 --- /dev/null +++ b/app/types/filtered_column_type.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class FilteredColumnType < ActiveRecord::Type::Value + # form_input or setter -> type + def cast(value) + value = value.deep_symbolize_keys if value.respond_to?(:deep_symbolize_keys) + + case value + in FilteredColumn + value + in NilClass # default value + nil + # from form (id is a string) or from db (id is a hash) + in { id: String|Hash, filter: String } => h + FilteredColumn.new(column: ColumnType.new.cast(h[:id]), filter: h[:filter]) + end + end + + # db -> ruby + def deserialize(value) = cast(value&.then { JSON.parse(_1) }) + + # ruby -> db + def serialize(value) + case value + in NilClass + nil + in FilteredColumn + JSON.generate({ + id: value.column.h_id, + filter: value.filter + }) + else + raise ArgumentError, "Invalid value for FilteredColumn serialization: #{value}" + end + end +end diff --git a/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml index e301a5c53..2fe1a3065 100644 --- a/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml +++ b/app/views/instructeurs/procedures/_dossiers_filter_dropdown.html.haml @@ -3,4 +3,4 @@ = t('views.instructeurs.dossiers.filters.title') - menu.with_form do - = render Instructeurs::ColumnFilterComponent.new(procedure:, procedure_presentation: @procedure_presentation, statut:) + = render Instructeurs::ColumnFilterComponent.new(procedure:, procedure_presentation:, statut:) diff --git a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml index d1b57d57e..b8673ed2f 100644 --- a/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml +++ b/app/views/instructeurs/procedures/_dossiers_filter_tags.html.haml @@ -1,11 +1,17 @@ - if current_filters.count > 0 .fr-mb-2w - - current_filters.group_by { |filter| filter['table'] }.each_with_index do |(table, filters), i| + - current_filters.group_by { |filter| filter.column.table }.each_with_index do |(table, filters), i| - if i > 0 = " et " - filters.each_with_index do |filter, i| - if i > 0 = " ou " - = link_to remove_filter_instructeur_procedure_path(procedure, { statut: statut, column: { procedure_id: procedure.id, column_id: filter['table'] + "/" + filter['column'] }.to_json, value: filter['value'] }), - class: "fr-tag fr-tag--dismiss fr-my-1w", aria: { label: "Retirer le filtre #{filter['column']}" } do - = "#{filter['label'].truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}" + = form_tag(add_filter_instructeur_procedure_path(procedure), class: 'inline') do + - prefix = procedure_presentation.filters_name_for(statut) + = hidden_field_tag "#{prefix}[]", '' + - (current_filters - [filter]).each do |f| + = hidden_field_tag "#{prefix}[][id]", f.column.id + = hidden_field_tag "#{prefix}[][filter]", f.filter + + = button_tag "#{filter.column.label.truncate(50)} : #{procedure_presentation.human_value_for_filter(filter)}", + class: 'fr-tag fr-tag--dismiss fr-my-1w' diff --git a/app/views/instructeurs/procedures/show.html.haml b/app/views/instructeurs/procedures/show.html.haml index 2a9a62fca..a7dd1e0a3 100644 --- a/app/views/instructeurs/procedures/show.html.haml +++ b/app/views/instructeurs/procedures/show.html.haml @@ -61,7 +61,7 @@ %hr .flex.align-center - if @filtered_sorted_paginated_ids.present? || @current_filters.count > 0 - = render partial: "dossiers_filter_dropdown", locals: { procedure: @procedure, statut: @statut} + = render partial: "dossiers_filter_dropdown", locals: { procedure: @procedure, statut: @statut, procedure_presentation: @procedure_presentation } = render Dossiers::NotifiedToggleComponent.new(procedure: @procedure, procedure_presentation: @procedure_presentation) if @statut != 'a-suivre' .fr-ml-auto diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 4cb15c70d..69ad55629 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -44,29 +44,6 @@ ], "note": "" }, - { - "warning_type": "SQL Injection", - "warning_code": 0, - "fingerprint": "31693060072e27c02ca4f884f2a07f4f1c1247b7a6f5cc5c724e88e6ca9b4873", - "check_name": "SQL", - "message": "Possible SQL injection", - "file": "app/models/concerns/dossier_filtering_concern.rb", - "line": 40, - "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "where(\"#{values.count} OR #{\"(#{ProcedurePresentation.sanitized_column(table, column)} = ?)\"}\", *values)", - "render_path": null, - "location": { - "type": "method", - "class": "DossierFilteringConcern", - "method": null - }, - "user_input": "values.count", - "confidence": "Medium", - "cwe_id": [ - 89 - ], - "note": "filtered by rails query params where(something: ?, values)" - }, { "warning_type": "SQL Injection", "warning_code": 0, @@ -136,6 +113,29 @@ ], "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "91ff8031e7c639c95fe6c244867349a72078ef456d8b3507deaf2bdb9bf62fe2", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/models/concerns/dossier_filtering_concern.rb", + "line": 34, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "where(\"#{values.count} OR #{\"(#{DossierFilterService.sanitized_column(table, column)} ILIKE ?)\"}\", *values.map do\n \"%#{value}%\"\n end)", + "render_path": null, + "location": { + "type": "method", + "class": "DossierFilteringConcern", + "method": null + }, + "user_input": "values.count", + "confidence": "Medium", + "cwe_id": [ + 89 + ], + "note": "filtered by rails query params where(something: ?, values)" + }, { "warning_type": "Cross-Site Scripting", "warning_code": 2, @@ -196,13 +196,13 @@ { "warning_type": "SQL Injection", "warning_code": 0, - "fingerprint": "bd1df30f95135357b646e21a03d95498874faffa32e3804fc643e9b6b957ee14", + "fingerprint": "aaff41afa7bd5a551cd2e3a385071090cb53c95caa40fad3785cd3d68c9b939c", "check_name": "SQL", "message": "Possible SQL injection", "file": "app/models/concerns/dossier_filtering_concern.rb", - "line": 34, + "line": 40, "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", - "code": "where(\"#{values.count} OR #{\"(#{ProcedurePresentation.sanitized_column(table, column)} ILIKE ?)\"}\", *values.map do\n \"%#{value}%\"\n end)", + "code": "where(\"#{values.count} OR #{\"(#{DossierFilterService.sanitized_column(table, column)} = ?)\"}\", *values)", "render_path": null, "location": { "type": "method", @@ -272,6 +272,6 @@ "note": "Current is not a model" } ], - "updated": "2024-09-24 20:56:24 +0200", + "updated": "2024-10-15 15:57:27 +0200", "brakeman_version": "6.1.2" } diff --git a/config/initializers/types.rb b/config/initializers/types.rb index 8ac4a38ed..46f40f05d 100644 --- a/config/initializers/types.rb +++ b/config/initializers/types.rb @@ -3,9 +3,11 @@ require Rails.root.join("app/types/column_type") require Rails.root.join("app/types/export_item_type") require Rails.root.join("app/types/sorted_column_type") +require Rails.root.join("app/types/filtered_column_type") ActiveSupport.on_load(:active_record) do ActiveRecord::Type.register(:column, ColumnType) ActiveRecord::Type.register(:export_item, ExportItemType) ActiveRecord::Type.register(:sorted_column, SortedColumnType) + ActiveRecord::Type.register(:filtered_column, FilteredColumnType) end diff --git a/db/migrate/20241014084333_add_filtered_and_sorted_column_to_exports_table.rb b/db/migrate/20241014084333_add_filtered_and_sorted_column_to_exports_table.rb new file mode 100644 index 000000000..64d4c398c --- /dev/null +++ b/db/migrate/20241014084333_add_filtered_and_sorted_column_to_exports_table.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddFilteredAndSortedColumnToExportsTable < ActiveRecord::Migration[7.0] + def change + add_column :exports, :filtered_columns, :jsonb, array: true, default: [], null: false + add_column :exports, :sorted_column, :jsonb + end +end diff --git a/db/schema.rb b/db/schema.rb index 30d626290..93aea3e2b 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_29_141825) do +ActiveRecord::Schema[7.0].define(version: 2024_10_14_084333) do # These are extensions that must be enabled in order to support this database enable_extension "pg_buffercache" enable_extension "pg_stat_statements" @@ -628,12 +628,14 @@ ActiveRecord::Schema[7.0].define(version: 2024_09_29_141825) do t.datetime "created_at", precision: nil, null: false t.integer "dossiers_count" t.bigint "export_template_id" + t.jsonb "filtered_columns", default: [], null: false, array: true t.string "format", null: false t.bigint "instructeur_id" t.string "job_status", default: "pending", null: false t.text "key", null: false t.bigint "procedure_presentation_id" t.jsonb "procedure_presentation_snapshot" + t.jsonb "sorted_column" t.string "statut", default: "tous" t.string "time_span_type", default: "everything", null: false t.datetime "updated_at", precision: nil, null: false diff --git a/spec/components/dossiers/export_link_component_spec.rb b/spec/components/dossiers/export_link_component_spec.rb index 4aa757b90..f1834c8ca 100644 --- a/spec/components/dossiers/export_link_component_spec.rb +++ b/spec/components/dossiers/export_link_component_spec.rb @@ -36,9 +36,9 @@ RSpec.describe Dossiers::ExportLinkComponent, type: :component do end end - context 'when export is for a presentation' do + context 'when export is from a presentation' do before do - export.update!(procedure_presentation: procedure_presentation) + export.update!(sorted_column: procedure.default_sorted_column) end it 'display the persisted dossiers count' do @@ -48,7 +48,7 @@ RSpec.describe Dossiers::ExportLinkComponent, type: :component do end context "when the export is not available" do - let(:export) { create(:export, :pending, groupe_instructeurs: [groupe_instructeur], procedure_presentation: procedure_presentation, created_at: 10.minutes.ago) } + let(:export) { create(:export, :pending, groupe_instructeurs: [groupe_instructeur], sorted_column: procedure.default_sorted_column, created_at: 10.minutes.ago) } before do create_list(:dossier, 3, :en_construction, procedure: procedure, groupe_instructeur: groupe_instructeur) diff --git a/spec/components/instructeurs/column_filter_component_spec.rb b/spec/components/instructeurs/column_filter_component_spec.rb index 8c31094dd..b1dc597ae 100644 --- a/spec/components/instructeurs/column_filter_component_spec.rb +++ b/spec/components/instructeurs/column_filter_component_spec.rb @@ -8,27 +8,22 @@ describe Instructeurs::ColumnFilterComponent, type: :component do let(:procedure_id) { procedure.id } let(:procedure_presentation) { nil } let(:statut) { nil } + let(:column) { nil } before do allow(component).to receive(:current_instructeur).and_return(instructeur) end describe ".filterable_columns_options" do - context 'filders' do - let(:column) { nil } - let(:included_displayable_field) do - [ - Column.new(procedure_id:, label: 'email', table: 'user', column: 'email'), - Column.new(procedure_id:, label: "depose_since", table: "self", column: "depose_since", displayable: false) - ] - end + let(:filterable_column) { Column.new(procedure_id:, label: 'email', table: 'user', column: 'email') } + let(:non_filterable_column) { Column.new(procedure_id:, label: 'depose_since', table: 'self', column: 'depose_since', filterable: false) } + let(:mocked_columns) { [filterable_column, non_filterable_column] } - before { allow(procedure).to receive(:columns).and_return(included_displayable_field) } + before { allow(procedure).to receive(:columns).and_return(mocked_columns) } - subject { component.filterable_columns_options } + subject { component.filterable_columns_options } - it { is_expected.to eq([["email", included_displayable_field.first.id], ["depose_since", included_displayable_field.second.id]]) } - end + it { is_expected.to eq([[filterable_column.label, filterable_column.id]]) } end describe '.options_for_select_of_column' do diff --git a/spec/controllers/instructeurs/procedures_controller_spec.rb b/spec/controllers/instructeurs/procedures_controller_spec.rb index c9e1a8894..996966fd8 100644 --- a/spec/controllers/instructeurs/procedures_controller_spec.rb +++ b/spec/controllers/instructeurs/procedures_controller_spec.rb @@ -905,8 +905,8 @@ describe Instructeurs::ProceduresController, type: :controller do end subject do - column = procedure.find_column(label: "Nom").id - post :add_filter, params: { procedure_id: procedure.id, column:, value: "n" * 4100, statut: "a-suivre" } + column = procedure.find_column(label: "Nom") + post :add_filter, params: { procedure_id: procedure.id, a_suivre_filters: [{ id: column.id, filter: "n" * 4049 }] } end it 'should render the error' do diff --git a/spec/factories/export.rb b/spec/factories/export.rb index d3afa2cb7..fa16c9ee6 100644 --- a/spec/factories/export.rb +++ b/spec/factories/export.rb @@ -8,7 +8,10 @@ FactoryBot.define do groupe_instructeurs { [association(:groupe_instructeur)] } after(:build) do |export, _evaluator| - export.key = Export.generate_cache_key(export.groupe_instructeurs.map(&:id), export.procedure_presentation) + procedure_presentation = export.procedure_presentation + filters = Array.wrap(procedure_presentation&.filters_for(export.statut)) + sorted_column = procedure_presentation&.sorted_column + export.key = Export.generate_cache_key(export.groupe_instructeurs.map(&:id), filters, sorted_column) export.user_profile = export.groupe_instructeurs.first&.instructeurs&.first if export.user_profile.nil? export.dossiers_count = 10 if !export.pending? end diff --git a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb index 46c868f03..795736c26 100644 --- a/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb +++ b/spec/lib/tasks/deployment/20240920130741_migrate_procedure_presentation_to_columns.rake_spec.rb @@ -38,10 +38,7 @@ describe '20240920130741_migrate_procedure_presentation_to_columns.rake' do it 'populates the columns' do procedure_id = procedure.id - expect(procedure_presentation.displayed_columns).to eq([ - { "procedure_id" => procedure_id, "column_id" => "etablissement/entreprise_raison_sociale" }, - { "procedure_id" => procedure_id, "column_id" => "type_de_champ/#{stable_id}" } - ]) + expect(procedure_presentation.displayed_columns.map(&:label)).to eq(["Raison sociale", procedure.active_revision.types_de_champ.first.libelle]) order, column_id = procedure_presentation .sorted_column @@ -52,9 +49,8 @@ describe '20240920130741_migrate_procedure_presentation_to_columns.rake' do expect(procedure_presentation.tous_filters).to eq([]) - traites = procedure_presentation.traites_filters - .map { [_1['id'], _1['filter']] } + traites = procedure_presentation.traites_filters.map { [_1.label, _1.filter] } - expect(traites).to eq([[{ "column_id" => "etablissement/libelle_naf", "procedure_id" => procedure_id }, "Administration publique générale"]]) + expect(traites).to eq([["Libellé NAF", "Administration publique générale"]]) end end diff --git a/spec/models/assign_to_spec.rb b/spec/models/assign_to_spec.rb index 8147d5bd0..59aaf55dd 100644 --- a/spec/models/assign_to_spec.rb +++ b/spec/models/assign_to_spec.rb @@ -9,32 +9,43 @@ describe AssignTo, type: :model do let(:procedure_presentation_or_default) { procedure_presentation_and_errors.first } let(:errors) { procedure_presentation_and_errors.second } - context "without a procedure_presentation" do - it { expect(procedure_presentation_or_default).to be_persisted } - it { expect(procedure_presentation_or_default).to be_valid } - it { expect(errors).to be_nil } + context "without a preexisting procedure_presentation" do + it 'creates a default pp' do + expect(procedure_presentation_or_default).to be_persisted + expect(procedure_presentation_or_default).to be_valid + expect(errors).to be_nil + end end - context "with a procedure_presentation" do - let!(:procedure_presentation) { ProcedurePresentation.create(assign_to: assign_to) } + context "with a preexisting procedure_presentation" do + let!(:procedure_presentation) { ProcedurePresentation.create(assign_to:) } - it { expect(procedure_presentation_or_default).to eq(procedure_presentation) } - it { expect(procedure_presentation_or_default).to be_valid } - it { expect(errors).to be_nil } + it 'returns the preexisting pp' do + expect(procedure_presentation_or_default).to eq(procedure_presentation) + expect(procedure_presentation_or_default).to be_valid + expect(errors).to be_nil + end end context "with an invalid procedure_presentation" do let!(:procedure_presentation) do - pp = ProcedurePresentation.new(assign_to: assign_to, displayed_fields: [{ 'table' => 'invalid', 'column' => 'random' }]) - pp.save(validate: false) - pp + pp = ProcedurePresentation.create(assign_to: assign_to) + + sql = <<-SQL.squish + UPDATE procedure_presentations + SET displayed_columns = ARRAY['{\"procedure_id\":666}'::jsonb] + WHERE id = #{pp.id} ; + SQL + + pp.class.connection.execute(sql) + + assign_to.reload end - it { expect(procedure_presentation_or_default).to be_persisted } - it { expect(procedure_presentation_or_default).to be_valid } - it { expect(errors).to be_present } it do - procedure_presentation_or_default + expect(procedure_presentation_or_default).to be_persisted + expect(procedure_presentation_or_default).to be_valid + expect(errors).to be_present expect(assign_to.procedure_presentation).not_to be(procedure_presentation) end end diff --git a/spec/models/concerns/columns_concern_spec.rb b/spec/models/concerns/columns_concern_spec.rb index e7f167865..d787329d9 100644 --- a/spec/models/concerns/columns_concern_spec.rb +++ b/spec/models/concerns/columns_concern_spec.rb @@ -29,37 +29,37 @@ describe ColumnsConcern do let(:tdc_private_2) { procedure.active_revision.types_de_champ_private[1] } let(:expected) { [ - { label: 'Nº dossier', table: 'self', column: 'id', classname: 'number-col', displayable: true, type: :number, scope: '', value_column: :value, filterable: true }, + { label: 'Nº dossier', table: 'self', column: 'id', displayable: true, type: :number, scope: '', value_column: :value, filterable: true }, { label: 'notifications', table: 'notifications', column: 'notifications', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, - { label: 'Créé le', table: 'self', column: 'created_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'Mis à jour le', table: 'self', column: 'updated_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'Déposé le', table: 'self', column: 'depose_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'En construction le', table: 'self', column: 'en_construction_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'En instruction le', table: 'self', column: 'en_instruction_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'Terminé le', table: 'self', column: 'processed_at', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Mis à jour depuis", table: "self", column: "updated_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Déposé depuis", table: "self", column: "depose_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "En construction depuis", table: "self", column: "en_construction_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "En instruction depuis", table: "self", column: "en_instruction_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Terminé depuis", table: "self", column: "processed_since", classname: "", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, - { label: "Statut", table: "self", column: "state", classname: "", displayable: false, scope: 'instructeurs.dossiers.filterable_state', type: :enum, value_column: :value, filterable: true }, - { label: 'Demandeur', table: 'user', column: 'email', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Groupe instructeur', table: 'groupe_instructeur', column: 'id', classname: '', displayable: true, type: :enum, scope: '', value_column: :value, filterable: true }, - { label: 'Avis oui/non', table: 'avis', column: 'question_answer', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: false }, - { label: 'SIREN', table: 'etablissement', column: 'entreprise_siren', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Forme juridique', table: 'etablissement', column: 'entreprise_forme_juridique', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Nom commercial', table: 'etablissement', column: 'entreprise_nom_commercial', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Raison sociale', table: 'etablissement', column: 'entreprise_raison_sociale', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'SIRET siège social', table: 'etablissement', column: 'entreprise_siret_siege_social', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Date de création', table: 'etablissement', column: 'entreprise_date_creation', classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, - { label: 'SIRET', table: 'etablissement', column: 'siret', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Libellé NAF', table: 'etablissement', column: 'libelle_naf', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: 'Code postal', table: 'etablissement', column: 'code_postal', classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_1.libelle, table: 'type_de_champ', column: tdc_1.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_2.libelle, table: 'type_de_champ', column: tdc_2.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_private_1.libelle, table: 'type_de_champ', column: tdc_private_1.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, - { label: tdc_private_2.libelle, table: 'type_de_champ', column: tdc_private_2.stable_id.to_s, classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true } + { label: 'Créé le', table: 'self', column: 'created_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'Mis à jour le', table: 'self', column: 'updated_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'Déposé le', table: 'self', column: 'depose_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'En construction le', table: 'self', column: 'en_construction_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'En instruction le', table: 'self', column: 'en_instruction_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'Terminé le', table: 'self', column: 'processed_at', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "Mis à jour depuis", table: "self", column: "updated_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "Déposé depuis", table: "self", column: "depose_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "En construction depuis", table: "self", column: "en_construction_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "En instruction depuis", table: "self", column: "en_instruction_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "Terminé depuis", table: "self", column: "processed_since", displayable: false, type: :date, scope: '', value_column: :value, filterable: true }, + { label: "Statut", table: "self", column: "state", displayable: false, scope: 'instructeurs.dossiers.filterable_state', type: :enum, value_column: :value, filterable: true }, + { label: 'Demandeur', table: 'user', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Email instructeur', table: 'followers_instructeurs', column: 'email', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { 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: '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 }, + { label: 'Raison sociale', table: 'etablissement', column: 'entreprise_raison_sociale', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'SIRET siège social', table: 'etablissement', column: 'entreprise_siret_siege_social', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Date de création', table: 'etablissement', column: 'entreprise_date_creation', displayable: true, type: :date, scope: '', value_column: :value, filterable: true }, + { label: 'SIRET', table: 'etablissement', column: 'siret', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Libellé NAF', table: 'etablissement', column: 'libelle_naf', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: 'Code postal', table: 'etablissement', column: 'code_postal', displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: tdc_1.libelle, table: 'type_de_champ', column: tdc_1.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: tdc_2.libelle, table: 'type_de_champ', column: tdc_2.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: tdc_private_1.libelle, table: 'type_de_champ', column: tdc_private_1.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true }, + { label: tdc_private_2.libelle, table: 'type_de_champ', column: tdc_private_2.stable_id.to_s, displayable: true, type: :text, scope: '', value_column: :value, filterable: true } ].map { Column.new(**_1.merge(procedure_id:)) } } @@ -84,9 +84,9 @@ describe ColumnsConcern do end context 'when the procedure is for individuals' do - let(:name_field) { Column.new(procedure_id:, label: "Prénom", table: "individual", column: "prenom", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } - let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } - let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", classname: '', displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } + let(:name_field) { Column.new(procedure_id:, label: "Prénom", table: "individual", column: "prenom", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } + let(:surname_field) { Column.new(procedure_id:, label: "Nom", table: "individual", column: "nom", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } + let(:gender_field) { Column.new(procedure_id:, label: "Civilité", table: "individual", column: "gender", displayable: true, type: :text, scope: '', value_column: :value, filterable: true) } let(:procedure) { create(:procedure, :for_individual) } let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } @@ -99,8 +99,8 @@ describe ColumnsConcern do let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } - let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } - let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", classname: '', displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVA", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVA avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } it { is_expected.to include(decision_on, decision_before_field) } end @@ -110,8 +110,8 @@ describe ColumnsConcern do let(:procedure_id) { procedure.id } let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to) } - let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", classname: '', displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } - let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", classname: '', displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_on) { Column.new(procedure_id:, label: "Date décision SVR", table: "self", column: "sva_svr_decision_on", displayable: true, type: :date, scope: '', value_column: :value, filterable: true) } + let(:decision_before_field) { Column.new(procedure_id:, label: "Date décision SVR avant", table: "self", column: "sva_svr_decision_before", displayable: false, type: :date, scope: '', value_column: :value, filterable: true) } it { is_expected.to include(decision_on, decision_before_field) } end diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb index e960acb8f..3a796ce93 100644 --- a/spec/models/export_spec.rb +++ b/spec/models/export_spec.rb @@ -61,31 +61,18 @@ RSpec.describe Export, type: :model do it { expect(groupe_instructeur.reload).to be_present } end - describe '.find_by groupe_instructeurs' do + describe '.by_key groupe_instructeurs' do let!(:procedure) { create(:procedure) } let!(:gi_1) { create(:groupe_instructeur, procedure: procedure, instructeurs: [create(:instructeur)]) } let!(:gi_2) { create(:groupe_instructeur, procedure: procedure, instructeurs: [create(:instructeur)]) } let!(:gi_3) { create(:groupe_instructeur, procedure: procedure, instructeurs: [create(:instructeur)]) } - context 'without procedure_presentation' do - context 'when an export is made for one groupe instructeur' do - let!(:export) { create(:export, groupe_instructeurs: [gi_1, gi_2]) } + context 'when an export is made for one groupe instructeur' do + let!(:export) { create(:export, groupe_instructeurs: [gi_1, gi_2]) } - it { expect(Export.by_key([gi_1.id], nil)).to be_empty } - it { expect(Export.by_key([gi_2.id, gi_1.id], nil)).to eq([export]) } - it { expect(Export.by_key([gi_1.id, gi_2.id, gi_3.id], nil)).to be_empty } - end - end - - context 'with procedure_presentation and without' do - let!(:export_global) { create(:export, statut: Export.statuts.fetch(:tous), groupe_instructeurs: [gi_1, gi_2], procedure_presentation: nil) } - let!(:export_with_filter) { create(:export, statut: Export.statuts.fetch(:suivis), groupe_instructeurs: [gi_1, gi_2], procedure_presentation: create(:procedure_presentation, procedure: procedure, assign_to: gi_1.instructeurs.first.assign_to.first)) } - let!(:procedure_presentation) { create(:procedure_presentation, procedure: gi_1.procedure) } - - it 'find global exports as well as filtered one' do - expect(Export.by_key([gi_2.id, gi_1.id], export_with_filter.procedure_presentation)) - .to contain_exactly(export_with_filter, export_global) - end + it { expect(Export.by_key([gi_1.id])).to be_empty } + it { expect(Export.by_key([gi_2.id, gi_1.id])).to eq([export]) } + it { expect(Export.by_key([gi_1.id, gi_2.id, gi_3.id])).to be_empty } end end @@ -94,7 +81,9 @@ RSpec.describe Export, type: :model do let(:instructeur) { create(:instructeur) } let!(:gi_1) { create(:groupe_instructeur, procedure: procedure, instructeurs: [instructeur]) } let!(:pp) { gi_1.instructeurs.first.procedure_presentation_and_errors_for_procedure_id(procedure.id).first } - before { pp.add_filter('tous', procedure.find_column(label: 'Créé le').id, '10/12/2021') } + let(:created_at_column) { FilteredColumn.new(column: procedure.find_column(label: 'Créé le'), filter: '10/12/2021') } + + before { pp.update(tous_filters: [created_at_column]) } context 'with procedure_presentation having different filters' do it 'works once' do @@ -105,7 +94,10 @@ RSpec.describe Export, type: :model do it 'works once, changes procedure_presentation, recreate a new' do expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } .to change { Export.count }.by(1) - pp.add_filter('tous', procedure.find_column(label: 'Mis à jour le').id, '10/12/2021') + + update_at_column = FilteredColumn.new(column: procedure.find_column(label: 'Mis à jour le'), filter: '10/12/2021') + pp.update(tous_filters: [created_at_column, update_at_column]) + expect { Export.find_or_create_fresh_export(:zip, [gi_1], instructeur, time_span_type: Export.time_span_types.fetch(:everything), statut: Export.statuts.fetch(:tous), procedure_presentation: pp) } .to change { Export.count }.by(1) end @@ -162,10 +154,16 @@ RSpec.describe Export, type: :model do let!(:dossier_accepte) { create(:dossier, :accepte, procedure: procedure) } let(:export) do - create(:export, - groupe_instructeurs: [procedure.groupe_instructeurs.first], - procedure_presentation: procedure_presentation, - statut: statut) + groupe_instructeurs = [procedure.groupe_instructeurs.first] + user_profile = groupe_instructeurs.first.instructeurs.first + + Export.find_or_create_fresh_export( + :csv, + groupe_instructeurs, + user_profile, + procedure_presentation:, + statut: + ) end context 'without procedure_presentation or since' do @@ -179,17 +177,28 @@ RSpec.describe Export, type: :model do end end - context 'with procedure_presentation and statut supprimes' do - let(:statut) { 'supprimes' } - let(:procedure_presentation) do - create(:procedure_presentation, - procedure: procedure, - assign_to: procedure.groupe_instructeurs.first.assign_tos.first) - end - let!(:dossier_supprime) { create(:dossier, :accepte, procedure: procedure, hidden_by_administration_at: 2.days.ago) } + context 'with procedure_presentation and statut tous and filter en_construction' do + let(:statut) { 'tous' } - it 'includes supprimes' do - expect(export.send(:dossiers_for_export)).to include(dossier_supprime) + let(:procedure_presentation) do + statut_column = procedure.find_column(label: 'Statut') + en_construction_filter = FilteredColumn.new(column: statut_column, filter: 'en_construction') + create(:procedure_presentation, + procedure:, + assign_to: procedure.groupe_instructeurs.first.assign_tos.first, + tous_filters: [en_construction_filter]) + end + + before do + # ensure the export is generated + export + + # change the procedure presentation + procedure_presentation.update(tous_filters: []) + end + + it 'only includes the en_construction' do + expect(export.send(:dossiers_for_export)).to eq([dossier_en_construction]) end end end diff --git a/spec/models/filtered_column_spec.rb b/spec/models/filtered_column_spec.rb new file mode 100644 index 000000000..f4882d960 --- /dev/null +++ b/spec/models/filtered_column_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +describe FilteredColumn do + describe '#check_filters_max_length' do + let(:column) { Column.new(procedure_id: 1, table: 'table', column: 'column', label: 'label') } + let(:filtered_column) { described_class.new(column:, filter:) } + + before { filtered_column.valid? } + + context 'when the filter is too long' do + let(:filter) { 'a' * (FilteredColumn::FILTERS_VALUE_MAX_LENGTH + 1) } + + it 'adds an error' do + expect(filtered_column.errors.map(&:message)).to include(/Le filtre label est trop long/) + end + end + + context 'when then filter is not too long' do + let(:filter) { 'a' * FilteredColumn::FILTERS_VALUE_MAX_LENGTH } + + it 'does not add an error' do + expect(filtered_column.errors).to be_empty + end + end + + context 'when the filter is empty' do + let(:filter) { nil } + + it 'does not add an error' do + expect(filtered_column.errors).to be_empty + end + end + end + + describe '#check_filters_max_integer' do + context 'when the target column is an id column' do + let(:column) { Column.new(procedure_id: 1, table: 'table', column: 'id', label: 'label') } + let(:filtered_column) { described_class.new(column:, filter:) } + + before { filtered_column.valid? } + + context 'when the filter is too high' do + let(:filter) { (FilteredColumn::PG_INTEGER_MAX_VALUE + 1).to_s } + + it 'adds an error' do + expect(filtered_column.errors.map(&:message)).to include(/Le filtre label n'est pas un numéro de dossier possible/) + end + end + + context 'when the filter is not too high' do + let(:filter) { FilteredColumn::PG_INTEGER_MAX_VALUE.to_s } + + it 'does not add an error' do + expect(filtered_column.errors).to be_empty + end + end + end + end +end diff --git a/spec/models/instructeur_spec.rb b/spec/models/instructeur_spec.rb index 9523e6779..5ac18c0df 100644 --- a/spec/models/instructeur_spec.rb +++ b/spec/models/instructeur_spec.rb @@ -167,20 +167,6 @@ describe Instructeur, type: :model do it { expect(errors).to be_nil } end - context 'with invalid presentation' do - let(:procedure_id) { procedure.id } - before do - pp = ProcedurePresentation.create(assign_to: procedure_assign, displayed_fields: [{ 'table' => 'invalid', 'column' => 'random' }]) - pp.save(:validate => false) - end - - it 'recreates a valid prsentation' do - expect(procedure_presentation).to be_persisted - end - it { expect(procedure_presentation).to be_valid } - it { expect(errors).to be_present } - end - context 'with default presentation' do let(:procedure_id) { procedure_2.id } diff --git a/spec/models/procedure_presentation_spec.rb b/spec/models/procedure_presentation_spec.rb index a160e0a5e..ac30ea06e 100644 --- a/spec/models/procedure_presentation_spec.rb +++ b/spec/models/procedure_presentation_spec.rb @@ -4,14 +4,15 @@ describe ProcedurePresentation do include ActiveSupport::Testing::TimeHelpers let(:procedure) { create(:procedure, :published, types_de_champ_public:, types_de_champ_private: [{}]) } + let(:procedure_id) { procedure.id } let(:types_de_champ_public) { [{}] } let(:instructeur) { create(:instructeur) } - let(:assign_to) { create(:assign_to, procedure: procedure, instructeur: instructeur) } + let(:assign_to) { create(:assign_to, procedure:, instructeur:) } let(:first_type_de_champ) { assign_to.procedure.active_revision.types_de_champ_public.first } let(:first_type_de_champ_id) { first_type_de_champ.stable_id.to_s } let(:procedure_presentation) { create(:procedure_presentation, - assign_to: assign_to, + assign_to:, displayed_fields: [ { label: "test1", table: "user", column: "email" }, { label: "test2", table: "type_de_champ", column: first_type_de_champ_id } @@ -22,6 +23,8 @@ describe ProcedurePresentation do let(:procedure_presentation_id) { procedure_presentation.id } let(:filters) { { "a-suivre" => [], "suivis" => [{ "label" => "label1", "table" => "self", "column" => "created_at" }] } } + def to_filter((label, filter)) = FilteredColumn.new(column: procedure.find_column(label: label), filter: filter) + describe "#displayed_fields" do it { expect(procedure_presentation.displayed_fields).to eq([{ "label" => "test1", "table" => "user", "column" => "email" }, { "label" => "test2", "table" => "type_de_champ", "column" => first_type_de_champ_id }]) } end @@ -37,744 +40,27 @@ describe ProcedurePresentation do describe 'validation' do it { expect(build(:procedure_presentation)).to be_valid } - context 'of displayed fields' do - it { expect(build(:procedure_presentation, displayed_fields: [{ table: "user", column: "reset_password_token", "order" => "asc" }])).to be_invalid } + context 'of displayed columns' do + it do + pp = build(:procedure_presentation, displayed_columns: [{ table: "user", column: "reset_password_token", procedure_id: }]) + expect { pp.displayed_columns }.to raise_error(ActiveRecord::RecordNotFound) + end end context 'of filters' do - it do - expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "reset_password_token", "order" => "asc" }] })).to be_invalid - expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "user", column: "email", "value" => "exceedingly long filter value" * 1000 }] })).to be_invalid - end - - describe 'check_filters_max_integer' do - it do - expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "self", column: "id", "value" => ProcedurePresentation::PG_INTEGER_MAX_VALUE.to_s }] })).to be_invalid - expect(build(:procedure_presentation, filters: { "suivis" => [{ table: "self", column: "id", "value" => (ProcedurePresentation::PG_INTEGER_MAX_VALUE - 1).to_s }] })).to be_valid - end - end - end - end - - describe '#sorted_ids' do - let(:instructeur) { create(:instructeur) } - let(:assign_to) { create(:assign_to, procedure:, instructeur:) } - let(:sorted_column) { SortedColumn.new(column:, order:) } - let(:procedure_presentation) { create(:procedure_presentation, assign_to:, sorted_column:) } - - subject { procedure_presentation.send(:sorted_ids, procedure.dossiers, procedure.dossiers.count) } - - context 'for notifications table' do - let(:column) { procedure.notifications_column } - - let!(:notified_dossier) { create(:dossier, :en_construction, procedure:) } - let!(:recent_dossier) { create(:dossier, :en_construction, procedure:) } - let!(:older_dossier) { create(:dossier, :en_construction, procedure:) } - - before do - notified_dossier.update!(last_champ_updated_at: Time.zone.local(2018, 9, 20)) - create(:follow, instructeur: instructeur, dossier: notified_dossier, demande_seen_at: Time.zone.local(2018, 9, 10)) - notified_dossier.touch(time: Time.zone.local(2018, 9, 20)) - recent_dossier.touch(time: Time.zone.local(2018, 9, 25)) - older_dossier.touch(time: Time.zone.local(2018, 5, 13)) - end - - context 'in ascending order' do - let(:order) { 'asc' } - - it { is_expected.to eq([older_dossier, recent_dossier, notified_dossier].map(&:id)) } - end - - context 'in descending order' do - let(:order) { 'desc' } - - it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) } - end - - context 'with a dossier terminé' do - let!(:notified_dossier) { create(:dossier, :accepte, procedure:) } - let(:order) { 'desc' } - - it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) } - end - end - - context 'for self table' do - let(:order) { 'asc' } # Desc works the same, no extra test required - - context 'for created_at column' do - let!(:column) { procedure.find_column(label: 'Créé le') } - let!(:recent_dossier) { Timecop.freeze(Time.zone.local(2018, 10, 17)) { create(:dossier, procedure: procedure) } } - let!(:older_dossier) { Timecop.freeze(Time.zone.local(2003, 11, 11)) { create(:dossier, procedure: procedure) } } - - it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } - end - - context 'for en_construction_at column' do - let!(:column) { procedure.find_column(label: 'En construction le') } - let!(:recent_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) } - let!(:older_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2013, 1, 1)) } - - it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } - end - - context 'for updated_at column' do - let(:column) { procedure.find_column(label: 'Mis à jour le') } - let(:recent_dossier) { create(:dossier, procedure: procedure) } - let(:older_dossier) { create(:dossier, procedure: procedure) } - - before do - recent_dossier.touch(time: Time.zone.local(2018, 9, 25)) - older_dossier.touch(time: Time.zone.local(2018, 5, 13)) - end - - it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } - end - end - - context 'for type_de_champ table' do - context 'with no revisions' do - let(:table) { 'type_de_champ' } - let(:column) { procedure.find_column(label: first_type_de_champ.libelle) } - - let(:beurre_dossier) { create(:dossier, procedure:) } - let(:tartine_dossier) { create(:dossier, procedure:) } - - before do - beurre_dossier.project_champs_public.first.update(value: 'beurre') - tartine_dossier.project_champs_public.first.update(value: 'tartine') - end - - context 'asc' do - let(:order) { 'asc' } - - it { is_expected.to eq([beurre_dossier, tartine_dossier].map(&:id)) } - end - - context 'desc' do - let(:order) { 'desc' } - - it { is_expected.to eq([tartine_dossier, beurre_dossier].map(&:id)) } - end - end - - context 'with a revision adding a new type_de_champ' do - let!(:tdc) { { type_champ: :text, libelle: 'nouveau champ' } } - let(:column) { procedure.find_column(label: 'nouveau champ') } - - let!(:nothing_dossier) { create(:dossier, procedure:) } - let!(:beurre_dossier) { create(:dossier, procedure:) } - let!(:tartine_dossier) { create(:dossier, procedure:) } - - before do - nothing_dossier - procedure.draft_revision.add_type_de_champ(tdc) - procedure.publish_revision! - beurre_dossier.project_champs_public.last.update(value: 'beurre') - tartine_dossier.project_champs_public.last.update(value: 'tartine') - end - - context 'asc' do - let(:order) { 'asc' } - it { is_expected.to eq([nothing_dossier, beurre_dossier, tartine_dossier].map(&:id)) } - end - - context 'desc' do - let(:order) { 'desc' } - it { is_expected.to eq([tartine_dossier, beurre_dossier, nothing_dossier].map(&:id)) } - end - end - end - - context 'for type_de_champ_private table' do - context 'with no revisions' do - let(:column) { procedure.find_column(label: procedure.active_revision.types_de_champ_private.first.libelle) } - - let(:biere_dossier) { create(:dossier, procedure: procedure) } - let(:vin_dossier) { create(:dossier, procedure: procedure) } - - before do - biere_dossier.project_champs_private.first.update(value: 'biere') - vin_dossier.project_champs_private.first.update(value: 'vin') - end - - context 'asc' do - let(:order) { 'asc' } - - it { is_expected.to eq([biere_dossier, vin_dossier].map(&:id)) } - end - - context 'desc' do - let(:order) { 'desc' } - - it { is_expected.to eq([vin_dossier, biere_dossier].map(&:id)) } - end - end - end - - context 'for individual table' do - let(:order) { 'asc' } # Desc works the same, no extra test required - - let(:procedure) { create(:procedure, :for_individual) } - - let!(:first_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Alain', nom: 'Antonelli')) } - let!(:last_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'Mme', prenom: 'Zora', nom: 'Zemmour')) } - - context 'for gender column' do - let(:column) { procedure.find_column(label: 'Civilité') } - - it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } - end - - context 'for prenom column' do - let(:column) { procedure.find_column(label: 'Prénom') } - - it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } - end - - context 'for nom column' do - let(:column) { procedure.find_column(label: 'Nom') } - - it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } - end - end - - context 'for followers_instructeurs table' do - let(:order) { 'asc' } # Desc works the same, no extra test required - - let!(:dossier_z) { create(:dossier, :en_construction, procedure: procedure) } - let!(:dossier_a) { create(:dossier, :en_construction, procedure: procedure) } - let!(:dossier_without_instructeur) { create(:dossier, :en_construction, procedure: procedure) } - - before do - create(:follow, dossier: dossier_z, instructeur: create(:instructeur, email: 'zythum@exemple.fr')) - create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca@exemple.fr')) - create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca2@exemple.fr')) - end - - context 'for email column' do - let(:column) { procedure.find_column(label: 'Email instructeur') } - - it { is_expected.to eq([dossier_a, dossier_z, dossier_without_instructeur].map(&:id)) } - end - end - - context 'for avis table' do - let(:column) { procedure.find_column(label: 'Avis oui/non') } - let(:order) { 'asc' } - - let!(:dossier_yes) { create(:dossier, procedure:) } - let!(:dossier_no) { create(:dossier, procedure:) } - - before do - create_list(:avis, 2, dossier: dossier_yes, question_answer: true) - create(:avis, dossier: dossier_no, question_answer: true) - create(:avis, dossier: dossier_no, question_answer: false) - end - - it { is_expected.to eq([dossier_no, dossier_yes].map(&:id)) } - end - - context 'for other tables' do - # All other columns and tables work the same so it’s ok to test only one - let(:column) { procedure.find_column(label: 'Code postal') } - let(:order) { 'asc' } # Desc works the same, no extra test required - - let!(:huitieme_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75008')) } - let!(:vingtieme_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75020')) } - - it { is_expected.to eq([huitieme_dossier, vingtieme_dossier].map(&:id)) } - end - end - - describe '#filtered_ids' do - let(:procedure_presentation) { create(:procedure_presentation, assign_to: assign_to, filters: { "suivis" => filter }) } - - subject { procedure_presentation.send(:filtered_ids, procedure.dossiers.joins(:user), 'suivis') } - - context 'for self table' do - context 'for created_at column' do - let(:filter) { [{ 'table' => 'self', 'column' => 'created_at', 'value' => '18/9/2018' }] } - - let!(:kept_dossier) { create(:dossier, procedure: procedure, created_at: Time.zone.local(2018, 9, 18, 14, 28)) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure, created_at: Time.zone.local(2018, 9, 17, 23, 59)) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for en_construction_at column' do - let(:filter) { [{ 'table' => 'self', 'column' => 'en_construction_at', 'value' => '17/10/2018' }] } - - let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) } - let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2013, 1, 1)) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for updated_at column' do - let(:filter) { [{ 'table' => 'self', 'column' => 'updated_at', 'value' => '18/9/2018' }] } - - let(:kept_dossier) { create(:dossier, procedure: procedure) } - let(:discarded_dossier) { create(:dossier, procedure: procedure) } - - before do - kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28)) - discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 23, 59)) - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for updated_since column' do - let(:filter) { [{ 'table' => 'self', 'column' => 'updated_since', 'value' => '18/9/2018' }] } - - let(:kept_dossier) { create(:dossier, procedure: procedure) } - let(:later_dossier) { create(:dossier, procedure: procedure) } - let(:discarded_dossier) { create(:dossier, procedure: procedure) } - - before do - kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28)) - later_dossier.touch(time: Time.zone.local(2018, 9, 19, 14, 28)) - discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 14, 28)) - end - - it { is_expected.to match_array([kept_dossier.id, later_dossier.id]) } - end - - context 'for sva_svr_decision_before column' do - before do - travel_to Time.zone.local(2023, 6, 10, 10) - end - - let(:procedure) { create(:procedure, :published, :sva, types_de_champ_public: [{}], types_de_champ_private: [{}]) } - let(:filter) { [{ 'table' => 'self', 'column' => 'sva_svr_decision_before', 'value' => '15/06/2023' }] } - - let!(:kept_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current) } - let!(:later_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 2.days) } - let!(:discarded_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 10.days) } - let!(:en_construction_dossier) { create(:dossier, :en_construction, procedure:, sva_svr_decision_on: Date.current + 2.days) } - let!(:accepte_dossier) { create(:dossier, :accepte, procedure:, sva_svr_decision_on: Date.current + 2.days) } - - it { is_expected.to match_array([kept_dossier.id, later_dossier.id, en_construction_dossier.id]) } - end - - context 'ignore time of day' do - let(:filter) { [{ 'table' => 'self', 'column' => 'en_construction_at', 'value' => '17/10/2018 19:30' }] } - - let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17, 15, 56)) } - let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 18, 5, 42)) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for a malformed date' do - context 'when its a string' do - let(:filter) { [{ 'table' => 'self', 'column' => 'updated_at', 'value' => 'malformed date' }] } - - it { is_expected.to match([]) } - end - - context 'when its a number' do - let(:filter) { [{ 'table' => 'self', 'column' => 'updated_at', 'value' => '177500' }] } - - it { is_expected.to match([]) } - end - end - - context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'self', 'column' => 'en_construction_at', 'value' => '17/10/2018' }, - { 'table' => 'self', 'column' => 'en_construction_at', 'value' => '19/10/2018' } - ] - end - - let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 17)) } - let!(:other_kept_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2018, 10, 19)) } - let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, en_construction_at: Time.zone.local(2013, 1, 1)) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - - context 'with multiple state filters' do - let(:filter) do - [ - { 'table' => 'self', 'column' => 'state', 'value' => 'en_construction' }, - { 'table' => 'self', 'column' => 'state', 'value' => 'en_instruction' } - ] - end - - let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure) } - let!(:other_kept_dossier) { create(:dossier, :en_instruction, procedure: procedure) } - let!(:discarded_dossier) { create(:dossier, :accepte, procedure: procedure) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - - context 'with en_construction state filters' do - let(:filter) do - [ - { 'table' => 'self', 'column' => 'state', 'value' => 'en_construction' } - ] - end - - let!(:en_construction) { create(:dossier, :en_construction, procedure: procedure) } - let!(:en_construction_with_correction) { create(:dossier, :en_construction, procedure: procedure) } - let!(:correction) { create(:dossier_correction, dossier: en_construction_with_correction) } - it 'excludes dossier en construction with pending correction' do - is_expected.to contain_exactly(en_construction.id) - end - end - end - - context 'for type_de_champ table' do - let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'keep' }] } - - let(:kept_dossier) { create(:dossier, procedure: procedure) } - let(:discarded_dossier) { create(:dossier, procedure: procedure) } - let(:type_de_champ) { procedure.active_revision.types_de_champ_public.first } - - context 'with single value' do - before do - kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me') - discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me') - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'keep' }, - { 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'and' } - ] - end - - let(:other_kept_dossier) { create(:dossier, procedure: procedure) } - - before do - kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me') - discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me') - other_kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'and me too') - end - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - - context 'with yes_no type_de_champ' do - let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value' => 'true' }] } - let(:types_de_champ_public) { [{ type: :yes_no }] } - - before do - kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'true') - discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'false') - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'with departement type_de_champ' do - let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value_column' => :external_id, 'value' => '13' }] } - let(:types_de_champ_public) { [{ type: :departements }] } - - before do - kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '13') - discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '69') - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'with enum type_de_champ' do - let(:filter_value) { 'Favorable' } - let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ.stable_id.to_s, 'value_column' => :value, 'value' => filter_value }] } - let(:types_de_champ_public) { [{ type: :drop_down_list, options: ['Favorable', 'Defavorable'] }] } - - before do - kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'Favorable') - discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: 'Defavorable') - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - end - - context 'for type_de_champ_private table' do - let(:filter) { [{ 'table' => 'type_de_champ', 'column' => type_de_champ_private.stable_id.to_s, 'value' => 'keep' }] } - - let(:kept_dossier) { create(:dossier, procedure: procedure) } - let(:discarded_dossier) { create(:dossier, procedure: procedure) } - let(:type_de_champ_private) { procedure.active_revision.types_de_champ_private.first } - - before do - kept_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'keep me') - discarded_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'discard me') - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'type_de_champ', 'column' => type_de_champ_private.stable_id.to_s, 'value' => 'keep' }, - { 'table' => 'type_de_champ', 'column' => type_de_champ_private.stable_id.to_s, 'value' => 'and' } - ] - end - - let(:other_kept_dossier) { create(:dossier, procedure: procedure) } - - before do - other_kept_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'and me too') - end - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - - context 'for type_de_champ using AddressableColumnConcern' do - let(:types_de_champ_public) { [{ type: :rna, stable_id: 1 }] } - let(:type_de_champ) { procedure.active_revision.types_de_champ.first } - let(:available_columns) { type_de_champ.columns(procedure_id: procedure.id) } - let(:column) { available_columns.find { _1.value_column == value_column_searched } } - let(:filter) { [column.to_json.merge({ "value" => value })] } - let(:kept_dossier) { create(:dossier, procedure: procedure) } - - context "when searching by postal_code (text)" do - let(:value) { "60580" } - let(:value_column_searched) { ['postal_code'] } - - before do - kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => value }) - create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => "unknown" }) - end - it { is_expected.to contain_exactly(kept_dossier.id) } - it 'describes column' do - expect(column.type).to eq(:text) - expect(column.options_for_select).to eq([]) - end - end - - context "when searching by departement_code (enum)" do - let(:value) { "99" } - let(:value_column_searched) { ['departement_code'] } - - before do - kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => value }) - create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => "unknown" }) - end - it { is_expected.to contain_exactly(kept_dossier.id) } - - it 'describes column' do - expect(column.type).to eq(:enum) - expect(column.options_for_select.first).to eq(["99 – Etranger", "99"]) - end - end - - context "when searching by region_name" do - let(:value) { "60" } - let(:value_column_searched) { ['region_name'] } - - before do - kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => value }) - create(:dossier, procedure: procedure).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => "unknown" }) - end - it { is_expected.to contain_exactly(kept_dossier.id) } - it 'describes column' do - expect(column.type).to eq(:enum) - expect(column.options_for_select.first).to eq(["Auvergne-Rhône-Alpes", "Auvergne-Rhône-Alpes"]) - end - end - end - - context 'for etablissement table' do - context 'for entreprise_date_creation column' do - let(:filter) { [{ 'table' => 'etablissement', 'column' => 'entreprise_date_creation', 'value' => '21/6/2018' }] } - - let!(:kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2018, 6, 21))) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2008, 6, 21))) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'etablissement', 'column' => 'entreprise_date_creation', 'value' => '21/6/2016' }, - { 'table' => 'etablissement', 'column' => 'entreprise_date_creation', 'value' => '21/6/2018' } - ] - end - - let!(:other_kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2016, 6, 21))) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - - context 'for code_postal column' do - # All columns except entreprise_date_creation work exacly the same, just testing one - - let(:filter) { [{ 'table' => 'etablissement', 'column' => 'code_postal', 'value' => '75017' }] } - - let!(:kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '75017')) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '25000')) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'etablissement', 'column' => 'code_postal', 'value' => '75017' }, - { 'table' => 'etablissement', 'column' => 'code_postal', 'value' => '88100' } - ] - end - - let!(:other_kept_dossier) { create(:dossier, procedure: procedure, etablissement: create(:etablissement, code_postal: '88100')) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - end - - context 'for user table' do - let(:filter) { [{ 'table' => 'user', 'column' => 'email', 'value' => 'keepmail' }] } - - let!(:kept_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'me@keepmail.com')) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'me@discard.com')) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'user', 'column' => 'email', 'value' => 'keepmail' }, - { 'table' => 'user', 'column' => 'email', 'value' => 'beta.gouv.fr' } - ] - end - - let!(:other_kept_dossier) { create(:dossier, procedure: procedure, user: create(:user, email: 'bazinga@beta.gouv.fr')) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - - context 'for individual table' do - let(:procedure) { create(:procedure, :for_individual) } - let!(:kept_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'Mme', prenom: 'Josephine', nom: 'Baker')) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Jean', nom: 'Tremblay')) } - - context 'for gender column' do - let(:filter) { [{ 'table' => 'individual', 'column' => 'gender', 'value' => 'Mme' }] } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for prenom column' do - let(:filter) { [{ 'table' => 'individual', 'column' => 'prenom', 'value' => 'Josephine' }] } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'for nom column' do - let(:filter) { [{ 'table' => 'individual', 'column' => 'nom', 'value' => 'Baker' }] } - - it { is_expected.to contain_exactly(kept_dossier.id) } - end - - context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'individual', 'column' => 'prenom', 'value' => 'Josephine' }, - { 'table' => 'individual', 'column' => 'prenom', 'value' => 'Romuald' } - ] - end - - let!(:other_kept_dossier) { create(:dossier, procedure: procedure, individual: build(:individual, gender: 'M', prenom: 'Romuald', nom: 'Pistis')) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - - context 'for followers_instructeurs table' do - let(:filter) { [{ 'table' => 'followers_instructeurs', 'column' => 'email', 'value' => 'keepmail' }] } - - let!(:kept_dossier) { create(:dossier, procedure: procedure) } - let!(:discarded_dossier) { create(:dossier, procedure: procedure) } - - before do - create(:follow, dossier: kept_dossier, instructeur: create(:instructeur, email: 'me@keepmail.com')) - create(:follow, dossier: discarded_dossier, instructeur: create(:instructeur, email: 'me@discard.com')) - end - - it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'followers_instructeurs', 'column' => 'email', 'value' => 'keepmail' }, - { 'table' => 'followers_instructeurs', 'column' => 'email', 'value' => 'beta.gouv.fr' } - ] - end - - let(:other_kept_dossier) { create(:dossier, procedure: procedure) } - - before do - create(:follow, dossier: other_kept_dossier, instructeur: create(:instructeur, email: 'bazinga@beta.gouv.fr')) - end - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end - end - end - - context 'for groupe_instructeur table' do - let(:filter) { [{ 'table' => 'groupe_instructeur', 'column' => 'id', 'value' => procedure.defaut_groupe_instructeur.id.to_s }] } - - let!(:gi_2) { create(:groupe_instructeur, label: 'gi2', procedure: procedure) } - let!(:gi_3) { create(:groupe_instructeur, label: 'gi3', procedure: procedure) } - - let!(:kept_dossier) { create(:dossier, :en_construction, procedure: procedure) } - let!(:discarded_dossier) { create(:dossier, :en_construction, procedure: procedure, groupe_instructeur: gi_2) } - - it { is_expected.to contain_exactly(kept_dossier.id) } - - context 'with multiple search values' do - let(:filter) do - [ - { 'table' => 'groupe_instructeur', 'column' => 'id', 'value' => procedure.defaut_groupe_instructeur.id.to_s }, - { 'table' => 'groupe_instructeur', 'column' => 'id', 'value' => gi_3.id.to_s } - ] - end - - let!(:other_kept_dossier) { create(:dossier, procedure: procedure, groupe_instructeur: gi_3) } - - it 'returns every dossier that matches any of the search criteria for a given column' do - is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) - end + it 'validates the filter_column objects' do + expect(build(:procedure_presentation, "suivis_filters": [{ id: { column_id: "user/email", procedure_id: }, "filter": "not so long filter value" }])).to be_valid + expect(build(:procedure_presentation, "suivis_filters": [{ id: { column_id: "user/email", procedure_id: }, "filter": "exceedingly long filter value" * 400 }])).to be_invalid end end end describe "#human_value_for_filter" do - let(:filters) { { "suivis" => [{ label: "label1", table: "type_de_champ", column: first_type_de_champ_id, "value" => "true" }] } } + let(:filtered_column) { to_filter([first_type_de_champ.libelle, "true"]) } - subject { procedure_presentation.human_value_for_filter(procedure_presentation.filters["suivis"].first) } + subject do + procedure_presentation.human_value_for_filter(filtered_column) + end context 'when type_de_champ text' do it 'should passthrough value' do @@ -791,7 +77,7 @@ describe ProcedurePresentation do end context 'when filter is state' do - let(:filters) { { "suivis" => [{ table: "self", column: "state", "value" => "en_construction" }] } } + let(:filtered_column) { to_filter(['Statut', "en_construction"]) } it 'should get i18n value' do expect(subject).to eq("En construction") @@ -799,7 +85,7 @@ describe ProcedurePresentation do end context 'when filter is a date' do - let(:filters) { { "suivis" => [{ table: "self", column: "en_instruction_at", "value" => "15/06/2023" }] } } + let(:filtered_column) { to_filter(['Créé le', "15/06/2023"]) } it 'should get formatted value' do expect(subject).to eq("15/06/2023") @@ -807,129 +93,10 @@ describe ProcedurePresentation do end end - describe "#add_filter" do - let(:filters) { { "suivis" => [] } } - - context 'when type_de_champ yes_no' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no, libelle: 'oui ou non' }]) } - - it 'should downcase and transform value' do - column_id = procedure.find_column(label: 'oui ou non').id - procedure_presentation.add_filter("suivis", column_id, "Oui") - - expect(procedure_presentation.filters).to eq({ - "suivis" => - [ - { "label" => first_type_de_champ.libelle, "table" => "type_de_champ", "column" => first_type_de_champ_id, "value" => "true", "value_column" => "value" } - ] - }) - - suivis = procedure_presentation.suivis_filters.map { [_1['id'], _1['filter']] } - - expect(suivis).to eq([[{ "column_id" => "type_de_champ/#{first_type_de_champ_id}", "procedure_id" => procedure.id }, "true"]]) - end - end - - context 'when type_de_champ text' do - let(:filters) { { "suivis" => [] } } - let(:column_id) { procedure.find_column(label: first_type_de_champ.libelle).id } - - it 'should passthrough value' do - procedure_presentation.add_filter("suivis", column_id, "Oui") - - expect(procedure_presentation.filters).to eq({ - "suivis" => [ - { "label" => first_type_de_champ.libelle, "table" => "type_de_champ", "column" => first_type_de_champ_id, "value" => "Oui", "value_column" => "value" } - ] - }) - end - end - - context 'when type_de_champ departements' do - let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :departements }]) } - let(:column_id) { procedure.find_column(label: first_type_de_champ.libelle).id } - let(:filters) { { "suivis" => [] } } - - it 'should set value_column' do - procedure_presentation.add_filter("suivis", column_id, "13") - - expect(procedure_presentation.filters).to eq({ - "suivis" => [ - { "label" => first_type_de_champ.libelle, "table" => "type_de_champ", "column" => first_type_de_champ_id, "value" => "13", "value_column" => "external_id" } - ] - }) - end - end - end - - describe "#remove_filter" do - let(:filters) { { "suivis" => [] } } - let(:email_column_id) { procedure.find_column(label: 'Demandeur').id } - - before do - procedure_presentation.add_filter("suivis", email_column_id, "a@a.com") - end - - it 'should remove filter' do - expect(procedure_presentation.filters).to eq({ "suivis" => [{ "column" => "email", "label" => "Demandeur", "table" => "user", "value" => "a@a.com", "value_column" => "value" }] }) - expect(procedure_presentation.suivis_filters).to eq([{ "filter" => "a@a.com", "id" => { "column_id" => "user/email", "procedure_id" => procedure.id } }]) - - procedure_presentation.remove_filter("suivis", email_column_id, "a@a.com") - procedure_presentation.reload - - expect(procedure_presentation.filters).to eq({ "suivis" => [] }) - expect(procedure_presentation.suivis_filters).to eq([]) - end - end - - describe '#filtered_sorted_ids' do - let(:procedure_presentation) { create(:procedure_presentation, assign_to:) } - let(:dossier_1) { create(:dossier) } - let(:dossier_2) { create(:dossier) } - let(:dossier_3) { create(:dossier) } - let(:dossiers) { Dossier.where(id: [dossier_1, dossier_2, dossier_3].map(&:id)) } - - let(:sorted_ids) { [dossier_2, dossier_3, dossier_1].map(&:id) } - let(:statut) { 'tous' } - - subject { procedure_presentation.filtered_sorted_ids(dossiers, statut) } - - context 'with no filters' do - let(:statut) { 'suivis' } - let(:dossiers) { procedure.dossiers } - - before do - create(:follow, dossier: en_construction_dossier, instructeur: procedure_presentation.instructeur) - create(:follow, dossier: accepte_dossier, instructeur: procedure_presentation.instructeur) - end - - let(:en_construction_dossier) { create(:dossier, :en_construction, procedure:) } - let(:accepte_dossier) { create(:dossier, :accepte, procedure:) } - - it { is_expected.to contain_exactly(en_construction_dossier.id) } - end - - context 'with mocked sorted_ids' do - before do - expect(procedure_presentation).to receive(:sorted_ids).and_return(sorted_ids) - end - - it { is_expected.to eq(sorted_ids) } - - context 'when a filter is present' do - let(:filtered_ids) { [dossier_1, dossier_2, dossier_3].map(&:id) } - - before do - procedure_presentation.filters['tous'] = 'some_filter' - expect(procedure_presentation).to receive(:filtered_ids).and_return(filtered_ids) - end - - it { is_expected.to eq(sorted_ids) } - end - end - end - describe '#update_displayed_fields' do + let(:en_construction_column) { procedure.find_column(label: 'En construction le') } + let(:mise_a_jour_column) { procedure.find_column(label: 'Mis à jour le') } + let(:procedure_presentation) do create(:procedure_presentation, assign_to:).tap do |pp| pp.update(sorted_column: SortedColumn.new(column: procedure.find_column(label: 'Demandeur'), order: 'desc')) @@ -937,24 +104,19 @@ describe ProcedurePresentation do end subject do - procedure_presentation.update_displayed_fields([ - procedure.find_column(label: 'En construction le').id, - procedure.find_column(label: 'Mis à jour le').id + procedure_presentation.update(displayed_columns: [ + en_construction_column.id, mise_a_jour_column.id ]) end it 'should update displayed_fields' do - expect(procedure_presentation.displayed_columns).to eq([]) + expect(procedure_presentation.displayed_columns).to eq(procedure.default_displayed_columns) subject expect(procedure_presentation.displayed_columns).to eq([ - { "column_id" => "self/en_construction_at", "procedure_id" => procedure.id }, - { "column_id" => "self/updated_at", "procedure_id" => procedure.id } + en_construction_column, mise_a_jour_column ]) - - expect(procedure_presentation.sorted_column).to eq(procedure.default_sorted_column) - expect(procedure_presentation.sorted_column.order).to eq('desc') end end end diff --git a/spec/services/dossier_filter_service_spec.rb b/spec/services/dossier_filter_service_spec.rb new file mode 100644 index 000000000..95737dd25 --- /dev/null +++ b/spec/services/dossier_filter_service_spec.rb @@ -0,0 +1,709 @@ +# frozen_string_literal: true + +describe DossierFilterService do + def to_filter((label, filter)) = FilteredColumn.new(column: procedure.find_column(label:), filter:) + + describe '.filtered_sorted_ids' do + let(:procedure) { create(:procedure) } + let(:instructeur) { create(:instructeur) } + let(:dossiers) { procedure.dossiers } + let(:statut) { 'suivis' } + let(:filters) { [] } + let(:sorted_columns) { procedure.default_sorted_column } + + subject { described_class.filtered_sorted_ids(dossiers, statut, filters, sorted_columns, instructeur) } + + context 'with no filters' do + let(:en_construction_dossier) { create(:dossier, :en_construction, procedure:) } + let(:accepte_dossier) { create(:dossier, :accepte, procedure:) } + + before do + create(:follow, dossier: en_construction_dossier, instructeur:) + create(:follow, dossier: accepte_dossier, instructeur:) + end + + it { is_expected.to contain_exactly(en_construction_dossier.id) } + end + + context 'with mocked sorted_ids' do + let(:dossier_1) { create(:dossier) } + let(:dossier_2) { create(:dossier) } + let(:dossier_3) { create(:dossier) } + let(:dossiers) { Dossier.where(id: [dossier_1, dossier_2, dossier_3].map(&:id)) } + + let(:sorted_ids) { [dossier_2, dossier_3, dossier_1].map(&:id) } + + before do + expect(described_class).to receive(:sorted_ids).and_return(sorted_ids) + end + + it { is_expected.to eq(sorted_ids) } + + context 'when a filter is present' do + let(:filtered_ids) { [dossier_1, dossier_2, dossier_3].map(&:id) } + let(:filters) { [to_filter(['Statut', 'en_construction'])] } + + before do + expect(described_class).to receive(:filtered_ids).and_return(filtered_ids) + end + + it { is_expected.to eq(sorted_ids) } + end + end + end + + describe '#sorted_ids' do + let(:procedure) { create(:procedure, :published, types_de_champ_public:, types_de_champ_private: [{}]) } + let(:types_de_champ_public) { [{}] } + let(:first_type_de_champ) { assign_to.procedure.active_revision.types_de_champ_public.first } + let(:dossiers) { procedure.dossiers } + let(:instructeur) { create(:instructeur) } + let(:assign_to) { create(:assign_to, procedure:, instructeur:) } + let(:sorted_column) { SortedColumn.new(column:, order:) } + + subject { described_class.send(:sorted_ids, dossiers, sorted_column, instructeur, dossiers.count) } + + context 'for notifications table' do + let(:column) { procedure.notifications_column } + + let!(:notified_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:recent_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:older_dossier) { create(:dossier, :en_construction, procedure:) } + + before do + notified_dossier.update!(last_champ_updated_at: Time.zone.local(2018, 9, 20)) + create(:follow, instructeur: instructeur, dossier: notified_dossier, demande_seen_at: Time.zone.local(2018, 9, 10)) + notified_dossier.touch(time: Time.zone.local(2018, 9, 20)) + recent_dossier.touch(time: Time.zone.local(2018, 9, 25)) + older_dossier.touch(time: Time.zone.local(2018, 5, 13)) + end + + context 'in ascending order' do + let(:order) { 'asc' } + + it { is_expected.to eq([older_dossier, recent_dossier, notified_dossier].map(&:id)) } + end + + context 'in descending order' do + let(:order) { 'desc' } + + it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) } + end + + context 'with a dossier terminé' do + let!(:notified_dossier) { create(:dossier, :accepte, procedure:) } + let(:order) { 'desc' } + + it { is_expected.to eq([notified_dossier, recent_dossier, older_dossier].map(&:id)) } + end + end + + context 'for self table' do + let(:order) { 'asc' } # Desc works the same, no extra test required + + context 'for created_at column' do + let!(:column) { procedure.find_column(label: 'Créé le') } + let!(:recent_dossier) { Timecop.freeze(Time.zone.local(2018, 10, 17)) { create(:dossier, procedure:) } } + let!(:older_dossier) { Timecop.freeze(Time.zone.local(2003, 11, 11)) { create(:dossier, procedure:) } } + + it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } + end + + context 'for en_construction_at column' do + let!(:column) { procedure.find_column(label: 'En construction le') } + let!(:recent_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } + let!(:older_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } + + it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } + end + + context 'for updated_at column' do + let(:column) { procedure.find_column(label: 'Mis à jour le') } + let(:recent_dossier) { create(:dossier, procedure:) } + let(:older_dossier) { create(:dossier, procedure:) } + + before do + recent_dossier.touch(time: Time.zone.local(2018, 9, 25)) + older_dossier.touch(time: Time.zone.local(2018, 5, 13)) + end + + it { is_expected.to eq([older_dossier, recent_dossier].map(&:id)) } + end + end + + context 'for type_de_champ table' do + context 'with no revisions' do + let(:column) { procedure.find_column(label: first_type_de_champ.libelle) } + + let(:beurre_dossier) { create(:dossier, procedure:) } + let(:tartine_dossier) { create(:dossier, procedure:) } + + before do + beurre_dossier.project_champs_public.first.update(value: 'beurre') + tartine_dossier.project_champs_public.first.update(value: 'tartine') + end + + context 'asc' do + let(:order) { 'asc' } + + it { is_expected.to eq([beurre_dossier, tartine_dossier].map(&:id)) } + end + + context 'desc' do + let(:order) { 'desc' } + + it { is_expected.to eq([tartine_dossier, beurre_dossier].map(&:id)) } + end + end + + context 'with a revision adding a new type_de_champ' do + let!(:tdc) { { type_champ: :text, libelle: 'nouveau champ' } } + let(:column) { procedure.find_column(label: 'nouveau champ') } + + let!(:nothing_dossier) { create(:dossier, procedure:) } + let!(:beurre_dossier) { create(:dossier, procedure:) } + let!(:tartine_dossier) { create(:dossier, procedure:) } + + before do + nothing_dossier + procedure.draft_revision.add_type_de_champ(tdc) + procedure.publish_revision! + beurre_dossier.project_champs_public.last.update(value: 'beurre') + tartine_dossier.project_champs_public.last.update(value: 'tartine') + end + + context 'asc' do + let(:order) { 'asc' } + it { is_expected.to eq([nothing_dossier, beurre_dossier, tartine_dossier].map(&:id)) } + end + + context 'desc' do + let(:order) { 'desc' } + it { is_expected.to eq([tartine_dossier, beurre_dossier, nothing_dossier].map(&:id)) } + end + end + end + + context 'for type_de_champ_private table' do + context 'with no revisions' do + let(:column) { procedure.find_column(label: procedure.active_revision.types_de_champ_private.first.libelle) } + + let(:biere_dossier) { create(:dossier, procedure:) } + let(:vin_dossier) { create(:dossier, procedure:) } + + before do + biere_dossier.project_champs_private.first.update(value: 'biere') + vin_dossier.project_champs_private.first.update(value: 'vin') + end + + context 'asc' do + let(:order) { 'asc' } + + it { is_expected.to eq([biere_dossier, vin_dossier].map(&:id)) } + end + + context 'desc' do + let(:order) { 'desc' } + + it { is_expected.to eq([vin_dossier, biere_dossier].map(&:id)) } + end + end + end + + context 'for individual table' do + let(:order) { 'asc' } # Desc works the same, no extra test required + + let(:procedure) { create(:procedure, :for_individual) } + + let!(:first_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'M', prenom: 'Alain', nom: 'Antonelli')) } + let!(:last_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'Mme', prenom: 'Zora', nom: 'Zemmour')) } + + context 'for gender column' do + let(:column) { procedure.find_column(label: 'Civilité') } + + it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } + end + + context 'for prenom column' do + let(:column) { procedure.find_column(label: 'Prénom') } + + it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } + end + + context 'for nom column' do + let(:column) { procedure.find_column(label: 'Nom') } + + it { is_expected.to eq([first_dossier, last_dossier].map(&:id)) } + end + end + + context 'for followers_instructeurs table' do + let(:order) { 'asc' } # Desc works the same, no extra test required + + let!(:dossier_z) { create(:dossier, :en_construction, procedure:) } + let!(:dossier_a) { create(:dossier, :en_construction, procedure:) } + let!(:dossier_without_instructeur) { create(:dossier, :en_construction, procedure:) } + + before do + create(:follow, dossier: dossier_z, instructeur: create(:instructeur, email: 'zythum@exemple.fr')) + create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca@exemple.fr')) + create(:follow, dossier: dossier_a, instructeur: create(:instructeur, email: 'abaca2@exemple.fr')) + end + + context 'for email column' do + let(:column) { procedure.find_column(label: 'Email instructeur') } + + it { is_expected.to eq([dossier_a, dossier_z, dossier_without_instructeur].map(&:id)) } + end + end + + context 'for avis table' do + let(:column) { procedure.find_column(label: 'Avis oui/non') } + let(:order) { 'asc' } + + let!(:dossier_yes) { create(:dossier, procedure:) } + let!(:dossier_no) { create(:dossier, procedure:) } + + before do + create_list(:avis, 2, dossier: dossier_yes, question_answer: true) + create(:avis, dossier: dossier_no, question_answer: true) + create(:avis, dossier: dossier_no, question_answer: false) + end + + it { is_expected.to eq([dossier_no, dossier_yes].map(&:id)) } + end + + context 'for other tables' do + # All other columns and tables work the same so it’s ok to test only one + let(:column) { procedure.find_column(label: 'Code postal') } + let(:order) { 'asc' } # Desc works the same, no extra test required + + let!(:huitieme_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75008')) } + let!(:vingtieme_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75020')) } + + it { is_expected.to eq([huitieme_dossier, vingtieme_dossier].map(&:id)) } + end + end + + describe '#filtered_ids' do + let(:procedure) { create(:procedure, types_de_champ_public:, types_de_champ_private:) } + let(:types_de_champ_public) { [{}] } + let(:types_de_champ_private) { [{}] } + let(:dossiers) { procedure.dossiers } + let(:filtered_columns) { filters.map { to_filter(_1) } } + let(:filters) { [filter] } + + subject { described_class.send(:filtered_ids, dossiers.joins(:user), filtered_columns) } + + context 'for self table' do + context 'for created_at column' do + let(:filter) { ['Créé le', '18/9/2018'] } + + let!(:kept_dossier) { create(:dossier, procedure:, created_at: Time.zone.local(2018, 9, 18, 14, 28)) } + let!(:discarded_dossier) { create(:dossier, procedure:, created_at: Time.zone.local(2018, 9, 17, 23, 59)) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for en_construction_at column' do + let(:filter) { ['En construction le', '17/10/2018'] } + + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } + let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for updated_at column' do + let(:filter) { ['Mis à jour le', '18/9/2018'] } + + let(:kept_dossier) { create(:dossier, procedure:) } + let(:discarded_dossier) { create(:dossier, procedure:) } + + before do + kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28)) + discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 23, 59)) + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for updated_since column' do + let(:filter) { ['Mis à jour depuis', '18/9/2018'] } + + let(:kept_dossier) { create(:dossier, procedure:) } + let(:later_dossier) { create(:dossier, procedure:) } + let(:discarded_dossier) { create(:dossier, procedure:) } + + before do + kept_dossier.touch(time: Time.zone.local(2018, 9, 18, 14, 28)) + later_dossier.touch(time: Time.zone.local(2018, 9, 19, 14, 28)) + discarded_dossier.touch(time: Time.zone.local(2018, 9, 17, 14, 28)) + end + + it { is_expected.to match_array([kept_dossier.id, later_dossier.id]) } + end + + context 'for sva_svr_decision_before column' do + before do + travel_to Time.zone.local(2023, 6, 10, 10) + end + + let(:procedure) { create(:procedure, :published, :sva, types_de_champ_public: [{}], types_de_champ_private: [{}]) } + let(:filter) { ['Date décision SVA avant', '15/06/2023'] } + + let!(:kept_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current) } + let!(:later_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 2.days) } + let!(:discarded_dossier) { create(:dossier, :en_instruction, procedure:, sva_svr_decision_on: Date.current + 10.days) } + let!(:en_construction_dossier) { create(:dossier, :en_construction, procedure:, sva_svr_decision_on: Date.current + 2.days) } + let!(:accepte_dossier) { create(:dossier, :accepte, procedure:, sva_svr_decision_on: Date.current + 2.days) } + + it { is_expected.to match_array([kept_dossier.id, later_dossier.id, en_construction_dossier.id]) } + end + + context 'ignore time of day' do + let(:filter) { ['En construction le', '17/10/2018 19:30'] } + + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17, 15, 56)) } + let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 18, 5, 42)) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for a malformed date' do + context 'when its a string' do + let(:filter) { ['Mis à jour le', 'malformed date'] } + + it { is_expected.to match([]) } + end + + context 'when its a number' do + let(:filter) { ['Mis à jour le', '177500'] } + + it { is_expected.to match([]) } + end + end + + context 'with multiple search values' do + let(:filters) { [['En construction le', '17/10/2018'], ['En construction le', '19/10/2018']] } + + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 17)) } + let!(:other_kept_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2018, 10, 19)) } + let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, en_construction_at: Time.zone.local(2013, 1, 1)) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + + context 'with multiple state filters' do + let(:filters) { [['Statut', 'en_construction'], ['Statut', 'en_instruction']] } + + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:other_kept_dossier) { create(:dossier, :en_instruction, procedure:) } + let!(:discarded_dossier) { create(:dossier, :accepte, procedure:) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + + context 'with en_construction state filters' do + let(:filter) { ['Statut', 'en_construction'] } + + let!(:en_construction) { create(:dossier, :en_construction, procedure:) } + let!(:en_construction_with_correction) { create(:dossier, :en_construction, procedure:) } + let!(:correction) { create(:dossier_correction, dossier: en_construction_with_correction) } + it 'excludes dossier en construction with pending correction' do + is_expected.to contain_exactly(en_construction.id) + end + end + end + + context 'for type_de_champ table' do + let(:filter) { [type_de_champ.libelle, 'keep'] } + + let(:kept_dossier) { create(:dossier, procedure:) } + let(:discarded_dossier) { create(:dossier, procedure:) } + let(:type_de_champ) { procedure.active_revision.types_de_champ_public.first } + + context 'with single value' do + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me') + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me') + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'with multiple search values' do + let(:filters) { [[type_de_champ.libelle, 'keep'], [type_de_champ.libelle, 'and']] } + let(:other_kept_dossier) { create(:dossier, procedure:) } + + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'keep me') + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'discard me') + other_kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'and me too') + end + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + + context 'with yes_no type_de_champ' do + let(:filter) { [type_de_champ.libelle, 'true'] } + let(:types_de_champ_public) { [{ type: :yes_no }] } + + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'true') + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'false') + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'with departement type_de_champ' do + let(:filter) { [type_de_champ.libelle, '13'] } + let(:types_de_champ_public) { [{ type: :departements }] } + + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '13') + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: '69') + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'with enum type_de_champ' do + let(:filter) { [type_de_champ.libelle, 'Favorable'] } + let(:types_de_champ_public) { [{ type: :drop_down_list, options: ['Favorable', 'Defavorable'] }] } + + before do + kept_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(value: 'Favorable') + discarded_dossier.champs.find_by(stable_id: type_de_champ.stable_id).update(external_id: 'Defavorable') + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + end + + context 'for type_de_champ_private table' do + let(:filter) { [type_de_champ_private.libelle, 'keep'] } + + let(:kept_dossier) { create(:dossier, procedure:) } + let(:discarded_dossier) { create(:dossier, procedure:) } + let(:type_de_champ_private) { procedure.active_revision.types_de_champ_private.first } + + before do + kept_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'keep me') + discarded_dossier.champs.find_by(stable_id: type_de_champ_private.stable_id).update(value: 'discard me') + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for type_de_champ using AddressableColumnConcern' do + let(:column) { filtered_columns.first.column } + let(:types_de_champ_public) { [{ type: :rna, stable_id: 1, libelle: 'rna' }] } + let(:type_de_champ) { procedure.active_revision.types_de_champ.first } + let(:kept_dossier) { create(:dossier, procedure:) } + + context "when searching by postal_code (text)" do + let(:value) { "60580" } + let(:filter) { ["rna – code postal (5 chiffres)", value] } + + before do + kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => value }) + create(:dossier, procedure:).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "postal_code" => "unknown" }) + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + + it 'describes column' do + expect(column.type).to eq(:text) + expect(column.options_for_select).to eq([]) + end + end + + context "when searching by departement_code (enum)" do + let(:value) { "99" } + let(:filter) { ["rna – département", value] } + + before do + kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => value }) + create(:dossier, procedure:).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "departement_code" => "unknown" }) + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + + it 'describes column' do + expect(column.type).to eq(:enum) + expect(column.options_for_select.first).to eq(["99 – Etranger", "99"]) + end + end + + context "when searching by region_name" do + let(:value) { "60" } + let(:filter) { ["rna – region", value] } + + before do + kept_dossier.project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => value }) + create(:dossier, procedure:).project_champs_public.find { _1.stable_id == 1 }.update(value_json: { "region_name" => "unknown" }) + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + + it 'describes column' do + expect(column.type).to eq(:enum) + expect(column.options_for_select.first).to eq(["Auvergne-Rhône-Alpes", "Auvergne-Rhône-Alpes"]) + end + end + end + + context 'for etablissement table' do + context 'for entreprise_date_creation column' do + let(:filter) { ['Date de création', '21/6/2018'] } + + let!(:kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2018, 6, 21))) } + let!(:discarded_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2008, 6, 21))) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + + context 'with multiple search values' do + let(:filters) { [['Date de création', '21/6/2016'], ['Date de création', '21/6/2018']] } + + let!(:other_kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, entreprise_date_creation: Time.zone.local(2016, 6, 21))) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + + context 'for code_postal column' do + # All columns except entreprise_date_creation work exacly the same, just testing one + + let(:filter) { ['Code postal', '75017'] } + + let!(:kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75017')) } + let!(:discarded_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '25000')) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + + context 'with multiple search values' do + let(:filters) { [['Code postal', '75017'], ['Code postal', '88100']] } + + let!(:other_kept_dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '88100')) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + end + + context 'for user table' do + let(:filter) { ['Demandeur', 'keepmail'] } + + let!(:kept_dossier) { create(:dossier, procedure:, user: create(:user, email: 'me@keepmail.com')) } + let!(:discarded_dossier) { create(:dossier, procedure:, user: create(:user, email: 'me@discard.com')) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + + context 'with multiple search values' do + let(:filters) { [['Demandeur', 'keepmail'], ['Demandeur', 'beta.gouv.fr']] } + + let!(:other_kept_dossier) { create(:dossier, procedure:, user: create(:user, email: 'bazinga@beta.gouv.fr')) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + + context 'for individual table' do + let(:procedure) { create(:procedure, :for_individual) } + let!(:kept_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'Mme', prenom: 'Josephine', nom: 'Baker')) } + let!(:discarded_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'M', prenom: 'Jean', nom: 'Tremblay')) } + + context 'for gender column' do + let(:filter) { ['Civilité', 'Mme'] } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for prenom column' do + let(:filter) { ['Prénom', 'Josephine'] } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'for nom column' do + let(:filter) { ['Nom', 'Baker'] } + + it { is_expected.to contain_exactly(kept_dossier.id) } + end + + context 'with multiple search values' do + let(:filters) { [['Prénom', 'Josephine'], ['Prénom', 'Romuald']] } + + let!(:other_kept_dossier) { create(:dossier, procedure:, individual: build(:individual, gender: 'M', prenom: 'Romuald', nom: 'Pistis')) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + + context 'for followers_instructeurs table' do + let(:filter) { ['Email instructeur', 'keepmail'] } + + let!(:kept_dossier) { create(:dossier, procedure:) } + let!(:discarded_dossier) { create(:dossier, procedure:) } + + before do + create(:follow, dossier: kept_dossier, instructeur: create(:instructeur, email: 'me@keepmail.com')) + create(:follow, dossier: discarded_dossier, instructeur: create(:instructeur, email: 'me@discard.com')) + end + + it { is_expected.to contain_exactly(kept_dossier.id) } + + context 'with multiple search values' do + let(:filters) { [['Email instructeur', 'keepmail'], ['Email instructeur', 'beta.gouv.fr']] } + + let(:other_kept_dossier) { create(:dossier, procedure:) } + + before do + create(:follow, dossier: other_kept_dossier, instructeur: create(:instructeur, email: 'bazinga@beta.gouv.fr')) + end + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + + context 'for groupe_instructeur table' do + let(:filter) { ['Groupe instructeur', procedure.defaut_groupe_instructeur.id.to_s] } + + let!(:gi_2) { create(:groupe_instructeur, label: 'gi2', procedure:) } + let!(:gi_3) { create(:groupe_instructeur, label: 'gi3', procedure:) } + + let!(:kept_dossier) { create(:dossier, :en_construction, procedure:) } + let!(:discarded_dossier) { create(:dossier, :en_construction, procedure:, groupe_instructeur: gi_2) } + + it { is_expected.to contain_exactly(kept_dossier.id) } + + context 'with multiple search values' do + let(:filters) { [['Groupe instructeur', procedure.defaut_groupe_instructeur.id.to_s], ['Groupe instructeur', gi_3.id.to_s]] } + + let!(:other_kept_dossier) { create(:dossier, procedure:, groupe_instructeur: gi_3) } + + it 'returns every dossier that matches any of the search criteria for a given column' do + is_expected.to contain_exactly(kept_dossier.id, other_kept_dossier.id) + end + end + end + end +end diff --git a/spec/services/dossier_projection_service_spec.rb b/spec/services/dossier_projection_service_spec.rb index 1b577cf40..7671bdda5 100644 --- a/spec/services/dossier_projection_service_spec.rb +++ b/spec/services/dossier_projection_service_spec.rb @@ -2,7 +2,7 @@ describe DossierProjectionService do describe '#project' do - subject { described_class.project(dossiers_ids, fields) } + subject { described_class.project(dossiers_ids, columns) } context 'with multiple dossier' do let!(:procedure) { create(:procedure, types_de_champ_public: [{}, { type: :linked_drop_down_list }]) } @@ -11,12 +11,9 @@ describe DossierProjectionService do let!(:dossier_3) { create(:dossier, :en_instruction, procedure: procedure) } let(:dossiers_ids) { [dossier_3.id, dossier_1.id, dossier_2.id] } - let(:fields) do + let(:columns) do procedure.active_revision.types_de_champ_public.map do |type_de_champ| - { - "table" => "type_de_champ", - "column" => type_de_champ.stable_id.to_s - } + procedure.find_column(label: type_de_champ.libelle) end end @@ -55,12 +52,9 @@ describe DossierProjectionService do let!(:dossier) { create(:dossier, procedure:) } let(:dossiers_ids) { [dossier.id] } - let(:fields) do + let(:columns) do [ - { - "table" => "type_de_champ", - "column" => procedure.active_revision.types_de_champ_public[0].stable_id.to_s - } + procedure.find_column(label: procedure.active_revision.types_de_champ_public[0].libelle) ] end @@ -78,38 +72,37 @@ describe DossierProjectionService do end context 'attributes by attributes' do - let(:fields) { [{ "table" => table, "column" => column }] } + let(:procedure) { create(:procedure) } + let(:columns) { [procedure.find_column(label:)] } let(:dossiers_ids) { [dossier.id] } subject { super()[0].columns[0] } context 'for self table' do - let(:table) { 'self' } - context 'for created_at column' do - let(:column) { 'created_at' } - let(:dossier) { Timecop.freeze(Time.zone.local(1992, 3, 22)) { create(:dossier) } } + let(:label) { 'Créé le' } + let(:dossier) { Timecop.freeze(Time.zone.local(1992, 3, 22)) { create(:dossier, procedure:) } } it { is_expected.to eq('22/03/1992') } end context 'for en_construction_at column' do - let(:column) { 'en_construction_at' } - let(:dossier) { create(:dossier, :en_construction, en_construction_at: Time.zone.local(2018, 10, 17)) } + let(:label) { 'En construction le' } + let(:dossier) { create(:dossier, :en_construction, en_construction_at: Time.zone.local(2018, 10, 17), procedure:) } it { is_expected.to eq('17/10/2018') } end context 'for depose_at column' do - let(:column) { 'depose_at' } - let(:dossier) { create(:dossier, :en_construction, depose_at: Time.zone.local(2018, 10, 17)) } + let(:label) { 'Déposé le' } + let(:dossier) { create(:dossier, :en_construction, depose_at: Time.zone.local(2018, 10, 17), procedure:) } it { is_expected.to eq('17/10/2018') } end context 'for updated_at column' do - let(:column) { 'updated_at' } - let(:dossier) { create(:dossier) } + let(:label) { 'Mis à jour le' } + let(:dossier) { create(:dossier, procedure:) } before { dossier.touch(time: Time.zone.local(2018, 9, 25)) } @@ -118,61 +111,56 @@ describe DossierProjectionService do end context 'for user table' do - let(:table) { 'user' } - let(:column) { 'email' } + let(:label) { 'Demandeur' } - let(:dossier) { create(:dossier, user: create(:user, email: 'bla@yopmail.com')) } + let(:dossier) { create(:dossier, user: create(:user, email: 'bla@yopmail.com'), procedure:) } it { is_expected.to eq('bla@yopmail.com') } end context 'for individual table' do - let(:table) { 'individual' } let(:procedure) { create(:procedure, :for_individual, :with_type_de_champ, :with_type_de_champ_private) } - let(:dossier) { create(:dossier, procedure: procedure, individual: build(:individual, nom: 'Martin', prenom: 'Jacques', gender: 'M.')) } + let(:dossier) { create(:dossier, procedure:, individual: build(:individual, nom: 'Martin', prenom: 'Jacques', gender: 'M.')) } context 'for prenom column' do - let(:column) { 'prenom' } + let(:label) { 'Prénom' } it { is_expected.to eq('Jacques') } end context 'for nom column' do - let(:column) { 'nom' } + let(:label) { 'Nom' } it { is_expected.to eq('Martin') } end context 'for gender column' do - let(:column) { 'gender' } + let(:label) { 'Civilité' } it { is_expected.to eq('M.') } end end context 'for etablissement table' do - let(:table) { 'etablissement' } - let(:column) { 'code_postal' } # All other columns work the same, no extra test required + let(:label) { 'Code postal' } - let!(:dossier) { create(:dossier, etablissement: create(:etablissement, code_postal: '75008')) } + let!(:dossier) { create(:dossier, procedure:, etablissement: create(:etablissement, code_postal: '75008')) } it { is_expected.to eq('75008') } end context 'for groupe_instructeur table' do - let(:table) { 'groupe_instructeur' } - let(:column) { 'label' } + let(:label) { 'Groupe instructeur' } - let!(:dossier) { create(:dossier) } + let!(:dossier) { create(:dossier, procedure:) } it { is_expected.to eq('défaut') } end context 'for followers_instructeurs table' do - let(:table) { 'followers_instructeurs' } - let(:column) { 'email' } + let(:label) { 'Email instructeur' } - let(:dossier) { create(:dossier) } + let(:dossier) { create(:dossier, procedure:) } let!(:follow1) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'b@host.fr')) } let!(:follow2) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'a@host.fr')) } let!(:follow3) { create(:follow, dossier: dossier, instructeur: create(:instructeur, email: 'c@host.fr')) } @@ -181,19 +169,21 @@ describe DossierProjectionService do end context 'for type_de_champ table' do - let(:table) { 'type_de_champ' } - let(:dossier) { create(:dossier) } - let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } + let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :text }]) } + let(:dossier) { create(:dossier, procedure:) } + let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle } - before { dossier.project_champs_public.first.update(value: 'kale') } + before do + dossier.project_champs_public.first.update(value: 'kale') + end it { is_expected.to eq('kale') } end context 'for type_de_champ_private table' do - let(:table) { 'type_de_champ_private' } - let(:dossier) { create(:dossier) } - let(:column) { dossier.procedure.active_revision.types_de_champ_private.first.stable_id.to_s } + let(:procedure) { create(:procedure, types_de_champ_private: [{ type: :text }]) } + let(:dossier) { create(:dossier, procedure:) } + let(:label) { dossier.procedure.active_revision.types_de_champ_private.first.libelle } before { dossier.project_champs_private.first.update(value: 'quinoa') } @@ -201,10 +191,9 @@ describe DossierProjectionService do end context 'for type_de_champ table and value to.s' do - let(:table) { 'type_de_champ' } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :yes_no }]) } - let(:dossier) { create(:dossier, procedure: procedure) } - let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } + let(:dossier) { create(:dossier, procedure:) } + let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle } before { dossier.project_champs_public.first.update(value: 'true') } @@ -212,10 +201,9 @@ describe DossierProjectionService do end context 'for type_de_champ table and value to.s which needs data field' do - let(:table) { 'type_de_champ' } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :address }]) } - let(:dossier) { create(:dossier, procedure: procedure) } - let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } + let(:dossier) { create(:dossier, procedure:) } + let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle } before { dossier.project_champs_public.first.update(value: '18 a la bonne rue', data: { 'label' => '18 a la bonne rue', 'departement' => 'd' }) } @@ -223,10 +211,9 @@ describe DossierProjectionService do end context 'for type_de_champ table: type_de_champ pays which needs external_id field' do - let(:table) { 'type_de_champ' } let(:procedure) { create(:procedure, types_de_champ_public: [{ type: :pays }]) } - let(:dossier) { create(:dossier, procedure: procedure) } - let(:column) { dossier.procedure.active_revision.types_de_champ_public.first.stable_id.to_s } + let(:dossier) { create(:dossier, procedure:) } + let(:label) { dossier.procedure.active_revision.types_de_champ_public.first.libelle } around do |example| I18n.with_locale(:fr) do @@ -254,8 +241,10 @@ describe DossierProjectionService do context 'for dossier corrections table' do let(:table) { 'dossier_corrections' } let(:column) { 'resolved_at' } - let(:dossier) { create(:dossier, :en_construction) } - subject { described_class.project(dossiers_ids, fields)[0] } + let(:procedure) { create(:procedure) } + let(:columns) { [Column.new(procedure_id: procedure.id, table:, column:)] } # should somehow be present in column concern + let(:dossier) { create(:dossier, :en_construction, procedure:) } + subject { described_class.project(dossiers_ids, columns)[0] } context "when dossier has pending correction" do before { create(:dossier_correction, dossier:) } diff --git a/spec/system/instructeurs/procedure_filters_spec.rb b/spec/system/instructeurs/procedure_filters_spec.rb index c8b588c89..85555764f 100644 --- a/spec/system/instructeurs/procedure_filters_spec.rb +++ b/spec/system/instructeurs/procedure_filters_spec.rb @@ -224,7 +224,7 @@ describe "procedure filters" do end def remove_filter(filter_value) - click_link text: filter_value + click_button text: filter_value end def add_column(column_name) diff --git a/spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb b/spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb index 6b95fe523..ba0915b52 100644 --- a/spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb +++ b/spec/tasks/maintenance/clean_invalid_procedure_presentation_task_spec.rb @@ -14,14 +14,14 @@ module Maintenance before { element.update_column(:filters, filters) } context 'when filter is valid' do - let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (ProcedurePresentation::PG_INTEGER_MAX_VALUE - 1).to_s }] } } + let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (FilteredColumn::PG_INTEGER_MAX_VALUE - 1).to_s }] } } it 'keeps it filters' do expect { subject }.not_to change { element.reload.filters } end end context 'when filter is invalid, drop it' do - let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (ProcedurePresentation::PG_INTEGER_MAX_VALUE).to_s }] } } + let(:filters) { { "suivis" => [{ 'table' => "self", 'column' => "id", "value" => (FilteredColumn::PG_INTEGER_MAX_VALUE).to_s }] } } it 'drop invalid filters' do expect { subject }.to change { element.reload.filters }.to({ "suivis" => [] }) end diff --git a/spec/types/filtered_column_type_spec.rb b/spec/types/filtered_column_type_spec.rb new file mode 100644 index 000000000..05595135c --- /dev/null +++ b/spec/types/filtered_column_type_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +describe FilteredColumnType do + let(:type) { FilteredColumnType.new } + + describe 'cast' do + it 'from FilteredColumn' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + filtered_column = FilteredColumn.new(column:, filter: 'filter') + expect(type.cast(filtered_column)).to eq(filtered_column) + end + + it 'from nil' do + expect(type.cast(nil)).to eq(nil) + end + + describe 'from form' do + it 'with valid column id' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + h = { filter: 'filter', id: column.id } + + expect(Column).to receive(:find).with(column.h_id).and_return(column) + expect(type.cast(h)).to eq(FilteredColumn.new(column:, filter: 'filter')) + end + + it 'with invalid column id' do + h = { filter: 'filter', id: 'invalid' } + expect { type.cast(h) }.to raise_error(JSON::ParserError) + + h = { filter: 'filter', id: { procedure_id: 'invalid', column_id: 'nop' }.to_json } + expect { type.cast(h) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + describe 'deserialize' do + context 'with valid value' do + it 'works' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + expect(Column).to receive(:find).with(column.h_id).and_return(column) + expect(type.deserialize({ id: column.h_id, filter: 'filter' }.to_json)).to eq(FilteredColumn.new(column: column, filter: 'filter')) + end + end + + context 'with nil' do + it { expect(type.deserialize(nil)).to eq(nil) } + end + end + + describe 'serialize' do + it 'with FilteredColumn' do + column = Column.new(procedure_id: 1, table: 'table', column: 'column') + sorted_column = FilteredColumn.new(column: column, filter: 'filter') + expect(type.serialize(sorted_column)).to eq({ id: column.h_id, filter: 'filter' }.to_json) + end + + it 'with nil' do + expect(type.serialize(nil)).to eq(nil) + end + + it 'with invalid value' do + expect { type.serialize('invalid') }.to raise_error(ArgumentError) + end + end +end