diff --git a/examples/gno.land/p/jeronimoalbi/pager/gno.mod b/examples/gno.land/p/jeronimoalbi/pager/gno.mod new file mode 100644 index 00000000000..e775954b9fe --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/pager/gno.mod @@ -0,0 +1 @@ +module gno.land/p/jeronimoalbi/pager diff --git a/examples/gno.land/p/jeronimoalbi/pager/pager.gno b/examples/gno.land/p/jeronimoalbi/pager/pager.gno new file mode 100644 index 00000000000..7b8a1948b3b --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/pager/pager.gno @@ -0,0 +1,204 @@ +// Package pager provides pagination functionality through a generic pager implementation. +// +// Example usage: +// +// import ( +// "strconv" +// "strings" +// +// "gno.land/p/jeronimoalbi/pager" +// ) +// +// func Render(path string) string { +// // Define the items to paginate +// items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} +// +// // Create a pager that paginates 4 items at a time +// p, err := pager.New(path, len(items), pager.WithPageSize(4)) +// if err != nil { +// panic(err) +// } +// +// // Render items for the current page +// var output strings.Builder +// p.Iterate(func(i int) bool { +// output.WriteString("- " + strconv.Itoa(items[i]) + "\n") +// return false +// }) +// +// // Render page picker +// if p.HasPages() { +// output.WriteString("\n" + pager.Picker(p)) +// } +// +// return output.String() +// } +package pager + +import ( + "errors" + "math" + "net/url" + "strconv" + "strings" +) + +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() + end := start + p.PageSize() + if end > p.totalItems { + end = p.totalItems + } + + for i := start; i < end; 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. +// An empty string is returned when the pager has no pages. +func Picker(p Pager) string { + if !p.HasPages() { + return "" + } + + var out strings.Builder + + if s := p.PrevPageURI(); s != "" { + out.WriteString("[«](" + s + ") | ") + } else { + out.WriteString("\\- | ") + } + + out.WriteString("page " + strconv.Itoa(p.Page()) + " of " + strconv.Itoa(p.PageCount())) + + if s := p.NextPageURI(); s != "" { + out.WriteString(" | [»](" + s + ")") + } else { + out.WriteString(" | \\-") + } + + return out.String() +} diff --git a/examples/gno.land/p/jeronimoalbi/pager/pager_options.gno b/examples/gno.land/p/jeronimoalbi/pager/pager_options.gno new file mode 100644 index 00000000000..3feb467682b --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/pager/pager_options.gno @@ -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 + } +} diff --git a/examples/gno.land/p/jeronimoalbi/pager/pager_test.gno b/examples/gno.land/p/jeronimoalbi/pager/pager_test.gno new file mode 100644 index 00000000000..f498079cad3 --- /dev/null +++ b/examples/gno.land/p/jeronimoalbi/pager/pager_test.gno @@ -0,0 +1,204 @@ +package pager + +import ( + "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/demo/test: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/demo/test: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/demo/test: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/demo/test:foo/bar?page=3&foo=bar", + err: ErrInvalidPageNumber, + }, + { + name: "invalid page zero", + uri: "gno.land/r/demo/test:foo/bar?page=0", + err: ErrInvalidPageNumber, + }, + { + name: "invalid page number", + uri: "gno.land/r/demo/test: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 bool + }{ + { + name: "page 1", + uri: "gno.land/r/demo/test:foo/bar?page=1", + items: []int{1, 2, 3, 4, 5, 6, 7}, + page: []int{1, 2, 3}, + }, + { + name: "page 2", + uri: "gno.land/r/demo/test:foo/bar?page=2", + items: []int{1, 2, 3, 4, 5, 6, 7}, + page: []int{4, 5, 6}, + }, + { + name: "page 3", + uri: "gno.land/r/demo/test:foo/bar?page=3", + items: []int{1, 2, 3, 4, 5, 6, 7}, + page: []int{7}, + }, + { + name: "stop iteration", + uri: "gno.land/r/demo/test:foo/bar?page=1", + items: []int{1, 2, 3}, + stop: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Arrange + var ( + items []int + p = MustNew(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.stop, 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") + } + }) + } +} + +func TestPicker(t *testing.T) { + pageSize := 3 + cases := []struct { + name, uri, output string + totalItems int + }{ + { + name: "one page", + uri: "gno.land/r/demo/test:foo/bar?page=1", + totalItems: 3, + output: "", + }, + { + name: "two pages", + uri: "gno.land/r/demo/test:foo/bar?page=1", + totalItems: 4, + output: "\\- | page 1 of 2 | [»](?page=2)", + }, + { + name: "three pages", + uri: "gno.land/r/demo/test:foo/bar?page=1", + totalItems: 7, + output: "\\- | page 1 of 3 | [»](?page=2)", + }, + { + name: "three pages second page", + uri: "gno.land/r/demo/test:foo/bar?page=2", + totalItems: 7, + output: "[«](?page=1) | page 2 of 3 | [»](?page=3)", + }, + { + name: "three pages third page", + uri: "gno.land/r/demo/test:foo/bar?page=3", + totalItems: 7, + output: "[«](?page=2) | page 3 of 3 | \\-", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Arrange + p := MustNew(tc.uri, tc.totalItems, WithPageSize(pageSize)) + + // Act + output := Picker(p) + + // Assert + uassert.Equal(t, tc.output, output) + }) + } +}