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:
@@ -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")}>
|
||||
|
||||
@@ -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="
|
||||
{category.name.toLowerCase()}
|
||||
{category.slug}
|
||||
"
|
||||
</code>
|
||||
</p>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user