From 98cb2de02ba8a945743e3eaadcbabe4ba08e5eb0 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:32:37 +0530 Subject: [PATCH] feat: UI to manage attribute keys (#7038) --- .../(contacts)/attributes/page.tsx | 1 + apps/web/i18n.lock | 22 ++ apps/web/lib/utils/safe-identifier.test.ts | 35 ++ apps/web/lib/utils/safe-identifier.ts | 13 + apps/web/lingodotdev/language.ts | 5 +- apps/web/locales/de-DE.json | 22 ++ apps/web/locales/en-US.json | 22 ++ apps/web/locales/es-ES.json | 22 ++ apps/web/locales/fr-FR.json | 22 ++ apps/web/locales/ja-JP.json | 22 ++ apps/web/locales/nl-NL.json | 22 ++ apps/web/locales/pt-BR.json | 22 ++ apps/web/locales/pt-PT.json | 22 ++ apps/web/locales/ro-RO.json | 22 ++ apps/web/locales/ru-RU.json | 22 ++ apps/web/locales/sv-SE.json | 22 ++ apps/web/locales/zh-Hans-CN.json | 22 ++ apps/web/locales/zh-Hant-TW.json | 22 ++ .../lib/contact-attribute-key.ts | 10 +- .../lib/tests/contact-attribute-key.test.ts | 4 +- .../[contactAttributeKeyId]/route.ts | 22 ++ .../types/contact-attribute-keys.ts | 3 +- .../contact-attribute-keys/route.ts | 2 +- .../types/contact-attribute-keys.ts | 21 +- .../modules/ee/contacts/attributes/actions.ts | 190 +++++++++ .../components/attribute-table-column.tsx | 79 ++++ .../components/attributes-table.tsx | 362 ++++++++++++++++++ .../components/create-attribute-modal.tsx | 203 ++++++++++ .../components/edit-attribute-modal.tsx | 125 ++++++ .../modules/ee/contacts/attributes/page.tsx | 40 ++ .../components/contacts-page-layout.tsx | 66 ++++ .../contacts-secondary-navigation.tsx | 5 + .../ee/contacts/components/contacts-table.tsx | 21 +- .../lib/contact-attribute-keys.test.ts | 198 +++++++++- .../ee/contacts/lib/contact-attribute-keys.ts | 92 +++++ apps/web/modules/ee/contacts/page.tsx | 66 +--- .../components/segment-table-data-row.tsx | 2 +- .../web/modules/ee/contacts/segments/page.tsx | 69 +--- .../components/data-table-header.tsx | 11 +- .../components/data-table-toolbar.tsx | 6 +- .../components/selected-row-settings.tsx | 42 +- 41 files changed, 1864 insertions(+), 137 deletions(-) create mode 100644 apps/web/app/(app)/environments/[environmentId]/(contacts)/attributes/page.tsx create mode 100644 apps/web/lib/utils/safe-identifier.test.ts create mode 100644 apps/web/lib/utils/safe-identifier.ts create mode 100644 apps/web/modules/ee/contacts/attributes/actions.ts create mode 100644 apps/web/modules/ee/contacts/attributes/components/attribute-table-column.tsx create mode 100644 apps/web/modules/ee/contacts/attributes/components/attributes-table.tsx create mode 100644 apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx create mode 100644 apps/web/modules/ee/contacts/attributes/components/edit-attribute-modal.tsx create mode 100644 apps/web/modules/ee/contacts/attributes/page.tsx create mode 100644 apps/web/modules/ee/contacts/components/contacts-page-layout.tsx diff --git a/apps/web/app/(app)/environments/[environmentId]/(contacts)/attributes/page.tsx b/apps/web/app/(app)/environments/[environmentId]/(contacts)/attributes/page.tsx new file mode 100644 index 0000000000..939c3f2b6d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/(contacts)/attributes/page.tsx @@ -0,0 +1 @@ +export { AttributesPage as default } from "@/modules/ee/contacts/attributes/page"; diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 48e4738710..94c601b146 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -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,31 @@ 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_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 +604,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 diff --git a/apps/web/lib/utils/safe-identifier.test.ts b/apps/web/lib/utils/safe-identifier.test.ts new file mode 100644 index 0000000000..707c287012 --- /dev/null +++ b/apps/web/lib/utils/safe-identifier.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import { isSafeIdentifier } 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); + }); + }); +}); diff --git a/apps/web/lib/utils/safe-identifier.ts b/apps/web/lib/utils/safe-identifier.ts new file mode 100644 index 0000000000..c3feed1a1c --- /dev/null +++ b/apps/web/lib/utils/safe-identifier.ts @@ -0,0 +1,13 @@ +/** + * Validates that a string is a safe identifier. + * Safe identifiers can only contain lowercase letters, numbers, and underscores. + * They cannot start with a number. + */ +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); +}; diff --git a/apps/web/lingodotdev/language.ts b/apps/web/lingodotdev/language.ts index d0f114fd71..1def6106d7 100644 --- a/apps/web/lingodotdev/language.ts +++ b/apps/web/lingodotdev/language.ts @@ -1,12 +1,13 @@ import { getServerSession } from "next-auth"; +import { TUserLocale } from "@formbricks/types/user"; import { DEFAULT_LOCALE } from "@/lib/constants"; import { getUserLocale } from "@/lib/user/service"; import { findMatchingLocale } from "@/lib/utils/locale"; import { authOptions } from "@/modules/auth/lib/authOptions"; -export const getLocale = async (): Promise => { +export const getLocale = async (): Promise => { const session = await getServerSession(authOptions); - let locale: string | undefined; + let locale: TUserLocale | undefined; if (session?.user?.id) { locale = await getUserLocale(session.user.id); } else { diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 0a435bd5b9..829a87465f 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -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,31 @@ "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_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 +640,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", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 5ff1b6efc8..4c2eff7e04 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -171,6 +171,7 @@ "copy": "Copy", "copy_code": "Copy code", "copy_link": "Copy Link", + "count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}", "count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}", "count_responses": "{value, plural, one {{value} response} other {{value} responses}}", "create_new_organization": "Create new organization", @@ -298,6 +299,7 @@ "only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.", "option_id": "Option ID", "option_ids": "Option IDs", + "optional": "Optional", "or": "or", "organization": "Organization", "organization_id": "Organization ID", @@ -603,12 +605,31 @@ "waiting_for_your_signal": "Waiting for your signal..." }, "contacts": { + "attribute_created_successfully": "Attribute created successfully", + "attribute_description": "Description", + "attribute_description_placeholder": "Short description", + "attribute_key": "Key", + "attribute_key_cannot_be_changed": "Key cannot be changed after creation", + "attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.", + "attribute_key_placeholder": "e.g. date_of_birth", + "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_label": "Label", + "attribute_label_placeholder": "e.g. Date of Birth", + "attribute_updated_successfully": "Attribute updated successfully", "contact_deleted_successfully": "Contact deleted successfully", "contact_not_found": "No such contact found", "contacts_table_refresh": "Refresh contacts", "contacts_table_refresh_success": "Contacts refreshed successfully", + "create_attribute": "Create attribute", + "create_key": "Create Key", + "create_new_attribute": "Create new attribute", + "create_new_attribute_description": "Create a new attribute for segmentation purposes.", + "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.}}", "delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.", "delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts' data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}", + "edit_attribute": "Edit Attribute", + "edit_attribute_description": "Update the label and description for this attribute.", "generate_personal_link": "Generate Personal Link", "generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.", "no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.", @@ -619,6 +640,7 @@ "personal_link_generated_but_clipboard_failed": "Personal link generated but failed to copy to clipboard: {url}", "personal_survey_link": "Personal Survey Link", "please_select_a_survey": "Please select a survey", + "search_attribute_keys": "Search attribute keys...", "search_contact": "Search contact", "select_a_survey": "Select a survey", "select_attribute": "Select Attribute", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 2e1552fa1c..76cf6079bc 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -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,31 @@ "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_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 +640,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", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index f80a1d799c..55495b4862 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -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,31 @@ "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_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 +640,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", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 6d6c10a4b3..baa7c7aaa5 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -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,31 @@ "waiting_for_your_signal": "あなたの信号を待っています..." }, "contacts": { + "attribute_created_successfully": "属性を作成しました", + "attribute_description": "説明", + "attribute_description_placeholder": "簡単な説明", + "attribute_key": "キー", + "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 +640,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": "属性を選択", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 961622dc1f..62c20caf3b 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -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,31 @@ "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_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 +640,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", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index f366004325..6dc5672d8a 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -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,31 @@ "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_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 +640,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", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index a10ab566ff..1bb0956c54 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -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,31 @@ "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_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 +640,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", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 7dcdec5fcb..44ec7ecf79 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -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,31 @@ "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_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 +640,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", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 876c87f779..82166ae5ae 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -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,31 @@ "waiting_for_your_signal": "Ожидание вашего сигнала..." }, "contacts": { + "attribute_created_successfully": "Атрибут успешно создан", + "attribute_description": "Описание", + "attribute_description_placeholder": "Краткое описание", + "attribute_key": "Ключ", + "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 +640,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": "Выберите атрибут", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index c8264289c6..3ce4df02c7 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -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,31 @@ "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_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 +640,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", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 9489fad8d6..3a757594fd 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -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,31 @@ "waiting_for_your_signal": "等待 您的 信号..." }, "contacts": { + "attribute_created_successfully": "属性创建成功", + "attribute_description": "描述", + "attribute_description_placeholder": "简短描述", + "attribute_key": "键", + "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 +640,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": "选择 属性", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 3a109c53d5..bb57444b62 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -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,31 @@ "waiting_for_your_signal": "正在等待您的訊號..." }, "contacts": { + "attribute_created_successfully": "屬性建立成功", + "attribute_description": "描述", + "attribute_description_placeholder": "簡短描述", + "attribute_key": "金鑰", + "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 +640,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": "選取屬性", diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts index 7889abfdeb..cb6075684c 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key.ts @@ -35,11 +35,17 @@ export const updateContactAttributeKey = async ( contactAttributeKeyInput: TContactAttributeKeyUpdateSchema ): Promise> => { 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", }, ], }); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts index e29f13e8a6..e1df15be65 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/tests/contact-attribute-key.test.ts @@ -132,9 +132,7 @@ describe("updateContactAttributeKey", () => { if (!result.ok) { expect(result.error).toStrictEqual({ type: "conflict", - details: [ - { field: "contactAttributeKey", issue: 'Contact attribute key with "email" already exists' }, - ], + details: [{ field: "contactAttributeKey", issue: "Contact attribute key update conflict" }], }); } }); diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts index b9777ffb6c..a8f53928e1 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts @@ -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, diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts index b855994b92..b2a106cde0 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys.ts @@ -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; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts index 4a8e521073..6d25d2b5c0 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts @@ -49,7 +49,7 @@ export const POST = async (request: NextRequest) => authenticatedApiClient({ request, schemas: { - body: ZContactAttributeKeyInput, + body: ZContactAttributeKeyInput.sourceType(), }, handler: async ({ authentication, parsedInput, auditLog }) => { const { body } = parsedInput; diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts index 11976ab45c..943938bfd2 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys.ts @@ -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; diff --git a/apps/web/modules/ee/contacts/attributes/actions.ts b/apps/web/modules/ee/contacts/attributes/actions.ts new file mode 100644 index 0000000000..322d16b5fb --- /dev/null +++ b/apps/web/modules/ee/contacts/attributes/actions.ts @@ -0,0 +1,190 @@ +"use server"; + +import { z } from "zod"; +import { ZId } from "@formbricks/types/common"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; +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, + deleteContactAttributeKey, + getContactAttributeKeyById, + updateContactAttributeKey, +} 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(), +}); + +type TCreateContactAttributeKeyActionInput = z.infer; +export const createContactAttributeKeyAction = authenticatedActionClient + .schema(ZCreateContactAttributeKeyAction) + .action( + withAuditLogging( + "created", + "contactAttributeKey", + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: TCreateContactAttributeKeyActionInput; + }) => { + 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(), +}); +type TUpdateContactAttributeKeyActionInput = z.infer; +export const updateContactAttributeKeyAction = authenticatedActionClient + .schema(ZUpdateContactAttributeKeyAction) + .action( + withAuditLogging( + "updated", + "contactAttributeKey", + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: TUpdateContactAttributeKeyActionInput; + }) => { + // Fetch existing key to check authorization and get environmentId + const existingKey = await getContactAttributeKeyById(parsedInput.id); + + if (!existingKey) { + throw new ResourceNotFoundError("contactAttributeKey", parsedInput.id); + } + + 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, +}); +type TDeleteContactAttributeKeyActionInput = z.infer; + +export const deleteContactAttributeKeyAction = authenticatedActionClient + .schema(ZDeleteContactAttributeKeyAction) + .action( + withAuditLogging( + "deleted", + "contactAttributeKey", + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: TDeleteContactAttributeKeyActionInput; + }) => { + // Fetch existing key to check authorization and get environmentId + const existingKey = await getContactAttributeKeyById(parsedInput.id); + + if (!existingKey) { + throw new ResourceNotFoundError("contactAttributeKey", parsedInput.id); + } + + 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; + } + ) + ); diff --git a/apps/web/modules/ee/contacts/attributes/components/attribute-table-column.tsx b/apps/web/modules/ee/contacts/attributes/components/attribute-table-column.tsx new file mode 100644 index 0000000000..b7d971e81d --- /dev/null +++ b/apps/web/modules/ee/contacts/attributes/components/attribute-table-column.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { format } from "date-fns"; +import { TFunction } from "i18next"; +import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; +import { TUserLocale } from "@formbricks/types/user"; +import { timeSince } from "@/lib/time"; +import { getSelectionColumn } from "@/modules/ui/components/data-table"; +import { HighlightedText } from "@/modules/ui/components/highlighted-text"; +import { IdBadge } from "@/modules/ui/components/id-badge"; + +export const generateAttributeTableColumns = ( + searchValue: string, + isReadOnly: boolean, + isExpanded: boolean, + t: TFunction, + locale: TUserLocale +): ColumnDef[] => { + const labelColumn: ColumnDef = { + id: "name", + accessorKey: "name", + header: t("common.label"), + cell: ({ row }) => { + const name = row.original.name ?? row.original.key; + return ; + }, + }; + + const keyColumn: ColumnDef = { + id: "key", + accessorKey: "key", + header: t("common.key"), + cell: ({ row }) => { + const key = row.original.key; + return ; + }, + }; + + const descriptionColumn: ColumnDef = { + id: "description", + accessorKey: "description", + header: t("common.description"), + cell: ({ row }) => { + const description = row.original.description; + return description ? ( +
+ +
+ ) : ( + - + ); + }, + }; + + const createdAtColumn: ColumnDef = { + id: "createdAt", + accessorKey: "createdAt", + header: t("common.created_at"), + cell: ({ row }) => { + const createdAt = row.original.createdAt; + return {format(createdAt, "do 'of' MMMM, yyyy")}; + }, + }; + + const updatedAtColumn: ColumnDef = { + id: "updatedAt", + accessorKey: "updatedAt", + header: t("common.updated_at"), + cell: ({ row }) => { + const updatedAt = row.original.updatedAt; + return {timeSince(updatedAt.toISOString(), locale)}; + }, + }; + + const baseColumns = [labelColumn, keyColumn, descriptionColumn, createdAtColumn, updatedAtColumn]; + + return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns]; +}; diff --git a/apps/web/modules/ee/contacts/attributes/components/attributes-table.tsx b/apps/web/modules/ee/contacts/attributes/components/attributes-table.tsx new file mode 100644 index 0000000000..3697de4c41 --- /dev/null +++ b/apps/web/modules/ee/contacts/attributes/components/attributes-table.tsx @@ -0,0 +1,362 @@ +"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 { TContactAttributeKey } from "@formbricks/types/contact-attribute-key"; +import { TUserLocale } from "@formbricks/types/user"; +import { cn } from "@/lib/cn"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; +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 { deleteContactAttributeKeyAction } from "../actions"; +import { generateAttributeTableColumns } from "./attribute-table-column"; +import { EditAttributeModal } from "./edit-attribute-modal"; + +interface AttributesTableProps { + contactAttributeKeys: TContactAttributeKey[]; + isReadOnly: boolean; + environmentId: string; + locale: TUserLocale; +} + +export const AttributesTable = ({ + contactAttributeKeys, + isReadOnly, + environmentId, + locale, +}: AttributesTableProps) => { + const [columnVisibility, setColumnVisibility] = useState({}); + const [columnOrder, setColumnOrder] = useState([]); + const [isTableSettingsModalOpen, setIsTableSettingsModalOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(null); + const [rowSelection, setRowSelection] = useState({}); + const [searchValue, setSearchValue] = useState(""); + const [editingAttribute, setEditingAttribute] = useState(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]); + + // Check if all filtered attributes are system attributes + const allSystemAttributes = useMemo(() => { + return filteredAttributes.length > 0 && filteredAttributes.every((attr) => attr.type === "default"); + }, [filteredAttributes]); + + // Generate columns + const columns = useMemo(() => { + return generateAttributeTableColumns(searchValue, isReadOnly, isExpanded ?? false, t, locale); + }, [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; + + setColumnVisibility(initialVisibility); + } + + if (savedExpandedSettings !== null) { + try { + setIsExpanded(JSON.parse(savedExpandedSettings)); + } catch (err) { + console.error(err); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [environmentId]); + + // Hide select column when all attributes are system attributes + useEffect(() => { + if (!isReadOnly && allSystemAttributes) { + setColumnVisibility((prev) => ({ + ...prev, + select: false, + })); + } else if (!isReadOnly && !allSystemAttributes) { + setColumnVisibility((prev) => ({ + ...prev, + select: true, + })); + } + }, [allSystemAttributes, isReadOnly]); + + // 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: allSystemAttributes ? ["createdAt"] : ["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); + throw new Error(errorMessage); + } + }; + + const updateAttributeList = () => { + router.refresh(); + }; + + return ( +
+ + + +
+ } + /> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + + {headerGroup.headers.map((header) => ( + + ))} + + + ))} + + + + {table.getRowModel().rows.map((row) => { + const attribute = row.original; + const isSystemAttribute = attribute.type === "default"; + const isSelectable = !isSystemAttribute && !isReadOnly; + + return ( + + {row.getVisibleCells().map((cell) => { + // Disable selection for system attributes + if (cell.column.id === "select" && isSystemAttribute) { + return ( + +
+ {/* Empty checkbox space for system attributes */} +
+
+ ); + } + + return ( + { + 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, + })}> +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ); + })} +
+ ); + })} + {table.getRowModel().rows.length === 0 && ( + + + {t("common.no_results")} + + + )} +
+
+
+ + + + + {editingAttribute && ( + { + if (!open) setEditingAttribute(null); + }} + /> + )} + + ); +}; diff --git a/apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx b/apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx new file mode 100644 index 0000000000..6b6dc11e77 --- /dev/null +++ b/apps/web/modules/ee/contacts/attributes/components/create-attribute-modal.tsx @@ -0,0 +1,203 @@ +"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 { getFormattedErrorMessage } from "@/lib/utils/helper"; +import { isSafeIdentifier } from "@/lib/utils/safe-identifier"; +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 { createContactAttributeKeyAction } from "../actions"; + +interface CreateAttributeModalProps { + environmentId: string; +} + +export function CreateAttributeModal({ environmentId }: Readonly) { + 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(""); + + const handleResetState = () => { + setFormData({ + key: "", + name: "", + description: "", + }); + setKeyError(""); + setOpen(false); + }; + + const handleNameChange = (value: string) => { + setFormData((prev) => ({ ...prev, name: value })); + if (keyError && formData.key) { + validateKey(formData.key); + } + }; + + 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); + + try { + 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(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong"); + toast.error(errorMessage); + } finally { + setIsCreating(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await handleCreate(); + }; + + return ( + <> + + + { + if (!open) { + handleResetState(); + } + }}> + + + {t("environments.contacts.create_new_attribute")} + + {t("environments.contacts.create_new_attribute_description")} + + + +
+ +
+
+ + handleKeyChange(e.target.value)} + placeholder={t("environments.contacts.attribute_key_placeholder")} + className={keyError ? "border-red-500" : ""} + /> + {keyError &&

{keyError}

} +

{t("environments.contacts.attribute_key_hint")}

+
+ +
+ + handleNameChange(e.target.value)} + placeholder={t("environments.contacts.attribute_label_placeholder")} + /> +
+ +
+ + setFormData((prev) => ({ ...prev, description: e.target.value }))} + placeholder={t("environments.contacts.attribute_description_placeholder")} + /> +
+
+
+ + + + + +
+
+
+ + ); +} diff --git a/apps/web/modules/ee/contacts/attributes/components/edit-attribute-modal.tsx b/apps/web/modules/ee/contacts/attributes/components/edit-attribute-modal.tsx new file mode 100644 index 0000000000..db5d7a7657 --- /dev/null +++ b/apps/web/modules/ee/contacts/attributes/components/edit-attribute-modal.tsx @@ -0,0 +1,125 @@ +"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 { getFormattedErrorMessage } from "@/lib/utils/helper"; +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 { updateContactAttributeKeyAction } from "../actions"; + +interface EditAttributeModalProps { + attribute: TContactAttributeKey; + open: boolean; + setOpen: (open: boolean) => void; +} + +export function EditAttributeModal({ attribute, open, setOpen }: Readonly) { + 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); + + try { + 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(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong"); + toast.error(errorMessage); + } finally { + setIsUpdating(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await handleUpdate(); + }; + + return ( + + + + {t("environments.contacts.edit_attribute")} + {t("environments.contacts.edit_attribute_description")} + +
+ +
+
+ + +

+ {t("environments.contacts.attribute_key_cannot_be_changed")} +

+
+ +
+ + setFormData((prev) => ({ ...prev, name: e.target.value }))} + placeholder={t("environments.contacts.attribute_label_placeholder")} + /> +
+ +
+ + setFormData((prev) => ({ ...prev, description: e.target.value }))} + placeholder={t("environments.contacts.attribute_description_placeholder")} + /> +
+
+
+ + + + + +
+
+
+ ); +} diff --git a/apps/web/modules/ee/contacts/attributes/page.tsx b/apps/web/modules/ee/contacts/attributes/page.tsx new file mode 100644 index 0000000000..100e2457b2 --- /dev/null +++ b/apps/web/modules/ee/contacts/attributes/page.tsx @@ -0,0 +1,40 @@ +import { getLocale } from "@/lingodotdev/language"; +import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout"; +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 { 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 locale = await getLocale(); + + const [{ isReadOnly }, contactAttributeKeys] = await Promise.all([ + getEnvironmentAuth(params.environmentId), + getContactAttributeKeys(params.environmentId), + ]); + + const isContactsEnabled = await getIsContactsEnabled(); + + return ( + }> + + + ); +}; diff --git a/apps/web/modules/ee/contacts/components/contacts-page-layout.tsx b/apps/web/modules/ee/contacts/components/contacts-page-layout.tsx new file mode 100644 index 0000000000..78a67309cd --- /dev/null +++ b/apps/web/modules/ee/contacts/components/contacts-page-layout.tsx @@ -0,0 +1,66 @@ +import { ReactNode } from "react"; +import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { getTranslate } from "@/lingodotdev/server"; +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 { ContactsSecondaryNavigation } from "./contacts-secondary-navigation"; + +interface ContactsPageLayoutProps { + pageTitle: string; + activeId: string; + environmentId: string; + isContactsEnabled: boolean; + isReadOnly: boolean; + cta?: ReactNode; + children: ReactNode; + upgradePromptTitle?: string; + upgradePromptDescription?: string; +} + +export const ContactsPageLayout = async ({ + pageTitle, + activeId, + environmentId, + isContactsEnabled, + isReadOnly, + cta, + children, + upgradePromptTitle, + upgradePromptDescription, +}: ContactsPageLayoutProps) => { + const t = await getTranslate(); + + return ( + + + + + + {isContactsEnabled ? ( + children + ) : ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/modules/ee/contacts/components/contacts-secondary-navigation.tsx b/apps/web/modules/ee/contacts/components/contacts-secondary-navigation.tsx index 7fb128819f..a4ab285d27 100644 --- a/apps/web/modules/ee/contacts/components/contacts-secondary-navigation.tsx +++ b/apps/web/modules/ee/contacts/components/contacts-secondary-navigation.tsx @@ -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 ; diff --git a/apps/web/modules/ee/contacts/components/contacts-table.tsx b/apps/web/modules/ee/contacts/components/contacts-table.tsx index faf5823f60..f57573746e 100644 --- a/apps/web/modules/ee/contacts/components/contacts-table.tsx +++ b/apps/web/modules/ee/contacts/components/contacts-table.tsx @@ -221,11 +221,6 @@ export const ContactsTable = ({ return (
- + +
+ } />
@@ -252,6 +256,7 @@ export const ContactsTable = ({ key={header.id} header={header} setIsTableSettingsModalOpen={setIsTableSettingsModalOpen} + showColumnDividers={false} /> ))} @@ -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" )}>
diff --git a/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts b/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts index fb2b189138..db99d9a3c5 100644 --- a/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts +++ b/apps/web/modules/ee/contacts/lib/contact-attribute-keys.test.ts @@ -1,10 +1,24 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { getContactAttributeKeys } from "./contact-attribute-keys"; +import { PrismaErrorType } from "@formbricks/database/types/error"; +import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { + createContactAttributeKey, + deleteContactAttributeKey, + getContactAttributeKeyById, + getContactAttributeKeys, + updateContactAttributeKey, +} from "./contact-attribute-keys"; vi.mock("@formbricks/database", () => ({ prisma: { - contactAttributeKey: { findMany: vi.fn() }, + contactAttributeKey: { + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, }, })); vi.mock("react", () => ({ cache: (fn) => fn })); @@ -33,3 +47,183 @@ describe("getContactAttributeKeys", () => { expect(result).toEqual([]); }); }); + +describe("getContactAttributeKeyById", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns the selected key fields when found", async () => { + const id = "cak-1"; + const key = { + id, + environmentId, + type: "custom", + name: "Email", + description: "Customer email", + }; + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValue(key); + + const result = await getContactAttributeKeyById(id); + + expect(prisma.contactAttributeKey.findUnique).toHaveBeenCalledWith({ + where: { id }, + select: { id: true, environmentId: true, type: true, name: true, description: true }, + }); + expect(result).toEqual(key); + }); + + test("returns null when not found", async () => { + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValue(null); + const result = await getContactAttributeKeyById("missing-id"); + expect(result).toBeNull(); + }); +}); + +describe("createContactAttributeKey", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("creates a custom key with name fallback and null description", async () => { + const data = { environmentId, key: "company" }; + const created = { + id: "cak-2", + key: data.key, + name: data.key, + description: null, + environmentId, + type: "custom", + }; + vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue(created); + + const result = await createContactAttributeKey(data); + + expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith({ + data: { + key: data.key, + name: data.key, + description: null, + environmentId, + type: "custom", + }, + }); + expect(result).toEqual(created); + }); + + test("keeps empty string description (does not coalesce to null)", async () => { + const data = { environmentId, key: "notes", description: "" }; + vi.mocked(prisma.contactAttributeKey.create).mockResolvedValue({ + id: "cak-3", + key: data.key, + name: data.key, + description: "", + environmentId, + type: "custom", + }); + + await createContactAttributeKey(data); + + expect(prisma.contactAttributeKey.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + description: "", + }), + }) + ); + }); + + test("throws InvalidInputError on unique constraint violation", async () => { + const err = Object.assign(new Error("Unique constraint failed"), { + code: PrismaErrorType.UniqueConstraintViolation, + }); + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(err); + + await expect(createContactAttributeKey({ environmentId, key: "email" })).rejects.toThrow( + InvalidInputError + ); + }); + + test("rethrows unknown prisma error codes", async () => { + const err = Object.assign(new Error("Some prisma error"), { code: PrismaErrorType.RecordDoesNotExist }); + vi.mocked(prisma.contactAttributeKey.create).mockRejectedValue(err); + + try { + await createContactAttributeKey({ environmentId, key: "x" }); + throw new Error("Expected createContactAttributeKey to throw"); + } catch (caught) { + expect(caught).toBe(err); + } + }); +}); + +describe("updateContactAttributeKey", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("throws ResourceNotFoundError when key does not exist", async () => { + const id = "missing-id"; + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValue(null); + + await expect(updateContactAttributeKey(id, { name: "New" })).rejects.toThrow(ResourceNotFoundError); + expect(prisma.contactAttributeKey.update).not.toHaveBeenCalled(); + }); + + test("throws OperationNotAllowedError when trying to update a default key", async () => { + const id = "default-id"; + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValue({ id, type: "default" }); + + await expect(updateContactAttributeKey(id, { name: "New" })).rejects.toThrow(OperationNotAllowedError); + expect(prisma.contactAttributeKey.update).not.toHaveBeenCalled(); + }); + + test("updates a non-default key", async () => { + const id = "custom-id"; + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValue({ id, type: "custom" }); + const updated = { id, key: "email", environmentId, type: "custom", name: "Email", description: null }; + vi.mocked(prisma.contactAttributeKey.update).mockResolvedValue(updated); + + const result = await updateContactAttributeKey(id, { name: "Email", description: null }); + + expect(prisma.contactAttributeKey.update).toHaveBeenCalledWith({ + where: { id }, + data: { name: "Email", description: null }, + }); + expect(result).toEqual(updated); + }); +}); + +describe("deleteContactAttributeKey", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("throws ResourceNotFoundError when key does not exist", async () => { + const id = "missing-id"; + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValue(null); + + await expect(deleteContactAttributeKey(id)).rejects.toThrow(ResourceNotFoundError); + expect(prisma.contactAttributeKey.delete).not.toHaveBeenCalled(); + }); + + test("throws OperationNotAllowedError when trying to delete a default key", async () => { + const id = "default-id"; + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValue({ id, type: "default" }); + + await expect(deleteContactAttributeKey(id)).rejects.toThrow(OperationNotAllowedError); + expect(prisma.contactAttributeKey.delete).not.toHaveBeenCalled(); + }); + + test("deletes a non-default key", async () => { + const id = "custom-id"; + vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValue({ id, type: "custom" }); + const deleted = { id, key: "email", environmentId, type: "custom", name: "Email", description: null }; + vi.mocked(prisma.contactAttributeKey.delete).mockResolvedValue(deleted); + + const result = await deleteContactAttributeKey(id); + + expect(prisma.contactAttributeKey.delete).toHaveBeenCalledWith({ where: { id } }); + expect(result).toEqual(deleted); + }); +}); diff --git a/apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts b/apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts index db6792917d..1e3c2423da 100644 --- a/apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts +++ b/apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts @@ -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 { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; export const getContactAttributeKeys = reactCache( async (environmentId: string): Promise => { @@ -9,3 +11,93 @@ export const getContactAttributeKeys = reactCache( }); } ); + +export const getContactAttributeKeyById = async ( + id: string +): Promise | 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 => { + 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 InvalidInputError("Attribute key already exists"); + } + } + throw error; + } +}; + +export const updateContactAttributeKey = async ( + id: string, + data: { + name?: string; + description?: string; + } +): Promise => { + const existingKey = await prisma.contactAttributeKey.findUnique({ + where: { id }, + }); + + if (!existingKey) { + console.log("throwing resource not found error"); + 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 => { + 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; +}; diff --git a/apps/web/modules/ee/contacts/page.tsx b/apps/web/modules/ee/contacts/page.tsx index cb02ca8546..0a886acfd7 100644 --- a/apps/web/modules/ee/contacts/page.tsx +++ b/apps/web/modules/ee/contacts/page.tsx @@ -1,15 +1,12 @@ -import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE } from "@/lib/constants"; +import { ITEMS_PER_PAGE } from "@/lib/constants"; import { getTranslate } from "@/lingodotdev/server"; +import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout"; import { UploadContactsCSVButton } from "@/modules/ee/contacts/components/upload-contacts-button"; import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys"; import { getContacts } from "@/modules/ee/contacts/lib/contacts"; import { getIsContactsEnabled, getIsQuotasEnabled } 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 { ContactDataView } from "./components/contact-data-view"; -import { ContactsSecondaryNavigation } from "./components/contacts-secondary-navigation"; export const ContactsPage = async ({ params: paramsProps, @@ -34,46 +31,23 @@ export const ContactsPage = async ({ ); return ( - - - - - - {isContactsEnabled ? ( - = ITEMS_PER_PAGE} - isQuotasAllowed={isQuotasAllowed} - /> - ) : ( -
- -
- )} -
+ + = ITEMS_PER_PAGE} + isQuotasAllowed={isQuotasAllowed} + /> + ); }; diff --git a/apps/web/modules/ee/contacts/segments/components/segment-table-data-row.tsx b/apps/web/modules/ee/contacts/segments/components/segment-table-data-row.tsx index c9bb20953e..108b9d0633 100644 --- a/apps/web/modules/ee/contacts/segments/components/segment-table-data-row.tsx +++ b/apps/web/modules/ee/contacts/segments/components/segment-table-data-row.tsx @@ -29,7 +29,7 @@ export const SegmentTableDataRow = ({ <>