mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-08 02:43:06 -05:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user