Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166]) by code.netlandish.com (Postfix) with ESMTP id A63C2308 for <~netlandish/links-dev@lists.code.netlandish.com>; Wed, 30 Apr 2025 20:19:26 +0000 (UTC) Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.222.46; helo=mail-ua1-f46.google.com; envelope-from=peter@netlandish.com; receiver= Authentication-Results: mail.netlandish.com; dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=WYkaHAyq Received: from mail-ua1-f46.google.com (mail-ua1-f46.google.com [209.85.222.46]) by mail.netlandish.com (Postfix) with ESMTP id 161921D6437 for <~netlandish/links-dev@lists.code.netlandish.com>; Wed, 30 Apr 2025 20:19:39 +0000 (UTC) Received: by mail-ua1-f46.google.com with SMTP id a1e0cc1a2514c-86d75f4e9a1so62093241.3 for <~netlandish/links-dev@lists.code.netlandish.com>; Wed, 30 Apr 2025 13:19:39 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=netlandish.com; s=google; t=1746044379; x=1746649179; darn=lists.code.netlandish.com; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:from:to:cc:subject:date:message-id:reply-to; bh=rSgnHLH5/+04QFnjzZDwGwT8l3WYwTHJ8FvCyX9NIO4=; b=WYkaHAyqeXqfLcbFEcjyhIshTGn0qbl8grM/sTdAiGj0sFeU30Jrg9DL04F7kk553J anNcS5r/LOFj2qNOYKqLIe8+JdV2JzBHyCKgrqCOeqA8mYh+0lk7J4bD139syS+xsRxS bPgGYuMLBazsVdVYHhlhgO16WZWA2HN5HzkdI= X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1746044379; x=1746649179; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:x-gm-message-state:from:to:cc:subject:date:message-id :reply-to; bh=rSgnHLH5/+04QFnjzZDwGwT8l3WYwTHJ8FvCyX9NIO4=; b=siFxrbURSDwVCiO3CAWxrvGAtEiEe0SfgPzI3lLvwb0XV7vNAbHKlO0yQ9W6ppONqr +owg9XhujsMZXiymNLwM0ohI44xyTC2YOLgse7kKPwWpEiLm5sGKh11Q5uktZSFN2EpF WqO+arh6WG6FHcwhlMNuatNFim7HvIqbqHy9LaJwu6Gzt5ePVOgRvB0Zz1cjiZYSDjg4 wVBuTETs21Ii9pnE1EMJaTDs0LkRTBvIr6ltBUIbAzqheqLhx2ZGeNl7N0cBdFD9GenK T7KXjre/QCJUbAqSjZrbMdOdet0ad+LTh0GV0/X7ashKgS5Wu6uNgUe2zvPTEBOEcWPJ POiQ== X-Gm-Message-State: AOJu0Yyi1b7WnfX5H0tn/7P8RwwHq0HJpzEHl3X/gpn70UYenE4SjJXf ZANa3rZnSszECNLBmgjiSafI/0SQNyO4l5Nmfb3xLVg+3xwQRvvckl2W0/NjuUCrLbzcoKT+sTO 57NU= X-Gm-Gg: ASbGncv8dC5qtZEUW/xoYEd1tgkeciaCdZg89nJeodNb7q6A+fic33sga9Gxc4Qf81B 53Rm5+f9YHOoI+ZIdVP3oZTwGRFhUhKS7zFgxtVBWY3yeOCthR+li+znmH+Yh+EXU3A12asRS0h dHr4UywfOK+PaAKNfdjVM4tVqv8tjWT9RkUChB14N3H6hw9p9pBFvS21DbvjKORe5TTWQk1AOpY VPXt+ocjW0ojWTyXLk5a18aPiK2NAhABXvfvxP9qhxvbmiNB7SxkwdA2p1Fl34d6dI6NPx3KQnM vdHbPfixGeGuvHi5UBJf20stanpNeOaiBtLOzfj2dQ== X-Google-Smtp-Source: AGHT+IE/Bq24odNq4pqKm9083Trqmfc2m6vIGAVCgDGyC4p3V5EOKIrRwEHrXOs4wQbUnXujNHK6DA== X-Received: by 2002:a05:6102:54a9:b0:4c4:fd0e:dde with SMTP id ada2fe7eead31-4dad3598a47mr4147476137.8.1746044378895; Wed, 30 Apr 2025 13:19:38 -0700 (PDT) Received: from localhost ([2803:2d60:1118:5ee:4b53:667a:5db1:f4ec]) by smtp.gmail.com with ESMTPSA id ada2fe7eead31-4dad40dc128sm441594137.8.2025.04.30.13.19.37 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Wed, 30 Apr 2025 13:19:38 -0700 (PDT) From: Peter Sanchez To: ~netlandish/links-dev@lists.code.netlandish.com Cc: Peter Sanchez Subject: [PATCH links] Adding pagination ordering on bookmark listing page (the getOrgLinks GraphQL call). Supports basic descending (newest first) and ascending (oldest first). Date: Wed, 30 Apr 2025 14:19:32 -0600 Message-ID: <20250430201935.15271-1-peter@netlandish.com> X-Mailer: git-send-email 2.47.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements: https://todo.code.netlandish.com/~netlandish/links/90 Signed-off-by: Peter Sanchez 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", "
\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", "
\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 @@ {{end}} {{- /* col-9 */ -}} -
+
{{- /* right side bar */ -}} +
+
+ +
+
+ {{ if eq .orderDir "DESC" }} + {{ .pd.Data.newest }}  •  {{ .pd.Data.oldest }} + {{ else }} + {{ .pd.Data.newest }}  •  {{ .pd.Data.oldest }} + {{ end }} +
+

{{ .pd.Data.tags }}

-
+

{{ range .tagCloud }} {{if isTagUsedInFilter .Slug $.tagFilter}} @@ -238,7 +250,7 @@ {{end}} {{ end }}

-
-
{{- /* row */ -}} + {{- /* end right side bar */ -}} + {{- /* end row */ -}} {{template "base_footer" .}} -- 2.47.2