feat: UI to change attribute value for contacts (#7040)

This commit is contained in:
Dhruwang Jariwala
2025-12-29 18:39:29 +05:30
committed by GitHub
parent fa2b63d6a1
commit 5b334f6623
28 changed files with 1055 additions and 30 deletions

View File

@@ -570,6 +570,7 @@ 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/add_attribute: 70f153afc5c400527ffccfb2eec58ae9
environments/contacts/attribute_created_successfully: e9f90d366d817f2f1c81fb819c0e2f05
environments/contacts/attribute_description: e17686a22ffad04cc7bb70524ed4478b
environments/contacts/attribute_description_placeholder: 05af83e4cfc6328476ef9719581e47af
@@ -582,6 +583,8 @@ checksums:
environments/contacts/attribute_label: a5c71bf158481233f8215dbd38cc196b
environments/contacts/attribute_label_placeholder: bf5106cb14d2ec0c21e7d8b4ab1f3a93
environments/contacts/attribute_updated_successfully: 0e64422156c29940cd4dab2f9d1f40b2
environments/contacts/attribute_value: 34b0eaa85808b15cbc4be94c64d0146b
environments/contacts/attribute_value_placeholder: 90fb17015de807031304d7a650a6cb8c
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
@@ -593,8 +596,11 @@ checksums:
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: 92a83c96a5d850e7d39002e8fd5898f4
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
environments/contacts/edit_attribute_values_description: 21593dfaf4cad965ffc17685bc005509
environments/contacts/edit_attributes_success: 39f93b1a6f1605bc5951f4da5847bb22
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "Warte auf ein Signal von dir..."
},
"contacts": {
"add_attribute": "Attribut hinzufügen",
"attribute_created_successfully": "Attribut erfolgreich erstellt",
"attribute_description": "Beschreibung",
"attribute_description_placeholder": "Kurze Beschreibung",
@@ -618,6 +619,8 @@
"attribute_label": "Bezeichnung",
"attribute_label_placeholder": "z. B. Geburtsdatum",
"attribute_updated_successfully": "Attribut erfolgreich aktualisiert",
"attribute_value": "Wert",
"attribute_value_placeholder": "Attributwert",
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"contact_not_found": "Kein solcher Kontakt gefunden",
"contacts_table_refresh": "Kontakte aktualisieren",
@@ -631,6 +634,9 @@
"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.",
"edit_attribute_values": "Attribute bearbeiten",
"edit_attribute_values_description": "Ändern Sie die Werte für bestimmte Attribute dieses Kontakts.",
"edit_attributes_success": "Kontaktattribute erfolgreich aktualisiert",
"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.",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "Waiting for your signal..."
},
"contacts": {
"add_attribute": "Add Attribute",
"attribute_created_successfully": "Attribute created successfully",
"attribute_description": "Description",
"attribute_description_placeholder": "Short description",
@@ -618,6 +619,8 @@
"attribute_label": "Label",
"attribute_label_placeholder": "e.g. Date of Birth",
"attribute_updated_successfully": "Attribute updated successfully",
"attribute_value": "Value",
"attribute_value_placeholder": "Attribute Value",
"contact_deleted_successfully": "Contact deleted successfully",
"contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts",
@@ -629,8 +632,11 @@
"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": "Edit attribute",
"edit_attribute_description": "Update the label and description for this attribute.",
"edit_attribute_values": "Edit attributes",
"edit_attribute_values_description": "Change the values for specific attributes for this contact.",
"edit_attributes_success": "Contact attributes updated successfully",
"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.",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "Esperando tu señal..."
},
"contacts": {
"add_attribute": "Añadir atributo",
"attribute_created_successfully": "Atributo creado con éxito",
"attribute_description": "Descripción",
"attribute_description_placeholder": "Descripción breve",
@@ -618,6 +619,8 @@
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "p. ej. fecha de nacimiento",
"attribute_updated_successfully": "Atributo actualizado con éxito",
"attribute_value": "Valor",
"attribute_value_placeholder": "Valor del atributo",
"contact_deleted_successfully": "Contacto eliminado correctamente",
"contact_not_found": "No se ha encontrado dicho contacto",
"contacts_table_refresh": "Actualizar contactos",
@@ -631,6 +634,9 @@
"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.",
"edit_attribute_values": "Editar atributos",
"edit_attribute_values_description": "Cambia los valores de atributos específicos para este contacto.",
"edit_attributes_success": "Atributos del contacto actualizados correctamente",
"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.",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "En attente de votre signal..."
},
"contacts": {
"add_attribute": "Ajouter un attribut",
"attribute_created_successfully": "Attribut créé avec succès",
"attribute_description": "Description",
"attribute_description_placeholder": "Brève description",
@@ -618,6 +619,8 @@
"attribute_label": "Étiquette",
"attribute_label_placeholder": "ex. Date de naissance",
"attribute_updated_successfully": "Attribut mis à jour avec succès",
"attribute_value": "Valeur",
"attribute_value_placeholder": "Valeur d'attribut",
"contact_deleted_successfully": "Contact supprimé avec succès",
"contact_not_found": "Aucun contact trouvé",
"contacts_table_refresh": "Actualiser les contacts",
@@ -631,6 +634,9 @@
"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.",
"edit_attribute_values": "Modifier les attributs",
"edit_attribute_values_description": "Modifiez les valeurs d'attributs spécifiques pour ce contact.",
"edit_attributes_success": "Attributs du contact mis à jour avec succès",
"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.",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "あなたの信号を待っています..."
},
"contacts": {
"add_attribute": "属性を追加",
"attribute_created_successfully": "属性を作成しました",
"attribute_description": "説明",
"attribute_description_placeholder": "簡単な説明",
@@ -618,6 +619,8 @@
"attribute_label": "ラベル",
"attribute_label_placeholder": "例: 生年月日",
"attribute_updated_successfully": "属性を更新しました",
"attribute_value": "値",
"attribute_value_placeholder": "属性値",
"contact_deleted_successfully": "連絡先を正常に削除しました",
"contact_not_found": "そのような連絡先は見つかりません",
"contacts_table_refresh": "連絡先を更新",
@@ -631,6 +634,9 @@
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
"edit_attribute": "属性を編集",
"edit_attribute_description": "この属性のラベルと説明を更新します。",
"edit_attribute_values": "属性を編集",
"edit_attribute_values_description": "この連絡先の特定の属性の値を変更します。",
"edit_attributes_success": "連絡先属性が正常に更新されました",
"generate_personal_link": "個人リンクを生成",
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
"no_published_link_surveys_available": "公開されたリンクフォームはありません。まずリンクフォームを公開してください。",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "Wachten op uw signaal..."
},
"contacts": {
"add_attribute": "Attribuut toevoegen",
"attribute_created_successfully": "Attribuut succesvol aangemaakt",
"attribute_description": "Beschrijving",
"attribute_description_placeholder": "Korte beschrijving",
@@ -618,6 +619,8 @@
"attribute_label": "Label",
"attribute_label_placeholder": "bijv. Geboortedatum",
"attribute_updated_successfully": "Attribuut succesvol bijgewerkt",
"attribute_value": "Waarde",
"attribute_value_placeholder": "Attribuutwaarde",
"contact_deleted_successfully": "Contact succesvol verwijderd",
"contact_not_found": "Er is geen dergelijk contact gevonden",
"contacts_table_refresh": "Vernieuw contacten",
@@ -631,6 +634,9 @@
"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.",
"edit_attribute_values": "Attributen bewerken",
"edit_attribute_values_description": "Wijzig de waarden voor specifieke attributen voor dit contact.",
"edit_attributes_success": "Contactattributen succesvol bijgewerkt",
"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.",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "Esperando seu sinal..."
},
"contacts": {
"add_attribute": "Adicionar atributo",
"attribute_created_successfully": "Atributo criado com sucesso",
"attribute_description": "Descrição",
"attribute_description_placeholder": "Descrição curta",
@@ -618,6 +619,8 @@
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex: Data de nascimento",
"attribute_updated_successfully": "Atributo atualizado com sucesso",
"attribute_value": "Valor",
"attribute_value_placeholder": "Valor do atributo",
"contact_deleted_successfully": "Contato excluído com sucesso",
"contact_not_found": "Nenhum contato encontrado",
"contacts_table_refresh": "Atualizar contatos",
@@ -631,6 +634,9 @@
"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.",
"edit_attribute_values": "Editar atributos",
"edit_attribute_values_description": "Altere os valores de atributos específicos para este contato.",
"edit_attributes_success": "Atributos do contato atualizados com sucesso",
"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.",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "À espera do seu sinal..."
},
"contacts": {
"add_attribute": "Adicionar atributo",
"attribute_created_successfully": "Atributo criado com sucesso",
"attribute_description": "Descrição",
"attribute_description_placeholder": "Descrição breve",
@@ -618,6 +619,8 @@
"attribute_label": "Etiqueta",
"attribute_label_placeholder": "ex. Data de nascimento",
"attribute_updated_successfully": "Atributo atualizado com sucesso",
"attribute_value": "Valor",
"attribute_value_placeholder": "Valor do atributo",
"contact_deleted_successfully": "Contacto eliminado com sucesso",
"contact_not_found": "Nenhum contacto encontrado",
"contacts_table_refresh": "Atualizar contactos",
@@ -631,6 +634,9 @@
"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.",
"edit_attribute_values": "Editar atributos",
"edit_attribute_values_description": "Altere os valores de atributos específicos para este contacto.",
"edit_attributes_success": "Atributos do contacto atualizados com sucesso",
"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.",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "Așteptăm semnalul dumneavoastră..."
},
"contacts": {
"add_attribute": "Adaugă atribut",
"attribute_created_successfully": "Atribut creat cu succes",
"attribute_description": "Descriere",
"attribute_description_placeholder": "Descriere scurtă",
@@ -618,6 +619,8 @@
"attribute_label": "Etichetă",
"attribute_label_placeholder": "ex: Data nașterii",
"attribute_updated_successfully": "Atribut actualizat cu succes",
"attribute_value": "Valoare",
"attribute_value_placeholder": "Valoare atribut",
"contact_deleted_successfully": "Contact șters cu succes",
"contact_not_found": "Nu a fost găsit niciun contact",
"contacts_table_refresh": "Reîmprospătare contacte",
@@ -631,6 +634,9 @@
"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.",
"edit_attribute_values": "Editează atributele",
"edit_attribute_values_description": "Modifică valorile anumitor atribute pentru acest contact.",
"edit_attributes_success": "Atributele contactului au fost actualizate cu succes",
"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.",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "Ожидание вашего сигнала..."
},
"contacts": {
"add_attribute": "Добавить атрибут",
"attribute_created_successfully": "Атрибут успешно создан",
"attribute_description": "Описание",
"attribute_description_placeholder": "Краткое описание",
@@ -618,6 +619,8 @@
"attribute_label": "Метка",
"attribute_label_placeholder": "например, дата рождения",
"attribute_updated_successfully": "Атрибут успешно обновлён",
"attribute_value": "Значение",
"attribute_value_placeholder": "Значение атрибута",
"contact_deleted_successfully": "Контакт успешно удалён",
"contact_not_found": "Такой контакт не найден",
"contacts_table_refresh": "Обновить контакты",
@@ -631,6 +634,9 @@
"delete_contact_confirmation_with_quotas": "{value, plural, one {Это удалит все ответы на опросы и атрибуты контакта, связанные с этим контактом. Любая таргетинг и персонализация на основе данных этого контакта будут потеряны. Если у этого контакта есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} few {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} many {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.} other {Это удалит все ответы на опросы и атрибуты контактов, связанные с этими контактами. Любая таргетинг и персонализация на основе данных этих контактов будут потеряны. Если у этих контактов есть ответы, которые учитываются в квотах опроса, количество по квотам будет уменьшено, но лимиты квот останутся без изменений.}}",
"edit_attribute": "Редактировать атрибут",
"edit_attribute_description": "Обновите метку и описание для этого атрибута.",
"edit_attribute_values": "Редактировать атрибуты",
"edit_attribute_values_description": "Измените значения определённых атрибутов для этого контакта.",
"edit_attributes_success": "Атрибуты контакта успешно обновлены",
"generate_personal_link": "Сгенерировать персональную ссылку",
"generate_personal_link_description": "Выберите опубликованный опрос, чтобы сгенерировать персональную ссылку для этого контакта.",
"no_published_link_surveys_available": "Нет доступных опубликованных опросов-ссылок. Пожалуйста, сначала опубликуйте опрос-ссылку.",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "Väntar på din signal..."
},
"contacts": {
"add_attribute": "Lägg till attribut",
"attribute_created_successfully": "Attributet har skapats",
"attribute_description": "Beskrivning",
"attribute_description_placeholder": "Kort beskrivning",
@@ -618,6 +619,8 @@
"attribute_label": "Etikett",
"attribute_label_placeholder": "t.ex. Födelsedatum",
"attribute_updated_successfully": "Attributet har uppdaterats",
"attribute_value": "Värde",
"attribute_value_placeholder": "Attributvärde",
"contact_deleted_successfully": "Kontakt borttagen",
"contact_not_found": "Ingen sådan kontakt hittades",
"contacts_table_refresh": "Uppdatera kontakter",
@@ -631,6 +634,9 @@
"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.",
"edit_attribute_values": "Redigera attribut",
"edit_attribute_values_description": "Ändra värdena för specifika attribut för denna kontakt.",
"edit_attributes_success": "Kontaktens attribut har uppdaterats",
"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.",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "等待 您的 信号..."
},
"contacts": {
"add_attribute": "添加属性",
"attribute_created_successfully": "属性创建成功",
"attribute_description": "描述",
"attribute_description_placeholder": "简短描述",
@@ -618,6 +619,8 @@
"attribute_label": "标签",
"attribute_label_placeholder": "例如:出生日期",
"attribute_updated_successfully": "属性更新成功",
"attribute_value": "值",
"attribute_value_placeholder": "属性值",
"contact_deleted_successfully": "联系人 删除 成功",
"contact_not_found": "未找到此 联系人",
"contacts_table_refresh": "刷新 联系人",
@@ -631,6 +634,9 @@
"delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}",
"edit_attribute": "编辑属性",
"edit_attribute_description": "更新此属性的标签和描述。",
"edit_attribute_values": "编辑属性",
"edit_attribute_values_description": "更改此联系人的特定属性值。",
"edit_attributes_success": "联系人属性更新成功",
"generate_personal_link": "生成个人链接",
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
"no_published_link_surveys_available": "没有可用的已发布链接调查。请先发布一个链接调查。",

View File

@@ -606,6 +606,7 @@
"waiting_for_your_signal": "正在等待您的訊號..."
},
"contacts": {
"add_attribute": "新增屬性",
"attribute_created_successfully": "屬性建立成功",
"attribute_description": "描述",
"attribute_description_placeholder": "簡短描述",
@@ -618,6 +619,8 @@
"attribute_label": "標籤",
"attribute_label_placeholder": "例如:出生日期",
"attribute_updated_successfully": "屬性更新成功",
"attribute_value": "值",
"attribute_value_placeholder": "屬性值",
"contact_deleted_successfully": "聯絡人已成功刪除",
"contact_not_found": "找不到此聯絡人",
"contacts_table_refresh": "重新整理聯絡人",
@@ -631,6 +634,9 @@
"delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}",
"edit_attribute": "編輯屬性",
"edit_attribute_description": "更新此屬性的標籤與描述。",
"edit_attribute_values": "編輯屬性",
"edit_attribute_values_description": "變更此聯絡人特定屬性的值。",
"edit_attributes_success": "聯絡人屬性已成功更新",
"generate_personal_link": "產生個人連結",
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
"no_published_link_surveys_available": "沒有可用的已發佈連結問卷。請先發佈一個連結問卷。",

View File

@@ -1,8 +1,8 @@
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";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
extendZodWithOpenApi(z);

View File

@@ -1,12 +1,15 @@
"use client";
import { LinkIcon, TrashIcon } from "lucide-react";
import { LinkIcon, PencilIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteContactAction } from "@/modules/ee/contacts/actions";
import { EditContactAttributesModal } from "@/modules/ee/contacts/components/edit-contact-attributes-modal";
import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
@@ -18,6 +21,8 @@ interface ContactControlBarProps {
isReadOnly: boolean;
isQuotasAllowed: boolean;
publishedLinkSurveys: PublishedLinkSurvey[];
currentAttributes: TContactAttributes;
attributeKeys: TContactAttributeKey[];
}
export const ContactControlBar = ({
@@ -26,12 +31,15 @@ export const ContactControlBar = ({
isReadOnly,
isQuotasAllowed,
publishedLinkSurveys,
currentAttributes,
attributeKeys,
}: ContactControlBarProps) => {
const router = useRouter();
const { t } = useTranslation();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeletingPerson, setIsDeletingPerson] = useState(false);
const [isGenerateLinkModalOpen, setIsGenerateLinkModalOpen] = useState(false);
const [isEditAttributesModalOpen, setIsEditAttributesModalOpen] = useState(false);
const handleDeletePerson = async () => {
setIsDeletingPerson(true);
@@ -53,6 +61,14 @@ export const ContactControlBar = ({
}
const iconActions = [
{
icon: PencilIcon,
tooltip: t("environments.contacts.edit_attribute_values"),
onClick: () => {
setIsEditAttributesModalOpen(true);
},
isVisible: true,
},
{
icon: LinkIcon,
tooltip: t("environments.contacts.generate_personal_link"),
@@ -94,6 +110,13 @@ export const ContactControlBar = ({
contactId={contactId}
publishedLinkSurveys={publishedLinkSurveys}
/>
<EditContactAttributesModal
open={isEditAttributesModalOpen}
setOpen={setIsEditAttributesModalOpen}
contactId={contactId}
currentAttributes={currentAttributes}
attributeKeys={attributeKeys}
/>
</>
);
};

View File

@@ -2,6 +2,7 @@ import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getTranslate } from "@/lingodotdev/server";
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
@@ -21,12 +22,14 @@ export const SingleContactPage = async (props: {
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
const [environmentTags, contact, contactAttributes, publishedLinkSurveys] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getContact(params.contactId),
getContactAttributes(params.contactId),
getPublishedLinkSurveys(params.environmentId),
]);
const [environmentTags, contact, contactAttributes, publishedLinkSurveys, contactAttributeKeys] =
await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getContact(params.contactId),
getContactAttributes(params.contactId),
getPublishedLinkSurveys(params.environmentId),
getContactAttributeKeys(params.environmentId),
]);
if (!contact) {
throw new Error(t("environments.contacts.contact_not_found"));
@@ -42,6 +45,8 @@ export const SingleContactPage = async (props: {
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
publishedLinkSurveys={publishedLinkSurveys}
currentAttributes={contactAttributes}
attributeKeys={contactAttributeKeys}
/>
);
};
@@ -50,7 +55,7 @@ export const SingleContactPage = async (props: {
<PageContentWrapper>
<GoBackButton url={`/environments/${params.environmentId}/contacts`} />
<PageHeader pageTitle={getContactIdentifier(contactAttributes)} cta={getContactControlBar()} />
<section className="pb-24 pt-6">
<section className="pt-6 pb-24">
<div className="grid grid-cols-4 gap-x-8">
<AttributesSection contactId={params.contactId} />
<ResponseSection

View File

@@ -2,6 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributes } from "@formbricks/types/contact-attribute";
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";
@@ -12,7 +13,8 @@ import {
getProjectIdFromEnvironmentId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { createContactsFromCSV, deleteContact, getContacts } from "./lib/contacts";
import { createContactsFromCSV, deleteContact, getContact, getContacts } from "./lib/contacts";
import { updateContactAttributes } from "./lib/update-contact-attributes";
import {
ZContactCSVAttributeMap,
ZContactCSVDuplicateAction,
@@ -129,3 +131,62 @@ export const createContactsFromCSVAction = authenticatedActionClient.schema(ZCre
}
)
);
const ZUpdateContactAttributesAction = z.object({
contactId: ZId,
attributes: ZContactAttributes,
});
export type TUpdateContactAttributesAction = z.infer<typeof ZUpdateContactAttributesAction>;
export const updateContactAttributesAction = authenticatedActionClient
.schema(ZUpdateContactAttributesAction)
.action(
withAuditLogging(
"updated",
"contact",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUpdateContactAttributesAction;
}) => {
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.contactId = parsedInput.contactId;
// Get contact to access environmentId for revalidation
const contact = await getContact(parsedInput.contactId);
if (!contact) {
throw new Error("Contact not found");
}
const result = await updateContactAttributes(parsedInput.contactId, parsedInput.attributes);
ctx.auditLoggingCtx.newObject = {
contactId: parsedInput.contactId,
attributes: result.updatedAttributes,
};
return result;
}
)
);

View File

@@ -0,0 +1,270 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
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 {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { InputCombobox, TComboboxOption } from "@/modules/ui/components/input-combo-box";
import { updateContactAttributesAction } from "../actions";
import { TEditContactAttributesForm, ZEditContactAttributesForm } from "../types/contact";
interface EditContactAttributesModalProps {
open: boolean;
setOpen: (open: boolean) => void;
contactId: string;
currentAttributes: TContactAttributes;
attributeKeys: TContactAttributeKey[];
}
export const EditContactAttributesModal = ({
open,
setOpen,
contactId,
currentAttributes,
attributeKeys,
}: EditContactAttributesModalProps) => {
const { t } = useTranslation();
const router = useRouter();
// Convert current attributes to form format
const defaultValues: TEditContactAttributesForm = useMemo(
() => ({
attributes: Object.entries(currentAttributes).map(([key, value]) => ({
key,
value: value ?? "",
})),
}),
[currentAttributes]
);
const form = useForm<TEditContactAttributesForm>({
resolver: zodResolver(ZEditContactAttributesForm),
defaultValues,
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "attributes",
});
// Watch form values to get currently selected keys
const watchedAttributes = form.watch("attributes");
// Prepare combobox options from attribute keys
const allKeyOptions: TComboboxOption[] = attributeKeys.map((attrKey) => ({
label: attrKey.name ?? attrKey.key,
value: attrKey.key,
}));
// Get available options for a specific field index (exclude already selected keys from other fields)
const getAvailableOptions = (currentIndex: number): TComboboxOption[] => {
const selectedKeys = new Set(
watchedAttributes
.map((attr, index) => (index !== currentIndex && attr.key ? String(attr.key) : null))
.filter((key): key is string => key !== null && key !== "")
);
return allKeyOptions.filter((option) => !selectedKeys.has(String(option.value)));
};
// Scroll to first error on validation failure
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
const errors = form.formState.errors;
if (
errors.attributes &&
Array.isArray(errors.attributes) &&
form.formState.isSubmitted &&
formRef.current
) {
// Find the first error field
const firstErrorIndex = errors.attributes.findIndex((error) => error?.key || error?.value);
if (firstErrorIndex !== -1) {
const errorFieldId = `attribute-key-${firstErrorIndex}`;
const errorElement = document.getElementById(errorFieldId);
if (errorElement) {
setTimeout(() => {
errorElement.scrollIntoView({ behavior: "smooth", block: "center" });
// Try to focus the input inside the combobox if it exists
const inputElement = errorElement.querySelector("input") as HTMLInputElement;
if (inputElement) {
inputElement.focus();
} else {
errorElement.focus();
}
}, 100);
}
}
}
}, [form.formState.errors, form.formState.isSubmitted]);
const onSubmit = async (data: TEditContactAttributesForm) => {
try {
const attributes = data.attributes.reduce((acc, { key, value }) => {
acc[key] = value;
return acc;
}, {});
const result = await updateContactAttributesAction({
contactId,
attributes,
});
if (result?.data) {
toast.success(t("environments.contacts.edit_attributes_success"));
if (result.data.messages && result.data.messages.length > 0) {
result.data.messages.forEach((message) => {
toast.error(message, { duration: 5000 });
});
}
router.refresh();
setOpen(false);
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error) {
toast.error("common.something_went_wrong");
console.error(error);
}
};
const handleAddAttribute = () => {
append({ key: "", value: "" });
};
const handleRemoveAttribute = (index: number) => {
remove(index);
};
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent width="default" className="max-h-[90vh]">
<DialogHeader>
<DialogTitle>{t("environments.contacts.edit_attribute_values")}</DialogTitle>
<DialogDescription>
{t("environments.contacts.edit_attribute_values_description")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<FormProvider {...form}>
<form ref={formRef} onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<FormField
control={form.control}
name={`attributes.${index}.key`}
render={({ field: keyField }) => (
<FormItem className="flex-1">
<FormLabel>{t("environments.contacts.attribute_key")}</FormLabel>
<FormControl>
<InputCombobox
id={`attribute-key-${index}`}
options={getAvailableOptions(index)}
value={keyField.value || null}
onChangeValue={(value) => {
keyField.onChange(typeof value === "string" ? value : String(value || ""));
}}
withInput={true}
showSearch={true}
inputProps={{
placeholder: t("environments.contacts.attribute_key_placeholder"),
className: "w-full border-0",
}}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`attributes.${index}.value`}
render={({ field: valueField }) => (
<FormItem className="flex-1">
<FormLabel>{t("environments.contacts.attribute_value")}</FormLabel>
<FormControl>
<Input
{...valueField}
placeholder={t("environments.contacts.attribute_value_placeholder")}
className="w-full"
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<div className="flex items-end pb-2">
<Button
type="button"
variant="ghost"
disabled={["email", "userId", "firstName", "lastName"].includes(field.key)}
size="sm"
onClick={() => handleRemoveAttribute(index)}
className="h-10 w-10 p-0">
<TrashIcon className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
<Button type="button" variant="outline" onClick={handleAddAttribute} className="w-fit">
<PlusIcon className="mr-2 h-4 w-4" />
{t("environments.contacts.add_attribute")}
</Button>
{form.formState.errors.attributes?.root && (
<FormError>{form.formState.errors.attributes.root.message}</FormError>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("common.save_changes")}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogBody>
</DialogContent>
</Dialog>
);
};

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContactAttributes, hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
import { updateAttributes } from "./attributes";
vi.mock("@/lib/constants", () => ({
@@ -14,13 +14,18 @@ vi.mock("@/lib/utils/validate", () => ({
vi.mock("@/modules/ee/contacts/lib/contact-attribute-keys", () => ({
getContactAttributeKeys: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/lib/contact-attributes", () => ({
hasEmailAttribute: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/lib/contact-attributes", async () => {
const actual = await vi.importActual("@/modules/ee/contacts/lib/contact-attributes");
return {
...actual,
getContactAttributes: vi.fn(),
hasEmailAttribute: vi.fn(),
};
});
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
contactAttribute: { upsert: vi.fn() },
contactAttribute: { upsert: vi.fn(), findMany: vi.fn(), deleteMany: vi.fn() },
contactAttributeKey: { create: vi.fn() },
},
}));
@@ -52,17 +57,34 @@ const attributeKeys: TContactAttributeKey[] = [
type: "default",
environmentId,
},
{
id: "key-3",
key: "customAttr",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: false,
name: "Custom Attribute",
description: null,
type: "custom",
environmentId,
},
];
describe("updateAttributes", () => {
beforeEach(() => {
vi.clearAllMocks();
// Set default mock return values - these will be overridden in individual tests
vi.mocked(getContactAttributes).mockResolvedValue({});
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
});
test("updates existing attributes", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", email: "john@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
@@ -72,8 +94,10 @@ describe("updateAttributes", () => {
test("skips updating email if it already exists", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", email: "john@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
@@ -84,8 +108,10 @@ describe("updateAttributes", () => {
test("creates new attributes if under limit", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0]]);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", newAttr: "val" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
@@ -96,8 +122,10 @@ describe("updateAttributes", () => {
test("does not create new attributes if over the limit", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { name: "John", newAttr: "val" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(result.success).toBe(true);
@@ -106,11 +134,110 @@ describe("updateAttributes", () => {
test("returns success with no attributes to update or create", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([]);
vi.mocked(getContactAttributes).mockResolvedValue({});
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = {};
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
});
test("deletes non-default attributes that are removed from payload", async () => {
// Reset mocks explicitly for this test
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(getContactAttributes).mockResolvedValue({
name: "Jane",
email: "jane@example.com",
customAttr: "oldValue",
});
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 1 });
const attributes = { name: "John", email: "john@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
// Only customAttr (key-3) should be deleted, not name (key-1) or email (key-2)
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalledWith({
where: {
contactId,
attributeKeyId: {
in: ["key-3"],
},
},
});
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
});
test("does not delete default attributes even if removed from payload", async () => {
// Reset mocks explicitly for this test
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
// Need to include userId and firstName in attributeKeys for this test
// Note: DEFAULT_ATTRIBUTES includes: email, userId, firstName, lastName (not "name")
const attributeKeysWithDefaults: TContactAttributeKey[] = [
{
id: "key-2",
key: "email",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: false,
name: "Email",
description: null,
type: "default",
environmentId,
},
{
id: "key-4",
key: "userId",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: false,
name: "User ID",
description: null,
type: "default",
environmentId,
},
{
id: "key-5",
key: "firstName",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: false,
name: "First Name",
description: null,
type: "default",
environmentId,
},
{
id: "key-3",
key: "customAttr",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: false,
name: "Custom Attribute",
description: null,
type: "custom",
environmentId,
},
];
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithDefaults);
vi.mocked(getContactAttributes).mockResolvedValue({
email: "test@example.com",
userId: "user-123",
firstName: "John",
});
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
const attributes = { customAttr: "value" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
// Should not delete default attributes (email, userId, firstName) - deleteMany should not be called
// since all current attributes are default attributes
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
expect(result.success).toBe(true);
});
});

View File

@@ -1,10 +1,51 @@
import { prisma } from "@formbricks/database";
import { ZId, ZString } from "@formbricks/types/common";
import { TContactAttributes, ZContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContactAttributes, hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
// Default/system attributes that should not be deleted even if missing from payload
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
const deleteAttributes = async (
contactId: string,
currentAttributes: TContactAttributes,
submittedAttributes: TContactAttributes,
contactAttributeKeys: TContactAttributeKey[]
): Promise<{ success: boolean }> => {
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
// Determine which attributes should be deleted (exist in DB but not in payload, and not default attributes)
const submittedKeys = new Set(Object.keys(submittedAttributes));
const currentKeys = new Set(Object.keys(currentAttributes));
const keysToDelete = Array.from(currentKeys).filter(
(key) => !submittedKeys.has(key) && !DEFAULT_ATTRIBUTES.has(key)
);
// Get attribute key IDs for deletion
const attributeKeyIdsToDelete = keysToDelete
.map((key) => contactAttributeKeyMap.get(key)?.id)
.filter((id): id is string => !!id);
// Delete attributes that were removed from the form (but not default attributes)
if (attributeKeyIdsToDelete.length > 0) {
await prisma.contactAttribute.deleteMany({
where: {
contactId,
attributeKeyId: {
in: attributeKeyIdsToDelete,
},
},
});
}
return {
success: true,
};
};
export const updateAttributes = async (
contactId: string,
@@ -21,8 +62,9 @@ export const updateAttributes = async (
let ignoreEmailAttribute = false;
// Fetch contact attribute keys and email check in parallel
const [contactAttributeKeys, existingEmailAttribute] = await Promise.all([
// Fetch current attributes, contact attribute keys, and email check in parallel
const [currentAttributes, contactAttributeKeys, existingEmailAttribute] = await Promise.all([
getContactAttributes(contactId),
getContactAttributeKeys(environmentId),
contactAttributesParam.email
? hasEmailAttribute(contactAttributesParam.email, environmentId, contactId)
@@ -34,6 +76,9 @@ export const updateAttributes = async (
const contactAttributes = existingEmailAttribute ? remainingAttributes : contactAttributesParam;
const emailExists = !!existingEmailAttribute;
// Delete attributes that were removed (using the deleteAttributes service)
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys);
// Create lookup map for attribute keys
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
@@ -62,7 +107,7 @@ export const updateAttributes = async (
ignoreEmailAttribute = true;
}
// First, update all existing attributes
// Update all existing attributes
if (existingAttributes.length > 0) {
await prisma.$transaction(
existingAttributes.map(({ attributeKeyId, value }) =>

View File

@@ -0,0 +1,294 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { updateAttributes } from "./attributes";
import { getContactAttributeKeys } from "./contact-attribute-keys";
import { getContactAttributes } from "./contact-attributes";
import { getContact } from "./contacts";
import { updateContactAttributes } from "./update-contact-attributes";
// Mock dependencies
vi.mock("./contacts");
vi.mock("./contact-attributes");
vi.mock("./contact-attribute-keys");
vi.mock("./attributes");
describe("updateContactAttributes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should update contact attributes successfully", async () => {
const contactId = "contact123";
const environmentId = "env123";
const userId = "user123";
const attributes = {
userId,
firstName: "John",
lastName: "Doe",
email: "john@example.com",
};
const mockContact = {
id: contactId,
environmentId,
attributes: {
userId,
firstName: "Jane",
lastName: "Smith",
},
};
const mockCurrentKeys = [
{
id: "key1",
key: "firstName",
name: "First Name",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "default" as const,
isUnique: false,
description: null,
},
{
id: "key2",
key: "lastName",
name: "Last Name",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "default" as const,
isUnique: false,
description: null,
},
{
id: "key3",
key: "email",
name: "Email",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "default" as const,
isUnique: false,
description: null,
},
];
const mockUpdatedAttributes = {
firstName: "John",
lastName: "Doe",
email: "john@example.com",
};
vi.mocked(getContact).mockResolvedValue(mockContact as any);
vi.mocked(getContactAttributeKeys).mockResolvedValueOnce(mockCurrentKeys);
vi.mocked(updateAttributes).mockResolvedValue({
success: true,
});
vi.mocked(getContactAttributes).mockResolvedValue(mockUpdatedAttributes);
vi.mocked(getContactAttributeKeys).mockResolvedValueOnce(mockCurrentKeys);
const result = await updateContactAttributes(contactId, attributes);
expect(getContact).toHaveBeenCalledWith(contactId);
expect(getContactAttributeKeys).toHaveBeenCalledWith(environmentId);
expect(updateAttributes).toHaveBeenCalledWith(contactId, userId, environmentId, attributes);
expect(getContactAttributes).toHaveBeenCalledWith(contactId);
expect(result.updatedAttributes).toEqual(mockUpdatedAttributes);
expect(result.updatedAttributeKeys).toBeUndefined();
});
it("should detect new attribute keys when created", async () => {
const contactId = "contact123";
const environmentId = "env123";
const userId = "user123";
const attributes = {
firstName: "John",
newCustomField: "custom value",
};
const mockContact = {
id: contactId,
environmentId,
attributes: {
userId,
firstName: "Jane",
},
};
const mockCurrentKeys = [
{
id: "key1",
key: "firstName",
name: "First Name",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "default" as const,
isUnique: false,
description: null,
},
];
const mockUpdatedKeys = [
{
id: "key1",
key: "firstName",
name: "First Name",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "default" as const,
isUnique: false,
description: null,
},
{
id: "key2",
key: "newCustomField",
name: "newCustomField",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "custom" as const,
isUnique: false,
description: null,
},
];
const mockUpdatedAttributes = {
firstName: "John",
newCustomField: "custom value",
};
vi.mocked(getContact).mockResolvedValue(mockContact as any);
vi.mocked(getContactAttributeKeys).mockResolvedValueOnce(mockCurrentKeys);
vi.mocked(updateAttributes).mockResolvedValue({
success: true,
});
vi.mocked(getContactAttributes).mockResolvedValue(mockUpdatedAttributes);
vi.mocked(getContactAttributeKeys).mockResolvedValueOnce(mockUpdatedKeys);
const result = await updateContactAttributes(contactId, attributes);
expect(result.updatedAttributes).toEqual(mockUpdatedAttributes);
expect(result.updatedAttributeKeys).toEqual([
{
id: "key2",
key: "newCustomField",
name: "newCustomField",
environmentId,
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
type: "custom",
isUnique: false,
description: null,
},
]);
});
it("should handle missing userId with warning message", async () => {
const contactId = "contact123";
const environmentId = "env123";
const attributes = {
firstName: "John",
};
const mockContact = {
id: contactId,
environmentId,
attributes: {
firstName: "Jane",
},
};
const mockCurrentKeys = [
{
id: "key1",
key: "firstName",
name: "First Name",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "default" as const,
isUnique: false,
description: null,
},
];
const mockUpdatedAttributes = {
firstName: "John",
};
vi.mocked(getContact).mockResolvedValue(mockContact as any);
vi.mocked(getContactAttributeKeys).mockResolvedValueOnce(mockCurrentKeys);
vi.mocked(updateAttributes).mockResolvedValue({
success: true,
});
vi.mocked(getContactAttributes).mockResolvedValue(mockUpdatedAttributes);
vi.mocked(getContactAttributeKeys).mockResolvedValueOnce(mockCurrentKeys);
const result = await updateContactAttributes(contactId, attributes);
expect(updateAttributes).toHaveBeenCalledWith(contactId, "", environmentId, attributes);
expect(result.messages).toContain(
"Warning: userId attribute is missing. Some operations may not work correctly."
);
});
it("should merge messages from updateAttributes", async () => {
const contactId = "contact123";
const environmentId = "env123";
const userId = "user123";
const attributes = {
email: "existing@example.com",
};
const mockContact = {
id: contactId,
environmentId,
attributes: {
userId,
},
};
const mockCurrentKeys = [
{
id: "key1",
key: "email",
name: "Email",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
type: "default" as const,
isUnique: false,
description: null,
},
];
const mockUpdatedAttributes = {
email: "existing@example.com",
};
vi.mocked(getContact).mockResolvedValue(mockContact as any);
vi.mocked(getContactAttributeKeys).mockResolvedValueOnce(mockCurrentKeys);
vi.mocked(updateAttributes).mockResolvedValue({
success: true,
messages: ["The email already exists for this environment and was not updated."],
});
vi.mocked(getContactAttributes).mockResolvedValue(mockUpdatedAttributes);
vi.mocked(getContactAttributeKeys).mockResolvedValueOnce(mockCurrentKeys);
const result = await updateContactAttributes(contactId, attributes);
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
});
it("should throw error if contact not found", async () => {
const contactId = "contact123";
const attributes = {
firstName: "John",
};
vi.mocked(getContact).mockResolvedValue(null);
await expect(updateContactAttributes(contactId, attributes)).rejects.toThrow(
"contact with ID contact123 not found"
);
});
});

View File

@@ -0,0 +1,66 @@
import "server-only";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { updateAttributes } from "./attributes";
import { getContactAttributeKeys } from "./contact-attribute-keys";
import { getContactAttributes } from "./contact-attributes";
import { getContact } from "./contacts";
export interface UpdateContactAttributesResult {
updatedAttributes: TContactAttributes;
messages?: string[];
updatedAttributeKeys?: TContactAttributeKey[];
}
/**
* Updates contact attributes for a single contact.
* Handles loading contact data, extracting userId, calling updateAttributes,
* and detecting if new attribute keys were created.
*/
export const updateContactAttributes = async (
contactId: string,
attributes: TContactAttributes
): Promise<UpdateContactAttributesResult> => {
// Load contact to get environmentId and current attributes
const contact = await getContact(contactId);
if (!contact) {
throw new ResourceNotFoundError("contact", contactId);
}
const environmentId = contact.environmentId;
// Extract userId from attributes (required by updateAttributes)
// If missing, pass empty string but note it in messages
const userId = attributes.userId ?? "";
const messages: string[] = [];
if (!attributes.userId) {
messages.push("Warning: userId attribute is missing. Some operations may not work correctly.");
}
// Get current attribute keys before update to detect new ones
const currentAttributeKeys = await getContactAttributeKeys(environmentId);
const currentKeysSet = new Set(currentAttributeKeys.map((key) => key.key));
// Call the existing updateAttributes function
const updateResult = await updateAttributes(contactId, userId, environmentId, attributes);
// Merge any messages from updateAttributes
if (updateResult.messages) {
messages.push(...updateResult.messages);
}
// Fetch updated attributes
const updatedAttributes = await getContactAttributes(contactId);
// Detect if new keys were created by comparing before/after
const updatedAttributeKeys = await getContactAttributeKeys(environmentId);
const newKeys = updatedAttributeKeys.filter((key) => !currentKeysSet.has(key.key));
return {
updatedAttributes,
messages: messages.length > 0 ? messages : undefined,
updatedAttributeKeys: newKeys.length > 0 ? newKeys : undefined,
};
};

View File

@@ -42,7 +42,7 @@ export const SegmentTableDataRow = ({
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{surveys?.length}</div>
</div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
@@ -52,7 +52,7 @@ export const SegmentTableDataRow = ({
}).replace("about", "")}
</div>
</div>
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
</div>
</button>

View File

@@ -300,3 +300,55 @@ export const ZContactResponse = z.object({
});
export type TContactResponse = z.infer<typeof ZContactResponse>;
// Schema for editing contact attributes in a form
export const ZAttributeRow = z.object({
key: z.string().min(1, "Key is required"),
value: z.string(),
});
export const ZEditContactAttributesForm = z.object({
attributes: z
.array(ZAttributeRow)
.min(1, "At least one attribute is required")
.superRefine((attributes, ctx) => {
// Check for duplicate keys and mark each duplicate row
const keyOccurrences = new Map<string, number[]>();
attributes.forEach((attr, index) => {
if (attr.key) {
const indices = keyOccurrences.get(attr.key) || [];
indices.push(index);
keyOccurrences.set(attr.key, indices);
}
});
// Mark all duplicate rows with errors
keyOccurrences.forEach((indices, key) => {
if (indices.length > 1) {
indices.forEach((index) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate key: ${key}`,
path: [index, "key"],
});
});
}
});
// Validate email format if key is "email"
attributes.forEach((attr, index) => {
if (attr.key === "email" && attr.value && attr.value.trim() !== "") {
const emailResult = z.string().email().safeParse(attr.value);
if (!emailResult.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid email format",
path: [index, "value"],
});
}
}
});
}),
});
export type TEditContactAttributesForm = z.infer<typeof ZEditContactAttributesForm>;

View File

@@ -70,7 +70,7 @@ export const DataTableHeader = <T,>({
onTouchStart={header.getResizeHandler()}
data-testid="column-resize-handle"
className={cn(
"absolute right-0 top-0 hidden h-full w-1 cursor-col-resize bg-slate-500",
"absolute top-0 right-0 hidden h-full w-1 cursor-col-resize bg-slate-500",
header.column.getIsResizing() ? "bg-black" : "bg-slate-500",
!header.column.getCanResize() ? "hidden" : "group-hover:block"
)}></button>

View File

@@ -108,8 +108,8 @@ export const SelectedRowSettings = <T,>({
const quotasDialogText = isQuotasAllowed
? t("environments.contacts.delete_contact_confirmation_with_quotas", {
value: selectedRowCount,
})
value: selectedRowCount,
})
: t("environments.contacts.delete_contact_confirmation");
let deleteDialogText: string;
@@ -142,9 +142,7 @@ export const SelectedRowSettings = <T,>({
return (
<>
<div className="bg-primary flex items-center gap-x-2 rounded-md p-1 px-2 text-xs text-white">
<div className="lowercase">
{`${selectedRowCount} ${selectedTypeLabel} ${t("common.selected")}`}
</div>
<div className="lowercase">{`${selectedRowCount} ${selectedTypeLabel} ${t("common.selected")}`}</div>
<Separator />
<Button
variant="outline"

View File

@@ -253,7 +253,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
data-testid="dropdown-menu-content">
<Command className="h-full max-h-[400px] overflow-y-auto">
{showSearch ? (
<div className="border-b border-slate-100 px-3">
<div className="border-b border-slate-100">
<CommandInput
placeholder={resolvedSearchPlaceholder}
className="h-8 border-none placeholder-slate-300 outline-none"