mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-07 10:32:50 -06:00
Compare commits
4 Commits
fix/add-ne
...
fix-lang-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6cbcd8280 | ||
|
|
23ef11d228 | ||
|
|
6c07e71b47 | ||
|
|
242b003048 |
@@ -579,7 +579,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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
@@ -1267,14 +1267,13 @@
|
||||
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
|
||||
"date_format": "Date format",
|
||||
"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_block": "Delete block",
|
||||
"delete_choice": "Delete choice",
|
||||
"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_number_of_responses_for_survey": "Display number of responses for survey",
|
||||
"display_type": "Display type",
|
||||
"divide": "Divide /",
|
||||
"does_not_contain": "Does not contain",
|
||||
"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_one_of": "Does not include one of",
|
||||
"does_not_start_with": "Does not start with",
|
||||
"dropdown": "Dropdown",
|
||||
"duplicate_block": "Duplicate block",
|
||||
"duplicate_question": "Duplicate question",
|
||||
"edit_link": "Edit link",
|
||||
@@ -1414,11 +1414,11 @@
|
||||
"limit_the_maximum_file_size": "Limit the maximum file size for uploads.",
|
||||
"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.",
|
||||
"list": "List",
|
||||
"load_segment": "Load segment",
|
||||
"logic_error_warning": "Changing will cause logic errors",
|
||||
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
|
||||
"logo_settings": "Logo settings",
|
||||
"list": "List",
|
||||
"long_answer": "Long answer",
|
||||
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
|
||||
"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_score_name": "System Usability Score (SUS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -615,7 +615,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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -11,30 +11,27 @@ 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
|
||||
// Get attribute key IDs for deletion, but exclude default attributes
|
||||
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);
|
||||
|
||||
// Delete attributes that were removed from the form (but not default attributes)
|
||||
if (attributeKeyIdsToDelete.length > 0) {
|
||||
await prisma.contactAttribute.deleteMany({
|
||||
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)
|
||||
// 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
|
||||
|
||||
@@ -10,7 +10,11 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
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 { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
|
||||
@@ -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] },
|
||||
};
|
||||
|
||||
@@ -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()}`);
|
||||
},
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -148,15 +148,15 @@ function DropdownVariant({
|
||||
<Button
|
||||
variant="outline"
|
||||
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-label={headline}>
|
||||
<span className="truncate font-input font-input-weight text-input-text">{displayText}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50 label-headline" />
|
||||
<span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
|
||||
<ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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">
|
||||
{options
|
||||
.filter((option) => option.id !== "none")
|
||||
|
||||
@@ -160,15 +160,15 @@ function SingleSelect({
|
||||
<Button
|
||||
variant="outline"
|
||||
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-label={headline}>
|
||||
<span className="truncate font-input font-input-weight text-input-text">{displayText}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50 label-headline" />
|
||||
<span className="font-input font-input-weight text-input-text truncate">{displayText}</span>
|
||||
<ChevronDown className="label-headline ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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">
|
||||
<DropdownMenuRadioGroup value={selectedValue} onValueChange={onChange}>
|
||||
{options
|
||||
@@ -193,7 +193,9 @@ function SingleSelect({
|
||||
id={`${inputId}-${otherOptionId}`}
|
||||
dir={dir}
|
||||
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>
|
||||
) : null}
|
||||
{options
|
||||
@@ -279,7 +281,7 @@ function SingleSelect({
|
||||
aria-required={required}
|
||||
/>
|
||||
<span
|
||||
className={cn("mr-3 ml-3 grow", optionLabelClassName)}
|
||||
className={cn("ml-3 mr-3 grow", optionLabelClassName)}
|
||||
style={{ fontSize: "var(--fb-option-font-size)" }}>
|
||||
{otherOptionLabel}
|
||||
</span>
|
||||
|
||||
@@ -29,13 +29,13 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuPrimitive.Portal >
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -76,12 +76,12 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
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
|
||||
)}
|
||||
checked={checked}
|
||||
{...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>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
@@ -104,11 +104,11 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
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
|
||||
)}
|
||||
{...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>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
@@ -175,12 +175,12 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
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
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto label-headline size-4" />
|
||||
<ChevronRightIcon className="label-headline ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
className={cn(
|
||||
// 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)
|
||||
"w-input h-input",
|
||||
"bg-input-bg border-input-border rounded-input",
|
||||
|
||||
@@ -13,7 +13,7 @@ function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.J
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
dir={dir}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"respondents_will_not_see_this_card": "Respondents will not see this card",
|
||||
"retry": "Retry",
|
||||
"retrying": "Retrying...",
|
||||
"select_option": "Select an option",
|
||||
"select_options": "Select options",
|
||||
"sending_responses": "Sending responses...",
|
||||
"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}}",
|
||||
@@ -28,9 +30,7 @@
|
||||
"terms_of_service": "Terms of Service",
|
||||
"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",
|
||||
"your_feedback_is_stuck": "Your feedback is stuck :(",
|
||||
"select_option": "Select an option",
|
||||
"select_options": "Select options"
|
||||
"your_feedback_is_stuck": "Your feedback is stuck :("
|
||||
},
|
||||
"errors": {
|
||||
"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_equal": "Value must not equal {value}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,4 +70,4 @@
|
||||
"vite-plugin-dts": "4.5.3",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user