-
Notifications
You must be signed in to change notification settings - Fork 397
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add
p/jeronimoalbi/pager
package
- Loading branch information
1 parent
bf2e03f
commit f15ba66
Showing
4 changed files
with
351 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module gno.land/p/jeronimoalbi/pager |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} | ||
}) | ||
} | ||
} |