Show third parties on banner trackers page

Wire the new CookieBanner.linkedThirdParties aggregation and the
TrackerPattern.thirdParty / commonThirdParty fields into the trackers
configuration UI. Three surfaces change:

CookieBannerTrackersPage: a new dedicated "Third party" column sits
between Name and Source so the link is visible at a glance, and a
fourth Select to the right of the category filter exposes the deduped
list returned by linkedThirdParties. The Select sends the chosen GID
straight through; the backend dispatches on the entity-type prefix
(ThirdParty vs CommonThirdParty), so the frontend stays oblivious to
which table the filter ends up hitting. The "%other" branch of the
union is filtered out before render to keep TypeScript happy if the
backend grows the union later.

TrackerPatternRow: extends the row fragment with thirdParty and
commonThirdParty, prefers the org-scoped link (mirroring the resolver
priority), and renders an Avatar + name. The org-scoped ThirdParty has
no logoUrl in the schema today so the Avatar falls back to initials;
CommonThirdParty supplies its catalog logo.

TrackerPatternPropertiesSection: same priority logic in a PropertyRow
under Category, so the detail page surfaces the same information.

The third-party Select uses the same set as the underlying patterns,
so picking a value never produces an empty list — the linkedThirdParties
resolver only walks rows the user can see.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-28 08:49:53 +02:00
parent 66ffcf3411
commit af4b8a3476
3 changed files with 98 additions and 2 deletions

View File

@@ -76,14 +76,27 @@ const trackersFragment = graphql`
source: { type: "CookieSource", defaultValue: null }
trackerType: { type: "TrackerType", defaultValue: null }
cookieCategoryId: { type: "ID", defaultValue: null }
thirdPartyId: { type: "ID", defaultValue: null }
) {
linkedThirdParties {
__typename
... on ThirdParty {
id
name
}
... on CommonThirdParty {
id
name
logoUrl
}
}
trackerPatterns(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
filter: { query: $query, source: $source, trackerType: $trackerType, cookieCategoryId: $cookieCategoryId }
filter: { query: $query, source: $source, trackerType: $trackerType, cookieCategoryId: $cookieCategoryId, thirdPartyId: $thirdPartyId }
)
@connection(
key: "CookieBannerTrackersPage_trackerPatterns"
@@ -120,6 +133,7 @@ export default function CookieBannerTrackersPage({
const [sourceFilter, setSourceFilter] = useState<CookieSource | null>(null);
const [trackerTypeFilter, setTrackerTypeFilter] = useState<TrackerType | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [thirdPartyFilter, setThirdPartyFilter] = useState<string | null>(null);
const { data: fragmentData, ...pagination } = usePaginationFragment<
CookieBannerTrackersPageRefetchQuery,
@@ -128,6 +142,11 @@ export default function CookieBannerTrackersPage({
const connectionId = fragmentData.trackerPatterns.__id;
const patterns = fragmentData.trackerPatterns.edges.map(edge => edge.node) ?? [];
const linkedThirdParties = (fragmentData.linkedThirdParties ?? []).filter(
(party): party is Extract<typeof party, { id: string; name: string }> =>
party.__typename === "ThirdParty"
|| party.__typename === "CommonThirdParty",
);
const categories = data.node.__typename === "CookieBanner"
? data.node.categories.edges.map(edge => edge.node)
@@ -141,6 +160,7 @@ export default function CookieBannerTrackersPage({
source: sourceFilter,
trackerType: trackerTypeFilter,
cookieCategoryId: categoryFilter,
thirdPartyId: thirdPartyFilter,
...overrides,
},
{ fetchPolicy: "network-only" },
@@ -170,6 +190,12 @@ export default function CookieBannerTrackersPage({
refetchFilters({ cookieCategoryId: newCategory });
};
const handleThirdPartyFilterChange = (value: string) => {
const newThirdParty = value === "ALL" ? null : value;
setThirdPartyFilter(newThirdParty);
refetchFilters({ thirdPartyId: newThirdParty });
};
const refetchWithFilters: ComponentProps<typeof SortableTable>["refetch"] = ({ order }) => {
pagination.refetch({
order: { direction: order.direction, field: order.field as TrackerPatternOrderField },
@@ -177,6 +203,7 @@ export default function CookieBannerTrackersPage({
source: sourceFilter,
trackerType: trackerTypeFilter,
cookieCategoryId: categoryFilter,
thirdPartyId: thirdPartyFilter,
});
};
@@ -220,6 +247,15 @@ export default function CookieBannerTrackersPage({
<Option key={category.id} value={category.id}>{category.name}</Option>
))}
</Select>
<Select
value={thirdPartyFilter ?? "ALL"}
onValueChange={handleThirdPartyFilterChange}
>
<Option value="ALL">{__("All third parties")}</Option>
{linkedThirdParties.map(party => (
<Option key={party.id} value={party.id}>{party.name}</Option>
))}
</Select>
</div>
<div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}>
@@ -234,6 +270,7 @@ export default function CookieBannerTrackersPage({
<Tr>
<Th>{__("Type")}</Th>
<SortableTh field="NAME">{__("Name")}</SortableTh>
<Th>{__("Third party")}</Th>
<SortableTh field="SOURCE">{__("Source")}</SortableTh>
<Th>{__("Category")}</Th>
<SortableTh field="LAST_MATCHED_AT">{__("Last Matched")}</SortableTh>

View File

@@ -14,7 +14,7 @@
import { humanizeSeconds } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Badge, Card, PropertyRow } from "@probo/ui";
import { Avatar, Badge, Card, PropertyRow } from "@probo/ui";
import { graphql, useFragment } from "react-relay";
import type { TrackerPatternPropertiesSection_trackerPattern$key } from "#/__generated__/core/TrackerPatternPropertiesSection_trackerPattern.graphql";
@@ -33,6 +33,15 @@ const trackerPatternPropertiesSectionFragment = graphql`
cookieCategory {
name
}
thirdParty {
id
name
}
commonThirdParty {
id
name
logoUrl
}
}
`;
@@ -93,6 +102,26 @@ export function TrackerPatternPropertiesSection({
{pattern.cookieCategory?.name ?? "-"}
</span>
</PropertyRow>
<PropertyRow label={__("Third party")}>
{pattern.thirdParty
? (
<div className="flex items-center gap-2">
<Avatar name={pattern.thirdParty.name} />
<span className="text-sm">{pattern.thirdParty.name}</span>
</div>
)
: pattern.commonThirdParty
? (
<div className="flex items-center gap-2">
<Avatar
name={pattern.commonThirdParty.name}
src={pattern.commonThirdParty.logoUrl}
/>
<span className="text-sm">{pattern.commonThirdParty.name}</span>
</div>
)
: <span className="text-txt-tertiary text-sm">-</span>}
</PropertyRow>
<PropertyRow label={__("Max Age")}>
<span className="text-sm">
{humanizeSeconds(pattern.maxAgeSeconds ?? null)}

View File

@@ -16,6 +16,7 @@ import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Avatar,
Badge,
Dropdown,
IconArrowBoxLeft,
@@ -56,6 +57,15 @@ const trackerPatternFragment = graphql`
cookieCategory {
name
}
thirdParty {
id
name
}
commonThirdParty {
id
name
logoUrl
}
}
`;
@@ -314,6 +324,26 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
)}
</div>
</Td>
<Td>
{pattern.thirdParty
? (
<div className="flex items-center gap-2 min-w-0">
<Avatar name={pattern.thirdParty.name} />
<span className="truncate">{pattern.thirdParty.name}</span>
</div>
)
: pattern.commonThirdParty
? (
<div className="flex items-center gap-2 min-w-0">
<Avatar
name={pattern.commonThirdParty.name}
src={pattern.commonThirdParty.logoUrl}
/>
<span className="truncate">{pattern.commonThirdParty.name}</span>
</div>
)
: <span className="text-txt-tertiary">-</span>}
</Td>
<Td>
{srcBadge
? <Badge variant={srcBadge.variant}>{srcBadge.label}</Badge>