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 d5cc263ac..63521f424 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 @@ -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((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) { diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPage.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPage.tsx index 31b520dcd..9c1fea0b2 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPage.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPage.tsx @@ -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; } @@ -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(deleteCategoryMutation); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const handleDeleteCategory = (categoryId: string, categoryName: string) => { + confirm( + () => + new Promise((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 (
@@ -85,7 +159,15 @@ export default function CookieBannerCookiesPage({ )} {sorted.map(category => ( - + handleDeleteCategory(category.id, category.name) + : undefined + } + /> ))} {showCreateDialog && ( diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx index 7edaf6b82..244aefedd 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx @@ -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 (
@@ -273,13 +381,21 @@ export function CategorySection({ categoryKey }: CategorySectionProps) { {__("Required")} )}
- +
+ + {onDelete && ( + + )} +
)} {!isEditingCategory && ( @@ -378,14 +494,36 @@ export function CategorySection({ categoryKey }: CategorySectionProps) { + {siblingCategories.length > 0 && ( + + + + )} + > + {siblingCategories.map(cat => ( + handleMoveCookie(index, cat.id)} + > + {cat.name} + + ))} + + )} diff --git a/packages/ui/src/Atoms/Button/Button.tsx b/packages/ui/src/Atoms/Button/Button.tsx index 77f90a5b7..4254ec2ad 100644 --- a/packages/ui/src/Atoms/Button/Button.tsx +++ b/packages/ui/src/Atoms/Button/Button.tsx @@ -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: