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:
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
@@ -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,
|
||||
|
||||
@@ -333,4 +333,70 @@ export function PageSection({ className, title, icon, children }: PageSectionPro
|
||||
}
|
||||
```
|
||||
|
||||
## Interaction-triggered data
|
||||
|
||||
Data that is only needed after a user interaction (opening a dropdown, clicking a button, hovering) must **not** be fetched at page load. Instead, the parent component owns the query lifecycle with `useQueryLoader`, triggers `loadQuery` in the interaction event handler, and passes `queryRef` to a child component that reads data with `usePreloadedQuery`.
|
||||
|
||||
This applies to any data displayed after interaction, not at page load: dropdown menus with server-sourced options, hover cards, expandable panels, dialogs, etc.
|
||||
|
||||
### Pattern
|
||||
|
||||
1. The **parent** component calls `useQueryLoader` and triggers `loadQuery` on the interaction event (e.g. `onOpenChange` for a dropdown).
|
||||
2. A **child** component exports its query, receives `queryRef` as a prop, and reads data with `usePreloadedQuery`.
|
||||
3. The parent wraps the child in a `Suspense` boundary and only renders it when `queryRef` is available.
|
||||
4. IDs needed for the query come from `useParams` in the parent — they are **not** passed as data props from a grandparent.
|
||||
|
||||
### Do / don't: dropdown with server-sourced options
|
||||
|
||||
```tsx
|
||||
// Bad — page fetches categories at load and drills them as a data prop
|
||||
function DetectionPage() {
|
||||
const data = usePreloadedQuery(/* query that includes consentCategories */);
|
||||
const categories = data.consentCategories.edges.map(e => e.node);
|
||||
return <PatternRow categories={categories} />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Good — parent owns useQueryLoader, child reads with usePreloadedQuery
|
||||
|
||||
// PatternRow.tsx (parent — owns query lifecycle)
|
||||
import { moveToCategoryQuery, MoveToCategoryMenu } from "./MoveToCategoryMenu";
|
||||
|
||||
function PatternRow() {
|
||||
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
|
||||
const [queryRef, loadQuery] = useQueryLoader<Query>(moveToCategoryQuery);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (open && cookieBannerId) {
|
||||
loadQuery({ cookieBannerId });
|
||||
}
|
||||
}, [loadQuery, cookieBannerId]);
|
||||
|
||||
return (
|
||||
<Dropdown onOpenChange={handleOpenChange} toggle={/* … */}>
|
||||
{queryRef && (
|
||||
<Suspense>
|
||||
<MoveToCategoryMenu queryRef={queryRef} onMove={handleMove} />
|
||||
</Suspense>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// MoveToCategoryMenu.tsx (child — reads data)
|
||||
export const moveToCategoryQuery = graphql`
|
||||
query MoveToCategoryMenuQuery($cookieBannerId: ID!) { /* … */ }
|
||||
`;
|
||||
|
||||
export function MoveToCategoryMenu({ queryRef, onMove }: Props) {
|
||||
const data = usePreloadedQuery(moveToCategoryQuery, queryRef);
|
||||
return /* render DropdownItems */;
|
||||
}
|
||||
```
|
||||
|
||||
See also the "Interaction-triggered queries" section in [`contrib/claude/relay.md`](relay.md).
|
||||
|
||||
(Snippet names and GraphQL types are illustrative; align with real schema and fragment names in the app.)
|
||||
|
||||
Reference in New Issue
Block a user