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:
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} }
|
||||
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user