Add reorderCookieCategory mutation

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é <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-20 16:51:20 +04:00
parent 9511fa3bd4
commit 5094ff49df
5 changed files with 166 additions and 39 deletions

View File

@@ -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<CategoryListDeleteMutation>(deleteCategoryMutation);
const [updateCategory] = useMutation<CategoryListUpdateMutation>(updateCategoryMutation);
const [reorderCategory] = useMutation<CategoryListReorderMutation>(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 (

View File

@@ -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,

View File

@@ -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,

View File

@@ -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} }

View File

@@ -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!
}