diff --git a/app/components/blacklight/search_context/server_applied_params_component.html.erb b/app/components/blacklight/search_context/server_applied_params_component.html.erb new file mode 100644 index 0000000000..7254cce572 --- /dev/null +++ b/app/components/blacklight/search_context/server_applied_params_component.html.erb @@ -0,0 +1,4 @@ +
+ <%= render 'start_over' %> + <%= link_back_to_catalog class: 'btn btn-outline-secondary' %> +
diff --git a/app/components/blacklight/search_context/server_applied_params_component.rb b/app/components/blacklight/search_context/server_applied_params_component.rb new file mode 100644 index 0000000000..d7783c714d --- /dev/null +++ b/app/components/blacklight/search_context/server_applied_params_component.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Blacklight + module SearchContext + class ServerAppliedParamsComponent < Blacklight::Component + delegate :current_search_session, :link_back_to_catalog, to: :helpers + + def render? + current_search_session + end + end + end +end diff --git a/app/components/blacklight/search_context_component.rb b/app/components/blacklight/search_context_component.rb index e7d8bb59a3..d227e4925f 100644 --- a/app/components/blacklight/search_context_component.rb +++ b/app/components/blacklight/search_context_component.rb @@ -4,13 +4,14 @@ module Blacklight class SearchContextComponent < Blacklight::Component with_collection_parameter :search_context - def initialize(search_context:, search_session:) + def initialize(search_context:, search_session:, current_document: nil) @search_context = search_context @search_session = search_session + @current_document_id = current_document&.id end def render? - @search_context.present? && (@search_context[:prev] || @search_context[:next]) + @search_context.present? && (@search_context[:prev] || @search_context[:next]) && (@current_document_id && @search_session['document_id'] == @current_document_id) end def item_page_entry_info diff --git a/app/controllers/concerns/blacklight/search_context.rb b/app/controllers/concerns/blacklight/search_context.rb index 33955c4952..ccee37d6bf 100644 --- a/app/controllers/concerns/blacklight/search_context.rb +++ b/app/controllers/concerns/blacklight/search_context.rb @@ -17,6 +17,28 @@ def record_search_parameters opts = { only: :index } end end + # GET previous and next document json for the document specified by + # the counter param in current search + def page_links + counter_param = params.delete(:counter) + @page_link_data = {} + if counter_param + index = counter_param.to_i - 1 + response, documents = search_service.previous_and_next_documents_for_search index, search_state.reset_search + if documents.detect(&:present?) + @page_link_data[:prev] = page_links_document_path(documents.first, index) + @page_link_data[:next] = page_links_document_path(documents.last, index + 2) + end + if response&.total && response.total.positive? + @page_link_data[:counterRaw] = counter_param + @page_link_data[:counterDelimited] = helpers.number_with_delimiter(counter_param.to_i) + @page_link_data[:totalRaw] = response.total + @page_link_data[:totalDelimited] = helpers.number_with_delimiter(response.total) + end + end + render json: @page_link_data + end + private # sets up the session[:search] hash if it doesn't already exist @@ -79,6 +101,8 @@ def agent_is_crawler? end def find_or_initialize_search_session_from_params params + return unless blacklight_config.track_search_session_config.storage == 'server' + params_copy = params.reject { |k, v| blacklisted_search_session_params.include?(k.to_sym) || v.blank? } return if params_copy.reject { |k, _v| [:action, :controller].include? k.to_sym }.blank? @@ -109,15 +133,31 @@ def blacklisted_search_session_params # calls setup_previous_document then setup_next_document. # used in the show action for single view pagination. def setup_next_and_previous_documents - if search_session['counter'] && current_search_session - index = search_session['counter'].to_i - 1 - response, documents = search_service.previous_and_next_documents_for_search index, search_state.reset(current_search_session.query_params) + return { counter: params[:counter] } if setup_next_and_previous_on_client? + return nil unless setup_next_and_previous_on_server? - search_session['total'] = response.total - { prev: documents.first, next: documents.last } - end + index = search_session['counter'].to_i - 1 + response, documents = search_service.previous_and_next_documents_for_search index, search_state.reset(current_search_session.query_params) + + search_session['total'] = response.total + { prev: documents.first, next: documents.last } rescue Blacklight::Exceptions::InvalidRequest => e logger&.warn "Unable to setup next and previous documents: #{e}" nil end + + def setup_next_and_previous_on_server? + search_session['counter'] && current_search_session && blacklight_config.track_search_session_config.storage == 'server' + end + + def setup_next_and_previous_on_client? + params[:counter] && blacklight_config.track_search_session_config.storage == 'client' + end + + def page_links_document_path(document, counter) + return nil unless document + return search_state.url_for_document(document, counter: counter) if blacklight_config.view_config(:show).route + + solr_document_path(document, counter: counter) + end end diff --git a/app/helpers/blacklight/url_helper_behavior.rb b/app/helpers/blacklight/url_helper_behavior.rb index 16b5ddb0b0..bbcc9832ba 100644 --- a/app/helpers/blacklight/url_helper_behavior.rb +++ b/app/helpers/blacklight/url_helper_behavior.rb @@ -80,21 +80,24 @@ def link_to_next_document(next_document, classes: 'next', **addl_link_opts) # @example # session_tracking_params(SolrDocument.new(id: 123), 7) # => { data: { context_href: '/catalog/123/track?counter=7&search_id=999' } } - def session_tracking_params document, counter - path = session_tracking_path(document, per_page: params.fetch(:per_page, search_session['per_page']), counter: counter, search_id: current_search_session.try(:id), document_id: document&.id) - - if path.nil? - return {} + def session_tracking_params document, counter, per_page: search_session['per_page'], search_id: current_search_session&.id + path_params = { per_page: params.fetch(:per_page, per_page), counter: counter, search_id: search_id } + if blacklight_config.track_search_session_config.storage == 'server' + path_params[:document_id] = document&.id + path_params[:search_id] = search_id end + path = session_tracking_path(document, path_params) + return {} if path.nil? - { data: { context_href: path, turbo_prefetch: false } } + context_method = blacklight_config.track_search_session_config.storage == 'client' ? 'get' : 'post' + { data: { context_href: path, context_method: context_method, turbo_prefetch: false } } end private :session_tracking_params ## # Get the URL for tracking search sessions across pages using polymorphic routing def session_tracking_path document, params = {} - return if document.nil? || !blacklight_config&.track_search_session + return if document.nil? || !blacklight_config.track_search_session_config.storage if main_app.respond_to?(controller_tracking_method) return main_app.public_send(controller_tracking_method, params.merge(id: document)) @@ -105,6 +108,8 @@ def session_tracking_path document, params = {} end def controller_tracking_method + return blacklight_config.track_search_session_config.url_helper if blacklight_config.track_search_session_config.url_helper + "track_#{controller_name}_path" end diff --git a/app/views/catalog/_show_main_content.html.erb b/app/views/catalog/_show_main_content.html.erb index 8190d7b5d4..b50c6bfacd 100644 --- a/app/views/catalog/_show_main_content.html.erb +++ b/app/views/catalog/_show_main_content.html.erb @@ -1,6 +1,6 @@ -<%= render(Blacklight::SearchContextComponent.new(search_context: @search_context, search_session: search_session)) if search_session['document_id'] == @document.id %> +<%= render blacklight_config.track_search_session_config.item_pagination_component.new(search_context: @search_context, search_session: search_session, current_document: @document) if blacklight_config.track_search_session_config.item_pagination_component %> -<% @page_title = t('blacklight.search.show.title', document_title: Deprecation.silence(Blacklight::BlacklightHelperBehavior) { document_show_html_title }, application_name: application_name).html_safe %> +<% @page_title = t('blacklight.search.show.title', document_title: document_presenter(@document).html_title, application_name: application_name).html_safe %> <% content_for(:head) { render_link_rel_alternates } %> <%= render (blacklight_config.view_config(:show).document_component || Blacklight::DocumentComponent).new(presenter: document_presenter(@document), component: :div, title_component: :h1, show: true) do |component| %> diff --git a/app/views/catalog/index.html.erb b/app/views/catalog/index.html.erb index c0af2dbe12..5dd4f3bf61 100644 --- a/app/views/catalog/index.html.erb +++ b/app/views/catalog/index.html.erb @@ -1,3 +1,6 @@ +<% content_for(:head) do %> + +<% end %> <% content_for(:sidebar) do %> <%= render 'search_sidebar' %> <% end %> diff --git a/app/views/catalog/show.html.erb b/app/views/catalog/show.html.erb index 39533c8543..c09f46956b 100644 --- a/app/views/catalog/show.html.erb +++ b/app/views/catalog/show.html.erb @@ -1,9 +1,4 @@ -<% if current_search_session %> -
- <%= render 'start_over' %> - <%= link_back_to_catalog class: 'btn btn-outline-secondary' %> -
-<% end %> +<%= render blacklight_config.track_search_session_config.applied_params_component.new if blacklight_config.track_search_session_config.applied_params_component %> <%= render 'show_main_content' %> diff --git a/lib/blacklight/configuration.rb b/lib/blacklight/configuration.rb index 970913764d..5361b1a1ba 100644 --- a/lib/blacklight/configuration.rb +++ b/lib/blacklight/configuration.rb @@ -306,6 +306,11 @@ def default_per_page # @return [Boolean] property :track_search_session, default: true + # @!attribute track_search_session_config + # @since v7.35.0 + # @return [Blacklight::Configuration::SessionTrackingConfig] + property :track_search_session_config, default: nil + # @!attribute advanced_search # @since v7.15.0 # @return [#enabled] @@ -618,6 +623,21 @@ def show_fields_for(document_or_display_types) fields.merge(show_fields) end + def track_search_session=(val) + self.track_search_session_config = Blacklight::Configuration::SessionTrackingConfig.new(storage: val ? 'server' : false) + super + end + + def track_search_session_config + return track_search_session if track_search_session.is_a?(Blacklight::Configuration::SessionTrackingConfig) + + stored_config = super + + return stored_config if stored_config + + self.track_search_session_config = Blacklight::Configuration::SessionTrackingConfig.new(storage: track_search_session ? 'server' : false) + end + # @!visibility private def freeze each { |_k, v| v.is_a?(OpenStruct) && v.freeze } diff --git a/lib/blacklight/configuration/session_tracking_config.rb b/lib/blacklight/configuration/session_tracking_config.rb new file mode 100644 index 0000000000..b7d87f7ee1 --- /dev/null +++ b/lib/blacklight/configuration/session_tracking_config.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Blacklight::Configuration + class SessionTrackingConfig < Blacklight::OpenStructWithHashAccess + # @!attribute storage + # @return [String, FalseClass] 'server': use server-side tracking; 'client': delegate search tracking and prev/next navigation to client + # @!attribute applied_params_component + # @return [Class] component class used to render a facet group + # @!attribute item_pagination_component + # @return [Class] component class used to render the constraints + + def initialize(property_hash = {}) + super({ storage: 'server' }.merge(property_hash)) + end + + def applied_params_component + super || default_applied_params_component(storage) + end + + def item_pagination_component + super || default_item_pagination_component(storage) + end + + def url_helper + super || default_url_helper(storage) + end + + def default_applied_params_component(storage) + return Blacklight::SearchContext::ServerAppliedParamsComponent if storage == 'server' + + nil + end + + def default_item_pagination_component(storage) + return Blacklight::SearchContextComponent if storage == 'server' + + nil + end + + # extension point for alternative storage types + def default_url_helper(_storage) + nil + end + end +end diff --git a/lib/blacklight/routes/searchable.rb b/lib/blacklight/routes/searchable.rb index 7dbcd36c85..ec2c287903 100644 --- a/lib/blacklight/routes/searchable.rb +++ b/lib/blacklight/routes/searchable.rb @@ -9,6 +9,7 @@ def initialize(defaults = {}) def call(mapper, _options = {}) mapper.match '/', action: 'index', as: 'search', via: [:get, :post] mapper.get '/advanced', action: 'advanced_search', as: 'advanced_search' + mapper.get '/page_links', action: 'page_links', as: 'page_links' mapper.post ":id/track", action: 'track', as: 'track' mapper.get ":id/raw", action: 'raw', as: 'raw', defaults: { format: 'json' } diff --git a/spec/components/blacklight/document_component_spec.rb b/spec/components/blacklight/document_component_spec.rb index 74dada0616..12713882d0 100644 --- a/spec/components/blacklight/document_component_spec.rb +++ b/spec/components/blacklight/document_component_spec.rb @@ -26,7 +26,7 @@ let(:blacklight_config) do CatalogController.blacklight_config.deep_copy.tap do |config| - config.track_search_session = false + config.track_search_session_config.storage = false config.index.thumbnail_field = 'thumbnail_path_ss' config.index.document_actions[:bookmark].partial = '/catalog/bookmark_control' end diff --git a/spec/components/blacklight/search_context/server_applied_params_component_spec.rb b/spec/components/blacklight/search_context/server_applied_params_component_spec.rb new file mode 100644 index 0000000000..ff21468e7f --- /dev/null +++ b/spec/components/blacklight/search_context/server_applied_params_component_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Blacklight::SearchContext::ServerAppliedParamsComponent, type: :component do + subject(:render) { instance.render_in(view_context) } + + let(:instance) { described_class.new } + let(:current_search_session) { nil } + let(:view_context) { controller.view_context } + + before do + view_context.view_paths.unshift(RSpec::Rails::ViewExampleGroup::StubResolverCache.resolver_for('application/_start_over.html.erb' => 'start over')) + allow(view_context).to receive(:current_search_session).and_return current_search_session + allow(view_context).to receive(:link_back_to_catalog).with(any_args) + end + + it 'is blank without current session' do + expect(render).to be_blank + end + + context 'with current session' do + let(:current_search_session) { double(query_params: { q: 'abc' }) } + + it 'is not blank' do + expect(render).not_to be_blank + end + end +end diff --git a/spec/components/blacklight/search_context_component_spec.rb b/spec/components/blacklight/search_context_component_spec.rb new file mode 100644 index 0000000000..7e2230e243 --- /dev/null +++ b/spec/components/blacklight/search_context_component_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Blacklight::SearchContextComponent, type: :component do + subject(:render) { render_inline(instance) } + + let(:current_document_id) { 9 } + let(:current_document) { SolrDocument.new(id: current_document_id) } + let(:search_session) { { 'document_id' => current_document_id } } + let(:current_search_session) { double(id: current_document_id) } + let(:instance) { described_class.new(search_context: search_context, search_session: search_session, current_document: current_document) } + + before do + allow(controller).to receive(:current_search_session).and_return(current_search_session) + allow(controller).to receive(:view_context).and_return(controller.view_context) + allow(controller.view_context).to receive(:current_search_session).and_return(current_search_session) + allow(controller.view_context).to receive(:search_session).and_return(search_session) + end + + context 'when there is no next or previous' do + let(:search_context) { {} } + + it "does not render content" do + expect(render.to_html).to be_blank + end + end + + context 'when there is next and previous' do + let(:search_context) { { next: next_doc, prev: prev_doc } } + let(:prev_doc) { SolrDocument.new(id: '777') } + let(:next_doc) { SolrDocument.new(id: '888') } + + before do + # allow(controller).to receive(:controller_tracking_method).and_return('track_catalog_path') + allow(controller).to receive(:controller_name).and_return('catalog') + + allow(controller).to receive(:link_to_previous_document).and_return('') + allow(controller).to receive(:link_to_next_document).and_return('') + end + + it "renders content" do + expect(render.css('.pagination-search-widgets').to_html).not_to be_blank + end + + context "session and document are out of sync" do + let(:current_document) { SolrDocument.new(id: current_document_id + 1) } + + it "does not render content" do + expect(render.to_html).to be_blank + end + end + end +end diff --git a/spec/controllers/bookmarks_controller_spec.rb b/spec/controllers/bookmarks_controller_spec.rb index 05df6104ca..7e4bf8fdd4 100644 --- a/spec/controllers/bookmarks_controller_spec.rb +++ b/spec/controllers/bookmarks_controller_spec.rb @@ -8,6 +8,7 @@ it 'opts out of search session tracking' do expect(@controller.blacklight_config.track_search_session).to eq false + expect(@controller.blacklight_config.track_search_session_config.storage).to be false end end diff --git a/spec/controllers/catalog_controller_spec.rb b/spec/controllers/catalog_controller_spec.rb index 2bef7d51a7..ac7abe207f 100644 --- a/spec/controllers/catalog_controller_spec.rb +++ b/spec/controllers/catalog_controller_spec.rb @@ -919,6 +919,21 @@ def export_as_mock end end end + + describe "page_links" do + it "has prev/next docs and result set data for non-empty result sets", integration: true do + get :page_links, params: { f: { "format" => 'Book' }, counter: 2 } + expect(assigns(:page_link_data)).not_to be_empty + expect(assigns(:page_link_data).fetch(:prev, nil)).to end_with('counter=1') + expect(assigns(:page_link_data).fetch(:next, nil)).to end_with('counter=3') + expect(assigns(:page_link_data).fetch(:totalRaw, nil)).to be 30 + end + + it "is empty for empty result sets", integration: true do + get :page_links, params: { f: { "format" => 'empty-result-set' }, counter: 1 } + expect(assigns(:page_link_data)).to be_empty + end + end end # there must be at least one facet, and each facet must have at least one value diff --git a/spec/lib/blacklight/configuration/session_tracking_config_spec.rb b/spec/lib/blacklight/configuration/session_tracking_config_spec.rb new file mode 100644 index 0000000000..ad0f648e9d --- /dev/null +++ b/spec/lib/blacklight/configuration/session_tracking_config_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe Blacklight::Configuration::SessionTrackingConfig do + subject(:config) { described_class.new } + + it "defaults @storage to 'server'" do + expect(config.storage).to eq 'server' + end + + context "@storage is set to 'server'" do + before do + config.storage = 'server' + end + + it "defaults components values" do + expect(config.applied_params_component).to eq Blacklight::SearchContext::ServerAppliedParamsComponent + end + + it "defaults tracking url helper" do + expect(config.url_helper).to be_nil + end + end + + context "@storage is set to false" do + before do + config.storage = false + end + + it "defaults components values" do + expect(config.applied_params_component).to be_nil + end + + it "defaults tracking url helper" do + expect(config.url_helper).to be_nil + end + end +end diff --git a/spec/routing/catalog_routing_spec.rb b/spec/routing/catalog_routing_spec.rb index 1a11c69aa3..8f9599161a 100644 --- a/spec/routing/catalog_routing_spec.rb +++ b/spec/routing/catalog_routing_spec.rb @@ -23,6 +23,10 @@ it "maps { :controller => 'catalog', :action => 'show', :id => 666 } to /catalog/666" do expect(get: "/catalog/666").to route_to(controller: 'catalog', action: 'show', id: "666") end + + it "maps GET {:controller => 'catalog', :action => 'prev_next_documents'} to /catalog/page_links" do + expect(get: "/catalog/page_links").to route_to(controller: 'catalog', action: 'page_links') + end end describe "solr_document_path for SolrDocument", test: true do