blacklight 7.25.1 → 7.26.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +1 -1
  3. data/.rubocop.yml +1 -0
  4. data/VERSION +1 -1
  5. data/app/components/blacklight/facet_component.rb +43 -0
  6. data/app/components/blacklight/facet_field_list_component.rb +1 -1
  7. data/app/components/blacklight/facet_item_component.rb +11 -9
  8. data/app/components/blacklight/search_bar_component.rb +5 -1
  9. data/app/controllers/concerns/blacklight/bookmarks.rb +6 -3
  10. data/app/helpers/blacklight/blacklight_helper_behavior.rb +12 -1
  11. data/app/helpers/blacklight/catalog_helper_behavior.rb +1 -0
  12. data/app/helpers/blacklight/facets_helper_behavior.rb +3 -6
  13. data/app/helpers/blacklight/render_constraints_helper_behavior.rb +10 -2
  14. data/app/models/blacklight/icon.rb +7 -1
  15. data/app/services/blacklight/search_service.rb +4 -0
  16. data/app/views/catalog/_previous_next_doc.html.erb +1 -0
  17. data/app/views/catalog/_search_form.html.erb +1 -1
  18. data/app/views/catalog/_show_main_content.html.erb +1 -1
  19. data/app/views/catalog/show.html.erb +1 -1
  20. data/blacklight.gemspec +1 -0
  21. data/lib/blacklight/configuration.rb +9 -2
  22. data/lib/blacklight/parameters.rb +97 -1
  23. data/lib/blacklight/search_builder.rb +3 -2
  24. data/lib/blacklight/search_state/filter_field.rb +12 -6
  25. data/lib/blacklight/search_state.rb +10 -64
  26. data/spec/controllers/bookmarks_controller_spec.rb +2 -1
  27. data/spec/helpers/blacklight/facets_helper_behavior_spec.rb +1 -1
  28. data/spec/helpers/blacklight/render_constraints_helper_behavior_spec.rb +9 -1
  29. data/spec/lib/blacklight/parameters_spec.rb +87 -0
  30. data/spec/lib/blacklight/search_state/filter_field_spec.rb +1 -14
  31. data/spec/lib/blacklight/search_state_spec.rb +1 -14
  32. data/spec/models/blacklight/search_builder_spec.rb +10 -1
  33. data/spec/models/blacklight/solr/search_builder_spec.rb +3 -1
  34. metadata +18 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5591ce042284ddde98c77e6027f09782e681195f31fc919870d3c5efd1dd029
4
- data.tar.gz: ffaf195a0ce3b1ea7a5b2e5958c4cea4daaab81bc34b3075452f44858ff084d9
3
+ metadata.gz: 61a8ce6dc6b7186a9facb02e2862cd9ab252992fd16ac8f3cfb52bb0dc414a0b
4
+ data.tar.gz: 8fca956b142ab7f1c446858320df1247bed718197ab499e826eb663406db95d9
5
5
  SHA512:
6
- metadata.gz: 5ae17a8834d21cc7e0229846a7ec37bdeb8ff2f451abf06b9d399580dcffe0d924d80840789d2db9152c440266fb4a8fced5c9fc7f9ef397873af8f5d84a1740
7
- data.tar.gz: 8c4a34218c70c030bc5622ac1e32d84ef5967f7ae3f84fc7b044ff2004922d3cbb78e74ff96ea7d4e0f4a3aedf95f57393bc3815c36c5e27958531d3f0b58ca4
6
+ metadata.gz: 175534daee84446996871538ece0c562194b485d53b801cb8ab839569fe36c712d94c7629623dc5e32d4e58993d82bc4f899669dbdfaf30de5447235236728f4
7
+ data.tar.gz: 53c10ab08c741669511f9e77ea1d948968fc0b635b16a883c5c055ebeab45dfd9d7ab295838049f5ff27328f40bb66e06f919a28c649b4c3a89595c49812f176
@@ -43,7 +43,7 @@ jobs:
43
43
  - name: Run tests
44
44
  run: bundle exec rake ci
45
45
  env:
46
- ENGINE_CART_RAILS_OPTIONS: '--skip-git --skip-listen --skip-spring --skip-keeps --skip-action-cable --skip-coffee --skip-test'
46
+ ENGINE_CART_RAILS_OPTIONS: '-a propshaft --skip-git --skip-listen --skip-spring --skip-keeps --skip-action-cable --skip-coffee --skip-test'
47
47
  test_bootstrap5:
48
48
  runs-on: ubuntu-latest
49
49
  strategy:
data/.rubocop.yml CHANGED
@@ -39,6 +39,7 @@ Metrics/ClassLength:
39
39
  - "lib/blacklight/configuration.rb"
40
40
  - "lib/blacklight/search_builder.rb"
41
41
  - "lib/blacklight/search_state.rb"
42
+ - "lib/blacklight/search_state/filter_field.rb"
42
43
 
43
44
  Layout/LineLength:
44
45
  Max: 200
data/VERSION CHANGED
@@ -1 +1 @@
1
- 7.25.1
1
+ 7.26.0
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blacklight
4
+ ##
5
+ # Renders a single section for facet limit with a specified
6
+ # solr field used for faceting. This renders no UI of it's own, but renders
7
+ # the component that is configured for the facet.
8
+ class FacetComponent < ViewComponent::Base
9
+ with_collection_parameter :display_facet_or_field_config
10
+
11
+ # @param [Blacklight::Solr::Response::Facets::FacetField] display_facet
12
+ # @param [Blacklight::Configuration] blacklight_config
13
+ # @param [Boolean] layout
14
+ def initialize(display_facet_or_field_config: nil, display_facet: nil, field_config: nil, response: nil, blacklight_config: nil, **component_args)
15
+ if display_facet_or_field_config.is_a?(Blacklight::Configuration::Field) || field_config
16
+ @field_config = display_facet_or_field_config || field_config
17
+ @display_facet = display_facet || (response && response.aggregations[@field_config.field])
18
+ elsif (display_facet || display_facet_or_field_config).respond_to?(:name)
19
+ @display_facet = display_facet || display_facet_or_field_config
20
+ @field_config = field_config || blacklight_config&.facet_configuration_for_field(@display_facet.name)
21
+ else
22
+ raise ArgumentError, 'You must provide one of display_facet or field_config' unless @field_config
23
+ end
24
+
25
+ @component_args = component_args
26
+ end
27
+
28
+ def render?
29
+ helpers.should_render_field?(@field_config, @display_facet)
30
+ end
31
+
32
+ def call
33
+ component = @field_config.component == true ? Blacklight::FacetFieldListComponent : @field_config.component
34
+
35
+ render(
36
+ component.new(
37
+ facet_field: helpers.facet_field_presenter(@field_config, @display_facet),
38
+ **@component_args
39
+ )
40
+ )
41
+ end
42
+ end
43
+ end
@@ -16,7 +16,7 @@ module Blacklight
16
16
  end
17
17
 
18
18
  def render?
19
- @facet_field.paginator.items.any?
19
+ @facet_field.paginator&.items&.any?
20
20
  end
21
21
  end
22
22
  end
@@ -4,6 +4,8 @@ module Blacklight
4
4
  class FacetItemComponent < Blacklight::Component
5
5
  extend Deprecation
6
6
 
7
+ attr_reader :label, :href, :hits
8
+
7
9
  with_collection_parameter :facet_item
8
10
 
9
11
  def initialize(facet_item:, wrapping_element: 'li', suppress_link: false)
@@ -48,9 +50,9 @@ module Blacklight
48
50
  def overridden_helper_methods?
49
51
  return false if explicit_component_configuration?
50
52
 
51
- @view_context.method(:render_facet_item).owner != Blacklight::FacetsHelperBehavior ||
52
- @view_context.method(:render_facet_value).owner != Blacklight::FacetsHelperBehavior ||
53
- @view_context.method(:render_selected_facet_value).owner != Blacklight::FacetsHelperBehavior
53
+ helpers.method(:render_facet_item).owner != Blacklight::FacetsHelperBehavior ||
54
+ helpers.method(:render_facet_value).owner != Blacklight::FacetsHelperBehavior ||
55
+ helpers.method(:render_selected_facet_value).owner != Blacklight::FacetsHelperBehavior
54
56
  end
55
57
 
56
58
  # Call out to the helper method equivalent of this component
@@ -72,7 +74,7 @@ module Blacklight
72
74
  # @private
73
75
  def render_facet_value
74
76
  tag.span(class: "facet-label") do
75
- link_to_unless(@suppress_link, @label, @href, class: "facet-select", rel: "nofollow")
77
+ link_to_unless(@suppress_link, label, href, class: "facet-select", rel: "nofollow")
76
78
  end + render_facet_count
77
79
  end
78
80
 
@@ -83,9 +85,9 @@ module Blacklight
83
85
  # @private
84
86
  def render_selected_facet_value
85
87
  tag.span(class: "facet-label") do
86
- tag.span(@label, class: "selected") +
88
+ tag.span(label, class: "selected") +
87
89
  # remove link
88
- link_to(@href, class: "remove", rel: "nofollow") do
90
+ link_to(href, class: "remove", rel: "nofollow") do
89
91
  tag.span('✖', class: "remove-icon", aria: { hidden: true }) +
90
92
  tag.span(helpers.t(:'blacklight.search.facets.selected.remove'), class: 'sr-only visually-hidden')
91
93
  end
@@ -101,12 +103,12 @@ module Blacklight
101
103
  # @return [String]
102
104
  # @private
103
105
  def render_facet_count(options = {})
104
- return helpers.render_facet_count(@hits, options) unless helpers.method(:render_facet_count).owner == Blacklight::FacetsHelperBehavior || explicit_component_configuration?
106
+ return helpers.render_facet_count(hits, options) unless helpers.method(:render_facet_count).owner == Blacklight::FacetsHelperBehavior || explicit_component_configuration?
105
107
 
106
- return '' if @hits.blank?
108
+ return '' if hits.blank?
107
109
 
108
110
  classes = (options[:classes] || []) << "facet-count"
109
- tag.span(t('blacklight.search.facets.count', number: number_with_delimiter(@hits)), class: classes)
111
+ tag.span(t('blacklight.search.facets.count', number: number_with_delimiter(hits)), class: classes)
110
112
  end
111
113
 
112
114
  private
@@ -10,7 +10,7 @@ module Blacklight
10
10
  # rubocop:disable Metrics/ParameterLists
11
11
  def initialize(
12
12
  url:, params:,
13
- advanced_search_url: nil,
13
+ advanced_search_url: nil, presenter: nil,
14
14
  classes: ['search-query-form'], prefix: nil,
15
15
  method: 'GET', q: nil, query_param: :q,
16
16
  search_field: nil, search_fields: nil, autocomplete_path: nil,
@@ -29,6 +29,10 @@ module Blacklight
29
29
  @autofocus = autofocus
30
30
  @search_fields = search_fields
31
31
  @i18n = i18n
32
+ return if presenter.nil?
33
+
34
+ Deprecation.warn(self, 'SearchBarComponent no longer uses a SearchBarPresenter, the presenter: param will be removed in 8.0. ' \
35
+ 'Set advanced_search.enabled, autocomplete_enabled, and enable_search_bar_autofocus on BlacklightConfiguration')
32
36
  end
33
37
  # rubocop:enable Metrics/ParameterLists
34
38
 
@@ -75,12 +75,15 @@ module Blacklight::Bookmarks
75
75
 
76
76
  current_or_guest_user.save! unless current_or_guest_user.persisted?
77
77
 
78
- success = @bookmarks.all? do |bookmark|
79
- current_or_guest_user.bookmarks.where(bookmark).exists? || current_or_guest_user.bookmarks.create(bookmark)
78
+ bookmarks_to_add = @bookmarks.reject { |bookmark| current_or_guest_user.bookmarks.where(bookmark).exists? }
79
+ success = ActiveRecord::Base.transaction do
80
+ current_or_guest_user.bookmarks.create!(bookmarks_to_add)
81
+ rescue ActiveRecord::RecordInvalid
82
+ false
80
83
  end
81
84
 
82
85
  if request.xhr?
83
- success ? render(json: { bookmarks: { count: current_or_guest_user.bookmarks.count } }) : render(plain: "", status: "500")
86
+ success ? render(json: { bookmarks: { count: current_or_guest_user.bookmarks.count } }) : render(json: current_or_guest_user.errors.full_messages, status: "500")
84
87
  else
85
88
  if @bookmarks.any? && success
86
89
  flash[:notice] = I18n.t('blacklight.bookmarks.add.success', count: @bookmarks.length)
@@ -79,7 +79,18 @@ module Blacklight::BlacklightHelperBehavior
79
79
  # Render the search navbar
80
80
  # @return [String]
81
81
  def render_search_bar
82
- search_bar_presenter.render
82
+ if search_bar_presenter_class == Blacklight::SearchBarPresenter && partial_from_blacklight?(Blacklight::SearchBarPresenter.partial)
83
+ component_class = blacklight_config&.view_config(document_index_view_type)&.search_bar_component || Blacklight::SearchBarComponent
84
+ render component_class.new(
85
+ url: search_action_url,
86
+ advanced_search_url: search_action_url(action: 'advanced_search'),
87
+ params: search_state.params_for_search.except(:qt),
88
+ search_fields: Deprecation.silence(Blacklight::ConfigurationHelperBehavior) { search_fields },
89
+ autocomplete_path: search_action_path(action: :suggest)
90
+ )
91
+ else
92
+ search_bar_presenter.render
93
+ end
83
94
  end
84
95
  deprecation_deprecate render_search_bar: "Call `render Blacklight::SearchBarComponent.new' instead"
85
96
 
@@ -181,6 +181,7 @@ module Blacklight::CatalogHelperBehavior
181
181
  def render_document_main_content_partial(_document = @document)
182
182
  render partial: 'show_main_content'
183
183
  end
184
+ deprecation_deprecate render_document_main_content_partial: "Use \"render 'show_main_content'\" instead"
184
185
 
185
186
  ##
186
187
  # Should we display the sort and per page widget?
@@ -76,13 +76,10 @@ module Blacklight::FacetsHelperBehavior
76
76
  field_config = facet_configuration_for_field(display_facet.name)
77
77
 
78
78
  if field_config.component
79
- return unless should_render_field?(field_config, display_facet)
80
-
81
- component = field_config.component == true ? Blacklight::FacetFieldListComponent : field_config.component
82
-
83
79
  return render(
84
- component.new(
85
- facet_field: facet_field_presenter(field_config, display_facet),
80
+ Blacklight::FacetComponent.new(
81
+ display_facet: display_facet,
82
+ field_config: field_config,
86
83
  layout: (params[:action] == 'facet' ? false : options[:layout])
87
84
  )
88
85
  )
@@ -107,9 +107,13 @@ module Blacklight::RenderConstraintsHelperBehavior
107
107
  safe_join(Array(values).map do |val|
108
108
  next if val.blank? # skip empty string
109
109
 
110
- presenter = facet_item_presenter(facet_config, val, facet)
111
-
112
110
  Deprecation.silence(Blacklight::RenderConstraintsHelperBehavior) do
111
+ presenter = if val.is_a? Array
112
+ inclusive_facet_item_presenter(facet_config, val, facet)
113
+ else
114
+ facet_item_presenter(facet_config, val, facet)
115
+ end
116
+
113
117
  render_constraint_element(presenter.field_label,
114
118
  presenter.label,
115
119
  remove: presenter.remove_href(search_state),
@@ -118,6 +122,10 @@ module Blacklight::RenderConstraintsHelperBehavior
118
122
  end, "\n")
119
123
  end
120
124
 
125
+ def inclusive_facet_item_presenter(facet_config, facet_item, facet_field)
126
+ Blacklight::InclusiveFacetItemPresenter.new(facet_item, facet_config, self, facet_field)
127
+ end
128
+
121
129
  # Render a label/value constraint on the screen. Can be called
122
130
  # by plugins and such to get application-defined rendering.
123
131
  #
@@ -55,7 +55,10 @@ module Blacklight
55
55
  def file_source
56
56
  raise Blacklight::Exceptions::IconNotFound, "Could not find #{path}" if file.blank?
57
57
 
58
- file.source.force_encoding('UTF-8')
58
+ # Handle both Sprockets::Asset and Propshaft::Asset
59
+ data = file.respond_to?(:source) ? file.source : file.path.read
60
+
61
+ data.force_encoding('UTF-8')
59
62
  end
60
63
 
61
64
  def ng_xml
@@ -68,7 +71,10 @@ module Blacklight
68
71
  [icon_name, additional_options[:label_context]].compact.join('_')
69
72
  end
70
73
 
74
+ # @return [Sprockets::Asset,Propshaft::Asset]
71
75
  def file
76
+ return Rails.application.assets.load_path.find(path) if defined? Propshaft
77
+
72
78
  # Rails.application.assets is `nil` in production mode (where compile assets is enabled).
73
79
  # This workaround is based off of this comment: https://github.com/fphilipe/premailer-rails/issues/145#issuecomment-225992564
74
80
  (Rails.application.assets || ::Sprockets::Railtie.build_environment(Rails.application)).find_asset(path)
@@ -17,6 +17,10 @@ module Blacklight
17
17
  search_builder_class.new(self)
18
18
  end
19
19
 
20
+ def search_state_class
21
+ @search_state.class
22
+ end
23
+
20
24
  # a solr query method
21
25
  # @yield [search_builder] optional block yields configured SearchBuilder, caller can modify or create new SearchBuilder to be used. Block should return SearchBuilder to be used.
22
26
  # @return [Blacklight::Solr::Response] the solr response object
@@ -1 +1,2 @@
1
+ <% Deprecation.warn(self, 'The partial _previous_next_doc.html.erb will be removed in 8.0. Render Blacklight::SearchContextComponent instead.') %>
1
2
  <%= render(Blacklight::SearchContextComponent.new(search_context: @search_context, search_session: search_session)) %>
@@ -1,4 +1,4 @@
1
- <%= warn "#{__file__} is a deprecated partial." %>
1
+ <%= warn "#{__FILE__} is a deprecated partial." %>
2
2
  <%= render((blacklight_config&.view_config(document_index_view_type)&.search_bar_component || Blacklight::SearchBarComponent).new(
3
3
  url: search_action_url,
4
4
  advanced_search_url: search_action_url(action: 'advanced_search'),
@@ -1,4 +1,4 @@
1
- <%= render 'previous_next_doc' if @search_context && search_session['document_id'] == @document.id %>
1
+ <%= render(Blacklight::SearchContextComponent.new(search_context: @search_context, search_session: search_session)) if search_session['document_id'] == @document.id %>
2
2
 
3
3
  <% @page_title = t('blacklight.search.show.title', document_title: Deprecation.silence(Blacklight::BlacklightHelperBehavior) { document_show_html_title }, application_name: application_name).html_safe %>
4
4
  <% content_for(:head) { render_link_rel_alternates } %>
@@ -5,7 +5,7 @@
5
5
  </div>
6
6
  <% end %>
7
7
 
8
- <%= render_document_main_content_partial %>
8
+ <%= render 'show_main_content' %>
9
9
 
10
10
  <% content_for(:sidebar) do %>
11
11
  <%= render_document_sidebar_partial @document %>
data/blacklight.gemspec CHANGED
@@ -33,6 +33,7 @@ Gem::Specification.new do |s|
33
33
  s.add_dependency "i18n", '>= 1.7.0' # added named parameters
34
34
  s.add_dependency "ostruct", '>= 0.3.2'
35
35
  s.add_dependency "view_component", '~> 2.43'
36
+ s.add_dependency 'hashdiff'
36
37
 
37
38
  s.add_development_dependency "rsolr", ">= 1.0.6", "< 3" # Library for interacting with rSolr.
38
39
  s.add_development_dependency "rspec-rails", "~> 5.0"
@@ -295,8 +295,8 @@ module Blacklight
295
295
  # @return [Boolean]
296
296
  property :enable_search_bar_autofocus, default: false
297
297
 
298
- BASIC_SEARCH_PARAMETERS = [:q, :qt, :page, :per_page, :search_field, :sort, :controller, :action, :'facet.page', :'facet.prefix', :'facet.sort', :rows, :format].freeze
299
- ADVANCED_SEARCH_PARAMETERS = [:clause, :op].freeze
298
+ BASIC_SEARCH_PARAMETERS = [:q, :qt, :page, :per_page, :search_field, :sort, :controller, :action, :'facet.page', :'facet.prefix', :'facet.sort', :rows, :format, :view].freeze
299
+ ADVANCED_SEARCH_PARAMETERS = [{ clause: {} }, :op].freeze
300
300
  # List the request parameters that compose the SearchState.
301
301
  # If you use a plugin that adds to the search state, then you can add the parameters
302
302
  # by modifiying this field.
@@ -305,6 +305,13 @@ module Blacklight
305
305
  # @return [Array<Symbol>]
306
306
  property :search_state_fields, default: BASIC_SEARCH_PARAMETERS + ADVANCED_SEARCH_PARAMETERS
307
307
 
308
+ # Have SearchState filter out unknown request parameters
309
+ #
310
+ # @!attribute filter_search_state_fields
311
+ # @since v8.0.0
312
+ # @return [Boolean]
313
+ property :filter_search_state_fields, default: false
314
+
308
315
  ##
309
316
  # Create collections of solr field configurations.
310
317
  # This will create array-like accessor methods for
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
+
3
+ require 'hashdiff'
4
+
2
5
  module Blacklight
3
- module Parameters
6
+ class Parameters
7
+ extend Deprecation
8
+
4
9
  ##
5
10
  # Sanitize the search parameters by removing unnecessary parameters
6
11
  # from the provided parameters.
@@ -9,5 +14,96 @@ module Blacklight
9
14
  params.reject { |_k, v| v.nil? }
10
15
  .except(:action, :controller, :id, :commit, :utf8)
11
16
  end
17
+
18
+ # rubocop:disable Naming/MethodParameterName
19
+ # Merge two Rails strong_params-style permissions into a single list of permitted parameters,
20
+ # deep-merging complex values as needed.
21
+ # @param [Array<Symbol, Hash>] a
22
+ # @param [Array<Symbol, Hash>] b
23
+ # @return [Array<Symbol, Hash>]
24
+ def self.deep_merge_permitted_params(a, b)
25
+ a = [a] if a.is_a? Hash
26
+ b = [b] if b.is_a? Hash
27
+
28
+ complex_params_from_a, scalar_params_from_a = a.flatten.uniq.partition { |x| x.is_a? Hash }
29
+ complex_params_from_a = complex_params_from_a.inject({}) { |tmp, h| _deep_merge_permitted_param_hashes(h, tmp) }
30
+ complex_params_from_b, scalar_params_from_b = b.flatten.uniq.partition { |x| x.is_a? Hash }
31
+ complex_params_from_b = complex_params_from_b.inject({}) { |tmp, h| _deep_merge_permitted_param_hashes(h, tmp) }
32
+
33
+ (scalar_params_from_a + scalar_params_from_b + [_deep_merge_permitted_param_hashes(complex_params_from_a, complex_params_from_b)]).reject(&:blank?).uniq
34
+ end
35
+
36
+ private_class_method def self._deep_merge_permitted_param_hashes(h1, h2)
37
+ h1.merge(h2) do |_key, old_value, new_value|
38
+ if (old_value.is_a?(Hash) && old_value.empty?) || (new_value.is_a?(Hash) && new_value.empty?)
39
+ {}
40
+ elsif old_value.is_a?(Hash) && new_value.is_a?(Hash)
41
+ _deep_merge_permitted_param_hashes(old_value, new_value)
42
+ elsif old_value.is_a?(Array) || new_value.is_a?(Array)
43
+ deep_merge_permitted_params(old_value, new_value)
44
+ else
45
+ new_value
46
+ end
47
+ end
48
+ end
49
+ # rubocop:enable Naming/MethodParameterName
50
+
51
+ attr_reader :params, :search_state
52
+
53
+ delegate :blacklight_config, :filter_fields, to: :search_state
54
+
55
+ def initialize(params, search_state)
56
+ @params = params.is_a?(Hash) ? params.with_indifferent_access : params
57
+ @search_state = search_state
58
+ end
59
+
60
+ # @param [Hash] params with unknown structure (not declared in the blacklight config or filters) stripped out
61
+ def permit_search_params
62
+ # if the parameters were generated internally, we can (probably) trust that they're fine
63
+ return params unless params.is_a?(ActionController::Parameters)
64
+
65
+ # if the parameters were permitted already, we should be able to trust them
66
+ return params if params.permitted?
67
+
68
+ permitted_params = filter_fields.inject(blacklight_config.search_state_fields) do |allowlist, filter|
69
+ Blacklight::Parameters.deep_merge_permitted_params(allowlist, filter.permitted_params)
70
+ end
71
+
72
+ deep_unmangle_params!(params, permitted_params)
73
+
74
+ if blacklight_config.filter_search_state_fields
75
+ params.permit(*permitted_params)
76
+ else
77
+ warn_about_deprecated_parameter_handling(params, permitted_params)
78
+ params.deep_dup.permit!
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def warn_about_deprecated_parameter_handling(params, permitted_params)
85
+ diff = Hashdiff.diff(params.to_unsafe_h, params.permit(*permitted_params).to_h)
86
+ return if diff.empty?
87
+
88
+ Deprecation.warn(Blacklight::Parameters, "Blacklight 8 will filter out non-search parameter, including: #{diff.map { |_op, key, *| key }.to_sentence}")
89
+ end
90
+
91
+ # Facebook's crawler turns array query parameters into a hash with numeric keys. Once we know
92
+ # the expected parameter structure, we can unmangle those parameters to match our expected values.
93
+ def deep_unmangle_params!(params, permitted_params)
94
+ permitted_params.select { |p| p.is_a?(Hash) }.each do |permission|
95
+ permission.each do |key, permitted_value|
96
+ if params[key].is_a?(ActionController::Parameters) && permitted_value.is_a?(Hash)
97
+ deep_unmangle_params!(params[key], [permitted_value])
98
+ elsif permitted_value.is_a?(Array) && permitted_value.empty?
99
+ if params[key].is_a?(ActionController::Parameters) && params[key]&.keys&.all? { |k| k.to_s =~ /\A\d+\z/ }
100
+ params[key] = params[key].values
101
+ elsif params[key].is_a?(String)
102
+ params[key] = Array(params[key])
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
12
108
  end
13
109
  end
@@ -27,7 +27,8 @@ module Blacklight
27
27
  end
28
28
 
29
29
  @blacklight_params = {}
30
- @search_state = Blacklight::SearchState.new(@blacklight_params, @scope&.blacklight_config, @scope)
30
+ search_state_class = @scope&.search_state_class || Blacklight::SearchState
31
+ @search_state = search_state_class.new(@blacklight_params, @scope&.blacklight_config, @scope)
31
32
  @additional_filters = {}
32
33
  @merged_params = {}
33
34
  @reverse_merged_params = {}
@@ -48,7 +49,7 @@ module Blacklight
48
49
  Deprecation.warn(Blacklight::SearchBuilder, "SearchBuilder#where must be called with a hash, received #{conditions.inspect}.") unless conditions.is_a? Hash
49
50
  params_will_change!
50
51
  @search_state = @search_state.reset(@search_state.params.merge(q: conditions))
51
- @blacklight_params = @search_state.params.dup
52
+ @blacklight_params = @search_state.params
52
53
  @additional_filters = conditions
53
54
  self
54
55
  end
@@ -159,12 +159,18 @@ module Blacklight
159
159
  end
160
160
  end
161
161
 
162
- def needs_normalization?(value_params)
163
- value_params&.is_a?(Hash) && value_params != Blacklight::SearchState::FilterField::MISSING
164
- end
165
-
166
- def normalize(value_params)
167
- needs_normalization?(value_params) ? value_params.values : value_params
162
+ def permitted_params
163
+ if config.pivot
164
+ {
165
+ filters_key => config.pivot.each_with_object({}) { |key, filter| filter.merge(key => [], "-#{key}" => []) },
166
+ inclusive_filters_key => config.pivot.each_with_object({}) { |key, filter| filter.merge(key => []) }
167
+ }
168
+ else
169
+ {
170
+ filters_key => { config.key => [], "-#{config.key}" => [] },
171
+ inclusive_filters_key => { config.key => [] }
172
+ }
173
+ end
168
174
  end
169
175
 
170
176
  private
@@ -19,71 +19,17 @@ module Blacklight
19
19
 
20
20
  delegate :facet_configuration_for_field, to: :blacklight_config
21
21
 
22
- def self.modifiable_params(params)
23
- if params.respond_to?(:to_unsafe_h)
24
- # This is the typical (not-ActionView::TestCase) code path.
25
- params = params.to_unsafe_h
26
- # In Rails 5 to_unsafe_h returns a HashWithIndifferentAccess, in Rails 4 it returns Hash
27
- params = params.with_indifferent_access if params.instance_of? Hash
28
- elsif params.is_a? Hash
29
- # This is an ActionView::TestCase workaround for Rails 4.2.
30
- params = params.dup.with_indifferent_access
31
- else
32
- params = params.dup.to_h.with_indifferent_access
33
- end
34
- params
35
- end
36
-
37
22
  # @param [ActionController::Parameters] params
38
23
  # @param [Blacklight::Config] blacklight_config
39
24
  # @param [ApplicationController] controller used for the routing helpers
40
25
  def initialize(params, blacklight_config, controller = nil)
41
26
  @blacklight_config = blacklight_config
42
27
  @controller = controller
43
- @params = SearchState.modifiable_params(params)
44
- normalize_params! if needs_normalization?
45
- end
46
-
47
- def needs_normalization?
48
- return false if params.blank?
49
- return true if (params.keys.map(&:to_s) - permitted_fields.map(&:to_s)).present?
50
-
51
- !!filters.detect { |filter| filter.values.detect { |value| filter.needs_normalization?(value) } }
52
- end
53
-
54
- def normalize_params!
55
- @params = normalize_params
56
- end
57
-
58
- def normalize_params
59
- return params unless needs_normalization?
60
-
61
- base_params = params.slice(*blacklight_config.search_state_fields)
62
- normal_state = blacklight_config.facet_fields.each_value.inject(reset(base_params)) do |working_state, filter_key|
63
- f = filter(filter_key)
64
- next working_state unless f.any?
65
-
66
- filter_values = f.values(except: [:inclusive_filters]).inject([]) do |memo, filter_value|
67
- # flatten arrays that had been mangled into integer-indexed hashes
68
- memo.concat([f.normalize(filter_value)].flatten)
69
- end
70
- filter_values = f.values(except: [:filters, :missing]).inject(filter_values) do |memo, filter_value|
71
- memo << f.normalize(filter_value)
72
- end
73
- filter_values.inject(working_state) do |memo, filter_value|
74
- memo.filter(filter_key).add(filter_value)
75
- end
76
- end
77
- normal_state.params
78
- end
79
-
80
- def permitted_fields
81
- filter_keys = filter_fields.inject(Set.new) { |memo, filter| memo.merge [filter.filters_key, filter.inclusive_filters_key] }
82
- blacklight_config.search_state_fields + filter_keys.subtract([nil, '']).to_a
28
+ @params = Blacklight::Parameters.new(params, self).permit_search_params.to_h.with_indifferent_access
83
29
  end
84
30
 
85
31
  def to_hash
86
- @params.deep_dup
32
+ params.deep_dup
87
33
  end
88
34
  alias to_h to_hash
89
35
 
@@ -132,7 +78,7 @@ module Blacklight
132
78
 
133
79
  # @return [Blacklight::SearchState]
134
80
  def reset(params = nil)
135
- self.class.new(params || ActionController::Parameters.new, blacklight_config, controller)
81
+ self.class.new(params || {}, blacklight_config, controller)
136
82
  end
137
83
 
138
84
  # @return [Blacklight::SearchState]
@@ -162,6 +108,10 @@ module Blacklight
162
108
  p
163
109
  end
164
110
 
111
+ def filter_fields
112
+ blacklight_config.facet_fields.each_value.map { |value| filter(value) }
113
+ end
114
+
165
115
  def filters
166
116
  @filters ||= filter_fields.select(&:any?)
167
117
  end
@@ -192,7 +142,7 @@ module Blacklight
192
142
  # catalog/index with their new facet choice.
193
143
  def add_facet_params_and_redirect(field, item)
194
144
  new_params = Deprecation.silence(self.class) do
195
- add_facet_params(field, item)
145
+ add_facet_params(field, item).to_h.with_indifferent_access
196
146
  end
197
147
 
198
148
  # Delete any request params from facet-specific action, needed
@@ -229,7 +179,7 @@ module Blacklight
229
179
  # @yield [params] The merged parameters hash before being sanitized
230
180
  def params_for_search(params_to_merge = {})
231
181
  # params hash we'll return
232
- my_params = params.dup.merge(self.class.new(params_to_merge, blacklight_config, controller))
182
+ my_params = to_h.merge(self.class.new(params_to_merge, blacklight_config, controller))
233
183
 
234
184
  if block_given?
235
185
  yield my_params
@@ -297,11 +247,7 @@ module Blacklight
297
247
  # and need to be reset when e.g. constraints change
298
248
  # @return [ActionController::Parameters]
299
249
  def reset_search_params
300
- Parameters.sanitize(params).except(:page, :counter)
301
- end
302
-
303
- def filter_fields
304
- blacklight_config.facet_fields.each_value.map { |value| filter(value) }
250
+ Parameters.sanitize(to_h).except(:page, :counter)
305
251
  end
306
252
  end
307
253
  end
@@ -24,7 +24,8 @@ RSpec.describe BookmarksController do
24
24
  allow(@controller).to receive_message_chain(:current_or_guest_user, :existing_bookmark_for).and_return(false)
25
25
  allow(@controller).to receive_message_chain(:current_or_guest_user, :persisted?).and_return(true)
26
26
  allow(@controller).to receive_message_chain(:current_or_guest_user, :bookmarks, :where, :exists?).and_return(false)
27
- allow(@controller).to receive_message_chain(:current_or_guest_user, :bookmarks, :create).and_return(false)
27
+ allow(@controller).to receive_message_chain(:current_or_guest_user, :bookmarks, :create!).and_raise(ActiveRecord::RecordInvalid)
28
+ allow(@controller).to receive_message_chain(:current_or_guest_user, :errors, :full_messages).and_return([1])
28
29
  put :update, xhr: true, params: { id: 'iamabooboo', format: :js }
29
30
  expect(response.code).to eq "500"
30
31
  end
@@ -199,7 +199,7 @@ RSpec.describe Blacklight::FacetsHelperBehavior do
199
199
 
200
200
  it "lets you override the rendered partial for pivot facets" do
201
201
  mock_facet = double(name: 'component_field')
202
- expect(helper).to receive(:render).with(an_instance_of(Blacklight::FacetFieldListComponent))
202
+ expect(helper).to receive(:render).with(an_instance_of(Blacklight::FacetComponent))
203
203
  helper.render_facet_limit(mock_facet)
204
204
  end
205
205
 
@@ -27,7 +27,7 @@ RSpec.describe Blacklight::RenderConstraintsHelperBehavior do
27
27
  let(:params) { ActionController::Parameters.new(q: 'foobar', f: { type: 'journal' }) }
28
28
 
29
29
  it "has a link relative to the current url" do
30
- expect(subject).to have_link 'Remove constraint', href: '/catalog?f%5Btype%5D=journal'
30
+ expect(subject).to have_link 'Remove constraint', href: '/catalog?f%5Btype%5D%5B%5D=journal'
31
31
  end
32
32
  end
33
33
 
@@ -54,6 +54,14 @@ RSpec.describe Blacklight::RenderConstraintsHelperBehavior do
54
54
  expect(subject).to have_link "Remove constraint Item Type: journal", href: "/catalog?q=biz"
55
55
  end
56
56
  end
57
+
58
+ context 'with multivalued facets' do
59
+ subject { helper.render_filter_element('type', [%w[journal book]], path) }
60
+
61
+ it "handles such values gracefully" do
62
+ expect(subject).to have_link "Remove constraint Item Type: journal OR book", href: "/catalog?q=biz"
63
+ end
64
+ end
57
65
  end
58
66
 
59
67
  describe "#render_constraints_filters" do
@@ -21,4 +21,91 @@ RSpec.describe Blacklight::Parameters do
21
21
  end
22
22
  end
23
23
  end
24
+
25
+ describe '.deep_merge_permitted_params' do
26
+ it 'merges scalar values' do
27
+ expect(described_class.deep_merge_permitted_params([:a], [:b])).to eq [:a, :b]
28
+ end
29
+
30
+ it 'appends complex values' do
31
+ expect(described_class.deep_merge_permitted_params([:a], { b: [] })).to eq [:a, { b: [] }]
32
+ end
33
+
34
+ it 'merges lists of scalar values' do
35
+ expect(described_class.deep_merge_permitted_params({ f: [:a, :b] }, { f: [:b, :c] })).to eq [{ f: [:a, :b, :c] }]
36
+ end
37
+
38
+ it 'merges complex value data structures' do
39
+ expect(described_class.deep_merge_permitted_params([{ f: { field1: [] } }], { f: { field2: [] } })).to eq [{ f: { field1: [], field2: [] } }]
40
+ end
41
+
42
+ it 'takes the most permissive value' do
43
+ expect(described_class.deep_merge_permitted_params([{ f: {} }], { f: { field2: [] } })).to eq [{ f: {} }]
44
+ expect(described_class.deep_merge_permitted_params([{ f: {} }], { f: [:some_value] })).to eq [{ f: {} }]
45
+ end
46
+ end
47
+
48
+ describe '#permit_search_params' do
49
+ subject(:params) { described_class.new(query_params, search_state) }
50
+
51
+ let(:query_params) { ActionController::Parameters.new(a: 1, b: 2, c: []) }
52
+ let(:search_state) { Blacklight::SearchState.new(query_params, blacklight_config) }
53
+ let(:blacklight_config) { Blacklight::Configuration.new }
54
+
55
+ context 'with facebooks badly mangled query parameters' do
56
+ let(:query_params) do
57
+ ActionController::Parameters.new(
58
+ f: { field: { '0': 'first', '1': 'second' } },
59
+ f_inclusive: { field: { '0': 'first', '1': 'second' } }
60
+ )
61
+ end
62
+
63
+ before do
64
+ blacklight_config.add_facet_field 'field'
65
+ end
66
+
67
+ it 'normalizes the facets to the expected format' do
68
+ expect(params.permit_search_params.to_h.with_indifferent_access).to include f: { field: %w[first second] }, f_inclusive: { field: %w[first second] }
69
+ end
70
+ end
71
+
72
+ context 'with filter_search_state_fields set to false' do
73
+ let(:blacklight_config) { Blacklight::Configuration.new(filter_search_state_fields: false) }
74
+
75
+ it 'allows all params, but warns about the behavior' do
76
+ allow(Deprecation).to receive(:warn)
77
+ expect(params.permit_search_params.to_h.with_indifferent_access).to include(a: 1, b: 2, c: [])
78
+
79
+ expect(Deprecation).to have_received(:warn).with(described_class, /including: a, b, and c/).at_least(:once)
80
+ end
81
+ end
82
+
83
+ context 'with filter_search_state_fields set to true' do
84
+ let(:blacklight_config) { Blacklight::Configuration.new(filter_search_state_fields: true) }
85
+
86
+ it 'rejects unknown params' do
87
+ expect(params.permit_search_params.to_h).to be_empty
88
+ end
89
+
90
+ context 'with some search parameters' do
91
+ let(:query_params) { ActionController::Parameters.new(q: 'abc', page: 5, f: { facet_field: %w[a b], unknown_field: ['a'] }) }
92
+
93
+ before do
94
+ blacklight_config.add_facet_field 'facet_field'
95
+ end
96
+
97
+ it 'allows scalar params' do
98
+ expect(params.permit_search_params.to_h.with_indifferent_access).to include(q: 'abc', page: 5)
99
+ end
100
+
101
+ it 'allows facet params' do
102
+ expect(params.permit_search_params.to_h.with_indifferent_access).to include(f: { facet_field: %w[a b] })
103
+ end
104
+
105
+ it 'removes unknown facet fields parameters' do
106
+ expect(params.permit_search_params.to_h.with_indifferent_access[:f]).not_to include(:unknown_field)
107
+ end
108
+ end
109
+ end
110
+ end
24
111
  end
@@ -6,6 +6,7 @@ RSpec.describe Blacklight::SearchState::FilterField do
6
6
  let(:params) { { f: { some_field: %w[1 2], another_field: ['3'] } } }
7
7
  let(:blacklight_config) do
8
8
  Blacklight::Configuration.new.configure do |config|
9
+ config.add_facet_field 'new_field'
9
10
  config.add_facet_field 'another_field', single: true
10
11
  simple_facet_fields.each { |simple_facet_field| config.add_facet_field simple_facet_field }
11
12
  config.search_state_fields = config.search_state_fields + additional_search_fields
@@ -23,14 +24,6 @@ RSpec.describe Blacklight::SearchState::FilterField do
23
24
  expect(new_state.filter('some_field').values).to eq %w[1 2 4]
24
25
  end
25
26
 
26
- it 'creates new parameter as needed' do
27
- filter = search_state.filter('unknown_field')
28
- new_state = filter.add('4')
29
-
30
- expect(new_state.filter('unknown_field').values).to eq %w[4]
31
- expect(new_state.params[:f]).to include(:unknown_field)
32
- end
33
-
34
27
  context 'without any parameters in the url' do
35
28
  let(:params) { {} }
36
29
 
@@ -205,10 +198,4 @@ RSpec.describe Blacklight::SearchState::FilterField do
205
198
  expect(search_state.filter('some_field').include?(OpenStruct.new(value: '1'))).to eq true
206
199
  end
207
200
  end
208
-
209
- describe '#needs_normalization?' do
210
- it 'returns false for Blacklight::SearchState::FilterField::MISSING' do
211
- expect(search_state.filter('some_field').needs_normalization?(Blacklight::SearchState::FilterField::MISSING)).to be false
212
- end
213
- end
214
201
  end
@@ -54,19 +54,6 @@ RSpec.describe Blacklight::SearchState do
54
54
  end
55
55
  end
56
56
 
57
- context 'with facebooks badly mangled query parameters' do
58
- let(:simple_facet_fields) { [:field] }
59
- let(:params) do
60
- { f: { field: { '0': 'first', '1': 'second' } },
61
- f_inclusive: { field: { '0': 'first', '1': 'second' } } }
62
- end
63
-
64
- it 'normalizes the facets to the expected format' do
65
- expect(search_state.to_h).to include f: { field: %w[first second] }
66
- expect(search_state.to_h).to include f_inclusive: { field: %w[first second] }
67
- end
68
- end
69
-
70
57
  context 'deleting item from to_h' do
71
58
  let(:additional_search_fields) { [:q_1] }
72
59
  let(:params) { { q: 'foo', q_1: 'bar' } }
@@ -81,7 +68,7 @@ RSpec.describe Blacklight::SearchState do
81
68
  end
82
69
 
83
70
  context 'deleting deep item from to_h' do
84
- let(:additional_search_fields) { [:foo] }
71
+ let(:additional_search_fields) { [{ foo: {} }] }
85
72
  let(:params) { { foo: { bar: [] } } }
86
73
 
87
74
  it 'does not mutate search_state to deep mutate search_state.to_h' do
@@ -5,7 +5,7 @@ RSpec.describe Blacklight::SearchBuilder, api: true do
5
5
 
6
6
  let(:processor_chain) { [] }
7
7
  let(:blacklight_config) { Blacklight::Configuration.new }
8
- let(:scope) { double blacklight_config: blacklight_config }
8
+ let(:scope) { double blacklight_config: blacklight_config, search_state_class: nil }
9
9
 
10
10
  context "with default processor chain" do
11
11
  subject { described_class.new scope }
@@ -15,6 +15,15 @@ RSpec.describe Blacklight::SearchBuilder, api: true do
15
15
  end
16
16
  end
17
17
 
18
+ context "with scope search_state_class" do
19
+ let(:state_class) { Class.new(Blacklight::SearchState) }
20
+ let(:scope) { double blacklight_config: blacklight_config, search_state_class: state_class }
21
+
22
+ it "uses the class-level default_processor_chain" do
23
+ expect(subject.search_state).to be_a state_class
24
+ end
25
+ end
26
+
18
27
  describe "#with" do
19
28
  it "sets the blacklight params" do
20
29
  params = {}
@@ -216,7 +216,9 @@ RSpec.describe Blacklight::Solr::SearchBuilderBehavior, api: true do
216
216
  expect(subject["spellcheck.q"]).to be_blank
217
217
 
218
218
  single_facet.each_value do |value|
219
- expect(subject[:fq]).to include("{!term f=#{single_facet.keys[0]}}#{value}")
219
+ Array(value).each do |v|
220
+ expect(subject[:fq]).to include("{!term f=#{single_facet.keys[0]}}#{v}")
221
+ end
220
222
  end
221
223
  end
222
224
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: blacklight
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.25.1
4
+ version: 7.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Rochkind
@@ -17,7 +17,7 @@ authors:
17
17
  autorequire:
18
18
  bindir: exe
19
19
  cert_chain: []
20
- date: 2022-05-05 00:00:00.000000000 Z
20
+ date: 2022-06-20 00:00:00.000000000 Z
21
21
  dependencies:
22
22
  - !ruby/object:Gem::Dependency
23
23
  name: rails
@@ -137,6 +137,20 @@ dependencies:
137
137
  - - "~>"
138
138
  - !ruby/object:Gem::Version
139
139
  version: '2.43'
140
+ - !ruby/object:Gem::Dependency
141
+ name: hashdiff
142
+ requirement: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ type: :runtime
148
+ prerelease: false
149
+ version_requirements: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: '0'
140
154
  - !ruby/object:Gem::Dependency
141
155
  name: rsolr
142
156
  requirement: !ruby/object:Gem::Requirement
@@ -439,6 +453,7 @@ files:
439
453
  - app/components/blacklight/document_metadata_component.rb
440
454
  - app/components/blacklight/document_title_component.html.erb
441
455
  - app/components/blacklight/document_title_component.rb
456
+ - app/components/blacklight/facet_component.rb
442
457
  - app/components/blacklight/facet_field_checkboxes_component.html.erb
443
458
  - app/components/blacklight/facet_field_checkboxes_component.rb
444
459
  - app/components/blacklight/facet_field_component.html.erb
@@ -932,7 +947,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
932
947
  - !ruby/object:Gem::Version
933
948
  version: '0'
934
949
  requirements: []
935
- rubygems_version: 3.1.6
950
+ rubygems_version: 3.2.32
936
951
  signing_key:
937
952
  specification_version: 4
938
953
  summary: Blacklight provides a discovery interface for any Solr (http://lucene.apache.org/solr)