views: factor out paginator component.

* Refactor the paginator into a ViewComponent.
* Fix inconsistent spacing between paginator items.
* Fix a bug where the sequential paginator generated the wrong next /
  previous page links in the <link rel="{next|prev}"> tags in the <head>.
* Always include the final page as a hidden html element, so that it can
  be unhidden with custom CSS.
* Make it easier to change the pagination window.
This commit is contained in:
evazion
2021-02-18 00:49:05 -06:00
parent 8b8a3f3836
commit c1805cc4e0
19 changed files with 181 additions and 195 deletions

View File

@@ -0,0 +1,44 @@
# frozen_string_literal: true
class PaginatorComponent < ApplicationComponent
attr_reader :records, :window, :params
delegate :current_page, :prev_page, :next_page, :total_pages, :paginator_mode, :paginator_page_limit, to: :records
delegate :ellipsis_icon, :chevron_left_icon, :chevron_right_icon, to: :helpers
def initialize(records:, params:, window: 4)
@records = records
@window = window
@params = params
end
def use_sequential_paginator?
paginator_mode != :numbered || current_page >= paginator_page_limit
end
def pages
last_page = total_pages
left = (current_page - window).clamp(2..)
right = (current_page + window).clamp(..last_page - 1)
[
1,
("..." unless left == 2),
(left..right).to_a,
("..." unless right == last_page - 1),
last_page
].flatten.compact
end
def link_to_page(anchor, page = anchor, **options)
if page.nil?
tag.span anchor, **options
else
hidden = paginator_mode == :numbered && page > paginator_page_limit
link_to anchor, url_for_page(page), **options, hidden: hidden
end
end
def url_for_page(page)
url_for(**params.merge(page: page).permit!)
end
end

View File

@@ -0,0 +1,9 @@
<% content_for(:html_header) do %>
<% if prev_page.present? %>
<%= tag.link rel: "prev", href: url_for_page(prev_page) %>
<% end %>
<% if next_page.present? %>
<%= tag.link rel: "next", href: url_for_page(next_page) %>
<% end %>
<% end %>

View File

@@ -0,0 +1,15 @@
<div class="paginator numbered-paginator mt-8 mb-4 space-x-2 flex justify-center items-center">
<%= link_to_page chevron_left_icon, prev_page, class: "paginator-prev", rel: "prev", "data-shortcut": "a left" %>
<% pages.each do |page| %>
<% if page == "..." %>
<%= ellipsis_icon class: "paginator-ellipsis text-muted desktop-only" %>
<% elsif page == current_page %>
<%= tag.span page, class: "paginator-current font-bold" %>
<% else %>
<%= link_to_page page, class: "paginator-page desktop-only" %>
<% end %>
<% end %>
<%= link_to_page chevron_right_icon, next_page, class: "paginator-next", rel: "next", "data-shortcut": "d right" %>
</div>

View File

@@ -0,0 +1,4 @@
<div class="paginator sequential-paginator mt-8 mb-4 space-x-2 flex justify-center items-center">
<%= link_to_page chevron_left_icon, prev_page, class: "paginator-prev", rel: "prev", "data-shortcut": "a left" %>
<%= link_to_page chevron_right_icon, next_page, class: "paginator-next", rel: "next", "data-shortcut": "d right" %>
</div>

View File

@@ -0,0 +1,10 @@
.paginator {
> * {
padding: 0.25rem 0.75rem;
}
> a:hover {
background: var(--paginator-arrow-color);
color: var(--paginator-arrow-background-color);
}
}

View File

@@ -50,4 +50,19 @@ module ComponentsHelper
tags = TagListComponent.tags_from_names(tag_names)
render TagListComponent.new(tags: tags, **options).with_variant(:search)
end
# The <link rel="next"> / <link rel="prev"> links in the <meta> element of the <head>.
def render_meta_links(records)
render PaginatorComponent.new(records: records, params: params).with_variant(:meta_links)
end
def numbered_paginator(records)
paginator = PaginatorComponent.new(records: records, params: params)
if paginator.use_sequential_paginator?
render paginator.with_variant(:sequential)
else
render paginator.with_variant(:numbered)
end
end
end

View File

@@ -1,115 +0,0 @@
module PaginationHelper
def sequential_paginator(records)
html = '<div class="paginator"><menu>'
if records.any?
if params[:page] =~ /[ab]/ && !records.is_first_page?
html << '<li>' + link_to("< Previous", nav_params_for("a#{records[0].id}"), rel: "prev", id: "paginator-prev", "data-shortcut": "a left") + '</li>'
end
unless records.is_last_page?
html << '<li>' + link_to("Next >", nav_params_for("b#{records[-1].id}"), rel: "next", id: "paginator-next", "data-shortcut": "d right") + '</li>'
end
end
html << "</menu></div>"
html.html_safe
end
def use_sequential_paginator?(records, page_limit)
params[:page] =~ /[ab]/ || records.current_page >= page_limit
end
def numbered_paginator(records, page_limit: CurrentUser.user.page_limit)
if use_sequential_paginator?(records, page_limit)
return sequential_paginator(records)
end
html = '<div class="paginator"><menu>'
window = 4
if records.current_page >= 2
html << "<li class='arrow'>" + link_to(chevron_left_icon, nav_params_for(records.current_page - 1), rel: "prev", id: "paginator-prev", "data-shortcut": "a left") + "</li>"
else
html << "<li class='arrow'><span>" + chevron_left_icon + "</span></li>"
end
if records.total_pages <= (window * 2) + 5
1.upto(records.total_pages) do |page|
html << numbered_paginator_item(page, records.current_page, page_limit)
end
elsif records.current_page <= window + 2
1.upto(records.current_page + window) do |page|
html << numbered_paginator_item(page, records.current_page, page_limit)
end
html << numbered_paginator_item("...", records.current_page, page_limit)
html << numbered_paginator_final_item(records.total_pages, records.current_page, page_limit)
elsif records.current_page >= records.total_pages - (window + 1)
html << numbered_paginator_item(1, records.current_page, page_limit)
html << numbered_paginator_item("...", records.current_page, page_limit)
(records.current_page - window).upto(records.total_pages) do |page|
html << numbered_paginator_item(page, records.current_page, page_limit)
end
else
html << numbered_paginator_item(1, records.current_page, page_limit)
html << numbered_paginator_item("...", records.current_page, page_limit)
if records.present?
right_window = records.current_page + window
else
right_window = records.current_page
end
(records.current_page - window).upto(right_window) do |page|
html << numbered_paginator_item(page, records.current_page, page_limit)
end
if records.present?
html << numbered_paginator_item("...", records.current_page, page_limit)
html << numbered_paginator_final_item(records.total_pages, records.current_page, page_limit)
end
end
if records.current_page < records.total_pages && records.present?
html << "<li class='arrow'>" + link_to(chevron_right_icon, nav_params_for(records.current_page + 1), rel: "next", id: "paginator-next", "data-shortcut": "d right") + "</li>"
else
html << "<li class='arrow'><span>" + chevron_right_icon + "</span></li>"
end
html << "</menu></div>"
html.html_safe
end
def numbered_paginator_final_item(total_pages, current_page, page_limit)
if total_pages <= page_limit
numbered_paginator_item(total_pages, current_page, page_limit)
else
""
end
end
def numbered_paginator_item(page, current_page, page_limit)
return "" if page.to_i > page_limit
html = []
if page == "..."
html << "<li class='more'>"
html << ellipsis_icon
html << "</li>"
elsif page == current_page
html << "<li class='current-page'>"
html << '<span>' + page.to_s + '</span>'
html << "</li>"
else
html << "<li class='numbered-page'>"
html << link_to(page, nav_params_for(page))
html << "</li>"
end
html.join.html_safe
end
private
def nav_params_for(page)
query_params = params.except(:controller, :action, :id).merge(page: page).permit!
{ params: query_params }
end
end

View File

@@ -183,7 +183,6 @@
--wiki-page-other-name-background-color: #EEE;
--paginator-ellipsis-color: grey;
--paginator-arrow-background-color: white;
--paginator-arrow-color: var(--link-color);
@@ -377,7 +376,6 @@ body[data-current-user-theme="dark"] {
--paginator-arrow-background-color: white;
--paginator-arrow-color: var(--link-color);
--paginator-ellipsis-color: var(--grey-4);
--post-artist-commentary-container-background: var(--grey-3);
--post-artist-commentary-container-border: 1px solid var(--grey-3);

View File

@@ -1,32 +0,0 @@
div.paginator {
display: block;
padding: 2em 0 1em 0;
text-align: center;
li {
a {
margin: 0 0.25em;
padding: 0.25em 0.75em;
}
&.more {
color: var(--paginator-ellipsis-color);
}
a.arrow:hover {
background: var(--paginator-arrow-background-color);
color: var(--paginator-arrow-color);
}
a:hover {
background: var(--paginator-arrow-color);
color: var(--paginator-arrow-background-color);
}
span {
margin: 0 0.25em;
padding: 0.25em 0.75em;
font-weight: bold;
}
}
}

View File

@@ -12,12 +12,14 @@ $spacer: 0.25rem; /* 4px */
.flex { display: flex; }
.text-center { text-align: center; }
.text-muted { color: var(--muted-text-color); }
.mx-auto { margin-left: auto; margin-right: auto; }
.mx-2 { margin-left: 2 * $spacer; margin-right: 2 * $spacer; }
.mt-2 { margin-top: 2 * $spacer; }
.mt-4 { margin-top: 4 * $spacer; }
.mt-8 { margin-top: 8 * $spacer; }
.mb-2 { margin-bottom: 2 * $spacer; }
.mb-4 { margin-bottom: 4 * $spacer; }
@@ -31,6 +33,7 @@ $spacer: 0.25rem; /* 4px */
.h-10 { height: 10 * $spacer; }
.space-x-2 > * + * { margin-left: 2 * $spacer; }
.space-x-4 > * + * { margin-left: 4 * $spacer; }
.space-y-4 > * + * { margin-top: 4 * $spacer; }
@@ -38,6 +41,7 @@ $spacer: 0.25rem; /* 4px */
.flex-auto { flex: 1 1 auto; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
@media screen and (min-width: 660px) {
.md\:inline-block { display: inline-block; }

View File

@@ -41,22 +41,6 @@
}
}
div.paginator {
font-size: var(--text-lg);
padding: 1em 0 0;
li {
a, span {
margin: 0 0.25rem;
padding: 0;
}
&.numbered-page, &.more {
display: none;
}
}
}
#posts #posts-container {
width: 100%;
display: flex;

View File

@@ -1,11 +1,12 @@
module PaginationExtension
class PaginationError < StandardError; end
attr_accessor :current_page, :records_per_page, :paginator_count, :paginator_mode
attr_accessor :current_page, :records_per_page, :paginator_count, :paginator_mode, :paginator_page_limit
def paginate(page, limit: nil, max_limit: 1000, page_limit: CurrentUser.user.page_limit, count: nil, search_count: nil)
@records_per_page = limit || Danbooru.config.posts_per_page
@records_per_page = @records_per_page.to_i.clamp(1, max_limit)
@paginator_page_limit = page_limit
if count.present?
@paginator_count = count
@@ -65,10 +66,8 @@ module PaginationExtension
nil
elsif paginator_mode == :numbered
current_page - 1
elsif paginator_mode == :sequential_before && records.present?
elsif records.present?
"a#{records.first.id}"
elsif paginator_mode == :sequential_after && records.present?
"b#{records.last.id}"
else
nil
end
@@ -81,10 +80,8 @@ module PaginationExtension
nil
elsif paginator_mode == :numbered
current_page + 1
elsif paginator_mode == :sequential_before && records.present?
elsif records.present?
"b#{records.last.id}"
elsif paginator_mode == :sequential_after && records.present?
"a#{records.first.id}"
else
nil
end

View File

@@ -1,11 +0,0 @@
<%# collection %>
<% content_for(:html_header) do %>
<% if collection.try(:prev_page) %>
<%= tag.link rel: "prev", href: url_for(nav_params_for(collection.prev_page)) %>
<% end %>
<% if collection.try(:next_page) %>
<%= tag.link rel: "next", href: url_for(nav_params_for(collection.next_page)) %>
<% end %>
<% end %>

View File

@@ -1,5 +1,5 @@
<% page_title "Favgroup: #{@favorite_group.pretty_name}" %>
<%= render "meta_links", collection: @posts %>
<%= render_meta_links @posts %>
<%= render "secondary_links" %>
<div id="c-favorite-groups">

View File

@@ -2,7 +2,7 @@
<% meta_description(DText.excerpt(@forum_topic.original_post&.body)) %>
<% atom_feed_tag(@forum_topic.title, forum_topic_url(@forum_topic.id, format: :atom)) %>
<%= render "meta_links", collection: @forum_posts %>
<%= render_meta_links @forum_posts %>
<%= render "secondary_links" %>
<div id="c-forum-topics">

View File

@@ -5,7 +5,7 @@
<title><%= page_title %></title>
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
<%= render "meta_links", collection: @current_item %>
<%= render_meta_links @current_item if @current_item.respond_to?(:paginate) %>
<%= tag.link rel: "canonical", href: canonical_url %>
<%= tag.link rel: "search", type: "application/opensearchdescription+xml", href: opensearch_url(format: :xml, version: 2), title: "Search posts" %>

View File

@@ -1,7 +1,7 @@
<% page_title @pool.pretty_name %>
<% meta_description("#{number_with_delimiter(@pool.post_count)} posts. #{DText.excerpt(@pool.description)}") %>
<%= render "meta_links", collection: @posts %>
<%= render_meta_links @posts %>
<%= render "secondary_links" %>
<div id="c-pools">

View File

@@ -0,0 +1,64 @@
require "test_helper"
class PaginatorComponentTest < ViewComponent::TestCase
def render_paginator(variant, page, limit: 3, page_limit: 100)
with_variant(variant) do
tags = Tag.paginate(page, limit: limit, page_limit: page_limit)
params = ActionController::Parameters.new(controller: :tags, action: :index)
return render_inline(PaginatorComponent.new(records: tags, params: params))
end
end
def assert_page(expected_page, link)
href = link.attr("href").value
uri = Addressable::URI.parse(href)
page = uri.query_values["page"]
assert_equal(expected_page, page)
end
context "The PaginatorComponent" do
setup do
@tags = create_list(:tag, 10)
end
context "when using sequential pagination" do
should "work with an aN page" do
html = render_paginator(:sequential, "a#{@tags[5].id}", limit: 3)
assert_page("a#{@tags[5+3].id}", html.css("a[rel=prev]"))
assert_page("b#{@tags[5+1].id}", html.css("a[rel=next]"))
end
should "work with a bN page" do
html = render_paginator(:sequential, "b#{@tags[5].id}", limit: 3)
assert_page("a#{@tags[5-1].id}", html.css("a[rel=prev]"))
assert_page("b#{@tags[5-3].id}", html.css("a[rel=next]"))
end
end
context "when using numbered pagination" do
should "work for page 1" do
html = render_paginator(:numbered, 1, limit: 3)
assert_css("span.paginator-prev")
assert_page("2", html.css("a.paginator-next"))
end
should "work for page 2" do
html = render_paginator(:numbered, 2, limit: 3)
assert_page("1", html.css("a.paginator-prev"))
assert_page("3", html.css("a.paginator-next"))
end
should "work for page 4" do
html = render_paginator(:numbered, 4, limit: 3)
assert_page("3", html.css("a.paginator-prev"))
assert_css("span.paginator-next")
end
end
end
end

View File

@@ -22,32 +22,32 @@ class PostsControllerTest < ActionDispatch::IntegrationTest
get posts_path(page: "a0")
assert_response :success
assert_select ".post-preview", count: 3
assert_select "#paginator-prev", count: 0
assert_select "#paginator-next", count: 1
assert_select "a.paginator-prev", count: 0
assert_select "a.paginator-next", count: 1
end
should "work with page=b0" do
get posts_path(page: "b0")
assert_response :success
assert_select ".post-preview", count: 0
assert_select "#paginator-prev", count: 0
assert_select "#paginator-next", count: 0
assert_select "a.paginator-prev", count: 0
assert_select "a.paginator-next", count: 0
end
should "work with page=b100000" do
get posts_path(page: "b100000")
assert_response :success
assert_select ".post-preview", count: 3
assert_select "#paginator-prev", count: 1
assert_select "#paginator-next", count: 0
assert_select "a.paginator-prev", count: 1
assert_select "a.paginator-next", count: 0
end
should "work with page=a100000" do
get posts_path(page: "a100000")
assert_response :success
assert_select ".post-preview", count: 0
assert_select "#paginator-prev", count: 0
assert_select "#paginator-next", count: 0
assert_select "a.paginator-prev", count: 0
assert_select "a.paginator-next", count: 0
end
end