Compare commits

...

4 Commits

Author SHA1 Message Date
Johannes
c6cbcd8280 fix unit test 2026-02-03 11:52:09 +01:00
Johannes
23ef11d228 Merge branch 'main' of https://github.com/formbricks/formbricks into fix-lang-attribute 2026-02-03 11:49:58 +01:00
Johannes
6c07e71b47 add translations 2026-01-27 14:59:39 +04:00
Johannes
242b003048 feat: add language attribute and improve attributes section rendering
- Introduced a new "Language" attribute in the environment service and database seeding.
- Refactored the attributes section to use a reusable AttributeRow component for better maintainability.
- Updated localization for attribute key terminology from "Key" to "Attribute".
- Enhanced delete logic in the edit contact attributes modal to prevent deletion of essential attributes.
2026-01-21 16:47:23 +00:00
29 changed files with 216 additions and 107 deletions

View File

@@ -579,7 +579,7 @@ checksums:
environments/contacts/attribute_created_successfully: e9f90d366d817f2f1c81fb819c0e2f05 environments/contacts/attribute_created_successfully: e9f90d366d817f2f1c81fb819c0e2f05
environments/contacts/attribute_description: e17686a22ffad04cc7bb70524ed4478b environments/contacts/attribute_description: e17686a22ffad04cc7bb70524ed4478b
environments/contacts/attribute_description_placeholder: 05af83e4cfc6328476ef9719581e47af environments/contacts/attribute_description_placeholder: 05af83e4cfc6328476ef9719581e47af
environments/contacts/attribute_key: 3d1065ab98a1c2f1210507fd5c7bf515 environments/contacts/attribute_key: cc92f647873ba9e17cff57d4a59737bd
environments/contacts/attribute_key_cannot_be_changed: 0ced703e77a8e620276c1fa21fcc8900 environments/contacts/attribute_key_cannot_be_changed: 0ced703e77a8e620276c1fa21fcc8900
environments/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8 environments/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
environments/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f environments/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f

View File

@@ -167,6 +167,12 @@ export const createEnvironment = async (
description: "Your contact's last name", description: "Your contact's last name",
type: "default", type: "default",
}, },
{
key: "language",
name: "Language",
description: "The language preference of a contact",
type: "default",
},
], ],
}, },
}, },

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Attribut erfolgreich erstellt", "attribute_created_successfully": "Attribut erfolgreich erstellt",
"attribute_description": "Beschreibung", "attribute_description": "Beschreibung",
"attribute_description_placeholder": "Kurze Beschreibung", "attribute_description_placeholder": "Kurze Beschreibung",
"attribute_key": "Schlüssel", "attribute_key": "Attribut",
"attribute_key_cannot_be_changed": "Schlüssel kann nach der Erstellung nicht geändert werden", "attribute_key_cannot_be_changed": "Schlüssel kann nach der Erstellung nicht geändert werden",
"attribute_key_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.", "attribute_key_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.",
"attribute_key_placeholder": "z. B. geburtsdatum", "attribute_key_placeholder": "z. B. geburtsdatum",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Attribute created successfully", "attribute_created_successfully": "Attribute created successfully",
"attribute_description": "Description", "attribute_description": "Description",
"attribute_description_placeholder": "Short description", "attribute_description_placeholder": "Short description",
"attribute_key": "Key", "attribute_key": "Attribute",
"attribute_key_cannot_be_changed": "Key cannot be changed after creation", "attribute_key_cannot_be_changed": "Key cannot be changed after creation",
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.", "attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
"attribute_key_placeholder": "e.g. date_of_birth", "attribute_key_placeholder": "e.g. date_of_birth",
@@ -1267,14 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.", "darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format", "date_format": "Date format",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.", "days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"display_type": "Display type",
"dropdown": "Dropdown",
"delete_anyways": "Delete anyways", "delete_anyways": "Delete anyways",
"delete_block": "Delete block", "delete_block": "Delete block",
"delete_choice": "Delete choice", "delete_choice": "Delete choice",
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.", "disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey", "display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
"display_number_of_responses_for_survey": "Display number of responses for survey", "display_number_of_responses_for_survey": "Display number of responses for survey",
"display_type": "Display type",
"divide": "Divide /", "divide": "Divide /",
"does_not_contain": "Does not contain", "does_not_contain": "Does not contain",
"does_not_end_with": "Does not end with", "does_not_end_with": "Does not end with",
@@ -1282,6 +1281,7 @@
"does_not_include_all_of": "Does not include all of", "does_not_include_all_of": "Does not include all of",
"does_not_include_one_of": "Does not include one of", "does_not_include_one_of": "Does not include one of",
"does_not_start_with": "Does not start with", "does_not_start_with": "Does not start with",
"dropdown": "Dropdown",
"duplicate_block": "Duplicate block", "duplicate_block": "Duplicate block",
"duplicate_question": "Duplicate question", "duplicate_question": "Duplicate question",
"edit_link": "Edit link", "edit_link": "Edit link",
@@ -1414,11 +1414,11 @@
"limit_the_maximum_file_size": "Limit the maximum file size for uploads.", "limit_the_maximum_file_size": "Limit the maximum file size for uploads.",
"limit_upload_file_size_to": "Limit upload file size to", "limit_upload_file_size_to": "Limit upload file size to",
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.", "link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
"list": "List",
"load_segment": "Load segment", "load_segment": "Load segment",
"logic_error_warning": "Changing will cause logic errors", "logic_error_warning": "Changing will cause logic errors",
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question", "logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
"logo_settings": "Logo settings", "logo_settings": "Logo settings",
"list": "List",
"long_answer": "Long answer", "long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.", "long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label", "lower_label": "Lower Label",
@@ -3094,4 +3094,4 @@
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.", "usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)" "usability_score_name": "System Usability Score (SUS)"
} }
} }

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Atributo creado con éxito", "attribute_created_successfully": "Atributo creado con éxito",
"attribute_description": "Descripción", "attribute_description": "Descripción",
"attribute_description_placeholder": "Descripción breve", "attribute_description_placeholder": "Descripción breve",
"attribute_key": "Clave", "attribute_key": "Atributo",
"attribute_key_cannot_be_changed": "La clave no se puede cambiar después de la creación", "attribute_key_cannot_be_changed": "La clave no se puede cambiar después de la creación",
"attribute_key_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.", "attribute_key_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.",
"attribute_key_placeholder": "p. ej. fecha_de_nacimiento", "attribute_key_placeholder": "p. ej. fecha_de_nacimiento",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Attribut créé avec succès", "attribute_created_successfully": "Attribut créé avec succès",
"attribute_description": "Description", "attribute_description": "Description",
"attribute_description_placeholder": "Brève description", "attribute_description_placeholder": "Brève description",
"attribute_key": "Clé", "attribute_key": "Attribut",
"attribute_key_cannot_be_changed": "La clé ne peut pas être modifiée après la création", "attribute_key_cannot_be_changed": "La clé ne peut pas être modifiée après la création",
"attribute_key_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.", "attribute_key_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.",
"attribute_key_placeholder": "ex. date_de_naissance", "attribute_key_placeholder": "ex. date_de_naissance",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "属性を作成しました", "attribute_created_successfully": "属性を作成しました",
"attribute_description": "説明", "attribute_description": "説明",
"attribute_description_placeholder": "簡単な説明", "attribute_description_placeholder": "簡単な説明",
"attribute_key": "キー", "attribute_key": "属性",
"attribute_key_cannot_be_changed": "キーは作成後に変更できません", "attribute_key_cannot_be_changed": "キーは作成後に変更できません",
"attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。", "attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。",
"attribute_key_placeholder": "例: date_of_birth", "attribute_key_placeholder": "例: date_of_birth",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Attribuut succesvol aangemaakt", "attribute_created_successfully": "Attribuut succesvol aangemaakt",
"attribute_description": "Beschrijving", "attribute_description": "Beschrijving",
"attribute_description_placeholder": "Korte beschrijving", "attribute_description_placeholder": "Korte beschrijving",
"attribute_key": "Sleutel", "attribute_key": "Kenmerk",
"attribute_key_cannot_be_changed": "Sleutel kan niet worden gewijzigd na aanmaak", "attribute_key_cannot_be_changed": "Sleutel kan niet worden gewijzigd na aanmaak",
"attribute_key_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.", "attribute_key_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.",
"attribute_key_placeholder": "bijv. geboortedatum", "attribute_key_placeholder": "bijv. geboortedatum",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Atributo criado com sucesso", "attribute_created_successfully": "Atributo criado com sucesso",
"attribute_description": "Descrição", "attribute_description": "Descrição",
"attribute_description_placeholder": "Descrição curta", "attribute_description_placeholder": "Descrição curta",
"attribute_key": "Chave", "attribute_key": "atributo",
"attribute_key_cannot_be_changed": "A chave não pode ser alterada após a criação", "attribute_key_cannot_be_changed": "A chave não pode ser alterada após a criação",
"attribute_key_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.", "attribute_key_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.",
"attribute_key_placeholder": "ex: data_de_nascimento", "attribute_key_placeholder": "ex: data_de_nascimento",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Atributo criado com sucesso", "attribute_created_successfully": "Atributo criado com sucesso",
"attribute_description": "Descrição", "attribute_description": "Descrição",
"attribute_description_placeholder": "Descrição breve", "attribute_description_placeholder": "Descrição breve",
"attribute_key": "Chave", "attribute_key": "Atributo",
"attribute_key_cannot_be_changed": "A chave não pode ser alterada após a criação", "attribute_key_cannot_be_changed": "A chave não pode ser alterada após a criação",
"attribute_key_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.", "attribute_key_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.",
"attribute_key_placeholder": "ex. data_de_nascimento", "attribute_key_placeholder": "ex. data_de_nascimento",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Atribut creat cu succes", "attribute_created_successfully": "Atribut creat cu succes",
"attribute_description": "Descriere", "attribute_description": "Descriere",
"attribute_description_placeholder": "Descriere scurtă", "attribute_description_placeholder": "Descriere scurtă",
"attribute_key": "Cheie", "attribute_key": "Atribut",
"attribute_key_cannot_be_changed": "Cheia nu poate fi modificată după creare", "attribute_key_cannot_be_changed": "Cheia nu poate fi modificată după creare",
"attribute_key_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.", "attribute_key_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.",
"attribute_key_placeholder": "ex: date_of_birth", "attribute_key_placeholder": "ex: date_of_birth",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Атрибут успешно создан", "attribute_created_successfully": "Атрибут успешно создан",
"attribute_description": "Описание", "attribute_description": "Описание",
"attribute_description_placeholder": "Краткое описание", "attribute_description_placeholder": "Краткое описание",
"attribute_key": "Ключ", "attribute_key": "Атрибут",
"attribute_key_cannot_be_changed": "Ключ нельзя изменить после создания", "attribute_key_cannot_be_changed": "Ключ нельзя изменить после создания",
"attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.", "attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.",
"attribute_key_placeholder": "например, date_of_birth", "attribute_key_placeholder": "например, date_of_birth",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "Attributet har skapats", "attribute_created_successfully": "Attributet har skapats",
"attribute_description": "Beskrivning", "attribute_description": "Beskrivning",
"attribute_description_placeholder": "Kort beskrivning", "attribute_description_placeholder": "Kort beskrivning",
"attribute_key": "Nyckel", "attribute_key": "Attribut",
"attribute_key_cannot_be_changed": "Nyckeln kan inte ändras efter skapande", "attribute_key_cannot_be_changed": "Nyckeln kan inte ändras efter skapande",
"attribute_key_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.", "attribute_key_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.",
"attribute_key_placeholder": "t.ex. date_of_birth", "attribute_key_placeholder": "t.ex. date_of_birth",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "属性创建成功", "attribute_created_successfully": "属性创建成功",
"attribute_description": "描述", "attribute_description": "描述",
"attribute_description_placeholder": "简短描述", "attribute_description_placeholder": "简短描述",
"attribute_key": "", "attribute_key": "属性",
"attribute_key_cannot_be_changed": "创建后键不可更改", "attribute_key_cannot_be_changed": "创建后键不可更改",
"attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。", "attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。",
"attribute_key_placeholder": "例如date_of_birth", "attribute_key_placeholder": "例如date_of_birth",

View File

@@ -615,7 +615,7 @@
"attribute_created_successfully": "屬性建立成功", "attribute_created_successfully": "屬性建立成功",
"attribute_description": "描述", "attribute_description": "描述",
"attribute_description_placeholder": "簡短描述", "attribute_description_placeholder": "簡短描述",
"attribute_key": "金鑰", "attribute_key": "屬性",
"attribute_key_cannot_be_changed": "建立後無法變更金鑰", "attribute_key_cannot_be_changed": "建立後無法變更金鑰",
"attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。", "attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。",
"attribute_key_placeholder": "例如date_of_birth", "attribute_key_placeholder": "例如date_of_birth",

View File

@@ -4,6 +4,34 @@ import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attribut
import { getContact } from "@/modules/ee/contacts/lib/contacts"; import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge"; import { IdBadge } from "@/modules/ui/components/id-badge";
const DEFAULT_ATTRIBUTE_KEYS = ["email", "language", "userId", "firstName", "lastName"] as const;
interface AttributeRowProps {
label: string;
value: string | undefined;
notProvidedText: string;
isIdBadge?: boolean;
}
const AttributeRow = ({ label, value, notProvidedText, isIdBadge = false }: AttributeRowProps) => {
const renderValue = () => {
if (!value) {
return <span className="text-slate-300">{notProvidedText}</span>;
}
if (isIdBadge) {
return <IdBadge id={value} />;
}
return <span>{value}</span>;
};
return (
<div>
<dt className="text-sm font-medium text-slate-500">{label}</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{renderValue()}</dd>
</div>
);
};
export const AttributesSection = async ({ contactId }: { contactId: string }) => { export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate(); const t = await getTranslate();
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]); const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
@@ -14,55 +42,34 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
const responses = await getResponsesByContactId(contactId); const responses = await getResponsesByContactId(contactId);
const numberOfResponses = responses?.length || 0; const numberOfResponses = responses?.length || 0;
const notProvidedText = t("environments.contacts.not_provided");
const customAttributes = Object.entries(attributes).filter(
([key]) => !DEFAULT_ATTRIBUTE_KEYS.includes(key as (typeof DEFAULT_ATTRIBUTE_KEYS)[number])
);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">{t("common.attributes")}</h2> <h2 className="text-lg font-bold text-slate-700">{t("common.attributes")}</h2>
<div>
<dt className="text-sm font-medium text-slate-500">email</dt> <AttributeRow label="email" value={attributes.email} notProvidedText={notProvidedText} />
<dd className="ph-no-capture mt-1 text-sm text-slate-900"> <AttributeRow label="language" value={attributes.language} notProvidedText={notProvidedText} />
{attributes.email ? ( <AttributeRow label="userId" value={attributes.userId} notProvidedText={notProvidedText} isIdBadge />
<span>{attributes.email}</span> <AttributeRow label="firstName" value={attributes.firstName} notProvidedText={notProvidedText} />
) : ( <AttributeRow label="lastName" value={attributes.lastName} notProvidedText={notProvidedText} />
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">language</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.language ? (
<span>{attributes.language}</span>
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">userId</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? (
<IdBadge id={attributes.userId} />
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
</dd>
</div>
<div> <div>
<dt className="text-sm font-medium text-slate-500">contactId</dt> <dt className="text-sm font-medium text-slate-500">contactId</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd> <dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd>
</div> </div>
{Object.entries(attributes) {customAttributes.map(([key, value]) => (
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language") <div key={key}>
.map(([key, attributeData]) => { <dt className="text-sm font-medium text-slate-500">{key}</dt>
return ( <dd className="mt-1 text-sm text-slate-900">{value}</dd>
<div key={key}> </div>
<dt className="text-sm font-medium text-slate-500">{key}</dt> ))}
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
</div>
);
})}
<hr /> <hr />
<div> <div>

View File

@@ -175,6 +175,23 @@ export const EditContactAttributesModal = ({
setOpen(newOpen); setOpen(newOpen);
}; };
// Memoize email/userId existence checks for delete button logic
// A contact MUST have either email or userId, so we prevent deletion if it would violate this
const { hasEmail, hasUserId } = useMemo(() => {
const emailAttr = watchedAttributes.find((attr) => attr.key === "email");
const userIdAttr = watchedAttributes.find((attr) => attr.key === "userId");
return {
hasEmail: !!emailAttr?.value?.trim(),
hasUserId: !!userIdAttr?.value?.trim(),
};
}, [watchedAttributes]);
const isDeleteDisabled = (key: string): boolean => {
if (key === "email") return !hasUserId;
if (key === "userId") return !hasEmail;
return false;
};
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent width="default" className="max-h-[90vh]"> <DialogContent width="default" className="max-h-[90vh]">
@@ -235,7 +252,7 @@ export const EditContactAttributesModal = ({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={["email", "userId", "firstName", "lastName"].includes(field.key)} disabled={isDeleteDisabled(field.key)}
size="sm" size="sm"
onClick={() => handleRemoveAttribute(index)} onClick={() => handleRemoveAttribute(index)}
className="h-10 w-10 p-0"> className="h-10 w-10 p-0">

View File

@@ -11,30 +11,27 @@ import {
hasUserIdAttribute, hasUserIdAttribute,
} from "@/modules/ee/contacts/lib/contact-attributes"; } from "@/modules/ee/contacts/lib/contact-attributes";
// Default/system attributes that should not be deleted even if missing from payload
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
const deleteAttributes = async ( const deleteAttributes = async (
contactId: string, contactId: string,
currentAttributes: TContactAttributes, currentAttributes: TContactAttributes,
submittedAttributes: TContactAttributes, submittedAttributes: TContactAttributes,
contactAttributeKeys: TContactAttributeKey[] contactAttributeKeys: TContactAttributeKey[]
): Promise<{ success: boolean }> => { ): Promise<void> => {
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack])); const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
// Determine which attributes should be deleted (exist in DB but not in payload, and not default attributes) // Determine which attributes should be deleted (exist in DB but not in payload)
const submittedKeys = new Set(Object.keys(submittedAttributes)); const submittedKeys = new Set(Object.keys(submittedAttributes));
const currentKeys = new Set(Object.keys(currentAttributes)); const keysToDelete = Object.keys(currentAttributes).filter((key) => !submittedKeys.has(key));
const keysToDelete = Array.from(currentKeys).filter(
(key) => !submittedKeys.has(key) && !DEFAULT_ATTRIBUTES.has(key)
);
// Get attribute key IDs for deletion // Get attribute key IDs for deletion, but exclude default attributes
const attributeKeyIdsToDelete = keysToDelete const attributeKeyIdsToDelete = keysToDelete
.map((key) => contactAttributeKeyMap.get(key)?.id) .map((key) => {
const attributeKey = contactAttributeKeyMap.get(key);
// Only include non-default attributes for deletion
return attributeKey?.type === "custom" ? attributeKey.id : null;
})
.filter((id): id is string => !!id); .filter((id): id is string => !!id);
// Delete attributes that were removed from the form (but not default attributes)
if (attributeKeyIdsToDelete.length > 0) { if (attributeKeyIdsToDelete.length > 0) {
await prisma.contactAttribute.deleteMany({ await prisma.contactAttribute.deleteMany({
where: { where: {
@@ -45,10 +42,6 @@ const deleteAttributes = async (
}, },
}); });
} }
return {
success: true,
};
}; };
/** /**
@@ -161,8 +154,10 @@ export const updateAttributes = async (
// Delete attributes that were removed (only when explicitly requested) // Delete attributes that were removed (only when explicitly requested)
// This is used by UI forms where all attributes are submitted // This is used by UI forms where all attributes are submitted
// For API calls, we want merge behavior by default (only update passed attributes) // For API calls, we want merge behavior by default (only update passed attributes)
// We use contactAttributes (not contactAttributesParam) because it includes validation adjustments
// (e.g., preserving email/userId when both would be empty)
if (deleteRemovedAttributes) { if (deleteRemovedAttributes) {
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys); await deleteAttributes(contactId, currentAttributes, contactAttributes, contactAttributeKeys);
} }
// Create lookup map for attribute keys // Create lookup map for attribute keys

View File

@@ -10,7 +10,11 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils"; import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n"; import { TI18nString } from "@formbricks/types/i18n";
import { TMultipleChoiceOptionDisplayType, TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements"; import {
TMultipleChoiceOptionDisplayType,
TSurveyElementTypeEnum,
TSurveyMultipleChoiceElement,
} from "@formbricks/types/surveys/elements";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types"; import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";

View File

@@ -1,4 +1,4 @@
import { v7 as uuidv7 } from "uuid"; import { createId } from "@paralleldrive/cuid2";
import type { SurveyElement, ValidationRule } from "./types"; import type { SurveyElement, ValidationRule } from "./types";
/** /**
@@ -62,7 +62,7 @@ export function migrateOpenTextCharLimit(element: SurveyElement): void {
!hasMatchingRule(existingRules, "minLength", { min: element.charLimit.min }) !hasMatchingRule(existingRules, "minLength", { min: element.charLimit.min })
) { ) {
const newRule: ValidationRule = { const newRule: ValidationRule = {
id: uuidv7(), id: createId(),
type: "minLength", type: "minLength",
params: { min: element.charLimit.min }, params: { min: element.charLimit.min },
}; };
@@ -78,7 +78,7 @@ export function migrateOpenTextCharLimit(element: SurveyElement): void {
}) })
) { ) {
const newRule: ValidationRule = { const newRule: ValidationRule = {
id: uuidv7(), id: createId(),
type: "maxLength", type: "maxLength",
params: { max: element.charLimit.max }, params: { max: element.charLimit.max },
}; };
@@ -138,7 +138,7 @@ export function migrateFileUploadExtensions(element: SurveyElement): void {
if (!hasMatchingExtensionRule) { if (!hasMatchingExtensionRule) {
// Create new fileExtensionIs rule // Create new fileExtensionIs rule
const newRule: ValidationRule = { const newRule: ValidationRule = {
id: uuidv7(), id: createId(),
type: "fileExtensionIs", type: "fileExtensionIs",
params: { extensions: [...extensions] }, params: { extensions: [...extensions] },
}; };

View File

@@ -0,0 +1,76 @@
/* eslint-disable no-constant-condition -- Required for the while loop */
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Required for a while loop here */
import { createId } from "@paralleldrive/cuid2";
import { logger } from "@formbricks/logger";
import type { MigrationScript } from "../../src/scripts/migration-runner";
export const addLanguageAttributeKey: MigrationScript = {
type: "data",
id: "l4n8g7u6a9g2e0k3e1y4a5d6",
name: "20260121161018_add_language_attribute_key",
run: async ({ tx }) => {
const BATCH_SIZE = 10000;
let skip = 0;
let totalEnvironmentsProcessed = 0;
let totalLanguageKeysAdded = 0;
logger.info("Starting migration to add language attribute key to all environments");
while (true) {
// Fetch environments in batches
const environments = await tx.$queryRaw<{ id: string }[]>`
SELECT id FROM "Environment" LIMIT ${BATCH_SIZE} OFFSET ${skip}
`;
if (environments.length === 0) {
break;
}
logger.info(`Processing ${environments.length.toString()} environments (batch starting at ${skip})`);
// Process each environment
for (const env of environments) {
try {
// Insert language attribute key if it doesn't exist
await tx.$executeRaw`
INSERT INTO "ContactAttributeKey" (
"id", "created_at", "updated_at", "key", "name", "description", "type", "isUnique", "environmentId"
) VALUES (
${createId()},
NOW(),
NOW(),
'language',
'Language',
'The language preference of a contact',
'default',
false,
${env.id}
)
ON CONFLICT ("key", "environmentId") DO NOTHING
`;
totalLanguageKeysAdded++;
} catch (error) {
logger.error(`Failed to add language attribute key for environment ${env.id}: ${error}`);
throw error;
}
}
skip += environments.length;
totalEnvironmentsProcessed += environments.length;
}
// Verify migration
const [{ total_language_keys: totalLanguageKeys }] = await tx.$queryRaw<
[{ total_language_keys: number }]
>`
SELECT COUNT(*)::integer AS total_language_keys
FROM "ContactAttributeKey"
WHERE "key" = 'language'
`;
logger.info(`Migration completed successfully!`);
logger.info(`Total environments processed: ${totalEnvironmentsProcessed.toString()}`);
logger.info(`Total language attribute keys in database: ${totalLanguageKeys.toString()}`);
},
};

View File

@@ -455,6 +455,7 @@ async function main(): Promise<void> {
{ name: "First Name", key: "firstName", isUnique: false, type: "default" }, { name: "First Name", key: "firstName", isUnique: false, type: "default" },
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" }, { name: "Last Name", key: "lastName", isUnique: false, type: "default" },
{ name: "userId", key: "userId", isUnique: true, type: "default" }, { name: "userId", key: "userId", isUnique: true, type: "default" },
{ name: "Language", key: "language", isUnique: false, type: "default" },
], ],
}, },
}, },
@@ -474,6 +475,7 @@ async function main(): Promise<void> {
{ name: "First Name", key: "firstName", isUnique: false, type: "default" }, { name: "First Name", key: "firstName", isUnique: false, type: "default" },
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" }, { name: "Last Name", key: "lastName", isUnique: false, type: "default" },
{ name: "userId", key: "userId", isUnique: true, type: "default" }, { name: "userId", key: "userId", isUnique: true, type: "default" },
{ name: "Language", key: "language", isUnique: false, type: "default" },
], ],
}, },
}, },

View File

@@ -148,15 +148,15 @@ function DropdownVariant({
<Button <Button
variant="outline" variant="outline"
disabled={disabled} disabled={disabled}
className="rounded-input w-full justify-between bg-option-bg rounded-option border border-option-border my-0 h-input" className="rounded-input bg-option-bg rounded-option border-option-border h-input my-0 w-full justify-between border"
aria-invalid={Boolean(errorMessage)} aria-invalid={Boolean(errorMessage)}
aria-label={headline}> aria-label={headline}>
<span className="truncate font-input font-input-weight text-input-text">{displayText}</span> <span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50 label-headline" /> <ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)] max-h-[300px] overflow-y-auto" className="bg-option-bg max-h-[300px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
align="start"> align="start">
{options {options
.filter((option) => option.id !== "none") .filter((option) => option.id !== "none")

View File

@@ -160,15 +160,15 @@ function SingleSelect({
<Button <Button
variant="outline" variant="outline"
disabled={disabled} disabled={disabled}
className="rounded-input w-full justify-between bg-option-bg rounded-option border border-option-border my-0 h-input" className="rounded-input bg-option-bg rounded-option border-option-border h-input my-0 w-full justify-between border"
aria-invalid={Boolean(errorMessage)} aria-invalid={Boolean(errorMessage)}
aria-label={headline}> aria-label={headline}>
<span className="truncate font-input font-input-weight text-input-text">{displayText}</span> <span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50 label-headline" /> <ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="bg-option-bg w-[var(--radix-dropdown-menu-trigger-width)] max-h-[300px] overflow-y-auto" className="bg-option-bg max-h-[300px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto"
align="start"> align="start">
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}> <DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
{options {options
@@ -193,7 +193,9 @@ function SingleSelect({
id={`${inputId}-${otherOptionId}`} id={`${inputId}-${otherOptionId}`}
dir={dir} dir={dir}
disabled={disabled}> disabled={disabled}>
<span className="font-input font-input-weight text-input-text">{otherValue || otherOptionLabel}</span> <span className="font-input font-input-weight text-input-text">
{otherValue || otherOptionLabel}
</span>
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
) : null} ) : null}
{options {options
@@ -279,7 +281,7 @@ function SingleSelect({
aria-required={required} aria-required={required}
/> />
<span <span
className={cn("mr-3 ml-3 grow", optionLabelClassName)} className={cn("ml-3 mr-3 grow", optionLabelClassName)}
style={{ fontSize: "var(--fb-option-font-size)" }}> style={{ fontSize: "var(--fb-option-font-size)" }}>
{otherOptionLabel} {otherOptionLabel}
</span> </span>

View File

@@ -29,13 +29,13 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md",
className className
)} )}
{...props} {...props}
/> />
</div> </div>
</DropdownMenuPrimitive.Portal > </DropdownMenuPrimitive.Portal>
); );
} }
@@ -58,7 +58,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...props} {...props}
@@ -76,12 +76,12 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_*]:cursor-pointer", "focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_*]:cursor-pointer [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
checked={checked} checked={checked}
{...props}> {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center label-headline"> <span className="label-headline pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
@@ -104,11 +104,11 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_*]:cursor-pointer", "focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_*]:cursor-pointer [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...props}> {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center label-headline"> <span className="label-headline pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
@@ -175,12 +175,12 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus-visible:outline-none data-[inset]:pl-8 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className className
)} )}
{...props}> {...props}>
{children} {children}
<ChevronRightIcon className="ml-auto label-headline size-4" /> <ChevronRightIcon className="label-headline ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
); );
} }

View File

@@ -22,7 +22,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
style={{ fontSize: "var(--fb-input-font-size)" }} style={{ fontSize: "var(--fb-input-font-size)" }}
className={cn( className={cn(
// Layout and behavior // Layout and behavior
"flex min-w-0 border transition-[color,box-shadow] outline-none", "flex min-w-0 border outline-none transition-[color,box-shadow]",
// Customizable styles via CSS variables (using Tailwind theme extensions) // Customizable styles via CSS variables (using Tailwind theme extensions)
"w-input h-input", "w-input h-input",
"bg-input-bg border-input-border rounded-input", "bg-input-bg border-input-border rounded-input",

View File

@@ -13,7 +13,7 @@ function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.J
style={{ fontSize: "var(--fb-input-font-size)" }} style={{ fontSize: "var(--fb-input-font-size)" }}
dir={dir} dir={dir}
className={cn( className={cn(
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", "w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text field-sizing-content flex min-h-16 border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
{...props} {...props}

View File

@@ -21,6 +21,8 @@
"respondents_will_not_see_this_card": "Respondents will not see this card", "respondents_will_not_see_this_card": "Respondents will not see this card",
"retry": "Retry", "retry": "Retry",
"retrying": "Retrying...", "retrying": "Retrying...",
"select_option": "Select an option",
"select_options": "Select options",
"sending_responses": "Sending responses...", "sending_responses": "Sending responses...",
"takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}", "takes_less_than_x_minutes": "{count, plural, one {Takes less than 1 minute} other {Takes less than {count} minutes}}",
"takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}", "takes_x_minutes": "{count, plural, one {Takes 1 minute} other {Takes {count} minutes}}",
@@ -28,9 +30,7 @@
"terms_of_service": "Terms of Service", "terms_of_service": "Terms of Service",
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.", "the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
"they_will_be_redirected_immediately": "They will be redirected immediately", "they_will_be_redirected_immediately": "They will be redirected immediately",
"your_feedback_is_stuck": "Your feedback is stuck :(", "your_feedback_is_stuck": "Your feedback is stuck :("
"select_option": "Select an option",
"select_options": "Select options"
}, },
"errors": { "errors": {
"all_options_must_be_ranked": "Please rank all options", "all_options_must_be_ranked": "Please rank all options",
@@ -78,4 +78,4 @@
"value_must_not_contain": "Value must not contain {value}", "value_must_not_contain": "Value must not contain {value}",
"value_must_not_equal": "Value must not equal {value}" "value_must_not_equal": "Value must not equal {value}"
} }
} }

View File

@@ -70,4 +70,4 @@
"vite-plugin-dts": "4.5.3", "vite-plugin-dts": "4.5.3",
"vite-tsconfig-paths": "5.1.4" "vite-tsconfig-paths": "5.1.4"
} }
} }