Link cookie policy doc and revamp tracker rows

Expose the generated cookie policy Document on the CookieBanner
GraphQL type through a nullable policyDocument field and resolver,
and surface a link to it from the banner configuration header next
to the origin and ID. The link is hidden until a banner version is
published and the document exists.

Rework the tracker table rows: drop the Source column in favour of
a tracker Type badge, and move each tracker's description inline
beneath its name (and into the add/edit row inputs) instead of a
separate Description column.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-02 15:46:42 +02:00
parent e9b36bed14
commit 9c4b6aff18
7 changed files with 103 additions and 53 deletions

View File

@@ -29,7 +29,7 @@ import {
useToast, useToast,
} from "@probo/ui"; } from "@probo/ui";
import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay"; import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay";
import { Outlet, useParams } from "react-router"; import { Link, Outlet, useParams } from "react-router";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import type { CookieBannerConfigLayoutActivateMutation } from "#/__generated__/core/CookieBannerConfigLayoutActivateMutation.graphql"; import type { CookieBannerConfigLayoutActivateMutation } from "#/__generated__/core/CookieBannerConfigLayoutActivateMutation.graphql";
@@ -52,6 +52,9 @@ export const cookieBannerConfigLayoutQuery = graphql`
version version
state state
} }
policyDocument {
id
}
} }
} }
} }
@@ -214,6 +217,17 @@ export default function CookieBannerConfigLayout({ queryRef }: CookieBannerConfi
<IconSquareBehindSquare2 size={16} /> <IconSquareBehindSquare2 size={16} />
</button> </button>
</span> </span>
{banner.policyDocument && (
<>
<span className="text-border-primary">·</span>
<Link
to={`/organizations/${organizationId}/documents/${banner.policyDocument.id}`}
className="font-medium text-txt-primary underline"
>
{__("Cookie Policy")}
</Link>
</>
)}
</span> </span>
)} )}
> >

View File

@@ -58,10 +58,16 @@ export function AddCookieRow({
return ( return (
<Tr> <Tr>
<Td className="pr-3"> <Td className="pr-3">
<Input <div className="flex flex-col gap-2 min-w-0 max-w-xs">
{...register("name")} <Input
placeholder={__("Cookie name")} {...register("name")}
/> placeholder={__("Cookie name")}
/>
<Input
{...register("description")}
placeholder={__("Description")}
/>
</div>
</Td> </Td>
<Td /> <Td />
<Td className="pr-3"> <Td className="pr-3">
@@ -78,12 +84,6 @@ export function AddCookieRow({
)} )}
/> />
</Td> </Td>
<Td className="pr-3">
<Input
{...register("description")}
placeholder={__("Description")}
/>
</Td>
<Td> <Td>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button

View File

@@ -13,7 +13,7 @@
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react"; import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
import { formatError, type GraphQLError, humanizeSeconds } from "@probo/helpers"; import { formatError, getTrackerTypeBadge, type GraphQLError, humanizeSeconds } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
Badge, Badge,
@@ -76,10 +76,10 @@ export const categorySectionFragment = graphql`
node { node {
id id
displayName displayName
trackerType
maxAgeSeconds maxAgeSeconds
description description
excluded excluded
source
...EditCookieRowFragment ...EditCookieRowFragment
} }
} }
@@ -136,10 +136,10 @@ const createPatternMutation = graphql`
node { node {
id id
displayName displayName
trackerType
maxAgeSeconds maxAgeSeconds
description description
excluded excluded
source
...EditCookieRowFragment ...EditCookieRowFragment
} }
} }
@@ -759,9 +759,8 @@ export function CategorySection({ categoryKey, connectionId }: CategorySectionPr
<Thead> <Thead>
<Tr> <Tr>
<Th>{__("Name")}</Th> <Th>{__("Name")}</Th>
<Th>{__("Source")}</Th> <Th>{__("Type")}</Th>
<Th>{__("Duration")}</Th> <Th>{__("Duration")}</Th>
<Th>{__("Description")}</Th>
<Th className="w-20" /> <Th className="w-20" />
</Tr> </Tr>
</Thead> </Thead>
@@ -780,26 +779,24 @@ export function CategorySection({ categoryKey, connectionId }: CategorySectionPr
: ( : (
<Tr key={pattern.id} className={pattern.excluded ? "opacity-80" : undefined}> <Tr key={pattern.id} className={pattern.excluded ? "opacity-80" : undefined}>
<Td> <Td>
<code className="text-sm font-mono">{pattern.displayName}</code> <div className="flex flex-col min-w-0 max-w-xs">
<code className="text-sm font-mono">{pattern.displayName}</code>
{pattern.description && (
<span className="text-xs text-txt-tertiary wrap-break-word line-clamp-1">
{pattern.description}
</span>
)}
</div>
</Td> </Td>
<Td> <Td>
<Badge {(() => {
variant={pattern.source === "SCRIPT" ? "info" : "neutral"} const typeBadge = getTrackerTypeBadge(pattern.trackerType, __);
title={ return <Badge variant={typeBadge.variant}>{typeBadge.label}</Badge>;
pattern.source === "SCRIPT" })()}
? __("Set by a script at runtime")
: __("Already present when the page was loaded")
}
>
{pattern.source === "SCRIPT" ? __("Script") : __("Pre-existing")}
</Badge>
</Td> </Td>
<Td className="text-sm text-muted-foreground"> <Td className="text-sm text-muted-foreground">
{humanizeSeconds(pattern.maxAgeSeconds ?? null)} {humanizeSeconds(pattern.maxAgeSeconds ?? null)}
</Td> </Td>
<Td className="text-sm text-muted-foreground">
{pattern.description}
</Td>
<Td> <Td>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button

View File

@@ -12,9 +12,9 @@
// 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 { fromMaxAgeSeconds, toMaxAgeSeconds } from "@probo/helpers"; import { fromMaxAgeSeconds, getTrackerTypeBadge, toMaxAgeSeconds } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { Button, DurationInput, Input, Td, Toggle, Tr } from "@probo/ui"; import { Badge, Button, DurationInput, Input, Td, Toggle, Tr } from "@probo/ui";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useFragment } from "react-relay"; import { useFragment } from "react-relay";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
@@ -26,6 +26,7 @@ import type { CookieEntry } from "./CategorySection";
export const editCookieRowFragment = graphql` export const editCookieRowFragment = graphql`
fragment EditCookieRowFragment on TrackerPattern { fragment EditCookieRowFragment on TrackerPattern {
displayName displayName
trackerType
maxAgeSeconds maxAgeSeconds
description description
excluded excluded
@@ -72,26 +73,36 @@ export function EditCookieRow({
}); });
}; };
const typeBadge = getTrackerTypeBadge(cookie.trackerType, __);
return ( return (
<Tr> <Tr>
<Td className="pr-3"> <Td className="pr-3">
<div className="flex items-center gap-2"> <div className="flex flex-col gap-2 min-w-0 max-w-xs">
<Controller <div className="flex items-center gap-2">
name="excluded" <Controller
control={control} name="excluded"
render={({ field }) => ( control={control}
<Toggle render={({ field }) => (
size="sm" <Toggle
checked={!field.value} size="sm"
onChange={checked => field.onChange(!checked)} checked={!field.value}
title={__("Include this cookie in the banner")} onChange={checked => field.onChange(!checked)}
/> title={__("Include this cookie in the banner")}
)} />
)}
/>
<code className="text-sm font-mono">{cookie.displayName}</code>
</div>
<Input
{...register("description")}
placeholder={__("Description")}
/> />
<code className="text-sm font-mono">{cookie.displayName}</code>
</div> </div>
</Td> </Td>
<Td /> <Td>
<Badge variant={typeBadge.variant}>{typeBadge.label}</Badge>
</Td>
<Td className="pr-3"> <Td className="pr-3">
<Controller <Controller
name="duration" name="duration"
@@ -106,12 +117,6 @@ export function EditCookieRow({
)} )}
/> />
</Td> </Td>
<Td className="pr-3">
<Input
{...register("description")}
placeholder={__("Description")}
/>
</Td>
<Td> <Td>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button

View File

@@ -143,6 +143,32 @@ func (r *cookieBannerResolver) LatestVersion(ctx context.Context, obj *types.Coo
}, nil }, nil
} }
// PolicyDocument is the resolver for the policyDocument field.
func (r *cookieBannerResolver) PolicyDocument(ctx context.Context, obj *types.CookieBanner) (*types.Document, error) {
if obj.PolicyDocument == nil {
return nil, nil
}
if _, err := r.authorize(ctx, obj.ID, probo.ActionDocumentGet); err != nil {
return nil, err
}
loaders := dataloader.FromContext(ctx)
document, err := loaders.Document.Load(ctx, obj.PolicyDocument.ID)
if err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) || errors.Is(err, dataloadgen.ErrNotFound) {
return nil, nil
}
r.logger.ErrorCtx(ctx, "cannot get policy document", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewDocument(document), nil
}
// ConsentRecords is the resolver for the consentRecords field. // ConsentRecords is the resolver for the consentRecords field.
func (r *cookieBannerResolver) ConsentRecords(ctx context.Context, obj *types.CookieBanner, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.CookieConsentRecordOrderBy, filter *types.CookieConsentRecordFilter) (*types.CookieConsentRecordConnection, error) { func (r *cookieBannerResolver) ConsentRecords(ctx context.Context, obj *types.CookieBanner, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.CookieConsentRecordOrderBy, filter *types.CookieConsentRecordFilter) (*types.CookieConsentRecordConnection, error) {
scope, err := r.authorize(ctx, obj.ID, probo.ActionCookieConsentRecordList) scope, err := r.authorize(ctx, obj.ID, probo.ActionCookieConsentRecordList)

View File

@@ -153,6 +153,8 @@ type CookieBanner implements Node {
latestVersion: CookieBannerVersion @goField(forceResolver: true) latestVersion: CookieBannerVersion @goField(forceResolver: true)
policyDocument: Document @goField(forceResolver: true)
consentRecords( consentRecords(
first: Int first: Int
after: CursorKey after: CursorKey

View File

@@ -61,7 +61,7 @@ func NewCookieBannerEdge(b *coredata.CookieBanner, orderBy coredata.CookieBanner
} }
func NewCookieBanner(b *coredata.CookieBanner) *CookieBanner { func NewCookieBanner(b *coredata.CookieBanner) *CookieBanner {
return &CookieBanner{ banner := &CookieBanner{
ID: b.ID, ID: b.ID,
Organization: &Organization{ Organization: &Organization{
ID: b.OrganizationID, ID: b.OrganizationID,
@@ -77,6 +77,12 @@ func NewCookieBanner(b *coredata.CookieBanner) *CookieBanner {
CreatedAt: b.CreatedAt, CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt, UpdatedAt: b.UpdatedAt,
} }
if b.PolicyDocumentID != nil {
banner.PolicyDocument = &Document{ID: *b.PolicyDocumentID}
}
return banner
} }
func NewCookieBannerTranslation(t *coredata.CookieBannerTranslation) *CookieBannerTranslation { func NewCookieBannerTranslation(t *coredata.CookieBannerTranslation) *CookieBannerTranslation {