~netlandish/links-dev

links: Adding pagination ordering on bookmark listing page (the getOrgLinks GraphQL call). Supports basic descending (newest first) and ascending (oldest first). v1 APPLIED

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(-)
Export patchset (mbox)
How do I use this?

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 -3
Learn more about email & git

[PATCH links] Adding pagination ordering on bookmark listing page (the getOrgLinks GraphQL call). Supports basic descending (newest first) and ascending (oldest first). Export this patch

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>&nbsp; &bull;&nbsp; <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>&nbsp; &bull;&nbsp; <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