Skip to content

Commit 9da1f33

Browse files
committed
Fix Turbo Frames pagination bug
Why these changes are being introduced: Navigation past page 2 in search results is broken due to Turbo Frame state management issues. Pagination controls were outside the Turbo Frame, causing full page reloads that lost tab context and broke the pagination flow. Additionally, Primo API has practical limits around 960 records (or thereabouts), despite claiming millions of results and a recommended offset limit of 2,000 and a hardcoded limit of 5,000. This leads to inconsistent behavior at high page counts (errors, timeouts, etc). Relevant ticket(s): - [USE-95](https://mitlibraries.atlassian.net/browse/USE-95) - [USE-110](https://mitlibraries.atlassian.net/browse/USE-110) How this addresses that need: - Moves pagination controls inside the `search-results` Turbo Frame to maintain state during navigation. - Adds `data-turbo-action='advance'` to pagination links for proper URL updates and browser history management. - Adds `rel='nofollow'` to pagination links to prevent bot crawling. - Set PRIMO_MAX_OFFSET at 960 based on real-world API testing. - Implement continuation page for Primo results beyond pagination limits. - Consolidates result count display logic to show `10,000+` for all sources when appropriate. - Adds a `First` affordance for users to return quickly to the first page. Side effects of this change: - Pagination now uses AJAX updates instead of full page reloads, providing faster navigation. (This is marginal given how slow the Primo API is.) - Users will see a "Continue in Search Our Collections" page when reaching Primo's practical pagination limits. It's notable that Primo UI has the same bug as the Search API, so they will continue to experience frustration at higher page counts regardless of which interface they use. - The URL updates provided by `data-turbo-action='advance'` also fixes USE-110 (ensuring consistent browser state). - Some autoformatting applied by VSCode around integers (e.g., 10000 becomes 10_000). - I have serious concerns about the Primo Search API performance. It is so slow that it times out occasionally after 60 seconds. This makes pagination an inherently poor user experience, as the problem seems to get worse at higher page numbers. I strongly urge us to reconsider making Primo the default tab. If that is not an option, we should evaluate creative workarounds. (E.g., perhaps a higher per-page count would dissuade users from using pagination.) Caching alone is unlikely to help in this case.
1 parent 22f5b1c commit 9da1f33

File tree

17 files changed

+442
-61
lines changed

17 files changed

+442
-61
lines changed

app/assets/stylesheets/partials/_pagination.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
justify-content: center;
66
margin-top: 3em;
77

8+
.first {
9+
border-right: 1px solid black;
10+
margin-right: 0.5em;
11+
padding-right: 0.5em;
12+
}
13+
814
.previous {
915
border-right: 1px solid black;
1016
margin-right: 0.5em;

app/assets/stylesheets/partials/_search.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,13 @@
127127
.about {
128128
margin-top: 5rem;
129129
}
130+
131+
/* primo continuation partial */
132+
.primo-continuation {
133+
background-color: #f8f9fa;
134+
border: 1px solid #dee2e6;
135+
border-radius: 0.375rem;
136+
padding: 2rem;
137+
margin: 2rem 0;
138+
text-align: center;
139+
}

app/controllers/search_controller.rb

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ def results
1919
else
2020
params[:tab] || 'primo' # Default to primo for new tabbed interface
2121
end
22+
23+
# Include the active tab in the enhanced query so it's available for pagination and other uses.
24+
params[:tab] = @active_tab unless Feature.enabled?(:gdt)
2225
@enhanced_query = Enhancer.new(params).enhanced_query
2326

2427
# Route to appropriate search based on active tab
@@ -47,28 +50,41 @@ def load_gdt_results
4750
@errors = extract_errors(response)
4851
return unless @errors.nil?
4952

50-
@pagination = Analyzer.new(@enhanced_query, response).pagination
53+
@pagination = Analyzer.new(@enhanced_query, response, :timdex).pagination
5154
raw_results = extract_results(response)
5255
@results = NormalizeTimdexResults.new(raw_results, @enhanced_query[:q]).normalize
5356
@filters = extract_filters(response)
5457
end
5558

5659
def load_primo_results
60+
current_page = @enhanced_query[:page] || 1
61+
per_page = @enhanced_query[:per_page] || 20
62+
offset = (current_page - 1) * per_page
63+
64+
# Check if we're beyond Primo API limits before making the request.
65+
if offset >= Analyzer::PRIMO_MAX_OFFSET
66+
@show_primo_continuation = true
67+
return
68+
end
69+
5770
primo_response = query_primo
5871
@results = NormalizePrimoResults.new(primo_response, @enhanced_query[:q]).normalize
5972

60-
# Enhanced pagination using cached response
61-
if @results.present?
62-
total_hits = primo_response.dig('info', 'total') || @results.count
63-
per_page = @enhanced_query[:per_page] || 20
64-
current_page = @enhanced_query[:page] || 1
65-
66-
@pagination = {
67-
hits: total_hits,
68-
start: ((current_page - 1) * per_page) + 1,
69-
end: [current_page * per_page, total_hits].min
70-
}
73+
# Handle empty results from Primo API. Sometimes Primo will return no results at a given offset,
74+
# despite claiming in the initial query that more are available. This happens randomly and
75+
# seemingly for no reason (well below the recommended offset of 2,000). While the bug also
76+
# exists in Primo UI, sending users there seems like the best we can do.
77+
if @results.empty?
78+
docs = primo_response['docs'] if primo_response.is_a?(Hash)
79+
if docs.nil? || docs.empty?
80+
@show_primo_continuation = true
81+
else
82+
@errors = [{ 'message' => 'No more results available at this page number.' }]
83+
end
7184
end
85+
86+
# Use Analyzer for consistent pagination across all search types
87+
@pagination = Analyzer.new(@enhanced_query, primo_response, :primo).pagination
7288
rescue StandardError => e
7389
@errors = handle_primo_errors(e)
7490
end
@@ -80,7 +96,7 @@ def load_timdex_results
8096
@errors = extract_errors(response)
8197
return unless @errors.nil?
8298

83-
@pagination = Analyzer.new(@enhanced_query, response).pagination
99+
@pagination = Analyzer.new(@enhanced_query, response, :timdex).pagination
84100
raw_results = extract_results(response)
85101
@results = NormalizeTimdexResults.new(raw_results, @enhanced_query[:q]).normalize
86102
end
@@ -117,7 +133,10 @@ def query_primo
117133
Rails.cache.fetch("#{cache_key}/primo", expires_in: 12.hours) do
118134
primo_search = PrimoSearch.new
119135
per_page = @enhanced_query[:per_page] || 20
120-
primo_search.search(@enhanced_query[:q], per_page)
136+
current_page = @enhanced_query[:page] || 1
137+
offset = (current_page - 1) * per_page
138+
139+
primo_search.search(@enhanced_query[:q], per_page, offset)
121140
end
122141
end
123142

app/helpers/pagination_helper.rb

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,45 @@
11
module PaginationHelper
2+
def first_url(query_params)
3+
# Work with a copy to avoid mutating the original enhanced_query.
4+
params_copy = query_params.dup
5+
params_copy[:page] = 1
6+
7+
# Preserve the active tab in pagination URLs.
8+
params_copy[:tab] = @active_tab if @active_tab.present?
9+
10+
link_to results_path(params_copy), 'aria-label': 'First page',
11+
data: { turbo_frame: 'search-results', turbo_action: 'advance' },
12+
rel: 'nofollow' do
13+
'«« First'.html_safe
14+
end
15+
end
16+
217
def next_url(query_params)
3-
query_params[:page] = @pagination[:next]
4-
link_to results_path(query_params), 'aria-label': 'Next page' do
18+
# Work with a copy to avoid mutating the original enhanced_query.
19+
params_copy = query_params.dup
20+
params_copy[:page] = @pagination[:next]
21+
22+
# Preserve the active tab in pagination URLs.
23+
params_copy[:tab] = @active_tab if @active_tab.present?
24+
25+
link_to results_path(params_copy), 'aria-label': 'Next page',
26+
data: { turbo_frame: 'search-results', turbo_action: 'advance' },
27+
rel: 'nofollow' do
528
'Next »'.html_safe
629
end
730
end
831

932
def prev_url(query_params)
10-
query_params[:page] = @pagination[:prev]
11-
link_to results_path(query_params), 'aria-label': 'Previous page' do
33+
# Work with a copy to avoid mutating the original enhanced_query.
34+
params_copy = query_params.dup
35+
params_copy[:page] = @pagination[:prev]
36+
37+
# Preserve the active tab in pagination URLs.
38+
params_copy[:tab] = @active_tab if @active_tab.present?
39+
40+
link_to results_path(params_copy), 'aria-label': 'Previous page',
41+
data: { turbo_frame: 'search-results', turbo_action: 'advance' },
42+
rel: 'nofollow' do
1243
'« Previous'.html_safe
1344
end
1445
end

app/helpers/search_helper.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,13 @@ def extract_year(date, delimiter)
110110
date.split(delimiter).last
111111
end
112112
end
113+
114+
def primo_search_url(query_term)
115+
base_url = 'https://mit.primo.exlibrisgroup.com/discovery/search'
116+
params = {
117+
vid: ENV.fetch('PRIMO_VID'),
118+
query: "any,contains,#{query_term}"
119+
}
120+
"#{base_url}?#{params.to_query}"
121+
end
113122
end

app/models/analyzer.rb

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,44 @@ class Analyzer
33

44
RESULTS_PER_PAGE = 20
55

6-
def initialize(enhanced_query, response)
6+
# Primo API theoretical maximum recommended offset is 2000 records (per Ex Libris documentation)
7+
# but in practice, the API often can't deliver results beyond ~960 records for large result sets,
8+
# likely due to performance constraints.
9+
PRIMO_MAX_OFFSET = 960
10+
11+
def initialize(enhanced_query, response, source)
12+
@source = source
713
@pagination = {}
814
@pagination[:hits] = hits(response)
915
@pagination[:start] = ((enhanced_query[:page] - 1) * RESULTS_PER_PAGE) + 1
10-
@pagination[:end] = [enhanced_query[:page] * RESULTS_PER_PAGE, hits(response)].min
16+
@pagination[:end] = [enhanced_query[:page] * RESULTS_PER_PAGE, @pagination[:hits]].min
1117
@pagination[:prev] = enhanced_query[:page] - 1 if enhanced_query[:page] > 1
12-
@pagination[:next] = next_page(enhanced_query[:page], @pagination[:hits])
18+
19+
next_page_num = next_page(enhanced_query[:page], @pagination[:hits])
20+
@pagination[:next] = next_page_num if next_page_num
1321
end
1422

1523
private
1624

1725
def hits(response)
1826
return 0 if response.nil?
27+
28+
if @source == :primo
29+
primo_hits(response)
30+
elsif @source == :timdex
31+
timdex_hits(response)
32+
else
33+
0
34+
end
35+
end
36+
37+
def primo_hits(response)
38+
return 0 unless response.is_a?(Hash)
39+
40+
response.dig('info', 'total') || 0
41+
end
42+
43+
def timdex_hits(response)
1944
return 0 unless response.is_a?(Hash) && response.key?(:data) && response[:data].key?('search')
2045
return 0 unless response[:data]['search'].is_a?(Hash) && response[:data]['search'].key?('hits')
2146

app/models/enhancer.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def initialize(params)
1313
@enhanced_query[:page] = calculate_page(params[:page].to_i)
1414
@enhanced_query[:advanced] = 'true' if params[:advanced].present?
1515
@enhanced_query[:booleanType] = params[:booleanType] || 'AND'
16+
@enhanced_query[:tab] = params[:tab] if params[:tab].present?
1617

1718
if Feature.enabled?(:geodata)
1819
@enhanced_query[:geobox] = 'true' if params[:geobox] == 'true'

app/models/primo_search.rb

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
# Searches Primo Search API and formats results
22
#
33
class PrimoSearch
4-
54
def initialize
65
validate_env
76
@primo_http = HTTP.persistent(primo_api_url)
87
@results = {}
98
end
109

11-
def search(term, per_page)
12-
url = search_url(term, per_page)
10+
def search(term, per_page, offset = 0)
11+
url = search_url(term, per_page, offset)
1312
result = @primo_http.timeout(http_timeout)
14-
.headers(
15-
accept: 'application/json',
16-
Authorization: "apikey #{primo_api_key}"
17-
)
18-
.get(url)
19-
13+
.headers(
14+
accept: 'application/json',
15+
Authorization: "apikey #{primo_api_key}"
16+
)
17+
.get(url)
18+
2019
raise "Primo Error Detected: #{result.status}" unless result.status == 200
2120

2221
JSON.parse(result)
@@ -26,15 +25,15 @@ def search(term, per_page)
2625

2726
def validate_env
2827
missing_vars = []
29-
28+
3029
missing_vars << 'PRIMO_API_URL' if primo_api_url.nil?
3130
missing_vars << 'PRIMO_API_KEY' if primo_api_key.nil?
3231
missing_vars << 'PRIMO_SCOPE' if primo_scope.nil?
3332
missing_vars << 'PRIMO_TAB' if primo_tab.nil?
3433
missing_vars << 'PRIMO_VID' if primo_vid.nil?
35-
34+
3635
return if missing_vars.empty?
37-
36+
3837
raise ArgumentError, "Required Primo environment variables are not set: #{missing_vars.join(', ')}"
3938
end
4039

@@ -65,8 +64,8 @@ def clean_term(term)
6564
end
6665

6766
# Constructs the search URL with required parameters for Primo API
68-
def search_url(term, per_page)
69-
[
67+
def search_url(term, per_page, offset = 0)
68+
url_parts = [
7069
primo_api_url,
7170
'/search?q=any,contains,',
7271
clean_term(term),
@@ -77,10 +76,18 @@ def search_url(term, per_page)
7776
'&scope=',
7877
primo_scope,
7978
'&limit=',
80-
per_page,
79+
per_page
80+
]
81+
82+
# Add offset parameter for pagination (only if > 0)
83+
url_parts += ['&offset=', offset] if offset > 0
84+
85+
url_parts += [
8186
'&apikey=',
8287
primo_api_key
83-
].join
88+
]
89+
90+
url_parts.join
8491
end
8592

8693
# Timeout configuration for HTTP requests

app/views/search/_pagination.html.erb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
<% return if @pagination.nil? %>
22

33
<nav class="pagination-container" aria-label="Pagination">
4+
<div class="first">
5+
<% current_page = @enhanced_query[:page] || 1 %>
6+
<% if current_page > 2 %>
7+
<%= first_url(@enhanced_query) %>
8+
<% else %>
9+
First
10+
<% end %>
11+
</div>
412
<div class="previous">
513
<% if @pagination[:prev] %>
614
<%= prev_url(@enhanced_query) %>
715
<% else %>
8-
First
16+
Previous
917
<% end %>
1018
</div>
11-
<div class="current"><%= @pagination[:start] %> - <%= @pagination[:end] %> of <%= @pagination[:hits] %></div>
19+
<div class="current"><%= @pagination[:start] %> - <%= @pagination[:end] %> of <%= number_with_delimiter(@pagination[:hits]) %></div>
1220
<div class="next">
1321
<% if @pagination[:next] %>
1422
<%= next_url(@enhanced_query) %>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<div class="primo-continuation">
2+
<h2 class="hd-3">Continue your search in Search Our Collections</h2>
3+
<p>
4+
You've reached the pagination limit for embedded search results. To see more results and access
5+
additional search features, continue your search in the full Search Our Collections interface.
6+
</p>
7+
<div>
8+
<%= link_to "Continue in Search Our Collections",
9+
primo_search_url(@enhanced_query[:q]),
10+
class: "button-primary",
11+
rel: "nofollow" %>
12+
</div>
13+
</div>

0 commit comments

Comments
 (0)