Rework tracker and resource row actions

Turn the category column into an inline Select so a tracker pattern or
resource can be recategorised in place. On the pattern row the move is
gated by a confirm that surfaces the existing behaviour of promoting or
linking a third party. The remaining row actions (include/exclude and
delete) move into a single action dropdown, leaving only Edit inline.

Add a Max Age column to the pattern table and a Category column to the
resource table, and lay the edit forms out with the name above the
inputs so the description can use the full row width. Shrink the action
column to its content so the freed space goes to the data columns.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-29 15:49:21 +02:00
parent f6aed77a74
commit 1b8bd1895e
7 changed files with 228 additions and 159 deletions

View File

@@ -187,8 +187,9 @@ export default function CookieBannerResourcesPage({
<Th>{__("Type")}</Th> <Th>{__("Type")}</Th>
<SortableTh field="ORIGIN">{__("Origin")}</SortableTh> <SortableTh field="ORIGIN">{__("Origin")}</SortableTh>
<Th>{__("Path")}</Th> <Th>{__("Path")}</Th>
<Th>{__("Category")}</Th>
<SortableTh field="LAST_DETECTED_AT">{__("Last Detected")}</SortableTh> <SortableTh field="LAST_DETECTED_AT">{__("Last Detected")}</SortableTh>
<Th className="w-28" /> <Th className="w-px" />
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>

View File

@@ -16,9 +16,9 @@ import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
import { formatError, type GraphQLError } from "@probo/helpers"; import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
ActionDropdown,
Badge, Badge,
Dropdown, DropdownItem,
IconArrowBoxLeft,
IconPencil, IconPencil,
IconTrashCan, IconTrashCan,
Td, Td,
@@ -26,21 +26,16 @@ import {
useConfirm, useConfirm,
useToast, useToast,
} from "@probo/ui"; } from "@probo/ui";
import { Suspense, useCallback, useState } from "react"; import { useState } from "react";
import { graphql, useFragment, useMutation, useQueryLoader } from "react-relay"; import { graphql, useFragment, useMutation } from "react-relay";
import { useParams } from "react-router";
import { ConnectionHandler } from "relay-runtime"; import { ConnectionHandler } from "relay-runtime";
import type { MoveToCategoryDropdownQuery } from "#/__generated__/core/MoveToCategoryDropdownQuery.graphql";
import type { TrackerResourceRowDeleteMutation } from "#/__generated__/core/TrackerResourceRowDeleteMutation.graphql"; import type { TrackerResourceRowDeleteMutation } from "#/__generated__/core/TrackerResourceRowDeleteMutation.graphql";
import type { TrackerResourceRowFragment$key } from "#/__generated__/core/TrackerResourceRowFragment.graphql"; import type { TrackerResourceRowFragment$key } from "#/__generated__/core/TrackerResourceRowFragment.graphql";
import type { TrackerResourceRowMoveMutation } from "#/__generated__/core/TrackerResourceRowMoveMutation.graphql"; import type { TrackerResourceRowMoveMutation } from "#/__generated__/core/TrackerResourceRowMoveMutation.graphql";
import type { TrackerResourceRowUpdateMutation } from "#/__generated__/core/TrackerResourceRowUpdateMutation.graphql"; import type { TrackerResourceRowUpdateMutation } from "#/__generated__/core/TrackerResourceRowUpdateMutation.graphql";
import { import { MoveToCategorySelect } from "../../trackers/_components/MoveToCategorySelect";
MoveToCategoryDropdown,
moveToCategoryDropdownQuery,
} from "../../trackers/_components/MoveToCategoryDropdown";
import { TrackerResourceRowEdit } from "./TrackerResourceRowEdit"; import { TrackerResourceRowEdit } from "./TrackerResourceRowEdit";
@@ -54,6 +49,10 @@ const trackerResourceFragment = graphql`
description description
excluded excluded
lastDetectedAt lastDetectedAt
cookieCategory {
id
name
}
} }
`; `;
@@ -147,22 +146,10 @@ export function TrackerResourceRow({ resourceKey, connectionId }: TrackerResourc
const { __ } = useTranslate(); const { __ } = useTranslate();
const { toast } = useToast(); const { toast } = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
const resource = useFragment(trackerResourceFragment, resourceKey); const resource = useFragment(trackerResourceFragment, resourceKey);
const typeBadge = resourceTypeBadge(resource.type, __); const typeBadge = resourceTypeBadge(resource.type, __);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [categoryQueryRef, loadCategoryQuery]
= useQueryLoader<MoveToCategoryDropdownQuery>(moveToCategoryDropdownQuery);
const handleCategoryDropdownOpen = useCallback(
(open: boolean) => {
if (open && cookieBannerId) {
loadCategoryQuery({ cookieBannerId });
}
},
[loadCategoryQuery, cookieBannerId],
);
const [deleteResource] const [deleteResource]
= useMutation<TrackerResourceRowDeleteMutation>(deleteResourceMutation); = useMutation<TrackerResourceRowDeleteMutation>(deleteResourceMutation);
@@ -309,6 +296,13 @@ export function TrackerResourceRow({ resourceKey, connectionId }: TrackerResourc
<Td> <Td>
<span className="font-mono text-xs break-all max-w-xs inline-block">{resource.path}</span> <span className="font-mono text-xs break-all max-w-xs inline-block">{resource.path}</span>
</Td> </Td>
<Td>
<MoveToCategorySelect
currentCategoryId={resource.cookieCategory?.id}
currentCategoryName={resource.cookieCategory?.name}
onSelect={handleMove}
/>
</Td>
<Td> <Td>
{resource.lastDetectedAt {resource.lastDetectedAt
? ( ? (
@@ -318,7 +312,7 @@ export function TrackerResourceRow({ resourceKey, connectionId }: TrackerResourc
) )
: <span className="text-txt-tertiary">-</span>} : <span className="text-txt-tertiary">-</span>}
</Td> </Td>
<Td> <Td className="w-px whitespace-nowrap">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
type="button" type="button"
@@ -328,40 +322,21 @@ export function TrackerResourceRow({ resourceKey, connectionId }: TrackerResourc
> >
<IconPencil size={14} /> <IconPencil size={14} />
</button> </button>
<Dropdown <ActionDropdown>
onOpenChange={handleCategoryDropdownOpen} <DropdownItem
toggle={( icon={resource.excluded ? EyeIcon : EyeSlashIcon}
<button onSelect={handleToggleExcluded}
type="button" >
className="p-1 rounded cursor-pointer" {resource.excluded ? __("Include") : __("Exclude")}
title={__("Move to category")} </DropdownItem>
> <DropdownItem
<IconArrowBoxLeft size={14} /> variant="danger"
</button> icon={IconTrashCan}
)} onSelect={handleDelete}
> >
{categoryQueryRef && ( {__("Delete")}
<Suspense> </DropdownItem>
<MoveToCategoryDropdown queryRef={categoryQueryRef} onMove={handleMove} /> </ActionDropdown>
</Suspense>
)}
</Dropdown>
<button
type="button"
onClick={handleToggleExcluded}
className="p-1 rounded cursor-pointer"
title={resource.excluded ? __("Include") : __("Exclude")}
>
{resource.excluded ? <EyeIcon size={14} /> : <EyeSlashIcon size={14} />}
</button>
<button
type="button"
onClick={handleDelete}
className="p-1 rounded cursor-pointer text-danger-dark"
title={__("Delete")}
>
<IconTrashCan size={14} />
</button>
</div> </div>
</Td> </Td>
</Tr> </Tr>

View File

@@ -61,7 +61,7 @@ export function TrackerResourceRowEdit({
placeholder={__("Display name")} placeholder={__("Display name")}
/> />
</Td> </Td>
<Td className="pr-3" colSpan={3}> <Td className="pr-3" colSpan={4}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
{...register("description")} {...register("description")}

View File

@@ -272,8 +272,9 @@ export default function CookieBannerTrackersPage({
<Th>{__("Third party")}</Th> <Th>{__("Third party")}</Th>
<SortableTh field="SOURCE">{__("Source")}</SortableTh> <SortableTh field="SOURCE">{__("Source")}</SortableTh>
<Th>{__("Category")}</Th> <Th>{__("Category")}</Th>
<Th>{__("Max Age")}</Th>
<SortableTh field="LAST_MATCHED_AT">{__("Last Matched")}</SortableTh> <SortableTh field="LAST_MATCHED_AT">{__("Last Matched")}</SortableTh>
<Th className="w-28" /> <Th className="w-px" />
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// 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 { Option, Select } from "@probo/ui";
import { Suspense, useCallback } from "react";
import { type PreloadedQuery, usePreloadedQuery, useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { MoveToCategoryDropdownQuery } from "#/__generated__/core/MoveToCategoryDropdownQuery.graphql";
import { moveToCategoryDropdownQuery } from "./MoveToCategoryDropdown";
interface MoveToCategorySelectProps {
currentCategoryId?: string;
currentCategoryName?: string;
onSelect: (categoryId: string) => void;
}
export function MoveToCategorySelect({
currentCategoryId,
currentCategoryName,
onSelect,
}: MoveToCategorySelectProps) {
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
const [categoryQueryRef, loadCategoryQuery]
= useQueryLoader<MoveToCategoryDropdownQuery>(moveToCategoryDropdownQuery);
const handleOpenChange = useCallback(
(open: boolean) => {
if (open && cookieBannerId) {
loadCategoryQuery({ cookieBannerId });
}
},
[loadCategoryQuery, cookieBannerId],
);
const handleValueChange = useCallback(
(categoryId: string) => {
if (categoryId !== currentCategoryId) {
onSelect(categoryId);
}
},
[currentCategoryId, onSelect],
);
return (
<Select
variant="ghost"
placeholder={currentCategoryName ?? <span className="text-txt-tertiary">-</span>}
onValueChange={handleValueChange}
onOpenChange={handleOpenChange}
>
{categoryQueryRef && (
<Suspense>
<MoveToCategoryOptions queryRef={categoryQueryRef} />
</Suspense>
)}
</Select>
);
}
interface MoveToCategoryOptionsProps {
queryRef: PreloadedQuery<MoveToCategoryDropdownQuery>;
}
function MoveToCategoryOptions({ queryRef }: MoveToCategoryOptionsProps) {
const { __ } = useTranslate();
const data = usePreloadedQuery(moveToCategoryDropdownQuery, queryRef);
if (data.node.__typename !== "CookieBanner") {
return null;
}
const categories = data.node.categories.edges.map(e => e.node);
if (categories.length === 0) {
return (
<Option value="" disabled className="text-txt-tertiary">
{__("No categories")}
</Option>
);
}
return (
<>
{categories.map(cat => (
<Option key={cat.id} value={cat.id}>
{cat.name}
</Option>
))}
</>
);
}

View File

@@ -13,12 +13,12 @@
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react"; import { EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
import { formatError, getTrackerSourceBadge, getTrackerTypeBadge, type GraphQLError } from "@probo/helpers"; import { formatError, getTrackerSourceBadge, getTrackerTypeBadge, type GraphQLError, humanizeSeconds } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { import {
ActionDropdown,
Badge, Badge,
Dropdown, DropdownItem,
IconArrowBoxLeft,
IconPencil, IconPencil,
IconTrashCan, IconTrashCan,
Td, Td,
@@ -26,21 +26,16 @@ import {
useConfirm, useConfirm,
useToast, useToast,
} from "@probo/ui"; } from "@probo/ui";
import { Suspense, useCallback, useState } from "react"; import { useState } from "react";
import { graphql, useFragment, useMutation, useQueryLoader } from "react-relay"; import { graphql, useFragment, useMutation } from "react-relay";
import { useParams } from "react-router";
import { ConnectionHandler } from "relay-runtime"; import { ConnectionHandler } from "relay-runtime";
import type { MoveToCategoryDropdownQuery } from "#/__generated__/core/MoveToCategoryDropdownQuery.graphql";
import type { TrackerPatternRowDeleteMutation } from "#/__generated__/core/TrackerPatternRowDeleteMutation.graphql"; import type { TrackerPatternRowDeleteMutation } from "#/__generated__/core/TrackerPatternRowDeleteMutation.graphql";
import type { TrackerPatternRowFragment$key } from "#/__generated__/core/TrackerPatternRowFragment.graphql"; import type { TrackerPatternRowFragment$key } from "#/__generated__/core/TrackerPatternRowFragment.graphql";
import type { TrackerPatternRowMoveMutation } from "#/__generated__/core/TrackerPatternRowMoveMutation.graphql"; import type { TrackerPatternRowMoveMutation } from "#/__generated__/core/TrackerPatternRowMoveMutation.graphql";
import type { TrackerPatternRowUpdateMutation } from "#/__generated__/core/TrackerPatternRowUpdateMutation.graphql"; import type { TrackerPatternRowUpdateMutation } from "#/__generated__/core/TrackerPatternRowUpdateMutation.graphql";
import { import { MoveToCategorySelect } from "./MoveToCategorySelect";
MoveToCategoryDropdown,
moveToCategoryDropdownQuery,
} from "./MoveToCategoryDropdown";
import { TrackerPatternRowEdit } from "./TrackerPatternRowEdit"; import { TrackerPatternRowEdit } from "./TrackerPatternRowEdit";
const trackerPatternFragment = graphql` const trackerPatternFragment = graphql`
@@ -54,6 +49,7 @@ const trackerPatternFragment = graphql`
excluded excluded
lastMatchedAt lastMatchedAt
cookieCategory { cookieCategory {
id
name name
} }
thirdParty { thirdParty {
@@ -143,21 +139,9 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
const { __ } = useTranslate(); const { __ } = useTranslate();
const { toast } = useToast(); const { toast } = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
const pattern = useFragment(trackerPatternFragment, patternKey); const pattern = useFragment(trackerPatternFragment, patternKey);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [categoryQueryRef, loadCategoryQuery]
= useQueryLoader<MoveToCategoryDropdownQuery>(moveToCategoryDropdownQuery);
const handleCategoryDropdownOpen = useCallback(
(open: boolean) => {
if (open && cookieBannerId) {
loadCategoryQuery({ cookieBannerId });
}
},
[loadCategoryQuery, cookieBannerId],
);
const [deletePattern] const [deletePattern]
= useMutation<TrackerPatternRowDeleteMutation>(deletePatternMutation); = useMutation<TrackerPatternRowDeleteMutation>(deletePatternMutation);
@@ -229,6 +213,22 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
}); });
}; };
const handleMoveWithConfirm = (targetCategoryId: string) => {
if (targetCategoryId === pattern.cookieCategory?.id) {
return;
}
confirm(
() => {
handleMove(targetCategoryId);
},
{
message: __("Moving this tracker to a category will create a third party for it (or link an existing one) if it doesn't have one yet. Continue?"),
variant: "primary",
label: __("Move"),
},
);
};
const handleToggleExcluded = () => { const handleToggleExcluded = () => {
updatePattern({ updatePattern({
variables: { variables: {
@@ -323,10 +323,15 @@ 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 noLink>
<MoveToCategorySelect
currentCategoryId={pattern.cookieCategory?.id}
currentCategoryName={pattern.cookieCategory?.name}
onSelect={handleMoveWithConfirm}
/>
</Td>
<Td> <Td>
{pattern.cookieCategory <span>{humanizeSeconds(pattern.maxAgeSeconds ?? null)}</span>
? <span>{pattern.cookieCategory.name}</span>
: <span className="text-txt-tertiary">-</span>}
</Td> </Td>
<Td> <Td>
{pattern.lastMatchedAt {pattern.lastMatchedAt
@@ -337,7 +342,7 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
) )
: <span className="text-txt-tertiary">-</span>} : <span className="text-txt-tertiary">-</span>}
</Td> </Td>
<Td> <Td noLink className="w-px whitespace-nowrap">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
type="button" type="button"
@@ -347,40 +352,21 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
> >
<IconPencil size={14} /> <IconPencil size={14} />
</button> </button>
<Dropdown <ActionDropdown>
onOpenChange={handleCategoryDropdownOpen} <DropdownItem
toggle={( icon={pattern.excluded ? EyeIcon : EyeSlashIcon}
<button onSelect={handleToggleExcluded}
type="button" >
className="p-1 rounded cursor-pointer" {pattern.excluded ? __("Include") : __("Exclude")}
title={__("Move to category")} </DropdownItem>
> <DropdownItem
<IconArrowBoxLeft size={14} /> variant="danger"
</button> icon={IconTrashCan}
)} onSelect={handleDelete}
> >
{categoryQueryRef && ( {__("Delete")}
<Suspense> </DropdownItem>
<MoveToCategoryDropdown queryRef={categoryQueryRef} onMove={handleMove} /> </ActionDropdown>
</Suspense>
)}
</Dropdown>
<button
type="button"
onClick={handleToggleExcluded}
className="p-1 rounded cursor-pointer"
title={pattern.excluded ? __("Include") : __("Exclude")}
>
{pattern.excluded ? <EyeIcon size={14} /> : <EyeSlashIcon size={14} />}
</button>
<button
type="button"
onClick={handleDelete}
className="p-1 rounded cursor-pointer text-danger-dark"
title={__("Delete")}
>
<IconTrashCan size={14} />
</button>
</div> </div>
</Td> </Td>
</Tr> </Tr>

View File

@@ -58,44 +58,45 @@ export function TrackerPatternRowEdit({
return ( return (
<Tr> <Tr>
<Td className="pr-3"> <Td colSpan={8}>
<span className="font-medium">{pattern}</span> <div className="flex flex-col gap-3">
</Td> <span className="font-medium wrap-break-word">{pattern}</span>
<Td /> <div className="flex items-end gap-2">
<Td /> <div className="flex flex-col gap-1 flex-1">
<Td className="pr-3"> <label className="text-xs text-txt-tertiary">{__("Description")}</label>
<Controller <Input
name="duration" {...register("description")}
control={control} placeholder={__("Description")}
render={({ field }) => ( />
<DurationInput </div>
value={field.value.value} <div className="flex flex-col gap-1">
unit={field.value.unit} <label className="text-xs text-txt-tertiary">{__("Max Age")}</label>
onValueChange={v => field.onChange({ ...field.value, value: v })} <Controller
onUnitChange={u => field.onChange({ ...field.value, unit: u })} name="duration"
/> control={control}
)} render={({ field }) => (
/> <DurationInput
</Td> value={field.value.value}
<Td className="pr-3" colSpan={2}> unit={field.value.unit}
<div className="flex items-center gap-2"> onValueChange={v => field.onChange({ ...field.value, value: v })}
<Input onUnitChange={u => field.onChange({ ...field.value, unit: u })}
{...register("description")} />
placeholder={__("Description")} )}
className="flex-1" />
/> </div>
<Button <Button
onClick={() => void handleSubmit(onSubmit)()} onClick={() => void handleSubmit(onSubmit)()}
disabled={isUpdating} disabled={isUpdating}
> >
{__("Save")} {__("Save")}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
onClick={onCancel} onClick={onCancel}
> >
{__("Cancel")} {__("Cancel")}
</Button> </Button>
</div>
</div> </div>
</Td> </Td>
</Tr> </Tr>