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>
<SortableTh field="ORIGIN">{__("Origin")}</SortableTh>
<Th>{__("Path")}</Th>
<Th>{__("Category")}</Th>
<SortableTh field="LAST_DETECTED_AT">{__("Last Detected")}</SortableTh>
<Th className="w-28" />
<Th className="w-px" />
</Tr>
</Thead>
<Tbody>

View File

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

View File

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

View File

@@ -272,8 +272,9 @@ export default function CookieBannerTrackersPage({
<Th>{__("Third party")}</Th>
<SortableTh field="SOURCE">{__("Source")}</SortableTh>
<Th>{__("Category")}</Th>
<Th>{__("Max Age")}</Th>
<SortableTh field="LAST_MATCHED_AT">{__("Last Matched")}</SortableTh>
<Th className="w-28" />
<Th className="w-px" />
</Tr>
</Thead>
<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.
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 {
ActionDropdown,
Badge,
Dropdown,
IconArrowBoxLeft,
DropdownItem,
IconPencil,
IconTrashCan,
Td,
@@ -26,21 +26,16 @@ import {
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 { useState } from "react";
import { graphql, useFragment, useMutation } from "react-relay";
import { ConnectionHandler } from "relay-runtime";
import type { MoveToCategoryDropdownQuery } from "#/__generated__/core/MoveToCategoryDropdownQuery.graphql";
import type { TrackerPatternRowDeleteMutation } from "#/__generated__/core/TrackerPatternRowDeleteMutation.graphql";
import type { TrackerPatternRowFragment$key } from "#/__generated__/core/TrackerPatternRowFragment.graphql";
import type { TrackerPatternRowMoveMutation } from "#/__generated__/core/TrackerPatternRowMoveMutation.graphql";
import type { TrackerPatternRowUpdateMutation } from "#/__generated__/core/TrackerPatternRowUpdateMutation.graphql";
import {
MoveToCategoryDropdown,
moveToCategoryDropdownQuery,
} from "./MoveToCategoryDropdown";
import { MoveToCategorySelect } from "./MoveToCategorySelect";
import { TrackerPatternRowEdit } from "./TrackerPatternRowEdit";
const trackerPatternFragment = graphql`
@@ -54,6 +49,7 @@ const trackerPatternFragment = graphql`
excluded
lastMatchedAt
cookieCategory {
id
name
}
thirdParty {
@@ -143,21 +139,9 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
const { __ } = useTranslate();
const { toast } = useToast();
const confirm = useConfirm();
const { cookieBannerId } = useParams<{ cookieBannerId: string }>();
const pattern = useFragment(trackerPatternFragment, patternKey);
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]
= 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 = () => {
updatePattern({
variables: {
@@ -323,10 +323,15 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
? <Badge variant={srcBadge.variant}>{srcBadge.label}</Badge>
: <span className="text-txt-tertiary">-</span>}
</Td>
<Td noLink>
<MoveToCategorySelect
currentCategoryId={pattern.cookieCategory?.id}
currentCategoryName={pattern.cookieCategory?.name}
onSelect={handleMoveWithConfirm}
/>
</Td>
<Td>
{pattern.cookieCategory
? <span>{pattern.cookieCategory.name}</span>
: <span className="text-txt-tertiary">-</span>}
<span>{humanizeSeconds(pattern.maxAgeSeconds ?? null)}</span>
</Td>
<Td>
{pattern.lastMatchedAt
@@ -337,7 +342,7 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
)
: <span className="text-txt-tertiary">-</span>}
</Td>
<Td>
<Td noLink className="w-px whitespace-nowrap">
<div className="flex items-center gap-1">
<button
type="button"
@@ -347,40 +352,21 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
>
<IconPencil size={14} />
</button>
<Dropdown
onOpenChange={handleCategoryDropdownOpen}
toggle={(
<button
type="button"
className="p-1 rounded cursor-pointer"
title={__("Move to category")}
>
<IconArrowBoxLeft size={14} />
</button>
)}
>
{categoryQueryRef && (
<Suspense>
<MoveToCategoryDropdown queryRef={categoryQueryRef} onMove={handleMove} />
</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>
<ActionDropdown>
<DropdownItem
icon={pattern.excluded ? EyeIcon : EyeSlashIcon}
onSelect={handleToggleExcluded}
>
{pattern.excluded ? __("Include") : __("Exclude")}
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onSelect={handleDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</div>
</Td>
</Tr>

View File

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