Move cookie management to display page

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é <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-05 11:03:36 +04:00
parent c26b9c0abe
commit 3b4aa7f971
16 changed files with 799 additions and 502 deletions

View File

@@ -20,7 +20,6 @@ import {
Breadcrumb,
Button,
IconGlobe,
IconListStack,
IconPageTextLine,
IconSettingsGear2,
IconSquareBehindSquare2,
@@ -248,10 +247,6 @@ export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfi
<IconGlobe size={20} />
{__("Translations")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/cookies`}>
<IconListStack size={20} />
{__("Cookies")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/detection`}>
<MagnifyingGlassIcon size={20} />
{__("Detection")}

View File

@@ -1,184 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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<CookieBannerCookiesPageQuery>;
}
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<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">
<p className="text-sm text-muted-foreground">
{__("Organize cookies into categories and declare which cookies your site uses.")}
</p>
<Button variant="secondary" onClick={() => setShowCreateDialog(true)}>
<IconPlusSmall size={16} />
{__("Add Category")}
</Button>
</div>
{sorted.length === 0 && (
<Card className="border p-8 text-center text-muted-foreground">
{__("No categories yet. Add a category to start managing cookies.")}
</Card>
)}
{sorted.map(category => (
<CategorySection
key={category.id}
categoryKey={category}
onDelete={
category.kind === "NORMAL"
? () => handleDeleteCategory(category.id, category.name)
: undefined
}
/>
))}
{showCreateDialog && (
<CategoryDialog
cookieBannerId={banner.id}
connectionId={connectionId}
nextRank={sorted.length > 0 ? sorted[sorted.length - 1].rank + 1 : 0}
onOpenChange={setShowCreateDialog}
/>
)}
</div>
);
}

View File

@@ -1,43 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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>(cookieBannerCookiesPageQuery);
useEffect(() => {
if (cookieBannerId) {
loadQuery({ cookieBannerId });
}
}, [loadQuery, cookieBannerId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<CookieBannerCookiesPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -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<string, unknown> = {}) => {
@@ -178,11 +181,16 @@ export default function CookieBannerDetectionPage({
<SortableTh field="SOURCE">{__("Source")}</SortableTh>
<SortableTh field="LAST_MATCHED_AT">{__("Last Matched")}</SortableTh>
<SortableTh field="UPDATED_AT">{__("Updated")}</SortableTh>
<Th className="w-28" />
</Tr>
</Thead>
<Tbody>
{patterns.map(pattern => (
<DetectionPatternRow key={pattern.id} patternKey={pattern} />
<DetectionPatternRow
key={pattern.id}
patternKey={pattern}
connectionId={connectionId}
/>
))}
</Tbody>
</SortableTable>

View File

@@ -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>(moveToCategoryDropdownQuery);
const handleCategoryDropdownOpen = useCallback(
(open: boolean) => {
if (open && cookieBannerId) {
loadCategoryQuery({ cookieBannerId });
}
},
[loadCategoryQuery, cookieBannerId],
);
const [deletePattern]
= useMutation<DetectionPatternRowDeleteMutation>(deletePatternMutation);
const [movePattern]
= useMutation<DetectionPatternRowMoveMutation>(movePatternMutation);
const [updatePattern, isUpdating]
= useMutation<DetectionPatternRowUpdateMutation>(updatePatternMutation);
const handleDelete = () => {
confirm(
() =>
new Promise<void>((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 (
<DetectionPatternRowEdit
displayName={pattern.displayName}
description={pattern.description}
maxAgeSeconds={pattern.maxAgeSeconds ?? null}
isUpdating={isUpdating}
onSave={handleSaveEdit}
onCancel={() => setIsEditing(false)}
/>
);
}
return (
<Tr>
<Tr className={pattern.excluded ? "opacity-80" : undefined}>
<Td>
<div className="flex flex-col min-w-0">
<span className="font-medium">{pattern.displayName}</span>
@@ -69,6 +301,52 @@ export function DetectionPatternRow({ patternKey }: DetectionPatternRowProps) {
{formatDate(pattern.updatedAt)}
</time>
</Td>
<Td>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setIsEditing(true)}
className="p-1 rounded cursor-pointer"
title={__("Edit")}
>
<IconPencil size={14} />
</button>
<Dropdown
onOpenChange={handleCategoryDropdownOpen}
toggle={(
<button
type="button"
className="p-1 rounded cursor-pointer"
title={__("Move to category")}
>
<IconArrowBoxLeft size={14} />
</button>
)}
>
{categoryQueryRef && (
<Suspense>
<MoveToCategoryDropdown queryRef={categoryQueryRef} onMove={handleMove} />
</Suspense>
)}
</Dropdown>
<button
type="button"
onClick={handleToggleExcluded}
className="p-1 rounded cursor-pointer"
title={pattern.excluded ? __("Include") : __("Exclude")}
>
{pattern.excluded ? <IconEye size={14} /> : <IconEyeSlash size={14} />}
</button>
<button
type="button"
onClick={handleDelete}
className="p-1 rounded cursor-pointer text-danger-dark"
title={__("Delete")}
>
<IconTrashCan size={14} />
</button>
</div>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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<FormValues>({
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 (
<Tr>
<Td className="pr-3">
<Input
{...register("displayName")}
placeholder={__("Cookie name")}
/>
</Td>
<Td />
<Td className="pr-3">
<Controller
name="duration"
control={control}
render={({ field }) => (
<DurationInput
value={field.value.value}
unit={field.value.unit}
onValueChange={v => field.onChange({ ...field.value, value: v })}
onUnitChange={u => field.onChange({ ...field.value, unit: u })}
/>
)}
/>
</Td>
<Td className="pr-3" colSpan={2}>
<div className="flex items-center gap-2">
<Input
{...register("description")}
placeholder={__("Description")}
className="flex-1"
/>
<Button
onClick={() => void handleSubmit(onSubmit)()}
disabled={isUpdating}
>
{__("Save")}
</Button>
<Button
variant="secondary"
onClick={onCancel}
>
{__("Cancel")}
</Button>
</div>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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<MoveToCategoryDropdownQuery>;
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 (
<DropdownItem className="text-sm text-txt-tertiary" disabled>
{__("No categories")}
</DropdownItem>
);
}
return (
<>
{categories.map(cat => (
<DropdownItem
className="text-sm"
key={cat.id}
onSelect={() => onMove(cat.id)}
>
{cat.name}
</DropdownItem>
))}
</>
);
}

View File

@@ -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<CookieBannerDisplayPageQuery>;
}
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 (
<div className="space-y-8">
<CategoryList cookieBannerKey={data.node} />
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{__("Organize cookies into categories and declare which cookies your site uses.")}
</p>
<Button variant="secondary" onClick={() => setShowCreateDialog(true)}>
<IconPlusSmall size={16} />
{__("Add Category")}
</Button>
</div>
{categories.length === 0 && (
<Card className="border p-8 text-center text-muted-foreground">
{__("No categories yet. Add a category to start managing cookies.")}
</Card>
)}
{categories.map(category => (
<CategorySection
key={category.id}
categoryKey={category}
connectionId={connectionId}
/>
))}
{showCreateDialog && (
<CategoryDialog
cookieBannerId={banner.id}
connectionId={connectionId}
nextRank={categories.length > 0 ? categories[categories.length - 1].rank + 1 : 0}
onOpenChange={setShowCreateDialog}
/>
)}
</div>
<ThemePreview cookieBannerKey={data.node} />
</div>
);

View File

@@ -1,206 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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<CategoryListDeleteMutation>(deleteCategoryMutation);
const [reorderCategory] = useMutation<CategoryListReorderMutation>(reorderCategoryMutation);
const sorted = [...categories].sort((a, b) => a.rank - b.rank);
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"),
},
);
};
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 (
<div className="space-y-4">
<h3 className="font-medium">{__("Categories Sorting")}</h3>
<p className="text-sm text-txt-secondary">
{__("Categories will be displayed in your cookie banner in the same order as below.")}
</p>
<Card className="divide-y divide-border-low rounded-lg border">
{sorted.map((category, index) => (
<div key={category.id} className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-medium">{category.name}</span>
{category.kind === "NECESSARY" && (
<Badge variant="neutral">{__("Required")}</Badge>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleMoveUp(index)}
disabled={index === 0}
className="p-0.5 rounded cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
>
<IconArrowUp size={14} />
</button>
<button
type="button"
onClick={() => handleMoveDown(index)}
disabled={index === sorted.length - 1}
className="p-0.5 rounded cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
>
<IconArrowDown size={14} />
</button>
</div>
{category.kind === "NORMAL" && (
<Button
variant="danger"
className="h-6 px-2 text-xs"
onClick={() => handleDelete(category.id, category.name)}
>
{__("Delete")}
</Button>
)}
</div>
</div>
<p className="text-sm text-muted-foreground mb-2">{category.description}</p>
</div>
))}
</Card>
</div>
);
}

View File

@@ -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<CategorySectionUpdateMutation>(updateCategoryMutation);
@@ -239,6 +291,10 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
= useMutation<CategorySectionDeletePatternMutation>(deletePatternMutation);
const [movePattern]
= useMutation<CategorySectionMovePatternMutation>(movePatternMutation);
const [deleteCategory]
= useMutation<CategorySectionDeleteCategoryMutation>(deleteCategoryMutation);
const [reorderCategory]
= useMutation<CategorySectionReorderMutation>(reorderCategoryMutation);
const [isEditingCategory, setIsEditingCategory] = useState(false);
const [editingCookieId, setEditingCookieId] = useState<string | null>(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<void>((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<void>((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)
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<button
type="button"
onClick={handleMoveUp}
disabled={isFirst}
className="p-0.5 rounded cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
>
<IconArrowUp size={14} />
</button>
<button
type="button"
onClick={handleMoveDown}
disabled={isLast}
className="p-0.5 rounded cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
>
<IconArrowDown size={14} />
</button>
</div>
<Button
variant="secondary"
onClick={() => setIsEditingCategory(true)}
@@ -555,8 +698,8 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
<IconPencil size={14} />
{__("Edit")}
</Button>
{onDelete && (
<Button variant="danger" onClick={onDelete}>
{canDelete && (
<Button variant="danger" onClick={handleDeleteCategory}>
<IconTrashCan size={14} />
{__("Delete")}
</Button>
@@ -607,7 +750,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
<table className="w-full text-left">
<Thead>
<Tr>
<Th><span className="pl-10">{__("Name")}</span></Th>
<Th>{__("Name")}</Th>
<Th>{__("Source")}</Th>
<Th>{__("Duration")}</Th>
<Th>{__("Description")}</Th>
@@ -627,18 +770,9 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
/>
)
: (
<Tr key={pattern.id} className={pattern.excluded ? "opacity-50" : undefined}>
<Tr key={pattern.id} className={pattern.excluded ? "opacity-80" : undefined}>
<Td>
<div className="flex items-center gap-2">
<Toggle
size="sm"
checked={!pattern.excluded}
onChange={() => handleToggleExcluded(pattern.id, !pattern.excluded)}
disabled={isUpdatingPattern}
title={__("Include this cookie in the banner")}
/>
<code className="text-sm font-mono">{pattern.displayName}</code>
</div>
<code className="text-sm font-mono">{pattern.displayName}</code>
</Td>
<Td>
<Badge
@@ -660,6 +794,14 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
</Td>
<Td>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleToggleExcluded(pattern.id, !pattern.excluded)}
className="p-1 rounded cursor-pointer"
title={pattern.excluded ? __("Include") : __("Exclude")}
>
{pattern.excluded ? <EyeIcon size={14} /> : <EyeSlashIcon size={14} />}
</button>
<button
type="button"
onClick={() => {
@@ -694,7 +836,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
)}
<button
type="button"
onClick={() => handleDeleteCookie(pattern.id)}
onClick={() => handleDeleteCookie(pattern.id, pattern.displayName)}
className="p-1 rounded cursor-pointer text-danger-dark"
>
<IconTrashCan size={14} />

View File

@@ -49,11 +49,6 @@ export const cookieBannerRoutes = [
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/settings/CookieBannerSettingsPageLoader")),
},
{
path: "cookies",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/cookies/CookieBannerCookiesPageLoader")),
},
{
path: "translations",
Fallback: LinkCardSkeleton,