mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
feat: UI to change attribute value for contacts (#7040)
This commit is contained in:
committed by
GitHub
parent
fa2b63d6a1
commit
5b334f6623
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "公開されたリンクフォームはありません。まずリンクフォームを公開してください。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Нет доступных опубликованных опросов-ссылок. Пожалуйста, сначала опубликуйте опрос-ссылку.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "没有可用的已发布链接调查。请先发布一个链接调查。",
|
||||
|
||||
@@ -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": "沒有可用的已發佈連結問卷。請先發佈一個連結問卷。",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user