diff --git a/app/components/blacklight/facet_field_list_range_component.html.erb b/app/components/blacklight/facet_field_list_range_component.html.erb
new file mode 100644
index 0000000000..e713728a04
--- /dev/null
+++ b/app/components/blacklight/facet_field_list_range_component.html.erb
@@ -0,0 +1,17 @@
+<%= render(@layout.new(facet_field: @facet_field)) do |component| %>
+ <% component.with_label do %>
+ <%= @facet_field.label %>
+ <% end %>
+
+ <% component.with_body do %>
+ <%# Don't display form if the missing facet is selected. Otherwise provide
+ the form as an easy way for users to updated the range. %>
+ <% unless @facet_field.missing_selected? %>
+ <%= render Blacklight::FacetFieldListRangeFormComponent.new(facet_field: @facet_field) %>
+ <% end %>
+
+
+ <%= render facet_items %>
+
+ <% end %>
+<% end %>
\ No newline at end of file
diff --git a/app/components/blacklight/facet_field_list_range_component.rb b/app/components/blacklight/facet_field_list_range_component.rb
new file mode 100644
index 0000000000..e7d89ba257
--- /dev/null
+++ b/app/components/blacklight/facet_field_list_range_component.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Blacklight
+ class FacetFieldListRangeComponent < Blacklight::Component
+ # @param [Blacklight::FacetFieldRangePresenter] facet_field
+ def initialize(facet_field:, layout: nil)
+ @facet_field = facet_field
+ @layout = layout == false ? FacetFieldNoLayoutComponent : Blacklight::FacetFieldComponent
+ end
+
+ def facet_items(wrapping_element: :li, **item_args)
+ facet_item_component_class.with_collection(facet_item_presenters, wrapping_element: wrapping_element, **item_args)
+ end
+
+ def facet_item_presenters
+ @facet_field.paginator.items.map do |item|
+ facet_item_presenter(item)
+ end
+ end
+
+ def facet_item_presenter(facet_item, deprecated_facet_config = nil, facet_field = nil)
+ (deprecated_facet_config || facet_config).item_presenter.new(facet_item, deprecated_facet_config || facet_config, helpers, facet_field || @facet_field.key)
+ end
+
+ def facet_item_component_class(deprecated_facet_config = nil)
+ (deprecated_facet_config || facet_config).item_component
+ end
+
+ def facet_config
+ @facet_field.facet_field
+ end
+ end
+end
diff --git a/app/components/blacklight/facet_field_list_range_form_component.html.erb b/app/components/blacklight/facet_field_list_range_form_component.html.erb
new file mode 100644
index 0000000000..358168de23
--- /dev/null
+++ b/app/components/blacklight/facet_field_list_range_form_component.html.erb
@@ -0,0 +1,12 @@
+<%= form_tag search_action_path, method: :get, class: ['range_limit subsection form-inline', "range_#{@facet_field.key} d-flex justify-content-center"].join(' ') do %>
+ <%= render hidden_search_state %>
+
+
+<% end %>
\ No newline at end of file
diff --git a/app/components/blacklight/facet_field_list_range_form_component.rb b/app/components/blacklight/facet_field_list_range_form_component.rb
new file mode 100644
index 0000000000..ceb7360384
--- /dev/null
+++ b/app/components/blacklight/facet_field_list_range_form_component.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module Blacklight
+ class FacetFieldListRangeFormComponent < Blacklight::Component
+ delegate :search_action_path, to: :helpers
+
+ def initialize(facet_field:)
+ @facet_field = facet_field
+ end
+
+ def start_label
+ t('blacklight.search.facets.range.form.start_label', field_label: @facet_field.label)
+ end
+
+ def end_label
+ t('blacklight.search.facets.range.form.end_label', field_label: @facet_field.label)
+ end
+
+ def input_options
+ return {} unless range_config
+
+ range_config.fetch(:input, {})
+ .slice(:min, :max, :placeholder, :step)
+ end
+
+ # type is 'start' or 'end'
+ def render_range_input(type, input_label = nil)
+ type = type.to_s
+
+ default = if @facet_field.selected_range.is_a?(Range)
+ case type
+ when 'start' then @facet_field.selected_range.first
+ when 'end' then @facet_field.selected_range.last
+ end
+ end
+ html = number_field_tag("range[#{@facet_field.key}][#{type}]", default, class: "form-control text-center range_#{type}", **input_options)
+ html += label_tag("range[#{@facet_field.key}][#{type}]", input_label, class: 'sr-only visually-hidden') if input_label.present?
+ html
+ end
+
+ private
+
+ ##
+ # the form needs to serialize any search parameters, including other potential range filters,
+ # as hidden fields. The parameters for this component's range filter are serialized as number
+ # inputs, and should not be in the hidden params.
+ # @return [Blacklight::HiddenSearchStateComponent]
+ def hidden_search_state
+ hidden_search_params = @facet_field.search_state.params_for_search.except(:utf8, :page)
+ hidden_search_params[:range]&.except!(@facet_field.key)
+ Blacklight::HiddenSearchStateComponent.new(params: hidden_search_params)
+ end
+
+ def range_config
+ config = @facet_field.facet_field.range
+ config == true ? {} : config
+ end
+ end
+end
diff --git a/app/presenters/blacklight/facet_field_range_presenter.rb b/app/presenters/blacklight/facet_field_range_presenter.rb
new file mode 100644
index 0000000000..e24447ccaf
--- /dev/null
+++ b/app/presenters/blacklight/facet_field_range_presenter.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Blacklight
+ class FacetFieldRangePresenter < Blacklight::FacetFieldPresenter
+ delegate :response, to: :display_facet
+ delegate :blacklight_config, to: :search_state
+
+ # Paginator will return the selected item or if no facet is selected, the [Missing] facet.
+ def paginator
+ return unless display_facet
+
+ @paginator ||= blacklight_config.facet_paginator_class.new(
+ Array.wrap(selected_item || display_facet.items.select(&:missing)),
+ sort: display_facet.sort,
+ offset: display_facet.offset,
+ prefix: display_facet.prefix,
+ limit: facet_limit
+ )
+ end
+
+ def selected_range
+ values&.first
+ end
+
+ # Wraps selected range in Blacklight::Solr::Response::Facets::FacetItem object.
+ #
+ # @return [Blacklight::Solr::Response::Facets::FacetItem] if range is selected
+ # @return [NilClass] if no range is selected
+ def selected_item
+ return unless selected_range
+
+ Blacklight::Solr::Response::Facets::FacetItem.new(value: selected_range, hits: response.total)
+ end
+
+ # Returns true if [Missing] facet is selected.
+ def missing_selected?
+ selected_range == Blacklight::SearchState::FilterField::MISSING
+ end
+ end
+end
diff --git a/app/presenters/blacklight/facet_item_range_presenter.rb b/app/presenters/blacklight/facet_item_range_presenter.rb
new file mode 100644
index 0000000000..5492858d1c
--- /dev/null
+++ b/app/presenters/blacklight/facet_item_range_presenter.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Blacklight
+ # Override the default item presenter to provide custom labels for
+ # range data.
+ class FacetItemRangePresenter < Blacklight::FacetItemPresenter
+ # Overriding method to generate a more descriptive label
+ def label
+ label_for_range || super
+ end
+
+ private
+
+ def label_for_range
+ return unless value.is_a? Range
+
+ view_context.t(range_limit_label_key, start: value.first, end: value.last)
+ end
+
+ def range_limit_label_key
+ if value.first == value.last
+ 'blacklight.search.facets.range.single_value'
+ else
+ 'blacklight.search.facets.range.range_value'
+ end
+ end
+ end
+end
diff --git a/config/locales/blacklight.ar.yml b/config/locales/blacklight.ar.yml
index d12cd2bf13..1999da71f2 100644
--- a/config/locales/blacklight.ar.yml
+++ b/config/locales/blacklight.ar.yml
@@ -113,6 +113,13 @@ ar:
count: ترتيب رقمي
index: ترتيب أبجدي
title: تحديد نطاق البحث
+ range:
+ form:
+ start_label: "%{field_label} بداية المدة"
+ end_label: "%{field_label} نهاية المدة"
+ submit: 'تطبيق'
+ single_value: '%{begin}'
+ range_value: '%{begin} الى %{end}'
filters:
label: "%{label}:"
remove:
diff --git a/config/locales/blacklight.de.yml b/config/locales/blacklight.de.yml
index dc2c9ba203..114346d36f 100644
--- a/config/locales/blacklight.de.yml
+++ b/config/locales/blacklight.de.yml
@@ -104,6 +104,13 @@ de:
count: Numerisch ordnen
index: A-Z Ordnen
title: Suche beschränken
+ range:
+ form:
+ start_label: "%{field_label} Bereichsanfang"
+ end_label: "%{field_label} Bereichsende"
+ submit: 'Anwenden'
+ single_value: '%{begin}'
+ range_value: '%{begin} bis %{end}'
filters:
label: "%{label}:"
remove:
diff --git a/config/locales/blacklight.en.yml b/config/locales/blacklight.en.yml
index f091aabace..bd1b8d22d8 100644
--- a/config/locales/blacklight.en.yml
+++ b/config/locales/blacklight.en.yml
@@ -194,6 +194,13 @@ en:
toggle: Toggle facets
open: Show facets
close: Hide facets
+ range:
+ form:
+ start_label: "%{field_label} range start"
+ end_label: "%{field_label} range end"
+ submit: 'Apply'
+ single_value: "%{start}"
+ range_value: "%{start} to %{end}"
group:
more: 'more »'
filters:
diff --git a/config/locales/blacklight.it.yml b/config/locales/blacklight.it.yml
index 672556a8eb..b6d99df7ad 100644
--- a/config/locales/blacklight.it.yml
+++ b/config/locales/blacklight.it.yml
@@ -104,6 +104,13 @@ it:
count: Ordina per numero
index: Ordina A-Z
title: Affina la ricerca
+ range:
+ form:
+ start_label: "%{field_label} da"
+ end_label: "%{field_label} a"
+ submit: 'Invia'
+ single_value: '%{begin}'
+ range_value: '%{begin} a %{end}'
filters:
label: "%{label}:"
remove:
diff --git a/lib/blacklight/configuration/facet_field.rb b/lib/blacklight/configuration/facet_field.rb
index 63a4033311..ee7242878e 100644
--- a/lib/blacklight/configuration/facet_field.rb
+++ b/lib/blacklight/configuration/facet_field.rb
@@ -70,6 +70,7 @@ def normalize! blacklight_config = nil
query.stringify_keys! if query
normalize_pivot_config! if pivot
+ normalize_range_config! if range
self.collapse = true if collapse.nil?
self.show = true if show.nil?
self.if = show if self.if.nil?
@@ -98,5 +99,13 @@ def normalize_pivot_config!
self.filter_class ||= Blacklight::SearchState::PivotFilterField
self.filter_query_builder ||= Blacklight::SearchState::PivotFilterField::QueryBuilder
end
+
+ def normalize_range_config!
+ self.presenter ||= Blacklight::FacetFieldRangePresenter
+ self.item_presenter ||= Blacklight::FacetItemRangePresenter
+ self.component ||= Blacklight::FacetFieldListRangeComponent
+ self.filter_class ||= Blacklight::SearchState::RangeFilterField
+ self.solr_params = (solr_params || {}).merge({ 'facet.missing' => true })
+ end
end
end
diff --git a/lib/blacklight/search_state.rb b/lib/blacklight/search_state.rb
index 0cf7f1b674..9621b68935 100644
--- a/lib/blacklight/search_state.rb
+++ b/lib/blacklight/search_state.rb
@@ -2,6 +2,7 @@
require 'blacklight/search_state/filter_field'
require 'blacklight/search_state/pivot_filter_field'
+require 'blacklight/search_state/range_filter_field'
module Blacklight
# This class encapsulates the search state as represented by the query
diff --git a/lib/blacklight/search_state/range_filter_field.rb b/lib/blacklight/search_state/range_filter_field.rb
new file mode 100644
index 0000000000..597813d452
--- /dev/null
+++ b/lib/blacklight/search_state/range_filter_field.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module Blacklight
+ class SearchState
+ # Modeling access to filter query parameters
+ class RangeFilterField < FilterField
+ # this accessor is unnecessary after Blacklight 7.25.0
+ attr_accessor :filters_key
+
+ def initialize(config, search_state)
+ super
+ @filters_key = :range
+ end
+
+ # @param [String,#value] a filter item to add to the url
+ # @return [Blacklight::SearchState] new state
+ def add(item)
+ new_state = search_state.reset_search
+ params = new_state.params
+ value = as_url_parameter(item)
+
+ if value.is_a? Range
+ param_key = filters_key
+ params[param_key] = (params[param_key] || {}).dup
+ params[param_key][config.key] = { start: value.first, end: value.last }
+ new_state.reset(params)
+ else
+ super
+ end
+ end
+
+ # @param [String,#value] a filter to remove from the url
+ # @return [Blacklight::SearchState] new state
+ def remove(item)
+ new_state = search_state.reset_search
+ params = new_state.params
+ value = as_url_parameter(item)
+
+ if value.is_a? Range
+ param_key = filters_key
+ params[param_key] = (params[param_key] || {}).dup
+ params[param_key]&.delete(config.key)
+ new_state.reset(params)
+ else
+ super
+ end
+ end
+
+ # @return [Array] an array of applied filters
+ def values(except: [])
+ params = search_state.params
+ param_key = filters_key
+
+ range = if params.dig(param_key, config.key).is_a? Range
+ params.dig(param_key, config.key)
+ elsif params.dig(param_key, config.key).is_a? Hash
+ b_bound = params.dig(param_key, config.key, :start).presence
+ e_bound = params.dig(param_key, config.key, :end).presence
+ Range.new(b_bound&.to_i, e_bound&.to_i) if b_bound && e_bound
+ end
+
+ f = except.include?(:filters) ? [] : [range].compact
+ f_missing = [Blacklight::SearchState::FilterField::MISSING] if params.dig(filters_key, "-#{key}")&.any? { |v| v == Blacklight::Engine.config.blacklight.facet_missing_param }
+ f_missing = [] if except.include?(:missing)
+
+ f + (f_missing || [])
+ end
+
+ # @param [String,#value] a filter to remove from the url
+ # @return [Boolean] whether the provided filter is currently applied/selected
+ delegate :include?, to: :values
+
+ # @since Blacklight v7.25.2
+ # normal filter fields demangle when they encounter a hash, which they assume to be a number-indexed map
+ # this filter should allow (expect) hashes if the keys include 'start' or 'end'
+ def permitted_params
+ {
+ filters_key => { config.key => [:start, :end], "-#{config.key}" => [] },
+ inclusive_filters_key => { config.key => [:start, :end] }
+ }
+ end
+ end
+ end
+end
diff --git a/spec/components/blacklight/facet_field_list_range_component_spec.rb b/spec/components/blacklight/facet_field_list_range_component_spec.rb
new file mode 100644
index 0000000000..678a246643
--- /dev/null
+++ b/spec/components/blacklight/facet_field_list_range_component_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Blacklight::FacetFieldListRangeComponent, type: :component do
+ subject(:rendered) do
+ render_inline_to_capybara_node(described_class.new(facet_field: facet_field))
+ end
+
+ let(:facet_field) do
+ instance_double(
+ Blacklight::FacetFieldRangePresenter,
+ paginator: paginator,
+ facet_field: facet_config,
+ key: 'field',
+ label: 'My facet field',
+ active?: false,
+ collapsed?: false,
+ modal_path: nil,
+ selected_range: nil,
+ selected_item: nil,
+ missing_selected?: false,
+ search_state: Blacklight::SearchState.new({}, nil)
+ )
+ end
+
+ let(:facet_config) do
+ Blacklight::Configuration::NullField.new(
+ key: 'field',
+ item_component: Blacklight::FacetItemComponent,
+ item_presenter: Blacklight::FacetItemRangePresenter
+ )
+ end
+
+ let(:paginator) { instance_double(Blacklight::FacetPaginator, items: items) }
+ let(:items) { [] }
+
+ it 'renders into the default facet layout' do
+ expect(rendered).to have_selector('h3', text: 'My facet field')
+ expect(rendered).to have_selector '.facet-content.collapse'
+ end
+
+ it 'renders a form for the range' do
+ expect(rendered).to have_selector('form[action="http://test.host/catalog"][method="get"]')
+ expect(rendered).to have_field('range[field][start]')
+ expect(rendered).to have_field('range[field][end]')
+ end
+
+ it 'does not render the missing link if there are no matching documents' do
+ expect(rendered).not_to have_link '[Missing]'
+ end
+
+ context 'with missing documents' do
+ let(:items) do
+ [
+ Blacklight::Solr::Response::Facets::FacetItem.new(
+ value: Blacklight::SearchState::FilterField::MISSING,
+ hits: 50
+ )
+ ]
+ end
+
+ it 'renders a facet value for the documents that are missing the field data' do
+ expected_facet_query_param = Regexp.new(Regexp.escape({ f: { '-field': ['[* TO *]'] } }.to_param))
+ expect(rendered).to have_link '[Missing]', href: expected_facet_query_param
+ end
+ end
+end
diff --git a/spec/components/blacklight/facet_field_list_range_form_component_spec.rb b/spec/components/blacklight/facet_field_list_range_form_component_spec.rb
new file mode 100644
index 0000000000..8d236cd671
--- /dev/null
+++ b/spec/components/blacklight/facet_field_list_range_form_component_spec.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Blacklight::FacetFieldListRangeFormComponent, type: :component do
+ subject(:rendered) do
+ render_inline_to_capybara_node(described_class.new(facet_field: facet_field))
+ end
+
+ let(:selected_range) { nil }
+ let(:search_params) { { another_field: 'another_value' } }
+
+ let(:facet_field) do
+ instance_double(
+ Blacklight::FacetFieldRangePresenter,
+ paginator: paginator,
+ facet_field: facet_config,
+ key: 'field',
+ label: 'My facet field',
+ active?: false,
+ collapsed?: false,
+ modal_path: nil,
+ selected_range: selected_range,
+ selected_item: nil,
+ missing_selected?: false,
+ search_state: Blacklight::SearchState.new(search_params, nil)
+ )
+ end
+
+ let(:facet_config) do
+ Blacklight::Configuration::NullField.new(
+ key: 'field',
+ item_component: Blacklight::FacetItemComponent,
+ item_presenter: Blacklight::FacetItemRangePresenter
+ )
+ end
+
+ let(:paginator) { instance_double(Blacklight::FacetPaginator, items: items) }
+ let(:items) { [] }
+
+ it 'renders a form with no selected range' do
+ expect(rendered).to have_selector('form[action="http://test.host/catalog"][method="get"]')
+ expect(rendered).to have_field('range[field][start]', type: 'number') { |e| e['value'].blank? }
+ expect(rendered).to have_field('range[field][end]', type: 'number') { |e| e['value'].blank? }
+ expect(rendered).to have_field('another_field', type: 'hidden', with: 'another_value', visible: :hidden)
+ end
+
+ it 'renders submit controls without a name to suppress from formData' do
+ anon_submit = rendered.find('input', visible: true) { |ele| ele[:type] == 'submit' && !ele[:'aria-hidden'] && !ele[:name] }
+ expect(anon_submit).to be_present
+ expect { rendered.find('input') { |ele| ele[:type] == 'submit' && ele[:name] } }.to raise_error(Capybara::ElementNotFound)
+ end
+
+ context 'with range data' do
+ let(:selected_range) { (100..300) }
+ let(:search_params) do
+ {
+ another_field: 'another_value',
+ range: {
+ another_range: { start: 128, end: 1024 },
+ field: { start: selected_range.first, end: selected_range.last }
+ }
+ }
+ end
+
+ it 'renders a form for the selected range' do
+ expect(rendered).to have_selector('form[action="http://test.host/catalog"][method="get"]')
+ expect(rendered).to have_field('range[field][start]', type: 'number', with: selected_range.first)
+ expect(rendered).to have_field('range[field][end]', type: 'number', with: selected_range.last)
+ expect(rendered).to have_field('another_field', type: 'hidden', with: 'another_value', visible: :hidden)
+ expect(rendered).to have_field('range[another_range][start]', type: 'hidden', with: 128, visible: :hidden)
+ expect(rendered).to have_field('range[another_range][end]', type: 'hidden', with: 1024, visible: :hidden)
+ end
+ end
+
+ context 'with configuration options for inputs' do
+ let(:facet_config) do
+ Blacklight::Configuration::NullField.new(
+ key: 'field',
+ range: { input: { placeholder: 'Year', max: 9999 } },
+ item_component: Blacklight::FacetItemComponent,
+ item_presenter: Blacklight::FacetItemRangePresenter
+ )
+ end
+
+ it 'renders inputs with the options provided' do
+ expect(rendered).to have_field('range[field][start]', type: :number) do |e|
+ e['placeholder'] == 'Year' && e['max'] == '9999'
+ end
+ expect(rendered).to have_field('range[field][end]', type: 'number') do |e|
+ e['placeholder'] == 'Year' && e['max'] == '9999'
+ end
+ end
+ end
+end
diff --git a/spec/lib/blacklight/search_state/range_filter_field_spec.rb b/spec/lib/blacklight/search_state/range_filter_field_spec.rb
new file mode 100644
index 0000000000..ece00f2423
--- /dev/null
+++ b/spec/lib/blacklight/search_state/range_filter_field_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Blacklight::SearchState::RangeFilterField do
+ let(:search_state) { Blacklight::SearchState.new(params, blacklight_config, controller) }
+
+ let(:param_values) { {} }
+ let(:params) { ActionController::Parameters.new(param_values) }
+ let(:blacklight_config) do
+ Blacklight::Configuration.new.configure do |config|
+ config.add_facet_field 'some_field', filter_class: described_class
+ config.filter_search_state_fields = true
+ end
+ end
+ let(:controller) { double }
+ let(:filter) { search_state.filter('some_field') }
+
+ describe '#add' do
+ it 'adds a new range parameter' do
+ new_state = filter.add(1999..2099)
+
+ expect(new_state.params.dig(:range, 'some_field')).to include start: 1999, end: 2099
+ end
+ end
+
+ context 'with some existing data' do
+ let(:param_values) { { range: { some_field: { start: '2013', end: '2022' } } } }
+
+ describe '#add' do
+ it 'replaces the existing range' do
+ new_state = filter.add(1999..2099)
+
+ expect(new_state.params.dig(:range, 'some_field')).to include start: 1999, end: 2099
+ end
+ end
+
+ describe '#remove' do
+ it 'removes the existing range' do
+ new_state = filter.remove(2013..2022)
+
+ expect(new_state.params.dig(:range, 'some_field')).to be_blank
+ end
+ end
+
+ describe '#values' do
+ it 'converts the parameters to a Range' do
+ expect(filter.values).to eq [2013..2022]
+ end
+ end
+
+ describe '#include?' do
+ it 'compares the provided value to the parameter values' do
+ expect(filter.include?(2013..2022)).to be true
+ expect(filter.include?(1234..2345)).to be false
+ end
+ end
+
+ describe '#permitted_params' do
+ let(:rails_params) { ActionController::Parameters.new(param_values) }
+ let(:blacklight_params) { Blacklight::Parameters.new(rails_params, search_state) }
+ let(:permitted_params) { blacklight_params.permit_search_params.to_h }
+
+ it 'sanitizes single start/end values as scalars' do
+ expect(permitted_params.dig(:range, 'some_field')).to include 'start' => '2013', 'end' => '2022'
+ end
+ end
+ end
+
+ context 'with empty data' do
+ let(:param_values) { { range: { some_field: { start: '', end: '' } } } }
+
+ describe '#values' do
+ it 'drops the empty range' do
+ expect(filter.values).to be_empty
+ end
+ end
+ end
+
+ context 'with missing data' do
+ let(:param_values) { { range: { '-some_field': ['[* TO *]'] } } }
+
+ describe '#values' do
+ it 'uses the missing special value' do
+ expect(filter.values).to eq [Blacklight::SearchState::FilterField::MISSING]
+ end
+ end
+ end
+end
diff --git a/spec/presenters/blacklight/facet_field_range_presenter_spec.rb b/spec/presenters/blacklight/facet_field_range_presenter_spec.rb
new file mode 100644
index 0000000000..edffc78c2c
--- /dev/null
+++ b/spec/presenters/blacklight/facet_field_range_presenter_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Blacklight::FacetFieldRangePresenter, type: :presenter do
+ subject(:presenter) do
+ described_class.new(facet_field, display_facet, view_context, search_state)
+ end
+
+ let(:facet_field) do
+ Blacklight::Configuration::FacetField.new(
+ key: 'field_key',
+ field: 'some_field',
+ filter_class: Blacklight::SearchState::RangeFilterField
+ )
+ end
+
+ let(:display_facet) do
+ instance_double(Blacklight::Solr::Response::Facets::FacetField, items: items, sort: :index, offset: 0, prefix: nil, response: response)
+ end
+ let(:response) { instance_double(Blacklight::Solr::Response, total: 12) }
+
+ let(:view_context) { controller.view_context }
+ let(:search_state) { Blacklight::SearchState.new(params, blacklight_config, view_context) }
+
+ let(:blacklight_config) do
+ Blacklight::Configuration.new.tap { |x| x.facet_fields['field_key'] = facet_field }
+ end
+
+ let(:params) { {} }
+ let(:items) { [] }
+
+ describe '#paginator' do
+ subject(:paginator) { presenter.paginator }
+
+ context 'when no range is selected' do
+ let(:items) { [Blacklight::Solr::Response::Facets::FacetItem.new(missing: true, value: '[Missing]')] }
+
+ it 'contains [Missing] facet' do
+ expect(paginator.total_count).to be 1
+ expect(paginator.items.first.value).to be '[Missing]'
+ end
+ end
+
+ context 'with a user selected range' do
+ let(:params) { { range: { field_key: { start: 100, end: 250 } } } }
+
+ it 'contains selected facet' do
+ expect(paginator.total_count).to be 1
+ expect(paginator.items.first.value).to eql 100..250
+ end
+ end
+ end
+
+ describe '#missing_selected?' do
+ context 'when missing facet is selected' do
+ let(:params) { { range: { '-field_key' => ['[* TO *]'] } } }
+
+ it 'returns true' do
+ expect(presenter.missing_selected?).to be true
+ end
+ end
+
+ it 'returns false if missing facet not selected' do
+ expect(presenter.missing_selected?).to be false
+ end
+ end
+
+ describe '#selected_range' do
+ it 'returns nil if no range is selected' do
+ expect(presenter.selected_range).to be_nil
+ end
+
+ context 'with a user-selected range' do
+ let(:params) { { range: { field_key: { start: 100, end: 250 } } } }
+
+ it 'returns the selected range' do
+ expect(presenter.selected_range).to eq 100..250
+ end
+ end
+ end
+end
diff --git a/spec/presenters/blacklight/facet_item_range_presenter_spec.rb b/spec/presenters/blacklight/facet_item_range_presenter_spec.rb
new file mode 100644
index 0000000000..9abd7d714a
--- /dev/null
+++ b/spec/presenters/blacklight/facet_item_range_presenter_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Blacklight::FacetItemRangePresenter, type: :presenter do
+ subject(:presenter) do
+ described_class.new(facet_item, facet_config, view_context, facet_field, search_state)
+ end
+
+ let(:facet_item) { instance_double(Blacklight::Solr::Response::Facets::FacetItem) }
+ let(:filter_field) { instance_double(Blacklight::SearchState::FilterField, include?: true) }
+ let(:facet_config) { Blacklight::Configuration::FacetField.new(key: 'key') }
+ let(:facet_field) { instance_double(Blacklight::Solr::Response::Facets::FacetField) }
+ let(:view_context) { controller.view_context }
+ let(:search_state) { instance_double(Blacklight::SearchState, filter: filter_field) }
+
+ describe '#label' do
+ context 'with a single value' do
+ let(:facet_item) { 'blah' }
+
+ it 'uses the normal logic for item values' do
+ expect(presenter.label).to eq 'blah'
+ end
+ end
+
+ context 'with a range' do
+ let(:facet_item) { 1234..2345 }
+
+ it 'translates the range into some nice, human-readable html' do
+ expect(Capybara.string(presenter.label)).to have_text('1234 to 2345')
+ end
+ end
+
+ context 'with a range that has the same start + end values' do
+ let(:facet_item) { 2021..2021 }
+
+ it 'translates the range into some nice, human-readable html' do
+ expect(Capybara.string(presenter.label)).to have_text('2021')
+ end
+ end
+ end
+end