feat: auto-fill safe attribute key from label (#7771)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
This commit is contained in:
Johannes
2026-04-19 23:44:10 -07:00
committed by GitHub
parent 65f5968fb1
commit 35b98863a4
3 changed files with 87 additions and 28 deletions
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { isSafeIdentifier } from "./safe-identifier";
import { isSafeIdentifier, toSafeIdentifier } from "./safe-identifier";
describe("safe-identifier", () => {
describe("isSafeIdentifier", () => {
@@ -32,4 +32,23 @@ describe("safe-identifier", () => {
expect(isSafeIdentifier("")).toBe(false);
});
});
describe("toSafeIdentifier", () => {
test("normalizes free-form labels into safe identifiers", () => {
expect(toSafeIdentifier("Date of Birth")).toBe("date_of_birth");
expect(toSafeIdentifier("Customer-ID")).toBe("customer_id");
expect(toSafeIdentifier(" Preferred Language ")).toBe("preferred_language");
expect(toSafeIdentifier("city__name")).toBe("city_name");
});
test("strips invalid leading characters until first lowercase letter", () => {
expect(toSafeIdentifier("123 Date")).toBe("date");
expect(toSafeIdentifier("__name")).toBe("name");
expect(toSafeIdentifier("99")).toBe("");
});
test("keeps already safe identifiers unchanged", () => {
expect(toSafeIdentifier("country_code")).toBe("country_code");
});
});
});
+38
View File
@@ -12,6 +12,44 @@ export const isSafeIdentifier = (value: string): boolean => {
return /^[a-z0-9_]+$/.test(value);
};
/**
* Converts a free-form string to a safe identifier candidate.
* The output only contains lowercase letters, numbers, and underscores.
* It also ensures the identifier starts with a lowercase letter by stripping invalid leading chars.
*/
export const toSafeIdentifier = (value: string): string => {
const normalized = value.trim().toLowerCase();
let safeIdentifier = "";
let shouldInsertUnderscore = false;
for (const char of normalized) {
const isLowercaseLetter = char >= "a" && char <= "z";
const isDigit = char >= "0" && char <= "9";
if (isLowercaseLetter || isDigit) {
if (shouldInsertUnderscore && safeIdentifier.length > 0) {
safeIdentifier += "_";
}
safeIdentifier += char;
shouldInsertUnderscore = false;
continue;
}
if (safeIdentifier.length > 0) {
shouldInsertUnderscore = true;
}
}
for (let i = 0; i < safeIdentifier.length; i++) {
const char = safeIdentifier[i];
if (char >= "a" && char <= "z") {
return safeIdentifier.slice(i);
}
}
return "";
};
/**
* Converts a snake_case string to Title Case for display as a label.
* Example: "job_description" -> "Job Description"
@@ -7,7 +7,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { formatSnakeCaseToTitleCase, isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { formatSnakeCaseToTitleCase, isSafeIdentifier, toSafeIdentifier } from "@/lib/utils/safe-identifier";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -57,25 +57,27 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
};
const handleNameChange = (value: string) => {
setFormData((prev) => ({ ...prev, name: value }));
if (keyError && formData.key) {
validateKey(formData.key);
const previousAutoKey = toSafeIdentifier(formData.name);
const newAutoKey = toSafeIdentifier(value);
const shouldAutoUpdateKey = !formData.key || formData.key === previousAutoKey;
setFormData((prev) => ({
...prev,
name: value,
key: shouldAutoUpdateKey ? newAutoKey : prev.key,
}));
if (shouldAutoUpdateKey && keyError) {
if (newAutoKey) {
validateKey(newAutoKey);
} else {
setKeyError("");
}
}
};
const handleKeyChange = (value: string) => {
const previousAutoLabel = formData.key ? formatSnakeCaseToTitleCase(formData.key) : "";
const newAutoLabel = value ? formatSnakeCaseToTitleCase(value) : "";
setFormData((prev) => {
// Auto-update name if it's empty or matches the previous auto-generated label
const shouldAutoUpdateName = !prev.name || prev.name === previousAutoLabel;
return {
...prev,
key: value,
name: shouldAutoUpdateName ? newAutoLabel : prev.name,
};
});
setFormData((prev) => ({ ...prev, key: value }));
validateKey(value);
};
@@ -163,6 +165,17 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
<form onSubmit={handleSubmit}>
<DialogBody>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.attribute_label")}
</label>
<Input
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t("environments.contacts.attribute_label_placeholder")}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.attribute_key")}
@@ -177,17 +190,6 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
<p className="text-xs text-slate-500">{t("environments.contacts.attribute_key_hint")}</p>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.attribute_label")}
</label>
<Input
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t("environments.contacts.attribute_label_placeholder")}
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.data_type")}