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 { CategoryList_cookieBanner$key } from "#/__generated__/core/CategoryList_cookieBanner.graphql";
import type { CategoryListDeleteMutation } from "#/__generated__/core/CategoryListDeleteMutation.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"; import { CategoryDialog } from "./CategoryDialog";
@@ -62,23 +62,12 @@ const deleteCategoryMutation = graphql`
} }
`; `;
const updateCategoryMutation = graphql` const reorderCategoryMutation = graphql`
mutation CategoryListUpdateMutation($input: UpdateCookieCategoryInput!) { mutation CategoryListReorderMutation($input: ReorderCookieCategoryInput!) {
updateCookieCategory(input: $input) { reorderCookieCategory(input: $input) {
cookieCategory {
id
name
description
rank
cookies {
name
duration
description
}
updatedAt
}
cookieBanner { cookieBanner {
id id
...CategoryList_cookieBanner
latestVersion { latestVersion {
id id
version version
@@ -103,7 +92,7 @@ export function CategoryList({ cookieBannerKey }: CategoryListProps) {
const categories = banner.categories.edges.map(e => e.node); const categories = banner.categories.edges.map(e => e.node);
const [deleteCategory] = useMutation<CategoryListDeleteMutation>(deleteCategoryMutation); 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); const sorted = [...categories].sort((a, b) => a.rank - b.rank);
@@ -126,36 +115,24 @@ export function CategoryList({ cookieBannerKey }: CategoryListProps) {
if (index === 0) return; if (index === 0) return;
const current = sorted[index]; const current = sorted[index];
const above = sorted[index - 1]; const above = sorted[index - 1];
updateCategory({ reorderCategory({
variables: { input: { cookieCategoryId: current.id, rank: above.rank } }, variables: { input: { cookieCategoryId: current.id, rank: above.rank } },
onError(error) { onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "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) => { const handleMoveDown = (index: number) => {
if (index >= sorted.length - 1) return; if (index >= sorted.length - 1) return;
const current = sorted[index]; const current = sorted[index];
const below = sorted[index + 1]; const below = sorted[index + 1];
updateCategory({ reorderCategory({
variables: { input: { cookieCategoryId: current.id, rank: below.rank } }, variables: { input: { cookieCategoryId: current.id, rank: below.rank } },
onError(error) { onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "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 ( return (

View File

@@ -82,10 +82,14 @@ type (
CookieCategoryID gid.GID CookieCategoryID gid.GID
Name *string Name *string
Description *string Description *string
Rank *int
Cookies *coredata.CookieItems Cookies *coredata.CookieItems
} }
ReorderCookieCategoryRequest struct {
CookieCategoryID gid.GID
Rank int
}
CreateCookieConsentRecordRequest struct { CreateCookieConsentRecordRequest struct {
CookieBannerID gid.GID CookieBannerID gid.GID
Version int 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.CookieCategoryID, "cookie_category_id", validator.Required(), validator.GID(coredata.CookieCategoryEntityType))
v.Check(r.Name, "name", validator.SafeTextNoNewLine(255)) v.Check(r.Name, "name", validator.SafeTextNoNewLine(255))
v.Check(r.Description, "description", validator.SafeText(1000)) 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)) v.Check(r.Rank, "rank", validator.Min(0))
return v.Error() return v.Error()
@@ -850,9 +862,6 @@ func (s *Service) UpdateCookieCategory(
if req.Description != nil { if req.Description != nil {
category.Description = *req.Description category.Description = *req.Description
} }
if req.Rank != nil {
category.Rank = *req.Rank
}
if req.Cookies != nil { if req.Cookies != nil {
category.Cookies = *req.Cookies category.Cookies = *req.Cookies
} }
@@ -887,6 +896,58 @@ func (s *Service) UpdateCookieCategory(
return &category, nil 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( func (s *Service) DeleteCookieCategory(
ctx context.Context, ctx context.Context,
scope coredata.Scoper, scope coredata.Scoper,

View File

@@ -332,7 +332,6 @@ UPDATE cookie_categories
SET SET
name = @name, name = @name,
description = @description, description = @description,
rank = @rank,
cookies = @cookies, cookies = @cookies,
updated_at = @updated_at updated_at = @updated_at
WHERE WHERE
@@ -357,7 +356,6 @@ RETURNING
"id": c.ID, "id": c.ID,
"name": c.Name, "name": c.Name,
"description": c.Description, "description": c.Description,
"rank": c.Rank,
"cookies": c.Cookies, "cookies": c.Cookies,
"updated_at": c.UpdatedAt, "updated_at": c.UpdatedAt,
} }
@@ -378,6 +376,55 @@ RETURNING
return nil 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( func (c *CookieCategory) Delete(
ctx context.Context, ctx context.Context,
tx pg.Tx, tx pg.Tx,

View File

@@ -410,7 +410,6 @@ func (r *mutationResolver) UpdateCookieCategory(ctx context.Context, input types
CookieCategoryID: input.CookieCategoryID, CookieCategoryID: input.CookieCategoryID,
Name: input.Name, Name: input.Name,
Description: input.Description, Description: input.Description,
Rank: input.Rank,
Cookies: cookies, Cookies: cookies,
}, },
) )
@@ -482,6 +481,38 @@ func (r *mutationResolver) DeleteCookieCategory(ctx context.Context, input types
}, nil }, 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. // CookieBanner returns schema.CookieBannerResolver implementation.
func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} } func (r *Resolver) CookieBanner() schema.CookieBannerResolver { return &cookieBannerResolver{r} }

View File

@@ -169,6 +169,9 @@ extend type Mutation {
deleteCookieCategory( deleteCookieCategory(
input: DeleteCookieCategoryInput! input: DeleteCookieCategoryInput!
): DeleteCookieCategoryPayload! ): DeleteCookieCategoryPayload!
reorderCookieCategory(
input: ReorderCookieCategoryInput!
): ReorderCookieCategoryPayload!
} }
input CreateCookieBannerInput { input CreateCookieBannerInput {
@@ -218,7 +221,6 @@ input UpdateCookieCategoryInput {
cookieCategoryId: ID! cookieCategoryId: ID!
name: String name: String
description: String description: String
rank: Int
cookies: [CookieItemInput!] cookies: [CookieItemInput!]
} }
@@ -226,6 +228,11 @@ input DeleteCookieCategoryInput {
cookieCategoryId: ID! cookieCategoryId: ID!
} }
input ReorderCookieCategoryInput {
cookieCategoryId: ID!
rank: Int!
}
input CookieItemInput { input CookieItemInput {
name: String! name: String!
duration: String! duration: String!
@@ -271,3 +278,7 @@ type DeleteCookieCategoryPayload {
deletedCookieCategoryId: ID! deletedCookieCategoryId: ID!
cookieBanner: CookieBanner! cookieBanner: CookieBanner!
} }
type ReorderCookieCategoryPayload {
cookieBanner: CookieBanner!
}