initial UI changes for the PoC

This commit is contained in:
pandeymangg
2025-11-12 22:23:47 +05:30
parent 56a6ba08ba
commit c79a600efc
41 changed files with 1735 additions and 1168 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}",

View File

@@ -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} 翻訳を編集",

View File

@@ -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}",

View File

@@ -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}",

View File

@@ -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}",

View File

@@ -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} 翻译",

View File

@@ -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'}' 翻譯",

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}

View File

@@ -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";

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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;

View File

@@ -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}

View File

@@ -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,
});
};

View File

@@ -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: {

View File

@@ -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[]) => {

View File

@@ -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;

View File

@@ -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;