Update console tracker page

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-20 11:58:40 +04:00
parent be9b43e98f
commit 5abd670707
12 changed files with 99 additions and 63 deletions

View File

@@ -30,8 +30,8 @@ export const cookieBannerDisplayPageQuery = graphql`
__typename __typename
... on CookieBanner { ... on CookieBanner {
id id
consentCategories(first: 50, orderBy: { field: RANK, direction: ASC }) categories(first: 50, orderBy: { field: RANK, direction: ASC }, filter: { excludeKind: UNCATEGORISED })
@connection(key: "CookieBannerDisplayPage_consentCategories") @connection(key: "CookieBannerDisplayPage_categories")
@required(action: THROW) { @required(action: THROW) {
__id __id
edges { edges {
@@ -63,8 +63,8 @@ export default function CookieBannerDisplayPage({
} }
const banner = data.node; const banner = data.node;
const connectionId = banner.consentCategories.__id; const connectionId = banner.categories.__id;
const categories = banner.consentCategories.edges.map(e => e.node); const categories = banner.categories.edges.map(e => e.node);
const [showCreateDialog, setShowCreateDialog] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false);

View File

@@ -85,7 +85,7 @@ export const categorySectionFragment = graphql`
} }
} }
cookieBanner @required(action: THROW) { cookieBanner @required(action: THROW) {
consentCategories(first: 50, orderBy: { field: RANK, direction: ASC }) @required(action: THROW) { categories(first: 50, orderBy: { field: RANK, direction: ASC }, filter: { excludeKind: UNCATEGORISED }) @required(action: THROW) {
edges { edges {
node { node {
id id
@@ -252,7 +252,7 @@ const reorderCategoryMutation = graphql`
reorderCookieCategory(input: $input) { reorderCookieCategory(input: $input) {
cookieBanner { cookieBanner {
id id
consentCategories(first: 50, orderBy: { field: RANK, direction: ASC }) { categories(first: 50, orderBy: { field: RANK, direction: ASC }, filter: { excludeKind: UNCATEGORISED }) {
edges { edges {
node { node {
id id
@@ -517,7 +517,7 @@ export function CategorySection({ categoryKey, connectionId }: CategorySectionPr
); );
}; };
const allCategories = category.cookieBanner.consentCategories.edges.map(e => e.node) ?? []; const allCategories = category.cookieBanner.categories.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 selfIndex = allCategories.findIndex(c => c.id === category.id);
const isFirst = selfIndex === 0; const isFirst = selfIndex === 0;

View File

@@ -49,6 +49,15 @@ export const cookieBannerTrackersPageQuery = graphql`
__typename __typename
... on CookieBanner { ... on CookieBanner {
...CookieBannerTrackersPageFragment ...CookieBannerTrackersPageFragment
categories(first: 50, orderBy: { field: RANK, direction: ASC })
@required(action: THROW) {
edges {
node {
id
name
}
}
}
} }
} }
} }
@@ -66,17 +75,18 @@ const trackersFragment = graphql`
query: { type: "String", defaultValue: null } query: { type: "String", defaultValue: null }
source: { type: "CookieSource", defaultValue: null } source: { type: "CookieSource", defaultValue: null }
trackerType: { type: "TrackerType", defaultValue: null } trackerType: { type: "TrackerType", defaultValue: null }
cookieCategoryId: { type: "ID", defaultValue: null }
) { ) {
uncategorisedTrackerPatterns( trackerPatterns(
first: $first first: $first
after: $after after: $after
last: $last last: $last
before: $before before: $before
orderBy: $order orderBy: $order
filter: { query: $query, source: $source, trackerType: $trackerType } filter: { query: $query, source: $source, trackerType: $trackerType, cookieCategoryId: $cookieCategoryId }
) )
@connection( @connection(
key: "CookieBannerTrackersPage_uncategorisedTrackerPatterns" key: "CookieBannerTrackersPage_trackerPatterns"
filters: ["filter", "orderBy"] filters: ["filter", "orderBy"]
) )
@required(action: THROW) { @required(action: THROW) {
@@ -109,14 +119,19 @@ export default function CookieBannerTrackersPage({
const [queryFilter, setQueryFilter] = useState(""); const [queryFilter, setQueryFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState<CookieSource | null>(null); const [sourceFilter, setSourceFilter] = useState<CookieSource | null>(null);
const [trackerTypeFilter, setTrackerTypeFilter] = useState<TrackerType | null>(null); const [trackerTypeFilter, setTrackerTypeFilter] = useState<TrackerType | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const { data: fragmentData, ...pagination } = usePaginationFragment< const { data: fragmentData, ...pagination } = usePaginationFragment<
CookieBannerTrackersPageRefetchQuery, CookieBannerTrackersPageRefetchQuery,
CookieBannerTrackersPageFragment$key CookieBannerTrackersPageFragment$key
>(trackersFragment, data.node); >(trackersFragment, data.node);
const connectionId = fragmentData.uncategorisedTrackerPatterns.__id; const connectionId = fragmentData.trackerPatterns.__id;
const patterns = fragmentData.uncategorisedTrackerPatterns.edges.map(edge => edge.node) ?? []; const patterns = fragmentData.trackerPatterns.edges.map(edge => edge.node) ?? [];
const categories = data.node.__typename === "CookieBanner"
? data.node.categories.edges.map(edge => edge.node)
: [];
const refetchFilters = (overrides: Record<string, unknown> = {}) => { const refetchFilters = (overrides: Record<string, unknown> = {}) => {
startTransition(() => { startTransition(() => {
@@ -125,6 +140,7 @@ export default function CookieBannerTrackersPage({
query: queryFilter || null, query: queryFilter || null,
source: sourceFilter, source: sourceFilter,
trackerType: trackerTypeFilter, trackerType: trackerTypeFilter,
cookieCategoryId: categoryFilter,
...overrides, ...overrides,
}, },
{ fetchPolicy: "network-only" }, { fetchPolicy: "network-only" },
@@ -148,12 +164,19 @@ export default function CookieBannerTrackersPage({
refetchFilters({ trackerType: newType }); refetchFilters({ trackerType: newType });
}; };
const handleCategoryFilterChange = (value: string) => {
const newCategory = value === "ALL" ? null : value;
setCategoryFilter(newCategory);
refetchFilters({ cookieCategoryId: newCategory });
};
const refetchWithFilters: ComponentProps<typeof SortableTable>["refetch"] = ({ order }) => { const refetchWithFilters: ComponentProps<typeof SortableTable>["refetch"] = ({ order }) => {
pagination.refetch({ pagination.refetch({
order: { direction: order.direction, field: order.field as TrackerPatternOrderField }, order: { direction: order.direction, field: order.field as TrackerPatternOrderField },
query: queryFilter || null, query: queryFilter || null,
source: sourceFilter, source: sourceFilter,
trackerType: trackerTypeFilter, trackerType: trackerTypeFilter,
cookieCategoryId: categoryFilter,
}); });
}; };
@@ -187,6 +210,15 @@ export default function CookieBannerTrackersPage({
<Option value="SCRIPT">{__("Script")}</Option> <Option value="SCRIPT">{__("Script")}</Option>
<Option value="PRE_EXISTING">{__("Pre-existing")}</Option> <Option value="PRE_EXISTING">{__("Pre-existing")}</Option>
</Select> </Select>
<Select
value={categoryFilter ?? "ALL"}
onValueChange={handleCategoryFilterChange}
>
<Option value="ALL">{__("All categories")}</Option>
{categories.map(category => (
<Option key={category.id} value={category.id}>{category.name}</Option>
))}
</Select>
</div> </div>
<div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}> <div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}>
@@ -202,6 +234,7 @@ export default function CookieBannerTrackersPage({
<SortableTh field="NAME">{__("Name")}</SortableTh> <SortableTh field="NAME">{__("Name")}</SortableTh>
<Th>{__("Type")}</Th> <Th>{__("Type")}</Th>
<SortableTh field="SOURCE">{__("Source")}</SortableTh> <SortableTh field="SOURCE">{__("Source")}</SortableTh>
<Th>{__("Category")}</Th>
<SortableTh field="LAST_MATCHED_AT">{__("Last Matched")}</SortableTh> <SortableTh field="LAST_MATCHED_AT">{__("Last Matched")}</SortableTh>
<Th className="w-28" /> <Th className="w-28" />
</Tr> </Tr>
@@ -221,10 +254,10 @@ export default function CookieBannerTrackersPage({
<Card padded> <Card padded>
<div className="text-center py-12"> <div className="text-center py-12">
<h3 className="text-lg font-semibold mb-2"> <h3 className="text-lg font-semibold mb-2">
{__("No uncategorised patterns")} {__("No tracker patterns")}
</h3> </h3>
<p className="text-txt-tertiary"> <p className="text-txt-tertiary">
{__("All detected cookie patterns have been categorised. New patterns will appear here when detected.")} {__("No cookie patterns have been detected yet. Patterns will appear here when detected.")}
</p> </p>
</div> </div>
</Card> </Card>

View File

@@ -23,7 +23,7 @@ export const moveToCategoryDropdownQuery = graphql`
node(id: $cookieBannerId) @required(action: THROW) { node(id: $cookieBannerId) @required(action: THROW) {
__typename __typename
... on CookieBanner { ... on CookieBanner {
consentCategories(first: 50, orderBy: { field: RANK, direction: ASC }) categories(first: 50, orderBy: { field: RANK, direction: ASC })
@required(action: THROW) { @required(action: THROW) {
edges { edges {
node { node {
@@ -53,7 +53,7 @@ export function MoveToCategoryDropdown({
return null; return null;
} }
const categories = data.node.consentCategories.edges.map(e => e.node); const categories = data.node.categories.edges.map(e => e.node);
if (categories.length === 0) { if (categories.length === 0) {
return ( return (

View File

@@ -53,6 +53,9 @@ const trackerPatternFragment = graphql`
maxAgeSeconds maxAgeSeconds
excluded excluded
lastMatchedAt lastMatchedAt
cookieCategory {
name
}
} }
`; `;
@@ -316,6 +319,11 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
? <Badge variant={srcBadge.variant}>{srcBadge.label}</Badge> ? <Badge variant={srcBadge.variant}>{srcBadge.label}</Badge>
: <span className="text-txt-tertiary">-</span>} : <span className="text-txt-tertiary">-</span>}
</Td> </Td>
<Td>
{pattern.cookieCategory
? <span>{pattern.cookieCategory.name}</span>
: <span className="text-txt-tertiary">-</span>}
</Td>
<Td> <Td>
{pattern.lastMatchedAt {pattern.lastMatchedAt
? ( ? (

View File

@@ -62,6 +62,7 @@ export function TrackerPatternRowEdit({
<span className="font-medium">{pattern}</span> <span className="font-medium">{pattern}</span>
</Td> </Td>
<Td /> <Td />
<Td />
<Td className="pr-3"> <Td className="pr-3">
<Controller <Controller
name="duration" name="duration"

View File

@@ -36,7 +36,7 @@ export const cookieBannerTranslationsPageQuery = graphql`
language language
translations translations
} }
consentCategories(first: 50, orderBy: { field: RANK, direction: ASC }) @required(action: THROW) { categories(first: 50, orderBy: { field: RANK, direction: ASC }, filter: { excludeKind: UNCATEGORISED }) @required(action: THROW) {
edges { edges {
node { node {
id id
@@ -101,14 +101,14 @@ export default function CookieBannerTranslationsPage({
const categories = useMemo( const categories = useMemo(
() => () =>
banner.consentCategories.edges.map(e => ({ banner.categories.edges.map(e => ({
id: e.node.id, id: e.node.id,
name: e.node.name, name: e.node.name,
slug: e.node.slug, slug: e.node.slug,
description: e.node.description, description: e.node.description,
kind: e.node.kind, kind: e.node.kind,
})), })),
[banner.consentCategories], [banner.categories],
); );
const necessaryCategoryName = useMemo( const necessaryCategoryName = useMemo(

View File

@@ -153,7 +153,7 @@ func TestCookieBanner_Create(t *testing.T) {
query($id: ID!) { query($id: ID!) {
node(id: $id) { node(id: $id) {
... on CookieBanner { ... on CookieBanner {
consentCategories(first: 10) { categories(first: 10) {
totalCount totalCount
edges { edges {
node { node {
@@ -170,7 +170,7 @@ func TestCookieBanner_Create(t *testing.T) {
var result struct { var result struct {
Node struct { Node struct {
ConsentCategories struct { Categories struct {
TotalCount int `json:"totalCount"` TotalCount int `json:"totalCount"`
Edges []struct { Edges []struct {
Node struct { Node struct {
@@ -179,16 +179,16 @@ func TestCookieBanner_Create(t *testing.T) {
Kind string `json:"kind"` Kind string `json:"kind"`
} `json:"node"` } `json:"node"`
} `json:"edges"` } `json:"edges"`
} `json:"consentCategories"` } `json:"categories"`
} `json:"node"` } `json:"node"`
} }
err := owner.Execute(query, map[string]any{"id": bannerID}, &result) err := owner.Execute(query, map[string]any{"id": bannerID}, &result)
require.NoError(t, err) require.NoError(t, err)
assert.Greater(t, result.Node.ConsentCategories.TotalCount, 0) assert.Greater(t, result.Node.Categories.TotalCount, 0)
kinds := make(map[string]bool) kinds := make(map[string]bool)
for _, e := range result.Node.ConsentCategories.Edges { for _, e := range result.Node.Categories.Edges {
kinds[e.Node.Kind] = true kinds[e.Node.Kind] = true
} }

View File

@@ -314,7 +314,7 @@ func TestCookieCategory_Delete(t *testing.T) {
query($id: ID!) { query($id: ID!) {
node(id: $id) { node(id: $id) {
... on CookieBanner { ... on CookieBanner {
consentCategories(first: 20) { categories(first: 20) {
edges { edges {
node { node {
id id
@@ -329,14 +329,14 @@ func TestCookieCategory_Delete(t *testing.T) {
var listResult struct { var listResult struct {
Node struct { Node struct {
ConsentCategories struct { Categories struct {
Edges []struct { Edges []struct {
Node struct { Node struct {
ID string `json:"id"` ID string `json:"id"`
Kind string `json:"kind"` Kind string `json:"kind"`
} `json:"node"` } `json:"node"`
} `json:"edges"` } `json:"edges"`
} `json:"consentCategories"` } `json:"categories"`
} `json:"node"` } `json:"node"`
} }
@@ -345,7 +345,7 @@ func TestCookieCategory_Delete(t *testing.T) {
var necessaryCategoryID string var necessaryCategoryID string
for _, e := range listResult.Node.ConsentCategories.Edges { for _, e := range listResult.Node.Categories.Edges {
if e.Node.Kind == "NECESSARY" { if e.Node.Kind == "NECESSARY" {
necessaryCategoryID = e.Node.ID necessaryCategoryID = e.Node.ID
break break
@@ -423,7 +423,7 @@ func TestCookieCategory_List(t *testing.T) {
query($id: ID!) { query($id: ID!) {
node(id: $id) { node(id: $id) {
... on CookieBanner { ... on CookieBanner {
consentCategories(first: 20, orderBy: {field: RANK, direction: ASC}) { categories(first: 20, orderBy: {field: RANK, direction: ASC}) {
totalCount totalCount
edges { edges {
node { node {
@@ -443,7 +443,7 @@ func TestCookieCategory_List(t *testing.T) {
var result struct { var result struct {
Node struct { Node struct {
ConsentCategories struct { Categories struct {
TotalCount int `json:"totalCount"` TotalCount int `json:"totalCount"`
Edges []struct { Edges []struct {
Node struct { Node struct {
@@ -455,7 +455,7 @@ func TestCookieCategory_List(t *testing.T) {
HasNextPage bool `json:"hasNextPage"` HasNextPage bool `json:"hasNextPage"`
HasPreviousPage bool `json:"hasPreviousPage"` HasPreviousPage bool `json:"hasPreviousPage"`
} `json:"pageInfo"` } `json:"pageInfo"`
} `json:"consentCategories"` } `json:"categories"`
} `json:"node"` } `json:"node"`
} }
@@ -463,13 +463,13 @@ func TestCookieCategory_List(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Default categories + 2 custom ones // Default categories + 2 custom ones
assert.GreaterOrEqual(t, result.Node.ConsentCategories.TotalCount, 4) assert.GreaterOrEqual(t, result.Node.Categories.TotalCount, 4)
// Verify ordering (ranks should be ascending) // Verify ordering (ranks should be ascending)
for i := 1; i < len(result.Node.ConsentCategories.Edges); i++ { for i := 1; i < len(result.Node.Categories.Edges); i++ {
assert.GreaterOrEqual(t, assert.GreaterOrEqual(t,
result.Node.ConsentCategories.Edges[i].Node.Rank, result.Node.Categories.Edges[i].Node.Rank,
result.Node.ConsentCategories.Edges[i-1].Node.Rank, result.Node.Categories.Edges[i-1].Node.Rank,
) )
} }
}) })

View File

@@ -647,7 +647,7 @@ WHERE
return nil return nil
} }
func (tps *TrackerPatterns) LoadUncategorisedByCookieBannerID( func (tps *TrackerPatterns) LoadByCookieBannerID(
ctx context.Context, ctx context.Context,
conn pg.Querier, conn pg.Querier,
scope Scoper, scope Scoper,
@@ -680,22 +680,14 @@ FROM
WHERE WHERE
%s %s
AND cookie_banner_id = @cookie_banner_id AND cookie_banner_id = @cookie_banner_id
AND cookie_category_id = (
SELECT id FROM cookie_categories
WHERE cookie_banner_id = @cookie_banner_id
AND kind = @category_kind
AND %s
LIMIT 1
)
AND %s AND %s
AND %s AND %s
` `
q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"cookie_banner_id": cookieBannerID, "cookie_banner_id": cookieBannerID,
"category_kind": CookieCategoryKindUncategorised,
} }
maps.Copy(args, scope.SQLArguments()) maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments()) maps.Copy(args, filter.SQLArguments())
@@ -703,12 +695,12 @@ WHERE
rows, err := conn.Query(ctx, q, args) rows, err := conn.Query(ctx, q, args)
if err != nil { if err != nil {
return fmt.Errorf("cannot query uncategorised tracker patterns: %w", err) return fmt.Errorf("cannot query tracker patterns: %w", err)
} }
patterns, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[TrackerPattern]) patterns, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[TrackerPattern])
if err != nil { if err != nil {
return fmt.Errorf("cannot collect uncategorised tracker patterns: %w", err) return fmt.Errorf("cannot collect tracker patterns: %w", err)
} }
*tps = patterns *tps = patterns
@@ -716,7 +708,7 @@ WHERE
return nil return nil
} }
func (tps *TrackerPatterns) CountUncategorisedByCookieBannerID( func (tps *TrackerPatterns) CountByCookieBannerID(
ctx context.Context, ctx context.Context,
conn pg.Querier, conn pg.Querier,
scope Scoper, scope Scoper,
@@ -731,21 +723,13 @@ FROM
WHERE WHERE
%s %s
AND cookie_banner_id = @cookie_banner_id AND cookie_banner_id = @cookie_banner_id
AND cookie_category_id = (
SELECT id FROM cookie_categories
WHERE cookie_banner_id = @cookie_banner_id
AND kind = @category_kind
AND %s
LIMIT 1
)
AND %s AND %s
` `
q = fmt.Sprintf(q, scope.SQLFragment(), scope.SQLFragment(), filter.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"cookie_banner_id": cookieBannerID, "cookie_banner_id": cookieBannerID,
"category_kind": CookieCategoryKindUncategorised,
} }
maps.Copy(args, scope.SQLArguments()) maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments()) maps.Copy(args, filter.SQLArguments())
@@ -754,7 +738,7 @@ WHERE
var count int var count int
if err := row.Scan(&count); err != nil { if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot scan count: %w", err) return 0, fmt.Errorf("cannot count tracker patterns: %w", err)
} }
return count, nil return count, nil

View File

@@ -112,6 +112,13 @@ input CookieCategoryOrder
field: CookieCategoryOrderField! field: CookieCategoryOrderField!
} }
input CookieCategoryFilter
@goModel(
model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CookieCategoryFilter"
) {
excludeKind: CookieCategoryKind
}
type CookieBanner implements Node { type CookieBanner implements Node {
id: ID! id: ID!
name: String! name: String!
@@ -125,12 +132,13 @@ type CookieBanner implements Node {
organization: Organization @goField(forceResolver: true) organization: Organization @goField(forceResolver: true)
consentCategories( categories(
first: Int first: Int
after: CursorKey after: CursorKey
last: Int last: Int
before: CursorKey before: CursorKey
orderBy: CookieCategoryOrder orderBy: CookieCategoryOrder
filter: CookieCategoryFilter
): CookieCategoryConnection @goField(forceResolver: true) ): CookieCategoryConnection @goField(forceResolver: true)
translations: [CookieBannerTranslation!]! @goField(forceResolver: true) translations: [CookieBannerTranslation!]! @goField(forceResolver: true)
@@ -146,7 +154,7 @@ type CookieBanner implements Node {
filter: CookieConsentRecordFilter filter: CookieConsentRecordFilter
): CookieConsentRecordConnection @goField(forceResolver: true) ): CookieConsentRecordConnection @goField(forceResolver: true)
uncategorisedTrackerPatterns( trackerPatterns(
first: Int first: Int
after: CursorKey after: CursorKey
last: Int last: Int
@@ -299,6 +307,7 @@ input TrackerPatternFilter
query: String query: String
source: CookieSource source: CookieSource
trackerType: TrackerType trackerType: TrackerType
cookieCategoryId: ID
} }
enum TrackerResourceType enum TrackerResourceType

View File

@@ -34,9 +34,10 @@ type (
} }
TrackerPatternFilter struct { TrackerPatternFilter struct {
Query *string Query *string
Source *coredata.CookieSource Source *coredata.CookieSource
TrackerType *coredata.TrackerType TrackerType *coredata.TrackerType
CookieCategoryID *gid.GID
} }
) )