Add slug to cookie categories for stable consent identifiers

The category slug provides a stable, URL-safe key used as the
data-cookie-consent attribute value and consent data key, replacing
the fragile category name. This prevents breakage when categories
are renamed.

- Add slug column with unique-per-banner constraint and backfill migration
- Add Slug validator (lowercase alphanumeric + hyphens)
- Propagate slug through GraphQL schema, service layer, and snapshot
- Update console UI with slug field in create/edit forms
- Switch cookie-banner widget to use slug as consent data keys

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-23 19:07:11 +04:00
parent bb39daebc5
commit 165b9ad9d3
19 changed files with 218 additions and 22 deletions

View File

@@ -40,6 +40,7 @@ const createMutation = graphql`
node {
id
name
slug
description
kind
rank
@@ -78,8 +79,27 @@ export function CategoryDialog({
const [create, isCreating] = useMutation<CategoryDialogCreateMutation>(createMutation);
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const [description, setDescription] = useState("");
const handleNameChange = (value: string) => {
setName(value);
if (!slugTouched) {
setSlug(
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, ""),
);
}
};
const handleSlugChange = (value: string) => {
setSlugTouched(true);
setSlug(value);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -88,6 +108,7 @@ export function CategoryDialog({
input: {
cookieBannerId,
name,
slug,
description,
rank: nextRank,
},
@@ -113,7 +134,11 @@ export function CategoryDialog({
<form onSubmit={handleSubmit}>
<DialogContent padded className="space-y-4">
<Field label={__("Name")}>
<Input value={name} onChange={e => setName(e.target.value)} required />
<Input value={name} onChange={e => handleNameChange(e.target.value)} required />
</Field>
<Field label={__("Slug")} help={__("Used as the data-cookie-consent attribute value")}>
<Input value={slug} onChange={e => handleSlugChange(e.target.value)} required pattern="[a-z0-9]+(-[a-z0-9]+)*" />
</Field>
<Field label={__("Description")}>

View File

@@ -56,6 +56,7 @@ export const categorySectionFragment = graphql`
fragment CategorySectionFragment on CookieCategory {
id
name
slug
description
kind
cookies(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
@@ -93,6 +94,7 @@ const updateCategoryMutation = graphql`
cookieCategory {
id
name
slug
description
rank
updatedAt
@@ -235,12 +237,13 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
const cookies = category.cookies.edges.map(e => e.node);
const isMutating = isUpdating || isCreating || isUpdatingCookie;
const handleSaveCategory = (name: string, description: string) => {
const handleSaveCategory = (name: string, slug: string, description: string) => {
updateCategory({
variables: {
input: {
cookieCategoryId: category.id,
name,
slug,
description,
},
},
@@ -476,6 +479,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
? (
<EditCategoryForm
name={category.name}
slug={category.slug}
description={category.description}
isUpdating={isUpdating}
onSave={handleSaveCategory}
@@ -517,7 +521,7 @@ export function CategorySection({ categoryKey, onDelete }: CategorySectionProps)
{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
data-cookie-consent=&quot;
{category.name.toLowerCase()}
{category.slug}
&quot;
</code>
</p>

View File

@@ -18,14 +18,16 @@ import { useState } from "react";
interface EditCategoryFormProps {
name: string;
slug: string;
description: string;
isUpdating: boolean;
onSave: (name: string, description: string) => void;
onSave: (name: string, slug: string, description: string) => void;
onCancel: () => void;
}
export function EditCategoryForm({
name,
slug,
description,
isUpdating,
onSave,
@@ -33,6 +35,7 @@ export function EditCategoryForm({
}: EditCategoryFormProps) {
const { __ } = useTranslate();
const [editName, setEditName] = useState(name);
const [editSlug, setEditSlug] = useState(slug);
const [editDescription, setEditDescription] = useState(description);
return (
@@ -42,6 +45,12 @@ export function EditCategoryForm({
onChange={e => setEditName(e.target.value)}
placeholder={__("Category name")}
/>
<Input
value={editSlug}
onChange={e => setEditSlug(e.target.value)}
placeholder={__("Category slug")}
pattern="[a-z0-9]+(-[a-z0-9]+)*"
/>
<Textarea
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
@@ -50,7 +59,7 @@ export function EditCategoryForm({
/>
<div className="flex items-center gap-2">
<Button
onClick={() => onSave(editName, editDescription)}
onClick={() => onSave(editName, editSlug, editDescription)}
disabled={isUpdating}
>
{isUpdating ? __("Saving...") : __("Save")}