Peter Sanchez: 1 Adding pagination ordering on bookmark listing page (the getOrgLinks GraphQL call). Supports basic descending (newest first) and ascending (oldest first). 10 files changed, 177 insertions(+), 22 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/137/mbox | git am -3Learn more about email & git
Implements: https://todo.code.netlandish.com/~netlandish/links/90 Signed-off-by: Peter Sanchez <peter@netlandish.com> Changelog-added: Pagination ordering on bookmark listing pages. --- api/graph/generated.go | 25 ++++++++++++++++++++- api/graph/model/models_gen.go | 42 +++++++++++++++++++++++++++++++++++ api/graph/pagination.go | 31 ++++++++++++++++++++++---- api/graph/schema.graphqls | 6 +++++ api/graph/schema.resolvers.go | 24 ++++++++++++++++++-- cmd/links/main.go | 10 +++++++-- cmd/test/helpers.go | 10 +++++++-- core/routes.go | 14 +++++++++++- helpers.go | 17 +++++++++----- templates/link_list.html | 20 +++++++++++++---- 10 files changed, 177 insertions(+), 22 deletions(-) diff --git a/api/graph/generated.go b/api/graph/generated.go index c964512..ff56a5e 100644 --- a/api/graph/generated.go +++ b/api/graph/generated.go @@ -25613,7 +25613,7 @@ func (ec *executionContext) unmarshalInputGetLinkInput(ctx context.Context, obj asMap[k] = v } - fieldsInOrder := [...]string{"orgSlug", "limit", "after", "before", "tag", "excludeTag", "search", "filter", "tagCloudType", "tagCloudOrder"} + fieldsInOrder := [...]string{"orgSlug", "limit", "after", "before", "tag", "excludeTag", "search", "filter", "order", "tagCloudType", "tagCloudOrder"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -25676,6 +25676,13 @@ func (ec *executionContext) unmarshalInputGetLinkInput(ctx context.Context, obj return it, err } it.Filter = data + case "order": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("order")) + data, err := ec.unmarshalOOrderType2ᚖlinksᚋapiᚋgraphᚋmodelᚐOrderType(ctx, v) + if err != nil { + return it, err + } + it.Order = data case "tagCloudType": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("tagCloudType")) data, err := ec.unmarshalOCloudType2ᚖlinksᚋapiᚋgraphᚋmodelᚐCloudType(ctx, v) @@ -33038,6 +33045,22 @@ func (ec *executionContext) unmarshalONoteInput2ᚖlinksᚋapiᚋgraphᚋmodel return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalOOrderType2ᚖlinksᚋapiᚋgraphᚋmodelᚐOrderType(ctx context.Context, v interface{}) (*model.OrderType, error) { + if v == nil { + return nil, nil + } + var res = new(model.OrderType) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOOrderType2ᚖlinksᚋapiᚋgraphᚋmodelᚐOrderType(ctx context.Context, sel ast.SelectionSet, v *model.OrderType) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + func (ec *executionContext) marshalOOrgLink2ᚖlinksᚋmodelsᚐOrgLink(ctx context.Context, sel ast.SelectionSet, v *models.OrgLink) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go index 5d3bc9f..1d9c463 100644 --- a/api/graph/model/models_gen.go +++ b/api/graph/model/models_gen.go @@ -199,6 +199,7 @@ type GetLinkInput struct { ExcludeTag *string `json:"excludeTag,omitempty"` Search *string `json:"search,omitempty"` Filter *string `json:"filter,omitempty"` + Order *OrderType `json:"order,omitempty"` TagCloudType *CloudType `json:"tagCloudType,omitempty"` TagCloudOrder *CloudOrderType `json:"tagCloudOrder,omitempty"` } @@ -940,6 +941,47 @@ func (e MemberPermission) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +type OrderType string + +const ( + OrderTypeDesc OrderType = "DESC" + OrderTypeAsc OrderType = "ASC" +) + +var AllOrderType = []OrderType{ + OrderTypeDesc, + OrderTypeAsc, +} + +func (e OrderType) IsValid() bool { + switch e { + case OrderTypeDesc, OrderTypeAsc: + return true + } + return false +} + +func (e OrderType) String() string { + return string(e) +} + +func (e *OrderType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = OrderType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid OrderType", str) + } + return nil +} + +func (e OrderType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type OrgBillingStatus string const ( diff --git a/api/graph/pagination.go b/api/graph/pagination.go index 82043a4..6312c5a 100644 --- a/api/graph/pagination.go +++ b/api/graph/pagination.go @@ -37,10 +37,28 @@ func PaginateResults[T any](items []T, limit int, before, after *model.Cursor, return items, &pageInfo } +func cursorField(before, after *model.Cursor, idField, orderDir string) sq.Sqlizer { + if after != nil { + if orderDir == "DESC" { + return sq.Lt{idField: after.After} + } else if orderDir == "ASC" { + return sq.Gt{idField: after.After} + } + } else if before != nil { + if orderDir == "DESC" { + return sq.Gt{idField: before.Before} + } else if orderDir == "ASC" { + return sq.Lt{idField: before.Before} + } + } + // Should never be reached. + return nil +} + func QueryModel[T any]( ctx context.Context, opts *database.FilterOptions, - idField string, + idField, orderDir string, limit *int, before, after *model.Cursor, getModels func(context.Context, *database.FilterOptions) ([]T, error), @@ -50,15 +68,20 @@ func QueryModel[T any]( if after != nil { opts.Filter = sq.And{ opts.Filter, - sq.Lt{idField: after.After}, + cursorField(before, after, idField, orderDir), } numElements = after.Limit } else if before != nil { opts.Filter = sq.And{ opts.Filter, - sq.Gt{idField: before.Before}, + cursorField(before, after, idField, orderDir), + } + if orderDir == "DESC" { + opts.OrderBy = idField + " ASC" + } else { + opts.OrderBy = idField + " DESC" + } - opts.OrderBy = idField + " ASC" numElements = before.Limit } diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index e170c97..adfa596 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -128,6 +128,11 @@ enum CloudOrderType { NAME_DESC } +enum OrderType { + DESC + ASC +} + # Considering removing these Null* fields: # https://todo.code.netlandish.com/~netlandish/links/75 @@ -601,6 +606,7 @@ input GetLinkInput { excludeTag: String search: String filter: String + order: OrderType tagCloudType: CloudType tagCloudOrder: CloudOrderType } diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index ec38373..18a5734 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -4777,7 +4777,7 @@ func (r *qRCodeResolver) CodeType(ctx context.Context, obj *models.QRCode) (mode func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) { return &model.Version{ Major: 0, - Minor: 3, + Minor: 4, Patch: 1, DeprecationDate: nil, }, nil @@ -4984,6 +4984,7 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP ctx, opts, "i.id", + "DESC", input.Limit, input.Before, input.After, @@ -5236,6 +5237,7 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp return nil, nil } + order := model.OrderTypeDesc cloudType := model.CloudTypeLinks cloudOrder := model.CloudOrderTypeNameAsc if input.TagCloudType != nil && *input.TagCloudType != "" { @@ -5245,9 +5247,18 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp cloudOrder = *input.TagCloudOrder } + if input.Order != nil { + order = *input.Order + } + + orderDir := "DESC" + if order == model.OrderTypeAsc { + orderDir = "ASC" + } + linkOpts := &database.FilterOptions{ Filter: sq.And{}, - OrderBy: "ol.id DESC", + OrderBy: "ol.id " + orderDir, } var org *models.Organization @@ -5348,6 +5359,7 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp ctx, linkOpts, "ol.id", + orderDir, input.Limit, input.Before, input.After, @@ -5621,6 +5633,7 @@ func (r *queryResolver) GetLinkShorts(ctx context.Context, input *model.GetLinkS ctx, linkOpts, "l.id", + "DESC", input.Limit, input.Before, input.After, @@ -5744,6 +5757,7 @@ func (r *queryResolver) GetListings(ctx context.Context, input *model.GetListing ctx, listingOpts, "l.id", + "DESC", input.Limit, input.Before, input.After, @@ -5828,6 +5842,7 @@ func (r *queryResolver) GetListing(ctx context.Context, input *model.GetListingD ctx, linkOpts, "ll.link_order", + "DESC", input.Limit, input.Before, input.After, @@ -6412,6 +6427,7 @@ func (r *queryResolver) GetFeed(ctx context.Context, input *model.GetFeedInput) ctx, linkOpts, "ol.id", + "DESC", input.Limit, input.Before, input.After, @@ -6596,6 +6612,7 @@ func (r *queryResolver) GetAuditLogs(ctx context.Context, input *model.AuditLogI ctx, opts, "al.id", + "DESC", input.Limit, input.Before, input.After, @@ -6659,6 +6676,7 @@ func (r *queryResolver) GetUsers(ctx context.Context, input *model.GetUserInput) ctx, opts, "u.id", + "DESC", input.Limit, input.Before, input.After, @@ -6750,6 +6768,7 @@ func (r *queryResolver) GetAdminOrganizations(ctx context.Context, input *model. ctx, opts, "o.id", + "DESC", input.Limit, input.Before, input.After, @@ -7000,6 +7019,7 @@ func (r *queryResolver) GetAdminDomains(ctx context.Context, input *model.GetAdm ctx, opts, "d.id", + "DESC", input.Limit, input.Before, input.After, diff --git a/cmd/links/main.go b/cmd/links/main.go index b8d7ade..2916ce0 100644 --- a/cmd/links/main.go +++ b/cmd/links/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + htemplate "html/template" "net/http" "net/url" "os" @@ -293,8 +294,13 @@ func run() error { } return slices.Contains(tags, strings.TrimSpace(tag)) }, - "addQueryElement": links.AddQueryElement, - "getAddLinkURL": links.GetAddLinkURL, + "addQueryElement": func(q htemplate.URL, param, val string) htemplate.URL { + return links.AddQueryElement(q, param, val, false) + }, + "setQueryElement": func(q htemplate.URL, param, val string) htemplate.URL { + return links.AddQueryElement(q, param, val, true) + }, + "getAddLinkURL": links.GetAddLinkURL, "newlinebr": func(blob string) string { return strings.ReplaceAll(blob, "\n", "<br />\n") }, diff --git a/cmd/test/helpers.go b/cmd/test/helpers.go index 1396162..d6d350e 100644 --- a/cmd/test/helpers.go +++ b/cmd/test/helpers.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/hex" "fmt" + htemplate "html/template" "io" "links" "links/accounts" @@ -130,8 +131,13 @@ func NewWebTestServer(t *testing.T) (*server.Server, *echo.Echo) { "isTagUsedInFilter": func(tag string, activeTags string) bool { return strings.Contains(activeTags, tag) }, - "addQueryElement": links.AddQueryElement, - "getAddLinkURL": links.GetAddLinkURL, + "addQueryElement": func(q htemplate.URL, param, val string) htemplate.URL { + return links.AddQueryElement(q, param, val, false) + }, + "setQueryElement": func(q htemplate.URL, param, val string) htemplate.URL { + return links.AddQueryElement(q, param, val, true) + }, + "getAddLinkURL": links.GetAddLinkURL, "newlinebr": func(blob string) string { return strings.ReplaceAll(blob, "\n", "<br />\n") }, diff --git a/core/routes.go b/core/routes.go index e97ef17..76ad4f8 100644 --- a/core/routes.go +++ b/core/routes.go @@ -2065,7 +2065,8 @@ func (s *Service) OrgLinksList(c echo.Context) error { op := gqlclient.NewOperation( `query GetOrgLinks($slug: String, $after: Cursor, $before: Cursor, $tag: String, $excludeTag: String, $search: String, - $filter: String, $cloudType: CloudType, $cloudOrder: CloudOrderType) { + $filter: String, $order: OrderType, $cloudType: CloudType, + $cloudOrder: CloudOrderType) { getOrgLinks(input: { orgSlug: $slug, after: $after, @@ -2074,6 +2075,7 @@ func (s *Service) OrgLinksList(c echo.Context) error { excludeTag: $excludeTag, search: $search, filter: $filter, + order: $order, tagCloudType: $cloudType, tagCloudOrder: $cloudOrder }) { @@ -2274,6 +2276,13 @@ func (s *Service) OrgLinksList(c echo.Context) error { queries.Add("q", search) } + orderDir := c.QueryParam("order") + if orderDir != "" && (orderDir == "DESC" || orderDir == "ASC") { + op.Var("order", orderDir) + } else { + orderDir = "DESC" // default + } + err = links.Execute(links.LangContext(c), op, &result) if err != nil { if graphError, ok := err.(*gqlclient.Error); ok { @@ -2319,6 +2328,8 @@ func (s *Service) OrgLinksList(c echo.Context) error { pd.Data["follow"] = lt.Translate("Follow") pd.Data["unfollow"] = lt.Translate("Unfollow") pd.Data["tags"] = lt.Translate("Tags") + pd.Data["newest"] = lt.Translate("newest") + pd.Data["oldest"] = lt.Translate("oldest") orgLinks := result.OrgLinks.Result if links.IsRSS(c.Path()) { domain := fmt.Sprintf("%s://%s", gctx.Server.Config.Scheme, gctx.Server.Config.Domain) @@ -2409,6 +2420,7 @@ func (s *Service) OrgLinksList(c echo.Context) error { "rssURL": rssURL, "followAction": followAction, "tagCloud": result.OrgLinks.TagCloud, + "orderDir": orderDir, } if search != "" { diff --git a/helpers.go b/helpers.go index a2fbc3d..9ce16dc 100644 --- a/helpers.go +++ b/helpers.go @@ -879,18 +879,23 @@ func ParseSearch(s string) string { return s } -func AddQueryElement(q template.URL, param, val string) template.URL { +func AddQueryElement(q template.URL, param, val string, replace bool) template.URL { query, err := url.ParseQuery(string(q)) if err != nil { return "" } - curVal := query.Get(param) - if curVal == "" { - curVal = val + + if !replace { + curVal := query.Get(param) + if curVal == "" { + curVal = val + } else { + curVal += fmt.Sprintf(",%s", val) + } + query.Set(param, curVal) } else { - curVal += fmt.Sprintf(",%s", val) + query.Set(param, val) } - query.Set(param, curVal) return template.URL(query.Encode()) } diff --git a/templates/link_list.html b/templates/link_list.html index a019bbd..e9c053c 100644 --- a/templates/link_list.html +++ b/templates/link_list.html @@ -226,9 +226,21 @@ </footer> {{end}} </div> {{- /* col-9 */ -}} - <div class="col-3"> + <div class="col-3"> {{- /* right side bar */ -}} + <div class="row"> + <div class="col-2"> + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="menu-item__icon"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" /></svg> + </div> + <div class="col is-vertical-align"> + {{ if eq .orderDir "DESC" }} + <small class="link-tag__side link-tag__item--simple">{{ .pd.Data.newest }}</small> • <a href="{{if $.queries}}?{{setQueryElement $.queries "order" "ASC"}}{{else}}?order=ASC{{end}}" class="link-tag__side link-tag__item--simple">{{ .pd.Data.oldest }}</a> + {{ else }} + <a href="{{if $.queries}}?{{setQueryElement $.queries "order" "DESC"}}{{else}}?order=DESC{{end}}" class="link-tag__side link-tag__item--simple">{{ .pd.Data.newest }}</a> • <small class="link-tag__side link-tag__item--simple">{{ .pd.Data.oldest }}</small> + {{ end }} + </div> + </div> <p>{{ .pd.Data.tags }}</p> - <hr></hr> + <hr> <p> {{ range .tagCloud }} {{if isTagUsedInFilter .Slug $.tagFilter}} @@ -238,7 +250,7 @@ {{end}} {{ end }} </p> - </div> - </div> {{- /* row */ -}} + </div> {{- /* end right side bar */ -}} + </div> {{- /* end row */ -}} </section> {{template "base_footer" .}} -- 2.47.2
Applied. To git@git.code.netlandish.com:~netlandish/links f0cf52a..4b576a8 master -> master