Use fragment and add confirm

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-20 20:19:11 +04:00
parent 7cd8c516b9
commit 1574600c72
4 changed files with 265 additions and 28 deletions

View File

@@ -14,7 +14,7 @@
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Badge, Button, Card, IconArrowDown, IconArrowUp, useToast } from "@probo/ui";
import { Badge, Button, Card, IconArrowDown, IconArrowUp, useConfirm, useToast } from "@probo/ui";
import { useState } from "react";
import { useFragment, useMutation } from "react-relay";
import { graphql } from "relay-runtime";
@@ -87,6 +87,7 @@ interface CategoryListProps {
export function CategoryList({ cookieBannerKey }: CategoryListProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const confirm = useConfirm();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const banner = useFragment(categoryListFragment, cookieBannerKey);
@@ -98,19 +99,35 @@ export function CategoryList({ cookieBannerKey }: CategoryListProps) {
const sorted = [...categories].sort((a, b) => a.rank - b.rank);
const handleDelete = (categoryId: string) => {
deleteCategory({
variables: {
input: { cookieCategoryId: categoryId },
connections: [connectionId],
const handleDelete = (categoryId: string, categoryName: string) => {
confirm(
() =>
new Promise<void>((resolve) => {
deleteCategory({
variables: {
input: { cookieCategoryId: categoryId },
connections: [connectionId],
},
onCompleted(_, errors) {
if (errors?.length) {
toast({ title: __("Error"), description: errors[0].message, variant: "error" });
} else {
toast({ title: __("Success"), description: __("Category deleted"), variant: "success" });
}
resolve();
},
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to delete category"), error as GraphQLError), variant: "error" });
resolve();
},
});
}),
{
message: __("Are you sure you want to delete the category \"%s\"? Any cookies in this category will be moved to Uncategorised.").replace("%s", categoryName),
variant: "danger",
label: __("Delete"),
},
onCompleted() {
toast({ title: __("Success"), description: __("Category deleted"), variant: "success" });
},
onError(error) {
toast({ title: __("Error"), description: formatError(__("Failed to delete category"), error as GraphQLError), variant: "error" });
},
});
);
};
const handleMoveUp = (index: number) => {
@@ -181,7 +198,7 @@ export function CategoryList({ cookieBannerKey }: CategoryListProps) {
<Button
variant="danger"
className="h-6 px-2 text-xs"
onClick={() => handleDelete(category.id)}
onClick={() => handleDelete(category.id, category.name)}
>
{__("Delete")}
</Button>

View File

@@ -12,12 +12,14 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Button, Card, IconPlusSmall } from "@probo/ui";
import { Button, Card, IconPlusSmall, useConfirm, useToast } from "@probo/ui";
import { useState } from "react";
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
import { graphql } from "relay-runtime";
import type { CookieBannerCookiesPageDeleteMutation } from "#/__generated__/core/CookieBannerCookiesPageDeleteMutation.graphql";
import type { CookieBannerCookiesPageQuery } from "#/__generated__/core/CookieBannerCookiesPageQuery.graphql";
import { CategoryDialog } from "../_components/CategoryDialog";
@@ -38,6 +40,8 @@ export const cookieBannerCookiesPageQuery = graphql`
node {
id
rank
name
kind
...CategorySectionFragment
}
}
@@ -47,6 +51,25 @@ export const cookieBannerCookiesPageQuery = graphql`
}
`;
const deleteCategoryMutation = graphql`
mutation CookieBannerCookiesPageDeleteMutation(
$input: DeleteCookieCategoryInput!
$connections: [ID!]!
) {
deleteCookieCategory(input: $input) {
deletedCookieCategoryId @deleteEdge(connections: $connections)
cookieBanner {
id
latestVersion {
id
version
state
}
}
}
}
`;
interface CookieBannerCookiesPageProps {
queryRef: PreloadedQuery<CookieBannerCookiesPageQuery>;
}
@@ -55,6 +78,8 @@ export default function CookieBannerCookiesPage({
queryRef,
}: CookieBannerCookiesPageProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const confirm = useConfirm();
const data = usePreloadedQuery(cookieBannerCookiesPageQuery, queryRef);
if (data.node.__typename !== "CookieBanner") {
@@ -66,8 +91,57 @@ export default function CookieBannerCookiesPage({
const categories = banner.categories.edges.map(e => e.node);
const sorted = [...categories].sort((a, b) => a.rank - b.rank);
const [deleteCategory]
= useMutation<CookieBannerCookiesPageDeleteMutation>(deleteCategoryMutation);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const handleDeleteCategory = (categoryId: string, categoryName: string) => {
confirm(
() =>
new Promise<void>((resolve) => {
deleteCategory({
variables: {
input: { cookieCategoryId: categoryId },
connections: [connectionId],
},
onCompleted(_, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: errors[0].message,
variant: "error",
});
} else {
toast({
title: __("Success"),
description: __("Category deleted"),
variant: "success",
});
}
resolve();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete category"),
error as GraphQLError,
),
variant: "error",
});
resolve();
},
});
}),
{
message: __("Are you sure you want to delete the category \"%s\"? Any cookies in this category will be moved to Uncategorised.").replace("%s", categoryName),
variant: "danger",
label: __("Delete"),
},
);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -85,7 +159,15 @@ export default function CookieBannerCookiesPage({
)}
{sorted.map(category => (
<CategorySection key={category.id} categoryKey={category} />
<CategorySection
key={category.id}
categoryKey={category}
onDelete={
category.kind === "NORMAL"
? () => handleDeleteCategory(category.id, category.name)
: undefined
}
/>
))}
{showCreateDialog && (

View File

@@ -18,6 +18,9 @@ import {
Badge,
Button,
Card,
Dropdown,
DropdownItem,
IconArrowBoxLeft,
IconPencil,
IconPlusSmall,
IconTrashCan,
@@ -48,6 +51,21 @@ export const categorySectionFragment = graphql`
duration
description
}
cookieBanner @required(action: THROW) {
categories(first: 50, orderBy: { field: RANK, direction: ASC }) @required(action: THROW) {
edges {
node {
id
name
cookies {
name
duration
description
}
}
}
}
}
}
`;
@@ -88,9 +106,10 @@ interface CookieEntry {
interface CategorySectionProps {
categoryKey: CategorySectionFragment$key;
onDelete?: () => void;
}
export function CategorySection({ categoryKey }: CategorySectionProps) {
export function CategorySection({ categoryKey, onDelete }: CategorySectionProps) {
const category = useFragment(categorySectionFragment, categoryKey);
const { __ } = useTranslate();
const { toast } = useToast();
@@ -232,6 +251,95 @@ export function CategorySection({ categoryKey }: CategorySectionProps) {
setCookieForm({ name: "", duration: "", description: "" });
};
const allCategories = category.cookieBanner.categories.edges.map(e => e.node) ?? [];
const siblingCategories = allCategories.filter(c => c.id !== category.id);
const handleMoveCookie = (cookieIndex: number, targetCategoryId: string) => {
const cookie = category.cookies[cookieIndex];
const targetCategory = siblingCategories.find(c => c.id === targetCategoryId);
if (!targetCategory) return;
const targetCookies = [
...targetCategory.cookies.map(c => ({
name: c.name,
duration: c.duration,
description: c.description,
})),
{ name: cookie.name, duration: cookie.duration, description: cookie.description },
];
updateCategory({
variables: {
input: {
cookieCategoryId: targetCategoryId,
cookies: targetCookies,
},
},
onCompleted(_response, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: errors[0].message,
variant: "error",
});
return;
}
const sourceCookies = category.cookies
.filter((_, i) => i !== cookieIndex)
.map(c => ({
name: c.name,
duration: c.duration,
description: c.description,
}));
updateCategory({
variables: {
input: {
cookieCategoryId: category.id,
cookies: sourceCookies,
},
},
onCompleted(_response, errors) {
if (errors?.length) {
toast({
title: __("Error"),
description: errors[0].message,
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Cookie moved"),
variant: "success",
});
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to move cookie"),
error as GraphQLError,
),
variant: "error",
});
},
});
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to move cookie"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Card className="border overflow-hidden">
<div className="p-4">
@@ -273,13 +381,21 @@ export function CategorySection({ categoryKey }: CategorySectionProps) {
<Badge variant="neutral">{__("Required")}</Badge>
)}
</div>
<Button
variant="secondary"
onClick={() => setIsEditingCategory(true)}
>
<IconPencil size={14} />
{__("Edit")}
</Button>
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => setIsEditingCategory(true)}
>
<IconPencil size={14} />
{__("Edit")}
</Button>
{onDelete && (
<Button variant="danger" onClick={onDelete}>
<IconTrashCan size={14} />
{__("Delete")}
</Button>
)}
</div>
</div>
)}
{!isEditingCategory && (
@@ -378,14 +494,36 @@ export function CategorySection({ categoryKey }: CategorySectionProps) {
<button
type="button"
onClick={() => handleStartEditCookie(index)}
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
className="p-1 rounded cursor-pointer"
>
<IconPencil size={14} />
</button>
{siblingCategories.length > 0 && (
<Dropdown
toggle={(
<button
type="button"
className="p-1 rounded cursor-pointer"
>
<IconArrowBoxLeft size={14} />
</button>
)}
>
{siblingCategories.map(cat => (
<DropdownItem
className="text-sm"
key={cat.id}
onSelect={() => handleMoveCookie(index, cat.id)}
>
{cat.name}
</DropdownItem>
))}
</Dropdown>
)}
<button
type="button"
onClick={() => handleDeleteCookie(index)}
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-red-500"
className="p-1 rounded cursor-pointer text-danger-dark"
>
<IconTrashCan size={14} />
</button>

View File

@@ -24,7 +24,7 @@ import { tv, type VariantProps } from "tailwind-variants";
import { Slot } from "../Slot";
export const button = tv({
base: "flex items-center justify-center gap-[6px] px-3 py-2 rounded-full cursor-pointer text-sm font-medium h-8 focus:outline-none whitespace-nowrap w-max",
base: "flex items-center justify-center gap-[6px] px-3 py-2 rounded-lg cursor-pointer text-sm font-medium h-8 focus:outline-none whitespace-nowrap w-max",
variants: {
variant: {
primary: