Skip to content

Commit

Permalink
Calendar improvements:
Browse files Browse the repository at this point in the history
- Added the :fit_time option to page_at and pagy_calendar_url_at methods. It avoids the OutOfRangeError by returning the first or last page
- Added starting_time_for and page_offset_at feedback methods to the Calendar base class
- Prepended the pagy_calendar_url_at to the Frontend and Backend
- Added calendar showtime
  • Loading branch information
ddnexus committed Jan 8, 2024
1 parent 8405937 commit cd49216
Show file tree
Hide file tree
Showing 11 changed files with 159 additions and 37 deletions.
5 changes: 4 additions & 1 deletion apps/pagy_calendar_app.ru
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,13 @@ __END__
<% else %>
<a href="?skip=true" >Hide Calendar</a>
<br>
<a href="<%= pagy_calendar_url_at(@calendar, Time.zone.now) %>">Go to current Page</a>
<a href="<%= pagy_calendar_url_at(@calendar, Time.zone.parse('2022-03-03')) %>">Go to the 2022-03 Page</a>
<!-- You can use Time.zone.now to find the current page if your time period include today -->
<% end %>
</p>

<p>Showtime: <%= @calendar.showtime %></p>

<!-- calendar filtering navs -->
<% if @calendar %>
<%= pagy_bootstrap_nav(@calendar[:year]) %> <!-- year nav -->
Expand Down
19 changes: 16 additions & 3 deletions docs/extras/calendar.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,19 @@ It returns an array with one more item than the usual two:
```
|||

The `@calendar` contains the hash of the generated `Pagy::Calendar::*` objects that can be used in the UI.
The `@calendar` is the hash of the generated `Pagy::Calendar::*` objects that can be used in the UI.

It also provides the `showtime` helper method that returns the `DateTime` of the smallest time unit currently shown in your calendar. For example:

```erb
<!-- Link to go to a specific page in the calendar -->
<a href="<%= pagy_calendar_url_at(@calendar, Time.zone.parse('2022-03-03')) %>">Go to the 2022-03 Page</a>
<!-- Showtime shows the `DateTime` beginning of the smallest time unit currently shown in the calendar -->
<p>Showtime: <%= @calendar.showtime %></p>
```

See also the the single-file self-contained [pagy_calendar_app.ru](https://github.com/ddnexus/pagy/blob/master/apps/pagy_calendar_app.ru) for an interactive demo.

### `collection` argument

Expand Down Expand Up @@ -270,13 +282,14 @@ You can use the calendar objects with any `pagy_*nav` and `pagy_*nav_js` helpers

The `pagy_*combo_nav_js` keeps into account only page numbers and not labels, so it is not very useful (if at all) with `Pagy::Calendar::*` objects.

==- `pagy_calendar_url_at(@calendar, time)`
==- `pagy_calendar_url_at(@calendar, time, **opts)`

This helper takes the `@calendar` and a `TimeWithZone` objects and returns the url complete with all the params for the pages in each bars that include the passed time.

For example: `pagy_calendar_url_at(@calendar, Time.zone.now)` will select the the bars pointing to today. You can see a working example in the [pagy_calendar_app.ru](https://github.com/ddnexus/pagy/blob/master/apps/pagy_calendar_app.ru) file.

If `time` is outside the pagination range it raises a `Pagy::Calendar::OutOfRangeError`.
If `time` is outside the pagination range it raises a `Pagy::Calendar::OutOfRangeError`, however you can pass the option `fit_time: true` to avoid the error and get the url to the page closest to the passed time argument (first or last page).

===

### Label format
Expand Down
36 changes: 30 additions & 6 deletions lib/pagy/calendar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ class Pagy # :nodoc:
# Base class for time units subclasses (Year, Quarter, Month, Week, Day)
class Calendar < Pagy
# Specific out of range error
class OutOfRangeError < StandardError; end
class OutOfRangeError < VariableError; end

# List of units in desc order of duration. It can be used for custom units.
UNITS = %i[year quarter month week day] # rubocop:disable Style/MutableConstant

attr_reader :order
attr_reader :order, :from, :to

# Merge and validate the options, do some simple arithmetic and set a few instance variables
def initialize(vars) # rubocop:disable Lint/MissingSuper
Expand Down Expand Up @@ -49,10 +49,24 @@ def label_for(page, opts = {})
protected

# The page that includes time
def page_at(time)
raise OutOfRangeError unless time.between?(@initial, @final)

offset = page_offset_at(time) # offset starts from 0
# In case of out of range time, the :fit_time option avoids the outOfRangeError
# and returns the closest page to the passed time argument (first or last page)
def page_at(time, **opts)
fit_time = time
fit_final = @final - 1
unless time.between?(@initial, fit_final)
raise OutOfRangeError.new(self, :time, "between #{@initial} and #{fit_final}", time) unless opts[:fit_time]

if time < @final
fit_time = @initial
ordinal = 'first'
else
fit_time = fit_final
ordinal = 'last'
end
Warning.warn "Pagy::Calendar#page_at: Rescued #{time} out of range by returning the #{ordinal} page."
end
offset = page_offset_at(fit_time) # offset starts from 0
@order == :asc ? offset + 1 : @pages - offset
end

Expand Down Expand Up @@ -84,6 +98,16 @@ def active_period
[[@starting, @from].max, [@to - 1, @ending].min] # -1 sec: include only last unit day
end

# This method must be implemented by the unit subclass
def starting_time_for(*)
raise NoMethodError, 'the starting_time_for method must be implemented by the unit subclass'
end

# This method must be implemented by the unit subclass
def page_offset_at(*)
raise NoMethodError, 'the page_offset_at method must be implemented by the unit subclass'
end

class << self
# Create a subclass instance by unit name (internal use)
def create(unit, vars)
Expand Down
16 changes: 12 additions & 4 deletions lib/pagy/calendar/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
class Pagy # :nodoc:
class Calendar # :nodoc:
# Initializes the calendar objects, reducing complexity in the extra
# The returned calendar is a simple hash of units/objects with an added helper
# returning the last_object_at(time) used in the extra
# The returned calendar is a simple hash of units/objects
class Helper < Hash
class << self
private
Expand All @@ -16,6 +15,7 @@ def init(conf, period, params)

private

# Create the calendar
def init(conf, period, params)
@units = Calendar::UNITS & conf.keys # get the units in time length desc order
raise ArgumentError, 'no calendar unit found in pagy_calendar @configuration' if @units.empty?
Expand Down Expand Up @@ -44,18 +44,26 @@ def init(conf, period, params)
[replace(calendar), object.from, object.to]
end

def last_object_at(time)
# Return the calendar object at time
def calendar_at(time, **opts)
conf = Marshal.load(Marshal.dump(@conf))
page_params = {}
@units.inject(nil) do |object, unit|
conf[unit][:period] = object&.send(:active_period) || @period
conf[unit][:page] = page_params[:"#{unit}_#{@page_param}"] \
= Calendar.send(:create, unit, conf[unit]).send(:page_at, time)
= Calendar.send(:create, unit, conf[unit]).send(:page_at, time, **opts)
conf[unit][:params] ||= {}
conf[unit][:params].merge!(page_params)
Calendar.send(:create, unit, conf[unit])
end
end

public

# Return the current time of the smallest time unit shown
def showtime
self[@units.last].from
end
end
end
end
15 changes: 10 additions & 5 deletions lib/pagy/extras/calendar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,6 @@ def pagy_calendar(collection, conf)
[calendar, pagy, results]
end

def pagy_calendar_url_at(calendar, time)
pagy_url_for(calendar.send(:last_object_at, time), 1)
end

# This method must be implemented by the application
def pagy_calendar_period(*)
raise NoMethodError, 'the pagy_calendar_period method must be implemented by the application ' \
Expand All @@ -44,6 +40,15 @@ def pagy_calendar_filter(*)
'(see https://ddnexus.github.io/pagy/docs/extras/calendar/#pagy-calendar-filter-collection-from-to)'
end
end

# Additions for the Frontend module
module UrlHelper
# Return the url for the calendar page at time
def pagy_calendar_url_at(calendar, time, **opts)
pagy_url_for(calendar.send(:calendar_at, time, **opts), 1, **opts)
end
end
end
Backend.prepend CalendarExtra::Backend
Backend.prepend CalendarExtra::Backend, CalendarExtra::UrlHelper
Frontend.prepend CalendarExtra::UrlHelper
end
2 changes: 1 addition & 1 deletion lib/pagy/extras/standalone.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def build_nested_query(value, prefix = nil)
# Return the URL for the page. If there is no pagy.vars[:url]
# it works exactly as the regular #pagy_url_for, relying on the params method and Rack.
# If there is a defined pagy.vars[:url] variable it does not need the params method nor Rack.
def pagy_url_for(pagy, page, absolute: false, html_escaped: false)
def pagy_url_for(pagy, page, absolute: false, html_escaped: false, **_)
return super unless pagy.vars[:url]

vars = pagy.vars
Expand Down
2 changes: 1 addition & 1 deletion lib/pagy/url_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module UrlHelpers
# Return the URL for the page, relying on the params method and Rack by default.
# It supports all Rack-based frameworks (Sinatra, Padrino, Rails, ...).
# For non-rack environments you can use the standalone extra
def pagy_url_for(pagy, page, absolute: false, html_escaped: false)
def pagy_url_for(pagy, page, absolute: false, html_escaped: false, **_)
vars = pagy.vars
request_path = vars[:request_path].to_s.empty? ? request.path : vars[:request_path]
page_param = vars[:page_param].to_s
Expand Down
3 changes: 2 additions & 1 deletion test/coverage_setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
command_name "Task##{$PROCESS_ID}"
merge_timeout 20
enable_coverage :branch
add_filter "/test/"
add_group 'Core', %w[ lib/pagy.rb
lib/pagy/backend.rb
lib/pagy/console.rb
Expand All @@ -21,7 +22,7 @@
lib/pagy/i18n.rb
lib/pagy/url_helpers.rb ]
add_group 'Extras', 'lib/pagy/extras'
add_group 'Tests', 'test'
# add_group 'Tests', 'test'
end

SimpleCov.formatter = SimpleCov::Formatter::SimpleFormatter unless ENV.fetch('HTML_REPORTS', nil) == 'true'
Expand Down
62 changes: 51 additions & 11 deletions test/pagy/calendar_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ def pagy(unit: :month, **vars)
end

describe 'pagy/calendar' do

Check failure on line 15 in test/pagy/calendar_test.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.0 Test

Layout/EmptyLinesAroundBlockBody: Extra empty line detected at block body beginning.

Check failure on line 15 in test/pagy/calendar_test.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.1 Test

Layout/EmptyLinesAroundBlockBody: Extra empty line detected at block body beginning.

Check failure on line 15 in test/pagy/calendar_test.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.2 Test

Layout/EmptyLinesAroundBlockBody: Extra empty line detected at block body beginning.

Check failure on line 15 in test/pagy/calendar_test.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.3 Test

Layout/EmptyLinesAroundBlockBody: Extra empty line detected at block body beginning.
describe "Unit subclasses feedback methods" do
it "raises NoMethodError for starting_time_for" do
_ { pagy.starting_time_for }.must_raise NoMethodError
end
it "raises NoMethodError for page_offset_at" do
_ { pagy.page_offset_at }.must_raise NoMethodError
end
end

describe 'instance methods and variables' do
it 'defines calendar specific accessors' do
assert_respond_to pagy, :order
Expand Down Expand Up @@ -277,72 +287,102 @@ def pagy(unit: :month, **vars)
_(p.send(:page_at, Time.zone.local(2021, 10, 21, 13, 18, 23, 0))).must_equal 1
_(p.send(:page_at, Time.zone.local(2022, 1, 1, 13, 18, 23, 0))).must_equal 2
_(p.send(:page_at, Time.zone.local(2023, 11, 13, 15, 43, 40, 0))).must_equal 3
_ { p.send(:page_at, Time.zone.local(2030)) }.must_raise Pagy::Calendar::OutOfRangeError
_(p.send(:page_at, Time.zone.local(2100), fit_time: true)).must_equal 3
_(p.send(:page_at, Time.zone.local(2000), fit_time: true)).must_equal 1
_ { p.send(:page_at, Time.zone.local(2100)) }.must_raise Pagy::Calendar::OutOfRangeError
_ { p.send(:page_at, Time.zone.local(2000)) }.must_raise Pagy::Calendar::OutOfRangeError
end
it 'returns the page number for :year desc' do
p = pagy(unit: :year, order: :desc)
_(p.send(:page_at, Time.zone.local(2021, 10, 21, 13, 18, 23, 0))).must_equal 3
_(p.send(:page_at, Time.zone.local(2022, 1, 1, 13, 18, 23, 0))).must_equal 2
_(p.send(:page_at, Time.zone.local(2023, 11, 13, 15, 43, 40, 0))).must_equal 1
_ { p.send(:page_at, Time.zone.local(2030)) }.must_raise Pagy::Calendar::OutOfRangeError
_(p.send(:page_at, Time.zone.local(2100), fit_time: true)).must_equal 1
_(p.send(:page_at, Time.zone.local(2000), fit_time: true)).must_equal 3
_ { p.send(:page_at, Time.zone.local(2100)) }.must_raise Pagy::Calendar::OutOfRangeError
_ { p.send(:page_at, Time.zone.local(2000)) }.must_raise Pagy::Calendar::OutOfRangeError
end

it 'returns the page number for :quarter' do
p = pagy(unit: :quarter)
_(p.send(:page_at, Time.zone.local(2021, 10, 21, 13, 18, 23, 0))).must_equal 1
_(p.send(:page_at, Time.zone.local(2022, 1, 1, 13, 18, 23, 0))).must_equal 2
_(p.send(:page_at, Time.zone.local(2023, 11, 13, 15, 43, 40, 0))).must_equal 9
_ { p.send(:page_at, Time.zone.local(2030)) }.must_raise Pagy::Calendar::OutOfRangeError
_(p.send(:page_at, Time.zone.local(2100), fit_time: true)).must_equal 9
_(p.send(:page_at, Time.zone.local(2000), fit_time: true)).must_equal 1
_ { p.send(:page_at, Time.zone.local(2100)) }.must_raise Pagy::Calendar::OutOfRangeError
_ { p.send(:page_at, Time.zone.local(2000)) }.must_raise Pagy::Calendar::OutOfRangeError
end
it 'returns the page number for :quarter desc' do
p = pagy(unit: :quarter, order: :desc)
_(p.send(:page_at, Time.zone.local(2021, 10, 21, 13, 18, 23, 0))).must_equal 9
_(p.send(:page_at, Time.zone.local(2022, 1, 1, 13, 18, 23, 0))).must_equal 8
_(p.send(:page_at, Time.zone.local(2023, 11, 13, 15, 43, 40, 0))).must_equal 1
_ { p.send(:page_at, Time.zone.local(2030)) }.must_raise Pagy::Calendar::OutOfRangeError
_(p.send(:page_at, Time.zone.local(2100), fit_time: true)).must_equal 1
_(p.send(:page_at, Time.zone.local(2000), fit_time: true)).must_equal 9
_ { p.send(:page_at, Time.zone.local(2100)) }.must_raise Pagy::Calendar::OutOfRangeError
_ { p.send(:page_at, Time.zone.local(2000)) }.must_raise Pagy::Calendar::OutOfRangeError
end
it 'returns the page number for :month' do
p = pagy(unit: :month)
_(p.send(:page_at, Time.zone.local(2021, 10, 21, 13, 18, 23, 0))).must_equal 1
_(p.send(:page_at, Time.zone.local(2022, 1, 1, 13, 18, 23, 0))).must_equal 4
_(p.send(:page_at, Time.zone.local(2023, 11, 13, 15, 43, 40, 0))).must_equal 26
_ { p.send(:page_at, Time.zone.local(2030)) }.must_raise Pagy::Calendar::OutOfRangeError
_(p.send(:page_at, Time.zone.local(2100), fit_time: true)).must_equal 26
_(p.send(:page_at, Time.zone.local(2000), fit_time: true)).must_equal 1
_ { p.send(:page_at, Time.zone.local(2100)) }.must_raise Pagy::Calendar::OutOfRangeError
_ { p.send(:page_at, Time.zone.local(2000)) }.must_raise Pagy::Calendar::OutOfRangeError
end
it 'returns the page number for :month desc' do
p = pagy(unit: :month, order: :desc)
_(p.send(:page_at, Time.zone.local(2021, 10, 21, 13, 18, 23, 0))).must_equal 26
_(p.send(:page_at, Time.zone.local(2022, 1, 1, 13, 18, 23, 0))).must_equal 23
_(p.send(:page_at, Time.zone.local(2023, 11, 13, 15, 43, 40, 0))).must_equal 1
_ { p.send(:page_at, Time.zone.local(2030)) }.must_raise Pagy::Calendar::OutOfRangeError
_(p.send(:page_at, Time.zone.local(2100), fit_time: true)).must_equal 1
_(p.send(:page_at, Time.zone.local(2000), fit_time: true)).must_equal 26
_ { p.send(:page_at, Time.zone.local(2100)) }.must_raise Pagy::Calendar::OutOfRangeError
_ { p.send(:page_at, Time.zone.local(2000)) }.must_raise Pagy::Calendar::OutOfRangeError
end

it 'returns the page number for :week' do
p = pagy(unit: :week)
_(p.send(:page_at, Time.zone.local(2021, 10, 21))).must_equal 1
_(p.send(:page_at, Time.zone.local(2021, 10, 26))).must_equal 2
_(p.send(:page_at, Time.zone.local(2023, 11, 13))).must_equal 109
_ { p.send(:page_at, Time.zone.local(2030)) }.must_raise Pagy::Calendar::OutOfRangeError
_(p.send(:page_at, Time.zone.local(2100), fit_time: true)).must_equal 109
_(p.send(:page_at, Time.zone.local(2000), fit_time: true)).must_equal 1
_ { p.send(:page_at, Time.zone.local(2100)) }.must_raise Pagy::Calendar::OutOfRangeError
_ { p.send(:page_at, Time.zone.local(2000)) }.must_raise Pagy::Calendar::OutOfRangeError
end
it 'returns the page number for :day desc' do
it 'returns the page number for :week desc' do
p = pagy(unit: :week, order: :desc)
_(p.send(:page_at, Time.zone.local(2021, 10, 21))).must_equal 109
_(p.send(:page_at, Time.zone.local(2021, 10, 26))).must_equal 108
_(p.send(:page_at, Time.zone.local(2023, 11, 13))).must_equal 1
_ { p.send(:page_at, Time.zone.local(2030)) }.must_raise Pagy::Calendar::OutOfRangeError
_(p.send(:page_at, Time.zone.local(2100), fit_time: true)).must_equal 1
_(p.send(:page_at, Time.zone.local(2000), fit_time: true)).must_equal 109
_ { p.send(:page_at, Time.zone.local(2100)) }.must_raise Pagy::Calendar::OutOfRangeError
_ { p.send(:page_at, Time.zone.local(2000)) }.must_raise Pagy::Calendar::OutOfRangeError
end
it 'returns the page number for :day' do
p = pagy(unit: :day)
_(p.send(:page_at, Time.zone.local(2021, 10, 21))).must_equal 1
_(p.send(:page_at, Time.zone.local(2021, 10, 26))).must_equal 6
_(p.send(:page_at, Time.zone.local(2023, 11, 13))).must_equal 754
_ { p.send(:page_at, Time.zone.local(2030)) }.must_raise Pagy::Calendar::OutOfRangeError
_(p.send(:page_at, Time.zone.local(2100), fit_time: true)).must_equal 754
_(p.send(:page_at, Time.zone.local(2000), fit_time: true)).must_equal 1
_ { p.send(:page_at, Time.zone.local(2100)) }.must_raise Pagy::Calendar::OutOfRangeError
_ { p.send(:page_at, Time.zone.local(2000)) }.must_raise Pagy::Calendar::OutOfRangeError
end
it 'returns the page number for :day desc' do
p = pagy(unit: :day, order: :desc)
_(p.send(:page_at, Time.zone.local(2021, 10, 21))).must_equal 754
_(p.send(:page_at, Time.zone.local(2021, 10, 26))).must_equal 749
_(p.send(:page_at, Time.zone.local(2023, 11, 13))).must_equal 1
_ { p.send(:page_at, Time.zone.local(2030)) }.must_raise Pagy::Calendar::OutOfRangeError
_(p.send(:page_at, Time.zone.local(2100), fit_time: true)).must_equal 1
_(p.send(:page_at, Time.zone.local(2000), fit_time: true)).must_equal 754
_ { p.send(:page_at, Time.zone.local(2100)) }.must_raise Pagy::Calendar::OutOfRangeError
_ { p.send(:page_at, Time.zone.local(2000)) }.must_raise Pagy::Calendar::OutOfRangeError
end
end

Expand Down
25 changes: 21 additions & 4 deletions test/pagy/extras/calendar_extra_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,28 @@ def app(**opts)
_(app.send(:pagy_calendar_url_at, calendar, Time.zone.local(2023, 11, 10)))
.must_equal "/foo?page=1&year_page=3&month_page=11"

_ { app.send(:pagy_calendar_url_at, calendar, Time.zone.local(2024, 1, 10)) }
.must_raise Pagy::Calendar::OutOfRangeError
_(app.send(:pagy_calendar_url_at, calendar, Time.zone.local(2100), fit_time: true))
.must_equal "/foo?page=1&year_page=3&month_page=11"

_ { app.send(:pagy_calendar_url_at, calendar, Time.zone.local(2021, 9, 10)) }
.must_raise Pagy::Calendar::OutOfRangeError
_(app.send(:pagy_calendar_url_at, calendar, Time.zone.local(2000), fit_time: true))
.must_equal "/foo?page=1&year_page=1&month_page=1"

_ { app.send(:pagy_calendar_url_at, calendar, Time.zone.local(2100)) }
.must_raise Pagy::Calendar::OutOfRangeError

_ { app.send(:pagy_calendar_url_at, calendar, Time.zone.local(2000)) }
.must_raise Pagy::Calendar::OutOfRangeError
end
end
describe "#showtime" do
it "returns the showtime" do
collection = MockCollection::Calendar.new(@collection)
calendar, _pagy, _entries = app(params: { year_page: 2, month_page: 7, page: 2 })
.send(:pagy_calendar, collection, year: {},
month: {},
pagy: { items: 10 })
_(calendar.showtime).must_equal Time.zone.local(2022, 7, 1)
end
end

Check failure on line 254 in test/pagy/extras/calendar_extra_test.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.0 Test

Layout/EmptyLinesAroundBlockBody: Extra empty line detected at block body end.

Check failure on line 254 in test/pagy/extras/calendar_extra_test.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.1 Test

Layout/EmptyLinesAroundBlockBody: Extra empty line detected at block body end.

Check failure on line 254 in test/pagy/extras/calendar_extra_test.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.2 Test

Layout/EmptyLinesAroundBlockBody: Extra empty line detected at block body end.

Check failure on line 254 in test/pagy/extras/calendar_extra_test.rb

View workflow job for this annotation

GitHub Actions / Ruby 3.3 Test

Layout/EmptyLinesAroundBlockBody: Extra empty line detected at block body end.
end
Loading

0 comments on commit cd49216

Please sign in to comment.