From 8911aa16d7d6e30877c40f5686fccbc9259212cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Fri, 8 May 2026 19:13:53 +0400 Subject: [PATCH] frontend: add TrackerResource resources page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add configuration/resources/ page for managing tracked scripts and iframes. Includes page/loader/skeleton, TrackerResourceRow with inline edit, delete/move/exclude mutations, search + type filter, and LAST_DETECTED_AT DESC default ordering. Register route and tab. Signed-off-by: Émile Ré --- .../resources/CookieBannerResourcesPage.tsx | 213 +++++++++++ .../CookieBannerResourcesPageLoader.tsx | 47 +++ .../CookieBannerResourcesPageSkeleton.tsx | 30 ++ .../_components/TrackerResourceRow.tsx | 361 ++++++++++++++++++ .../_components/TrackerResourceRowEdit.tsx | 87 +++++ 5 files changed, 738 insertions(+) create mode 100644 apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPage.tsx create mode 100644 apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPageLoader.tsx create mode 100644 apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPageSkeleton.tsx create mode 100644 apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRow.tsx create mode 100644 apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRowEdit.tsx diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPage.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPage.tsx new file mode 100644 index 000000000..aa69d28d0 --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPage.tsx @@ -0,0 +1,213 @@ +// 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, + Input, + Option, + Select, + Tbody, + Th, + Thead, + Tr, +} from "@probo/ui"; +import { type ComponentProps, useState, useTransition } from "react"; +import { + graphql, + type PreloadedQuery, + usePaginationFragment, + usePreloadedQuery, +} from "react-relay"; + +import type { CookieBannerResourcesPageFragment$key } from "#/__generated__/core/CookieBannerResourcesPageFragment.graphql"; +import type { CookieBannerResourcesPageQuery } from "#/__generated__/core/CookieBannerResourcesPageQuery.graphql"; +import type { + CookieBannerResourcesPageRefetchQuery, + TrackerResourceOrderField, + TrackerResourceType, +} from "#/__generated__/core/CookieBannerResourcesPageRefetchQuery.graphql"; +import { SortableTable, SortableTh } from "#/components/SortableTable"; + +import { TrackerResourceRow } from "./_components/TrackerResourceRow"; + +export const cookieBannerResourcesPageQuery = graphql` + query CookieBannerResourcesPageQuery($cookieBannerId: ID!) { + node(id: $cookieBannerId) @required(action: THROW) { + __typename + ... on CookieBanner { + ...CookieBannerResourcesPageFragment + } + } + } +`; + +const resourcesFragment = graphql` + fragment CookieBannerResourcesPageFragment on CookieBanner + @refetchable(queryName: "CookieBannerResourcesPageRefetchQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 50 } + order: { type: "TrackerResourceOrder", defaultValue: { field: LAST_DETECTED_AT, direction: DESC } } + after: { type: "CursorKey", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + last: { type: "Int", defaultValue: null } + query: { type: "String", defaultValue: null } + type: { type: "TrackerResourceType", defaultValue: null } + ) { + uncategorisedTrackerResources( + first: $first + after: $after + last: $last + before: $before + orderBy: $order + filter: { query: $query, type: $type } + ) + @connection( + key: "CookieBannerResourcesPage_uncategorisedTrackerResources" + filters: ["filter", "orderBy"] + ) + @required(action: THROW) { + __id + edges { + node { + id + ...TrackerResourceRowFragment + } + } + } + } +`; + +interface CookieBannerResourcesPageProps { + queryRef: PreloadedQuery; +} + +export default function CookieBannerResourcesPage({ + queryRef, +}: CookieBannerResourcesPageProps) { + const { __ } = useTranslate(); + const data = usePreloadedQuery(cookieBannerResourcesPageQuery, queryRef); + + if (data.node.__typename !== "CookieBanner") { + throw new Error("invalid type for node"); + } + + const [isPending, startTransition] = useTransition(); + const [queryFilter, setQueryFilter] = useState(""); + const [typeFilter, setTypeFilter] = useState(null); + + const { data: fragmentData, ...pagination } = usePaginationFragment< + CookieBannerResourcesPageRefetchQuery, + CookieBannerResourcesPageFragment$key + >(resourcesFragment, data.node); + + const connectionId = fragmentData.uncategorisedTrackerResources.__id; + const resources = fragmentData.uncategorisedTrackerResources.edges.map(edge => edge.node) ?? []; + + const refetchFilters = (overrides: Record = {}) => { + startTransition(() => { + pagination.refetch( + { + query: queryFilter || null, + type: typeFilter, + ...overrides, + }, + { fetchPolicy: "network-only" }, + ); + }); + }; + + const handleQuerySubmit = () => { + refetchFilters({ query: queryFilter || null }); + }; + + const handleTypeFilterChange = (value: string) => { + const newType = value === "ALL" ? null : (value as TrackerResourceType); + setTypeFilter(newType); + refetchFilters({ type: newType }); + }; + + const refetchWithFilters: ComponentProps["refetch"] = ({ order }) => { + pagination.refetch({ + order: { direction: order.direction, field: order.field as TrackerResourceOrderField }, + query: queryFilter || null, + type: typeFilter, + }); + }; + + return ( +
+
+ setQueryFilter(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleQuerySubmit()} + onBlur={handleQuerySubmit} + className="w-72" + /> + +
+ +
+ {resources.length > 0 + ? ( + + + + {__("Type")} + {__("Origin")} + {__("Path")} + {__("Last Detected")} + + + + + {resources.map(resource => ( + + ))} + + + ) + : ( + +
+

+ {__("No uncategorised resources")} +

+

+ {__("All detected scripts and iframes have been categorised. New resources will appear here when detected.")} +

+
+
+ )} +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPageLoader.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPageLoader.tsx new file mode 100644 index 000000000..68dfb6098 --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPageLoader.tsx @@ -0,0 +1,47 @@ +// 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 { CookieBannerResourcesPageQuery } from "#/__generated__/core/CookieBannerResourcesPageQuery.graphql"; + +import CookieBannerResourcesPage, { cookieBannerResourcesPageQuery } from "./CookieBannerResourcesPage"; +import { CookieBannerResourcesPageSkeleton } from "./CookieBannerResourcesPageSkeleton"; + +export default function CookieBannerResourcesPageLoader() { + const { cookieBannerId } = useParams<{ cookieBannerId: string }>(); + if (typeof cookieBannerId !== "string") { + throw new Error("Missing cookieBannerId parameter"); + } + + const [queryRef, loadQuery] = useQueryLoader( + cookieBannerResourcesPageQuery, + ); + + useEffect(() => { + loadQuery({ cookieBannerId }); + }, [loadQuery, cookieBannerId]); + + if (!queryRef) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPageSkeleton.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPageSkeleton.tsx new file mode 100644 index 000000000..e7d7001a9 --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/CookieBannerResourcesPageSkeleton.tsx @@ -0,0 +1,30 @@ +// 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 CookieBannerResourcesPageSkeleton() { + return ( +
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+
+ ); +} 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 new file mode 100644 index 000000000..fcb5295f5 --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRow.tsx @@ -0,0 +1,361 @@ +// 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 { Eye as IconEye, EyeSlash as IconEyeSlash } from "@phosphor-icons/react"; +import { formatError, type GraphQLError } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Badge, + Dropdown, + IconArrowBoxLeft, + IconPencil, + IconTrashCan, + Td, + Tr, + useConfirm, + useToast, +} from "@probo/ui"; +import { Suspense, useCallback, useState } from "react"; +import { graphql, useFragment, useMutation, useQueryLoader } from "react-relay"; +import { useParams } from "react-router"; +import { ConnectionHandler } from "relay-runtime"; + +import type { TrackerResourceRowDeleteMutation } from "#/__generated__/core/TrackerResourceRowDeleteMutation.graphql"; +import type { TrackerResourceRowFragment$key } from "#/__generated__/core/TrackerResourceRowFragment.graphql"; +import type { TrackerResourceRowMoveMutation } from "#/__generated__/core/TrackerResourceRowMoveMutation.graphql"; +import type { TrackerResourceRowUpdateMutation } from "#/__generated__/core/TrackerResourceRowUpdateMutation.graphql"; +import type { MoveToCategoryDropdownQuery } from "#/__generated__/core/MoveToCategoryDropdownQuery.graphql"; + +import { + MoveToCategoryDropdown, + moveToCategoryDropdownQuery, +} from "../../trackers/_components/MoveToCategoryDropdown"; +import { TrackerResourceRowEdit } from "./TrackerResourceRowEdit"; + +const trackerResourceFragment = graphql` + fragment TrackerResourceRowFragment on TrackerResource { + id + type + origin + path + displayName + description + excluded + lastDetectedAt + updatedAt + } +`; + +const deleteResourceMutation = graphql` + mutation TrackerResourceRowDeleteMutation( + $input: DeleteTrackerResourceInput! + $connections: [ID!]! + ) { + deleteTrackerResource(input: $input) { + deletedTrackerResourceId @deleteEdge(connections: $connections) + cookieBanner { + id + latestVersion { + id + version + state + } + } + } + } +`; + +const moveResourceMutation = graphql` + mutation TrackerResourceRowMoveMutation( + $input: MoveTrackerResourceToCategoryInput! + ) { + moveTrackerResourceToCategory(input: $input) { + trackerResource { + id + cookieCategory { + id + } + } + cookieBanner { + id + latestVersion { + id + version + state + } + } + } + } +`; + +const updateResourceMutation = graphql` + mutation TrackerResourceRowUpdateMutation( + $input: UpdateTrackerResourceInput! + ) { + updateTrackerResource(input: $input) { + trackerResource { + id + displayName + description + excluded + updatedAt + } + cookieBanner { + id + latestVersion { + id + version + state + } + } + } + } +`; + +function resourceTypeLabel(type: string, __: (s: string) => string): string { + switch (type) { + case "SCRIPT": return __("Script"); + case "IFRAME": return __("Iframe"); + default: return type; + } +} + +interface TrackerResourceRowProps { + resourceKey: TrackerResourceRowFragment$key; + connectionId: string; +} + +export function TrackerResourceRow({ resourceKey, connectionId }: TrackerResourceRowProps) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const confirm = useConfirm(); + const { cookieBannerId } = useParams<{ cookieBannerId: string }>(); + const resource = useFragment(trackerResourceFragment, resourceKey); + + const [isEditing, setIsEditing] = useState(false); + const [categoryQueryRef, loadCategoryQuery] + = useQueryLoader(moveToCategoryDropdownQuery); + + const handleCategoryDropdownOpen = useCallback( + (open: boolean) => { + if (open && cookieBannerId) { + loadCategoryQuery({ cookieBannerId }); + } + }, + [loadCategoryQuery, cookieBannerId], + ); + + const [deleteResource] + = useMutation(deleteResourceMutation); + const [moveResource] + = useMutation(moveResourceMutation); + const [updateResource, isUpdating] + = useMutation(updateResourceMutation); + + const handleDelete = () => { + confirm( + () => + new Promise((resolve) => { + deleteResource({ + variables: { + input: { trackerResourceId: resource.id }, + connections: [connectionId], + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ title: __("Error"), description: errors[0].message, variant: "error" }); + } else { + toast({ title: __("Success"), description: __("Resource deleted"), variant: "success" }); + } + resolve(); + }, + onError(error) { + toast({ title: __("Error"), description: formatError(__("Failed to delete resource"), error as GraphQLError), variant: "error" }); + resolve(); + }, + }); + }), + { + message: __("Are you sure you want to delete \"%s\"?").replace("%s", resource.displayName), + variant: "danger", + label: __("Delete"), + }, + ); + }; + + const handleMove = (targetCategoryId: string) => { + moveResource({ + variables: { + input: { + trackerResourceId: resource.id, + targetCookieCategoryId: targetCategoryId, + }, + }, + updater(store) { + const payload = store.getRootField("moveTrackerResourceToCategory"); + if (!payload?.getLinkedRecord("trackerResource")) { + return; + } + + const conn = store.get(connectionId); + if (conn) { + ConnectionHandler.deleteNode(conn, resource.id); + } + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ title: __("Error"), description: errors[0].message, variant: "error" }); + return; + } + toast({ title: __("Success"), description: __("Resource moved"), variant: "success" }); + }, + onError(error) { + toast({ title: __("Error"), description: formatError(__("Failed to move resource"), error as GraphQLError), variant: "error" }); + }, + }); + }; + + const handleToggleExcluded = () => { + updateResource({ + variables: { + input: { + trackerResourceId: resource.id, + excluded: !resource.excluded, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ title: __("Error"), description: errors[0].message, variant: "error" }); + return; + } + }, + onError(error) { + toast({ title: __("Error"), description: formatError(__("Failed to update resource"), error as GraphQLError), variant: "error" }); + }, + }); + }; + + const handleSaveEdit = (data: { displayName: string; description: string }) => { + updateResource({ + variables: { + input: { + trackerResourceId: resource.id, + displayName: data.displayName, + description: data.description, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ title: __("Error"), description: errors[0].message, variant: "error" }); + return; + } + toast({ title: __("Success"), description: __("Resource updated"), variant: "success" }); + setIsEditing(false); + }, + onError(error) { + toast({ title: __("Error"), description: formatError(__("Failed to update resource"), error as GraphQLError), variant: "error" }); + }, + }); + }; + + if (isEditing) { + return ( + setIsEditing(false)} + /> + ); + } + + return ( + + + + {resourceTypeLabel(resource.type, __)} + + + +
+ {resource.origin} + {resource.description && ( + + {resource.description} + + )} +
+ + + {resource.path} + + + {resource.lastDetectedAt + ? ( + + ) + : -} + + +
+ + + + + )} + > + {categoryQueryRef && ( + + + + )} + + + +
+ + + ); +} diff --git a/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRowEdit.tsx b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRowEdit.tsx new file mode 100644 index 000000000..8221c5010 --- /dev/null +++ b/apps/console/src/pages/organizations/cookie-banners/configuration/resources/_components/TrackerResourceRowEdit.tsx @@ -0,0 +1,87 @@ +// 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 { Button, Input, Td, Tr } from "@probo/ui"; +import { useForm } from "react-hook-form"; + +interface FormValues { + displayName: string; + description: string; +} + +interface TrackerResourceRowEditProps { + displayName: string; + description: string; + isUpdating: boolean; + onSave: (data: { displayName: string; description: string }) => void; + onCancel: () => void; +} + +export function TrackerResourceRowEdit({ + displayName, + description, + isUpdating, + onSave, + onCancel, +}: TrackerResourceRowEditProps) { + const { __ } = useTranslate(); + + const { register, handleSubmit } = useForm({ + defaultValues: { + displayName, + description, + }, + }); + + const onSubmit = (data: FormValues) => { + onSave({ + displayName: data.displayName, + description: data.description, + }); + }; + + return ( + + + + + + +
+ + + +
+ + + ); +}