mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
fix: refactor nested ternaries and improve code quality
- Extract nested ternary operations into independent if-else statements in selected-row-settings.tsx - Use export...from syntax for default re-exports in attributes page - Improve code readability and maintainability
This commit is contained in:
@@ -144,6 +144,7 @@ checksums:
|
||||
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
|
||||
common/copy_code: 704c13d9bc01caad29a1cf3179baa111
|
||||
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
|
||||
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
|
||||
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
|
||||
common/count_responses: 690118a456c01c5b4d437ae82b50b131
|
||||
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
||||
@@ -271,6 +272,7 @@ checksums:
|
||||
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
|
||||
common/option_id: ed21d97b8ab035ba89fb3f5f073229bd
|
||||
common/option_ids: e68c25215ce81ea7ad82ff7be0a0bf2d
|
||||
common/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
common/or: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||
common/organization: 3dc8489af7e74121f65ce6d9677bc94d
|
||||
common/organization_id: ef09b71c84a25b5da02a23c77e68a335
|
||||
@@ -567,12 +569,32 @@ checksums:
|
||||
environments/connect/insert_this_code_into_the_head_tag_of_your_website: c4bec1089168efe0fa565397bd7cb115
|
||||
environments/connect/subtitle: d913df50a1c6b7acc283484195f3732a
|
||||
environments/connect/waiting_for_your_signal: 9b0a62d5b61c04674596643d2f9f5c09
|
||||
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_already_exists: c1ac75f3324781bdc165cf4567ff6497
|
||||
environments/contacts/attribute_key_cannot_be_changed: 0ced703e77a8e620276c1fa21fcc8900
|
||||
environments/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
|
||||
environments/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f
|
||||
environments/contacts/attribute_key_required: 75f22558e9bafe7da2a549e75fab5f75
|
||||
environments/contacts/attribute_key_safe_identifier_required: aece7d4708065ec5f110b82fc061621d
|
||||
environments/contacts/attribute_label: a5c71bf158481233f8215dbd38cc196b
|
||||
environments/contacts/attribute_label_placeholder: bf5106cb14d2ec0c21e7d8b4ab1f3a93
|
||||
environments/contacts/attribute_updated_successfully: 0e64422156c29940cd4dab2f9d1f40b2
|
||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||
environments/contacts/create_key: 0d385c354af8963acbe35cd646710f86
|
||||
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
||||
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
||||
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
|
||||
environments/contacts/delete_contact_confirmation: 4304d36277daa205b4aa09f5e0d494ab
|
||||
environments/contacts/delete_contact_confirmation_with_quotas: 7c0e2e223ca55101270ac2988c53e616
|
||||
environments/contacts/edit_attribute: a53676332a19e6470239bf5bdff19851
|
||||
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
|
||||
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
|
||||
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
|
||||
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
||||
@@ -583,6 +605,7 @@ checksums:
|
||||
environments/contacts/personal_link_generated_but_clipboard_failed: 4eb1e208e729bd5ac00c33f72fc38d53
|
||||
environments/contacts/personal_survey_link: 5b3f1afc53733718c4ed5b1443b6a604
|
||||
environments/contacts/please_select_a_survey: 465aa7048773079c8ffdde8b333b78eb
|
||||
environments/contacts/search_attribute_keys: 2811dd44b98a6a74e55246bfff6100d4
|
||||
environments/contacts/search_contact: 020205a93846ab3e12c203ac4fa97c12
|
||||
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
||||
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
||||
|
||||
90
apps/web/lib/utils/safe-identifier.test.ts
Normal file
90
apps/web/lib/utils/safe-identifier.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isSafeIdentifier, toSafeIdentifier } from "./safe-identifier";
|
||||
|
||||
describe("safe-identifier", () => {
|
||||
describe("isSafeIdentifier", () => {
|
||||
test("returns true for valid identifiers starting with lowercase letter", () => {
|
||||
expect(isSafeIdentifier("email")).toBe(true);
|
||||
expect(isSafeIdentifier("user_name")).toBe(true);
|
||||
expect(isSafeIdentifier("attr123")).toBe(true);
|
||||
expect(isSafeIdentifier("test_key_123")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for identifiers starting with uppercase letter", () => {
|
||||
expect(isSafeIdentifier("Email")).toBe(false);
|
||||
expect(isSafeIdentifier("User_Name")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for identifiers starting with number", () => {
|
||||
expect(isSafeIdentifier("123attr")).toBe(false);
|
||||
expect(isSafeIdentifier("01region")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for identifiers with invalid characters", () => {
|
||||
expect(isSafeIdentifier("email-address")).toBe(false);
|
||||
expect(isSafeIdentifier("user:name")).toBe(false);
|
||||
expect(isSafeIdentifier("user name")).toBe(false);
|
||||
expect(isSafeIdentifier("user(name)")).toBe(false);
|
||||
expect(isSafeIdentifier("email@domain")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isSafeIdentifier("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSafeIdentifier", () => {
|
||||
test("converts valid strings to safe identifiers", () => {
|
||||
expect(toSafeIdentifier("email")).toBe("email");
|
||||
expect(toSafeIdentifier("user_name")).toBe("user_name");
|
||||
});
|
||||
|
||||
test("converts spaces to underscores", () => {
|
||||
expect(toSafeIdentifier("email address")).toBe("email_address");
|
||||
expect(toSafeIdentifier("user name")).toBe("user_name");
|
||||
});
|
||||
|
||||
test("converts special characters to underscores", () => {
|
||||
expect(toSafeIdentifier("user:name")).toBe("user_name");
|
||||
expect(toSafeIdentifier("user-name")).toBe("user_name");
|
||||
expect(toSafeIdentifier("user(name)")).toBe("user_name");
|
||||
});
|
||||
|
||||
test("handles strings starting with numbers", () => {
|
||||
expect(toSafeIdentifier("123attr")).toBe("attr_123attr");
|
||||
expect(toSafeIdentifier("01region")).toBe("attr_01region");
|
||||
});
|
||||
|
||||
test("removes accents and normalizes", () => {
|
||||
expect(toSafeIdentifier("café")).toBe("cafe");
|
||||
expect(toSafeIdentifier("naïve")).toBe("naive");
|
||||
expect(toSafeIdentifier("résumé")).toBe("resume");
|
||||
});
|
||||
|
||||
test("collapses multiple underscores", () => {
|
||||
expect(toSafeIdentifier("user__name")).toBe("user_name");
|
||||
expect(toSafeIdentifier("email___address")).toBe("email_address");
|
||||
});
|
||||
|
||||
test("removes leading and trailing underscores", () => {
|
||||
expect(toSafeIdentifier("_email_")).toBe("email");
|
||||
expect(toSafeIdentifier("__user__")).toBe("user");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(toSafeIdentifier("")).toBe("");
|
||||
});
|
||||
|
||||
test("handles strings that become empty after sanitization", () => {
|
||||
expect(toSafeIdentifier("!!!")).toBe("attr_key");
|
||||
expect(toSafeIdentifier("---")).toBe("attr_key");
|
||||
});
|
||||
|
||||
test("converts to lowercase", () => {
|
||||
expect(toSafeIdentifier("Email")).toBe("email");
|
||||
expect(toSafeIdentifier("USER_NAME")).toBe("user_name");
|
||||
expect(toSafeIdentifier("TestKey123")).toBe("testkey123");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
48
apps/web/lib/utils/safe-identifier.ts
Normal file
48
apps/web/lib/utils/safe-identifier.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Validates that a string is a safe identifier.
|
||||
* Safe identifiers can only contain lowercase letters, numbers, and underscores.
|
||||
* They cannot start with a number.
|
||||
*
|
||||
* This matches the validation used for survey variable names (see formbricks#5342).
|
||||
*/
|
||||
export const isSafeIdentifier = (value: string): boolean => {
|
||||
// Must start with a lowercase letter
|
||||
if (!/^[a-z]/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
// Can only contain lowercase letters, numbers, and underscores
|
||||
return /^[a-z0-9_]+$/.test(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a string to a safe identifier by:
|
||||
* - Converting to lowercase
|
||||
* - Replacing invalid characters with underscores
|
||||
* - Removing leading/trailing underscores
|
||||
* - Ensuring it starts with a letter (prepending 'attr_' if it starts with a number)
|
||||
*/
|
||||
export const toSafeIdentifier = (value: string): string => {
|
||||
if (!value) return "";
|
||||
|
||||
// Convert to lowercase and replace invalid characters with underscores
|
||||
let safe = value
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replaceAll(/[\u0300-\u036f]/g, "") // Remove accents
|
||||
.replaceAll(/[^a-z\d_]/g, "_") // Replace invalid chars with underscore
|
||||
.replaceAll(/_+/g, "_") // Collapse multiple underscores
|
||||
.replaceAll(/(^_+|_+$)/g, ""); // Remove leading/trailing underscores
|
||||
|
||||
// If it starts with a number, prepend 'attr_'
|
||||
if (/^\d/.test(safe)) {
|
||||
safe = `attr_${safe}`;
|
||||
}
|
||||
|
||||
// If empty after sanitization, return a default
|
||||
if (!safe) {
|
||||
safe = "attr_key";
|
||||
}
|
||||
|
||||
return safe;
|
||||
};
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "Kopieren",
|
||||
"copy_code": "Code kopieren",
|
||||
"copy_link": "Link kopieren",
|
||||
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
|
||||
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
|
||||
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
|
||||
"create_new_organization": "Neue Organisation erstellen",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
|
||||
"option_id": "Option-ID",
|
||||
"option_ids": "Option-IDs",
|
||||
"optional": "Optional",
|
||||
"or": "oder",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Organisations-ID",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "Warte auf ein Signal von dir..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "Attribut erfolgreich erstellt",
|
||||
"attribute_description": "Beschreibung",
|
||||
"attribute_description_placeholder": "Kurze Beschreibung",
|
||||
"attribute_key": "Schlüssel",
|
||||
"attribute_key_already_exists": "Attributschlüssel existiert bereits",
|
||||
"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",
|
||||
"attribute_key_required": "Schlüssel ist erforderlich",
|
||||
"attribute_key_safe_identifier_required": "Schlüssel muss ein sicherer Identifikator sein: nur Kleinbuchstaben, Zahlen und Unterstriche, und muss mit einem Buchstaben beginnen",
|
||||
"attribute_label": "Bezeichnung",
|
||||
"attribute_label_placeholder": "z. B. Geburtsdatum",
|
||||
"attribute_updated_successfully": "Attribut erfolgreich aktualisiert",
|
||||
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
|
||||
"contact_not_found": "Kein solcher Kontakt gefunden",
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||
"create_attribute": "Attribut erstellen",
|
||||
"create_key": "Schlüssel erstellen",
|
||||
"create_new_attribute": "Neues Attribut erstellen",
|
||||
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Dadurch wird das ausgewählte Attribut gelöscht. Alle mit diesem Attribut verknüpften Kontaktdaten gehen verloren.} other {Dadurch werden die ausgewählten Attribute gelöscht. Alle mit diesen Attributen verknüpften Kontaktdaten gehen verloren.}}",
|
||||
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.} other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesen Kontakten verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn diesen Kontakten Antworten haben, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
|
||||
"edit_attribute": "Attribut bearbeiten",
|
||||
"edit_attribute_description": "Aktualisieren Sie die Bezeichnung und Beschreibung für dieses Attribut.",
|
||||
"generate_personal_link": "Persönlichen Link generieren",
|
||||
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu generieren.",
|
||||
"no_published_link_surveys_available": "Keine veröffentlichten Link-Umfragen verfügbar. Bitte veröffentliche zuerst eine Link-Umfrage.",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "Persönlicher Link wurde generiert, konnte aber nicht in die Zwischenablage kopiert werden: {url}",
|
||||
"personal_survey_link": "Link zur persönlichen Umfrage",
|
||||
"please_select_a_survey": "Bitte wähle eine Umfrage aus",
|
||||
"search_attribute_keys": "Attributschlüssel suchen...",
|
||||
"search_contact": "Kontakt suchen",
|
||||
"select_a_survey": "Wähle eine Umfrage aus",
|
||||
"select_attribute": "Attribut auswählen",
|
||||
|
||||
@@ -173,6 +173,7 @@
|
||||
"copy_link": "Copy Link",
|
||||
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
|
||||
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
|
||||
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
|
||||
"create_new_organization": "Create new organization",
|
||||
"create_project": "Create project",
|
||||
"create_segment": "Create segment",
|
||||
@@ -275,6 +276,7 @@
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
"name": "Name",
|
||||
"optional": "Optional",
|
||||
"new": "New",
|
||||
"new_version_available": "Formbricks {version} is here. Upgrade now!",
|
||||
"next": "Next",
|
||||
@@ -620,8 +622,29 @@
|
||||
"personal_survey_link": "Personal Survey Link",
|
||||
"please_select_a_survey": "Please select a survey",
|
||||
"search_contact": "Search contact",
|
||||
"search_attribute_keys": "Search attribute keys...",
|
||||
"select_a_survey": "Select a survey",
|
||||
"select_attribute": "Select Attribute",
|
||||
"create_attribute": "Create attribute",
|
||||
"create_new_attribute": "Create new attribute",
|
||||
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
|
||||
"create_key": "Create Key",
|
||||
"edit_attribute": "Edit Attribute",
|
||||
"edit_attribute_description": "Update the label and description for this attribute.",
|
||||
"attribute_key": "Key",
|
||||
"attribute_key_placeholder": "e.g. date_of_birth",
|
||||
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
|
||||
"attribute_key_required": "Key is required",
|
||||
"attribute_key_safe_identifier_required": "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
|
||||
"attribute_key_already_exists": "Attribute key already exists",
|
||||
"attribute_key_cannot_be_changed": "Key cannot be changed after creation",
|
||||
"attribute_label": "Label",
|
||||
"attribute_label_placeholder": "e.g. Date of Birth",
|
||||
"attribute_description": "Description",
|
||||
"attribute_description_placeholder": "Short description",
|
||||
"attribute_created_successfully": "Attribute created successfully",
|
||||
"attribute_updated_successfully": "Attribute updated successfully",
|
||||
"delete_attribute_confirmation": "{value, plural, one {This will delete the selected attribute. Any contact data associated with this attribute will be lost.} other {This will delete the selected attributes. Any contact data associated with these attributes will be lost.}}",
|
||||
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
|
||||
"unlock_contacts_title": "Unlock contacts with a higher plan",
|
||||
"upload_contacts_modal_attributes_description": "Map the columns in your CSV to the attributes in Formbricks.",
|
||||
@@ -2973,4 +2996,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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "Copiar",
|
||||
"copy_code": "Copiar código",
|
||||
"copy_link": "Copiar enlace",
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {{value} contacto} other {{value} contactos}}",
|
||||
"count_responses": "{value, plural, one {{value} respuesta} other {{value} respuestas}}",
|
||||
"create_new_organization": "Crear organización nueva",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
|
||||
"option_id": "ID de opción",
|
||||
"option_ids": "IDs de opciones",
|
||||
"optional": "Opcional",
|
||||
"or": "o",
|
||||
"organization": "Organización",
|
||||
"organization_id": "ID de organización",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "Esperando tu señal..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "Atributo creado con éxito",
|
||||
"attribute_description": "Descripción",
|
||||
"attribute_description_placeholder": "Descripción breve",
|
||||
"attribute_key": "Clave",
|
||||
"attribute_key_already_exists": "La clave del atributo ya existe",
|
||||
"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",
|
||||
"attribute_key_required": "La clave es obligatoria",
|
||||
"attribute_key_safe_identifier_required": "La clave debe ser un identificador seguro: solo letras minúsculas, números y guiones bajos, y debe empezar con una letra",
|
||||
"attribute_label": "Etiqueta",
|
||||
"attribute_label_placeholder": "p. ej. fecha de nacimiento",
|
||||
"attribute_updated_successfully": "Atributo actualizado con éxito",
|
||||
"contact_deleted_successfully": "Contacto eliminado correctamente",
|
||||
"contact_not_found": "No se ha encontrado dicho contacto",
|
||||
"contacts_table_refresh": "Actualizar contactos",
|
||||
"contacts_table_refresh_success": "Contactos actualizados correctamente",
|
||||
"create_attribute": "Crear atributo",
|
||||
"create_key": "Crear clave",
|
||||
"create_new_attribute": "Crear atributo nuevo",
|
||||
"create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Esto eliminará el atributo seleccionado. Se perderán todos los datos de contacto asociados con este atributo.} other {Esto eliminará los atributos seleccionados. Se perderán todos los datos de contacto asociados con estos atributos.}}",
|
||||
"delete_contact_confirmation": "Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con este contacto. Cualquier segmentación y personalización basada en los datos de este contacto se perderá. Si este contacto tiene respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.} other {Esto eliminará todas las respuestas de encuestas y atributos de contacto asociados con estos contactos. Cualquier segmentación y personalización basada en los datos de estos contactos se perderá. Si estos contactos tienen respuestas que cuentan para las cuotas de encuesta, los recuentos de cuota se reducirán pero los límites de cuota permanecerán sin cambios.}}",
|
||||
"edit_attribute": "Editar atributo",
|
||||
"edit_attribute_description": "Actualiza la etiqueta y la descripción de este atributo.",
|
||||
"generate_personal_link": "Generar enlace personal",
|
||||
"generate_personal_link_description": "Selecciona una encuesta publicada para generar un enlace personalizado para este contacto.",
|
||||
"no_published_link_surveys_available": "No hay encuestas de enlace publicadas disponibles. Por favor, publica primero una encuesta de enlace.",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "Enlace personal generado pero falló al copiar al portapapeles: {url}",
|
||||
"personal_survey_link": "Enlace personal de encuesta",
|
||||
"please_select_a_survey": "Por favor, selecciona una encuesta",
|
||||
"search_attribute_keys": "Buscar claves de atributo...",
|
||||
"search_contact": "Buscar contacto",
|
||||
"select_a_survey": "Selecciona una encuesta",
|
||||
"select_attribute": "Seleccionar atributo",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "Copier",
|
||||
"copy_code": "Copier le code",
|
||||
"copy_link": "Copier le lien",
|
||||
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attributs}}",
|
||||
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
|
||||
"count_responses": "{value, plural, other {# réponses}}",
|
||||
"create_new_organization": "Créer une nouvelle organisation",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
|
||||
"option_id": "Identifiant de l'option",
|
||||
"option_ids": "Identifiants des options",
|
||||
"optional": "Facultatif",
|
||||
"or": "ou",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Identifiant de l'organisation",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "En attente de votre signal..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "Attribut créé avec succès",
|
||||
"attribute_description": "Description",
|
||||
"attribute_description_placeholder": "Brève description",
|
||||
"attribute_key": "Clé",
|
||||
"attribute_key_already_exists": "La clé d'attribut existe déjà",
|
||||
"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",
|
||||
"attribute_key_required": "La clé est requise",
|
||||
"attribute_key_safe_identifier_required": "La clé doit être un identifiant sûr : uniquement des lettres minuscules, des chiffres et des underscores, et doit commencer par une lettre",
|
||||
"attribute_label": "Étiquette",
|
||||
"attribute_label_placeholder": "ex. Date de naissance",
|
||||
"attribute_updated_successfully": "Attribut mis à jour avec succès",
|
||||
"contact_deleted_successfully": "Contact supprimé avec succès",
|
||||
"contact_not_found": "Aucun contact trouvé",
|
||||
"contacts_table_refresh": "Actualiser les contacts",
|
||||
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
||||
"create_attribute": "Créer un attribut",
|
||||
"create_key": "Créer une clé",
|
||||
"create_new_attribute": "Créer un nouvel attribut",
|
||||
"create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Cela supprimera l'attribut sélectionné. Toutes les données de contact associées à cet attribut seront perdues.} other {Cela supprimera les attributs sélectionnés. Toutes les données de contact associées à ces attributs seront perdues.}}",
|
||||
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}",
|
||||
"edit_attribute": "Modifier l'attribut",
|
||||
"edit_attribute_description": "Mettez à jour l'étiquette et la description de cet attribut.",
|
||||
"generate_personal_link": "Générer un lien personnel",
|
||||
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
|
||||
"no_published_link_surveys_available": "Aucune enquête par lien publiée n'est disponible. Veuillez d'abord publier une enquête par lien.",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "Lien personnel généré mais échec de la copie dans le presse-papiers : {url}",
|
||||
"personal_survey_link": "Lien vers le sondage personnel",
|
||||
"please_select_a_survey": "Veuillez sélectionner une enquête",
|
||||
"search_attribute_keys": "Rechercher des clés d'attribut...",
|
||||
"search_contact": "Rechercher un contact",
|
||||
"select_a_survey": "Sélectionner une enquête",
|
||||
"select_attribute": "Sélectionner un attribut",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "コピー",
|
||||
"copy_code": "コードをコピー",
|
||||
"copy_link": "リンクをコピー",
|
||||
"count_attributes": "{value, plural, other {{value}個の属性}}",
|
||||
"count_contacts": "{count, plural, other {# 件の連絡先}}",
|
||||
"count_responses": "{count, plural, other {# 件の回答}}",
|
||||
"create_new_organization": "新しい組織を作成",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
|
||||
"option_id": "オプションID",
|
||||
"option_ids": "オプションID",
|
||||
"optional": "任意",
|
||||
"or": "または",
|
||||
"organization": "組織",
|
||||
"organization_id": "組織ID",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "あなたの信号を待っています..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "属性を作成しました",
|
||||
"attribute_description": "説明",
|
||||
"attribute_description_placeholder": "簡単な説明",
|
||||
"attribute_key": "キー",
|
||||
"attribute_key_already_exists": "属性キーは既に存在します",
|
||||
"attribute_key_cannot_be_changed": "キーは作成後に変更できません",
|
||||
"attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。",
|
||||
"attribute_key_placeholder": "例: date_of_birth",
|
||||
"attribute_key_required": "キーは必須です",
|
||||
"attribute_key_safe_identifier_required": "キーは安全な識別子である必要があります: 小文字のアルファベット、数字、アンダースコアのみ使用可能で、アルファベットで始める必要があります",
|
||||
"attribute_label": "ラベル",
|
||||
"attribute_label_placeholder": "例: 生年月日",
|
||||
"attribute_updated_successfully": "属性を更新しました",
|
||||
"contact_deleted_successfully": "連絡先を正常に削除しました",
|
||||
"contact_not_found": "そのような連絡先は見つかりません",
|
||||
"contacts_table_refresh": "連絡先を更新",
|
||||
"contacts_table_refresh_success": "連絡先を正常に更新しました",
|
||||
"create_attribute": "属性を作成",
|
||||
"create_key": "キーを作成",
|
||||
"create_new_attribute": "新しい属性を作成",
|
||||
"create_new_attribute_description": "セグメンテーション用の新しい属性を作成します。",
|
||||
"delete_attribute_confirmation": "{value, plural, one {選択した属性を削除します。この属性に関連付けられたすべてのコンタクトデータは失われます。} other {選択した属性を削除します。これらの属性に関連付けられたすべてのコンタクトデータは失われます。}}",
|
||||
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
|
||||
"edit_attribute": "属性を編集",
|
||||
"edit_attribute_description": "この属性のラベルと説明を更新します。",
|
||||
"generate_personal_link": "個人リンクを生成",
|
||||
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
|
||||
"no_published_link_surveys_available": "公開されたリンクフォームはありません。まずリンクフォームを公開してください。",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "個人用リンクは生成されましたが、クリップボードへのコピーに失敗しました: {url}",
|
||||
"personal_survey_link": "個人調査リンク",
|
||||
"please_select_a_survey": "フォームを選択してください",
|
||||
"search_attribute_keys": "属性キーを検索...",
|
||||
"search_contact": "連絡先を検索",
|
||||
"select_a_survey": "フォームを選択",
|
||||
"select_attribute": "属性を選択",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "Kopiëren",
|
||||
"copy_code": "Kopieer code",
|
||||
"copy_link": "Kopieer link",
|
||||
"count_attributes": "{value, plural, one {{value} attribuut} other {{value} attributen}}",
|
||||
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacten}}",
|
||||
"count_responses": "{value, plural, one {{value} reactie} other {{value} reacties}}",
|
||||
"create_new_organization": "Creëer een nieuwe organisatie",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
|
||||
"option_id": "Optie-ID",
|
||||
"option_ids": "Optie-ID's",
|
||||
"optional": "Optioneel",
|
||||
"or": "of",
|
||||
"organization": "Organisatie",
|
||||
"organization_id": "Organisatie-ID",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "Wachten op uw signaal..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "Attribuut succesvol aangemaakt",
|
||||
"attribute_description": "Beschrijving",
|
||||
"attribute_description_placeholder": "Korte beschrijving",
|
||||
"attribute_key": "Sleutel",
|
||||
"attribute_key_already_exists": "Attribuutsleutel bestaat al",
|
||||
"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",
|
||||
"attribute_key_required": "Sleutel is verplicht",
|
||||
"attribute_key_safe_identifier_required": "Sleutel moet een veilige identifier zijn: alleen kleine letters, cijfers en onderstrepingstekens, en moet beginnen met een letter",
|
||||
"attribute_label": "Label",
|
||||
"attribute_label_placeholder": "bijv. Geboortedatum",
|
||||
"attribute_updated_successfully": "Attribuut succesvol bijgewerkt",
|
||||
"contact_deleted_successfully": "Contact succesvol verwijderd",
|
||||
"contact_not_found": "Er is geen dergelijk contact gevonden",
|
||||
"contacts_table_refresh": "Vernieuw contacten",
|
||||
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
|
||||
"create_attribute": "Attribuut aanmaken",
|
||||
"create_key": "Sleutel aanmaken",
|
||||
"create_new_attribute": "Nieuw attribuut aanmaken",
|
||||
"create_new_attribute_description": "Maak een nieuw attribuut aan voor segmentatiedoeleinden.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Dit verwijdert het geselecteerde attribuut. Alle contactgegevens die aan dit attribuut zijn gekoppeld, gaan verloren.} other {Dit verwijdert de geselecteerde attributen. Alle contactgegevens die aan deze attributen zijn gekoppeld, gaan verloren.}}",
|
||||
"delete_contact_confirmation": "Hierdoor worden alle enquêtereacties en contactkenmerken verwijderd die aan dit contact zijn gekoppeld. Elke targeting en personalisatie op basis van de gegevens van dit contact gaat verloren.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dit verwijdert alle enquêteresultaten en contactattributen die aan dit contact zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van dit contact gaan verloren. Als dit contact reacties heeft die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.} other {Dit verwijdert alle enquêteresultaten en contactattributen die aan deze contacten zijn gekoppeld. Alle targeting en personalisatie op basis van de gegevens van deze contacten gaan verloren. Als deze contacten reacties hebben die meetellen voor enquêtekvota, worden de quotawaarden verlaagd maar blijven de limieten ongewijzigd.}}",
|
||||
"edit_attribute": "Attribuut bewerken",
|
||||
"edit_attribute_description": "Werk het label en de beschrijving voor dit attribuut bij.",
|
||||
"generate_personal_link": "Persoonlijke link genereren",
|
||||
"generate_personal_link_description": "Selecteer een gepubliceerde enquête om een gepersonaliseerde link voor dit contact te genereren.",
|
||||
"no_published_link_surveys_available": "Geen gepubliceerde link-enquêtes beschikbaar. Publiceer eerst een link-enquête.",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "Persoonlijke link gegenereerd maar kopiëren naar klembord mislukt: {url}",
|
||||
"personal_survey_link": "Persoonlijke enquêtelink",
|
||||
"please_select_a_survey": "Selecteer een enquête",
|
||||
"search_attribute_keys": "Zoek attribuutsleutels...",
|
||||
"search_contact": "Zoek contactpersoon",
|
||||
"select_a_survey": "Selecteer een enquête",
|
||||
"select_attribute": "Selecteer Kenmerk",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "Copiar",
|
||||
"copy_code": "Copiar código",
|
||||
"copy_link": "Copiar Link",
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
|
||||
"count_responses": "{value, plural, other {# respostas}}",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
|
||||
"option_id": "ID da opção",
|
||||
"option_ids": "IDs da Opção",
|
||||
"optional": "Opcional",
|
||||
"or": "ou",
|
||||
"organization": "organização",
|
||||
"organization_id": "ID da Organização",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "Esperando seu sinal..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "Atributo criado com sucesso",
|
||||
"attribute_description": "Descrição",
|
||||
"attribute_description_placeholder": "Descrição curta",
|
||||
"attribute_key": "Chave",
|
||||
"attribute_key_already_exists": "A chave do atributo já existe",
|
||||
"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",
|
||||
"attribute_key_required": "A chave é obrigatória",
|
||||
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e underscores, e deve começar com uma letra",
|
||||
"attribute_label": "Etiqueta",
|
||||
"attribute_label_placeholder": "ex: Data de nascimento",
|
||||
"attribute_updated_successfully": "Atributo atualizado com sucesso",
|
||||
"contact_deleted_successfully": "Contato excluído com sucesso",
|
||||
"contact_not_found": "Nenhum contato encontrado",
|
||||
"contacts_table_refresh": "Atualizar contatos",
|
||||
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
||||
"create_attribute": "Criar atributo",
|
||||
"create_key": "Criar chave",
|
||||
"create_new_attribute": "Criar novo atributo",
|
||||
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Isso excluirá o atributo selecionado. Todos os dados de contato associados a este atributo serão perdidos.} other {Isso excluirá os atributos selecionados. Todos os dados de contato associados a estes atributos serão perdidos.}}",
|
||||
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos. Se este contato tiver respostas que contam para cotas da pesquisa, as contagens das cotas serão reduzidas, mas os limites das cotas permanecerão inalterados.}}",
|
||||
"edit_attribute": "Editar atributo",
|
||||
"edit_attribute_description": "Atualize a etiqueta e a descrição deste atributo.",
|
||||
"generate_personal_link": "Gerar link pessoal",
|
||||
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
|
||||
"no_published_link_surveys_available": "Não há pesquisas de link publicadas disponíveis. Por favor, publique uma pesquisa de link primeiro.",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "Link pessoal gerado, mas falha ao copiar para a área de transferência: {url}",
|
||||
"personal_survey_link": "Link da pesquisa pessoal",
|
||||
"please_select_a_survey": "Por favor, selecione uma pesquisa",
|
||||
"search_attribute_keys": "Buscar chaves de atributos...",
|
||||
"search_contact": "Buscar contato",
|
||||
"select_a_survey": "Selecione uma pesquisa",
|
||||
"select_attribute": "Selecionar Atributo",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "Copiar",
|
||||
"copy_code": "Copiar código",
|
||||
"copy_link": "Copiar Link",
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
|
||||
"count_responses": "{value, plural, other {# respostas}}",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
|
||||
"option_id": "ID de Opção",
|
||||
"option_ids": "IDs de Opção",
|
||||
"optional": "Opcional",
|
||||
"or": "ou",
|
||||
"organization": "Organização",
|
||||
"organization_id": "ID da Organização",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "À espera do seu sinal..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "Atributo criado com sucesso",
|
||||
"attribute_description": "Descrição",
|
||||
"attribute_description_placeholder": "Descrição breve",
|
||||
"attribute_key": "Chave",
|
||||
"attribute_key_already_exists": "A chave do atributo já existe",
|
||||
"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",
|
||||
"attribute_key_required": "A chave é obrigatória",
|
||||
"attribute_key_safe_identifier_required": "A chave deve ser um identificador seguro: apenas letras minúsculas, números e sublinhados, e deve começar com uma letra",
|
||||
"attribute_label": "Etiqueta",
|
||||
"attribute_label_placeholder": "ex. Data de nascimento",
|
||||
"attribute_updated_successfully": "Atributo atualizado com sucesso",
|
||||
"contact_deleted_successfully": "Contacto eliminado com sucesso",
|
||||
"contact_not_found": "Nenhum contacto encontrado",
|
||||
"contacts_table_refresh": "Atualizar contactos",
|
||||
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
||||
"create_attribute": "Criar atributo",
|
||||
"create_key": "Criar chave",
|
||||
"create_new_attribute": "Criar novo atributo",
|
||||
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Isto irá eliminar o atributo selecionado. Todos os dados de contacto associados a este atributo serão perdidos.} other {Isto irá eliminar os atributos selecionados. Todos os dados de contacto associados a estes atributos serão perdidos.}}",
|
||||
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isto irá eliminar todas as respostas das pesquisas e os atributos de contacto associados a este contacto. Qualquer segmentação e personalização baseados nos dados deste contacto serão perdidos. Se este contacto tiver respostas que contribuam para as quotas das pesquisas, as contagens de quotas serão reduzidas, mas os limites das quotas permanecerão inalterados.}}",
|
||||
"edit_attribute": "Editar atributo",
|
||||
"edit_attribute_description": "Atualize a etiqueta e a descrição deste atributo.",
|
||||
"generate_personal_link": "Gerar Link Pessoal",
|
||||
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
|
||||
"no_published_link_surveys_available": "Não existem inquéritos de link publicados disponíveis. Por favor, publique primeiro um inquérito de link.",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "Link pessoal gerado mas falha ao copiar para a área de transferência: {url}",
|
||||
"personal_survey_link": "Link do inquérito pessoal",
|
||||
"please_select_a_survey": "Por favor, selecione um inquérito",
|
||||
"search_attribute_keys": "Pesquisar chaves de atributos...",
|
||||
"search_contact": "Procurar contacto",
|
||||
"select_a_survey": "Selecione um inquérito",
|
||||
"select_attribute": "Selecionar Atributo",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "Copiază",
|
||||
"copy_code": "Copiază codul",
|
||||
"copy_link": "Copiază legătura",
|
||||
"count_attributes": "{value, plural, one {{value} atribut} few {{value} atribute} other {{value} de atribute}}",
|
||||
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
|
||||
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
|
||||
"create_new_organization": "Creează organizație nouă",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
|
||||
"option_id": "ID opțiune",
|
||||
"option_ids": "ID-uri opțiuni",
|
||||
"optional": "Opțional",
|
||||
"or": "sau",
|
||||
"organization": "Organizație",
|
||||
"organization_id": "ID Organizație",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "Așteptăm semnalul dumneavoastră..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "Atribut creat cu succes",
|
||||
"attribute_description": "Descriere",
|
||||
"attribute_description_placeholder": "Descriere scurtă",
|
||||
"attribute_key": "Cheie",
|
||||
"attribute_key_already_exists": "Cheia atributului există deja",
|
||||
"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",
|
||||
"attribute_key_required": "Cheia este obligatorie",
|
||||
"attribute_key_safe_identifier_required": "Cheia trebuie să fie un identificator sigur: doar litere mici, cifre și caractere de subliniere, și trebuie să înceapă cu o literă",
|
||||
"attribute_label": "Etichetă",
|
||||
"attribute_label_placeholder": "ex: Data nașterii",
|
||||
"attribute_updated_successfully": "Atribut actualizat cu succes",
|
||||
"contact_deleted_successfully": "Contact șters cu succes",
|
||||
"contact_not_found": "Nu a fost găsit niciun contact",
|
||||
"contacts_table_refresh": "Reîmprospătare contacte",
|
||||
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
|
||||
"create_attribute": "Creează atribut",
|
||||
"create_key": "Creează cheie",
|
||||
"create_new_attribute": "Creează atribut nou",
|
||||
"create_new_attribute_description": "Creează un atribut nou pentru segmentare.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Acest lucru va șterge atributul selectat. Orice date de contact asociate cu acest atribut vor fi pierdute.} few {Acest lucru va șterge atributele selectate. Orice date de contact asociate cu aceste atribute vor fi pierdute.} other {Acest lucru va șterge atributele selectate. Orice date de contact asociate cu aceste atribute vor fi pierdute.}}",
|
||||
"delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Această acțiune va șterge toate răspunsurile chestionarului și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute. Dacă acest contact are răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} other {Aceste acțiuni vor șterge toate răspunsurile chestionarului și atributele de contact asociate cu acești contacți. Orice țintire și personalizare bazată pe datele acestor contacți vor fi pierdute. Dacă acești contacți au răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} }",
|
||||
"edit_attribute": "Editează atributul",
|
||||
"edit_attribute_description": "Actualizează eticheta și descrierea acestui atribut.",
|
||||
"generate_personal_link": "Generează link personal",
|
||||
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
|
||||
"no_published_link_surveys_available": "Nu există sondaje publicate pentru linkuri disponibile. Vă rugăm să publicați mai întâi un sondaj pentru linkuri.",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "Linkul personal a fost generat, dar nu s-a reușit copierea în clipboard: {url}",
|
||||
"personal_survey_link": "Link către sondajul personal",
|
||||
"please_select_a_survey": "Vă rugăm să selectați un sondaj",
|
||||
"search_attribute_keys": "Caută chei de atribute...",
|
||||
"search_contact": "Căutați contact",
|
||||
"select_a_survey": "Selectați un sondaj",
|
||||
"select_attribute": "Selectează atributul",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "Копировать",
|
||||
"copy_code": "Скопировать код",
|
||||
"copy_link": "Скопировать ссылку",
|
||||
"count_attributes": "{value, plural, one {{value} атрибут} few {{value} атрибута} many {{value} атрибутов} other {{value} атрибута}}",
|
||||
"count_contacts": "{value, plural, one {{value} контакт} few {{value} контакта} many {{value} контактов} other {{value} контактов}}",
|
||||
"count_responses": "{value, plural, one {{value} ответ} few {{value} ответа} many {{value} ответов} other {{value} ответов}}",
|
||||
"create_new_organization": "Создать новую организацию",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
|
||||
"option_id": "ID опции",
|
||||
"option_ids": "ID опций",
|
||||
"optional": "Необязательно",
|
||||
"or": "или",
|
||||
"organization": "Организация",
|
||||
"organization_id": "ID организации",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "Ожидание вашего сигнала..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "Атрибут успешно создан",
|
||||
"attribute_description": "Описание",
|
||||
"attribute_description_placeholder": "Краткое описание",
|
||||
"attribute_key": "Ключ",
|
||||
"attribute_key_already_exists": "Ключ атрибута уже существует",
|
||||
"attribute_key_cannot_be_changed": "Ключ нельзя изменить после создания",
|
||||
"attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.",
|
||||
"attribute_key_placeholder": "например, date_of_birth",
|
||||
"attribute_key_required": "Ключ обязателен",
|
||||
"attribute_key_safe_identifier_required": "Ключ должен быть безопасным идентификатором: только строчные буквы, цифры и символы подчёркивания, и должен начинаться с буквы",
|
||||
"attribute_label": "Метка",
|
||||
"attribute_label_placeholder": "например, дата рождения",
|
||||
"attribute_updated_successfully": "Атрибут успешно обновлён",
|
||||
"contact_deleted_successfully": "Контакт успешно удалён",
|
||||
"contact_not_found": "Такой контакт не найден",
|
||||
"contacts_table_refresh": "Обновить контакты",
|
||||
"contacts_table_refresh_success": "Контакты успешно обновлены",
|
||||
"create_attribute": "Создать атрибут",
|
||||
"create_key": "Создать ключ",
|
||||
"create_new_attribute": "Создать новый атрибут",
|
||||
"create_new_attribute_description": "Создайте новый атрибут для целей сегментации.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Будет удалён выбранный атрибут. Все данные контактов, связанные с этим атрибутом, будут потеряны.} few {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.} many {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.} other {Будут удалены выбранные атрибуты. Все данные контактов, связанные с этими атрибутами, будут потеряны.}}",
|
||||
"delete_contact_confirmation": "Это удалит все ответы на опросы и атрибуты контакта, связанные с этим контактом. Любая таргетинг и персонализация на основе данных этого контакта будут потеряны.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Это удалит все ответы на опросы и атрибуты контакта, связанные с этим контактом. Любая таргетинг и персонализация на основе данных этого контакта будут потеряны. Если у этого контакта есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} few {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} many {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} other {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.}}",
|
||||
"edit_attribute": "Редактировать атрибут",
|
||||
"edit_attribute_description": "Обновите метку и описание для этого атрибута.",
|
||||
"generate_personal_link": "Сгенерировать персональную ссылку",
|
||||
"generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.",
|
||||
"no_published_link_surveys_available": "Нет доступных опубликованных опросов-ссылок. Пожалуйста, сначала опубликуйте опрос-ссылку.",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "Персональная ссылка сгенерирована, но не удалось скопировать в буфер обмена: {url}",
|
||||
"personal_survey_link": "Персональная ссылка на опрос",
|
||||
"please_select_a_survey": "Пожалуйста, выберите опрос",
|
||||
"search_attribute_keys": "Поиск по ключам атрибутов...",
|
||||
"search_contact": "Поиск контакта",
|
||||
"select_a_survey": "Выберите опрос",
|
||||
"select_attribute": "Выберите атрибут",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "Kopiera",
|
||||
"copy_code": "Kopiera kod",
|
||||
"copy_link": "Kopiera länk",
|
||||
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attribut}}",
|
||||
"count_contacts": "{value, plural, one {{value} kontakt} other {{value} kontakter}}",
|
||||
"count_responses": "{value, plural, one {{value} svar} other {{value} svar}}",
|
||||
"create_new_organization": "Skapa ny organisation",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
|
||||
"option_id": "Alternativ-ID",
|
||||
"option_ids": "Alternativ-ID:n",
|
||||
"optional": "Valfritt",
|
||||
"or": "eller",
|
||||
"organization": "Organisation",
|
||||
"organization_id": "Organisations-ID",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "Väntar på din signal..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "Attributet har skapats",
|
||||
"attribute_description": "Beskrivning",
|
||||
"attribute_description_placeholder": "Kort beskrivning",
|
||||
"attribute_key": "Nyckel",
|
||||
"attribute_key_already_exists": "Attributnyckeln finns redan",
|
||||
"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",
|
||||
"attribute_key_required": "Nyckel krävs",
|
||||
"attribute_key_safe_identifier_required": "Nyckeln måste vara en säker identifierare: endast små bokstäver, siffror och understreck, och måste börja med en bokstav",
|
||||
"attribute_label": "Etikett",
|
||||
"attribute_label_placeholder": "t.ex. Födelsedatum",
|
||||
"attribute_updated_successfully": "Attributet har uppdaterats",
|
||||
"contact_deleted_successfully": "Kontakt borttagen",
|
||||
"contact_not_found": "Ingen sådan kontakt hittades",
|
||||
"contacts_table_refresh": "Uppdatera kontakter",
|
||||
"contacts_table_refresh_success": "Kontakter uppdaterade",
|
||||
"create_attribute": "Skapa attribut",
|
||||
"create_key": "Skapa nyckel",
|
||||
"create_new_attribute": "Skapa nytt attribut",
|
||||
"create_new_attribute_description": "Skapa ett nytt attribut för segmenteringsändamål.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Detta kommer att ta bort det valda attributet. All kontaktdata som är kopplad till detta attribut kommer att gå förlorad.} other {Detta kommer att ta bort de valda attributen. All kontaktdata som är kopplad till dessa attribut kommer att gå förlorad.}}",
|
||||
"delete_contact_confirmation": "Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till denna kontakt. All målgruppsinriktning och personalisering baserad på denna kontakts data kommer att gå förlorad.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till denna kontakt. All målgruppsinriktning och personalisering baserad på denna kontakts data kommer att gå förlorad. Om denna kontakt har svar som räknas mot enkätkvoter, kommer kvotantalet att minskas men kvotgränserna förblir oförändrade.} other {Detta kommer att ta bort alla enkätsvar och kontaktattribut som är kopplade till dessa kontakter. All målgruppsinriktning och personalisering baserad på dessa kontakters data kommer att gå förlorad. Om dessa kontakter har svar som räknas mot enkätkvoter, kommer kvotantalet att minskas men kvotgränserna förblir oförändrade.}}",
|
||||
"edit_attribute": "Redigera attribut",
|
||||
"edit_attribute_description": "Uppdatera etikett och beskrivning för detta attribut.",
|
||||
"generate_personal_link": "Generera personlig länk",
|
||||
"generate_personal_link_description": "Välj en publicerad enkät för att generera en personlig länk för denna kontakt.",
|
||||
"no_published_link_surveys_available": "Inga publicerade länkenkäter tillgängliga. Vänligen publicera en länkenkät först.",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "Personlig länk genererad men kunde inte kopieras till urklipp: {url}",
|
||||
"personal_survey_link": "Personlig enkätlänk",
|
||||
"please_select_a_survey": "Vänligen välj en enkät",
|
||||
"search_attribute_keys": "Sök attributnycklar...",
|
||||
"search_contact": "Sök kontakt",
|
||||
"select_a_survey": "Välj en enkät",
|
||||
"select_attribute": "Välj attribut",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "复制",
|
||||
"copy_code": "复制 代码",
|
||||
"copy_link": "复制 链接",
|
||||
"count_attributes": "{value, plural, one {{value} 个属性} other {{value} 个属性}}",
|
||||
"count_contacts": "{value, plural, other {{value} 联系人} }",
|
||||
"count_responses": "{value, plural, other {{value} 回复} }",
|
||||
"create_new_organization": "创建 新的 组织",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
|
||||
"option_id": "选项 ID",
|
||||
"option_ids": "选项 ID",
|
||||
"optional": "可选",
|
||||
"or": "或",
|
||||
"organization": "组织",
|
||||
"organization_id": "组织 ID",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "等待 您的 信号..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "属性创建成功",
|
||||
"attribute_description": "描述",
|
||||
"attribute_description_placeholder": "简短描述",
|
||||
"attribute_key": "键",
|
||||
"attribute_key_already_exists": "属性键已存在",
|
||||
"attribute_key_cannot_be_changed": "创建后键不可更改",
|
||||
"attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。",
|
||||
"attribute_key_placeholder": "例如:date_of_birth",
|
||||
"attribute_key_required": "键为必填项",
|
||||
"attribute_key_safe_identifier_required": "键必须为安全标识符:仅允许小写字母、数字和下划线,且必须以字母开头",
|
||||
"attribute_label": "标签",
|
||||
"attribute_label_placeholder": "例如:出生日期",
|
||||
"attribute_updated_successfully": "属性更新成功",
|
||||
"contact_deleted_successfully": "联系人 删除 成功",
|
||||
"contact_not_found": "未找到此 联系人",
|
||||
"contacts_table_refresh": "刷新 联系人",
|
||||
"contacts_table_refresh_success": "联系人 已成功刷新",
|
||||
"create_attribute": "创建属性",
|
||||
"create_key": "创建键",
|
||||
"create_new_attribute": "创建新属性",
|
||||
"create_new_attribute_description": "为细分目的创建新属性。",
|
||||
"delete_attribute_confirmation": "{value, plural, one {这将删除所选属性。与该属性相关的任何联系人数据都将丢失。} other {这将删除所选属性。与这些属性相关的任何联系人数据都将丢失。}}",
|
||||
"delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}",
|
||||
"edit_attribute": "编辑属性",
|
||||
"edit_attribute_description": "更新此属性的标签和描述。",
|
||||
"generate_personal_link": "生成个人链接",
|
||||
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
|
||||
"no_published_link_surveys_available": "没有可用的已发布链接调查。请先发布一个链接调查。",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "个性化链接已生成,但复制到剪贴板失败:{url}",
|
||||
"personal_survey_link": "个人调查链接",
|
||||
"please_select_a_survey": "请选择一个调查",
|
||||
"search_attribute_keys": "搜索属性键...",
|
||||
"search_contact": "搜索 联系人",
|
||||
"select_a_survey": "选择一个调查",
|
||||
"select_attribute": "选择 属性",
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"copy": "複製",
|
||||
"copy_code": "複製程式碼",
|
||||
"copy_link": "複製連結",
|
||||
"count_attributes": "{value, plural, one {{value} 個屬性} other {{value} 個屬性}}",
|
||||
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
|
||||
"count_responses": "{value, plural, other {{value} 回應} }",
|
||||
"create_new_organization": "建立新組織",
|
||||
@@ -298,6 +299,7 @@
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
|
||||
"option_id": "選項 ID",
|
||||
"option_ids": "選項 IDs",
|
||||
"optional": "選填",
|
||||
"or": "或",
|
||||
"organization": "組織",
|
||||
"organization_id": "組織 ID",
|
||||
@@ -603,12 +605,32 @@
|
||||
"waiting_for_your_signal": "正在等待您的訊號..."
|
||||
},
|
||||
"contacts": {
|
||||
"attribute_created_successfully": "屬性建立成功",
|
||||
"attribute_description": "描述",
|
||||
"attribute_description_placeholder": "簡短描述",
|
||||
"attribute_key": "金鑰",
|
||||
"attribute_key_already_exists": "屬性金鑰已存在",
|
||||
"attribute_key_cannot_be_changed": "建立後無法變更金鑰",
|
||||
"attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。",
|
||||
"attribute_key_placeholder": "例如:date_of_birth",
|
||||
"attribute_key_required": "金鑰為必填項目",
|
||||
"attribute_key_safe_identifier_required": "金鑰必須為安全識別字:僅限小寫字母、數字和底線,且必須以字母開頭",
|
||||
"attribute_label": "標籤",
|
||||
"attribute_label_placeholder": "例如:出生日期",
|
||||
"attribute_updated_successfully": "屬性更新成功",
|
||||
"contact_deleted_successfully": "聯絡人已成功刪除",
|
||||
"contact_not_found": "找不到此聯絡人",
|
||||
"contacts_table_refresh": "重新整理聯絡人",
|
||||
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
||||
"create_attribute": "建立屬性",
|
||||
"create_key": "建立金鑰",
|
||||
"create_new_attribute": "建立新屬性",
|
||||
"create_new_attribute_description": "建立新屬性以進行分群用途。",
|
||||
"delete_attribute_confirmation": "{value, plural, one {這將刪除所選屬性。與此屬性相關的聯絡人資料將會遺失。} other {這將刪除所選屬性。與這些屬性相關的聯絡人資料將會遺失。}}",
|
||||
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}",
|
||||
"edit_attribute": "編輯屬性",
|
||||
"edit_attribute_description": "更新此屬性的標籤與描述。",
|
||||
"generate_personal_link": "產生個人連結",
|
||||
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
|
||||
"no_published_link_surveys_available": "沒有可用的已發佈連結問卷。請先發佈一個連結問卷。",
|
||||
@@ -619,6 +641,7 @@
|
||||
"personal_link_generated_but_clipboard_failed": "已生成個人連結,但無法複製到剪貼簿:{url}",
|
||||
"personal_survey_link": "個人調查連結",
|
||||
"please_select_a_survey": "請選擇一個問卷",
|
||||
"search_attribute_keys": "搜尋屬性金鑰...",
|
||||
"search_contact": "搜尋聯絡人",
|
||||
"select_a_survey": "選擇問卷",
|
||||
"select_attribute": "選取屬性",
|
||||
|
||||
@@ -35,11 +35,17 @@ export const updateContactAttributeKey = async (
|
||||
contactAttributeKeyInput: TContactAttributeKeyUpdateSchema
|
||||
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
// Only allow updating name and description, not key
|
||||
const updateData: Prisma.ContactAttributeKeyUpdateInput = {
|
||||
name: contactAttributeKeyInput.name,
|
||||
description: contactAttributeKeyInput.description,
|
||||
};
|
||||
|
||||
const updatedKey = await prisma.contactAttributeKey.update({
|
||||
where: {
|
||||
id: contactAttributeKeyId,
|
||||
},
|
||||
data: contactAttributeKeyInput,
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await prisma.contactAttribute.findMany({
|
||||
@@ -70,7 +76,7 @@ export const updateContactAttributeKey = async (
|
||||
details: [
|
||||
{
|
||||
field: "contactAttributeKey",
|
||||
issue: `Contact attribute key with "${contactAttributeKeyInput.key}" already exists`,
|
||||
issue: "Contact attribute key update conflict",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -79,6 +79,17 @@ export const PUT = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (res.data.type === "default") {
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: "contactAttributeKey", issue: "cannot update default contact attribute key" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
if (res.data.isUnique) {
|
||||
return handleApiError(
|
||||
request,
|
||||
@@ -141,6 +152,17 @@ export const DELETE = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (res.data.type === "default") {
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: [{ field: "contactAttributeKey", issue: "cannot delete default contact attribute key" }],
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
if (res.data.isUnique) {
|
||||
return handleApiError(
|
||||
request,
|
||||
|
||||
@@ -19,10 +19,9 @@ export const ZContactAttributeKeyIdSchema = z
|
||||
export const ZContactAttributeKeyUpdateSchema = ZContactAttributeKey.pick({
|
||||
name: true,
|
||||
description: true,
|
||||
key: true,
|
||||
}).openapi({
|
||||
ref: "contactAttributeKeyUpdate",
|
||||
description: "A contact attribute key to update.",
|
||||
description: "A contact attribute key to update. Key cannot be changed.",
|
||||
});
|
||||
|
||||
export type TContactAttributeKeyUpdateSchema = z.infer<typeof ZContactAttributeKeyUpdateSchema>;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
@@ -28,9 +29,21 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
|
||||
name: true,
|
||||
description: true,
|
||||
environmentId: true,
|
||||
}).openapi({
|
||||
ref: "contactAttributeKeyInput",
|
||||
description: "Input data for creating or updating a contact attribute",
|
||||
});
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Enforce safe identifier format for key
|
||||
if (!isSafeIdentifier(data.key)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
|
||||
path: ["key"],
|
||||
});
|
||||
}
|
||||
})
|
||||
.openapi({
|
||||
ref: "contactAttributeKeyInput",
|
||||
description: "Input data for creating or updating a contact attribute",
|
||||
});
|
||||
|
||||
export type TContactAttributeKeyInput = z.infer<typeof ZContactAttributeKeyInput>;
|
||||
|
||||
176
apps/web/modules/ee/contacts/attributes/actions.ts
Normal file
176
apps/web/modules/ee/contacts/attributes/actions.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import {
|
||||
createContactAttributeKey,
|
||||
updateContactAttributeKey,
|
||||
deleteContactAttributeKey,
|
||||
getContactAttributeKeyById,
|
||||
} from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
|
||||
const ZCreateContactAttributeKeyAction = z.object({
|
||||
environmentId: ZId,
|
||||
key: z.string().refine(
|
||||
(val) => isSafeIdentifier(val),
|
||||
{
|
||||
message:
|
||||
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
|
||||
}
|
||||
),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const createContactAttributeKeyAction = authenticatedActionClient
|
||||
.schema(ZCreateContactAttributeKeyAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"contactAttributeKey",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
|
||||
const contactAttributeKey = await createContactAttributeKey({
|
||||
environmentId: parsedInput.environmentId,
|
||||
key: parsedInput.key,
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.newObject = contactAttributeKey;
|
||||
|
||||
return contactAttributeKey;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateContactAttributeKeyAction = z.object({
|
||||
id: ZId,
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateContactAttributeKeyAction = authenticatedActionClient
|
||||
.schema(ZUpdateContactAttributeKeyAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"contactAttributeKey",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
// Fetch existing key to check authorization and get environmentId
|
||||
const existingKey = await getContactAttributeKeyById(parsedInput.id);
|
||||
|
||||
if (!existingKey) {
|
||||
throw new Error("Contact attribute key not found");
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(existingKey.environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(existingKey.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.oldObject = existingKey;
|
||||
|
||||
const updatedKey = await updateContactAttributeKey(parsedInput.id, {
|
||||
name: parsedInput.name,
|
||||
description: parsedInput.description,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.newObject = updatedKey;
|
||||
|
||||
return updatedKey;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZDeleteContactAttributeKeyAction = z.object({
|
||||
id: ZId,
|
||||
});
|
||||
|
||||
export const deleteContactAttributeKeyAction = authenticatedActionClient
|
||||
.schema(ZDeleteContactAttributeKeyAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"contactAttributeKey",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
// Fetch existing key to check authorization and get environmentId
|
||||
const existingKey = await getContactAttributeKeyById(parsedInput.id);
|
||||
|
||||
if (!existingKey) {
|
||||
throw new Error("Contact attribute key not found");
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(existingKey.environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(existingKey.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.oldObject = existingKey;
|
||||
|
||||
const deletedKey = await deleteContactAttributeKey(parsedInput.id);
|
||||
|
||||
return deletedKey;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
export const generateAttributeTableColumns = (
|
||||
searchValue: string,
|
||||
isReadOnly: boolean,
|
||||
isExpanded: boolean,
|
||||
t: TFunction
|
||||
): ColumnDef<TContactAttributeKey>[] => {
|
||||
const labelColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: t("common.label"),
|
||||
cell: ({ row }) => {
|
||||
const name = row.original.name ?? row.original.key;
|
||||
return <HighlightedText value={name} searchValue={searchValue} />;
|
||||
},
|
||||
};
|
||||
|
||||
const keyColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "key",
|
||||
accessorKey: "key",
|
||||
header: t("common.key"),
|
||||
cell: ({ row }) => {
|
||||
const key = row.original.key;
|
||||
return <IdBadge id={key} showCopyIconOnHover={true} />;
|
||||
},
|
||||
};
|
||||
|
||||
const descriptionColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
header: t("common.description"),
|
||||
cell: ({ row }) => {
|
||||
const description = row.original.description;
|
||||
return description ? (
|
||||
<div className={isExpanded ? "whitespace-normal break-words" : "truncate"}>
|
||||
<HighlightedText value={description} searchValue={searchValue} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400">-</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const createdAtColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: t("common.created_at"),
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.original.createdAt;
|
||||
return <span>{format(createdAt, "do 'of' MMMM, yyyy")}</span>;
|
||||
},
|
||||
};
|
||||
|
||||
const updatedAtColumn: ColumnDef<TContactAttributeKey> = {
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
header: t("common.updated_at"),
|
||||
cell: ({ row }) => {
|
||||
const updatedAt = row.original.updatedAt;
|
||||
return (
|
||||
<span>
|
||||
{formatDistanceToNow(updatedAt, {
|
||||
addSuffix: true,
|
||||
}).replace("about", "")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const baseColumns = [labelColumn, keyColumn, descriptionColumn, createdAtColumn, updatedAtColumn];
|
||||
|
||||
return isReadOnly ? baseColumns : [getSelectionColumn<TContactAttributeKey>(), ...baseColumns];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { deleteContactAttributeKeyAction } from "../actions";
|
||||
import {
|
||||
DataTableHeader,
|
||||
DataTableSettingsModal,
|
||||
DataTableToolbar,
|
||||
} from "@/modules/ui/components/data-table";
|
||||
import { getCommonPinningStyles } from "@/modules/ui/components/data-table/lib/utils";
|
||||
import { SearchBar } from "@/modules/ui/components/search-bar";
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { generateAttributeTableColumns } from "./attribute-table-column";
|
||||
import { EditAttributeModal } from "./edit-attribute-modal";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
|
||||
interface AttributesTableProps {
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
isReadOnly: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const AttributesTable = ({
|
||||
contactAttributeKeys,
|
||||
isReadOnly,
|
||||
environmentId,
|
||||
}: AttributesTableProps) => {
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||
const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean | null>(null);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
const [editingAttribute, setEditingAttribute] = useState<TContactAttributeKey | null>(null);
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
// Filter attributes based on search
|
||||
const filteredAttributes = useMemo(() => {
|
||||
if (!searchValue) return contactAttributeKeys;
|
||||
const searchLower = searchValue.toLowerCase();
|
||||
return contactAttributeKeys.filter(
|
||||
(attr) =>
|
||||
attr.key.toLowerCase().includes(searchLower) ||
|
||||
attr.name?.toLowerCase().includes(searchLower) ||
|
||||
attr.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}, [contactAttributeKeys, searchValue]);
|
||||
|
||||
// Generate columns
|
||||
const columns = useMemo(() => {
|
||||
return generateAttributeTableColumns(searchValue, isReadOnly, isExpanded ?? false, t);
|
||||
}, [searchValue, isReadOnly, isExpanded]);
|
||||
|
||||
// Load saved settings from localStorage
|
||||
useEffect(() => {
|
||||
const savedColumnOrder = localStorage.getItem(`${environmentId}-attributes-columnOrder`);
|
||||
const savedColumnVisibility = localStorage.getItem(`${environmentId}-attributes-columnVisibility`);
|
||||
const savedExpandedSettings = localStorage.getItem(`${environmentId}-attributes-rowExpand`);
|
||||
|
||||
let savedColumnOrderParsed: string[] = [];
|
||||
if (savedColumnOrder) {
|
||||
try {
|
||||
savedColumnOrderParsed = JSON.parse(savedColumnOrder);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
savedColumnOrderParsed.length > 0 &&
|
||||
table.getAllLeafColumns().length === savedColumnOrderParsed.length
|
||||
) {
|
||||
setColumnOrder(savedColumnOrderParsed);
|
||||
} else {
|
||||
setColumnOrder(table.getAllLeafColumns().map((d) => d.id));
|
||||
}
|
||||
|
||||
let savedColumnVisibilityParsed: VisibilityState = {};
|
||||
if (savedColumnVisibility) {
|
||||
try {
|
||||
savedColumnVisibilityParsed = JSON.parse(savedColumnVisibility);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
savedColumnVisibilityParsed &&
|
||||
Object.keys(savedColumnVisibilityParsed).length === table.getAllLeafColumns().length
|
||||
) {
|
||||
setColumnVisibility(savedColumnVisibilityParsed);
|
||||
} else {
|
||||
const initialVisibility = table
|
||||
.getAllLeafColumns()
|
||||
.map((column) => column.id)
|
||||
.reduce((acc, curr) => {
|
||||
acc[curr] = true;
|
||||
return acc;
|
||||
}, {}) as Record<string, true>;
|
||||
|
||||
setColumnVisibility(initialVisibility);
|
||||
}
|
||||
|
||||
if (savedExpandedSettings !== null) {
|
||||
setIsExpanded(JSON.parse(savedExpandedSettings));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [environmentId]);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
useEffect(() => {
|
||||
if (columnOrder.length > 0) {
|
||||
localStorage.setItem(`${environmentId}-attributes-columnOrder`, JSON.stringify(columnOrder));
|
||||
}
|
||||
|
||||
if (Object.keys(columnVisibility).length > 0) {
|
||||
localStorage.setItem(
|
||||
`${environmentId}-attributes-columnVisibility`,
|
||||
JSON.stringify(columnVisibility)
|
||||
);
|
||||
}
|
||||
|
||||
if (isExpanded !== null) {
|
||||
localStorage.setItem(`${environmentId}-attributes-rowExpand`, JSON.stringify(isExpanded));
|
||||
}
|
||||
}, [columnOrder, columnVisibility, isExpanded, environmentId]);
|
||||
|
||||
// Initialize DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
);
|
||||
|
||||
// React Table instance
|
||||
const table = useReactTable({
|
||||
data: filteredAttributes,
|
||||
columns,
|
||||
getRowId: (originalRow) => originalRow.id,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
columnResizeMode: "onChange",
|
||||
columnResizeDirection: "ltr",
|
||||
defaultColumn: { maxSize: 1000, size: 300 },
|
||||
enableRowSelection: (row) => {
|
||||
// Only allow selection of custom attributes
|
||||
return row.original.type === "custom";
|
||||
},
|
||||
state: {
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnPinning: {
|
||||
left: ["select", "createdAt"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Handle column drag end
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active && over && active.id !== over.id) {
|
||||
setColumnOrder((prevOrder) => {
|
||||
const oldIndex = prevOrder.indexOf(active.id as string);
|
||||
const newIndex = prevOrder.indexOf(over.id as string);
|
||||
return arrayMove(prevOrder, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAttribute = async (attributeId: string) => {
|
||||
const deleteContactAttributeKeyResponse = await deleteContactAttributeKeyAction({ id: attributeId });
|
||||
if (!deleteContactAttributeKeyResponse?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(deleteContactAttributeKeyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAttributeList = () => {
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
sensors={sensors}>
|
||||
<DataTableToolbar
|
||||
setIsExpanded={setIsExpanded}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
updateRowList={updateAttributeList}
|
||||
type="attribute"
|
||||
deleteAction={deleteAttribute}
|
||||
isQuotasAllowed={false}
|
||||
leftContent={
|
||||
<div className="w-64">
|
||||
<SearchBar
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
placeholder={t("environments.contacts.search_attribute_keys")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="w-full overflow-x-auto rounded-xl border border-slate-200">
|
||||
<Table className="w-full" style={{ tableLayout: "fixed" }}>
|
||||
<TableHeader className="pointer-events-auto">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataTableHeader
|
||||
key={header.id}
|
||||
header={header}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
showColumnDividers={false}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody ref={parent}>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const attribute = row.original;
|
||||
const isSystemAttribute = attribute.type === "default";
|
||||
const isSelectable = !isSystemAttribute && !isReadOnly;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn({
|
||||
"group cursor-pointer": isSelectable,
|
||||
"cursor-default": !isSelectable,
|
||||
})}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
// Disable selection for system attributes
|
||||
if (cell.column.id === "select" && isSystemAttribute) {
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={getCommonPinningStyles(cell.column)}
|
||||
className="bg-white px-4 py-2">
|
||||
<div className="flex w-full items-center justify-center pr-4">
|
||||
{/* Empty checkbox space for system attributes */}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
onClick={() => {
|
||||
if (cell.column.id === "select") return;
|
||||
if (isSelectable) {
|
||||
setEditingAttribute(attribute);
|
||||
}
|
||||
}}
|
||||
style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}}
|
||||
className={cn(
|
||||
"border-slate-200 bg-white px-4 py-2 shadow-none",
|
||||
{
|
||||
"group-hover:bg-slate-100": isSelectable,
|
||||
"bg-slate-100": row.getIsSelected() && isSelectable,
|
||||
}
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center",
|
||||
isExpanded ? "h-auto min-h-10" : "h-full truncate"
|
||||
)}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{table.getRowModel().rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{t("common.no_results")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DataTableSettingsModal
|
||||
open={isTableSettingsModalOpen}
|
||||
setOpen={setIsTableSettingsModalOpen}
|
||||
table={table}
|
||||
columnOrder={columnOrder}
|
||||
handleDragEnd={handleDragEnd}
|
||||
/>
|
||||
</DndContext>
|
||||
|
||||
{editingAttribute && (
|
||||
<EditAttributeModal
|
||||
attribute={editingAttribute}
|
||||
open={!!editingAttribute}
|
||||
setOpen={(open) => {
|
||||
if (!open) setEditingAttribute(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createContactAttributeKeyAction } from "../actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { isSafeIdentifier, toSafeIdentifier } from "@/lib/utils/safe-identifier";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
|
||||
interface CreateAttributeModalProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function CreateAttributeModal({ environmentId }: Readonly<CreateAttributeModalProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
key: "",
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
const [keyError, setKeyError] = useState<string>("");
|
||||
|
||||
const handleResetState = () => {
|
||||
setFormData({
|
||||
key: "",
|
||||
name: "",
|
||||
description: "",
|
||||
});
|
||||
setKeyError("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setFormData((prev) => {
|
||||
const newName = value;
|
||||
// Auto-suggest key from name if key is empty or matches previous name suggestion
|
||||
let newKey = prev.key;
|
||||
if (!prev.key || prev.key === toSafeIdentifier(prev.name)) {
|
||||
newKey = toSafeIdentifier(newName);
|
||||
}
|
||||
return { ...prev, name: newName, key: newKey };
|
||||
});
|
||||
if (keyError) {
|
||||
validateKey(formData.key || toSafeIdentifier(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyChange = (value: string) => {
|
||||
setFormData((prev) => ({ ...prev, key: value }));
|
||||
validateKey(value);
|
||||
};
|
||||
|
||||
const validateKey = (key: string) => {
|
||||
if (!key) {
|
||||
setKeyError(t("environments.contacts.attribute_key_required"));
|
||||
return false;
|
||||
}
|
||||
if (!isSafeIdentifier(key)) {
|
||||
setKeyError(
|
||||
t("environments.contacts.attribute_key_safe_identifier_required") ||
|
||||
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
setKeyError("");
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formData.key) {
|
||||
setKeyError(t("environments.contacts.attribute_key_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateKey(formData.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
const createContactAttributeKeyResponse = await createContactAttributeKeyAction({
|
||||
environmentId,
|
||||
key: formData.key,
|
||||
name: formData.name || formData.key,
|
||||
description: formData.description || undefined,
|
||||
});
|
||||
|
||||
if (!createContactAttributeKeyResponse?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(createContactAttributeKeyResponse);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.contacts.attribute_created_successfully"));
|
||||
handleResetState();
|
||||
router.refresh();
|
||||
setIsCreating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)} size="sm">
|
||||
{t("environments.contacts.create_attribute")}
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleResetState();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-lg" disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.contacts.create_new_attribute")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.contacts.create_new_attribute_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_key")}
|
||||
</label>
|
||||
<Input
|
||||
value={formData.key}
|
||||
onChange={(e) => handleKeyChange(e.target.value)}
|
||||
placeholder={t("environments.contacts.attribute_key_placeholder")}
|
||||
className={keyError ? "border-red-500" : ""}
|
||||
/>
|
||||
{keyError && <p className="text-sm text-red-500">{keyError}</p>}
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("environments.contacts.attribute_key_hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_label")}
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder={t("environments.contacts.attribute_label_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_description")} ({t("common.optional")})
|
||||
</label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder={t("environments.contacts.attribute_description_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleResetState();
|
||||
}}
|
||||
type="button"
|
||||
variant="secondary">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={!formData.key || !!keyError} loading={isCreating} onClick={handleCreate} type="submit">
|
||||
{t("environments.contacts.create_key")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { updateContactAttributeKeyAction } from "../actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
|
||||
interface EditAttributeModalProps {
|
||||
attribute: TContactAttributeKey;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditAttributeModal({
|
||||
attribute,
|
||||
open,
|
||||
setOpen,
|
||||
}: Readonly<EditAttributeModalProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: attribute.name ?? "",
|
||||
description: attribute.description ?? "",
|
||||
});
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setIsUpdating(true);
|
||||
const updateContactAttributeKeyResponse = await updateContactAttributeKeyAction({
|
||||
id: attribute.id,
|
||||
name: formData.name || undefined,
|
||||
description: formData.description || undefined,
|
||||
});
|
||||
|
||||
if (!updateContactAttributeKeyResponse?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(updateContactAttributeKeyResponse);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("environments.contacts.attribute_updated_successfully"));
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-lg" disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.contacts.edit_attribute")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.contacts.edit_attribute_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_key")}
|
||||
</label>
|
||||
<Input value={attribute.key} disabled className="bg-slate-50" />
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("environments.contacts.attribute_key_cannot_be_changed")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_label")}
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder={t("environments.contacts.attribute_label_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
{t("environments.contacts.attribute_description")} ({t("common.optional")})
|
||||
</label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder={t("environments.contacts.attribute_description_placeholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setOpen(false)} type="button" variant="secondary">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={!formData.name} loading={isUpdating} onClick={handleUpdate} type="submit">
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
71
apps/web/modules/ee/contacts/attributes/page.tsx
Normal file
71
apps/web/modules/ee/contacts/attributes/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ContactsSecondaryNavigation } from "@/modules/ee/contacts/components/contacts-secondary-navigation";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { AttributesTable } from "./components/attributes-table";
|
||||
import { CreateAttributeModal } from "./components/create-attribute-modal";
|
||||
|
||||
export const AttributesPage = async ({
|
||||
params: paramsProps,
|
||||
}: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
}) => {
|
||||
const params = await paramsProps;
|
||||
const t = await getTranslate();
|
||||
|
||||
const [{ isReadOnly }, contactAttributeKeys] = await Promise.all([
|
||||
getEnvironmentAuth(params.environmentId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
]);
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
pageTitle="Contacts"
|
||||
cta={
|
||||
isContactsEnabled && !isReadOnly ? (
|
||||
<CreateAttributeModal environmentId={params.environmentId} />
|
||||
) : undefined
|
||||
}>
|
||||
<ContactsSecondaryNavigation activeId="attributes" environmentId={params.environmentId} />
|
||||
</PageHeader>
|
||||
|
||||
{isContactsEnabled ? (
|
||||
<AttributesTable
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isReadOnly={isReadOnly}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center">
|
||||
<UpgradePrompt
|
||||
title={t("environments.contacts.unlock_contacts_title")}
|
||||
description={t("environments.contacts.unlock_contacts_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${params.environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${params.environmentId}/settings/billing`
|
||||
: "https://formbricks.com/learn-more-self-hosting-license",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,6 +35,11 @@ export const ContactsSecondaryNavigation = async ({
|
||||
label: t("common.segments"),
|
||||
href: `/environments/${environmentId}/segments`,
|
||||
},
|
||||
{
|
||||
id: "attributes",
|
||||
label: t("common.attributes"),
|
||||
href: `/environments/${environmentId}/attributes`,
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
|
||||
@@ -221,11 +221,6 @@ export const ContactsTable = ({
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<SearchBar
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
placeholder={t("environments.contacts.search_contact")}
|
||||
/>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
@@ -240,6 +235,15 @@ export const ContactsTable = ({
|
||||
type="contact"
|
||||
deleteAction={deleteContact}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
leftContent={
|
||||
<div className="w-64">
|
||||
<SearchBar
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
placeholder={t("environments.contacts.search_contact")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="w-full overflow-x-auto rounded-xl border border-slate-200">
|
||||
<Table className="w-full" style={{ tableLayout: "fixed" }}>
|
||||
@@ -252,6 +256,7 @@ export const ContactsTable = ({
|
||||
key={header.id}
|
||||
header={header}
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
showColumnDividers={false}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -275,11 +280,7 @@ export const ContactsTable = ({
|
||||
style={cell.column.id === "select" ? getCommonPinningStyles(cell.column) : {}}
|
||||
className={cn(
|
||||
"border-slate-200 bg-white px-4 py-2 shadow-none group-hover:bg-slate-100",
|
||||
row.getIsSelected() && "bg-slate-100",
|
||||
{
|
||||
"border-r": !cell.column.getIsLastColumn(),
|
||||
"border-l": !cell.column.getIsFirstColumn(),
|
||||
}
|
||||
row.getIsSelected() && "bg-slate-100"
|
||||
)}>
|
||||
<div
|
||||
className={cn("flex flex-1 items-center truncate", isExpanded ? "h-10" : "h-full")}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { DatabaseError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getContactAttributeKeys = reactCache(
|
||||
async (environmentId: string): Promise<TContactAttributeKey[]> => {
|
||||
@@ -9,3 +11,92 @@ export const getContactAttributeKeys = reactCache(
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const getContactAttributeKeyById = async (
|
||||
id: string
|
||||
): Promise<Pick<TContactAttributeKey, "id" | "environmentId" | "type" | "name" | "description"> | null> => {
|
||||
const key = await prisma.contactAttributeKey.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, environmentId: true, type: true, name: true, description: true },
|
||||
});
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
export const createContactAttributeKey = async (data: {
|
||||
environmentId: string;
|
||||
key: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}): Promise<TContactAttributeKey> => {
|
||||
try {
|
||||
const contactAttributeKey = await prisma.contactAttributeKey.create({
|
||||
data: {
|
||||
key: data.key,
|
||||
name: data.name ?? data.key,
|
||||
description: data.description ?? null,
|
||||
environmentId: data.environmentId,
|
||||
type: "custom",
|
||||
},
|
||||
});
|
||||
|
||||
return contactAttributeKey;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new DatabaseError("Attribute key already exists");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateContactAttributeKey = async (
|
||||
id: string,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
): Promise<TContactAttributeKey> => {
|
||||
const existingKey = await prisma.contactAttributeKey.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingKey) {
|
||||
throw new ResourceNotFoundError("contactAttributeKey", id);
|
||||
}
|
||||
|
||||
if (existingKey.type === "default") {
|
||||
throw new OperationNotAllowedError("Cannot update default contact attribute key");
|
||||
}
|
||||
|
||||
const updatedKey = await prisma.contactAttributeKey.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedKey;
|
||||
};
|
||||
|
||||
export const deleteContactAttributeKey = async (id: string): Promise<TContactAttributeKey> => {
|
||||
const existingKey = await prisma.contactAttributeKey.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingKey) {
|
||||
throw new ResourceNotFoundError("contactAttributeKey", id);
|
||||
}
|
||||
|
||||
if (existingKey.type === "default") {
|
||||
throw new OperationNotAllowedError("Cannot delete default contact attribute key");
|
||||
}
|
||||
|
||||
const deletedKey = await prisma.contactAttributeKey.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return deletedKey;
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@ export const SegmentTableDataRow = ({
|
||||
<>
|
||||
<button
|
||||
key={id}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-7 content-center rounded-lg p-2 text-left transition-colors ease-in-out hover:bg-slate-100"
|
||||
className="grid h-12 w-full cursor-pointer grid-cols-7 content-center rounded-lg p-2 text-left transition-colors ease-in-out hover:bg-slate-100"
|
||||
onClick={() => setIsEditSegmentModalOpen(true)}>
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -11,9 +11,14 @@ import { ColumnSettingsDropdown } from "./column-settings-dropdown";
|
||||
interface DataTableHeaderProps<T> {
|
||||
header: Header<T, unknown>;
|
||||
setIsTableSettingsModalOpen: (isTableSettingsModalOpen: boolean) => void;
|
||||
showColumnDividers?: boolean;
|
||||
}
|
||||
|
||||
export const DataTableHeader = <T,>({ header, setIsTableSettingsModalOpen }: DataTableHeaderProps<T>) => {
|
||||
export const DataTableHeader = <T,>({
|
||||
header,
|
||||
setIsTableSettingsModalOpen,
|
||||
showColumnDividers = true,
|
||||
}: DataTableHeaderProps<T>) => {
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform } = useSortable({
|
||||
id: header.column.id,
|
||||
});
|
||||
@@ -36,8 +41,8 @@ export const DataTableHeader = <T,>({ header, setIsTableSettingsModalOpen }: Dat
|
||||
style={style}
|
||||
key={header.id}
|
||||
className={cn("group relative h-10 border-b border-slate-200 bg-white px-4 text-center", {
|
||||
"border-r": !header.column.getIsLastColumn(),
|
||||
"border-l": !header.column.getIsFirstColumn(),
|
||||
"border-r": showColumnDividers && !header.column.getIsLastColumn(),
|
||||
"border-l": showColumnDividers && !header.column.getIsFirstColumn(),
|
||||
})}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-full truncate text-left font-semibold">
|
||||
|
||||
@@ -15,10 +15,11 @@ interface DataTableToolbarProps<T> {
|
||||
isExpanded: boolean;
|
||||
table: Table<T>;
|
||||
updateRowList: (rowIds: string[]) => void;
|
||||
type: "response" | "contact";
|
||||
type: "response" | "contact" | "attribute";
|
||||
deleteAction: (id: string, params?: Record<string, boolean>) => Promise<void>;
|
||||
downloadRowsAction?: (rowIds: string[], format: string) => Promise<void>;
|
||||
isQuotasAllowed: boolean;
|
||||
leftContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DataTableToolbar = <T,>({
|
||||
@@ -31,6 +32,7 @@ export const DataTableToolbar = <T,>({
|
||||
deleteAction,
|
||||
downloadRowsAction,
|
||||
isQuotasAllowed,
|
||||
leftContent,
|
||||
}: DataTableToolbarProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -47,7 +49,7 @@ export const DataTableToolbar = <T,>({
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
/>
|
||||
) : (
|
||||
<div></div>
|
||||
<div>{leftContent}</div>
|
||||
)}
|
||||
<div className="flex space-x-2">
|
||||
{type === "contact" ? (
|
||||
|
||||
@@ -20,7 +20,7 @@ import { cn } from "@/modules/ui/lib/utils";
|
||||
interface SelectedRowSettingsProps<T> {
|
||||
table: Table<T>;
|
||||
updateRowList: (rowId: string[]) => void;
|
||||
type: "response" | "contact";
|
||||
type: "response" | "contact" | "attribute";
|
||||
deleteAction: (id: string, params?: Record<string, boolean>) => Promise<void>;
|
||||
downloadRowsAction?: (rowIds: string[], format: string) => Promise<void>;
|
||||
isQuotasAllowed: boolean;
|
||||
@@ -108,18 +108,42 @@ export const SelectedRowSettings = <T,>({
|
||||
|
||||
const quotasDialogText = isQuotasAllowed
|
||||
? t("environments.contacts.delete_contact_confirmation_with_quotas", {
|
||||
value: selectedRowCount,
|
||||
})
|
||||
value: selectedRowCount,
|
||||
})
|
||||
: t("environments.contacts.delete_contact_confirmation");
|
||||
|
||||
const deleteDialogText =
|
||||
type === "response" ? t("environments.surveys.responses.delete_response_confirmation") : quotasDialogText;
|
||||
let deleteDialogText: string;
|
||||
if (type === "response") {
|
||||
deleteDialogText = t("environments.surveys.responses.delete_response_confirmation");
|
||||
} else if (type === "attribute") {
|
||||
deleteDialogText = t("environments.contacts.delete_attribute_confirmation", { value: selectedRowCount });
|
||||
} else {
|
||||
deleteDialogText = quotasDialogText;
|
||||
}
|
||||
|
||||
let selectedTypeLabel: string;
|
||||
if (type === "response") {
|
||||
selectedTypeLabel = t("common.responses");
|
||||
} else if (type === "contact") {
|
||||
selectedTypeLabel = t("common.contacts");
|
||||
} else {
|
||||
selectedTypeLabel = t("common.attributes");
|
||||
}
|
||||
|
||||
let deleteWhatText: string;
|
||||
if (type === "response") {
|
||||
deleteWhatText = t("common.count_responses", { value: selectedRowCount });
|
||||
} else if (type === "contact") {
|
||||
deleteWhatText = t("common.count_contacts", { value: selectedRowCount });
|
||||
} else {
|
||||
deleteWhatText = t("common.count_attributes", { value: selectedRowCount });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-primary flex items-center gap-x-2 rounded-md p-1 px-2 text-xs text-white">
|
||||
<div className="lowercase">
|
||||
{`${selectedRowCount} ${type === "response" ? t("common.responses") : t("common.contacts")} ${t("common.selected")}`}
|
||||
{`${selectedRowCount} ${selectedTypeLabel} ${t("common.selected")}`}
|
||||
</div>
|
||||
<Separator />
|
||||
<Button
|
||||
@@ -176,11 +200,7 @@ export const SelectedRowSettings = <T,>({
|
||||
<DeleteDialog
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
deleteWhat={
|
||||
type === "response"
|
||||
? t("common.count_responses", { value: selectedRowCount })
|
||||
: t("common.count_contacts", { value: selectedRowCount })
|
||||
}
|
||||
deleteWhat={deleteWhatText}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
text={deleteDialogText}>
|
||||
|
||||
Reference in New Issue
Block a user