Add tracker type filter and color-coded badges

The trackers page now lets users filter by tracker type
(Cookie, localStorage, sessionStorage, IndexedDB, Cache
Storage) in addition to the existing source filter. Each
tracker type and cookie source badge uses a distinct color
for quick visual scanning.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-11 15:34:24 +04:00
parent 4aebf65b89
commit 71a33e412b
4 changed files with 70 additions and 21 deletions

View File

@@ -37,6 +37,7 @@ import type {
CookieBannerTrackersPageRefetchQuery, CookieBannerTrackersPageRefetchQuery,
CookieSource, CookieSource,
TrackerPatternOrderField, TrackerPatternOrderField,
TrackerType,
} from "#/__generated__/core/CookieBannerTrackersPageRefetchQuery.graphql"; } from "#/__generated__/core/CookieBannerTrackersPageRefetchQuery.graphql";
import { SortableTable, SortableTh } from "#/components/SortableTable"; import { SortableTable, SortableTh } from "#/components/SortableTable";
@@ -64,6 +65,7 @@ const trackersFragment = graphql`
last: { type: "Int", defaultValue: null } last: { type: "Int", defaultValue: null }
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 }
) { ) {
uncategorisedTrackerPatterns( uncategorisedTrackerPatterns(
first: $first first: $first
@@ -71,7 +73,7 @@ const trackersFragment = graphql`
last: $last last: $last
before: $before before: $before
orderBy: $order orderBy: $order
filter: { query: $query, source: $source } filter: { query: $query, source: $source, trackerType: $trackerType }
) )
@connection( @connection(
key: "CookieBannerTrackersPage_uncategorisedTrackerPatterns" key: "CookieBannerTrackersPage_uncategorisedTrackerPatterns"
@@ -106,6 +108,7 @@ export default function CookieBannerTrackersPage({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
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 { data: fragmentData, ...pagination } = usePaginationFragment< const { data: fragmentData, ...pagination } = usePaginationFragment<
CookieBannerTrackersPageRefetchQuery, CookieBannerTrackersPageRefetchQuery,
@@ -121,6 +124,7 @@ export default function CookieBannerTrackersPage({
{ {
query: queryFilter || null, query: queryFilter || null,
source: sourceFilter, source: sourceFilter,
trackerType: trackerTypeFilter,
...overrides, ...overrides,
}, },
{ fetchPolicy: "network-only" }, { fetchPolicy: "network-only" },
@@ -138,11 +142,18 @@ export default function CookieBannerTrackersPage({
refetchFilters({ source: newSource }); refetchFilters({ source: newSource });
}; };
const handleTrackerTypeFilterChange = (value: string) => {
const newType = value === "ALL" ? null : (value as TrackerType);
setTrackerTypeFilter(newType);
refetchFilters({ trackerType: newType });
};
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,
}); });
}; };
@@ -165,6 +176,17 @@ 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={trackerTypeFilter ?? "ALL"}
onValueChange={handleTrackerTypeFilterChange}
>
<Option value="ALL">{__("All types")}</Option>
<Option value="COOKIE">{__("Cookie")}</Option>
<Option value="LOCAL_STORAGE">{__("localStorage")}</Option>
<Option value="SESSION_STORAGE">{__("sessionStorage")}</Option>
<Option value="INDEXED_DB">{__("IndexedDB")}</Option>
<Option value="CACHE_STORAGE">{__("Cache Storage")}</Option>
</Select>
</div> </div>
<div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}> <div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}>

View File

@@ -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 { Eye as IconEye, EyeSlash as IconEyeSlash } from "@phosphor-icons/react"; import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
import { formatDate, formatError, type GraphQLError } from "@probo/helpers"; import { formatDate, formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
@@ -124,14 +124,24 @@ const updatePatternMutation = graphql`
} }
`; `;
function trackerTypeLabel(type: string, __: (s: string) => string): string { type BadgeVariant = "success" | "warning" | "danger" | "info" | "neutral" | "outline" | "highlight";
function trackerTypeBadge(type: string, __: (s: string) => string): { label: string; variant: BadgeVariant } {
switch (type) { switch (type) {
case "COOKIE": return __("Cookie"); case "COOKIE": return { label: __("Cookie"), variant: "warning" };
case "LOCAL_STORAGE": return __("localStorage"); case "LOCAL_STORAGE": return { label: __("localStorage"), variant: "info" };
case "SESSION_STORAGE": return __("sessionStorage"); case "SESSION_STORAGE": return { label: __("sessionStorage"), variant: "highlight" };
case "INDEXED_DB": return __("IndexedDB"); case "INDEXED_DB": return { label: __("IndexedDB"), variant: "success" };
case "CACHE_STORAGE": return __("Cache Storage"); case "CACHE_STORAGE": return { label: __("Cache Storage"), variant: "outline" };
default: return type; default: return { label: type, variant: "neutral" };
}
}
function sourceBadge(source: string, __: (s: string) => string): { label: string; variant: BadgeVariant } {
switch (source) {
case "SCRIPT": return { label: __("Script"), variant: "info" };
case "PRE_EXISTING": return { label: __("Pre-existing"), variant: "outline" };
default: return { label: source, variant: "neutral" };
} }
} }
@@ -286,6 +296,9 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
); );
} }
const typeBadge = trackerTypeBadge(pattern.trackerType, __);
const srcBadge = pattern.source ? sourceBadge(pattern.source, __) : null;
return ( return (
<Tr className={pattern.excluded ? "bg-txt-quaternary opacity-80 line-through" : undefined}> <Tr className={pattern.excluded ? "bg-txt-quaternary opacity-80 line-through" : undefined}>
<Td> <Td>
@@ -299,17 +312,11 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
</div> </div>
</Td> </Td>
<Td> <Td>
<Badge variant="neutral"> <Badge variant={typeBadge.variant}>{typeBadge.label}</Badge>
{trackerTypeLabel(pattern.trackerType, __)}
</Badge>
</Td> </Td>
<Td> <Td>
{pattern.source {srcBadge
? ( ? <Badge variant={srcBadge.variant}>{srcBadge.label}</Badge>
<Badge variant={pattern.source === "SCRIPT" ? "info" : "neutral"}>
{pattern.source === "SCRIPT" ? __("Script") : __("Pre-existing")}
</Badge>
)
: <span className="text-txt-tertiary">-</span>} : <span className="text-txt-tertiary">-</span>}
</Td> </Td>
<Td> <Td>
@@ -360,7 +367,7 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
className="p-1 rounded cursor-pointer" className="p-1 rounded cursor-pointer"
title={pattern.excluded ? __("Include") : __("Exclude")} title={pattern.excluded ? __("Include") : __("Exclude")}
> >
{pattern.excluded ? <IconEye size={14} /> : <IconEyeSlash size={14} />} {pattern.excluded ? <EyeIcon size={14} /> : <EyeSlashIcon size={14} />}
</button> </button>
<button <button
type="button" type="button"

View File

@@ -25,6 +25,7 @@ type TrackerPatternFilter struct {
excluded *bool excluded *bool
query *string query *string
source *CookieSource source *CookieSource
trackerType *TrackerType
} }
func NewTrackerPatternFilter( func NewTrackerPatternFilter(
@@ -49,6 +50,11 @@ func (f *TrackerPatternFilter) WithSource(source *CookieSource) *TrackerPatternF
return f return f
} }
func (f *TrackerPatternFilter) WithTrackerType(trackerType *TrackerType) *TrackerPatternFilter {
f.trackerType = trackerType
return f
}
func (f *TrackerPatternFilter) SQLFragment() string { func (f *TrackerPatternFilter) SQLFragment() string {
if f == nil { if f == nil {
return "TRUE" return "TRUE"
@@ -90,6 +96,13 @@ func (f *TrackerPatternFilter) SQLFragment() string {
source = @filter_source::cookie_source source = @filter_source::cookie_source
ELSE TRUE ELSE TRUE
END END
AND
CASE
WHEN @has_tracker_type_filter::boolean = false THEN TRUE
WHEN @has_tracker_type_filter::boolean = true THEN
tracker_type = @filter_tracker_type::tracker_type
ELSE TRUE
END
)` )`
} }
@@ -108,6 +121,8 @@ func (f *TrackerPatternFilter) SQLArguments() pgx.StrictNamedArgs {
"filter_query": nil, "filter_query": nil,
"has_source_filter": false, "has_source_filter": false,
"filter_source": nil, "filter_source": nil,
"has_tracker_type_filter": false,
"filter_tracker_type": nil,
} }
if f.matchType != nil { if f.matchType != nil {
@@ -134,5 +149,10 @@ func (f *TrackerPatternFilter) SQLArguments() pgx.StrictNamedArgs {
args["filter_source"] = string(*f.source) args["filter_source"] = string(*f.source)
} }
if f.trackerType != nil {
args["has_tracker_type_filter"] = true
args["filter_tracker_type"] = string(*f.trackerType)
}
return args return args
} }

View File

@@ -199,7 +199,7 @@ func (r *cookieBannerResolver) UncategorisedTrackerPatterns(ctx context.Context,
coredataFilter := coredata.NewTrackerPatternFilter(nil, nil, nil) coredataFilter := coredata.NewTrackerPatternFilter(nil, nil, nil)
if filter != nil { if filter != nil {
coredataFilter = coredataFilter.WithQuery(filter.Query).WithSource(filter.Source) coredataFilter = coredataFilter.WithQuery(filter.Query).WithSource(filter.Source).WithTrackerType(filter.TrackerType)
} }
patterns, err := r.cookieBanner.ListUncategorisedTrackerPatterns(ctx, scope, obj.ID, cursor, coredataFilter) patterns, err := r.cookieBanner.ListUncategorisedTrackerPatterns(ctx, scope, obj.ID, cursor, coredataFilter)
@@ -1220,7 +1220,7 @@ func (r *trackerPatternConnectionResolver) TotalCount(ctx context.Context, obj *
default: default:
filter := coredata.NewTrackerPatternFilter(nil, nil, nil) filter := coredata.NewTrackerPatternFilter(nil, nil, nil)
if obj.Filter != nil { if obj.Filter != nil {
filter = filter.WithQuery(obj.Filter.Query).WithSource(obj.Filter.Source) filter = filter.WithQuery(obj.Filter.Query).WithSource(obj.Filter.Source).WithTrackerType(obj.Filter.TrackerType)
} }
count, err = r.cookieBanner.CountUncategorisedTrackerPatterns(ctx, scope, obj.ParentID, filter) count, err = r.cookieBanner.CountUncategorisedTrackerPatterns(ctx, scope, obj.ParentID, filter)
} }