diff --git a/.cursor/rules/relay-connection-item-components.mdc b/.cursor/rules/relay-connection-item-components.mdc new file mode 100644 index 000000000..2058c99d7 --- /dev/null +++ b/.cursor/rules/relay-connection-item-components.mdc @@ -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 ( + + {thing.name} + {thing.status} + + ); +} +``` + +```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 => ( + +))} +``` + +## Naming + +- File: `_components/Row.tsx` (for table rows) or + `_components/Card.tsx` (for card lists) +- Fragment: `_` (e.g. `ThingRow_thing`) +- Prop: `Key` (e.g. `thingKey`) diff --git a/.cursor/rules/relay-fragments-not-data-props.mdc b/.cursor/rules/relay-fragments-not-data-props.mdc index 43fa5566f..a6883b5a9 100644 --- a/.cursor/rules/relay-fragments-not-data-props.mdc +++ b/.cursor/rules/relay-fragments-not-data-props.mdc @@ -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`. diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRow.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRow.tsx index ca292bab3..f1d8afcac 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRow.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRow.tsx @@ -307,7 +307,7 @@ export function TrackerResourceRow({ resourceKey, connectionId }: TrackerResourc - {resource.path} + {resource.path} {resource.lastDetectedAt diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/TrackerPatternDetailPage.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/TrackerPatternDetailPage.tsx new file mode 100644 index 000000000..e0915f9ee --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/TrackerPatternDetailPage.tsx @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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; +} + +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 ( +
+ + + + + + + +
+ ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/TrackerPatternDetailPageLoader.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/TrackerPatternDetailPageLoader.tsx new file mode 100644 index 000000000..40d689733 --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/TrackerPatternDetailPageLoader.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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, + ); + + useEffect(() => { + loadQuery({ cookieBannerId, trackerPatternId }); + }, [loadQuery, cookieBannerId, trackerPatternId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/TrackerPatternDetailPageSkeleton.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/TrackerPatternDetailPageSkeleton.tsx new file mode 100644 index 000000000..811698856 --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/TrackerPatternDetailPageSkeleton.tsx @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/DetectedTrackerRow.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/DetectedTrackerRow.tsx new file mode 100644 index 000000000..f07355ef0 --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/DetectedTrackerRow.tsx @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + + + {tracker.identifier} + + + {tracker.initiatorUrl + ? {tracker.initiatorUrl} + : -} + + + {tracker.maxAgeSeconds != null + ? {tracker.maxAgeSeconds} + : -} + + + {tracker.source + ? ( + + {sourceBadge(tracker.source, __).label} + + ) + : -} + + + + + + ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternDetectedTrackersSection.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternDetectedTrackersSection.tsx new file mode 100644 index 000000000..28081d68e --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternDetectedTrackersSection.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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["refetch"] = ({ order }) => { + pagination.refetch({ + order: { direction: order.direction, field: order.field as DetectedTrackerOrderField }, + }); + }; + + return ( + <> +

{__("Detected Trackers")}

+ + {trackers.length > 0 + ? ( + + + + {__("Identifier")} + {__("Initiator URL")} + {__("Max Age (s)")} + {__("Source")} + {__("Detection Time")} + + + + {trackers.map(tracker => ( + + ))} + + + ) + : ( + +
+

+ {__("No detected trackers for this pattern yet.")} +

+
+
+ )} + + ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternPropertiesSection.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternPropertiesSection.tsx new file mode 100644 index 000000000..75713189a --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternPropertiesSection.tsx @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + + + {pattern.pattern} + + + {pattern.matchType === "EXACT" ? __("Exact") : __("Glob")} + + + {typeBadge.label} + + {pattern.source && ( + + + {sourceBadge(pattern.source, __).label} + + + )} + + + {pattern.cookieCategory?.name ?? "-"} + + + + + {humanizeSeconds(pattern.maxAgeSeconds ?? null)} + + + {pattern.description && ( + + {pattern.description} + + )} + + {pattern.excluded ? __("Yes") : __("No")} + + + {pattern.detectedCount} + + + {pattern.lastMatchedAt + ? ( + + ) + : -} + + + ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx index a10374b3a..b618fd959 100644 --- a/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/trackers/_components/TrackerPatternRow.tsx @@ -300,9 +300,9 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo const srcBadge = pattern.source ? sourceBadge(pattern.source, __) : null; return ( - + -
+
{pattern.displayName} {pattern.description && ( diff --git a/apps/console/src/pages/organizations/cookie-banners/routes.ts b/apps/console/src/pages/organizations/cookie-banners/routes.ts index 5091275dd..27c5e499e 100644 --- a/apps/console/src/pages/organizations/cookie-banners/routes.ts +++ b/apps/console/src/pages/organizations/cookie-banners/routes.ts @@ -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[]; diff --git a/contrib/claude/app-arborescence.md b/contrib/claude/app-arborescence.md index f3bb886da..de0621957 100644 --- a/contrib/claude/app-arborescence.md +++ b/contrib/claude/app-arborescence.md @@ -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: + + +``` + +```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 {/* PropertyRows */}; +} +``` + +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. diff --git a/contrib/claude/react-components.md b/contrib/claude/react-components.md index 1acf0803a..fde2e2d67 100644 --- a/contrib/claude/react-components.md +++ b/contrib/claude/react-components.md @@ -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: + + +``` + +```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 {/* PropertyRows */}; +} +``` + +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 => ( + +))} +``` + +This ensures field additions/removals in the row never modify the parent's fragment, and keeps the item independently testable. diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 1d43909b4..30e09bcbe 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -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, diff --git a/pkg/coredata/detected_tracker.go b/pkg/coredata/detected_tracker.go index 3982555ed..d80633799 100644 --- a/pkg/coredata/detected_tracker.go +++ b/pkg/coredata/detected_tracker.go @@ -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, diff --git a/pkg/coredata/detected_tracker_order_field.go b/pkg/coredata/detected_tracker_order_field.go new file mode 100644 index 000000000..ca6c7aa44 --- /dev/null +++ b/pkg/coredata/detected_tracker_order_field.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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)) +} diff --git a/pkg/server/api/console/v1/base_resolvers.go b/pkg/server/api/console/v1/base_resolvers.go index 89e18cfbf..de8552ca0 100644 --- a/pkg/server/api/console/v1/base_resolvers.go +++ b/pkg/server/api/console/v1/base_resolvers.go @@ -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) { diff --git a/pkg/server/api/console/v1/cookie_banner_resolvers.go b/pkg/server/api/console/v1/cookie_banner_resolvers.go index c90324963..caa5539c3 100644 --- a/pkg/server/api/console/v1/cookie_banner_resolvers.go +++ b/pkg/server/api/console/v1/cookie_banner_resolvers.go @@ -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 } diff --git a/pkg/server/api/console/v1/graphql/cookie_banner.graphql b/pkg/server/api/console/v1/graphql/cookie_banner.graphql index ad9d376c1..fa172b436 100644 --- a/pkg/server/api/console/v1/graphql/cookie_banner.graphql +++ b/pkg/server/api/console/v1/graphql/cookie_banner.graphql @@ -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 diff --git a/pkg/server/api/console/v1/types/detected_tracker.go b/pkg/server/api/console/v1/types/detected_tracker.go new file mode 100644 index 000000000..c6e75bc20 --- /dev/null +++ b/pkg/server/api/console/v1/types/detected_tracker.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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, + } +}