From 3b4aa7f971a342e238dbf495638affd9743d832a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 5 May 2026 11:03:36 +0400 Subject: [PATCH] Move cookie management to display page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the separate Cookies tab into the Display page so categories with full cookie CRUD, reordering, and theme preview live together. Delete the now-redundant CookieBannerCookiesPage, its loader, route, and nav tab. Add row actions (edit, move-to-category, exclude, delete) to the detection page. The move-to-category dropdown uses an interaction-triggered preloaded query following the useQueryLoader pattern. Document this pattern in the react-components guide. Signed-off-by: Émile Ré --- .../CookieBannerConfigLayout.tsx | 5 - .../cookies/CookieBannerCookiesPage.tsx | 184 ----------- .../cookies/CookieBannerCookiesPageLoader.tsx | 43 --- .../detection/CookieBannerDetectionPage.tsx | 10 +- .../_components/DetectionPatternRow.tsx | 290 +++++++++++++++++- .../_components/DetectionPatternRowEdit.tsx | 108 +++++++ .../_components/MoveToCategoryDropdown.tsx | 79 +++++ .../display/CookieBannerDisplayPage.tsx | 67 +++- .../_components/AddCookieRow.tsx | 0 .../_components/CategoryDialog.tsx | 0 .../display/_components/CategoryList.tsx | 206 ------------- .../_components/CategorySection.tsx | 238 +++++++++++--- .../_components/EditCategoryForm.tsx | 0 .../_components/EditCookieRow.tsx | 0 .../organizations/cookie-banners/routes.ts | 5 - contrib/claude/react-components.md | 66 ++++ 16 files changed, 799 insertions(+), 502 deletions(-) delete mode 100644 apps/console/src/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPage.tsx delete mode 100644 apps/console/src/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPageLoader.tsx create mode 100644 apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRowEdit.tsx create mode 100644 apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/MoveToCategoryDropdown.tsx rename apps/console/src/pages/organizations/cookie-banners/configuration/{cookies => display}/_components/AddCookieRow.tsx (100%) rename apps/console/src/pages/organizations/cookie-banners/configuration/{cookies => display}/_components/CategoryDialog.tsx (100%) delete mode 100644 apps/console/src/pages/organizations/cookie-banners/configuration/display/_components/CategoryList.tsx rename apps/console/src/pages/organizations/cookie-banners/configuration/{cookies => display}/_components/CategorySection.tsx (75%) rename apps/console/src/pages/organizations/cookie-banners/configuration/{cookies => display}/_components/EditCategoryForm.tsx (100%) rename apps/console/src/pages/organizations/cookie-banners/configuration/{cookies => display}/_components/EditCookieRow.tsx (100%) diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayout.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayout.tsx index 3e74bca6f..31824fdbd 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayout.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/CookieBannerConfigLayout.tsx @@ -20,7 +20,6 @@ import { Breadcrumb, Button, IconGlobe, - IconListStack, IconPageTextLine, IconSettingsGear2, IconSquareBehindSquare2, @@ -248,10 +247,6 @@ export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfi {__("Translations")} - - - {__("Cookies")} - {__("Detection")} 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 deleted file mode 100644 index 59fd024e8..000000000 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPage.tsx +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// 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, useConfirm, useToast } from "@probo/ui"; -import { useState } from "react"; -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"; -import { CategorySection } from "./_components/CategorySection"; - -export const cookieBannerCookiesPageQuery = graphql` - query CookieBannerCookiesPageQuery($cookieBannerId: ID!) { - node(id: $cookieBannerId) { - __typename - ... on CookieBanner { - id - consentCategories(first: 50, orderBy: { field: RANK, direction: ASC }) - @connection(key: "CookieBannerCookiesPage_consentCategories") - @required(action: THROW) { - __id - edges { - node { - id - rank - name - kind - ...CategorySectionFragment - } - } - } - } - } - } -`; - -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; -} - -export default function CookieBannerCookiesPage({ - queryRef, -}: CookieBannerCookiesPageProps) { - const { __ } = useTranslate(); - const { toast } = useToast(); - const confirm = useConfirm(); - const data = usePreloadedQuery(cookieBannerCookiesPageQuery, queryRef); - - if (data.node.__typename !== "CookieBanner") { - throw new Error("invalid type for node"); - } - - const banner = data.node; - const connectionId = banner.consentCategories.__id; - const categories = banner.consentCategories.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 ( -
-
-

- {__("Organize cookies into categories and declare which cookies your site uses.")} -

- -
- - {sorted.length === 0 && ( - - {__("No categories yet. Add a category to start managing cookies.")} - - )} - - {sorted.map(category => ( - handleDeleteCategory(category.id, category.name) - : undefined - } - /> - ))} - - {showCreateDialog && ( - 0 ? sorted[sorted.length - 1].rank + 1 : 0} - onOpenChange={setShowCreateDialog} - /> - )} -
- ); -} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPageLoader.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPageLoader.tsx deleted file mode 100644 index 66ef4c5b1..000000000 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPageLoader.tsx +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -import { Suspense, useEffect } from "react"; -import { useQueryLoader } from "react-relay"; -import { useParams } from "react-router"; - -import type { CookieBannerCookiesPageQuery } from "#/__generated__/core/CookieBannerCookiesPageQuery.graphql"; -import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; - -import CookieBannerCookiesPage, { cookieBannerCookiesPageQuery } from "./CookieBannerCookiesPage"; - -export default function CookieBannerCookiesPageLoader() { - const { cookieBannerId } = useParams<{ cookieBannerId: string }>(); - const [queryRef, loadQuery] = useQueryLoader(cookieBannerCookiesPageQuery); - - useEffect(() => { - if (cookieBannerId) { - loadQuery({ cookieBannerId }); - } - }, [loadQuery, cookieBannerId]); - - if (!queryRef) { - return ; - } - - return ( - }> - - - ); -} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/detection/CookieBannerDetectionPage.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/CookieBannerDetectionPage.tsx index e34ddca96..64a89aeeb 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/detection/CookieBannerDetectionPage.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/CookieBannerDetectionPage.tsx @@ -19,6 +19,7 @@ import { Option, Select, Tbody, + Th, Thead, Tr, } from "@probo/ui"; @@ -77,6 +78,7 @@ const detectionFragment = graphql` filters: ["filter", "orderBy"] ) @required(action: THROW) { + __id edges { node { id @@ -110,6 +112,7 @@ export default function CookieBannerDetectionPage({ CookieBannerDetectionPageFragment$key >(detectionFragment, data.node); + const connectionId = fragmentData.uncategorisedPatterns.__id; const patterns = fragmentData.uncategorisedPatterns.edges.map(edge => edge.node) ?? []; const refetchFilters = (overrides: Record = {}) => { @@ -178,11 +181,16 @@ export default function CookieBannerDetectionPage({ {__("Source")} {__("Last Matched")} {__("Updated")} + {patterns.map(pattern => ( - + ))} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRow.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRow.tsx index 22de898a4..0b803100e 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRow.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRow.tsx @@ -12,34 +12,266 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -import { formatDate } from "@probo/helpers"; +import { Eye as IconEye, EyeSlash as IconEyeSlash } from "@phosphor-icons/react"; +import { formatDate, formatError, type GraphQLError } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; -import { Badge, Td, Tr } from "@probo/ui"; -import { graphql, useFragment } from "react-relay"; +import { + Badge, + Dropdown, + IconArrowBoxLeft, + IconPencil, + IconTrashCan, + Td, + Tr, + useConfirm, + useToast, +} from "@probo/ui"; +import { Suspense, useCallback, useState } from "react"; +import { graphql, useFragment, useMutation, useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; +import { ConnectionHandler } from "relay-runtime"; +import type { DetectionPatternRowDeleteMutation } from "#/__generated__/core/DetectionPatternRowDeleteMutation.graphql"; import type { DetectionPatternRowFragment$key } from "#/__generated__/core/DetectionPatternRowFragment.graphql"; +import type { DetectionPatternRowMoveMutation } from "#/__generated__/core/DetectionPatternRowMoveMutation.graphql"; +import type { DetectionPatternRowUpdateMutation } from "#/__generated__/core/DetectionPatternRowUpdateMutation.graphql"; +import type { MoveToCategoryDropdownQuery } from "#/__generated__/core/MoveToCategoryDropdownQuery.graphql"; + +import { DetectionPatternRowEdit } from "./DetectionPatternRowEdit"; +import { + MoveToCategoryDropdown, + moveToCategoryDropdownQuery, +} from "./MoveToCategoryDropdown"; const detectionPatternFragment = graphql` fragment DetectionPatternRowFragment on CookiePattern { + id displayName - matchType source description + maxAgeSeconds + excluded lastMatchedAt updatedAt } `; +const deletePatternMutation = graphql` + mutation DetectionPatternRowDeleteMutation( + $input: DeleteCookiePatternInput! + $connections: [ID!]! + ) { + deleteCookiePattern(input: $input) { + deletedCookiePatternId @deleteEdge(connections: $connections) + cookieBanner { + id + latestVersion { + id + version + state + } + } + } + } +`; + +const movePatternMutation = graphql` + mutation DetectionPatternRowMoveMutation( + $input: MoveCookiePatternToCategoryInput! + ) { + moveCookiePatternToCategory(input: $input) { + cookiePattern { + id + cookieCategory { + id + } + } + cookieBanner { + id + latestVersion { + id + version + state + } + } + } + } +`; + +const updatePatternMutation = graphql` + mutation DetectionPatternRowUpdateMutation( + $input: UpdateCookiePatternInput! + ) { + updateCookiePattern(input: $input) { + cookiePattern { + id + displayName + maxAgeSeconds + description + excluded + updatedAt + } + cookieBanner { + id + latestVersion { + id + version + state + } + } + } + } +`; + interface DetectionPatternRowProps { patternKey: DetectionPatternRowFragment$key; + connectionId: string; } -export function DetectionPatternRow({ patternKey }: DetectionPatternRowProps) { +export function DetectionPatternRow({ patternKey, connectionId }: DetectionPatternRowProps) { const { __ } = useTranslate(); + const { toast } = useToast(); + const confirm = useConfirm(); + const { cookieBannerId } = useParams<{ cookieBannerId: string }>(); const pattern = useFragment(detectionPatternFragment, patternKey); + const [isEditing, setIsEditing] = useState(false); + const [categoryQueryRef, loadCategoryQuery] + = useQueryLoader(moveToCategoryDropdownQuery); + + const handleCategoryDropdownOpen = useCallback( + (open: boolean) => { + if (open && cookieBannerId) { + loadCategoryQuery({ cookieBannerId }); + } + }, + [loadCategoryQuery, cookieBannerId], + ); + + const [deletePattern] + = useMutation(deletePatternMutation); + const [movePattern] + = useMutation(movePatternMutation); + const [updatePattern, isUpdating] + = useMutation(updatePatternMutation); + + const handleDelete = () => { + confirm( + () => + new Promise((resolve) => { + deletePattern({ + variables: { + input: { cookiePatternId: pattern.id }, + connections: [connectionId], + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ title: __("Error"), description: errors[0].message, variant: "error" }); + } else { + toast({ title: __("Success"), description: __("Cookie deleted"), variant: "success" }); + } + resolve(); + }, + onError(error) { + toast({ title: __("Error"), description: formatError(__("Failed to delete cookie"), error as GraphQLError), variant: "error" }); + resolve(); + }, + }); + }), + { + message: __("Are you sure you want to delete \"%s\"?").replace("%s", pattern.displayName), + variant: "danger", + label: __("Delete"), + }, + ); + }; + + const handleMove = (targetCategoryId: string) => { + movePattern({ + variables: { + input: { + cookiePatternId: pattern.id, + targetCookieCategoryId: targetCategoryId, + }, + }, + updater(store) { + const conn = store.get(connectionId); + if (conn) { + ConnectionHandler.deleteNode(conn, pattern.id); + } + }, + onCompleted(_, 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" }); + }, + }); + }; + + const handleToggleExcluded = () => { + updatePattern({ + variables: { + input: { + cookiePatternId: pattern.id, + excluded: !pattern.excluded, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ title: __("Error"), description: errors[0].message, variant: "error" }); + return; + } + }, + onError(error) { + toast({ title: __("Error"), description: formatError(__("Failed to update cookie"), error as GraphQLError), variant: "error" }); + }, + }); + }; + + const handleSaveEdit = (data: { displayName: string; description: string; maxAgeSeconds: number | null }) => { + updatePattern({ + variables: { + input: { + cookiePatternId: pattern.id, + displayName: data.displayName, + description: data.description, + maxAgeSeconds: data.maxAgeSeconds, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ title: __("Error"), description: errors[0].message, variant: "error" }); + return; + } + toast({ title: __("Success"), description: __("Cookie updated"), variant: "success" }); + setIsEditing(false); + }, + onError(error) { + toast({ title: __("Error"), description: formatError(__("Failed to update cookie"), error as GraphQLError), variant: "error" }); + }, + }); + }; + + if (isEditing) { + return ( + setIsEditing(false)} + /> + ); + } + return ( - +
{pattern.displayName} @@ -69,6 +301,52 @@ export function DetectionPatternRow({ patternKey }: DetectionPatternRowProps) { {formatDate(pattern.updatedAt)} + +
+ + + + + )} + > + {categoryQueryRef && ( + + + + )} + + + +
+ ); } diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRowEdit.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRowEdit.tsx new file mode 100644 index 000000000..b611ad61c --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/DetectionPatternRowEdit.tsx @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { fromMaxAgeSeconds, toMaxAgeSeconds } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { Button, DurationInput, Input, Td, Tr } from "@probo/ui"; +import { Controller, useForm } from "react-hook-form"; + +interface FormValues { + displayName: string; + duration: { value: string; unit: string }; + description: string; +} + +interface DetectionPatternRowEditProps { + displayName: string; + description: string; + maxAgeSeconds: number | null; + isUpdating: boolean; + onSave: (data: { displayName: string; description: string; maxAgeSeconds: number | null }) => void; + onCancel: () => void; +} + +export function DetectionPatternRowEdit({ + displayName, + description, + maxAgeSeconds, + isUpdating, + onSave, + onCancel, +}: DetectionPatternRowEditProps) { + const { __ } = useTranslate(); + const initial = fromMaxAgeSeconds(maxAgeSeconds); + + const { register, handleSubmit, control } = useForm({ + defaultValues: { + displayName, + duration: initial, + description, + }, + }); + + const onSubmit = (data: FormValues) => { + onSave({ + displayName: data.displayName, + description: data.description, + maxAgeSeconds: toMaxAgeSeconds(data.duration.value, data.duration.unit), + }); + }; + + return ( + + + + + + + ( + field.onChange({ ...field.value, value: v })} + onUnitChange={u => field.onChange({ ...field.value, unit: u })} + /> + )} + /> + + +
+ + + +
+ + + ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/MoveToCategoryDropdown.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/MoveToCategoryDropdown.tsx new file mode 100644 index 000000000..a13fdc9fb --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/detection/_components/MoveToCategoryDropdown.tsx @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { useTranslate } from "@probo/i18n"; +import { DropdownItem } from "@probo/ui"; +import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay"; + +import type { MoveToCategoryDropdownQuery } from "#/__generated__/core/MoveToCategoryDropdownQuery.graphql"; + +export const moveToCategoryDropdownQuery = graphql` + query MoveToCategoryDropdownQuery($cookieBannerId: ID!) { + node(id: $cookieBannerId) @required(action: THROW) { + __typename + ... on CookieBanner { + consentCategories(first: 50, orderBy: { field: RANK, direction: ASC }) + @required(action: THROW) { + edges { + node { + id + name + } + } + } + } + } + } +`; + +interface MoveToCategoryDropdownProps { + queryRef: PreloadedQuery; + onMove: (categoryId: string) => void; +} + +export function MoveToCategoryDropdown({ + queryRef, + onMove, +}: MoveToCategoryDropdownProps) { + const { __ } = useTranslate(); + const data = usePreloadedQuery(moveToCategoryDropdownQuery, queryRef); + + if (data.node.__typename !== "CookieBanner") { + return null; + } + + const categories = data.node.consentCategories.edges.map(e => e.node); + + if (categories.length === 0) { + return ( + + {__("No categories")} + + ); + } + + return ( + <> + {categories.map(cat => ( + onMove(cat.id)} + > + {cat.name} + + ))} + + ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/display/CookieBannerDisplayPage.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/display/CookieBannerDisplayPage.tsx index 1c9ba3550..7ed58f51b 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/display/CookieBannerDisplayPage.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/display/CookieBannerDisplayPage.tsx @@ -12,12 +12,16 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { useTranslate } from "@probo/i18n"; +import { Button, Card, IconPlusSmall } from "@probo/ui"; +import { useState } from "react"; import { type PreloadedQuery, usePreloadedQuery } from "react-relay"; import { graphql } from "relay-runtime"; import type { CookieBannerDisplayPageQuery } from "#/__generated__/core/CookieBannerDisplayPageQuery.graphql"; -import { CategoryList } from "./_components/CategoryList"; +import { CategoryDialog } from "./_components/CategoryDialog"; +import { CategorySection } from "./_components/CategorySection"; import { ThemePreview } from "./_components/ThemePreview"; export const cookieBannerDisplayPageQuery = graphql` @@ -25,7 +29,19 @@ export const cookieBannerDisplayPageQuery = graphql` node(id: $cookieBannerId) @required(action: THROW) { __typename ... on CookieBanner { - ...CategoryList_cookieBanner + id + consentCategories(first: 50, orderBy: { field: RANK, direction: ASC }) + @connection(key: "CookieBannerDisplayPage_consentCategories") + @required(action: THROW) { + __id + edges { + node { + id + rank + ...CategorySectionFragment + } + } + } ...ThemePreview_cookieBanner } } @@ -36,16 +52,59 @@ interface CookieBannerDisplayPageProps { queryRef: PreloadedQuery; } -export default function CookieBannerDisplayPage({ queryRef }: CookieBannerDisplayPageProps) { +export default function CookieBannerDisplayPage({ + queryRef, +}: CookieBannerDisplayPageProps) { + const { __ } = useTranslate(); const data = usePreloadedQuery(cookieBannerDisplayPageQuery, queryRef); if (data.node.__typename !== "CookieBanner") { throw new Error("invalid type for node"); } + const banner = data.node; + const connectionId = banner.consentCategories.__id; + const categories = banner.consentCategories.edges.map(e => e.node); + + const [showCreateDialog, setShowCreateDialog] = useState(false); + return (
- +
+
+

+ {__("Organize cookies into categories and declare which cookies your site uses.")} +

+ +
+ + {categories.length === 0 && ( + + {__("No categories yet. Add a category to start managing cookies.")} + + )} + + {categories.map(category => ( + + ))} + + {showCreateDialog && ( + 0 ? categories[categories.length - 1].rank + 1 : 0} + onOpenChange={setShowCreateDialog} + /> + )} +
+
); diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/AddCookieRow.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/display/_components/AddCookieRow.tsx similarity index 100% rename from apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/AddCookieRow.tsx rename to apps/console/src/pages/organizations/cookie-banners/configuration/display/_components/AddCookieRow.tsx diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategoryDialog.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/display/_components/CategoryDialog.tsx similarity index 100% rename from apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategoryDialog.tsx rename to apps/console/src/pages/organizations/cookie-banners/configuration/display/_components/CategoryDialog.tsx diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/display/_components/CategoryList.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/display/_components/CategoryList.tsx deleted file mode 100644 index ecdf7b2fa..000000000 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/display/_components/CategoryList.tsx +++ /dev/null @@ -1,206 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// 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 { Badge, Button, Card, IconArrowDown, IconArrowUp, useConfirm, useToast } from "@probo/ui"; -import { useFragment, useMutation } from "react-relay"; -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 { CategoryListReorderMutation } from "#/__generated__/core/CategoryListReorderMutation.graphql"; - -const categoryListFragment = graphql` - fragment CategoryList_cookieBanner on CookieBanner { - id - consentCategories(first: 50, orderBy: { field: RANK, direction: ASC }) - @connection(key: "CategoryList_consentCategories") - @required(action: THROW) { - __id - edges { - node { - id - name - description - kind - rank - } - } - } - } -`; - -const deleteCategoryMutation = graphql` - mutation CategoryListDeleteMutation( - $input: DeleteCookieCategoryInput! - $connections: [ID!]! - ) { - deleteCookieCategory(input: $input) { - deletedCookieCategoryId @deleteEdge(connections: $connections) - cookieBanner { - id - latestVersion { - id - version - state - } - } - } - } -`; - -const reorderCategoryMutation = graphql` - mutation CategoryListReorderMutation($input: ReorderCookieCategoryInput!) { - reorderCookieCategory(input: $input) { - cookieBanner { - id - ...CategoryList_cookieBanner - latestVersion { - id - version - state - } - } - } - } -`; - -interface CategoryListProps { - cookieBannerKey: CategoryList_cookieBanner$key; -} - -export function CategoryList({ cookieBannerKey }: CategoryListProps) { - const { __ } = useTranslate(); - const { toast } = useToast(); - const confirm = useConfirm(); - - const banner = useFragment(categoryListFragment, cookieBannerKey); - const connectionId = banner.consentCategories.__id; - const categories = banner.consentCategories.edges.map(e => e.node); - - const [deleteCategory] = useMutation(deleteCategoryMutation); - const [reorderCategory] = useMutation(reorderCategoryMutation); - - const sorted = [...categories].sort((a, b) => a.rank - b.rank); - - 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"), - }, - ); - }; - - const handleMoveUp = (index: number) => { - if (index === 0) return; - const current = sorted[index]; - const above = sorted[index - 1]; - reorderCategory({ - variables: { input: { cookieCategoryId: current.id, rank: above.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]; - reorderCategory({ - variables: { input: { cookieCategoryId: current.id, rank: below.rank } }, - onError(error) { - toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" }); - }, - }); - }; - - return ( -
-

{__("Categories Sorting")}

-

- {__("Categories will be displayed in your cookie banner in the same order as below.")} -

- - {sorted.map((category, index) => ( -
-
-
- {category.name} - {category.kind === "NECESSARY" && ( - {__("Required")} - )} -
-
-
- - -
- {category.kind === "NORMAL" && ( - - )} -
-
-

{category.description}

-
- ))} -
- -
- ); -} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/display/_components/CategorySection.tsx similarity index 75% rename from apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx rename to apps/console/src/pages/organizations/cookie-banners/configuration/display/_components/CategorySection.tsx index 859101d97..8e941492c 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/cookies/_components/CategorySection.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/display/_components/CategorySection.tsx @@ -12,6 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react"; import { formatError, type GraphQLError, humanizeSeconds } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { @@ -21,6 +22,8 @@ import { Dropdown, DropdownItem, IconArrowBoxLeft, + IconArrowDown, + IconArrowUp, IconPencil, IconPlusSmall, IconTrashCan, @@ -28,8 +31,8 @@ import { Td, Th, Thead, - Toggle, Tr, + useConfirm, useToast, } from "@probo/ui"; import { useState } from "react"; @@ -37,9 +40,11 @@ import { useFragment, useMutation } from "react-relay"; import { ConnectionHandler, graphql } from "relay-runtime"; import type { CategorySectionCreatePatternMutation } from "#/__generated__/core/CategorySectionCreatePatternMutation.graphql"; +import type { CategorySectionDeleteCategoryMutation } from "#/__generated__/core/CategorySectionDeleteCategoryMutation.graphql"; import type { CategorySectionDeletePatternMutation } from "#/__generated__/core/CategorySectionDeletePatternMutation.graphql"; import type { CategorySectionFragment$key } from "#/__generated__/core/CategorySectionFragment.graphql"; import type { CategorySectionMovePatternMutation } from "#/__generated__/core/CategorySectionMovePatternMutation.graphql"; +import type { CategorySectionReorderMutation } from "#/__generated__/core/CategorySectionReorderMutation.graphql"; import type { CategorySectionUpdateMutation } from "#/__generated__/core/CategorySectionUpdateMutation.graphql"; import type { CategorySectionUpdatePatternMutation } from "#/__generated__/core/CategorySectionUpdatePatternMutation.graphql"; @@ -85,6 +90,8 @@ export const categorySectionFragment = graphql` node { id name + rank + kind } } } @@ -219,15 +226,60 @@ const movePatternMutation = graphql` } `; +const deleteCategoryMutation = graphql` + mutation CategorySectionDeleteCategoryMutation( + $input: DeleteCookieCategoryInput! + $connections: [ID!]! + ) { + deleteCookieCategory(input: $input) { + deletedCookieCategoryId @deleteEdge(connections: $connections) + cookieBanner { + id + latestVersion { + id + version + state + } + } + } + } +`; + +const reorderCategoryMutation = graphql` + mutation CategorySectionReorderMutation( + $input: ReorderCookieCategoryInput! + ) { + reorderCookieCategory(input: $input) { + cookieBanner { + id + consentCategories(first: 50, orderBy: { field: RANK, direction: ASC }) { + edges { + node { + id + rank + } + } + } + latestVersion { + id + version + state + } + } + } + } +`; + interface CategorySectionProps { categoryKey: CategorySectionFragment$key; - onDelete?: () => void; + connectionId: string; } -export function CategorySection({ categoryKey, onDelete }: CategorySectionProps) { +export function CategorySection({ categoryKey, connectionId }: CategorySectionProps) { const category = useFragment(categorySectionFragment, categoryKey); const { __ } = useTranslate(); const { toast } = useToast(); + const confirm = useConfirm(); const [updateCategory, isUpdating] = useMutation(updateCategoryMutation); @@ -239,6 +291,10 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps) = useMutation(deletePatternMutation); const [movePattern] = useMutation(movePatternMutation); + const [deleteCategory] + = useMutation(deleteCategoryMutation); + const [reorderCategory] + = useMutation(reorderCategoryMutation); const [isEditingCategory, setIsEditingCategory] = useState(false); const [editingCookieId, setEditingCookieId] = useState(null); @@ -417,42 +473,111 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps) }); }; - const handleDeleteCookie = (patternId: string) => { - deletePattern({ - variables: { - input: { cookiePatternId: patternId }, - connections: [patternsConnectionId], - }, - onCompleted(_response, errors) { - if (errors?.length) { - toast({ - title: __("Error"), - description: errors[0].message, - variant: "error", + const handleDeleteCookie = (patternId: string, patternName: string) => { + confirm( + () => + new Promise((resolve) => { + deletePattern({ + variables: { + input: { cookiePatternId: patternId }, + connections: [patternsConnectionId], + }, + onCompleted(_response, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: errors[0].message, + variant: "error", + }); + } else { + toast({ + title: __("Success"), + description: __("Cookie deleted"), + variant: "success", + }); + } + resolve(); + }, + onError(error) { + toast({ + title: __("Error"), + description: formatError( + __("Failed to delete cookie"), + error as GraphQLError, + ), + variant: "error", + }); + resolve(); + }, }); - return; - } - toast({ - title: __("Success"), - description: __("Cookie deleted"), - variant: "success", - }); + }), + { + message: __("Are you sure you want to delete \"%s\"?").replace("%s", patternName), + variant: "danger", + label: __("Delete"), }, - onError(error) { - toast({ - title: __("Error"), - description: formatError( - __("Failed to delete cookie"), - error as GraphQLError, - ), - variant: "error", - }); - }, - }); + ); }; const allCategories = category.cookieBanner.consentCategories.edges.map(e => e.node) ?? []; const siblingCategories = allCategories.filter(c => c.id !== category.id); + const selfIndex = allCategories.findIndex(c => c.id === category.id); + const isFirst = selfIndex === 0; + const isLast = selfIndex === allCategories.length - 1; + const canDelete = category.kind === "NORMAL"; + + const handleDeleteCategory = () => { + confirm( + () => + new Promise((resolve) => { + deleteCategory({ + variables: { + input: { cookieCategoryId: category.id }, + 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", category.name), + variant: "danger", + label: __("Delete"), + }, + ); + }; + + const handleMoveUp = () => { + if (isFirst) return; + const above = allCategories[selfIndex - 1]; + reorderCategory({ + variables: { input: { cookieCategoryId: category.id, rank: above.rank } }, + onError(error) { + toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" }); + }, + }); + }; + + const handleMoveDown = () => { + if (isLast) return; + const below = allCategories[selfIndex + 1]; + reorderCategory({ + variables: { input: { cookieCategoryId: category.id, rank: below.rank } }, + onError(error) { + toast({ title: __("Error"), description: formatError(__("Failed to reorder"), error as GraphQLError), variant: "error" }); + }, + }); + }; const handleMoveCookie = (patternId: string, targetCategoryId: string) => { movePattern({ @@ -548,6 +673,24 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps) )}
+
+ + +
- {onDelete && ( - @@ -607,7 +750,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps) - + @@ -627,18 +770,9 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps) /> ) : ( - +
{__("Name")}{__("Name")} {__("Source")} {__("Duration")} {__("Description")}
-
- handleToggleExcluded(pattern.id, !pattern.excluded)} - disabled={isUpdatingPattern} - title={__("Include this cookie in the banner")} - /> - {pattern.displayName} -
+ {pattern.displayName}
+