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,
|
Breadcrumb,
|
||||||
Button,
|
Button,
|
||||||
IconGlobe,
|
IconGlobe,
|
||||||
IconListStack,
|
|
||||||
IconPageTextLine,
|
IconPageTextLine,
|
||||||
IconSettingsGear2,
|
IconSettingsGear2,
|
||||||
IconSquareBehindSquare2,
|
IconSquareBehindSquare2,
|
||||||
@@ -248,10 +247,6 @@ export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfi
|
|||||||
<IconGlobe size={20} />
|
<IconGlobe size={20} />
|
||||||
{__("Translations")}
|
{__("Translations")}
|
||||||
</TabLink>
|
</TabLink>
|
||||||
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/cookies`}>
|
|
||||||
<IconListStack size={20} />
|
|
||||||
{__("Cookies")}
|
|
||||||
</TabLink>
|
|
||||||
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/detection`}>
|
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/detection`}>
|
||||||
<MagnifyingGlassIcon size={20} />
|
<MagnifyingGlassIcon size={20} />
|
||||||
{__("Detection")}
|
{__("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,
|
Option,
|
||||||
Select,
|
Select,
|
||||||
Tbody,
|
Tbody,
|
||||||
|
Th,
|
||||||
Thead,
|
Thead,
|
||||||
Tr,
|
Tr,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
@@ -77,6 +78,7 @@ const detectionFragment = graphql`
|
|||||||
filters: ["filter", "orderBy"]
|
filters: ["filter", "orderBy"]
|
||||||
)
|
)
|
||||||
@required(action: THROW) {
|
@required(action: THROW) {
|
||||||
|
__id
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
@@ -110,6 +112,7 @@ export default function CookieBannerDetectionPage({
|
|||||||
CookieBannerDetectionPageFragment$key
|
CookieBannerDetectionPageFragment$key
|
||||||
>(detectionFragment, data.node);
|
>(detectionFragment, data.node);
|
||||||
|
|
||||||
|
const connectionId = fragmentData.uncategorisedPatterns.__id;
|
||||||
const patterns = fragmentData.uncategorisedPatterns.edges.map(edge => edge.node) ?? [];
|
const patterns = fragmentData.uncategorisedPatterns.edges.map(edge => edge.node) ?? [];
|
||||||
|
|
||||||
const refetchFilters = (overrides: Record<string, unknown> = {}) => {
|
const refetchFilters = (overrides: Record<string, unknown> = {}) => {
|
||||||
@@ -178,11 +181,16 @@ export default function CookieBannerDetectionPage({
|
|||||||
<SortableTh field="SOURCE">{__("Source")}</SortableTh>
|
<SortableTh field="SOURCE">{__("Source")}</SortableTh>
|
||||||
<SortableTh field="LAST_MATCHED_AT">{__("Last Matched")}</SortableTh>
|
<SortableTh field="LAST_MATCHED_AT">{__("Last Matched")}</SortableTh>
|
||||||
<SortableTh field="UPDATED_AT">{__("Updated")}</SortableTh>
|
<SortableTh field="UPDATED_AT">{__("Updated")}</SortableTh>
|
||||||
|
<Th className="w-28" />
|
||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
{patterns.map(pattern => (
|
{patterns.map(pattern => (
|
||||||
<DetectionPatternRow key={pattern.id} patternKey={pattern} />
|
<DetectionPatternRow
|
||||||
|
key={pattern.id}
|
||||||
|
patternKey={pattern}
|
||||||
|
connectionId={connectionId}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Tbody>
|
</Tbody>
|
||||||
</SortableTable>
|
</SortableTable>
|
||||||
|
|||||||
@@ -12,34 +12,266 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// 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 { useTranslate } from "@probo/i18n";
|
||||||
import { Badge, Td, Tr } from "@probo/ui";
|
import {
|
||||||
import { graphql, useFragment } from "react-relay";
|
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 { 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`
|
const detectionPatternFragment = graphql`
|
||||||
fragment DetectionPatternRowFragment on CookiePattern {
|
fragment DetectionPatternRowFragment on CookiePattern {
|
||||||
|
id
|
||||||
displayName
|
displayName
|
||||||
matchType
|
|
||||||
source
|
source
|
||||||
description
|
description
|
||||||
|
maxAgeSeconds
|
||||||
|
excluded
|
||||||
lastMatchedAt
|
lastMatchedAt
|
||||||
updatedAt
|
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 {
|
interface DetectionPatternRowProps {
|
||||||
patternKey: DetectionPatternRowFragment$key;
|
patternKey: DetectionPatternRowFragment$key;
|
||||||
|
connectionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetectionPatternRow({ patternKey }: DetectionPatternRowProps) {
|
export function DetectionPatternRow({ patternKey, connectionId }: DetectionPatternRowProps) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
|
||||||
const pattern = useFragment(detectionPatternFragment, patternKey);
|
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 (
|
return (
|
||||||
<Tr>
|
<DetectionPatternRowEdit
|
||||||
|
displayName={pattern.displayName}
|
||||||
|
description={pattern.description}
|
||||||
|
maxAgeSeconds={pattern.maxAgeSeconds ?? null}
|
||||||
|
isUpdating={isUpdating}
|
||||||
|
onSave={handleSaveEdit}
|
||||||
|
onCancel={() => setIsEditing(false)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr className={pattern.excluded ? "opacity-80" : undefined}>
|
||||||
<Td>
|
<Td>
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="font-medium">{pattern.displayName}</span>
|
<span className="font-medium">{pattern.displayName}</span>
|
||||||
@@ -69,6 +301,52 @@ export function DetectionPatternRow({ patternKey }: DetectionPatternRowProps) {
|
|||||||
{formatDate(pattern.updatedAt)}
|
{formatDate(pattern.updatedAt)}
|
||||||
</time>
|
</time>
|
||||||
</Td>
|
</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>
|
</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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// 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 { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { CookieBannerDisplayPageQuery } from "#/__generated__/core/CookieBannerDisplayPageQuery.graphql";
|
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";
|
import { ThemePreview } from "./_components/ThemePreview";
|
||||||
|
|
||||||
export const cookieBannerDisplayPageQuery = graphql`
|
export const cookieBannerDisplayPageQuery = graphql`
|
||||||
@@ -25,7 +29,19 @@ export const cookieBannerDisplayPageQuery = graphql`
|
|||||||
node(id: $cookieBannerId) @required(action: THROW) {
|
node(id: $cookieBannerId) @required(action: THROW) {
|
||||||
__typename
|
__typename
|
||||||
... on CookieBanner {
|
... 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
|
...ThemePreview_cookieBanner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,16 +52,59 @@ interface CookieBannerDisplayPageProps {
|
|||||||
queryRef: PreloadedQuery<CookieBannerDisplayPageQuery>;
|
queryRef: PreloadedQuery<CookieBannerDisplayPageQuery>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CookieBannerDisplayPage({ queryRef }: CookieBannerDisplayPageProps) {
|
export default function CookieBannerDisplayPage({
|
||||||
|
queryRef,
|
||||||
|
}: CookieBannerDisplayPageProps) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
const data = usePreloadedQuery(cookieBannerDisplayPageQuery, queryRef);
|
const data = usePreloadedQuery(cookieBannerDisplayPageQuery, queryRef);
|
||||||
|
|
||||||
if (data.node.__typename !== "CookieBanner") {
|
if (data.node.__typename !== "CookieBanner") {
|
||||||
throw new Error("invalid type for node");
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<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} />
|
<ThemePreview cookieBannerKey={data.node} />
|
||||||
</div>
|
</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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
|
||||||
import { formatError, type GraphQLError, humanizeSeconds } from "@probo/helpers";
|
import { formatError, type GraphQLError, humanizeSeconds } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,8 @@ import {
|
|||||||
Dropdown,
|
Dropdown,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
IconArrowBoxLeft,
|
IconArrowBoxLeft,
|
||||||
|
IconArrowDown,
|
||||||
|
IconArrowUp,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPlusSmall,
|
IconPlusSmall,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
@@ -28,8 +31,8 @@ import {
|
|||||||
Td,
|
Td,
|
||||||
Th,
|
Th,
|
||||||
Thead,
|
Thead,
|
||||||
Toggle,
|
|
||||||
Tr,
|
Tr,
|
||||||
|
useConfirm,
|
||||||
useToast,
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -37,9 +40,11 @@ import { useFragment, useMutation } from "react-relay";
|
|||||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { CategorySectionCreatePatternMutation } from "#/__generated__/core/CategorySectionCreatePatternMutation.graphql";
|
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 { CategorySectionDeletePatternMutation } from "#/__generated__/core/CategorySectionDeletePatternMutation.graphql";
|
||||||
import type { CategorySectionFragment$key } from "#/__generated__/core/CategorySectionFragment.graphql";
|
import type { CategorySectionFragment$key } from "#/__generated__/core/CategorySectionFragment.graphql";
|
||||||
import type { CategorySectionMovePatternMutation } from "#/__generated__/core/CategorySectionMovePatternMutation.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 { CategorySectionUpdateMutation } from "#/__generated__/core/CategorySectionUpdateMutation.graphql";
|
||||||
import type { CategorySectionUpdatePatternMutation } from "#/__generated__/core/CategorySectionUpdatePatternMutation.graphql";
|
import type { CategorySectionUpdatePatternMutation } from "#/__generated__/core/CategorySectionUpdatePatternMutation.graphql";
|
||||||
|
|
||||||
@@ -85,6 +90,8 @@ export const categorySectionFragment = graphql`
|
|||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
name
|
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 {
|
interface CategorySectionProps {
|
||||||
categoryKey: CategorySectionFragment$key;
|
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 category = useFragment(categorySectionFragment, categoryKey);
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
const [updateCategory, isUpdating]
|
const [updateCategory, isUpdating]
|
||||||
= useMutation<CategorySectionUpdateMutation>(updateCategoryMutation);
|
= useMutation<CategorySectionUpdateMutation>(updateCategoryMutation);
|
||||||
@@ -239,6 +291,10 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
= useMutation<CategorySectionDeletePatternMutation>(deletePatternMutation);
|
= useMutation<CategorySectionDeletePatternMutation>(deletePatternMutation);
|
||||||
const [movePattern]
|
const [movePattern]
|
||||||
= useMutation<CategorySectionMovePatternMutation>(movePatternMutation);
|
= useMutation<CategorySectionMovePatternMutation>(movePatternMutation);
|
||||||
|
const [deleteCategory]
|
||||||
|
= useMutation<CategorySectionDeleteCategoryMutation>(deleteCategoryMutation);
|
||||||
|
const [reorderCategory]
|
||||||
|
= useMutation<CategorySectionReorderMutation>(reorderCategoryMutation);
|
||||||
|
|
||||||
const [isEditingCategory, setIsEditingCategory] = useState(false);
|
const [isEditingCategory, setIsEditingCategory] = useState(false);
|
||||||
const [editingCookieId, setEditingCookieId] = useState<string | null>(null);
|
const [editingCookieId, setEditingCookieId] = useState<string | null>(null);
|
||||||
@@ -417,7 +473,10 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCookie = (patternId: string) => {
|
const handleDeleteCookie = (patternId: string, patternName: string) => {
|
||||||
|
confirm(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
deletePattern({
|
deletePattern({
|
||||||
variables: {
|
variables: {
|
||||||
input: { cookiePatternId: patternId },
|
input: { cookiePatternId: patternId },
|
||||||
@@ -430,13 +489,14 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
description: errors[0].message,
|
description: errors[0].message,
|
||||||
variant: "error",
|
variant: "error",
|
||||||
});
|
});
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
toast({
|
toast({
|
||||||
title: __("Success"),
|
title: __("Success"),
|
||||||
description: __("Cookie deleted"),
|
description: __("Cookie deleted"),
|
||||||
variant: "success",
|
variant: "success",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
},
|
},
|
||||||
onError(error) {
|
onError(error) {
|
||||||
toast({
|
toast({
|
||||||
@@ -447,12 +507,77 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
),
|
),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
});
|
});
|
||||||
|
resolve();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
message: __("Are you sure you want to delete \"%s\"?").replace("%s", patternName),
|
||||||
|
variant: "danger",
|
||||||
|
label: __("Delete"),
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const allCategories = category.cookieBanner.consentCategories.edges.map(e => e.node) ?? [];
|
const allCategories = category.cookieBanner.consentCategories.edges.map(e => e.node) ?? [];
|
||||||
const siblingCategories = allCategories.filter(c => c.id !== category.id);
|
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) => {
|
const handleMoveCookie = (patternId: string, targetCategoryId: string) => {
|
||||||
movePattern({
|
movePattern({
|
||||||
@@ -548,6 +673,24 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setIsEditingCategory(true)}
|
onClick={() => setIsEditingCategory(true)}
|
||||||
@@ -555,8 +698,8 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
<IconPencil size={14} />
|
<IconPencil size={14} />
|
||||||
{__("Edit")}
|
{__("Edit")}
|
||||||
</Button>
|
</Button>
|
||||||
{onDelete && (
|
{canDelete && (
|
||||||
<Button variant="danger" onClick={onDelete}>
|
<Button variant="danger" onClick={handleDeleteCategory}>
|
||||||
<IconTrashCan size={14} />
|
<IconTrashCan size={14} />
|
||||||
{__("Delete")}
|
{__("Delete")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -607,7 +750,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
<Thead>
|
<Thead>
|
||||||
<Tr>
|
<Tr>
|
||||||
<Th><span className="pl-10">{__("Name")}</span></Th>
|
<Th>{__("Name")}</Th>
|
||||||
<Th>{__("Source")}</Th>
|
<Th>{__("Source")}</Th>
|
||||||
<Th>{__("Duration")}</Th>
|
<Th>{__("Duration")}</Th>
|
||||||
<Th>{__("Description")}</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>
|
<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>
|
<code className="text-sm font-mono">{pattern.displayName}</code>
|
||||||
</div>
|
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -660,6 +794,14 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -694,7 +836,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDeleteCookie(pattern.id)}
|
onClick={() => handleDeleteCookie(pattern.id, pattern.displayName)}
|
||||||
className="p-1 rounded cursor-pointer text-danger-dark"
|
className="p-1 rounded cursor-pointer text-danger-dark"
|
||||||
>
|
>
|
||||||
<IconTrashCan size={14} />
|
<IconTrashCan size={14} />
|
||||||
@@ -49,11 +49,6 @@ export const cookieBannerRoutes = [
|
|||||||
Fallback: LinkCardSkeleton,
|
Fallback: LinkCardSkeleton,
|
||||||
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/settings/CookieBannerSettingsPageLoader")),
|
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",
|
path: "translations",
|
||||||
Fallback: LinkCardSkeleton,
|
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.)
|
(Snippet names and GraphQL types are illustrative; align with real schema and fragment names in the app.)
|
||||||
|
|||||||
Reference in New Issue
Block a user