Add tracker pattern detail page with properties and detected trackers sections

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-22 11:54:44 +02:00
parent f8debf5406
commit b46f2656f5
20 changed files with 1077 additions and 3 deletions

View File

@@ -0,0 +1,72 @@
---
description: Extract connection list items (table rows, list entries) into their own fragment component
globs: "**/*.tsx"
alwaysApply: false
---
# Extract connection items into fragment components
When rendering items from a Relay connection (e.g. `edges.map(…)`), each item
MUST be rendered by a dedicated component that owns its own fragment — never
inline the rendering of node fields directly in the parent's `.map()` body.
This ensures:
- Data requirements are colocated with the rendering component
- Adding/removing fields in a row doesn't bloat the parent's fragment
- The row component is independently testable and reusable
## Pattern
```tsx
// _components/ThingRow.tsx — owns its fragment
const thingRowFragment = graphql`
fragment ThingRow_thing on Thing {
id
name
status
}
`;
interface ThingRowProps {
thingKey: ThingRow_thing$key;
}
export function ThingRow({ thingKey }: ThingRowProps) {
const thing = useFragment(thingRowFragment, thingKey);
return (
<Tr>
<Td>{thing.name}</Td>
<Td>{thing.status}</Td>
</Tr>
);
}
```
```tsx
// Parent — spreads the row fragment in its connection and renders the component
const parentFragment = graphql`
fragment ParentPage_things on Query
@refetchable(queryName: "ParentPageRefetchQuery") {
things(first: $first, after: $after) @connection(key: "ParentPage_things") {
edges {
node {
id
...ThingRow_thing
}
}
}
}
`;
// In JSX:
{things.map(thing => (
<ThingRow key={thing.id} thingKey={thing} />
))}
```
## Naming
- File: `_components/<NodeType>Row.tsx` (for table rows) or
`_components/<NodeType>Card.tsx` (for card lists)
- Fragment: `<ComponentName>_<typeName>` (e.g. `ThingRow_thing`)
- Prop: `<typeName>Key` (e.g. `thingKey`)

View File

@@ -39,3 +39,6 @@ interface EditCookieRowProps {
Callback props (`onSave`, `onCancel`) and configuration props (`isUpdating`,
`variant`) are fine — only **domain data** must come from fragments.
For connection items (table rows, list entries rendered in `.map()`), see the
companion rule `relay-connection-item-components.mdc`.

View File

@@ -307,7 +307,7 @@ export function TrackerResourceRow({ resourceKey, connectionId }: TrackerResourc
</div>
</Td>
<Td>
<span className="font-mono text-sm">{resource.path}</span>
<span className="font-mono text-xs break-all max-w-xs inline-block">{resource.path}</span>
</Td>
<Td>
{resource.lastDetectedAt

View File

@@ -0,0 +1,99 @@
// 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 { Breadcrumb, PageHeader } from "@probo/ui";
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
import type { TrackerPatternDetailPageQuery } from "#/__generated__/core/TrackerPatternDetailPageQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { TrackerPatternDetectedTrackersSection } from "./_components/TrackerPatternDetectedTrackersSection";
import { TrackerPatternPropertiesSection } from "./_components/TrackerPatternPropertiesSection";
export const trackerPatternDetailPageQuery = graphql`
query TrackerPatternDetailPageQuery(
$cookieBannerId: ID!
$trackerPatternId: ID!
) {
cookieBanner: node(id: $cookieBannerId) @required(action: THROW) {
__typename
... on CookieBanner {
id
name
}
}
node(id: $trackerPatternId) @required(action: THROW) {
__typename
... on TrackerPattern {
id
displayName
...TrackerPatternPropertiesSection_trackerPattern
...TrackerPatternDetectedTrackersSection_trackerPattern
}
}
}
`;
interface TrackerPatternDetailPageProps {
queryRef: PreloadedQuery<TrackerPatternDetailPageQuery>;
}
export default function TrackerPatternDetailPage({
queryRef,
}: TrackerPatternDetailPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const data = usePreloadedQuery(trackerPatternDetailPageQuery, queryRef);
if (data.cookieBanner.__typename !== "CookieBanner") {
throw new Error("invalid type for cookieBanner node");
}
if (data.node.__typename !== "TrackerPattern") {
throw new Error("invalid type for node");
}
const cookieBanner = data.cookieBanner;
const pattern = data.node;
return (
<div className="space-y-6">
<Breadcrumb
items={[
{
label: __("Cookie Banners"),
to: `/organizations/${organizationId}/cookie-banners`,
},
{
label: cookieBanner.name,
to: `/organizations/${organizationId}/cookie-banners/${cookieBanner.id}/settings`,
},
{
label: __("Trackers"),
to: `/organizations/${organizationId}/cookie-banners/${cookieBanner.id}/trackers`,
},
{
label: pattern.displayName,
},
]}
/>
<PageHeader title={pattern.displayName} />
<TrackerPatternPropertiesSection trackerPatternKey={pattern} />
<TrackerPatternDetectedTrackersSection trackerPatternKey={pattern} />
</div>
);
}

View File

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

View File

@@ -0,0 +1,40 @@
// 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 TrackerPatternDetailPageSkeleton() {
return (
<div className="space-y-6 animate-pulse">
<div className="rounded-2xl border border-border-low p-6 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center justify-between py-3 border-b border-border-low last:border-b-0">
<div className="h-4 w-28 rounded bg-bg-subtle" />
<div className="h-4 w-48 rounded bg-bg-subtle" />
</div>
))}
</div>
<div className="rounded-2xl border border-border-low p-6 space-y-4">
<div className="h-5 w-48 rounded bg-bg-subtle" />
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 py-3 border-b border-border-low last:border-b-0">
<div className="h-4 w-32 rounded bg-bg-subtle" />
<div className="h-4 w-48 rounded bg-bg-subtle" />
<div className="h-4 w-16 rounded bg-bg-subtle" />
<div className="h-4 w-20 rounded bg-bg-subtle" />
<div className="h-4 w-28 rounded bg-bg-subtle" />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useTranslate } from "@probo/i18n";
import { Badge, Td, Tr } from "@probo/ui";
import { graphql, useFragment } from "react-relay";
import type { DetectedTrackerRow_detectedTracker$key } from "#/__generated__/core/DetectedTrackerRow_detectedTracker.graphql";
const detectedTrackerFragment = graphql`
fragment DetectedTrackerRow_detectedTracker on DetectedTracker {
id
identifier
initiatorUrl
maxAgeSeconds
source
lastDetectedAt
}
`;
function sourceBadge(source: string, __: (s: string) => string) {
switch (source) {
case "SCRIPT": return { label: __("Script"), variant: "info" as const };
case "PRE_EXISTING": return { label: __("Pre-existing"), variant: "outline" as const };
default: return { label: source, variant: "neutral" as const };
}
}
interface DetectedTrackerRowProps {
detectedTrackerKey: DetectedTrackerRow_detectedTracker$key;
}
export function DetectedTrackerRow({ detectedTrackerKey }: DetectedTrackerRowProps) {
const { __ } = useTranslate();
const tracker = useFragment(detectedTrackerFragment, detectedTrackerKey);
return (
<Tr>
<Td>
<span className="font-mono text-xs break-all max-w-xs inline-block">{tracker.identifier}</span>
</Td>
<Td>
{tracker.initiatorUrl
? <span className="font-mono text-xs break-all max-w-xs inline-block">{tracker.initiatorUrl}</span>
: <span className="text-txt-tertiary">-</span>}
</Td>
<Td>
{tracker.maxAgeSeconds != null
? <span className="text-sm">{tracker.maxAgeSeconds}</span>
: <span className="text-txt-tertiary">-</span>}
</Td>
<Td>
{tracker.source
? (
<Badge variant={sourceBadge(tracker.source, __).variant}>
{sourceBadge(tracker.source, __).label}
</Badge>
)
: <span className="text-txt-tertiary">-</span>}
</Td>
<Td>
<time dateTime={tracker.lastDetectedAt}>
{new Date(tracker.lastDetectedAt).toLocaleString()}
</time>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,120 @@
// 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, Tbody, Th, Thead, Tr } from "@probo/ui";
import { type ComponentProps } from "react";
import { graphql, usePaginationFragment } from "react-relay";
import type { TrackerPatternDetectedTrackersSection_trackerPattern$key } from "#/__generated__/core/TrackerPatternDetectedTrackersSection_trackerPattern.graphql";
import type {
DetectedTrackerOrderField,
TrackerPatternDetectedTrackersSectionRefetchQuery,
} from "#/__generated__/core/TrackerPatternDetectedTrackersSectionRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable";
import { DetectedTrackerRow } from "./DetectedTrackerRow";
export const trackerPatternDetectedTrackersSectionFragment = graphql`
fragment TrackerPatternDetectedTrackersSection_trackerPattern on TrackerPattern
@refetchable(queryName: "TrackerPatternDetectedTrackersSectionRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 50 }
order: { type: "DetectedTrackerOrder", defaultValue: { field: LAST_DETECTED_AT, direction: DESC } }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
detectedTrackers(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "TrackerPatternDetectedTrackersSection_detectedTrackers", filters: ["orderBy"]) {
__id
edges {
node {
id
...DetectedTrackerRow_detectedTracker
}
}
}
}
`;
interface TrackerPatternDetectedTrackersSectionProps {
trackerPatternKey: TrackerPatternDetectedTrackersSection_trackerPattern$key;
}
export function TrackerPatternDetectedTrackersSection({
trackerPatternKey,
}: TrackerPatternDetectedTrackersSectionProps) {
const { __ } = useTranslate();
const { data, ...pagination } = usePaginationFragment<
TrackerPatternDetectedTrackersSectionRefetchQuery,
TrackerPatternDetectedTrackersSection_trackerPattern$key
>(trackerPatternDetectedTrackersSectionFragment, trackerPatternKey);
const trackers = data.detectedTrackers?.edges.map(edge => edge.node) ?? [];
const refetchWithOrder: ComponentProps<typeof SortableTable>["refetch"] = ({ order }) => {
pagination.refetch({
order: { direction: order.direction, field: order.field as DetectedTrackerOrderField },
});
};
return (
<>
<h3 className="text-lg font-semibold">{__("Detected Trackers")}</h3>
{trackers.length > 0
? (
<SortableTable
{...pagination}
refetch={refetchWithOrder}
pageSize={50}
>
<Thead>
<Tr>
<Th>{__("Identifier")}</Th>
<SortableTh field="INITIATOR_URL">{__("Initiator URL")}</SortableTh>
<Th>{__("Max Age (s)")}</Th>
<Th>{__("Source")}</Th>
<SortableTh field="LAST_DETECTED_AT">{__("Detection Time")}</SortableTh>
</Tr>
</Thead>
<Tbody>
{trackers.map(tracker => (
<DetectedTrackerRow
key={tracker.id}
detectedTrackerKey={tracker}
/>
))}
</Tbody>
</SortableTable>
)
: (
<Card padded>
<div className="text-center py-12">
<p className="text-txt-tertiary">
{__("No detected trackers for this pattern yet.")}
</p>
</div>
</Card>
)}
</>
);
}

View File

@@ -0,0 +1,123 @@
// 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 { humanizeSeconds } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Badge, Card, PropertyRow } from "@probo/ui";
import { graphql, useFragment } from "react-relay";
import type { TrackerPatternPropertiesSection_trackerPattern$key } from "#/__generated__/core/TrackerPatternPropertiesSection_trackerPattern.graphql";
const trackerPatternPropertiesSectionFragment = graphql`
fragment TrackerPatternPropertiesSection_trackerPattern on TrackerPattern {
pattern
matchType
trackerType
source
maxAgeSeconds
description
excluded
detectedCount
lastMatchedAt
cookieCategory {
name
}
}
`;
function trackerTypeBadge(type: string, __: (s: string) => string) {
switch (type) {
case "COOKIE": return { label: __("Cookie"), variant: "warning" as const };
case "LOCAL_STORAGE": return { label: __("localStorage"), variant: "info" as const };
case "SESSION_STORAGE": return { label: __("sessionStorage"), variant: "highlight" as const };
case "INDEXED_DB": return { label: __("IndexedDB"), variant: "success" as const };
case "CACHE_STORAGE": return { label: __("Cache Storage"), variant: "outline" as const };
default: return { label: type, variant: "neutral" as const };
}
}
function sourceBadge(source: string, __: (s: string) => string) {
switch (source) {
case "SCRIPT": return { label: __("Script"), variant: "info" as const };
case "PRE_EXISTING": return { label: __("Pre-existing"), variant: "outline" as const };
default: return { label: source, variant: "neutral" as const };
}
}
interface TrackerPatternPropertiesSectionProps {
trackerPatternKey: TrackerPatternPropertiesSection_trackerPattern$key;
}
export function TrackerPatternPropertiesSection({
trackerPatternKey,
}: TrackerPatternPropertiesSectionProps) {
const { __ } = useTranslate();
const pattern = useFragment(
trackerPatternPropertiesSectionFragment,
trackerPatternKey,
);
const typeBadge = trackerTypeBadge(pattern.trackerType, __);
return (
<Card padded>
<PropertyRow label={__("Pattern")}>
<span className="font-mono text-sm">{pattern.pattern}</span>
</PropertyRow>
<PropertyRow label={__("Match Type")}>
<span className="text-sm">{pattern.matchType === "EXACT" ? __("Exact") : __("Glob")}</span>
</PropertyRow>
<PropertyRow label={__("Type")}>
<Badge variant={typeBadge.variant}>{typeBadge.label}</Badge>
</PropertyRow>
{pattern.source && (
<PropertyRow label={__("Source")}>
<Badge variant={sourceBadge(pattern.source, __).variant}>
{sourceBadge(pattern.source, __).label}
</Badge>
</PropertyRow>
)}
<PropertyRow label={__("Category")}>
<span className="text-sm">
{pattern.cookieCategory?.name ?? "-"}
</span>
</PropertyRow>
<PropertyRow label={__("Max Age")}>
<span className="text-sm">
{humanizeSeconds(pattern.maxAgeSeconds ?? null)}
</span>
</PropertyRow>
{pattern.description && (
<PropertyRow label={__("Description")}>
<span className="text-sm">{pattern.description}</span>
</PropertyRow>
)}
<PropertyRow label={__("Excluded")}>
<span className="text-sm">{pattern.excluded ? __("Yes") : __("No")}</span>
</PropertyRow>
<PropertyRow label={__("Detected Count")}>
<span className="text-sm">{pattern.detectedCount}</span>
</PropertyRow>
<PropertyRow label={__("Last Matched")}>
{pattern.lastMatchedAt
? (
<time dateTime={pattern.lastMatchedAt}>
{new Date(pattern.lastMatchedAt).toLocaleString()}
</time>
)
: <span className="text-txt-tertiary">-</span>}
</PropertyRow>
</Card>
);
}

View File

@@ -300,9 +300,9 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
const srcBadge = pattern.source ? sourceBadge(pattern.source, __) : null;
return (
<Tr className={pattern.excluded ? "bg-txt-quaternary opacity-80 line-through" : undefined}>
<Tr to={pattern.id} className={pattern.excluded ? "bg-txt-quaternary opacity-80 line-through" : undefined}>
<Td>
<div className="flex flex-col min-w-0">
<div className="flex flex-col min-w-0 max-w-xs">
<span className={pattern.excluded ? undefined : "font-medium"}>{pattern.displayName}</span>
{pattern.description && (
<span className="text-xs text-txt-tertiary wrap-break-word line-clamp-1">

View File

@@ -81,4 +81,9 @@ export const cookieBannerRoutes = [
Fallback: PageSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/consent-records/CookieBannerConsentRecordPageLoader")),
},
{
path: "cookie-banners/:cookieBannerId/trackers/:trackerPatternId",
Fallback: PageSkeleton,
Component: lazy(() => import("#/pages/organizations/cookie-banners/configuration/trackers/TrackerPatternDetailPageLoader")),
},
] satisfies AppRoute[];

View File

@@ -264,6 +264,51 @@ pages/organizations/third-parties/ThirdPartiesPage.tsx
pages/organizations/third-parties/ThirdPartiesPageSkeleton.tsx
```
## Page sections
When a detail page has visually distinct sections (e.g. a properties card and a paginated list), extract each section into its own component in `_components/`. Each section owns a colocated Relay fragment (or pagination fragment) so that field additions never modify the parent page's query.
The page query spreads the section fragments and passes the fragment key to each section component:
```tsx
// TrackerPatternDetailPage.tsx (page — spreads section fragments)
export const trackerPatternDetailPageQuery = graphql`
query TrackerPatternDetailPageQuery($trackerPatternId: ID!) {
node(id: $trackerPatternId) {
... on TrackerPattern {
id
displayName
...TrackerPatternPropertiesSection_trackerPattern
...TrackerPatternDetectedTrackersSection_trackerPattern
}
}
}
`;
// In JSX:
<TrackerPatternPropertiesSection trackerPatternKey={pattern} />
<TrackerPatternDetectedTrackersSection trackerPatternKey={pattern} />
```
```tsx
// _components/TrackerPatternPropertiesSection.tsx — owns its fragment
const fragment = graphql`
fragment TrackerPatternPropertiesSection_trackerPattern on TrackerPattern {
pattern
matchType
trackerType
// ...
}
`;
export function TrackerPatternPropertiesSection({ trackerPatternKey }: Props) {
const pattern = useFragment(fragment, trackerPatternKey);
return <Card padded>{/* PropertyRows */}</Card>;
}
```
Section components follow the same naming and fragment conventions as connection item components (see below), but represent a **logical section** of a page rather than a single list item.
## `_components` folder
Sub-components that are used **only** by a single page live in a `_components/` folder next to that page. The underscore prefix visually distinguishes them from route-segment folders.

View File

@@ -400,3 +400,68 @@ export function MoveToCategoryMenu({ queryRef, onMove }: Props) {
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.)
## Page sections are components
When a detail page has multiple distinct sections (e.g. a properties card and a paginated list), extract each section into its own component in `_components/`. Each section owns a colocated Relay fragment so that field additions never modify the parent page's query.
The page spreads the section fragments on the shared node and passes the fragment key:
```tsx
// Page query — spreads section fragments
export const detailPageQuery = graphql`
query DetailPageQuery($nodeId: ID!) {
node(id: $nodeId) {
... on MyType {
id
displayName
...MyTypePropertiesSection_myType
...MyTypeListSection_myType
}
}
}
`;
// Page JSX:
<MyTypePropertiesSection myTypeKey={node} />
<MyTypeListSection myTypeKey={node} />
```
```tsx
// _components/MyTypePropertiesSection.tsx
const fragment = graphql`
fragment MyTypePropertiesSection_myType on MyType {
field1
field2
}
`;
interface MyTypePropertiesSectionProps {
myTypeKey: MyTypePropertiesSection_myType$key;
}
export function MyTypePropertiesSection({ myTypeKey }: MyTypePropertiesSectionProps) {
const data = useFragment(fragment, myTypeKey);
return <Card padded>{/* PropertyRows */}</Card>;
}
```
For sections that own a paginated connection, use `usePaginationFragment` with a `@refetchable` fragment — the same pattern as a standalone page, but scoped to a section component.
## Connection items are components
When rendering items from a Relay connection (e.g. table rows via `edges.map(…)`), each item **must** be a dedicated component with its own colocated fragment — never inline the rendering of node fields directly in the parent's `.map()` body.
Place the item component in `_components/` adjacent to the page. Name it after the GraphQL type it renders (e.g. `DetectedTrackerRow.tsx`, `ThirdPartyCard.tsx`). The component receives a single fragment key prop (e.g. `detectedTrackerKey: DetectedTrackerRow_detectedTracker$key`) and calls `useFragment` internally.
```tsx
// Parent (page) — spreads the child fragment in the connection:
edges { node { id ...DetectedTrackerRow_detectedTracker } }
// Parent JSX:
{trackers.map(tracker => (
<DetectedTrackerRow key={tracker.id} detectedTrackerKey={tracker} />
))}
```
This ensures field additions/removals in the row never modify the parent's fragment, and keeps the item independently testable.

View File

@@ -2714,6 +2714,31 @@ func (s *Service) CountDetectedTrackersByPatternID(
return count, nil
}
func (s *Service) ListDetectedTrackersForPattern(
ctx context.Context,
scope coredata.Scoper,
trackerPatternID gid.GID,
cursor *page.Cursor[coredata.DetectedTrackerOrderField],
) (coredata.DetectedTrackers, error) {
var trackers coredata.DetectedTrackers
err := s.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
if err := trackers.LoadByTrackerPatternID(ctx, conn, scope, trackerPatternID, cursor); err != nil {
return fmt.Errorf("cannot list detected trackers for pattern: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return trackers, nil
}
func (s *Service) CreateTrackerResource(
ctx context.Context,
scope coredata.Scoper,

View File

@@ -23,6 +23,7 @@ import (
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -45,6 +46,21 @@ type (
DetectedTrackers []*DetectedTracker
)
func (dt *DetectedTracker) CursorKey(field DetectedTrackerOrderField) page.CursorKey {
switch field {
case DetectedTrackerOrderFieldInitiatorURL:
if dt.InitiatorURL == nil {
return page.NewCursorKey(dt.ID, "")
}
return page.NewCursorKey(dt.ID, *dt.InitiatorURL)
case DetectedTrackerOrderFieldLastDetectedAt:
return page.NewCursorKey(dt.ID, dt.LastDetectedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", field))
}
func (dt *DetectedTracker) Upsert(
ctx context.Context,
tx pg.Tx,
@@ -183,6 +199,59 @@ LIMIT @limit;
return domains, nil
}
func (dts *DetectedTrackers) LoadByTrackerPatternID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
trackerPatternID gid.GID,
cursor *page.Cursor[DetectedTrackerOrderField],
) error {
q := `
SELECT
id,
cookie_banner_id,
tracker_pattern_id,
tracker_type,
identifier,
max_age_seconds,
source,
value_size,
initiator_url,
initiator_domain,
last_detected_at,
created_at,
updated_at
FROM
detected_trackers
WHERE
%s
AND tracker_pattern_id = @tracker_pattern_id
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{
"tracker_pattern_id": trackerPatternID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query detected trackers: %w", err)
}
trackers, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DetectedTracker])
if err != nil {
return fmt.Errorf("cannot collect detected trackers: %w", err)
}
*dts = trackers
return nil
}
func (dts *DetectedTrackers) RelinkByTrackerPatternID(
ctx context.Context,
tx pg.Tx,

View File

@@ -0,0 +1,84 @@
// 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.
package coredata
import (
"encoding"
"fmt"
"go.probo.inc/probo/pkg/page"
)
type DetectedTrackerOrderField string
const (
DetectedTrackerOrderFieldInitiatorURL DetectedTrackerOrderField = "INITIATOR_URL"
DetectedTrackerOrderFieldLastDetectedAt DetectedTrackerOrderField = "LAST_DETECTED_AT"
)
var (
_ page.OrderField = DetectedTrackerOrderField("")
_ fmt.Stringer = DetectedTrackerOrderField("")
_ encoding.TextMarshaler = DetectedTrackerOrderField("")
_ encoding.TextUnmarshaler = (*DetectedTrackerOrderField)(nil)
)
func DetectedTrackerOrderFields() []DetectedTrackerOrderField {
return []DetectedTrackerOrderField{
DetectedTrackerOrderFieldInitiatorURL,
DetectedTrackerOrderFieldLastDetectedAt,
}
}
func (v DetectedTrackerOrderField) IsValid() bool {
switch v {
case
DetectedTrackerOrderFieldInitiatorURL,
DetectedTrackerOrderFieldLastDetectedAt:
return true
}
return false
}
func (v DetectedTrackerOrderField) String() string {
return string(v)
}
func (v DetectedTrackerOrderField) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
}
func (v *DetectedTrackerOrderField) UnmarshalText(text []byte) error {
val := DetectedTrackerOrderField(text)
if !val.IsValid() {
return fmt.Errorf("invalid DetectedTrackerOrderField value: %q", string(text))
}
*v = val
return nil
}
func (p DetectedTrackerOrderField) Column() string {
switch p {
case DetectedTrackerOrderFieldInitiatorURL:
return "COALESCE(initiator_url, '')"
case DetectedTrackerOrderFieldLastDetectedAt:
return "last_detected_at"
}
panic(fmt.Sprintf("unsupported order by: %s", p))
}

View File

@@ -465,6 +465,18 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
return types.NewCookieConsentRecord(record), nil
}
case coredata.TrackerPatternEntityType:
action = probo.ActionTrackerPatternGet
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {
scope := coredata.NewScopeFromObjectID(id)
pattern, err := r.cookieBanner.GetTrackerPattern(ctx, scope, id)
if err != nil {
return nil, err
}
return types.NewTrackerPatternNode(pattern), nil
}
case coredata.CookieBannerVersionEntityType:
action = probo.ActionCookieBannerVersionGet
loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) {

View File

@@ -434,6 +434,19 @@ func (r *cookieCategoryConnectionResolver) TotalCount(ctx context.Context, obj *
return count, nil
}
// TotalCount is the resolver for the totalCount field.
func (r *detectedTrackerConnectionResolver) TotalCount(ctx context.Context, obj *types.DetectedTrackerConnection) (int, error) {
scope := coredata.NewScopeFromObjectID(obj.ParentID)
count, err := r.cookieBanner.CountDetectedTrackersByPatternID(ctx, scope, obj.ParentID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count detected trackers", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
// CreateCookieBanner is the resolver for the createCookieBanner field.
func (r *mutationResolver) CreateCookieBanner(ctx context.Context, input types.CreateCookieBannerInput) (*types.CreateCookieBannerPayload, error) {
if err := r.authorize(ctx, input.OrganizationID, probo.ActionCookieBannerCreate); err != nil {
@@ -1278,6 +1291,37 @@ func (r *trackerPatternResolver) DetectedCount(ctx context.Context, obj *types.T
return count, nil
}
// DetectedTrackers is the resolver for the detectedTrackers field.
func (r *trackerPatternResolver) DetectedTrackers(ctx context.Context, obj *types.TrackerPattern, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DetectedTrackerOrderBy) (*types.DetectedTrackerConnection, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionTrackerPatternGet); err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.DetectedTrackerOrderField]{
Field: coredata.DetectedTrackerOrderFieldLastDetectedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.DetectedTrackerOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
scope := coredata.NewScopeFromObjectID(obj.ID)
trackers, err := r.cookieBanner.ListDetectedTrackersForPattern(ctx, scope, obj.ID, cursor)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list detected trackers", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
p := page.NewPage(trackers, cursor)
return types.NewDetectedTrackerConnection(p, r, obj.ID), nil
}
// Permission is the resolver for the permission field.
func (r *trackerPatternResolver) Permission(ctx context.Context, obj *types.TrackerPattern, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -1390,6 +1434,11 @@ func (r *Resolver) CookieCategoryConnection() schema.CookieCategoryConnectionRes
return &cookieCategoryConnectionResolver{r}
}
// DetectedTrackerConnection returns schema.DetectedTrackerConnectionResolver implementation.
func (r *Resolver) DetectedTrackerConnection() schema.DetectedTrackerConnectionResolver {
return &detectedTrackerConnectionResolver{r}
}
// TrackerPattern returns schema.TrackerPatternResolver implementation.
func (r *Resolver) TrackerPattern() schema.TrackerPatternResolver { return &trackerPatternResolver{r} }
@@ -1413,6 +1462,7 @@ type cookieBannerConnectionResolver struct{ *Resolver }
type cookieBannerVersionResolver struct{ *Resolver }
type cookieCategoryResolver struct{ *Resolver }
type cookieCategoryConnectionResolver struct{ *Resolver }
type detectedTrackerConnectionResolver struct{ *Resolver }
type trackerPatternResolver struct{ *Resolver }
type trackerPatternConnectionResolver struct{ *Resolver }
type trackerResourceResolver struct{ *Resolver }

View File

@@ -275,6 +275,14 @@ type TrackerPattern implements Node {
createdAt: Datetime!
updatedAt: Datetime!
detectedTrackers(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: DetectedTrackerOrder
): DetectedTrackerConnection @goField(forceResolver: true)
permission(action: String!): Boolean! @goField(forceResolver: true)
}
@@ -310,6 +318,52 @@ input TrackerPatternFilter
cookieCategoryId: ID
}
type DetectedTracker implements Node {
id: ID!
identifier: String!
initiatorUrl: String
maxAgeSeconds: Int
source: CookieSource
lastDetectedAt: Datetime!
createdAt: Datetime!
}
enum DetectedTrackerOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.DetectedTrackerOrderField"
) {
INITIATOR_URL
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DetectedTrackerOrderFieldInitiatorURL"
)
LAST_DETECTED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.DetectedTrackerOrderFieldLastDetectedAt"
)
}
input DetectedTrackerOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DetectedTrackerOrderBy"
) {
direction: OrderDirection!
field: DetectedTrackerOrderField!
}
type DetectedTrackerConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DetectedTrackerConnection"
) {
totalCount: Int! @goField(forceResolver: true)
edges: [DetectedTrackerEdge!]!
pageInfo: PageInfo!
}
type DetectedTrackerEdge {
cursor: CursorKey!
node: DetectedTracker!
}
enum TrackerResourceType
@goModel(model: "go.probo.inc/probo/pkg/coredata.TrackerResourceType") {
SCRIPT

View File

@@ -0,0 +1,76 @@
// 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.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
DetectedTrackerOrderBy OrderBy[coredata.DetectedTrackerOrderField]
DetectedTrackerConnection struct {
TotalCount int
Edges []*DetectedTrackerEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
)
func NewDetectedTrackerConnection(
p *page.Page[*coredata.DetectedTracker, coredata.DetectedTrackerOrderField],
parentType any,
parentID gid.GID,
) *DetectedTrackerConnection {
edges := make([]*DetectedTrackerEdge, len(p.Data))
for i := range edges {
edges[i] = NewDetectedTrackerEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &DetectedTrackerConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: parentType,
ParentID: parentID,
}
}
func NewDetectedTrackerEdge(
dt *coredata.DetectedTracker,
orderBy coredata.DetectedTrackerOrderField,
) *DetectedTrackerEdge {
return &DetectedTrackerEdge{
Cursor: dt.CursorKey(orderBy),
Node: NewDetectedTrackerNode(dt),
}
}
func NewDetectedTrackerNode(dt *coredata.DetectedTracker) *DetectedTracker {
return &DetectedTracker{
ID: dt.ID,
Identifier: dt.Identifier,
InitiatorURL: dt.InitiatorURL,
MaxAgeSeconds: dt.MaxAgeSeconds,
Source: dt.Source,
LastDetectedAt: dt.LastDetectedAt,
CreatedAt: dt.CreatedAt,
}
}