Skip to content

Commit

Permalink
feat: add p/jeronimoalbi/pager package
Browse files Browse the repository at this point in the history
  • Loading branch information
jeronimoalbi committed Feb 21, 2025
1 parent bf2e03f commit f15ba66
Show file tree
Hide file tree
Showing 4 changed files with 351 additions and 0 deletions.
1 change: 1 addition & 0 deletions examples/gno.land/p/jeronimoalbi/pager/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/p/jeronimoalbi/pager
157 changes: 157 additions & 0 deletions examples/gno.land/p/jeronimoalbi/pager/pager.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package pager

import (
"errors"
"math"
"net/url"
"strconv"
)

var ErrInvalidPageNumber = errors.New("invalid page number")

// PagerIterFn defines a callback to iterate page items.
type PagerIterFn func(index int) (stop bool)

// New creates a new pager.
func New(rawURL string, totalItems int, options ...PagerOption) (Pager, error) {
u, err := url.Parse(rawURL)
if err != nil {
return Pager{}, err
}

p := Pager{
query: u.RawQuery,
pageQueryParam: DefaultPageQueryParam,
pageSize: DefaultPageSize,
page: 1,
totalItems: totalItems,
}
for _, apply := range options {
apply(&p)
}

p.pageCount = int(math.Ceil(float64(p.totalItems) / float64(p.pageSize)))

rawPage := u.Query().Get(p.pageQueryParam)
if rawPage != "" {
p.page, _ = strconv.Atoi(rawPage)
if p.page == 0 || p.page > p.pageCount {
return Pager{}, ErrInvalidPageNumber
}
}

return p, nil
}

// MustNew creates a new pager or panics if there is an error.
func MustNew(rawURL string, totalItems int, options ...PagerOption) Pager {
p, err := New(rawURL, totalItems, options...)
if err != nil {
panic(err)
}
return p
}

// Pager allows paging items.
type Pager struct {
query, pageQueryParam string
pageSize, page, pageCount, totalItems int
}

// TotalItems returns the total number of items to paginate.
func (p Pager) TotalItems() int {
return p.totalItems
}

// PageSize returns the size of each page.
func (p Pager) PageSize() int {
return p.pageSize
}

// Page returns the current page number.
func (p Pager) Page() int {
return p.page
}

// PageCount returns the number pages.
func (p Pager) PageCount() int {
return p.pageCount
}

// Offset returns the index of the first page item.
func (p Pager) Offset() int {
return (p.page - 1) * p.pageSize
}

// HasPages checks if pager has more than one page.
func (p Pager) HasPages() bool {
return p.pageCount > 1
}

// GetPageURI returns the URI for a page.
// An empty string is returned when page doesn't exist.
func (p Pager) GetPageURI(page int) string {
if page < 1 || page > p.PageCount() {
return ""
}

values, _ := url.ParseQuery(p.query)
values.Set(p.pageQueryParam, strconv.Itoa(page))
return "?" + values.Encode()
}

// PrevPageURI returns the URI path to the previous page.
// An empty string is returned when current page is the first page.
func (p Pager) PrevPageURI() string {
if p.page == 1 || !p.HasPages() {
return ""
}
return p.GetPageURI(p.page - 1)
}

// NextPageURI returns the URI path to the next page.
// An empty string is returned when current page is the last page.
func (p Pager) NextPageURI() string {
if p.page == p.pageCount {
// Current page is the last page
return ""
}
return p.GetPageURI(p.page + 1)
}

// Iterate allows iterating page items.
func (p Pager) Iterate(fn PagerIterFn) bool {
if p.totalItems == 0 {
return true
}

start := p.Offset()
for i := start; i < start+p.PageSize(); i++ {
if fn(i) {
return true
}
}
return false
}

// TODO: Support different types of pickers (ex. with clickable page numbers)

// Picker returns a string with the pager as Markdown.
func Picker(p Pager) string {
var out string
if s := p.PrevPageURI(); s != "" {
out = "[«](" + s + ") | "
} else {
out = "\\- | "
}

out += "page " + strconv.Itoa(p.Page()) + " of " + strconv.Itoa(p.PageCount())

if s := p.NextPageURI(); s != "" {
out += " | [»](" + s + ")"
} else {
out += " | \\-"
}

return out
}
33 changes: 33 additions & 0 deletions examples/gno.land/p/jeronimoalbi/pager/pager_options.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package pager

import "strings"

const (
DefaultPageSize = 50
DefaultPageQueryParam = "page"
)

// PagerOption configures the pager.
type PagerOption func(*Pager)

// WithPageSize assigns a page size to a pager.
func WithPageSize(size int) PagerOption {
return func(p *Pager) {
if size < 1 {
p.pageSize = DefaultPageSize
} else {
p.pageSize = size
}
}
}

// WithPageQueryParam assigns the name of the URL query param for the page value.
func WithPageQueryParam(name string) PagerOption {
return func(p *Pager) {
name = strings.TrimSpace(name)
if name == "" {
name = DefaultPageQueryParam
}
p.pageQueryParam = name
}
}
160 changes: 160 additions & 0 deletions examples/gno.land/p/jeronimoalbi/pager/pager_test.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package pager

import (
"fmt"
"testing"

"gno.land/p/demo/uassert"
"gno.land/p/demo/urequire"
)

func TestPager(t *testing.T) {
cases := []struct {
name, uri, prevPath, nextPath, param string
offset, pageSize, page, pageCount int
hasPages bool
items []int
err error
}{
{
name: "page 1",
uri: "gno.land/r/devxteam/gnome:foo/bar?page=1&foo=bar",
items: []int{1, 2, 3, 4, 5, 6},
hasPages: true,
nextPath: "?foo=bar&page=2",
pageSize: 5,
page: 1,
pageCount: 2,
},
{
name: "page 2",
uri: "gno.land/r/devxteam/gnome:foo/bar?page=2&foo=bar",
items: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
hasPages: true,
prevPath: "?foo=bar&page=1",
nextPath: "",
offset: 5,
pageSize: 5,
page: 2,
pageCount: 2,
},
{
name: "custom query param",
uri: "gno.land/r/devxteam/gnome:foo/bar?current=2&foo=bar",
items: []int{1, 2, 3},
param: "current",
hasPages: true,
prevPath: "?current=1&foo=bar",
nextPath: "",
offset: 2,
pageSize: 2,
page: 2,
pageCount: 2,
},
{
name: "missing page",
uri: "gno.land/r/devxteam/gnome:foo/bar?page=3&foo=bar",
err: ErrInvalidPageNumber,
},
{
name: "invalid page zero",
uri: "gno.land/r/devxteam/gnome:foo/bar?page=0",
err: ErrInvalidPageNumber,
},
{
name: "invalid page number",
uri: "gno.land/r/devxteam/gnome:foo/bar?page=foo",
err: ErrInvalidPageNumber,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Act
p, err := New(tc.uri, len(tc.items), WithPageSize(tc.pageSize), WithPageQueryParam(tc.param))

// Assert
if tc.err != nil {
urequire.ErrorIs(t, err, tc.err, "expected an error")
return
}

urequire.NoError(t, err, "expect no error")
uassert.Equal(t, len(tc.items), p.TotalItems(), "total items")
uassert.Equal(t, tc.page, p.Page(), "page number")
uassert.Equal(t, tc.pageCount, p.PageCount(), "number of pages")
uassert.Equal(t, tc.pageSize, p.PageSize(), "page size")
uassert.Equal(t, tc.prevPath, p.PrevPageURI(), "prev URL page")
uassert.Equal(t, tc.nextPath, p.NextPageURI(), "next URL page")
uassert.Equal(t, tc.hasPages, p.HasPages(), "has pages")
uassert.Equal(t, tc.offset, p.Offset(), "item offset")
})
}
}

func TestPagerIterate(t *testing.T) {
cases := []struct {
name, uri string
items, page []int
stop, stopped bool
}{
{
name: "page 1",
uri: "gno.land/r/devxteam/gnome:foo/bar?page=1",
items: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
page: []int{1, 2, 3},
},
{
name: "page 2",
uri: "gno.land/r/devxteam/gnome:foo/bar?page=2",
items: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
page: []int{4, 5, 6},
},
{
name: "page 3",
uri: "gno.land/r/devxteam/gnome:foo/bar?page=3",
items: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
page: []int{7, 8, 9},
},
{
name: "invalid page",
uri: "gno.land/r/devxteam/gnome:foo/bar?page=2",
items: []int{1, 2, 3},
stopped: true,
},
{
name: "stop iteration",
uri: "gno.land/r/devxteam/gnome:foo/bar?page=1",
items: []int{1, 2, 3},
stop: true,
stopped: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Arrange
var (
items []int
p, _ = New(tc.uri, len(tc.items), WithPageSize(3))
)

// Act
stopped := p.Iterate(func(i int) bool {
if tc.stop {
return true
}

items = append(items, tc.items[i])
return false
})

// Assert
uassert.Equal(t, tc.stopped, stopped)
urequire.Equal(t, len(tc.page), len(items), "expect iteration of the right number of items")

for i, v := range items {
urequire.Equal(t, tc.page[i], v, "expect iterated items to match")
}
})
}
}

0 comments on commit f15ba66

Please sign in to comment.