Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Porting essential blacklight range limit functionality #3098

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 %>

<ul class="facet-values list-unstyled">
<%= render facet_items %>
</ul>
<% end %>
<% end %>
33 changes: 33 additions & 0 deletions app/components/blacklight/facet_field_list_range_component.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 %>

<div class="input-group input-group-sm mb-3 flex-nowrap range-limit-input-group">
<%= render_range_input(:start, start_label) %>
<%= render_range_input(:end, end_label) %>
<div class="input-group-append visually-hidden">
<%= submit_tag t('blacklight.search.facets.range.form.submit'), class: 'submit btn btn-secondary', name: nil %>
</div>
<%= submit_tag t('blacklight.search.facets.range.form.submit'), class: "submit btn btn-secondary sr-only", "aria-hidden": "true", name: nil %>
</div>
<% end %>
59 changes: 59 additions & 0 deletions app/components/blacklight/facet_field_list_range_form_component.rb
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions app/presenters/blacklight/facet_field_range_presenter.rb
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions app/presenters/blacklight/facet_item_range_presenter.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions config/locales/blacklight.ar.yml
Original file line number Diff line number Diff line change
@@ -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:
7 changes: 7 additions & 0 deletions config/locales/blacklight.de.yml
Original file line number Diff line number Diff line change
@@ -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:
7 changes: 7 additions & 0 deletions config/locales/blacklight.en.yml
Original file line number Diff line number Diff line change
@@ -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:
7 changes: 7 additions & 0 deletions config/locales/blacklight.it.yml
Original file line number Diff line number Diff line change
@@ -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:
9 changes: 9 additions & 0 deletions lib/blacklight/configuration/facet_field.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions lib/blacklight/search_state.rb
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions lib/blacklight/search_state/range_filter_field.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
89 changes: 89 additions & 0 deletions spec/lib/blacklight/search_state/range_filter_field_spec.rb
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions spec/presenters/blacklight/facet_field_range_presenter_spec.rb
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions spec/presenters/blacklight/facet_item_range_presenter_spec.rb
Original file line number Diff line number Diff line change
@@ -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