mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-18 19:41:17 -05:00
initial UI changes for the PoC
This commit is contained in:
@@ -1188,9 +1188,9 @@
|
||||
"add": "+ hinzufügen",
|
||||
"add_a_delay_or_auto_close_the_survey": "Füge eine Verzögerung hinzu oder schließe die Umfrage automatisch.",
|
||||
"add_a_four_digit_pin": "Füge eine vierstellige PIN hinzu",
|
||||
"add_a_new_question_to_your_survey": "Neue Frage hinzufügen",
|
||||
"add_a_variable_to_calculate": "Variable hinzufügen",
|
||||
"add_action_below": "Aktion unten hinzufügen",
|
||||
"add_block": "Block hinzufügen",
|
||||
"add_choice_below": "Auswahl unten hinzufügen",
|
||||
"add_color_coding": "Farbkodierung hinzufügen",
|
||||
"add_color_coding_description": "Füge rote, orange und grüne Farbcodes zu den Optionen hinzu.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Anderes hinzufügen",
|
||||
"add_photo_or_video": "Foto oder Video hinzufügen",
|
||||
"add_pin": "PIN hinzufügen",
|
||||
"add_question": "Frage hinzufügen",
|
||||
"add_question_below": "Frage unten hinzufügen",
|
||||
"add_row": "Zeile hinzufügen",
|
||||
"add_variable": "Variable hinzufügen",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
|
||||
"back_button_label": "Zurück\"- Button ",
|
||||
"background_styling": "Hintergründe",
|
||||
"block_deleted": "Block gelöscht.",
|
||||
"block_duplicated": "Block dupliziert.",
|
||||
"bold": "Fett",
|
||||
"brand_color": "Markenfarbe",
|
||||
"brightness": "Helligkeit",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
|
||||
"checkbox_label": "Checkbox-Beschriftung",
|
||||
"choose_the_actions_which_trigger_the_survey": "Aktionen auswählen, die die Umfrage auslösen.",
|
||||
"choose_the_first_question_on_your_block": "Wählen sie die erste frage in ihrem block",
|
||||
"choose_where_to_run_the_survey": "Wähle, wo die Umfrage durchgeführt werden soll.",
|
||||
"city": "Stadt",
|
||||
"close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Datumsformat",
|
||||
"days_before_showing_this_survey_again": "Tage, bevor diese Umfrage erneut angezeigt wird.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Entscheide, wie oft Leute diese Umfrage beantworten können.",
|
||||
"delete_block": "Block löschen",
|
||||
"delete_choice": "Auswahl löschen",
|
||||
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Enthält nicht alle von",
|
||||
"does_not_include_one_of": "Enthält nicht eines von",
|
||||
"does_not_start_with": "Fängt nicht an mit",
|
||||
"duplicate_block": "Block duplizieren",
|
||||
"edit_link": "Bearbeitungslink",
|
||||
"edit_recall": "Erinnerung bearbeiten",
|
||||
"edit_translations": "{lang} -Übersetzungen bearbeiten",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "Add +",
|
||||
"add_a_delay_or_auto_close_the_survey": "Add a delay or auto-close the survey",
|
||||
"add_a_four_digit_pin": "Add a four digit PIN",
|
||||
"add_a_new_question_to_your_survey": "Add a new question to your survey",
|
||||
"add_a_variable_to_calculate": "Add a variable to calculate",
|
||||
"add_action_below": "Add action below",
|
||||
"add_block": "Add Block",
|
||||
"add_choice_below": "Add choice below",
|
||||
"add_color_coding": "Add color coding",
|
||||
"add_color_coding_description": "Add red, orange and green color codes to the options.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Add \"Other\"",
|
||||
"add_photo_or_video": "Add photo or video",
|
||||
"add_pin": "Add PIN",
|
||||
"add_question": "Add question",
|
||||
"add_question_below": "Add question below",
|
||||
"add_row": "Add row",
|
||||
"add_variable": "Add variable",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
|
||||
"back_button_label": "\"Back\" Button Label",
|
||||
"background_styling": "Background Styling",
|
||||
"block_deleted": "Block deleted.",
|
||||
"block_duplicated": "Block duplicated.",
|
||||
"bold": "Bold",
|
||||
"brand_color": "Brand color",
|
||||
"brightness": "Brightness",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Add character limits",
|
||||
"checkbox_label": "Checkbox Label",
|
||||
"choose_the_actions_which_trigger_the_survey": "Choose the actions which trigger the survey.",
|
||||
"choose_the_first_question_on_your_block": "Choose the first question on your Block",
|
||||
"choose_where_to_run_the_survey": "Choose where to run the survey.",
|
||||
"city": "City",
|
||||
"close_survey_on_response_limit": "Close survey on response limit",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Date format",
|
||||
"days_before_showing_this_survey_again": "days before showing this survey again.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide how often people can answer this survey.",
|
||||
"delete_block": "Delete block",
|
||||
"delete_choice": "Delete choice",
|
||||
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Does not include all of",
|
||||
"does_not_include_one_of": "Does not include one of",
|
||||
"does_not_start_with": "Does not start with",
|
||||
"duplicate_block": "Duplicate block",
|
||||
"edit_link": "Edit link",
|
||||
"edit_recall": "Edit Recall",
|
||||
"edit_translations": "Edit {lang} translations",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "Ajouter +",
|
||||
"add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête",
|
||||
"add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.",
|
||||
"add_a_new_question_to_your_survey": "Ajouter une nouvelle question à votre enquête",
|
||||
"add_a_variable_to_calculate": "Ajouter une variable à calculer",
|
||||
"add_action_below": "Ajouter une action ci-dessous",
|
||||
"add_block": "Ajouter un bloc",
|
||||
"add_choice_below": "Ajouter une option ci-dessous",
|
||||
"add_color_coding": "Ajouter un code couleur",
|
||||
"add_color_coding_description": "Ajoutez des codes de couleur rouge, orange et vert aux options.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Ajouter \"Autre",
|
||||
"add_photo_or_video": "Ajouter une photo ou une vidéo",
|
||||
"add_pin": "Ajouter un code PIN",
|
||||
"add_question": "Ajouter une question",
|
||||
"add_question_below": "Ajouter une question ci-dessous",
|
||||
"add_row": "Ajouter une ligne",
|
||||
"add_variable": "Ajouter une variable",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
|
||||
"back_button_label": "Label du bouton \"Retour''",
|
||||
"background_styling": "Style de fond",
|
||||
"block_deleted": "Bloc supprimé.",
|
||||
"block_duplicated": "Bloc dupliqué.",
|
||||
"bold": "Gras",
|
||||
"brand_color": "Couleur de marque",
|
||||
"brightness": "Luminosité",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Ajouter des limites de caractères",
|
||||
"checkbox_label": "Étiquette de case à cocher",
|
||||
"choose_the_actions_which_trigger_the_survey": "Choisissez les actions qui déclenchent l'enquête.",
|
||||
"choose_the_first_question_on_your_block": "Choisissez la première question de votre bloc",
|
||||
"choose_where_to_run_the_survey": "Choisissez où réaliser l'enquête.",
|
||||
"city": "Ville",
|
||||
"close_survey_on_response_limit": "Fermer l'enquête sur la limite de réponse",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Format de date",
|
||||
"days_before_showing_this_survey_again": "jours avant de montrer à nouveau cette enquête.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Décidez à quelle fréquence les gens peuvent répondre à cette enquête.",
|
||||
"delete_block": "Supprimer le bloc",
|
||||
"delete_choice": "Supprimer l'option",
|
||||
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "n'inclut pas tout",
|
||||
"does_not_include_one_of": "n'inclut pas un de",
|
||||
"does_not_start_with": "Ne commence pas par",
|
||||
"duplicate_block": "Dupliquer le bloc",
|
||||
"edit_link": "Modifier le lien",
|
||||
"edit_recall": "Modifier le rappel",
|
||||
"edit_translations": "Modifier les traductions {lang}",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "追加 +",
|
||||
"add_a_delay_or_auto_close_the_survey": "遅延を追加するか、フォームを自動的に閉じる",
|
||||
"add_a_four_digit_pin": "4桁のPINを追加",
|
||||
"add_a_new_question_to_your_survey": "フォームに新しい質問を追加",
|
||||
"add_a_variable_to_calculate": "計算する変数を追加",
|
||||
"add_action_below": "以下にアクションを追加",
|
||||
"add_block": "ブロックを追加",
|
||||
"add_choice_below": "以下に選択肢を追加",
|
||||
"add_color_coding": "色分けを追加",
|
||||
"add_color_coding_description": "オプションに赤、オレンジ、緑の色コードを追加します。",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "「その他」を追加",
|
||||
"add_photo_or_video": "写真または動画を追加",
|
||||
"add_pin": "PINを追加",
|
||||
"add_question": "質問を追加",
|
||||
"add_question_below": "以下に質問を追加",
|
||||
"add_row": "行を追加",
|
||||
"add_variable": "変数を追加",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
|
||||
"back_button_label": "「戻る」ボタンのラベル",
|
||||
"background_styling": "背景のスタイル",
|
||||
"block_deleted": "ブロックが削除されました。",
|
||||
"block_duplicated": "ブロックが複製されました。",
|
||||
"bold": "太字",
|
||||
"brand_color": "ブランドカラー",
|
||||
"brightness": "明るさ",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "文字数制限を追加",
|
||||
"checkbox_label": "チェックボックスのラベル",
|
||||
"choose_the_actions_which_trigger_the_survey": "フォームをトリガーするアクションを選択してください。",
|
||||
"choose_the_first_question_on_your_block": "ブロックの最初の質問を選択してください",
|
||||
"choose_where_to_run_the_survey": "フォームを実行する場所を選択してください。",
|
||||
"city": "市区町村",
|
||||
"close_survey_on_response_limit": "回答数の上限でフォームを閉じる",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "日付形式",
|
||||
"days_before_showing_this_survey_again": "日後にこのフォームを再度表示します。",
|
||||
"decide_how_often_people_can_answer_this_survey": "このフォームに人々が何回回答できるかを決定します。",
|
||||
"delete_block": "ブロックを削除",
|
||||
"delete_choice": "選択肢を削除",
|
||||
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "のすべてを含まない",
|
||||
"does_not_include_one_of": "のいずれも含まない",
|
||||
"does_not_start_with": "で始まらない",
|
||||
"duplicate_block": "ブロックを複製",
|
||||
"edit_link": "編集 リンク",
|
||||
"edit_recall": "リコールを編集",
|
||||
"edit_translations": "{lang} 翻訳を編集",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "Adicionar +",
|
||||
"add_a_delay_or_auto_close_the_survey": "Adicione um atraso ou feche a pesquisa automaticamente",
|
||||
"add_a_four_digit_pin": "Adicione um PIN de quatro dígitos",
|
||||
"add_a_new_question_to_your_survey": "Adicionar uma nova pergunta à sua pesquisa",
|
||||
"add_a_variable_to_calculate": "Adicione uma variável para calcular",
|
||||
"add_action_below": "Adicionar ação abaixo",
|
||||
"add_block": "Adicionar bloco",
|
||||
"add_choice_below": "Adicionar opção abaixo",
|
||||
"add_color_coding": "Adicionar codificação por cores",
|
||||
"add_color_coding_description": "Adicione os códigos de cores vermelho, laranja e verde às opções.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Adicionar \"Outro",
|
||||
"add_photo_or_video": "Adicionar foto ou video",
|
||||
"add_pin": "Adicionar PIN",
|
||||
"add_question": "Adicionar pergunta",
|
||||
"add_question_below": "Adicione a pergunta abaixo",
|
||||
"add_row": "Adicionar linha",
|
||||
"add_variable": "Adicionar variável",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
|
||||
"back_button_label": "Voltar",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"block_deleted": "Bloco excluído.",
|
||||
"block_duplicated": "Bloco duplicado.",
|
||||
"bold": "Negrito",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "brilho",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Adicionar limites de caracteres",
|
||||
"checkbox_label": "Rótulo da Caixa de Seleção",
|
||||
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que disparam a pesquisa.",
|
||||
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta do seu bloco",
|
||||
"choose_where_to_run_the_survey": "Escolha onde realizar a pesquisa.",
|
||||
"city": "cidade",
|
||||
"close_survey_on_response_limit": "Fechar pesquisa ao atingir limite de respostas",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Formato de data",
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar essa pesquisa de novo.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a essa pesquisa.",
|
||||
"delete_block": "Excluir bloco",
|
||||
"delete_choice": "Deletar opção",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"duplicate_block": "Duplicar bloco",
|
||||
"edit_link": "Editar link",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções de {lang}",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "Adicionar +",
|
||||
"add_a_delay_or_auto_close_the_survey": "Adicionar um atraso ou fechar automaticamente o inquérito",
|
||||
"add_a_four_digit_pin": "Adicione um PIN de quatro dígitos",
|
||||
"add_a_new_question_to_your_survey": "Adicionar uma nova pergunta ao seu inquérito",
|
||||
"add_a_variable_to_calculate": "Adicionar uma variável para calcular",
|
||||
"add_action_below": "Adicionar ação abaixo",
|
||||
"add_block": "Adicionar bloco",
|
||||
"add_choice_below": "Adicionar escolha abaixo",
|
||||
"add_color_coding": "Adicionar codificação de cores",
|
||||
"add_color_coding_description": "Adicionar códigos de cores vermelho, laranja e verde às opções.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Adicionar \"Outro\"",
|
||||
"add_photo_or_video": "Adicionar foto ou vídeo",
|
||||
"add_pin": "Adicionar PIN",
|
||||
"add_question": "Adicionar pergunta",
|
||||
"add_question_below": "Adicionar pergunta abaixo",
|
||||
"add_row": "Adicionar linha",
|
||||
"add_variable": "Adicionar variável",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
|
||||
"back_button_label": "Rótulo do botão \"Voltar\"",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"block_deleted": "Bloco eliminado.",
|
||||
"block_duplicated": "Bloco duplicado.",
|
||||
"bold": "Negrito",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "Brilho",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Adicionar limites de caracteres",
|
||||
"checkbox_label": "Rótulo da Caixa de Seleção",
|
||||
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que desencadeiam o inquérito.",
|
||||
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta no seu bloco",
|
||||
"choose_where_to_run_the_survey": "Escolha onde realizar o inquérito.",
|
||||
"city": "Cidade",
|
||||
"close_survey_on_response_limit": "Fechar inquérito no limite de respostas",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Formato da data",
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar este inquérito novamente.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a este inquérito.",
|
||||
"delete_block": "Eliminar bloco",
|
||||
"delete_choice": "Eliminar escolha",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"duplicate_block": "Duplicar bloco",
|
||||
"edit_link": "Editar link",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções {lang}",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "Adaugă +",
|
||||
"add_a_delay_or_auto_close_the_survey": "Adăugați o întârziere sau închideți automat sondajul",
|
||||
"add_a_four_digit_pin": "Adăugați un cod PIN din patru cifre",
|
||||
"add_a_new_question_to_your_survey": "Adaugă o nouă întrebare la sondajul tău",
|
||||
"add_a_variable_to_calculate": "Adaugă o variabilă pentru calcul",
|
||||
"add_action_below": "Adăugați acțiune mai jos",
|
||||
"add_block": "Adaugă bloc",
|
||||
"add_choice_below": "Adaugă opțiunea de mai jos",
|
||||
"add_color_coding": "Adăugați codificare color",
|
||||
"add_color_coding_description": "Adăugați coduri de culoare roșu, portocaliu și verde la opțiuni.",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "Adăugați \"Altele\"",
|
||||
"add_photo_or_video": "Adaugă fotografie sau video",
|
||||
"add_pin": "Adaugă PIN",
|
||||
"add_question": "Adaugă întrebare",
|
||||
"add_question_below": "Adaugă întrebare mai jos",
|
||||
"add_row": "Adăugați rând",
|
||||
"add_variable": "Adaugă variabilă",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după",
|
||||
"back_button_label": "Etichetă buton \"Înapoi\"",
|
||||
"background_styling": "Stilizare fundal",
|
||||
"block_deleted": "Bloc șters.",
|
||||
"block_duplicated": "Bloc duplicat.",
|
||||
"bold": "Îngroșat",
|
||||
"brand_color": "Culoarea brandului",
|
||||
"brightness": "Luminozitate",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "Adăugați limite de caractere",
|
||||
"checkbox_label": "Etichetă casetă de selectare",
|
||||
"choose_the_actions_which_trigger_the_survey": "Alegeți acțiunile care declanșează sondajul.",
|
||||
"choose_the_first_question_on_your_block": "Alege prima întrebare din blocul tău",
|
||||
"choose_where_to_run_the_survey": "Alegeți unde să rulați chestionarul.",
|
||||
"city": "Oraș",
|
||||
"close_survey_on_response_limit": "Închideți sondajul la limită de răspunsuri",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "Format dată",
|
||||
"days_before_showing_this_survey_again": "zile înainte de a afișa din nou acest sondaj.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide cât de des pot răspunde oamenii la acest sondaj",
|
||||
"delete_block": "Șterge blocul",
|
||||
"delete_choice": "Șterge alegerea",
|
||||
"disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "Nu include toate",
|
||||
"does_not_include_one_of": "Nu include una dintre",
|
||||
"does_not_start_with": "Nu începe cu",
|
||||
"duplicate_block": "Duplicați blocul",
|
||||
"edit_link": "Editare legătură",
|
||||
"edit_recall": "Editează Referințele",
|
||||
"edit_translations": "Editează traducerile {lang}",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "添加 +",
|
||||
"add_a_delay_or_auto_close_the_survey": "添加 延迟 或 自动 关闭 调查",
|
||||
"add_a_four_digit_pin": "添加 一个 四 位 数 PIN",
|
||||
"add_a_new_question_to_your_survey": "添加一个新问题到您的调查中",
|
||||
"add_a_variable_to_calculate": "添加 变量 以 计算",
|
||||
"add_action_below": "在下面添加操作",
|
||||
"add_block": "添加区块",
|
||||
"add_choice_below": "在下方添加选项",
|
||||
"add_color_coding": "添加 颜色 编码",
|
||||
"add_color_coding_description": "添加 红色 、橙色 和 绿色 颜色 编码 到 选项。",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "添加 \"其他\"",
|
||||
"add_photo_or_video": "添加 照片 或 视频",
|
||||
"add_pin": "添加 PIN",
|
||||
"add_question": "添加问题",
|
||||
"add_question_below": "在下面 添加 问题",
|
||||
"add_row": "添加 行",
|
||||
"add_variable": "添加 变量",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
|
||||
"back_button_label": "\"返回\" 按钮标签",
|
||||
"background_styling": "背景 样式",
|
||||
"block_deleted": "区块已删除。",
|
||||
"block_duplicated": "区块已复制。",
|
||||
"bold": "粗体",
|
||||
"brand_color": "品牌 颜色",
|
||||
"brightness": "亮度",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "添加 字符限制",
|
||||
"checkbox_label": "复选框 标签",
|
||||
"choose_the_actions_which_trigger_the_survey": "选择 触发 调查 的 动作 。",
|
||||
"choose_the_first_question_on_your_block": "选择区块中的第一个问题",
|
||||
"choose_where_to_run_the_survey": "选择 调查 运行 的 位置 。",
|
||||
"city": "城市",
|
||||
"close_survey_on_response_limit": "在响应限制时关闭 调查",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "显示 此 调查 之前 的 天数。",
|
||||
"decide_how_often_people_can_answer_this_survey": "决定 人 可以 回答 这份 调查 的 频率 。",
|
||||
"delete_block": "删除区块",
|
||||
"delete_choice": "删除 选择",
|
||||
"disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "不包括所有 ",
|
||||
"does_not_include_one_of": "不包括一 个",
|
||||
"does_not_start_with": "不 以 开头",
|
||||
"duplicate_block": "复制区块",
|
||||
"edit_link": "编辑 链接",
|
||||
"edit_recall": "编辑 调用",
|
||||
"edit_translations": "编辑 {lang} 翻译",
|
||||
|
||||
@@ -1188,9 +1188,9 @@
|
||||
"add": "新增 +",
|
||||
"add_a_delay_or_auto_close_the_survey": "新增延遲或自動關閉問卷",
|
||||
"add_a_four_digit_pin": "新增四位數 PIN 碼",
|
||||
"add_a_new_question_to_your_survey": "在您的問卷中新增一個新問題",
|
||||
"add_a_variable_to_calculate": "新增要計算的變數",
|
||||
"add_action_below": "在下方新增操作",
|
||||
"add_block": "新增區塊",
|
||||
"add_choice_below": "在下方新增選項",
|
||||
"add_color_coding": "新增顏色編碼",
|
||||
"add_color_coding_description": "為選項新增紅色、橘色和綠色顏色代碼。",
|
||||
@@ -1211,7 +1211,6 @@
|
||||
"add_other": "新增「其他」",
|
||||
"add_photo_or_video": "新增照片或影片",
|
||||
"add_pin": "新增 PIN 碼",
|
||||
"add_question": "新增問題",
|
||||
"add_question_below": "在下方新增問題",
|
||||
"add_row": "新增列",
|
||||
"add_variable": "新增變數",
|
||||
@@ -1239,6 +1238,8 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
|
||||
"back_button_label": "「返回」按鈕標籤",
|
||||
"background_styling": "背景樣式設定",
|
||||
"block_deleted": "區塊已刪除。",
|
||||
"block_duplicated": "區塊已複製。",
|
||||
"bold": "粗體",
|
||||
"brand_color": "品牌顏色",
|
||||
"brightness": "亮度",
|
||||
@@ -1283,6 +1284,7 @@
|
||||
"character_limit_toggle_title": "新增字元限制",
|
||||
"checkbox_label": "核取方塊標籤",
|
||||
"choose_the_actions_which_trigger_the_survey": "選擇觸發問卷的操作。",
|
||||
"choose_the_first_question_on_your_block": "選擇此區塊的第一個問題",
|
||||
"choose_where_to_run_the_survey": "選擇在哪裡執行問卷。",
|
||||
"city": "城市",
|
||||
"close_survey_on_response_limit": "在回應次數上限關閉問卷",
|
||||
@@ -1311,6 +1313,7 @@
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "天後再次顯示此問卷。",
|
||||
"decide_how_often_people_can_answer_this_survey": "決定人們可以回答此問卷的頻率。",
|
||||
"delete_block": "刪除區塊",
|
||||
"delete_choice": "刪除選項",
|
||||
"disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間",
|
||||
@@ -1322,6 +1325,7 @@
|
||||
"does_not_include_all_of": "不包含全部",
|
||||
"does_not_include_one_of": "不包含其中之一",
|
||||
"does_not_start_with": "不以...開頭",
|
||||
"duplicate_block": "複製區塊",
|
||||
"edit_link": "編輯 連結",
|
||||
"edit_recall": "編輯回憶",
|
||||
"edit_translations": "編輯 '{'language'}' 翻譯",
|
||||
|
||||
@@ -10,7 +10,6 @@ import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/survey
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -43,10 +42,10 @@ interface QuestionFormInputProps {
|
||||
value: TI18nString | undefined;
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
updateQuestion?: (questionIdx: number, data: Partial<TSurveyQuestion>) => void;
|
||||
updateQuestion?: (questionIdx: number, data: Partial<TSurveyElement>) => void;
|
||||
updateSurvey?: (data: Partial<TSurveyEndScreenCard> | Partial<TSurveyRedirectUrlCard>) => void;
|
||||
updateChoice?: (choiceIdx: number, data: Partial<TSurveyQuestionChoice>) => void;
|
||||
updateMatrixLabel?: (index: number, type: "row" | "column", data: Partial<TSurveyQuestion>) => void;
|
||||
updateMatrixLabel?: (index: number, type: "row" | "column", data: Partial<TSurveyElement>) => void;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
|
||||
@@ -42,9 +42,9 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
|
||||
<PlusIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-sm font-semibold">{t("environments.surveys.edit.add_question")}</p>
|
||||
<p className="text-sm font-semibold">{t("environments.surveys.edit.add_block")}</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.surveys.edit.add_a_new_question_to_your_survey")}
|
||||
{t("environments.surveys.edit.choose_the_first_question_on_your_block")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Project } from "@prisma/client";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { addElementToBlock } from "@/modules/survey/editor/lib/blocks";
|
||||
import {
|
||||
getCXQuestionNameMap,
|
||||
getQuestionDefaults,
|
||||
getQuestionIconMap,
|
||||
getQuestionNameMap,
|
||||
universalQuestionPresets,
|
||||
} from "@/modules/survey/lib/questions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface AddQuestionToBlockButtonProps {
|
||||
localSurvey: TSurvey;
|
||||
block: TSurveyBlock;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
project: Project;
|
||||
isCxMode: boolean;
|
||||
}
|
||||
|
||||
export const AddQuestionToBlockButton = ({
|
||||
localSurvey,
|
||||
block,
|
||||
setLocalSurvey,
|
||||
project,
|
||||
isCxMode,
|
||||
}: AddQuestionToBlockButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const availableQuestionTypes = isCxMode ? getCXQuestionNameMap(t) : getQuestionNameMap(t);
|
||||
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||
|
||||
// Check if block contains CTA or Cal.com question (these must be alone)
|
||||
const hasRestrictedType = block.elements.some(
|
||||
(element) => element.type === TSurveyElementTypeEnum.CTA || element.type === TSurveyElementTypeEnum.Cal
|
||||
);
|
||||
|
||||
const handleAddQuestion = (questionType: string) => {
|
||||
// Check if adding this type would violate restrictions
|
||||
if (questionType === TSurveyElementTypeEnum.CTA || questionType === TSurveyElementTypeEnum.Cal) {
|
||||
if (block.elements.length > 0) {
|
||||
toast.error("CTA and Cal.com questions must be alone in a block");
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get language symbols and add multi-language support
|
||||
const languageSymbols = extractLanguageCodes(localSurvey.languages);
|
||||
|
||||
const questionDefaults = getQuestionDefaults(questionType, project, t);
|
||||
const questionWithLabels = addMultiLanguageLabels(
|
||||
{
|
||||
...universalQuestionPresets,
|
||||
...questionDefaults,
|
||||
id: createId(),
|
||||
type: questionType,
|
||||
},
|
||||
languageSymbols
|
||||
);
|
||||
|
||||
const result = addElementToBlock(localSurvey, block.id, questionWithLabels);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalSurvey(result.data);
|
||||
setOpen(false);
|
||||
toast.success("Question added to block");
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild disabled={hasRestrictedType}>
|
||||
<Button variant="secondary" disabled={hasRestrictedType}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">Add question to block</p>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{Object.entries(availableQuestionTypes).map(([type, name]) => (
|
||||
<DropdownMenuItem key={type} className="min-h-8" onClick={() => handleAddQuestion(type)}>
|
||||
{QUESTIONS_ICON_MAP[type]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,8 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyAddressElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
@@ -13,9 +14,9 @@ import { QuestionToggleTable } from "@/modules/ui/components/question-toggle-tab
|
||||
|
||||
interface AddressQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyAddressQuestion;
|
||||
question: TSurveyAddressElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyAddressQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyAddressElement>) => void;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
|
||||
@@ -32,14 +32,15 @@ export const AdvancedSettings = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ConditionalLogic
|
||||
{/* TODO: Re-enable ConditionalLogic in post-MVP */}
|
||||
{/* <ConditionalLogic
|
||||
question={question}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
updateBlockLogicFallback={updateBlockLogicFallback}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
/>
|
||||
/> */}
|
||||
|
||||
<UpdateQuestionId
|
||||
question={question}
|
||||
|
||||
782
apps/web/modules/survey/editor/components/block-card.tsx
Normal file
782
apps/web/modules/survey/editor/components/block-card.tsx
Normal file
@@ -0,0 +1,782 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { Project } from "@prisma/client";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { AddQuestionToBlockButton } from "@/modules/survey/editor/components/add-question-to-block-button";
|
||||
import { AddressQuestionForm } from "@/modules/survey/editor/components/address-question-form";
|
||||
import { AdvancedSettings } from "@/modules/survey/editor/components/advanced-settings";
|
||||
import { BlockMenu } from "@/modules/survey/editor/components/block-menu";
|
||||
import { CalQuestionForm } from "@/modules/survey/editor/components/cal-question-form";
|
||||
import { ConsentQuestionForm } from "@/modules/survey/editor/components/consent-question-form";
|
||||
import { ContactInfoQuestionForm } from "@/modules/survey/editor/components/contact-info-question-form";
|
||||
import { CTAQuestionForm } from "@/modules/survey/editor/components/cta-question-form";
|
||||
import { DateQuestionForm } from "@/modules/survey/editor/components/date-question-form";
|
||||
import { EditorCardMenu } from "@/modules/survey/editor/components/editor-card-menu";
|
||||
import { FileUploadQuestionForm } from "@/modules/survey/editor/components/file-upload-question-form";
|
||||
import { MatrixQuestionForm } from "@/modules/survey/editor/components/matrix-question-form";
|
||||
import { MultipleChoiceQuestionForm } from "@/modules/survey/editor/components/multiple-choice-question-form";
|
||||
import { NPSQuestionForm } from "@/modules/survey/editor/components/nps-question-form";
|
||||
import { OpenQuestionForm } from "@/modules/survey/editor/components/open-question-form";
|
||||
import { PictureSelectionForm } from "@/modules/survey/editor/components/picture-selection-form";
|
||||
import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-question-form";
|
||||
import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
|
||||
interface BlockCardProps {
|
||||
localSurvey: TSurvey;
|
||||
project: Project;
|
||||
block: TSurveyBlock;
|
||||
blockIdx: number;
|
||||
moveQuestion: (questionIndex: number, up: boolean) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
|
||||
updateBlockButtonLabel: (
|
||||
blockIndex: number,
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
labelValue: TI18nString | undefined
|
||||
) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
lastQuestion: boolean;
|
||||
lastElementIndex: number;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
invalidQuestions?: string[];
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
isCxMode: boolean;
|
||||
locale: TUserLocale;
|
||||
responseCount: number;
|
||||
onAlertTrigger: () => void;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed: boolean;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
duplicateBlock: (blockId: string) => void;
|
||||
deleteBlock: (blockId: string) => void;
|
||||
moveBlock: (blockId: string, direction: "up" | "down") => void;
|
||||
addElementToBlock: (element: TSurveyElement, questionIdx: number) => void;
|
||||
totalBlocks: number;
|
||||
}
|
||||
|
||||
export const BlockCard = ({
|
||||
localSurvey,
|
||||
project,
|
||||
block,
|
||||
blockIdx,
|
||||
moveQuestion,
|
||||
updateQuestion,
|
||||
updateBlockLogic,
|
||||
updateBlockLogicFallback,
|
||||
updateBlockButtonLabel,
|
||||
duplicateQuestion,
|
||||
deleteQuestion,
|
||||
activeQuestionId,
|
||||
setActiveQuestionId,
|
||||
lastQuestion,
|
||||
lastElementIndex,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
invalidQuestions,
|
||||
addQuestion,
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
locale,
|
||||
responseCount,
|
||||
onAlertTrigger,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
setLocalSurvey,
|
||||
duplicateBlock,
|
||||
deleteBlock,
|
||||
moveBlock,
|
||||
addElementToBlock,
|
||||
totalBlocks,
|
||||
}: BlockCardProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: block.id,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||
|
||||
// Block-level properties
|
||||
const blockName = block.name || `Block ${blockIdx + 1}`;
|
||||
const hasMultipleElements = block.elements.length > 1;
|
||||
const blockLogic = block.logic ?? [];
|
||||
|
||||
// Check if any element in this block is currently active
|
||||
const isBlockOpen = block.elements.some((element) => element.id === activeQuestionId);
|
||||
|
||||
const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
// Get button labels from the block
|
||||
const blockButtonLabel = block.buttonLabel;
|
||||
const blockBackButtonLabel = block.backButtonLabel;
|
||||
|
||||
const updateEmptyButtonLabels = (
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
labelValue: TI18nString,
|
||||
skipBlockIndex: number
|
||||
) => {
|
||||
// Update button labels for all blocks except the one at skipBlockIndex
|
||||
localSurvey.blocks.forEach((block, index) => {
|
||||
if (index === skipBlockIndex) return;
|
||||
const currentLabel = block[labelKey];
|
||||
if (!currentLabel || currentLabel[selectedLanguageCode]?.trim() === "") {
|
||||
updateBlockButtonLabel(index, labelKey, labelValue);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const style = {
|
||||
transition: transition ?? "transform 100ms ease",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
isBlockOpen ? "shadow-lg" : "shadow-md",
|
||||
"flex w-full flex-row rounded-lg bg-white duration-300"
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
id={block.id}>
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
isBlockOpen ? "bg-slate-700" : "bg-slate-400",
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
|
||||
"flex flex-col items-center justify-between"
|
||||
)}>
|
||||
<div className="mt-3 flex w-full items-center justify-center text-xs font-medium">{blockIdx + 1}</div>
|
||||
|
||||
<button
|
||||
className="opacity-0 hover:cursor-move group-hover:opacity-100"
|
||||
aria-label="Drag to reorder block">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-[95%] flex-1 rounded-r-lg border border-slate-200">
|
||||
{/* Block header - shown when block has multiple elements */}
|
||||
{hasMultipleElements && (
|
||||
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700">{blockName}</h4>
|
||||
<p className="text-xs text-slate-500">{block.elements.length} questions</p>
|
||||
</div>
|
||||
<BlockMenu
|
||||
blockIndex={blockIdx}
|
||||
isFirstBlock={blockIdx === 0}
|
||||
isLastBlock={blockIdx === totalBlocks - 1}
|
||||
onDuplicate={() => duplicateBlock(block.id)}
|
||||
onDelete={() => deleteBlock(block.id)}
|
||||
onMoveUp={() => moveBlock(block.id, "up")}
|
||||
onMoveDown={() => moveBlock(block.id, "down")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render each element in the block */}
|
||||
{block.elements.map((element, elementIndex) => {
|
||||
// Calculate the actual question index in the flattened questions array
|
||||
let questionIdx = 0;
|
||||
for (let i = 0; i < blockIdx; i++) {
|
||||
questionIdx += localSurvey.blocks[i].elements.length;
|
||||
}
|
||||
questionIdx += elementIndex;
|
||||
|
||||
const isInvalid = invalidQuestions ? invalidQuestions.includes(element.id) : false;
|
||||
const open = activeQuestionId === element.id;
|
||||
|
||||
const getIsRequiredToggleDisabled = (): boolean => {
|
||||
if (element.type === TSurveyElementTypeEnum.Address) {
|
||||
const allFieldsAreOptional = [
|
||||
element.addressLine1,
|
||||
element.addressLine2,
|
||||
element.city,
|
||||
element.state,
|
||||
element.zip,
|
||||
element.country,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.every((field) => !field.required);
|
||||
|
||||
if (allFieldsAreOptional) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
element.addressLine1,
|
||||
element.addressLine2,
|
||||
element.city,
|
||||
element.state,
|
||||
element.zip,
|
||||
element.country,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.some((condition) => condition.required === true);
|
||||
}
|
||||
|
||||
if (element.type === TSurveyElementTypeEnum.ContactInfo) {
|
||||
const allFieldsAreOptional = [
|
||||
element.firstName,
|
||||
element.lastName,
|
||||
element.email,
|
||||
element.phone,
|
||||
element.company,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.every((field) => !field.required);
|
||||
|
||||
if (allFieldsAreOptional) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [element.firstName, element.lastName, element.email, element.phone, element.company]
|
||||
.filter((field) => field.show)
|
||||
.some((condition) => condition.required === true);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRequiredToggle = () => {
|
||||
// Fix for NPS and Rating element having missing translations when buttonLabel is not removed
|
||||
if (!element.required && (element.type === "nps" || element.type === "rating")) {
|
||||
// Remove buttonLabel from the block when making NPS/Rating required
|
||||
updateBlockButtonLabel(blockIdx, "buttonLabel", undefined);
|
||||
updateQuestion(questionIdx, { required: true });
|
||||
} else {
|
||||
updateQuestion(questionIdx, { required: !element.required });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={element.id} className={cn(elementIndex > 0 && "border-t border-slate-200")}>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={() => {
|
||||
if (activeQuestionId !== element.id) {
|
||||
setActiveQuestionId(element.id);
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
}}
|
||||
className="w-full">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
"flex w-full cursor-pointer justify-between gap-4 p-4 hover:bg-slate-50"
|
||||
)}
|
||||
aria-label="Toggle question details">
|
||||
<div>
|
||||
<div className="flex grow">
|
||||
<div className="flex grow items-center gap-3" dir="auto">
|
||||
<div className="flex items-center text-slate-600">
|
||||
{QUESTIONS_ICON_MAP[element.type]}
|
||||
</div>
|
||||
<div className="flex grow flex-col justify-center">
|
||||
{hasMultipleElements && (
|
||||
<p className="mb-1 text-xs font-medium text-slate-500">
|
||||
Question {elementIndex + 1}
|
||||
</p>
|
||||
)}
|
||||
<h3 className="text-sm font-semibold">
|
||||
{recallToHeadline(element.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
]
|
||||
? formatTextWithSlashes(
|
||||
getTextContent(
|
||||
recallToHeadline(
|
||||
element.headline,
|
||||
localSurvey,
|
||||
true,
|
||||
selectedLanguageCode
|
||||
)[selectedLanguageCode] ?? ""
|
||||
)
|
||||
)
|
||||
: getTSurveyQuestionTypeEnumName(element.type, t)}
|
||||
</h3>
|
||||
{!open && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{element?.required
|
||||
? t("environments.surveys.edit.required")
|
||||
: t("environments.surveys.edit.optional")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<EditorCardMenu
|
||||
survey={localSurvey}
|
||||
cardIdx={questionIdx}
|
||||
lastCard={lastQuestion && elementIndex === lastElementIndex}
|
||||
duplicateCard={duplicateQuestion}
|
||||
deleteCard={deleteQuestion}
|
||||
moveCard={moveQuestion}
|
||||
card={{
|
||||
...element,
|
||||
logic: block.logic,
|
||||
buttonLabel: block.buttonLabel,
|
||||
backButtonLabel: block.backButtonLabel,
|
||||
}}
|
||||
blockId={block.id}
|
||||
project={project}
|
||||
updateCard={updateQuestion}
|
||||
addCard={addQuestion}
|
||||
addCardToBlock={addElementToBlock}
|
||||
cardType="question"
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-4"}`}>
|
||||
{responseCount > 0 &&
|
||||
[
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
TSurveyElementTypeEnum.PictureSelection,
|
||||
TSurveyElementTypeEnum.Rating,
|
||||
TSurveyElementTypeEnum.NPS,
|
||||
TSurveyElementTypeEnum.Ranking,
|
||||
TSurveyElementTypeEnum.Matrix,
|
||||
].includes(element.type) ? (
|
||||
<Alert variant="warning" size="small" className="w-fill" role="alert">
|
||||
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
|
||||
<AlertButton onClick={() => onAlertTrigger()}>{t("common.learn_more")}</AlertButton>
|
||||
</Alert>
|
||||
) : null}
|
||||
{element.type === TSurveyElementTypeEnum.OpenText ? (
|
||||
<OpenQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.NPS ? (
|
||||
<NPSQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
buttonLabel={blockButtonLabel}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.CTA ? (
|
||||
<CTAQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
buttonLabel={blockButtonLabel}
|
||||
backButtonLabel={blockBackButtonLabel}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Rating ? (
|
||||
<RatingQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Consent ? (
|
||||
<ConsentQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Date ? (
|
||||
<DateQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.PictureSelection ? (
|
||||
<PictureSelectionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.FileUpload ? (
|
||||
<FileUploadQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
project={project}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Cal ? (
|
||||
<CalQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Matrix ? (
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Address ? (
|
||||
<AddressQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.Ranking ? (
|
||||
<RankingQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : element.type === TSurveyElementTypeEnum.ContactInfo ? (
|
||||
<ContactInfoQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
className="flex items-center text-sm text-slate-700"
|
||||
aria-label="Toggle advanced settings">
|
||||
{openAdvanced ? (
|
||||
<ChevronDownIcon className="mr-1 h-4 w-3" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mr-2 h-4 w-3" />
|
||||
)}
|
||||
{openAdvanced
|
||||
? t("environments.surveys.edit.hide_advanced_settings")
|
||||
: t("environments.surveys.edit.show_advanced_settings")}
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
|
||||
<Collapsible.CollapsibleContent className="flex flex-col gap-4" ref={parent}>
|
||||
{element.type !== TSurveyElementTypeEnum.NPS &&
|
||||
element.type !== TSurveyElementTypeEnum.Rating &&
|
||||
element.type !== TSurveyElementTypeEnum.CTA ? (
|
||||
<div className="mt-2 flex space-x-2">
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={blockBackButtonLabel}
|
||||
label={t("environments.surveys.edit.back_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={t("common.back")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
onBlur={(e) => {
|
||||
if (!blockBackButtonLabel) return;
|
||||
let translatedBackButtonLabel = {
|
||||
...blockBackButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
updateBlockButtonLabel(
|
||||
blockIdx,
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel
|
||||
);
|
||||
updateEmptyButtonLabels(
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel,
|
||||
blockIdx
|
||||
);
|
||||
}}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={blockButtonLabel}
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
onBlur={(e) => {
|
||||
if (!blockButtonLabel) return;
|
||||
let translatedNextButtonLabel = {
|
||||
...blockButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
updateBlockButtonLabel(blockIdx, "buttonLabel", translatedNextButtonLabel);
|
||||
// Don't propagate to last block
|
||||
const lastBlockIndex = localSurvey.blocks.length - 1;
|
||||
if (blockIdx !== lastBlockIndex) {
|
||||
updateEmptyButtonLabels(
|
||||
"buttonLabel",
|
||||
translatedNextButtonLabel,
|
||||
lastBlockIndex
|
||||
);
|
||||
}
|
||||
}}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{(element.type === TSurveyElementTypeEnum.Rating ||
|
||||
element.type === TSurveyElementTypeEnum.NPS) &&
|
||||
questionIdx !== 0 && (
|
||||
<div className="mt-4">
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={blockBackButtonLabel}
|
||||
label={`"Back" Button Label`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={"Back"}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
onBlur={(e) => {
|
||||
if (!blockBackButtonLabel) return;
|
||||
const translatedBackButtonLabel = {
|
||||
...blockBackButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
updateBlockButtonLabel(
|
||||
blockIdx,
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel
|
||||
);
|
||||
updateEmptyButtonLabels(
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel,
|
||||
blockIdx
|
||||
);
|
||||
}}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdvancedSettings
|
||||
// TODO -- We should remove this when we can confirm that everything works fine with the survey editor, not changing this right now in this file because it would require changing the question type to the respective element type in all the question forms.
|
||||
question={element}
|
||||
questionIdx={questionIdx}
|
||||
localSurvey={localSurvey}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
updateBlockLogicFallback={updateBlockLogicFallback}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
/>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
|
||||
{open && (
|
||||
<div className="mx-4 flex justify-end space-x-6 border-t border-slate-200">
|
||||
{element.type === "openText" && (
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="longAnswer">{t("environments.surveys.edit.long_answer")}</Label>
|
||||
<Switch
|
||||
id="longAnswer"
|
||||
disabled={element.inputType !== "text"}
|
||||
checked={element.longAnswer !== false}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuestion(questionIdx, {
|
||||
longAnswer:
|
||||
typeof element.longAnswer === "undefined" ? false : !element.longAnswer,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="required-toggle">{t("environments.surveys.edit.required")}</Label>
|
||||
<Switch
|
||||
id="required-toggle"
|
||||
checked={element.required}
|
||||
disabled={getIsRequiredToggleDisabled()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRequiredToggle();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add Question to Block button */}
|
||||
|
||||
<div className="p-4">
|
||||
<AddQuestionToBlockButton
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
block={block}
|
||||
project={project}
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
90
apps/web/modules/survey/editor/components/block-menu.tsx
Normal file
90
apps/web/modules/survey/editor/components/block-menu.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface BlockMenuProps {
|
||||
blockIndex: number;
|
||||
isFirstBlock: boolean;
|
||||
isLastBlock: boolean;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
}
|
||||
|
||||
export const BlockMenu = ({
|
||||
blockIndex,
|
||||
isFirstBlock,
|
||||
isLastBlock,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: BlockMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipRenderer tooltipContent={t("common.move_up")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={isFirstBlock}
|
||||
onClick={(e) => {
|
||||
if (!isFirstBlock) {
|
||||
e.stopPropagation();
|
||||
onMoveUp();
|
||||
}
|
||||
}}
|
||||
className="h-8 w-8">
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer tooltipContent={t("common.move_down")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={isLastBlock}
|
||||
onClick={(e) => {
|
||||
if (!isLastBlock) {
|
||||
e.stopPropagation();
|
||||
onMoveDown();
|
||||
}
|
||||
}}
|
||||
className="h-8 w-8">
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.duplicate_block")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
className="h-8 w-8">
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.delete_block")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="h-8 w-8">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
125
apps/web/modules/survey/editor/components/blocks-droppable.tsx
Normal file
125
apps/web/modules/survey/editor/components/blocks-droppable.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { Project } from "@prisma/client";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { BlockCard } from "@/modules/survey/editor/components/block-card";
|
||||
|
||||
interface BlocksDroppableProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
project: Project;
|
||||
moveQuestion: (questionIndex: number, up: boolean) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
|
||||
updateBlockButtonLabel: (
|
||||
blockIndex: number,
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
labelValue: TI18nString | undefined
|
||||
) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
invalidQuestions: string[] | null;
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
isCxMode: boolean;
|
||||
locale: TUserLocale;
|
||||
responseCount: number;
|
||||
onAlertTrigger: () => void;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed: boolean;
|
||||
duplicateBlock: (blockId: string) => void;
|
||||
deleteBlock: (blockId: string) => void;
|
||||
moveBlock: (blockId: string, direction: "up" | "down") => void;
|
||||
addElementToBlock: (element: TSurveyElement, questionIdx: number) => void;
|
||||
}
|
||||
|
||||
export const BlocksDroppable = ({
|
||||
activeQuestionId,
|
||||
deleteQuestion,
|
||||
duplicateQuestion,
|
||||
invalidQuestions,
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
moveQuestion,
|
||||
project,
|
||||
selectedLanguageCode,
|
||||
setActiveQuestionId,
|
||||
setSelectedLanguageCode,
|
||||
updateQuestion,
|
||||
updateBlockLogic,
|
||||
updateBlockLogicFallback,
|
||||
updateBlockButtonLabel,
|
||||
addQuestion,
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
locale,
|
||||
responseCount,
|
||||
onAlertTrigger,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
duplicateBlock,
|
||||
deleteBlock,
|
||||
moveBlock,
|
||||
addElementToBlock,
|
||||
}: BlocksDroppableProps) => {
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
return (
|
||||
<div className="group mb-5 flex w-full flex-col gap-5" ref={parent}>
|
||||
<SortableContext items={localSurvey.blocks} strategy={verticalListSortingStrategy}>
|
||||
{localSurvey.blocks.map((block, blockIdx) => {
|
||||
// Check if this is the last block and has elements
|
||||
const isLastBlock = blockIdx === localSurvey.blocks.length - 1;
|
||||
const lastElementIndex = block.elements.length - 1;
|
||||
|
||||
return (
|
||||
<BlockCard
|
||||
key={block.id}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
project={project}
|
||||
block={block}
|
||||
blockIdx={blockIdx}
|
||||
moveQuestion={moveQuestion}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
updateBlockLogicFallback={updateBlockLogicFallback}
|
||||
updateBlockButtonLabel={updateBlockButtonLabel}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
deleteQuestion={deleteQuestion}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
lastQuestion={isLastBlock}
|
||||
lastElementIndex={lastElementIndex}
|
||||
invalidQuestions={invalidQuestions ?? undefined}
|
||||
addQuestion={addQuestion}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isCxMode={isCxMode}
|
||||
locale={locale}
|
||||
responseCount={responseCount}
|
||||
onAlertTrigger={onAlertTrigger}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
duplicateBlock={duplicateBlock}
|
||||
deleteBlock={deleteBlock}
|
||||
moveBlock={moveBlock}
|
||||
addElementToBlock={addElementToBlock}
|
||||
totalBlocks={localSurvey.blocks.length}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,8 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyCalElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
@@ -14,9 +15,9 @@ import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface CalQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyCalQuestion;
|
||||
question: TSurveyCalElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyCalQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyCalElement>) => void;
|
||||
lastQuestion: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import { type JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyConsentElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
|
||||
interface ConsentQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyConsentQuestion;
|
||||
question: TSurveyConsentElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyConsentQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyConsentElement>) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
isInvalid: boolean;
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyContactInfoElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
@@ -13,9 +14,9 @@ import { QuestionToggleTable } from "@/modules/ui/components/question-toggle-tab
|
||||
|
||||
interface ContactInfoQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyContactInfoQuestion;
|
||||
question: TSurveyContactInfoElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyContactInfoQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyContactInfoElement>) => void;
|
||||
lastQuestion: boolean;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { type JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyCTAElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
@@ -12,9 +14,9 @@ import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface CTAQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyCTAQuestion;
|
||||
question: TSurveyCTAElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyCTAQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyCTAElement>) => void;
|
||||
lastQuestion: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
@@ -22,6 +24,8 @@ interface CTAQuestionFormProps {
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
buttonLabel?: TI18nString;
|
||||
backButtonLabel?: TI18nString;
|
||||
}
|
||||
|
||||
export const CTAQuestionForm = ({
|
||||
@@ -36,6 +40,8 @@ export const CTAQuestionForm = ({
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
}: CTAQuestionFormProps): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const options = [
|
||||
@@ -106,7 +112,7 @@ export const CTAQuestionForm = ({
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={question.backButtonLabel}
|
||||
value={backButtonLabel}
|
||||
label={t("environments.surveys.edit.back_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
@@ -122,7 +128,7 @@ export const CTAQuestionForm = ({
|
||||
)}
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
value={buttonLabel}
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyDateElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
@@ -14,9 +15,9 @@ import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
|
||||
interface IDateQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyDateQuestion;
|
||||
question: TSurveyDateElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyDateQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyDateElement>) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
isInvalid: boolean;
|
||||
|
||||
@@ -4,14 +4,12 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { Project } from "@prisma/client";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import {
|
||||
getCXQuestionNameMap,
|
||||
@@ -32,16 +30,24 @@ import {
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
type EditorCardMenuSurveyElement = TSurveyElement & {
|
||||
logic?: TSurveyBlockLogic[];
|
||||
buttonLabel?: TI18nString;
|
||||
backButtonLabel?: TI18nString;
|
||||
};
|
||||
|
||||
interface EditorCardMenuProps {
|
||||
survey: TSurvey;
|
||||
cardIdx: number;
|
||||
lastCard: boolean;
|
||||
blockId?: string;
|
||||
duplicateCard: (cardIdx: number) => void;
|
||||
deleteCard: (cardIdx: number) => void;
|
||||
moveCard: (cardIdx: number, up: boolean) => void;
|
||||
card: TSurveyQuestion | TSurveyEndScreenCard | TSurveyRedirectUrlCard;
|
||||
card: EditorCardMenuSurveyElement | TSurveyEndScreenCard | TSurveyRedirectUrlCard;
|
||||
updateCard: (cardIdx: number, updatedAttributes: any) => void;
|
||||
addCard: (question: any, index?: number) => void;
|
||||
addCardToBlock?: (element: TSurveyElement, questionIdx: number) => void;
|
||||
cardType: "question" | "ending";
|
||||
project?: Project;
|
||||
isCxMode?: boolean;
|
||||
@@ -51,6 +57,7 @@ export const EditorCardMenu = ({
|
||||
survey,
|
||||
cardIdx,
|
||||
lastCard,
|
||||
blockId,
|
||||
duplicateCard,
|
||||
deleteCard,
|
||||
moveCard,
|
||||
@@ -58,6 +65,7 @@ export const EditorCardMenu = ({
|
||||
card,
|
||||
updateCard,
|
||||
addCard,
|
||||
addCardToBlock,
|
||||
cardType,
|
||||
isCxMode = false,
|
||||
}: EditorCardMenuProps) => {
|
||||
@@ -78,26 +86,24 @@ export const EditorCardMenu = ({
|
||||
|
||||
const availableQuestionTypes = isCxMode ? getCXQuestionNameMap(t) : getQuestionNameMap(t);
|
||||
|
||||
const changeQuestionType = (type?: TSurveyQuestionTypeEnum) => {
|
||||
const changeQuestionType = (type?: TSurveyElementTypeEnum) => {
|
||||
if (!type) return;
|
||||
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } =
|
||||
card as TSurveyQuestion;
|
||||
card as EditorCardMenuSurveyElement;
|
||||
|
||||
const questionDefaults = getQuestionDefaults(type, project, t);
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
card.type === TSurveyQuestionTypeEnum.Ranking) ||
|
||||
(type === TSurveyQuestionTypeEnum.Ranking &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
card.type === TSurveyQuestionTypeEnum.Ranking) ||
|
||||
(type === TSurveyQuestionTypeEnum.Ranking && card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
(type === TSurveyElementTypeEnum.MultipleChoiceSingle &&
|
||||
card.type === TSurveyElementTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyElementTypeEnum.MultipleChoiceMulti &&
|
||||
card.type === TSurveyElementTypeEnum.MultipleChoiceSingle) ||
|
||||
(type === TSurveyElementTypeEnum.MultipleChoiceMulti && card.type === TSurveyElementTypeEnum.Ranking) ||
|
||||
(type === TSurveyElementTypeEnum.Ranking && card.type === TSurveyElementTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyElementTypeEnum.MultipleChoiceSingle &&
|
||||
card.type === TSurveyElementTypeEnum.Ranking) ||
|
||||
(type === TSurveyElementTypeEnum.Ranking && card.type === TSurveyElementTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateCard(cardIdx, {
|
||||
choices: card.choices,
|
||||
@@ -122,18 +128,34 @@ export const EditorCardMenu = ({
|
||||
});
|
||||
};
|
||||
|
||||
const addQuestionCardBelow = (type: TSurveyQuestionTypeEnum) => {
|
||||
const addQuestionCardBelow = (type: TSurveyElementTypeEnum) => {
|
||||
const questionDefaults = getQuestionDefaults(type, project, t);
|
||||
|
||||
addCard(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
cardIdx + 1
|
||||
);
|
||||
const newQuestion = {
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
};
|
||||
|
||||
// If addCardToBlock is available, we need to check for restricted types
|
||||
if (addCardToBlock && blockId) {
|
||||
// Check if the current question is CTA or Cal.com (these must be alone)
|
||||
if (card.type === TSurveyElementTypeEnum.CTA || card.type === TSurveyElementTypeEnum.Cal) {
|
||||
toast.error("CTA and Cal.com questions must be alone in a block");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if trying to add CTA or Cal.com to a block that already has questions
|
||||
if (type === TSurveyElementTypeEnum.CTA || type === TSurveyElementTypeEnum.Cal) {
|
||||
toast.error("CTA and Cal.com questions must be alone in a block");
|
||||
return;
|
||||
}
|
||||
|
||||
addCardToBlock(newQuestion as TSurveyElement, cardIdx + 1);
|
||||
} else {
|
||||
addCard(newQuestion, cardIdx + 1);
|
||||
}
|
||||
|
||||
const section = document.getElementById(`${card.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
@@ -228,15 +250,15 @@ export const EditorCardMenu = ({
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if ((card as TSurveyQuestion).logic) {
|
||||
setChangeToType(type as TSurveyElementTypeEnum);
|
||||
if ((card as EditorCardMenuSurveyElement).logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
changeQuestionType(type as TSurveyElementTypeEnum);
|
||||
}}
|
||||
icon={QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}>
|
||||
icon={QUESTIONS_ICON_MAP[type as TSurveyElementTypeEnum]}>
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
@@ -270,10 +292,10 @@ export const EditorCardMenu = ({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (cardType === "question") {
|
||||
addQuestionCardBelow(type as TSurveyQuestionTypeEnum);
|
||||
addQuestionCardBelow(type as TSurveyElementTypeEnum);
|
||||
}
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyElementTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@ import { type JSX, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
@@ -20,9 +21,9 @@ import { useGetBillingInfo } from "@/modules/utils/hooks/useGetBillingInfo";
|
||||
interface FileUploadFormProps {
|
||||
localSurvey: TSurvey;
|
||||
project?: Project;
|
||||
question: TSurveyFileUploadQuestion;
|
||||
question: TSurveyFileUploadElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyFileUploadQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyFileUploadElement>) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
isInvalid: boolean;
|
||||
|
||||
@@ -9,7 +9,8 @@ import { type JSX, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyMatrixElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
@@ -22,9 +23,9 @@ import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface MatrixQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyMatrixQuestion;
|
||||
question: TSurveyMatrixElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMatrixQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMatrixElement>) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
isInvalid: boolean;
|
||||
|
||||
@@ -6,18 +6,19 @@ import { GripVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurvey, TSurveyMatrixQuestion, TSurveyMatrixQuestionChoice } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyMatrixElement, TSurveyMatrixElementChoice } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface MatrixSortableItemProps {
|
||||
choice: TSurveyMatrixQuestionChoice;
|
||||
choice: TSurveyMatrixElementChoice;
|
||||
type: "row" | "column";
|
||||
index: number;
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyMatrixQuestion;
|
||||
question: TSurveyMatrixElement;
|
||||
questionIdx: number;
|
||||
updateMatrixLabel: (index: number, type: "row" | "column", matrixLabel: TI18nString) => void;
|
||||
onDelete: (index: number) => void;
|
||||
|
||||
@@ -9,12 +9,8 @@ import { type JSX, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import {
|
||||
TShuffleOption,
|
||||
TSurvey,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
||||
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
@@ -26,9 +22,9 @@ import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-sele
|
||||
|
||||
interface MultipleChoiceQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyMultipleChoiceQuestion;
|
||||
question: TSurveyMultipleChoiceElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMultipleChoiceQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMultipleChoiceElement>) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
isInvalid: boolean;
|
||||
@@ -94,7 +90,7 @@ export const MultipleChoiceQuestionForm = ({
|
||||
[question.choices]
|
||||
);
|
||||
|
||||
const ensureSpecialChoicesOrder = (choices: TSurveyMultipleChoiceQuestion["choices"]) => {
|
||||
const ensureSpecialChoicesOrder = (choices: TSurveyMultipleChoiceElement["choices"]) => {
|
||||
const otherChoice = choices.find((c) => c.id === "other");
|
||||
const noneChoice = choices.find((c) => c.id === "none");
|
||||
// [regularChoices, otherChoice, noneChoice]
|
||||
@@ -335,12 +331,12 @@ export const MultipleChoiceQuestionForm = ({
|
||||
onClick={() => {
|
||||
updateQuestion(questionIdx, {
|
||||
type:
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
|
||||
? TSurveyQuestionTypeEnum.MultipleChoiceSingle
|
||||
: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
question.type === TSurveyElementTypeEnum.MultipleChoiceMulti
|
||||
? TSurveyElementTypeEnum.MultipleChoiceSingle
|
||||
: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
});
|
||||
}}>
|
||||
{question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
|
||||
{question.type === TSurveyElementTypeEnum.MultipleChoiceSingle
|
||||
? t("environments.surveys.edit.convert_to_multiple_choice")
|
||||
: t("environments.surveys.edit.convert_to_single_choice")}
|
||||
</Button>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyNPSElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
@@ -13,9 +15,9 @@ import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface NPSQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyNPSQuestion;
|
||||
question: TSurveyNPSElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyNPSQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyNPSElement>) => void;
|
||||
lastQuestion: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
@@ -23,6 +25,7 @@ interface NPSQuestionFormProps {
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
buttonLabel?: TI18nString;
|
||||
}
|
||||
|
||||
export const NPSQuestionForm = ({
|
||||
@@ -37,6 +40,7 @@ export const NPSQuestionForm = ({
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
buttonLabel,
|
||||
}: NPSQuestionFormProps): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
@@ -136,7 +140,7 @@ export const NPSQuestionForm = ({
|
||||
<div className="mt-3">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
value={buttonLabel}
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
|
||||
@@ -4,11 +4,8 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
|
||||
import { JSX, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyOpenTextQuestionInputType,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyOpenTextElement, TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
@@ -20,9 +17,9 @@ import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
|
||||
interface OpenQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyOpenTextQuestion;
|
||||
question: TSurveyOpenTextElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyOpenTextQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyOpenTextElement>) => void;
|
||||
lastQuestion: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
@@ -57,7 +54,7 @@ export const OpenQuestionForm = ({
|
||||
|
||||
const [showCharLimits, setShowCharLimits] = useState(question.inputType === "text");
|
||||
|
||||
const handleInputChange = (inputType: TSurveyOpenTextQuestionInputType) => {
|
||||
const handleInputChange = (inputType: TSurveyOpenTextElementInputType) => {
|
||||
const updatedAttributes = {
|
||||
inputType: inputType,
|
||||
placeholder: createI18nString(getPlaceholderByInputType(inputType), surveyLanguageCodes),
|
||||
@@ -238,7 +235,7 @@ export const OpenQuestionForm = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getPlaceholderByInputType = (inputType: TSurveyOpenTextQuestionInputType) => {
|
||||
const getPlaceholderByInputType = (inputType: TSurveyOpenTextElementInputType) => {
|
||||
switch (inputType) {
|
||||
case "email":
|
||||
return "example@email.com";
|
||||
|
||||
@@ -5,7 +5,8 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
@@ -17,9 +18,9 @@ import { Switch } from "@/modules/ui/components/switch";
|
||||
|
||||
interface PictureSelectionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyPictureSelectionQuestion;
|
||||
question: TSurveyPictureSelectionElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyPictureSelectionQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyPictureSelectionElement>) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
isInvalid: boolean;
|
||||
|
||||
@@ -1,698 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { Project } from "@prisma/client";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { AddressQuestionForm } from "@/modules/survey/editor/components/address-question-form";
|
||||
import { AdvancedSettings } from "@/modules/survey/editor/components/advanced-settings";
|
||||
import { CalQuestionForm } from "@/modules/survey/editor/components/cal-question-form";
|
||||
import { ConsentQuestionForm } from "@/modules/survey/editor/components/consent-question-form";
|
||||
import { ContactInfoQuestionForm } from "@/modules/survey/editor/components/contact-info-question-form";
|
||||
import { CTAQuestionForm } from "@/modules/survey/editor/components/cta-question-form";
|
||||
import { DateQuestionForm } from "@/modules/survey/editor/components/date-question-form";
|
||||
import { EditorCardMenu } from "@/modules/survey/editor/components/editor-card-menu";
|
||||
import { FileUploadQuestionForm } from "@/modules/survey/editor/components/file-upload-question-form";
|
||||
import { MatrixQuestionForm } from "@/modules/survey/editor/components/matrix-question-form";
|
||||
import { MultipleChoiceQuestionForm } from "@/modules/survey/editor/components/multiple-choice-question-form";
|
||||
import { NPSQuestionForm } from "@/modules/survey/editor/components/nps-question-form";
|
||||
import { OpenQuestionForm } from "@/modules/survey/editor/components/open-question-form";
|
||||
import { PictureSelectionForm } from "@/modules/survey/editor/components/picture-selection-form";
|
||||
import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-question-form";
|
||||
import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form";
|
||||
import { findElementLocation } from "@/modules/survey/editor/lib/blocks";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
|
||||
interface QuestionCardProps {
|
||||
localSurvey: TSurvey;
|
||||
project: Project;
|
||||
question: TSurveyQuestion;
|
||||
questionIdx: number;
|
||||
moveQuestion: (questionIndex: number, up: boolean) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
|
||||
updateBlockButtonLabel: (
|
||||
blockIndex: number,
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
labelValue: TI18nString | undefined
|
||||
) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void;
|
||||
lastQuestion: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
isInvalid: boolean;
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
isCxMode: boolean;
|
||||
locale: TUserLocale;
|
||||
responseCount: number;
|
||||
onAlertTrigger: () => void;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed: boolean;
|
||||
}
|
||||
|
||||
export const QuestionCard = ({
|
||||
localSurvey,
|
||||
project,
|
||||
question,
|
||||
questionIdx,
|
||||
moveQuestion,
|
||||
updateQuestion,
|
||||
updateBlockLogic,
|
||||
updateBlockLogicFallback,
|
||||
updateBlockButtonLabel,
|
||||
duplicateQuestion,
|
||||
deleteQuestion,
|
||||
activeQuestionId,
|
||||
setActiveQuestionId,
|
||||
lastQuestion,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
isInvalid,
|
||||
addQuestion,
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
locale,
|
||||
responseCount,
|
||||
onAlertTrigger,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: QuestionCardProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: question.id,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||
const open = activeQuestionId === question.id;
|
||||
|
||||
// Find the parent block for this question/element to get its logic
|
||||
const { blockIndex: parentBlockIndex } = findElementLocation(localSurvey, question.id);
|
||||
const parentBlock = parentBlockIndex !== -1 ? localSurvey.blocks[parentBlockIndex] : undefined;
|
||||
const blockLogic = parentBlock?.logic ?? [];
|
||||
|
||||
const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0);
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
// Get button labels from the parent block (not from element)
|
||||
const blockButtonLabel = parentBlock?.buttonLabel;
|
||||
const blockBackButtonLabel = parentBlock?.backButtonLabel;
|
||||
|
||||
const updateEmptyButtonLabels = (
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
labelValue: TI18nString,
|
||||
skipBlockIndex: number
|
||||
) => {
|
||||
// Update button labels for all blocks except the one at skipBlockIndex
|
||||
localSurvey.blocks.forEach((block, index) => {
|
||||
if (index === skipBlockIndex) return;
|
||||
const currentLabel = block[labelKey];
|
||||
if (!currentLabel || currentLabel[selectedLanguageCode]?.trim() === "") {
|
||||
updateBlockButtonLabel(index, labelKey, labelValue);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getIsRequiredToggleDisabled = (): boolean => {
|
||||
if (question.type === TSurveyQuestionTypeEnum.Address) {
|
||||
const allFieldsAreOptional = [
|
||||
question.addressLine1,
|
||||
question.addressLine2,
|
||||
question.city,
|
||||
question.state,
|
||||
question.zip,
|
||||
question.country,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.every((field) => !field.required);
|
||||
|
||||
if (allFieldsAreOptional) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
question.addressLine1,
|
||||
question.addressLine2,
|
||||
question.city,
|
||||
question.state,
|
||||
question.zip,
|
||||
question.country,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.some((condition) => condition.required === true);
|
||||
}
|
||||
|
||||
if (question.type === TSurveyQuestionTypeEnum.ContactInfo) {
|
||||
const allFieldsAreOptional = [
|
||||
question.firstName,
|
||||
question.lastName,
|
||||
question.email,
|
||||
question.phone,
|
||||
question.company,
|
||||
]
|
||||
.filter((field) => field.show)
|
||||
.every((field) => !field.required);
|
||||
|
||||
if (allFieldsAreOptional) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [question.firstName, question.lastName, question.email, question.phone, question.company]
|
||||
.filter((field) => field.show)
|
||||
.some((condition) => condition.required === true);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRequiredToggle = () => {
|
||||
// Fix for NPS and Rating questions having missing translations when buttonLabel is not removed
|
||||
if (!question.required && (question.type === "nps" || question.type === "rating")) {
|
||||
// Remove buttonLabel from the block when making NPS/Rating required
|
||||
if (parentBlockIndex !== -1) {
|
||||
updateBlockButtonLabel(parentBlockIndex, "buttonLabel", undefined);
|
||||
}
|
||||
updateQuestion(questionIdx, { required: true });
|
||||
} else {
|
||||
updateQuestion(questionIdx, { required: !question.required });
|
||||
}
|
||||
};
|
||||
|
||||
const style = {
|
||||
transition: transition ?? "transform 100ms ease",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "shadow-lg" : "shadow-md",
|
||||
"flex w-full flex-row rounded-lg bg-white duration-300"
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
id={question.id}>
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
open ? "bg-slate-700" : "bg-slate-400",
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
|
||||
isInvalid && "bg-red-400 hover:bg-red-600",
|
||||
"flex flex-col items-center justify-between"
|
||||
)}>
|
||||
<div className="mt-3 flex w-full justify-center">{QUESTIONS_ICON_MAP[question.type]}</div>
|
||||
|
||||
<button
|
||||
className="opacity-0 hover:cursor-move group-hover:opacity-100"
|
||||
aria-label="Drag to reorder question">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={() => {
|
||||
if (activeQuestionId !== question.id) {
|
||||
setActiveQuestionId(question.id);
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
}}
|
||||
className="w-[95%] flex-1 rounded-r-lg border border-slate-200">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
open ? "" : " ",
|
||||
"flex cursor-pointer justify-between gap-4 rounded-r-lg p-4 hover:bg-slate-50"
|
||||
)}
|
||||
aria-label="Toggle question details">
|
||||
<div>
|
||||
<div className="flex grow">
|
||||
<div className="flex grow flex-col justify-center" dir="auto">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
]
|
||||
? formatTextWithSlashes(
|
||||
getTextContent(
|
||||
recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[
|
||||
selectedLanguageCode
|
||||
] ?? ""
|
||||
)
|
||||
)
|
||||
: getTSurveyQuestionTypeEnumName(question.type, t)}
|
||||
</h3>
|
||||
{!open && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{question?.required
|
||||
? t("environments.surveys.edit.required")
|
||||
: t("environments.surveys.edit.optional")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<EditorCardMenu
|
||||
survey={localSurvey}
|
||||
cardIdx={questionIdx}
|
||||
lastCard={lastQuestion}
|
||||
duplicateCard={duplicateQuestion}
|
||||
deleteCard={deleteQuestion}
|
||||
moveCard={moveQuestion}
|
||||
card={question}
|
||||
project={project}
|
||||
updateCard={updateQuestion}
|
||||
addCard={addQuestion}
|
||||
cardType="question"
|
||||
isCxMode={isCxMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-4"}`}>
|
||||
{responseCount > 0 &&
|
||||
[
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
TSurveyQuestionTypeEnum.PictureSelection,
|
||||
TSurveyQuestionTypeEnum.Rating,
|
||||
TSurveyQuestionTypeEnum.NPS,
|
||||
TSurveyQuestionTypeEnum.Ranking,
|
||||
TSurveyQuestionTypeEnum.Matrix,
|
||||
].includes(question.type) ? (
|
||||
<Alert variant="warning" size="small" className="w-fill" role="alert">
|
||||
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
|
||||
<AlertButton onClick={() => onAlertTrigger()}>{t("common.learn_more")}</AlertButton>
|
||||
</Alert>
|
||||
) : null}
|
||||
{question.type === TSurveyQuestionTypeEnum.OpenText ? (
|
||||
<OpenQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.NPS ? (
|
||||
<NPSQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.CTA ? (
|
||||
<CTAQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Rating ? (
|
||||
<RatingQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Consent ? (
|
||||
<ConsentQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Date ? (
|
||||
<DateQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.PictureSelection ? (
|
||||
<PictureSelectionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.FileUpload ? (
|
||||
<FileUploadQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
project={project}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Cal ? (
|
||||
<CalQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Matrix ? (
|
||||
<MatrixQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Address ? (
|
||||
<AddressQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.Ranking ? (
|
||||
<RankingQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? (
|
||||
<ContactInfoQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-5">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
className="flex items-center text-sm text-slate-700"
|
||||
aria-label="Toggle advanced settings">
|
||||
{openAdvanced ? (
|
||||
<ChevronDownIcon className="mr-1 h-4 w-3" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mr-2 h-4 w-3" />
|
||||
)}
|
||||
{openAdvanced
|
||||
? t("environments.surveys.edit.hide_advanced_settings")
|
||||
: t("environments.surveys.edit.show_advanced_settings")}
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
|
||||
<Collapsible.CollapsibleContent className="flex flex-col gap-4" ref={parent}>
|
||||
{question.type !== TSurveyQuestionTypeEnum.NPS &&
|
||||
question.type !== TSurveyQuestionTypeEnum.Rating &&
|
||||
question.type !== TSurveyQuestionTypeEnum.CTA ? (
|
||||
<div className="mt-2 flex space-x-2">
|
||||
{questionIdx !== 0 && (
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={blockBackButtonLabel}
|
||||
label={t("environments.surveys.edit.back_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={t("common.back")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
onBlur={(e) => {
|
||||
if (!blockBackButtonLabel) return;
|
||||
let translatedBackButtonLabel = {
|
||||
...blockBackButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
if (parentBlockIndex === -1) return;
|
||||
updateBlockButtonLabel(
|
||||
parentBlockIndex,
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel
|
||||
);
|
||||
updateEmptyButtonLabels(
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel,
|
||||
parentBlockIndex
|
||||
);
|
||||
}}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={blockButtonLabel}
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={lastQuestion ? t("common.finish") : t("common.next")}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
onBlur={(e) => {
|
||||
if (!blockButtonLabel) return;
|
||||
let translatedNextButtonLabel = {
|
||||
...blockButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
if (parentBlockIndex === -1) return;
|
||||
updateBlockButtonLabel(parentBlockIndex, "buttonLabel", translatedNextButtonLabel);
|
||||
// Don't propagate to last block
|
||||
const lastBlockIndex = localSurvey.blocks.length - 1;
|
||||
if (parentBlockIndex !== lastBlockIndex) {
|
||||
updateEmptyButtonLabels("buttonLabel", translatedNextButtonLabel, lastBlockIndex);
|
||||
}
|
||||
}}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{(question.type === TSurveyQuestionTypeEnum.Rating ||
|
||||
question.type === TSurveyQuestionTypeEnum.NPS) &&
|
||||
questionIdx !== 0 && (
|
||||
<div className="mt-4">
|
||||
<QuestionFormInput
|
||||
id="backButtonLabel"
|
||||
value={blockBackButtonLabel}
|
||||
label={`"Back" Button Label`}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
maxLength={48}
|
||||
placeholder={"Back"}
|
||||
isInvalid={isInvalid}
|
||||
updateQuestion={updateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
onBlur={(e) => {
|
||||
if (!blockBackButtonLabel) return;
|
||||
const translatedBackButtonLabel = {
|
||||
...blockBackButtonLabel,
|
||||
[selectedLanguageCode]: e.target.value,
|
||||
};
|
||||
if (parentBlockIndex === -1) return;
|
||||
updateBlockButtonLabel(
|
||||
parentBlockIndex,
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel
|
||||
);
|
||||
updateEmptyButtonLabels(
|
||||
"backButtonLabel",
|
||||
translatedBackButtonLabel,
|
||||
parentBlockIndex
|
||||
);
|
||||
}}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdvancedSettings
|
||||
// TODO -- We should remove this when we can confirm that everything works fine with the survey editor, not changing this right now in this file because it would require changing the question type to the respective element type in all the question forms.
|
||||
question={question as unknown as TSurveyElement}
|
||||
questionIdx={questionIdx}
|
||||
localSurvey={localSurvey}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
updateBlockLogicFallback={updateBlockLogicFallback}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
/>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
|
||||
{open && (
|
||||
<div className="mx-4 flex justify-end space-x-6 border-t border-slate-200">
|
||||
{question.type === "openText" && (
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="longAnswer">{t("environments.surveys.edit.long_answer")}</Label>
|
||||
<Switch
|
||||
id="longAnswer"
|
||||
disabled={question.inputType !== "text"}
|
||||
checked={question.longAnswer !== false}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuestion(questionIdx, {
|
||||
longAnswer: typeof question.longAnswer === "undefined" ? false : !question.longAnswer,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="required-toggle">{t("environments.surveys.edit.required")}</Label>
|
||||
<Switch
|
||||
id="required-toggle"
|
||||
checked={question.required}
|
||||
disabled={getIsRequiredToggleDisabled()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRequiredToggle();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,13 +5,8 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyQuestionChoice,
|
||||
TSurveyRankingQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TSurveyMultipleChoiceElement, TSurveyRankingElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyLanguage, TSurveyQuestionChoice } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
@@ -32,10 +27,10 @@ interface ChoiceProps {
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
surveyLanguages: TSurveyLanguage[];
|
||||
question: TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion;
|
||||
question: TSurveyMultipleChoiceElement | TSurveyRankingElement;
|
||||
updateQuestion: (
|
||||
questionIdx: number,
|
||||
updatedAttributes: Partial<TSurveyMultipleChoiceQuestion> | Partial<TSurveyRankingQuestion>
|
||||
updatedAttributes: Partial<TSurveyMultipleChoiceElement> | Partial<TSurveyRankingElement>
|
||||
) => void;
|
||||
surveyLanguageCodes: string[];
|
||||
locale: TUserLocale;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { Project } from "@prisma/client";
|
||||
import { useMemo } from "react";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
|
||||
interface QuestionsDraggableProps {
|
||||
localSurvey: TSurvey;
|
||||
project: Project;
|
||||
moveQuestion: (questionIndex: number, up: boolean) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void;
|
||||
updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void;
|
||||
updateBlockButtonLabel: (
|
||||
blockIndex: number,
|
||||
labelKey: "buttonLabel" | "backButtonLabel",
|
||||
labelValue: TI18nString | undefined
|
||||
) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
invalidQuestions: string[] | null;
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
isCxMode: boolean;
|
||||
locale: TUserLocale;
|
||||
responseCount: number;
|
||||
onAlertTrigger: () => void;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed: boolean;
|
||||
}
|
||||
|
||||
export const QuestionsDroppable = ({
|
||||
activeQuestionId,
|
||||
deleteQuestion,
|
||||
duplicateQuestion,
|
||||
invalidQuestions,
|
||||
localSurvey,
|
||||
moveQuestion,
|
||||
project,
|
||||
selectedLanguageCode,
|
||||
setActiveQuestionId,
|
||||
setSelectedLanguageCode,
|
||||
updateQuestion,
|
||||
updateBlockLogic,
|
||||
updateBlockLogicFallback,
|
||||
updateBlockButtonLabel,
|
||||
addQuestion,
|
||||
isFormbricksCloud,
|
||||
isCxMode,
|
||||
locale,
|
||||
responseCount,
|
||||
onAlertTrigger,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: QuestionsDraggableProps) => {
|
||||
const [parent] = useAutoAnimate();
|
||||
|
||||
// Derive questions from blocks for display
|
||||
const questions = useMemo(() => getElementsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
|
||||
|
||||
return (
|
||||
<div className="group mb-5 flex w-full flex-col gap-5" ref={parent}>
|
||||
<SortableContext items={questions} strategy={verticalListSortingStrategy}>
|
||||
{questions.map((question, questionIdx) => (
|
||||
<QuestionCard
|
||||
key={question.id}
|
||||
localSurvey={localSurvey}
|
||||
project={project}
|
||||
// TODO: Refactor question forms to use TSurveyElement instead of TSurveyQuestion
|
||||
// The forms no longer need TSurveyQuestion since logic/buttonLabel are now block-level
|
||||
question={question as unknown as TSurveyQuestion}
|
||||
questionIdx={questionIdx}
|
||||
moveQuestion={moveQuestion}
|
||||
updateQuestion={updateQuestion}
|
||||
updateBlockLogic={updateBlockLogic}
|
||||
updateBlockLogicFallback={updateBlockLogicFallback}
|
||||
updateBlockButtonLabel={updateBlockButtonLabel}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
deleteQuestion={deleteQuestion}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
lastQuestion={questionIdx === questions.length - 1}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
|
||||
addQuestion={addQuestion}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isCxMode={isCxMode}
|
||||
locale={locale}
|
||||
responseCount={responseCount}
|
||||
onAlertTrigger={onAlertTrigger}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -19,8 +19,9 @@ import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TSurveyBlock, TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
|
||||
import { findBlocksWithCyclicLogic } from "@formbricks/types/surveys/blocks-validation";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
@@ -30,17 +31,20 @@ import { checkForEmptyFallBackValue, extractRecallInfo } from "@/lib/utils/recal
|
||||
import { MultiLanguageCard } from "@/modules/ee/multi-language-surveys/components/multi-language-card";
|
||||
import { AddEndingCardButton } from "@/modules/survey/editor/components/add-ending-card-button";
|
||||
import { AddQuestionButton } from "@/modules/survey/editor/components/add-question-button";
|
||||
import { BlocksDroppable } from "@/modules/survey/editor/components/blocks-droppable";
|
||||
import { EditEndingCard } from "@/modules/survey/editor/components/edit-ending-card";
|
||||
import { EditWelcomeCard } from "@/modules/survey/editor/components/edit-welcome-card";
|
||||
import { HiddenFieldsCard } from "@/modules/survey/editor/components/hidden-fields-card";
|
||||
import { QuestionsDroppable } from "@/modules/survey/editor/components/questions-droppable";
|
||||
import { SurveyVariablesCard } from "@/modules/survey/editor/components/survey-variables-card";
|
||||
import {
|
||||
addBlock,
|
||||
addElementToBlock,
|
||||
deleteBlock,
|
||||
duplicateBlock,
|
||||
deleteElementFromBlock,
|
||||
duplicateBlock as duplicateBlockHelper,
|
||||
findElementLocation,
|
||||
moveBlock,
|
||||
moveBlock as moveBlockHelper,
|
||||
moveElementInBlock,
|
||||
updateElementInBlock,
|
||||
} from "@/modules/survey/editor/lib/blocks";
|
||||
import { findQuestionUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
|
||||
@@ -48,15 +52,15 @@ import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import {
|
||||
isEndingCardValid,
|
||||
isWelcomeCardValid,
|
||||
validateQuestion,
|
||||
validateSurveyQuestionsInBatch,
|
||||
validateElement,
|
||||
validateSurveyElementsInBatch,
|
||||
} from "../lib/validation";
|
||||
|
||||
interface QuestionsViewProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<SetStateAction<TSurvey>>;
|
||||
activeQuestionId: TSurveyQuestionId | null;
|
||||
setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
project: Project;
|
||||
projectLanguages: Language[];
|
||||
invalidQuestions: string[] | null;
|
||||
@@ -229,35 +233,32 @@ export const QuestionsView = ({
|
||||
}
|
||||
}, [localSurvey.welcomeCard, localSurvey.endings, surveyLanguages, invalidQuestions, setInvalidQuestions]);
|
||||
|
||||
// function to validate individual questions
|
||||
const validateSurveyQuestion = (question: TSurveyQuestion) => {
|
||||
// function to validate individual elements
|
||||
const validateSurveyElement = (element: TSurveyElement) => {
|
||||
// prevent this function to execute further if user hasnt still tried to save the survey
|
||||
if (invalidQuestions === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstElement = localSurvey.blocks?.[0]?.elements[0];
|
||||
const isFirstQuestion = firstElement ? question.id === firstElement.id : false;
|
||||
|
||||
if (validateQuestion(question as unknown as TSurveyQuestion, surveyLanguages, isFirstQuestion)) {
|
||||
if (validateElement(element, surveyLanguages)) {
|
||||
const blocksWithCyclicLogic = findBlocksWithCyclicLogic(localSurvey.blocks);
|
||||
|
||||
for (const blockId of blocksWithCyclicLogic) {
|
||||
const block = localSurvey.blocks.find((b) => b.id === blockId);
|
||||
if (block) {
|
||||
const questionId = getQuestionIdFromBlockId(block);
|
||||
if (questionId === question.id) {
|
||||
setInvalidQuestions([...invalidQuestions, question.id]);
|
||||
const elementId = getQuestionIdFromBlockId(block);
|
||||
if (elementId === element.id) {
|
||||
setInvalidQuestions([...invalidQuestions, element.id]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInvalidQuestions(invalidQuestions.filter((id) => id !== question.id));
|
||||
setInvalidQuestions(invalidQuestions.filter((id) => id !== element.id));
|
||||
return;
|
||||
}
|
||||
|
||||
setInvalidQuestions([...invalidQuestions, question.id]);
|
||||
setInvalidQuestions([...invalidQuestions, element.id]);
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -337,12 +338,12 @@ export const QuestionsView = ({
|
||||
|
||||
updatedSurvey = result.data;
|
||||
|
||||
// Validate the updated question
|
||||
const updatedQuestion = updatedSurvey.blocks
|
||||
// Validate the updated element
|
||||
const updatedElement = updatedSurvey.blocks
|
||||
?.flatMap((b) => b.elements)
|
||||
.find((q) => q.id === (cleanedAttributes.id ?? question.id));
|
||||
if (updatedQuestion) {
|
||||
validateSurveyQuestion(updatedQuestion as unknown as TSurveyQuestion);
|
||||
if (updatedElement) {
|
||||
validateSurveyElement(updatedElement);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,22 +467,36 @@ export const QuestionsView = ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Find and delete the block containing this question
|
||||
const { blockId } = findElementLocation(localSurvey, questionId);
|
||||
if (!blockId) return;
|
||||
// Find the block containing this question
|
||||
const { blockId, blockIndex } = findElementLocation(localSurvey, questionId);
|
||||
if (!blockId || blockIndex === -1) return;
|
||||
|
||||
const result = deleteBlock(updatedSurvey, blockId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
const block = updatedSurvey.blocks[blockIndex];
|
||||
|
||||
// If this is the only element in the block, delete the entire block
|
||||
if (block.elements.length === 1) {
|
||||
const result = deleteBlock(updatedSurvey, blockId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
updatedSurvey = result.data;
|
||||
} else {
|
||||
// Otherwise, just remove this element from the block
|
||||
const result = deleteElementFromBlock(updatedSurvey, blockId, questionId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
updatedSurvey = result.data;
|
||||
}
|
||||
|
||||
const firstEndingCard = localSurvey.endings[0];
|
||||
setLocalSurvey(result.data);
|
||||
setLocalSurvey(updatedSurvey);
|
||||
delete internalQuestionIdMap[questionId];
|
||||
|
||||
if (questionId === activeQuestionIdTemp) {
|
||||
const newQuestions = result.data.blocks?.flatMap((b) => b.elements) ?? [];
|
||||
const newQuestions = updatedSurvey.blocks.flatMap((b) => b.elements) ?? [];
|
||||
if (questionIdx <= newQuestions.length && newQuestions.length > 0) {
|
||||
setActiveQuestionId(newQuestions[questionIdx % newQuestions.length].id);
|
||||
} else if (firstEndingCard) {
|
||||
@@ -496,32 +511,29 @@ export const QuestionsView = ({
|
||||
const question = questions[questionIdx];
|
||||
if (!question) return;
|
||||
|
||||
const { blockId } = findElementLocation(localSurvey, question.id);
|
||||
if (!blockId) return;
|
||||
const { blockId, blockIndex } = findElementLocation(localSurvey, question.id);
|
||||
if (!blockId || blockIndex === -1) return;
|
||||
|
||||
const result = duplicateBlock(localSurvey, blockId);
|
||||
// Create a duplicate of the element with a new ID
|
||||
const newElementId = createId();
|
||||
const duplicatedElement = { ...question, id: newElementId };
|
||||
|
||||
// Add the duplicated element to the same block
|
||||
const result = addElementToBlock(localSurvey, blockId, duplicatedElement);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// The duplicated block has new element IDs, find the first one
|
||||
const allBlocks = result.data.blocks ?? [];
|
||||
const { blockIndex } = findElementLocation(localSurvey, question.id);
|
||||
const duplicatedBlock = allBlocks[blockIndex + 1];
|
||||
const newElementId = duplicatedBlock?.elements[0]?.id;
|
||||
|
||||
if (newElementId) {
|
||||
setActiveQuestionId(newElementId);
|
||||
internalQuestionIdMap[newElementId] = createId();
|
||||
}
|
||||
setActiveQuestionId(newElementId);
|
||||
internalQuestionIdMap[newElementId] = createId();
|
||||
|
||||
setLocalSurvey(result.data);
|
||||
toast.success(t("environments.surveys.edit.question_duplicated"));
|
||||
};
|
||||
|
||||
const addQuestion = (question: TSurveyQuestion, index?: number) => {
|
||||
const addQuestion = (question: TSurveyElement, index?: number) => {
|
||||
const languageSymbols = extractLanguageCodes(localSurvey.languages);
|
||||
const updatedQuestion = addMultiLanguageLabels(question, languageSymbols);
|
||||
|
||||
@@ -543,6 +555,61 @@ export const QuestionsView = ({
|
||||
internalQuestionIdMap[question.id] = createId();
|
||||
};
|
||||
|
||||
const _addElementToBlock = (question: TSurveyElement, questionIdx: number) => {
|
||||
const languageSymbols = extractLanguageCodes(localSurvey.languages);
|
||||
const updatedQuestion = addMultiLanguageLabels(question, languageSymbols);
|
||||
|
||||
// Find which block this question belongs to
|
||||
const currentQuestion = questions[questionIdx];
|
||||
if (!currentQuestion) return;
|
||||
|
||||
const { blockId, blockIndex } = findElementLocation(localSurvey, currentQuestion.id);
|
||||
if (!blockId || blockIndex === -1) return;
|
||||
|
||||
const block = localSurvey.blocks[blockIndex];
|
||||
const elementIndexInBlock = block.elements.findIndex((el) => el.id === currentQuestion.id);
|
||||
|
||||
// Add the new element after the current element in the same block
|
||||
const result = addElementToBlock(localSurvey, blockId, {
|
||||
...updatedQuestion,
|
||||
isDraft: true,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Now move the newly added element to the correct position (after the current element)
|
||||
const newElementId = updatedQuestion.id;
|
||||
const newBlock = result.data.blocks[blockIndex];
|
||||
const newElementIndex = newBlock.elements.findIndex((el) => el.id === newElementId);
|
||||
|
||||
// If we need to move the element (it was added at the end but should be after current element)
|
||||
let finalSurvey = result.data;
|
||||
if (newElementIndex !== elementIndexInBlock + 1) {
|
||||
// Move the element to the correct position
|
||||
const reorderedElements = [...newBlock.elements];
|
||||
const [movedElement] = reorderedElements.splice(newElementIndex, 1);
|
||||
reorderedElements.splice(elementIndexInBlock + 1, 0, movedElement);
|
||||
|
||||
const updatedBlocks = [...finalSurvey.blocks];
|
||||
updatedBlocks[blockIndex] = {
|
||||
...newBlock,
|
||||
elements: reorderedElements,
|
||||
};
|
||||
|
||||
finalSurvey = {
|
||||
...finalSurvey,
|
||||
blocks: updatedBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
setLocalSurvey(finalSurvey);
|
||||
setActiveQuestionId(newElementId);
|
||||
internalQuestionIdMap[newElementId] = createId();
|
||||
};
|
||||
|
||||
const addEndingCard = (index: number) => {
|
||||
const updatedSurvey = structuredClone(localSurvey);
|
||||
const newEndingCard = getDefaultEndingCard(localSurvey.languages, t);
|
||||
@@ -557,11 +624,87 @@ export const QuestionsView = ({
|
||||
const question = questions[questionIndex];
|
||||
if (!question) return;
|
||||
|
||||
const { blockId } = findElementLocation(localSurvey, question.id);
|
||||
if (!blockId) return;
|
||||
const { blockId, blockIndex } = findElementLocation(localSurvey, question.id);
|
||||
if (!blockId || blockIndex === -1) return;
|
||||
|
||||
const block = localSurvey.blocks[blockIndex];
|
||||
const elementIndex = block.elements.findIndex((el) => el.id === question.id);
|
||||
|
||||
// If block has multiple elements, move element within the block
|
||||
if (block.elements.length > 1) {
|
||||
// Check if we can move in the desired direction within the block
|
||||
if ((up && elementIndex > 0) || (!up && elementIndex < block.elements.length - 1)) {
|
||||
const direction = up ? "up" : "down";
|
||||
const result = moveElementInBlock(localSurvey, blockId, question.id, direction);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalSurvey(result.data);
|
||||
return;
|
||||
}
|
||||
// If we can't move within block, fall through to move the entire block
|
||||
}
|
||||
|
||||
// Move the entire block
|
||||
const direction = up ? "up" : "down";
|
||||
const result = moveBlock(localSurvey, blockId, direction);
|
||||
const result = moveBlockHelper(localSurvey, blockId, direction);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalSurvey(result.data);
|
||||
};
|
||||
|
||||
// Block-level operations
|
||||
const duplicateBlock = (blockId: string) => {
|
||||
const result = duplicateBlockHelper(localSurvey, blockId);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the duplicated block and set the first element as active
|
||||
const blockIndex = localSurvey.blocks.findIndex((b) => b.id === blockId);
|
||||
if (blockIndex !== -1) {
|
||||
const duplicatedBlock = result.data.blocks[blockIndex + 1];
|
||||
if (duplicatedBlock?.elements[0]) {
|
||||
setActiveQuestionId(duplicatedBlock.elements[0].id);
|
||||
internalQuestionIdMap[duplicatedBlock.elements[0].id] = createId();
|
||||
}
|
||||
}
|
||||
|
||||
setLocalSurvey(result.data);
|
||||
toast.success(t("environments.surveys.edit.block_duplicated"));
|
||||
};
|
||||
|
||||
const deleteBlockById = (blockId: string) => {
|
||||
const result = deleteBlock(localSurvey, blockId);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set active question to the first element of the first remaining block or ending card
|
||||
const newBlocks = result.data.blocks ?? [];
|
||||
if (newBlocks.length > 0 && newBlocks[0].elements.length > 0) {
|
||||
setActiveQuestionId(newBlocks[0].elements[0].id);
|
||||
} else if (result.data.endings[0]) {
|
||||
setActiveQuestionId(result.data.endings[0].id);
|
||||
}
|
||||
|
||||
setLocalSurvey(result.data);
|
||||
toast.success(t("environments.surveys.edit.block_deleted"));
|
||||
};
|
||||
|
||||
const moveBlockById = (blockId: string, direction: "up" | "down") => {
|
||||
const result = moveBlockHelper(localSurvey, blockId, direction);
|
||||
|
||||
if (!result.ok) {
|
||||
toast.error(result.error.message);
|
||||
@@ -575,13 +718,12 @@ export const QuestionsView = ({
|
||||
useEffect(() => {
|
||||
if (!invalidQuestions) return;
|
||||
let updatedInvalidQuestions: string[] = invalidQuestions;
|
||||
// Validate each question
|
||||
questions.forEach((question, index) => {
|
||||
updatedInvalidQuestions = validateSurveyQuestionsInBatch(
|
||||
question as unknown as TSurveyQuestion,
|
||||
// Validate each element
|
||||
questions.forEach((element) => {
|
||||
updatedInvalidQuestions = validateSurveyElementsInBatch(
|
||||
element,
|
||||
updatedInvalidQuestions,
|
||||
surveyLanguages,
|
||||
index === 0
|
||||
surveyLanguages
|
||||
);
|
||||
});
|
||||
|
||||
@@ -669,8 +811,9 @@ export const QuestionsView = ({
|
||||
sensors={sensors}
|
||||
onDragEnd={onQuestionCardDragEnd}
|
||||
collisionDetection={closestCorners}>
|
||||
<QuestionsDroppable
|
||||
<BlocksDroppable
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
project={project}
|
||||
moveQuestion={moveQuestion}
|
||||
updateQuestion={updateQuestion}
|
||||
@@ -692,6 +835,10 @@ export const QuestionsView = ({
|
||||
onAlertTrigger={() => setIsCautionDialogOpen(true)}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
duplicateBlock={duplicateBlock}
|
||||
deleteBlock={deleteBlockById}
|
||||
moveBlock={moveBlockById}
|
||||
addElementToBlock={_addElementToBlock}
|
||||
/>
|
||||
</DndContext>
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurvey, TSurveyRankingQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyRankingElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
@@ -19,9 +20,9 @@ import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-sele
|
||||
|
||||
interface RankingQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyRankingQuestion;
|
||||
question: TSurveyRankingElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyRankingQuestion>) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyRankingElement>) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
isInvalid: boolean;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey, TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyRatingElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
@@ -14,9 +16,9 @@ import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface RatingQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyRatingQuestion;
|
||||
question: TSurveyRatingElement;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyRatingElement>) => void;
|
||||
lastQuestion: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
@@ -24,6 +26,7 @@ interface RatingQuestionFormProps {
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
buttonLabel?: TI18nString;
|
||||
}
|
||||
|
||||
export const RatingQuestionForm = ({
|
||||
@@ -37,6 +40,7 @@ export const RatingQuestionForm = ({
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
buttonLabel,
|
||||
}: RatingQuestionFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
@@ -115,7 +119,7 @@ export const RatingQuestionForm = ({
|
||||
updateQuestion(questionIdx, { scale: option.value, isColorCodingEnabled: false });
|
||||
return;
|
||||
}
|
||||
updateQuestion(questionIdx, { scale: option.value });
|
||||
updateQuestion(questionIdx, { scale: option.value as "number" | "smiley" | "star" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -134,7 +138,9 @@ export const RatingQuestionForm = ({
|
||||
]}
|
||||
/* disabled={survey.status !== "draft"} */
|
||||
defaultValue={question.range || 5}
|
||||
onSelect={(option) => updateQuestion(questionIdx, { range: option.value })}
|
||||
onSelect={(option) =>
|
||||
updateQuestion(questionIdx, { range: option.value as TSurveyRatingElement["range"] })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,7 +186,7 @@ export const RatingQuestionForm = ({
|
||||
<div className="flex-1">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
value={question.buttonLabel}
|
||||
value={buttonLabel}
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
|
||||
@@ -411,3 +411,54 @@ export const duplicateElementInBlock = (
|
||||
blocks,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves an element up or down within a block
|
||||
* @param survey - The survey containing the block
|
||||
* @param blockId - The CUID of the block containing the element
|
||||
* @param elementId - The ID of the element to move
|
||||
* @param direction - Direction to move ("up" or "down")
|
||||
* @returns Result with updated survey (or unchanged if at boundary) or Error
|
||||
*/
|
||||
export const moveElementInBlock = (
|
||||
survey: TSurvey,
|
||||
blockId: string,
|
||||
elementId: string,
|
||||
direction: "up" | "down"
|
||||
): Result<TSurvey, Error> => {
|
||||
const blocks = [...(survey.blocks || [])];
|
||||
const blockIndex = blocks.findIndex((b) => b.id === blockId);
|
||||
|
||||
if (blockIndex === -1) {
|
||||
return err(new Error(`Block with ID "${blockId}" not found`));
|
||||
}
|
||||
|
||||
const block = { ...blocks[blockIndex] };
|
||||
const elements = [...block.elements];
|
||||
const elementIndex = elements.findIndex((e) => e.id === elementId);
|
||||
|
||||
if (elementIndex === -1) {
|
||||
return err(new Error(`Element with ID "${elementId}" not found in block "${blockId}"`));
|
||||
}
|
||||
|
||||
if (direction === "up" && elementIndex === 0) {
|
||||
return ok(survey); // Already at top
|
||||
}
|
||||
|
||||
if (direction === "down" && elementIndex === elements.length - 1) {
|
||||
return ok(survey); // Already at bottom
|
||||
}
|
||||
|
||||
const targetIndex = direction === "up" ? elementIndex - 1 : elementIndex + 1;
|
||||
|
||||
// Swap using destructuring assignment
|
||||
[elements[elementIndex], elements[targetIndex]] = [elements[targetIndex], elements[elementIndex]];
|
||||
|
||||
block.elements = elements;
|
||||
blocks[blockIndex] = block;
|
||||
|
||||
return ok({
|
||||
...survey,
|
||||
blocks,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,14 +3,16 @@ import { toast } from "react-hot-toast";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import {
|
||||
TSurveyConsentElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyOpenTextElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyLanguage,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRedirectUrlCard,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -285,22 +287,20 @@ describe("validation.isEndingCardValid", () => {
|
||||
// });
|
||||
});
|
||||
|
||||
describe("validation.validateQuestion", () => {
|
||||
const baseQuestionFields = {
|
||||
id: "question1",
|
||||
describe("validation.validateElement", () => {
|
||||
const baseElementFields = {
|
||||
id: "element1",
|
||||
required: false,
|
||||
logic: [],
|
||||
};
|
||||
|
||||
// Test OpenText Question
|
||||
describe("OpenText Question", () => {
|
||||
const openTextQuestionBase: TSurveyOpenTextQuestion = {
|
||||
...baseQuestionFields,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
// Test OpenText Element
|
||||
describe("OpenText Element", () => {
|
||||
const openTextElementBase: TSurveyOpenTextElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Open Text", en: "Open Text", de: "Offener Text" },
|
||||
subheader: { default: "Enter here", en: "Enter here", de: "Hier eingeben" },
|
||||
placeholder: { default: "Your answer...", en: "Your answer...", de: "Deine Antwort..." },
|
||||
longAnswer: false,
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: true,
|
||||
@@ -309,39 +309,39 @@ describe("validation.validateQuestion", () => {
|
||||
},
|
||||
};
|
||||
|
||||
test("should return true for a valid OpenText question", () => {
|
||||
expect(validation.validateQuestion(openTextQuestionBase, surveyLanguagesEnabled, false)).toBe(true);
|
||||
test("should return true for a valid OpenText element", () => {
|
||||
expect(validation.validateElement(openTextElementBase, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if headline is invalid", () => {
|
||||
const q = { ...openTextQuestionBase, headline: { default: "Open Text", en: "Open Text", de: "" } };
|
||||
expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(false);
|
||||
const q = { ...openTextElementBase, headline: { default: "Open Text", en: "Open Text", de: "" } };
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if placeholder is valid (default not empty, other languages valid)", () => {
|
||||
const q = {
|
||||
...openTextQuestionBase,
|
||||
...openTextElementBase,
|
||||
placeholder: { default: "Type here", en: "Type here", de: "Tippe hier" },
|
||||
};
|
||||
expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(true);
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if placeholder.default is not empty but other lang is empty", () => {
|
||||
const q = { ...openTextQuestionBase, placeholder: { default: "Type here", en: "Type here", de: "" } };
|
||||
expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(false);
|
||||
const q = { ...openTextElementBase, placeholder: { default: "Type here", en: "Type here", de: "" } };
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true if placeholder.default is empty (placeholder validation skipped)", () => {
|
||||
const q = { ...openTextQuestionBase, placeholder: { default: "", en: "Type here", de: "" } };
|
||||
expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(true);
|
||||
const q = { ...openTextElementBase, placeholder: { default: "", en: "Type here", de: "" } };
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Test MultipleChoiceSingle Question
|
||||
describe("MultipleChoiceSingle Question", () => {
|
||||
const mcSingleQuestionBase: TSurveyMultipleChoiceQuestion = {
|
||||
...baseQuestionFields,
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
// Test MultipleChoiceSingle Element
|
||||
describe("MultipleChoiceSingle Element", () => {
|
||||
const mcSingleElementBase: TSurveyMultipleChoiceElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "Single Choice", en: "Single Choice", de: "Einzelauswahl" },
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Option 1", en: "Option 1", de: "Option 1" } },
|
||||
@@ -349,47 +349,47 @@ describe("validation.validateQuestion", () => {
|
||||
],
|
||||
};
|
||||
|
||||
test("should return true for a valid MultipleChoiceSingle question", () => {
|
||||
expect(validation.validateQuestion(mcSingleQuestionBase, surveyLanguagesEnabled, false)).toBe(true);
|
||||
test("should return true for a valid MultipleChoiceSingle element", () => {
|
||||
expect(validation.validateElement(mcSingleElementBase, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if a choice label is invalid", () => {
|
||||
const q = {
|
||||
...mcSingleQuestionBase,
|
||||
...mcSingleElementBase,
|
||||
choices: [
|
||||
{ id: "c1", label: { default: "Option 1", en: "Option 1", de: "Option 1" } },
|
||||
{ id: "c2", label: { default: "Option 2", en: "Option 2", de: "" } },
|
||||
],
|
||||
};
|
||||
expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(false);
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Test Consent Question
|
||||
describe("Consent Question", () => {
|
||||
const consentQuestionBase: TSurveyConsentQuestion = {
|
||||
...baseQuestionFields,
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
// Test Consent Element
|
||||
describe("Consent Element", () => {
|
||||
const consentElementBase: TSurveyConsentElement = {
|
||||
...baseElementFields,
|
||||
type: TSurveyElementTypeEnum.Consent,
|
||||
headline: { default: "Consent", en: "Consent", de: "Zustimmung" },
|
||||
label: { default: "I agree", en: "I agree", de: "Ich stimme zu" },
|
||||
subheader: { default: "Details...", en: "Details...", de: "Details..." },
|
||||
};
|
||||
|
||||
test("should return true for a valid Consent question", () => {
|
||||
expect(validation.validateQuestion(consentQuestionBase, surveyLanguagesEnabled, false)).toBe(true);
|
||||
test("should return true for a valid Consent element", () => {
|
||||
expect(validation.validateElement(consentElementBase, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if consent label is invalid", () => {
|
||||
const q = { ...consentQuestionBase, label: { default: "I agree", en: "I agree", de: "" } };
|
||||
expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(false);
|
||||
const q = { ...consentElementBase, label: { default: "I agree", en: "I agree", de: "" } };
|
||||
expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation.validateSurveyQuestionsInBatch", () => {
|
||||
const q2Valid: TSurveyOpenTextQuestion = {
|
||||
describe("validation.validateSurveyElementsInBatch", () => {
|
||||
const q2Valid: TSurveyOpenTextElement = {
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q2", en: "Q2", de: "Q2" },
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
@@ -400,9 +400,9 @@ describe("validation.validateSurveyQuestionsInBatch", () => {
|
||||
required: false,
|
||||
};
|
||||
|
||||
const q2Invalid: TSurveyOpenTextQuestion = {
|
||||
const q2Invalid: TSurveyOpenTextElement = {
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q2", en: "Q2", de: "" },
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
@@ -413,42 +413,39 @@ describe("validation.validateSurveyQuestionsInBatch", () => {
|
||||
required: false,
|
||||
};
|
||||
|
||||
test("should return empty array if invalidQuestions is null", () => {
|
||||
expect(validation.validateSurveyQuestionsInBatch(q2Valid, null, surveyLanguagesEnabled, false)).toEqual(
|
||||
[]
|
||||
);
|
||||
test("should return empty array if invalidElements is null", () => {
|
||||
expect(validation.validateSurveyElementsInBatch(q2Valid, null, surveyLanguagesEnabled)).toEqual([]);
|
||||
});
|
||||
|
||||
test("should add question.id if question is invalid and not already in list", () => {
|
||||
const invalidQuestions = ["q1"];
|
||||
test("should add element.id if element is invalid and not already in list", () => {
|
||||
const invalidElements = ["q1"];
|
||||
expect(
|
||||
validation.validateSurveyQuestionsInBatch(q2Invalid, invalidQuestions, surveyLanguagesEnabled, false)
|
||||
validation.validateSurveyElementsInBatch(q2Invalid, invalidElements, surveyLanguagesEnabled)
|
||||
).toEqual(["q1", "q2"]);
|
||||
});
|
||||
|
||||
test("should not add question.id if question is invalid but already in list", () => {
|
||||
const invalidQuestions = ["q1", "q2"];
|
||||
test("should not add element.id if element is invalid but already in list", () => {
|
||||
const invalidElements = ["q1", "q2"];
|
||||
expect(
|
||||
validation.validateSurveyQuestionsInBatch(q2Invalid, invalidQuestions, surveyLanguagesEnabled, false)
|
||||
validation.validateSurveyElementsInBatch(q2Invalid, invalidElements, surveyLanguagesEnabled)
|
||||
).toEqual(["q1", "q2"]);
|
||||
});
|
||||
|
||||
test("should remove question.id if question is valid and in list", () => {
|
||||
const invalidQuestions = ["q1", "q2"];
|
||||
test("should remove element.id if element is valid and in list", () => {
|
||||
const invalidElements = ["q1", "q2"];
|
||||
expect(
|
||||
validation.validateSurveyQuestionsInBatch(q2Valid, invalidQuestions, surveyLanguagesEnabled, false)
|
||||
validation.validateSurveyElementsInBatch(q2Valid, invalidElements, surveyLanguagesEnabled)
|
||||
).toEqual(["q1"]);
|
||||
});
|
||||
|
||||
test("should not change list if question is valid and not in list", () => {
|
||||
const invalidQuestions = ["q1"];
|
||||
const validateQuestionSpy = vi.spyOn(validation, "validateQuestion");
|
||||
validateQuestionSpy.mockReturnValue(true);
|
||||
const result = validation.validateSurveyQuestionsInBatch(
|
||||
test("should not change list if element is valid and not in list", () => {
|
||||
const invalidElements = ["q1"];
|
||||
const validateElementSpy = vi.spyOn(validation, "validateElement");
|
||||
validateElementSpy.mockReturnValue(true);
|
||||
const result = validation.validateSurveyElementsInBatch(
|
||||
q2Valid,
|
||||
[...invalidQuestions],
|
||||
surveyLanguagesEnabled,
|
||||
false
|
||||
[...invalidElements],
|
||||
surveyLanguagesEnabled
|
||||
);
|
||||
expect(result).toEqual(["q1"]);
|
||||
});
|
||||
@@ -468,14 +465,23 @@ describe("validation.isSurveyValid", () => {
|
||||
type: "web",
|
||||
environmentId: "env1",
|
||||
status: "draft",
|
||||
questions: [
|
||||
questions: [],
|
||||
blocks: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Q1", en: "Q1", de: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: 0,
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1", en: "Q1", de: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
@@ -500,10 +506,10 @@ describe("validation.isSurveyValid", () => {
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return false and toast error if checkForEmptyFallBackValue returns a question", () => {
|
||||
test("should return false and toast error if checkForEmptyFallBackValue returns an element", () => {
|
||||
vi.mocked(checkForEmptyFallBackValue).mockReturnValue({
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1", en: "Q1", de: "Q1" },
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
|
||||
@@ -6,18 +6,20 @@ import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import {
|
||||
TInputFieldConfig,
|
||||
TSurveyAddressElement,
|
||||
TSurveyCTAElement,
|
||||
TSurveyConsentElement,
|
||||
TSurveyContactInfoElement,
|
||||
TSurveyElement,
|
||||
TSurveyMatrixElement,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyOpenTextElement,
|
||||
TSurveyPictureSelectionElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyAddressQuestion,
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyContactInfoQuestion,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyLanguage,
|
||||
TSurveyMatrixQuestion,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyRedirectUrlCard,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -38,13 +40,13 @@ export const isLabelValidForAllLanguages = (
|
||||
return languages.every((language) => label?.[language] && getTextContent(label[language]).length > 0);
|
||||
};
|
||||
|
||||
// Validation logic for multiple choice questions
|
||||
// Validation logic for multiple choice elements
|
||||
const handleI18nCheckForMultipleChoice = (
|
||||
question: TSurveyMultipleChoiceQuestion,
|
||||
element: TSurveyMultipleChoiceElement,
|
||||
languages: TSurveyLanguage[]
|
||||
): boolean => {
|
||||
const invalidLangCodes = findLanguageCodesForDuplicateLabels(
|
||||
question.choices.map((choice) => choice.label),
|
||||
element.choices.map((choice) => choice.label),
|
||||
languages
|
||||
);
|
||||
|
||||
@@ -52,21 +54,21 @@ const handleI18nCheckForMultipleChoice = (
|
||||
return false;
|
||||
}
|
||||
|
||||
return question.choices.every((choice) => isLabelValidForAllLanguages(choice.label, languages));
|
||||
return element.choices.every((choice) => isLabelValidForAllLanguages(choice.label, languages));
|
||||
};
|
||||
|
||||
const handleI18nCheckForMatrixLabels = (
|
||||
question: TSurveyMatrixQuestion,
|
||||
element: TSurveyMatrixElement,
|
||||
languages: TSurveyLanguage[]
|
||||
): boolean => {
|
||||
const rowsAndColumns = [...question.rows, ...question.columns];
|
||||
const rowsAndColumns = [...element.rows, ...element.columns];
|
||||
|
||||
const invalidRowsLangCodes = findLanguageCodesForDuplicateLabels(
|
||||
question.rows.map((row) => row.label),
|
||||
element.rows.map((row) => row.label),
|
||||
languages
|
||||
);
|
||||
const invalidColumnsLangCodes = findLanguageCodesForDuplicateLabels(
|
||||
question.columns.map((column) => column.label),
|
||||
element.columns.map((column) => column.label),
|
||||
languages
|
||||
);
|
||||
|
||||
@@ -78,15 +80,15 @@ const handleI18nCheckForMatrixLabels = (
|
||||
};
|
||||
|
||||
const handleI18nCheckForContactAndAddressFields = (
|
||||
question: TSurveyContactInfoQuestion | TSurveyAddressQuestion,
|
||||
element: TSurveyContactInfoElement | TSurveyAddressElement,
|
||||
languages: TSurveyLanguage[]
|
||||
): boolean => {
|
||||
let fields: TInputFieldConfig[] = [];
|
||||
if (question.type === "contactInfo") {
|
||||
const { firstName, lastName, phone, email, company } = question;
|
||||
if (element.type === "contactInfo") {
|
||||
const { firstName, lastName, phone, email, company } = element;
|
||||
fields = [firstName, lastName, phone, email, company];
|
||||
} else if (question.type === "address") {
|
||||
const { addressLine1, addressLine2, city, state, zip, country } = question;
|
||||
} else if (element.type === "address") {
|
||||
const { addressLine1, addressLine2, city, state, zip, country } = element;
|
||||
fields = [addressLine1, addressLine2, city, state, zip, country];
|
||||
}
|
||||
return fields.every((field) => {
|
||||
@@ -99,70 +101,61 @@ const handleI18nCheckForContactAndAddressFields = (
|
||||
|
||||
// Validation rules
|
||||
export const validationRules = {
|
||||
openText: (question: TSurveyOpenTextQuestion, languages: TSurveyLanguage[]) => {
|
||||
return question.placeholder &&
|
||||
getLocalizedValue(question.placeholder, "default").trim() !== "" &&
|
||||
openText: (element: TSurveyOpenTextElement, languages: TSurveyLanguage[]) => {
|
||||
return element.placeholder &&
|
||||
getLocalizedValue(element.placeholder, "default").trim() !== "" &&
|
||||
languages.length > 1
|
||||
? isLabelValidForAllLanguages(question.placeholder, languages)
|
||||
? isLabelValidForAllLanguages(element.placeholder, languages)
|
||||
: true;
|
||||
},
|
||||
multipleChoiceMulti: (question: TSurveyMultipleChoiceQuestion, languages: TSurveyLanguage[]) => {
|
||||
return handleI18nCheckForMultipleChoice(question, languages);
|
||||
multipleChoiceMulti: (element: TSurveyMultipleChoiceElement, languages: TSurveyLanguage[]) => {
|
||||
return handleI18nCheckForMultipleChoice(element, languages);
|
||||
},
|
||||
multipleChoiceSingle: (question: TSurveyMultipleChoiceQuestion, languages: TSurveyLanguage[]) => {
|
||||
return handleI18nCheckForMultipleChoice(question, languages);
|
||||
multipleChoiceSingle: (element: TSurveyMultipleChoiceElement, languages: TSurveyLanguage[]) => {
|
||||
return handleI18nCheckForMultipleChoice(element, languages);
|
||||
},
|
||||
consent: (question: TSurveyConsentQuestion, languages: TSurveyLanguage[]) => {
|
||||
return isLabelValidForAllLanguages(question.label, languages);
|
||||
consent: (element: TSurveyConsentElement, languages: TSurveyLanguage[]) => {
|
||||
return isLabelValidForAllLanguages(element.label, languages);
|
||||
},
|
||||
pictureSelection: (question: TSurveyPictureSelectionQuestion) => {
|
||||
return question.choices.length >= 2;
|
||||
pictureSelection: (element: TSurveyPictureSelectionElement) => {
|
||||
return element.choices.length >= 2;
|
||||
},
|
||||
cta: (question: TSurveyCTAQuestion, languages: TSurveyLanguage[]) => {
|
||||
return !question.required && question.dismissButtonLabel
|
||||
? isLabelValidForAllLanguages(question.dismissButtonLabel, languages)
|
||||
cta: (element: TSurveyCTAElement, languages: TSurveyLanguage[]) => {
|
||||
return !element.required && element.dismissButtonLabel
|
||||
? isLabelValidForAllLanguages(element.dismissButtonLabel, languages)
|
||||
: true;
|
||||
},
|
||||
matrix: (question: TSurveyMatrixQuestion, languages: TSurveyLanguage[]) => {
|
||||
return handleI18nCheckForMatrixLabels(question, languages);
|
||||
matrix: (element: TSurveyMatrixElement, languages: TSurveyLanguage[]) => {
|
||||
return handleI18nCheckForMatrixLabels(element, languages);
|
||||
},
|
||||
contactInfo: (question: TSurveyContactInfoQuestion, languages: TSurveyLanguage[]) => {
|
||||
return handleI18nCheckForContactAndAddressFields(question, languages);
|
||||
contactInfo: (element: TSurveyContactInfoElement, languages: TSurveyLanguage[]) => {
|
||||
return handleI18nCheckForContactAndAddressFields(element, languages);
|
||||
},
|
||||
address: (question: TSurveyAddressQuestion, languages: TSurveyLanguage[]) => {
|
||||
return handleI18nCheckForContactAndAddressFields(question, languages);
|
||||
address: (element: TSurveyAddressElement, languages: TSurveyLanguage[]) => {
|
||||
return handleI18nCheckForContactAndAddressFields(element, languages);
|
||||
},
|
||||
// Assuming headline is of type TI18nString
|
||||
defaultValidation: (question: TSurveyQuestion, languages: TSurveyLanguage[], isFirstQuestion: boolean) => {
|
||||
// headline and subheader are default for every question
|
||||
const isHeadlineValid = isLabelValidForAllLanguages(question.headline, languages);
|
||||
defaultValidation: (element: TSurveyElement, languages: TSurveyLanguage[]) => {
|
||||
// headline and subheader are default for every element
|
||||
const isHeadlineValid = isLabelValidForAllLanguages(element.headline, languages);
|
||||
const isSubheaderValid =
|
||||
question.subheader &&
|
||||
getLocalizedValue(question.subheader, "default").trim() !== "" &&
|
||||
element.subheader &&
|
||||
getLocalizedValue(element.subheader, "default").trim() !== "" &&
|
||||
languages.length > 1
|
||||
? isLabelValidForAllLanguages(question.subheader, languages)
|
||||
? isLabelValidForAllLanguages(element.subheader, languages)
|
||||
: true;
|
||||
let isValid = isHeadlineValid && isSubheaderValid;
|
||||
const defaultLanguageCode = "default";
|
||||
//question specific fields
|
||||
let fieldsToValidate = ["buttonLabel", "upperLabel", "backButtonLabel", "lowerLabel"];
|
||||
|
||||
// Remove backButtonLabel from validation if it is the first question
|
||||
if (isFirstQuestion) {
|
||||
fieldsToValidate = fieldsToValidate.filter((field) => field !== "backButtonLabel");
|
||||
}
|
||||
|
||||
if ((question.type === "nps" || question.type === "rating") && question.required) {
|
||||
fieldsToValidate = fieldsToValidate.filter((field) => field !== "buttonLabel");
|
||||
}
|
||||
// Element specific fields (note: buttonLabel and backButtonLabel are now block-level, not element-level)
|
||||
let fieldsToValidate = ["upperLabel", "lowerLabel"];
|
||||
|
||||
for (const field of fieldsToValidate) {
|
||||
if (
|
||||
question[field] &&
|
||||
typeof question[field][defaultLanguageCode] !== "undefined" &&
|
||||
question[field][defaultLanguageCode].trim() !== ""
|
||||
element[field] &&
|
||||
typeof element[field][defaultLanguageCode] !== "undefined" &&
|
||||
element[field][defaultLanguageCode].trim() !== ""
|
||||
) {
|
||||
isValid = isValid && isLabelValidForAllLanguages(question[field], languages);
|
||||
isValid = isValid && isLabelValidForAllLanguages(element[field], languages);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,38 +164,33 @@ export const validationRules = {
|
||||
};
|
||||
|
||||
// Main validation function
|
||||
export const validateQuestion = (
|
||||
question: TSurveyQuestion,
|
||||
surveyLanguages: TSurveyLanguage[],
|
||||
isFirstQuestion: boolean
|
||||
): boolean => {
|
||||
const specificValidation = validationRules[question.type];
|
||||
export const validateElement = (element: TSurveyElement, surveyLanguages: TSurveyLanguage[]): boolean => {
|
||||
const specificValidation = validationRules[element.type];
|
||||
const defaultValidation = validationRules.defaultValidation;
|
||||
|
||||
const specificValidationResult = specificValidation ? specificValidation(question, surveyLanguages) : true;
|
||||
const defaultValidationResult = defaultValidation(question, surveyLanguages, isFirstQuestion);
|
||||
const specificValidationResult = specificValidation ? specificValidation(element, surveyLanguages) : true;
|
||||
const defaultValidationResult = defaultValidation(element, surveyLanguages);
|
||||
|
||||
// Return true only if both specific and default validation pass
|
||||
return specificValidationResult && defaultValidationResult;
|
||||
};
|
||||
|
||||
export const validateSurveyQuestionsInBatch = (
|
||||
question: TSurveyQuestion,
|
||||
invalidQuestions: string[] | null,
|
||||
surveyLanguages: TSurveyLanguage[],
|
||||
isFirstQuestion: boolean
|
||||
export const validateSurveyElementsInBatch = (
|
||||
element: TSurveyElement,
|
||||
invalidElements: string[] | null,
|
||||
surveyLanguages: TSurveyLanguage[]
|
||||
) => {
|
||||
if (invalidQuestions === null) {
|
||||
if (invalidElements === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (validateQuestion(question, surveyLanguages, isFirstQuestion)) {
|
||||
return invalidQuestions.filter((id) => id !== question.id);
|
||||
} else if (!invalidQuestions.includes(question.id)) {
|
||||
return [...invalidQuestions, question.id];
|
||||
if (validateElement(element, surveyLanguages)) {
|
||||
return invalidElements.filter((id) => id !== element.id);
|
||||
} else if (!invalidElements.includes(element.id)) {
|
||||
return [...invalidElements, element.id];
|
||||
}
|
||||
|
||||
return invalidQuestions;
|
||||
return invalidElements;
|
||||
};
|
||||
|
||||
const isContentValid = (content: Record<string, string> | undefined, surveyLanguages: TSurveyLanguage[]) => {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurvey, TSurveyAddressQuestion, TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TSurveyAddressElement, TSurveyContactInfoElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
@@ -21,7 +22,7 @@ interface QuestionToggleTableProps {
|
||||
isInvalid: boolean;
|
||||
updateQuestion: (
|
||||
questionIdx: number,
|
||||
updatedAttributes: Partial<TSurveyContactInfoQuestion | TSurveyAddressQuestion>
|
||||
updatedAttributes: Partial<TSurveyContactInfoElement | TSurveyAddressElement>
|
||||
) => void;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TShuffleOption,
|
||||
TSurveyMatrixQuestion,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyRankingQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
TSurveyMatrixElement,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyRankingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TShuffleOption } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -31,7 +31,7 @@ interface ShuffleOptionSelectProps {
|
||||
shuffleOption: TShuffleOption | undefined;
|
||||
updateQuestion: (
|
||||
questionIdx: number,
|
||||
updatedAttributes: Partial<TSurveyMatrixQuestion | TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion>
|
||||
updatedAttributes: Partial<TSurveyMatrixElement | TSurveyMultipleChoiceElement | TSurveyRankingElement>
|
||||
) => void;
|
||||
questionIdx: number;
|
||||
shuffleOptionsTypes: ShuffleOptionsTypes;
|
||||
|
||||
Reference in New Issue
Block a user