Add cookie banner detection page

Displays uncategorised cookie patterns in a sortable,
filterable table under a new Detection tab on the banner
configuration layout.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-04 19:11:29 +04:00
parent bb442bb86d
commit 3254c6ddec
6 changed files with 366 additions and 1 deletions

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { ClipboardTextIcon } from "@phosphor-icons/react";
import { ClipboardTextIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
@@ -252,6 +252,10 @@ export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfi
<IconListStack size={20} />
{__("Cookies")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/detection`}>
<MagnifyingGlassIcon size={20} />
{__("Detection")}
</TabLink>
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/consent-records`}>
<ClipboardTextIcon size={20} />
{__("Consent Records")}

View File

@@ -0,0 +1,205 @@
// 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 {
Card,
Input,
Option,
Select,
Tbody,
Thead,
Tr,
} from "@probo/ui";
import { type ComponentProps, useState, useTransition } from "react";
import {
graphql,
type PreloadedQuery,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import type { CookieBannerDetectionPageFragment$key } from "#/__generated__/core/CookieBannerDetectionPageFragment.graphql";
import type { CookieBannerDetectionPageQuery } from "#/__generated__/core/CookieBannerDetectionPageQuery.graphql";
import type {
CookieBannerDetectionPageRefetchQuery,
CookiePatternOrderField,
CookieSource,
} from "#/__generated__/core/CookieBannerDetectionPageRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { DetectionPatternRow } from "./_components/DetectionPatternRow";
export const cookieBannerDetectionPageQuery = graphql`
query CookieBannerDetectionPageQuery($cookieBannerId: ID!) {
node(id: $cookieBannerId) @required(action: THROW) {
__typename
... on CookieBanner {
...CookieBannerDetectionPageFragment
}
}
}
`;
const detectionFragment = graphql`
fragment CookieBannerDetectionPageFragment on CookieBanner
@refetchable(queryName: "CookieBannerDetectionPageRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "CookiePatternOrder", defaultValue: { field: NAME, direction: ASC } }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
query: { type: "String", defaultValue: null }
source: { type: "CookieSource", defaultValue: null }
) {
uncategorisedPatterns(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
filter: { query: $query, source: $source }
)
@connection(
key: "CookieBannerDetectionPage_uncategorisedPatterns"
filters: ["filter", "orderBy"]
)
@required(action: THROW) {
edges {
node {
id
...DetectionPatternRowFragment
}
}
}
}
`;
interface CookieBannerDetectionPageProps {
queryRef: PreloadedQuery<CookieBannerDetectionPageQuery>;
}
export default function CookieBannerDetectionPage({
queryRef,
}: CookieBannerDetectionPageProps) {
const { __ } = useTranslate();
const data = usePreloadedQuery(cookieBannerDetectionPageQuery, queryRef);
if (data.node.__typename !== "CookieBanner") {
throw new Error("invalid type for node");
}
const [isPending, startTransition] = useTransition();
const [queryFilter, setQueryFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState<CookieSource | null>(null);
const { data: fragmentData, ...pagination } = usePaginationFragment<
CookieBannerDetectionPageRefetchQuery,
CookieBannerDetectionPageFragment$key
>(detectionFragment, data.node);
const patterns = fragmentData.uncategorisedPatterns.edges.map(edge => edge.node) ?? [];
const refetchFilters = (overrides: Record<string, unknown> = {}) => {
startTransition(() => {
pagination.refetch(
{
query: queryFilter || null,
source: sourceFilter,
...overrides,
},
{ fetchPolicy: "network-only" },
);
});
};
const handleQuerySubmit = () => {
refetchFilters({ query: queryFilter || null });
};
const handleSourceFilterChange = (value: string) => {
const newSource = value === "ALL" ? null : (value as CookieSource);
setSourceFilter(newSource);
refetchFilters({ source: newSource });
};
const refetchWithFilters: ComponentProps<typeof SortableTable>["refetch"] = ({ order }) => {
pagination.refetch({
order: { direction: order.direction, field: order.field as CookiePatternOrderField },
query: queryFilter || null,
source: sourceFilter,
});
};
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Input
placeholder={__("Search by name or description...")}
value={queryFilter}
onChange={e => setQueryFilter(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleQuerySubmit()}
onBlur={handleQuerySubmit}
className="w-72"
/>
<Select
value={sourceFilter ?? "ALL"}
onValueChange={handleSourceFilterChange}
>
<Option value="ALL">{__("All sources")}</Option>
<Option value="SCRIPT">{__("Script")}</Option>
<Option value="PRE_EXISTING">{__("Pre-existing")}</Option>
</Select>
</div>
<div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}>
{patterns.length > 0
? (
<SortableTable
{...pagination}
refetch={refetchWithFilters}
pageSize={50}
>
<Thead>
<Tr>
<SortableTh field="NAME">{__("Name")}</SortableTh>
<SortableTh field="SOURCE">{__("Source")}</SortableTh>
<SortableTh field="LAST_MATCHED_AT">{__("Last Matched")}</SortableTh>
<SortableTh field="UPDATED_AT">{__("Updated")}</SortableTh>
</Tr>
</Thead>
<Tbody>
{patterns.map(pattern => (
<DetectionPatternRow key={pattern.id} patternKey={pattern} />
))}
</Tbody>
</SortableTable>
)
: (
<Card padded>
<div className="text-center py-12">
<h3 className="text-lg font-semibold mb-2">
{__("No uncategorised patterns")}
</h3>
<p className="text-txt-tertiary">
{__("All detected cookie patterns have been categorised. New patterns will appear here when detected.")}
</p>
</div>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
// 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 { CookieBannerDetectionPageQuery } from "#/__generated__/core/CookieBannerDetectionPageQuery.graphql";
import CookieBannerDetectionPage, { cookieBannerDetectionPageQuery } from "./CookieBannerDetectionPage";
import { CookieBannerDetectionPageSkeleton } from "./CookieBannerDetectionPageSkeleton";
export default function CookieBannerDetectionPageLoader() {
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
if (typeof cookieBannerId !== "string") {
throw new Error("Missing cookieBannerId parameter");
}
const [queryRef, loadQuery] = useQueryLoader<CookieBannerDetectionPageQuery>(
cookieBannerDetectionPageQuery,
);
useEffect(() => {
loadQuery({ cookieBannerId });
}, [loadQuery, cookieBannerId]);
if (!queryRef) {
return <CookieBannerDetectionPageSkeleton />;
}
return (
<Suspense fallback={<CookieBannerDetectionPageSkeleton />}>
<CookieBannerDetectionPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,30 @@
// 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.
export function CookieBannerDetectionPageSkeleton() {
return (
<div className="space-y-4 animate-pulse">
<div className="flex items-center gap-4">
<div className="h-9 w-64 rounded bg-bg-subtle" />
<div className="h-9 w-36 rounded bg-bg-subtle" />
</div>
<div className="rounded-lg border border-border-low">
<div className="h-10 border-b border-border-low bg-bg-subtle" />
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-12 border-b border-border-low last:border-b-0" />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
// 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 { formatDate } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Badge, Td, Tr } from "@probo/ui";
import { graphql, useFragment } from "react-relay";
import type { DetectionPatternRowFragment$key } from "#/__generated__/core/DetectionPatternRowFragment.graphql";
const detectionPatternFragment = graphql`
fragment DetectionPatternRowFragment on CookiePattern {
displayName
matchType
source
description
lastMatchedAt
updatedAt
}
`;
interface DetectionPatternRowProps {
patternKey: DetectionPatternRowFragment$key;
}
export function DetectionPatternRow({ patternKey }: DetectionPatternRowProps) {
const { __ } = useTranslate();
const pattern = useFragment(detectionPatternFragment, patternKey);
return (
<Tr>
<Td>
<div className="flex flex-col min-w-0">
<span className="font-medium">{pattern.displayName}</span>
{pattern.description && (
<span className="text-xs text-txt-tertiary wrap-break-word line-clamp-1">
{pattern.description}
</span>
)}
</div>
</Td>
<Td>
<Badge variant={pattern.source === "SCRIPT" ? "info" : "neutral"}>
{pattern.source === "SCRIPT" ? __("Script") : __("Pre-existing")}
</Badge>
</Td>
<Td>
{pattern.lastMatchedAt
? (
<time dateTime={pattern.lastMatchedAt}>
{formatDate(pattern.lastMatchedAt)}
</time>
)
: <span className="text-txt-tertiary">-</span>}
</Td>
<Td>
<time dateTime={pattern.updatedAt}>
{formatDate(pattern.updatedAt)}
</time>
</Td>
</Tr>
);
}

View File

@@ -69,6 +69,11 @@ export const cookieBannerRoutes = [
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPageLoader")),
},
{
path: "detection",
Fallback: LinkCardSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/detection/CookieBannerDetectionPageLoader")),
},
],
},
{