Compare commits

...

2 Commits

Author SHA1 Message Date
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
21 changed files with 173 additions and 74 deletions

View File

@@ -577,7 +577,7 @@ checksums:
environments/contacts/attribute_created_successfully: e9f90d366d817f2f1c81fb819c0e2f05
environments/contacts/attribute_description: e17686a22ffad04cc7bb70524ed4478b
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_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
environments/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f

View File

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

View File

@@ -613,7 +613,7 @@
"attribute_created_successfully": "Attribut erfolgreich erstellt",
"attribute_description": "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_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.",
"attribute_key_placeholder": "z. B. geburtsdatum",

View File

@@ -613,7 +613,7 @@
"attribute_created_successfully": "Attribute created successfully",
"attribute_description": "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_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
"attribute_key_placeholder": "e.g. date_of_birth",

View File

@@ -613,7 +613,7 @@
"attribute_created_successfully": "Atributo creado con éxito",
"attribute_description": "Descripción",
"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_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.",
"attribute_key_placeholder": "p. ej. fecha_de_nacimiento",

View File

@@ -613,7 +613,7 @@
"attribute_created_successfully": "Attribut créé avec succès",
"attribute_description": "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_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.",
"attribute_key_placeholder": "ex. date_de_naissance",

View File

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

View File

@@ -613,7 +613,7 @@
"attribute_created_successfully": "Attribuut succesvol aangemaakt",
"attribute_description": "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_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.",
"attribute_key_placeholder": "bijv. geboortedatum",

View File

@@ -613,7 +613,7 @@
"attribute_created_successfully": "Atributo criado com sucesso",
"attribute_description": "Descrição",
"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_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.",
"attribute_key_placeholder": "ex: data_de_nascimento",

View File

@@ -613,7 +613,7 @@
"attribute_created_successfully": "Atributo criado com sucesso",
"attribute_description": "Descrição",
"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_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.",
"attribute_key_placeholder": "ex. data_de_nascimento",

View File

@@ -613,7 +613,7 @@
"attribute_created_successfully": "Atribut creat cu succes",
"attribute_description": "Descriere",
"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_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.",
"attribute_key_placeholder": "ex: date_of_birth",

View File

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

View File

@@ -613,7 +613,7 @@
"attribute_created_successfully": "Attributet har skapats",
"attribute_description": "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_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.",
"attribute_key_placeholder": "t.ex. date_of_birth",

View File

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

View File

@@ -613,7 +613,7 @@
"attribute_created_successfully": "屬性建立成功",
"attribute_description": "描述",
"attribute_description_placeholder": "簡短描述",
"attribute_key": "金鑰",
"attribute_key": "屬性",
"attribute_key_cannot_be_changed": "建立後無法變更金鑰",
"attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。",
"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 { 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 }) => {
const t = await getTranslate();
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 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 (
<div className="space-y-6">
<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>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.email ? (
<span>{attributes.email}</span>
) : (
<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>
<AttributeRow label="email" value={attributes.email} notProvidedText={notProvidedText} />
<AttributeRow label="language" value={attributes.language} notProvidedText={notProvidedText} />
<AttributeRow label="userId" value={attributes.userId} notProvidedText={notProvidedText} isIdBadge />
<AttributeRow label="firstName" value={attributes.firstName} notProvidedText={notProvidedText} />
<AttributeRow label="lastName" value={attributes.lastName} notProvidedText={notProvidedText} />
<div>
<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>
</div>
{Object.entries(attributes)
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
.map(([key, attributeData]) => {
return (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{key}</dt>
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
</div>
);
})}
{customAttributes.map(([key, value]) => (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{key}</dt>
<dd className="mt-1 text-sm text-slate-900">{value}</dd>
</div>
))}
<hr />
<div>

View File

@@ -175,6 +175,23 @@ export const EditContactAttributesModal = ({
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent width="default" className="max-h-[90vh]">
@@ -235,7 +252,7 @@ export const EditContactAttributesModal = ({
<Button
type="button"
variant="outline"
disabled={["email", "userId", "firstName", "lastName"].includes(field.key)}
disabled={isDeleteDisabled(field.key)}
size="sm"
onClick={() => handleRemoveAttribute(index)}
className="h-10 w-10 p-0">

View File

@@ -11,30 +11,23 @@ import {
hasUserIdAttribute,
} 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 (
contactId: string,
currentAttributes: TContactAttributes,
submittedAttributes: TContactAttributes,
contactAttributeKeys: TContactAttributeKey[]
): Promise<{ success: boolean }> => {
): Promise<void> => {
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 currentKeys = new Set(Object.keys(currentAttributes));
const keysToDelete = Array.from(currentKeys).filter(
(key) => !submittedKeys.has(key) && !DEFAULT_ATTRIBUTES.has(key)
);
const keysToDelete = Object.keys(currentAttributes).filter((key) => !submittedKeys.has(key));
// Get attribute key IDs for deletion
const attributeKeyIdsToDelete = keysToDelete
.map((key) => contactAttributeKeyMap.get(key)?.id)
.filter((id): id is string => !!id);
// Delete attributes that were removed from the form (but not default attributes)
if (attributeKeyIdsToDelete.length > 0) {
await prisma.contactAttribute.deleteMany({
where: {
@@ -45,10 +38,6 @@ const deleteAttributes = async (
},
});
}
return {
success: true,
};
};
/**
@@ -161,8 +150,10 @@ export const updateAttributes = async (
// Delete attributes that were removed (only when explicitly requested)
// This is used by UI forms where all attributes are submitted
// 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) {
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys);
await deleteAttributes(contactId, currentAttributes, contactAttributes, contactAttributeKeys);
}
// Create lookup map for attribute keys

View File

@@ -1,4 +1,4 @@
import { v7 as uuidv7 } from "uuid";
import { createId } from "@paralleldrive/cuid2";
import type { SurveyElement, ValidationRule } from "./types";
/**
@@ -62,7 +62,7 @@ export function migrateOpenTextCharLimit(element: SurveyElement): void {
!hasMatchingRule(existingRules, "minLength", { min: element.charLimit.min })
) {
const newRule: ValidationRule = {
id: uuidv7(),
id: createId(),
type: "minLength",
params: { min: element.charLimit.min },
};
@@ -78,7 +78,7 @@ export function migrateOpenTextCharLimit(element: SurveyElement): void {
})
) {
const newRule: ValidationRule = {
id: uuidv7(),
id: createId(),
type: "maxLength",
params: { max: element.charLimit.max },
};
@@ -138,7 +138,7 @@ export function migrateFileUploadExtensions(element: SurveyElement): void {
if (!hasMatchingExtensionRule) {
// Create new fileExtensionIs rule
const newRule: ValidationRule = {
id: uuidv7(),
id: createId(),
type: "fileExtensionIs",
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: "Last Name", key: "lastName", isUnique: false, 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: "Last Name", key: "lastName", isUnique: false, type: "default" },
{ name: "userId", key: "userId", isUnique: true, type: "default" },
{ name: "Language", key: "language", isUnique: false, type: "default" },
],
},
},