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:
@@ -12,7 +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 { ClipboardTextIcon } from "@phosphor-icons/react";
|
import { ClipboardTextIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
@@ -252,6 +252,10 @@ export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfi
|
|||||||
<IconListStack size={20} />
|
<IconListStack size={20} />
|
||||||
{__("Cookies")}
|
{__("Cookies")}
|
||||||
</TabLink>
|
</TabLink>
|
||||||
|
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/detection`}>
|
||||||
|
<MagnifyingGlassIcon size={20} />
|
||||||
|
{__("Detection")}
|
||||||
|
</TabLink>
|
||||||
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/consent-records`}>
|
<TabLink to={`/organizations/${organizationId}/cookie-banners/${cookieBannerId}/consent-records`}>
|
||||||
<ClipboardTextIcon size={20} />
|
<ClipboardTextIcon size={20} />
|
||||||
{__("Consent Records")}
|
{__("Consent Records")}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -69,6 +69,11 @@ export const cookieBannerRoutes = [
|
|||||||
Fallback: LinkCardSkeleton,
|
Fallback: LinkCardSkeleton,
|
||||||
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordsPageLoader")),
|
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")),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user