From 5094ff49dfdbf17fda03e8021b6f54fe0aac828a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Mon, 20 Apr 2026 16:51:20 +0400 Subject: [PATCH] Add reorderCookieCategory mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Category reordering previously required two separate updateCookieCategory calls to swap ranks, which was not atomic. Replace with a single reorderCookieCategory mutation that shifts all affected ranks in one SQL statement, and remove the rank field from UpdateCookieCategoryInput. Signed-off-by: Émile Ré --- .../_components/CategoryList.tsx | 39 +++-------- pkg/cookiebanner/service.go | 69 +++++++++++++++++-- pkg/coredata/cookie_category.go | 51 +++++++++++++- .../api/console/v1/cookie_banner_resolvers.go | 33 ++++++++- .../console/v1/graphql/cookie_banner.graphql | 13 +++- 5 files changed, 166 insertions(+), 39 deletions(-) diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/_components/CategoryList.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/_components/CategoryList.tsx index 1d2087d73..c8dce8b1b 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/_components/CategoryList.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/_components/CategoryList.tsx @@ -21,7 +21,7 @@ import { graphql } from "relay-runtime"; import type { CategoryList_cookieBanner$key } from "#/__generated__/core/CategoryList_cookieBanner.graphql"; import type { CategoryListDeleteMutation } from "#/__generated__/core/CategoryListDeleteMutation.graphql"; -import type { CategoryListUpdateMutation } from "#/__generated__/core/CategoryListUpdateMutation.graphql"; +import type { CategoryListReorderMutation } from "#/__generated__/core/CategoryListReorderMutation.graphql"; import { CategoryDialog } from "./CategoryDialog"; @@ -62,23 +62,12 @@ const deleteCategoryMutation = graphql` } `; -const updateCategoryMutation = graphql` - mutation CategoryListUpdateMutation($input: UpdateCookieCategoryInput!) { - updateCookieCategory(input: $input) { - cookieCategory { - id - name - description - rank - cookies { - name - duration - description - } - updatedAt - } +const reorderCategoryMutation = graphql` + mutation CategoryListReorderMutation($input: ReorderCookieCategoryInput!) { + reorderCookieCategory(input: $input) { cookieBanner { id + ...CategoryList_cookieBanner latestVersion { id version @@ -103,7 +92,7 @@ export function CategoryList({ cookieBannerKey }: CategoryListProps) { const categories = banner.categories.edges.map(e => e.node); const [deleteCategory] = useMutation(deleteCategoryMutation); - const [updateCategory] = useMutation(updateCategoryMutation); + const [reorderCategory] = useMutation(reorderCategoryMutation); const sorted = [...categories].sort((a, b) => a.rank - b.rank); @@ -126,36 +115,24 @@ export function CategoryList({ cookieBannerKey }: CategoryListProps) { if (index === 0) return; const current = sorted[index]; const above = sorted[index - 1]; - updateCategory({ + reorderCategory({ variables: { input: { cookieCategoryId: current.id, rank: above.rank } }, onError(error) { toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" }); }, }); - updateCategory({ - variables: { input: { cookieCategoryId: above.id, rank: current.rank } }, - onError(error) { - toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" }); - }, - }); }; const handleMoveDown = (index: number) => { if (index >= sorted.length - 1) return; const current = sorted[index]; const below = sorted[index + 1]; - updateCategory({ + reorderCategory({ variables: { input: { cookieCategoryId: current.id, rank: below.rank } }, onError(error) { toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" }); }, }); - updateCategory({ - variables: { input: { cookieCategoryId: below.id, rank: current.rank } }, - onError(error) { - toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" }); - }, - }); }; return ( diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index ec57695d7..f573914b8 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -82,10 +82,14 @@ type ( CookieCategoryID gid.GID Name *string Description *string - Rank *int Cookies *coredata.CookieItems } + ReorderCookieCategoryRequest struct { + CookieCategoryID gid.GID + Rank int + } + CreateCookieConsentRecordRequest struct { CookieBannerID gid.GID Version int @@ -166,6 +170,14 @@ func (r *UpdateCookieCategoryRequest) Validate() error { v.Check(r.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType)) v.Check(r.Name, "name", validator.SafeTextNoNewLine(255)) v.Check(r.Description, "description", validator.SafeText(1000)) + + return v.Error() +} + +func (r *ReorderCookieCategoryRequest) Validate() error { + v := validator.New() + + v.Check(r.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType)) v.Check(r.Rank, "rank", validator.Min(0)) return v.Error() @@ -850,9 +862,6 @@ func (s *Service) UpdateCookieCategory( if req.Description != nil { category.Description = *req.Description } - if req.Rank != nil { - category.Rank = *req.Rank - } if req.Cookies != nil { category.Cookies = *req.Cookies } @@ -887,6 +896,58 @@ func (s *Service) UpdateCookieCategory( return &category, nil } +func (s *Service) ReorderCookieCategory( + ctx context.Context, + scope coredata.Scoper, + req ReorderCookieCategoryRequest, +) (*coredata.CookieBanner, error) { + if err := req.Validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + var banner coredata.CookieBanner + + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + var category coredata.CookieCategory + if err := category.LoadByID(ctx, tx, scope, req.CookieCategoryID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return ErrCategoryNotFound + } + return fmt.Errorf("cannot load cookie category: %w", err) + } + + category.Rank = req.Rank + category.UpdatedAt = time.Now() + + if err := category.UpdateRank(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot reorder cookie category: %w", err) + } + + if err := banner.LoadByID(ctx, tx, scope, category.CookieBannerID); err != nil { + return fmt.Errorf("cannot load cookie banner: %w", err) + } + + var categories coredata.CookieCategories + if err := categories.LoadAllByCookieBannerID(ctx, tx, scope, category.CookieBannerID); err != nil { + return fmt.Errorf("cannot load cookie categories: %w", err) + } + + if _, err := s.ensureDraftVersion(ctx, tx, scope, &banner, categories); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } + + return nil + }, + ) + if err != nil { + return nil, err + } + + return &banner, nil +} + func (s *Service) DeleteCookieCategory( ctx context.Context, scope coredata.Scoper, diff --git a/pkg/coredata/cookie_category.go b/pkg/coredata/cookie_category.go index fbfc73d64..1f651e63c 100644 --- a/pkg/coredata/cookie_category.go +++ b/pkg/coredata/cookie_category.go @@ -332,7 +332,6 @@ UPDATE cookie_categories SET name = @name, description = @description, - rank = @rank, cookies = @cookies, updated_at = @updated_at WHERE @@ -357,7 +356,6 @@ RETURNING "id": c.ID, "name": c.Name, "description": c.Description, - "rank": c.Rank, "cookies": c.Cookies, "updated_at": c.UpdatedAt, } @@ -378,6 +376,55 @@ RETURNING return nil } +func (c *CookieCategory) UpdateRank( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +WITH old AS ( + SELECT rank AS old_rank + FROM cookie_categories + WHERE %s AND id = @id AND cookie_banner_id = @cookie_banner_id +) +UPDATE cookie_categories +SET + rank = CASE + WHEN id = @id THEN @new_rank + ELSE rank + CASE + WHEN @new_rank < old.old_rank THEN 1 + WHEN @new_rank > old.old_rank THEN -1 + END + END, + updated_at = @updated_at +FROM old +WHERE %s + AND cookie_banner_id = @cookie_banner_id + AND ( + id = @id + OR (rank BETWEEN LEAST(old.old_rank, @new_rank) AND GREATEST(old.old_rank, @new_rank)) + ); +` + + scopeFragment := scope.SQLFragment() + q = fmt.Sprintf(q, scopeFragment, scopeFragment) + + args := pgx.StrictNamedArgs{ + "id": c.ID, + "new_rank": c.Rank, + "cookie_banner_id": c.CookieBannerID, + "updated_at": c.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + _, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update cookie category rank: %w", err) + } + + return nil +} + func (c *CookieCategory) Delete( ctx context.Context, tx pg.Tx, diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index ed8004730..afd30c04e 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -410,7 +410,6 @@ func (r *mutationResolver) UpdateCookieCategory(ctx context.Context, input types CookieCategoryID: input.CookieCategoryID, Name: input.Name, Description: input.Description, - Rank: input.Rank, Cookies: cookies, }, ) @@ -482,6 +481,38 @@ func (r *mutationResolver) DeleteCookieCategory(ctx context.Context, input types }, nil } +// ReorderCookieCategory is the resolver for the reorderCookieCategory field. +func (r *mutationResolver) ReorderCookieCategory(ctx context.Context, input types.ReorderCookieCategoryInput) (*types.ReorderCookieCategoryPayload, error) { + if err := r.authorize(ctx, input.CookieCategoryID, probo.ActionCookieCategoryUpdate); err != nil { + return nil, err + } + + scope := coredata.NewScopeFromObjectID(input.CookieCategoryID) + + banner, err := r.cookieBanner.ReorderCookieCategory( + ctx, + scope, + cookiebanner.ReorderCookieCategoryRequest{ + CookieCategoryID: input.CookieCategoryID, + Rank: input.Rank, + }, + ) + if err != nil { + if errors.Is(err, cookiebanner.ErrCategoryNotFound) { + return nil, gqlutils.NotFound(ctx, err) + } + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + r.logger.ErrorCtx(ctx, "cannot reorder cookie category", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.ReorderCookieCategoryPayload{ + CookieBanner: types.NewCookieBanner(banner), + }, nil +} + // CookieBanner returns schema.CookieBannerResolver implementation. func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} } diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index bdd5b296a..240d3a766 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -169,6 +169,9 @@ extend type Mutation { deleteCookieCategory( input: DeleteCookieCategoryInput! ): DeleteCookieCategoryPayload! + reorderCookieCategory( + input: ReorderCookieCategoryInput! + ): ReorderCookieCategoryPayload! } input CreateCookieBannerInput { @@ -218,7 +221,6 @@ input UpdateCookieCategoryInput { cookieCategoryId: ID! name: String description: String - rank: Int cookies: [CookieItemInput!] } @@ -226,6 +228,11 @@ input DeleteCookieCategoryInput { cookieCategoryId: ID! } +input ReorderCookieCategoryInput { + cookieCategoryId: ID! + rank: Int! +} + input CookieItemInput { name: String! duration: String! @@ -271,3 +278,7 @@ type DeleteCookieCategoryPayload { deletedCookieCategoryId: ID! cookieBanner: CookieBanner! } + +type ReorderCookieCategoryPayload { + cookieBanner: CookieBanner! +}