From c79a600efcdd25a91b67379f0f3c86b277171ba0 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Wed, 12 Nov 2025 22:23:47 +0530 Subject: [PATCH 01/26] initial UI changes for the PoC --- apps/web/locales/de-DE.json | 8 +- apps/web/locales/en-US.json | 8 +- apps/web/locales/fr-FR.json | 8 +- apps/web/locales/ja-JP.json | 8 +- apps/web/locales/pt-BR.json | 8 +- apps/web/locales/pt-PT.json | 8 +- apps/web/locales/ro-RO.json | 8 +- apps/web/locales/zh-Hans-CN.json | 8 +- apps/web/locales/zh-Hant-TW.json | 8 +- .../components/question-form-input/index.tsx | 5 +- .../editor/components/add-question-button.tsx | 4 +- .../add-question-to-block-button.tsx | 111 +++ .../components/address-question-form.tsx | 7 +- .../editor/components/advanced-settings.tsx | 5 +- .../survey/editor/components/block-card.tsx | 782 ++++++++++++++++++ .../survey/editor/components/block-menu.tsx | 90 ++ .../editor/components/blocks-droppable.tsx | 125 +++ .../editor/components/cal-question-form.tsx | 7 +- .../components/consent-question-form.tsx | 7 +- .../components/contact-info-question-form.tsx | 7 +- .../editor/components/cta-question-form.tsx | 16 +- .../editor/components/date-question-form.tsx | 7 +- .../editor/components/editor-card-menu.tsx | 96 ++- .../components/file-upload-question-form.tsx | 7 +- .../components/matrix-question-form.tsx | 7 +- .../components/matrix-sortable-item.tsx | 7 +- .../multiple-choice-question-form.tsx | 22 +- .../editor/components/nps-question-form.tsx | 12 +- .../editor/components/open-question-form.tsx | 15 +- .../components/picture-selection-form.tsx | 7 +- .../editor/components/question-card.tsx | 698 ---------------- .../components/question-option-choice.tsx | 13 +- .../editor/components/questions-droppable.tsx | 108 --- .../editor/components/questions-view.tsx | 259 ++++-- .../components/ranking-question-form.tsx | 7 +- .../components/rating-question-form.tsx | 18 +- apps/web/modules/survey/editor/lib/blocks.ts | 51 ++ .../survey/editor/lib/validation.test.ts | 160 ++-- .../modules/survey/editor/lib/validation.ts | 154 ++-- .../question-toggle-table/index.tsx | 5 +- .../shuffle-option-select/index.tsx | 12 +- 41 files changed, 1735 insertions(+), 1168 deletions(-) create mode 100644 apps/web/modules/survey/editor/components/add-question-to-block-button.tsx create mode 100644 apps/web/modules/survey/editor/components/block-card.tsx create mode 100644 apps/web/modules/survey/editor/components/block-menu.tsx create mode 100644 apps/web/modules/survey/editor/components/blocks-droppable.tsx delete mode 100644 apps/web/modules/survey/editor/components/question-card.tsx delete mode 100644 apps/web/modules/survey/editor/components/questions-droppable.tsx diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index aed5639baf..ae67a4eecc 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -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", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 90f7be88aa..0942ae466e 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -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", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 07f050bf0a..c9817009e1 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -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}", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 97bbac5790..f6fe0974ba 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -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} 翻訳を編集", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index f7cf75df55..c4203e8902 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -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}", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 6b2d1f4cdc..fee39f1ce1 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -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}", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 8603bfe8d2..b39d9d4a0d 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -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}", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index fd8e6fd90f..ae760e775f 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -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} 翻译", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 40f68b2c82..053fe92446 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -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'}' 翻譯", diff --git a/apps/web/modules/survey/components/question-form-input/index.tsx b/apps/web/modules/survey/components/question-form-input/index.tsx index e9957bc5c6..06da501199 100644 --- a/apps/web/modules/survey/components/question-form-input/index.tsx +++ b/apps/web/modules/survey/components/question-form-input/index.tsx @@ -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) => void; + updateQuestion?: (questionIdx: number, data: Partial) => void; updateSurvey?: (data: Partial | Partial) => void; updateChoice?: (choiceIdx: number, data: Partial) => void; - updateMatrixLabel?: (index: number, type: "row" | "column", data: Partial) => void; + updateMatrixLabel?: (index: number, type: "row" | "column", data: Partial) => void; isInvalid: boolean; selectedLanguageCode: string; setSelectedLanguageCode: (languageCode: string) => void; diff --git a/apps/web/modules/survey/editor/components/add-question-button.tsx b/apps/web/modules/survey/editor/components/add-question-button.tsx index 8632beac9b..03c3feb61d 100644 --- a/apps/web/modules/survey/editor/components/add-question-button.tsx +++ b/apps/web/modules/survey/editor/components/add-question-button.tsx @@ -42,9 +42,9 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
-

{t("environments.surveys.edit.add_question")}

+

{t("environments.surveys.edit.add_block")}

- {t("environments.surveys.edit.add_a_new_question_to_your_survey")} + {t("environments.surveys.edit.choose_the_first_question_on_your_block")}

diff --git a/apps/web/modules/survey/editor/components/add-question-to-block-button.tsx b/apps/web/modules/survey/editor/components/add-question-to-block-button.tsx new file mode 100644 index 0000000000..a47629c827 --- /dev/null +++ b/apps/web/modules/survey/editor/components/add-question-to-block-button.tsx @@ -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 ( + + + + + + {Object.entries(availableQuestionTypes).map(([type, name]) => ( + handleAddQuestion(type)}> + {QUESTIONS_ICON_MAP[type]} + {name} + + ))} + + + ); +}; diff --git a/apps/web/modules/survey/editor/components/address-question-form.tsx b/apps/web/modules/survey/editor/components/address-question-form.tsx index 4b5caecd24..5b94e71526 100644 --- a/apps/web/modules/survey/editor/components/address-question-form.tsx +++ b/apps/web/modules/survey/editor/components/address-question-form.tsx @@ -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) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; isInvalid: boolean; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; diff --git a/apps/web/modules/survey/editor/components/advanced-settings.tsx b/apps/web/modules/survey/editor/components/advanced-settings.tsx index 7173ab27c9..69180984e8 100644 --- a/apps/web/modules/survey/editor/components/advanced-settings.tsx +++ b/apps/web/modules/survey/editor/components/advanced-settings.tsx @@ -32,14 +32,15 @@ export const AdvancedSettings = ({ return (
- + /> */} 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 ( +
+
+
{blockIdx + 1}
+ + +
+
+ {/* Block header - shown when block has multiple elements */} + {hasMultipleElements && ( +
+
+

{blockName}

+

{block.elements.length} questions

+
+ duplicateBlock(block.id)} + onDelete={() => deleteBlock(block.id)} + onMoveUp={() => moveBlock(block.id, "up")} + onMoveDown={() => moveBlock(block.id, "down")} + /> +
+ )} + + {/* 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 ( +
0 && "border-t border-slate-200")}> + { + if (activeQuestionId !== element.id) { + setActiveQuestionId(element.id); + } else { + setActiveQuestionId(null); + } + }} + className="w-full"> + +
+
+
+
+ {QUESTIONS_ICON_MAP[element.type]} +
+
+ {hasMultipleElements && ( +

+ Question {elementIndex + 1} +

+ )} +

+ {recallToHeadline(element.headline, localSurvey, true, selectedLanguageCode)[ + selectedLanguageCode + ] + ? formatTextWithSlashes( + getTextContent( + recallToHeadline( + element.headline, + localSurvey, + true, + selectedLanguageCode + )[selectedLanguageCode] ?? "" + ) + ) + : getTSurveyQuestionTypeEnumName(element.type, t)} +

+ {!open && ( +

+ {element?.required + ? t("environments.surveys.edit.required") + : t("environments.surveys.edit.optional")} +

+ )} +
+
+
+ +
+ +
+
+
+ + {responseCount > 0 && + [ + TSurveyElementTypeEnum.MultipleChoiceSingle, + TSurveyElementTypeEnum.MultipleChoiceMulti, + TSurveyElementTypeEnum.PictureSelection, + TSurveyElementTypeEnum.Rating, + TSurveyElementTypeEnum.NPS, + TSurveyElementTypeEnum.Ranking, + TSurveyElementTypeEnum.Matrix, + ].includes(element.type) ? ( + + {t("environments.surveys.edit.caution_text")} + onAlertTrigger()}>{t("common.learn_more")} + + ) : null} + {element.type === TSurveyElementTypeEnum.OpenText ? ( + + ) : element.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? ( + + ) : element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? ( + + ) : element.type === TSurveyElementTypeEnum.NPS ? ( + + ) : element.type === TSurveyElementTypeEnum.CTA ? ( + + ) : element.type === TSurveyElementTypeEnum.Rating ? ( + + ) : element.type === TSurveyElementTypeEnum.Consent ? ( + + ) : element.type === TSurveyElementTypeEnum.Date ? ( + + ) : element.type === TSurveyElementTypeEnum.PictureSelection ? ( + + ) : element.type === TSurveyElementTypeEnum.FileUpload ? ( + + ) : element.type === TSurveyElementTypeEnum.Cal ? ( + + ) : element.type === TSurveyElementTypeEnum.Matrix ? ( + + ) : element.type === TSurveyElementTypeEnum.Address ? ( + + ) : element.type === TSurveyElementTypeEnum.Ranking ? ( + + ) : element.type === TSurveyElementTypeEnum.ContactInfo ? ( + + ) : null} +
+ + + {openAdvanced ? ( + + ) : ( + + )} + {openAdvanced + ? t("environments.surveys.edit.hide_advanced_settings") + : t("environments.surveys.edit.show_advanced_settings")} + + + + {element.type !== TSurveyElementTypeEnum.NPS && + element.type !== TSurveyElementTypeEnum.Rating && + element.type !== TSurveyElementTypeEnum.CTA ? ( +
+ {questionIdx !== 0 && ( + { + if (!blockBackButtonLabel) return; + let translatedBackButtonLabel = { + ...blockBackButtonLabel, + [selectedLanguageCode]: e.target.value, + }; + updateBlockButtonLabel( + blockIdx, + "backButtonLabel", + translatedBackButtonLabel + ); + updateEmptyButtonLabels( + "backButtonLabel", + translatedBackButtonLabel, + blockIdx + ); + }} + isStorageConfigured={isStorageConfigured} + /> + )} +
+ { + 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} + /> +
+
+ ) : null} + {(element.type === TSurveyElementTypeEnum.Rating || + element.type === TSurveyElementTypeEnum.NPS) && + questionIdx !== 0 && ( +
+ { + if (!blockBackButtonLabel) return; + const translatedBackButtonLabel = { + ...blockBackButtonLabel, + [selectedLanguageCode]: e.target.value, + }; + updateBlockButtonLabel( + blockIdx, + "backButtonLabel", + translatedBackButtonLabel + ); + updateEmptyButtonLabels( + "backButtonLabel", + translatedBackButtonLabel, + blockIdx + ); + }} + isStorageConfigured={isStorageConfigured} + /> +
+ )} + + +
+
+
+
+ + {open && ( +
+ {element.type === "openText" && ( +
+ + { + e.stopPropagation(); + updateQuestion(questionIdx, { + longAnswer: + typeof element.longAnswer === "undefined" ? false : !element.longAnswer, + }); + }} + /> +
+ )} + { +
+ + { + e.stopPropagation(); + handleRequiredToggle(); + }} + /> +
+ } +
+ )} +
+
+ ); + })} + + {/* Add Question to Block button */} + +
+ +
+
+
+ ); +}; diff --git a/apps/web/modules/survey/editor/components/block-menu.tsx b/apps/web/modules/survey/editor/components/block-menu.tsx new file mode 100644 index 0000000000..0a1ccdd215 --- /dev/null +++ b/apps/web/modules/survey/editor/components/block-menu.tsx @@ -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 ( +
+ + + + + + + + + + + + + + + +
+ ); +}; diff --git a/apps/web/modules/survey/editor/components/blocks-droppable.tsx b/apps/web/modules/survey/editor/components/blocks-droppable.tsx new file mode 100644 index 0000000000..46076d3350 --- /dev/null +++ b/apps/web/modules/survey/editor/components/blocks-droppable.tsx @@ -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 ( +
+ + {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 ( + + ); + })} + +
+ ); +}; diff --git a/apps/web/modules/survey/editor/components/cal-question-form.tsx b/apps/web/modules/survey/editor/components/cal-question-form.tsx index 0a19ea3086..4b247eee68 100644 --- a/apps/web/modules/survey/editor/components/cal-question-form.tsx +++ b/apps/web/modules/survey/editor/components/cal-question-form.tsx @@ -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) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; lastQuestion: boolean; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; diff --git a/apps/web/modules/survey/editor/components/consent-question-form.tsx b/apps/web/modules/survey/editor/components/consent-question-form.tsx index 4533bc05c3..be44d84085 100644 --- a/apps/web/modules/survey/editor/components/consent-question-form.tsx +++ b/apps/web/modules/survey/editor/components/consent-question-form.tsx @@ -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) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (languageCode: string) => void; isInvalid: boolean; diff --git a/apps/web/modules/survey/editor/components/contact-info-question-form.tsx b/apps/web/modules/survey/editor/components/contact-info-question-form.tsx index 334b4557a5..5fd0f7a90d 100644 --- a/apps/web/modules/survey/editor/components/contact-info-question-form.tsx +++ b/apps/web/modules/survey/editor/components/contact-info-question-form.tsx @@ -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) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; lastQuestion: boolean; isInvalid: boolean; selectedLanguageCode: string; diff --git a/apps/web/modules/survey/editor/components/cta-question-form.tsx b/apps/web/modules/survey/editor/components/cta-question-form.tsx index 66c588cf1c..9912810c5d 100644 --- a/apps/web/modules/survey/editor/components/cta-question-form.tsx +++ b/apps/web/modules/survey/editor/components/cta-question-form.tsx @@ -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) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => 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 && ( ) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; diff --git a/apps/web/modules/survey/editor/components/editor-card-menu.tsx b/apps/web/modules/survey/editor/components/editor-card-menu.tsx index c10150fa65..8738675c1f 100644 --- a/apps/web/modules/survey/editor/components/editor-card-menu.tsx +++ b/apps/web/modules/survey/editor/components/editor-card-menu.tsx @@ -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 = ({ { - 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]}> {name} ); @@ -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]} {name} ); diff --git a/apps/web/modules/survey/editor/components/file-upload-question-form.tsx b/apps/web/modules/survey/editor/components/file-upload-question-form.tsx index 71e2feea89..cbedb6ab18 100644 --- a/apps/web/modules/survey/editor/components/file-upload-question-form.tsx +++ b/apps/web/modules/survey/editor/components/file-upload-question-form.tsx @@ -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) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (languageCode: string) => void; isInvalid: boolean; diff --git a/apps/web/modules/survey/editor/components/matrix-question-form.tsx b/apps/web/modules/survey/editor/components/matrix-question-form.tsx index c16e15daaf..e13a741125 100644 --- a/apps/web/modules/survey/editor/components/matrix-question-form.tsx +++ b/apps/web/modules/survey/editor/components/matrix-question-form.tsx @@ -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) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; diff --git a/apps/web/modules/survey/editor/components/matrix-sortable-item.tsx b/apps/web/modules/survey/editor/components/matrix-sortable-item.tsx index 63d55c8dfb..6cb006ff59 100644 --- a/apps/web/modules/survey/editor/components/matrix-sortable-item.tsx +++ b/apps/web/modules/survey/editor/components/matrix-sortable-item.tsx @@ -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; diff --git a/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx b/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx index 5fb133959a..15a0a66e28 100644 --- a/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx +++ b/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx @@ -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) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => 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")} diff --git a/apps/web/modules/survey/editor/components/nps-question-form.tsx b/apps/web/modules/survey/editor/components/nps-question-form.tsx index 9ef5cb3888..4054f23d59 100644 --- a/apps/web/modules/survey/editor/components/nps-question-form.tsx +++ b/apps/web/modules/survey/editor/components/nps-question-form.tsx @@ -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) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => 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 = ({
) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => 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"; diff --git a/apps/web/modules/survey/editor/components/picture-selection-form.tsx b/apps/web/modules/survey/editor/components/picture-selection-form.tsx index bf3ac8f439..4c079a47d1 100644 --- a/apps/web/modules/survey/editor/components/picture-selection-form.tsx +++ b/apps/web/modules/survey/editor/components/picture-selection-form.tsx @@ -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) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; diff --git a/apps/web/modules/survey/editor/components/question-card.tsx b/apps/web/modules/survey/editor/components/question-card.tsx deleted file mode 100644 index a83f17fc2f..0000000000 --- a/apps/web/modules/survey/editor/components/question-card.tsx +++ /dev/null @@ -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 ( -
-
-
{QUESTIONS_ICON_MAP[question.type]}
- - -
- { - if (activeQuestionId !== question.id) { - setActiveQuestionId(question.id); - } else { - setActiveQuestionId(null); - } - }} - className="w-[95%] flex-1 rounded-r-lg border border-slate-200"> - -
-
-
-

- {recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[ - selectedLanguageCode - ] - ? formatTextWithSlashes( - getTextContent( - recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[ - selectedLanguageCode - ] ?? "" - ) - ) - : getTSurveyQuestionTypeEnumName(question.type, t)} -

- {!open && ( -

- {question?.required - ? t("environments.surveys.edit.required") - : t("environments.surveys.edit.optional")} -

- )} -
-
- -
- -
-
-
- - {responseCount > 0 && - [ - TSurveyQuestionTypeEnum.MultipleChoiceSingle, - TSurveyQuestionTypeEnum.MultipleChoiceMulti, - TSurveyQuestionTypeEnum.PictureSelection, - TSurveyQuestionTypeEnum.Rating, - TSurveyQuestionTypeEnum.NPS, - TSurveyQuestionTypeEnum.Ranking, - TSurveyQuestionTypeEnum.Matrix, - ].includes(question.type) ? ( - - {t("environments.surveys.edit.caution_text")} - onAlertTrigger()}>{t("common.learn_more")} - - ) : null} - {question.type === TSurveyQuestionTypeEnum.OpenText ? ( - - ) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ? ( - - ) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ? ( - - ) : question.type === TSurveyQuestionTypeEnum.NPS ? ( - - ) : question.type === TSurveyQuestionTypeEnum.CTA ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Rating ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Consent ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Date ? ( - - ) : question.type === TSurveyQuestionTypeEnum.PictureSelection ? ( - - ) : question.type === TSurveyQuestionTypeEnum.FileUpload ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Cal ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Matrix ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Address ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Ranking ? ( - - ) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? ( - - ) : null} -
- - - {openAdvanced ? ( - - ) : ( - - )} - {openAdvanced - ? t("environments.surveys.edit.hide_advanced_settings") - : t("environments.surveys.edit.show_advanced_settings")} - - - - {question.type !== TSurveyQuestionTypeEnum.NPS && - question.type !== TSurveyQuestionTypeEnum.Rating && - question.type !== TSurveyQuestionTypeEnum.CTA ? ( -
- {questionIdx !== 0 && ( - { - if (!blockBackButtonLabel) return; - let translatedBackButtonLabel = { - ...blockBackButtonLabel, - [selectedLanguageCode]: e.target.value, - }; - if (parentBlockIndex === -1) return; - updateBlockButtonLabel( - parentBlockIndex, - "backButtonLabel", - translatedBackButtonLabel - ); - updateEmptyButtonLabels( - "backButtonLabel", - translatedBackButtonLabel, - parentBlockIndex - ); - }} - isStorageConfigured={isStorageConfigured} - /> - )} -
- { - 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} - /> -
-
- ) : null} - {(question.type === TSurveyQuestionTypeEnum.Rating || - question.type === TSurveyQuestionTypeEnum.NPS) && - questionIdx !== 0 && ( -
- { - if (!blockBackButtonLabel) return; - const translatedBackButtonLabel = { - ...blockBackButtonLabel, - [selectedLanguageCode]: e.target.value, - }; - if (parentBlockIndex === -1) return; - updateBlockButtonLabel( - parentBlockIndex, - "backButtonLabel", - translatedBackButtonLabel - ); - updateEmptyButtonLabels( - "backButtonLabel", - translatedBackButtonLabel, - parentBlockIndex - ); - }} - isStorageConfigured={isStorageConfigured} - /> -
- )} - - -
-
-
-
- - {open && ( -
- {question.type === "openText" && ( -
- - { - e.stopPropagation(); - updateQuestion(questionIdx, { - longAnswer: typeof question.longAnswer === "undefined" ? false : !question.longAnswer, - }); - }} - /> -
- )} - { -
- - { - e.stopPropagation(); - handleRequiredToggle(); - }} - /> -
- } -
- )} -
-
- ); -}; diff --git a/apps/web/modules/survey/editor/components/question-option-choice.tsx b/apps/web/modules/survey/editor/components/question-option-choice.tsx index 798622f753..6e7f4a910a 100644 --- a/apps/web/modules/survey/editor/components/question-option-choice.tsx +++ b/apps/web/modules/survey/editor/components/question-option-choice.tsx @@ -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 | Partial + updatedAttributes: Partial | Partial ) => void; surveyLanguageCodes: string[]; locale: TUserLocale; diff --git a/apps/web/modules/survey/editor/components/questions-droppable.tsx b/apps/web/modules/survey/editor/components/questions-droppable.tsx deleted file mode 100644 index 19c7c9574b..0000000000 --- a/apps/web/modules/survey/editor/components/questions-droppable.tsx +++ /dev/null @@ -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 ( -
- - {questions.map((question, questionIdx) => ( - - ))} - -
- ); -}; diff --git a/apps/web/modules/survey/editor/components/questions-view.tsx b/apps/web/modules/survey/editor/components/questions-view.tsx index 38425cb495..dd93cefd98 100644 --- a/apps/web/modules/survey/editor/components/questions-view.tsx +++ b/apps/web/modules/survey/editor/components/questions-view.tsx @@ -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>; - 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}> - setIsCautionDialogOpen(true)} isStorageConfigured={isStorageConfigured} isExternalUrlsAllowed={isExternalUrlsAllowed} + duplicateBlock={duplicateBlock} + deleteBlock={deleteBlockById} + moveBlock={moveBlockById} + addElementToBlock={_addElementToBlock} /> diff --git a/apps/web/modules/survey/editor/components/ranking-question-form.tsx b/apps/web/modules/survey/editor/components/ranking-question-form.tsx index f79c8c5396..a6c8e63b2a 100644 --- a/apps/web/modules/survey/editor/components/ranking-question-form.tsx +++ b/apps/web/modules/survey/editor/components/ranking-question-form.tsx @@ -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) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; diff --git a/apps/web/modules/survey/editor/components/rating-question-form.tsx b/apps/web/modules/survey/editor/components/rating-question-form.tsx index 15695cdde9..ce1161da54 100644 --- a/apps/web/modules/survey/editor/components/rating-question-form.tsx +++ b/apps/web/modules/survey/editor/components/rating-question-form.tsx @@ -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) => 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" }); }} />
@@ -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"] }) + } />
@@ -180,7 +186,7 @@ export const RatingQuestionForm = ({
=> { + 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, + }); +}; diff --git a/apps/web/modules/survey/editor/lib/validation.test.ts b/apps/web/modules/survey/editor/lib/validation.test.ts index 8ea3933f6d..ceb355895a 100644 --- a/apps/web/modules/survey/editor/lib/validation.test.ts +++ b/apps/web/modules/survey/editor/lib/validation.test.ts @@ -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: { diff --git a/apps/web/modules/survey/editor/lib/validation.ts b/apps/web/modules/survey/editor/lib/validation.ts index 58fd39f3ce..3eaf2b1331 100644 --- a/apps/web/modules/survey/editor/lib/validation.ts +++ b/apps/web/modules/survey/editor/lib/validation.ts @@ -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 | undefined, surveyLanguages: TSurveyLanguage[]) => { diff --git a/apps/web/modules/ui/components/question-toggle-table/index.tsx b/apps/web/modules/ui/components/question-toggle-table/index.tsx index e6d06dbd67..057fe8e06d 100644 --- a/apps/web/modules/ui/components/question-toggle-table/index.tsx +++ b/apps/web/modules/ui/components/question-toggle-table/index.tsx @@ -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 + updatedAttributes: Partial ) => void; selectedLanguageCode: string; setSelectedLanguageCode: (languageCode: string) => void; diff --git a/apps/web/modules/ui/components/shuffle-option-select/index.tsx b/apps/web/modules/ui/components/shuffle-option-select/index.tsx index 162b9ee611..0bcbc6ab54 100644 --- a/apps/web/modules/ui/components/shuffle-option-select/index.tsx +++ b/apps/web/modules/ui/components/shuffle-option-select/index.tsx @@ -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 + updatedAttributes: Partial ) => void; questionIdx: number; shuffleOptionsTypes: ShuffleOptionsTypes; From f7d462cc7f8ee68daf625748bc7b7fcc24cd0a73 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Thu, 13 Nov 2025 17:34:37 +0530 Subject: [PATCH 02/26] surveys package UI changes for supporting blocks --- .../editor/components/add-question-button.tsx | 1 - .../editor/components/questions-view.tsx | 2 +- .../ui/components/preview-survey/index.tsx | 2 +- .../components/general/block-conditional.tsx | 218 ++++++++++ .../general/element-conditional.tsx | 330 +++++++++++++++ .../src/components/general/progress-bar.tsx | 35 +- .../general/question-conditional.tsx | 399 ------------------ .../surveys/src/components/general/survey.tsx | 203 +++++---- .../components/questions/address-question.tsx | 36 -- .../src/components/questions/cal-question.tsx | 35 -- .../components/questions/consent-question.tsx | 34 -- .../questions/contact-info-question.tsx | 33 -- .../components/questions/date-question.tsx | 33 -- .../questions/file-upload-question.tsx | 32 -- .../components/questions/matrix-question.tsx | 35 -- .../multiple-choice-multi-question.tsx | 33 -- .../multiple-choice-single-question.tsx | 37 +- .../src/components/questions/nps-question.tsx | 37 -- .../questions/open-text-question.tsx | 34 -- .../questions/picture-selection-question.tsx | 33 -- .../components/questions/ranking-question.tsx | 38 -- .../components/questions/rating-question.tsx | 38 -- .../wrappers/stacked-cards-container.tsx | 136 +++--- packages/surveys/src/lib/utils.ts | 36 ++ 24 files changed, 778 insertions(+), 1072 deletions(-) create mode 100644 packages/surveys/src/components/general/block-conditional.tsx create mode 100644 packages/surveys/src/components/general/element-conditional.tsx delete mode 100644 packages/surveys/src/components/general/question-conditional.tsx diff --git a/apps/web/modules/survey/editor/components/add-question-button.tsx b/apps/web/modules/survey/editor/components/add-question-button.tsx index 03c3feb61d..91bb4ced5e 100644 --- a/apps/web/modules/survey/editor/components/add-question-button.tsx +++ b/apps/web/modules/survey/editor/components/add-question-button.tsx @@ -50,7 +50,6 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
- {/*
*/} {availableQuestionTypes.map((questionType) => ( - )} - - { - const date = value as Date; - setSelectedDate(date); - - // Get the timezone offset in minutes and convert it to milliseconds - const timezoneOffset = date.getTimezoneOffset() * 60000; - - // Adjust the date by subtracting the timezone offset - const adjustedDate = new Date(date.getTime() - timezoneOffset); - - // Format the date as YYYY-MM-DD - const dateString = adjustedDate.toISOString().split("T")[0]; - - onChange({ [question.id]: dateString }); - }} - minDate={new Date(new Date().getFullYear() - 100, new Date().getMonth(), new Date().getDate())} - maxDate={new Date("3000-12-31")} - dayPlaceholder="DD" - monthPlaceholder="MM" - yearPlaceholder="YYYY" - format={question.format ?? "M-d-y"} - className={`dp-input-root fb-rounded-custom wrapper-hide ${!datePickerOpen ? "" : "fb-h-[46dvh] sm:fb-h-[34dvh]"} ${hideInvalid ? "hide-invalid" : ""} `} - calendarProps={{ - className: - "calendar-root !fb-text-heading !fb-bg-input-bg fb-border fb-border-border fb-rounded-custom fb-p-3 fb-h-[46dvh] sm:fb-h-[33dvh] fb-overflow-auto", - tileClassName: ({ date }: { date: Date }) => { - const baseClass = - "hover:fb-bg-input-bg-selected fb-rounded-custom fb-h-9 fb-p-0 fb-mt-1 fb-font-normal aria-selected:fb-opacity-100 focus:fb-ring-2 focus:fb-bg-slate-200"; - // active date class (check first to take precedence over today's date) - if ( - selectedDate && - date.getDate() === selectedDate?.getDate() && - date.getMonth() === selectedDate.getMonth() && - date.getFullYear() === selectedDate.getFullYear() - ) { - return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-calendar-tile`; - } - // today's date class - if ( - date.getDate() === new Date().getDate() && - date.getMonth() === new Date().getMonth() && - date.getFullYear() === new Date().getFullYear() - ) { - return `${baseClass} !fb-bg-brand !fb-opacity-50 !fb-border-border-highlight !fb-text-calendar-tile focus:fb-ring-2 focus:fb-bg-slate-200`; - } - - return `${baseClass} !fb-text-heading`; - }, - formatShortWeekday: (_: any, date: Date) => { - return date.toLocaleDateString("en-US", { weekday: "short" }).slice(0, 2); - }, - showNeighboringMonth: false, - }} - clearIcon={null} - onCalendarOpen={() => { + { + e.preventDefault(); + if (question.required && !value) { + setErrorMessage(t("errors.please_select_a_date")); + return; + } + const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedTtcObj); + }} + className="fb-w-full"> + {isMediaAvailable ? : null} + + +
+ {errorMessage} +
+
+
+ {!datePickerOpen && ( +
+ aria-label={ + selectedDate + ? t("common.you_have_selected_x_date", { date: formattedDate }) + : t("common.select_a_date") + } + aria-describedby={errorMessage ? "error-message" : undefined} + className="focus:fb-outline-brand fb-bg-input-bg hover:fb-bg-input-bg-selected fb-border-border fb-text-heading fb-rounded-custom fb-relative fb-flex fb-h-[12dvh] fb-w-full fb-cursor-pointer fb-appearance-none fb-items-center fb-justify-center fb-border fb-text-left fb-text-base fb-font-normal"> +
+ {selectedDate ? ( +
+ {formattedDate} +
+ ) : ( +
+ {t("common.select_a_date")} +
+ )} +
+ + )} + + { + const date = value as Date; + setSelectedDate(date); + + // Get the timezone offset in minutes and convert it to milliseconds + const timezoneOffset = date.getTimezoneOffset() * 60000; + + // Adjust the date by subtracting the timezone offset + const adjustedDate = new Date(date.getTime() - timezoneOffset); + + // Format the date as YYYY-MM-DD + const dateString = adjustedDate.toISOString().split("T")[0]; + + onChange({ [question.id]: dateString }); + }} + minDate={new Date(new Date().getFullYear() - 100, new Date().getMonth(), new Date().getDate())} + maxDate={new Date("3000-12-31")} + dayPlaceholder="DD" + monthPlaceholder="MM" + yearPlaceholder="YYYY" + format={question.format ?? "M-d-y"} + className={`dp-input-root fb-rounded-custom wrapper-hide ${!datePickerOpen ? "" : "fb-h-[46dvh] sm:fb-h-[34dvh]"} ${hideInvalid ? "hide-invalid" : ""} `} + calendarProps={{ + className: + "calendar-root !fb-text-heading !fb-bg-input-bg fb-border fb-border-border fb-rounded-custom fb-p-3 fb-h-[46dvh] sm:fb-h-[33dvh] fb-overflow-auto", + tileClassName: ({ date }: { date: Date }) => { + const baseClass = + "hover:fb-bg-input-bg-selected fb-rounded-custom fb-h-9 fb-p-0 fb-mt-1 fb-font-normal aria-selected:fb-opacity-100 focus:fb-ring-2 focus:fb-bg-slate-200"; + // active date class (check first to take precedence over today's date) + if ( + selectedDate && + date.getDate() === selectedDate?.getDate() && + date.getMonth() === selectedDate.getMonth() && + date.getFullYear() === selectedDate.getFullYear() + ) { + return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-calendar-tile`; + } + // today's date class + if ( + date.getDate() === new Date().getDate() && + date.getMonth() === new Date().getMonth() && + date.getFullYear() === new Date().getFullYear() + ) { + return `${baseClass} !fb-bg-brand !fb-opacity-50 !fb-border-border-highlight !fb-text-calendar-tile focus:fb-ring-2 focus:fb-bg-slate-200`; + } + + return `${baseClass} !fb-text-heading`; + }, + formatShortWeekday: (_: any, date: Date) => { + return date.toLocaleDateString("en-US", { weekday: "short" }).slice(0, 2); + }, + showNeighboringMonth: false, + }} + clearIcon={null} + onCalendarOpen={() => { + setDatePickerOpen(true); + }} + onCalendarClose={() => { + // reset state + setDatePickerOpen(false); + setSelectedDate(selectedDate); + }} + calendarIcon={() as DatePickerProps["calendarIcon"]} + showLeadingZeros={false} + />
- - + + ); } diff --git a/packages/surveys/src/components/questions/file-upload-question.tsx b/packages/surveys/src/components/questions/file-upload-question.tsx index 9ef30cb547..7cd60a90fd 100644 --- a/packages/surveys/src/components/questions/file-upload-question.tsx +++ b/packages/surveys/src/components/questions/file-upload-question.tsx @@ -6,7 +6,6 @@ import { type TUploadFileConfig } from "@formbricks/types/storage"; import type { TSurveyFileUploadElement } from "@formbricks/types/surveys/elements"; import { Headline } from "@/components/general/headline"; import { QuestionMedia } from "@/components/general/question-media"; -import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { FileInput } from "../general/file-input"; @@ -23,7 +22,6 @@ interface FileUploadQuestionProps { setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; currentQuestionId: string; - fullSizeCards: boolean; } export function FileUploadQuestion({ @@ -36,7 +34,6 @@ export function FileUploadQuestion({ ttc, setTtc, currentQuestionId, - fullSizeCards, }: Readonly) { const { t } = useTranslation(); const [startTime, setStartTime] = useState(performance.now()); @@ -44,49 +41,45 @@ export function FileUploadQuestion({ useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId); return ( - -
{ - e.preventDefault(); - const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); - setTtc(updatedTtcObj); - if (question.required) { - if (!(value && value.length > 0)) { - alert(t("errors.please_upload_a_file")); - } + { + e.preventDefault(); + const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedTtcObj); + if (question.required) { + if (!(value && value.length > 0)) { + alert(t("errors.please_upload_a_file")); + } + } + }} + className="fb-w-full"> + {isMediaAvailable ? : null} + + + { + if (urls) { + onChange({ [question.id]: urls }); + } else { + onChange({ [question.id]: "skipped" }); } }} - className="fb-w-full"> - {isMediaAvailable ? : null} - - - { - if (urls) { - onChange({ [question.id]: urls }); - } else { - onChange({ [question.id]: "skipped" }); - } - }} - fileUrls={value} - allowMultipleFiles={question.allowMultipleFiles} - {...(question.allowedFileExtensions - ? { allowedFileExtensions: question.allowedFileExtensions } - : {})} - {...(question.maxSizeInMB ? { maxSizeInMB: question.maxSizeInMB } : {})} - /> - -
+ fileUrls={value} + allowMultipleFiles={question.allowMultipleFiles} + {...(question.allowedFileExtensions ? { allowedFileExtensions: question.allowedFileExtensions } : {})} + {...(question.maxSizeInMB ? { maxSizeInMB: question.maxSizeInMB } : {})} + /> + ); } diff --git a/packages/surveys/src/components/questions/matrix-question.tsx b/packages/surveys/src/components/questions/matrix-question.tsx index 22ed8090f4..9c342ee082 100644 --- a/packages/surveys/src/components/questions/matrix-question.tsx +++ b/packages/surveys/src/components/questions/matrix-question.tsx @@ -5,7 +5,6 @@ import type { TSurveyMatrixElement, TSurveyMatrixElementChoice } from "@formbric import { Headline } from "@/components/general/headline"; import { QuestionMedia } from "@/components/general/question-media"; import { Subheader } from "@/components/general/subheader"; -import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { getShuffledRowIndices } from "@/lib/utils"; @@ -18,7 +17,6 @@ interface MatrixQuestionProps { ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; currentQuestionId: string; - fullSizeCards: boolean; } export function MatrixQuestion({ @@ -29,7 +27,6 @@ export function MatrixQuestion({ ttc, setTtc, currentQuestionId, - fullSizeCards, }: Readonly) { const [startTime, setStartTime] = useState(performance.now()); const isMediaAvailable = question.imageUrl || question.videoUrl; @@ -102,83 +99,81 @@ export function MatrixQuestion({ ); return ( - -
- {isMediaAvailable ? : null} - - -
- - - - - - - {questionRows.map((row, rowIndex) => ( - - - {question.columns.map((column, columnIndex) => ( - + ))} + + ))} + +
- {columnsHeaders} -
- {getLocalizedValue(row.label, languageCode)} - { + + {isMediaAvailable ? : null} + + +
+ + + + + + + {questionRows.map((row, rowIndex) => ( + + + {question.columns.map((column, columnIndex) => ( + - ))} - - ))} - -
+ {columnsHeaders} +
+ {getLocalizedValue(row.label, languageCode)} + { + handleSelect( + getLocalizedValue(column.label, languageCode), + getLocalizedValue(row.label, languageCode) + ); + }} + onKeyDown={(e) => { + if (e.key === " ") { + e.preventDefault(); handleSelect( getLocalizedValue(column.label, languageCode), getLocalizedValue(row.label, languageCode) ); - }} - onKeyDown={(e) => { - if (e.key === " ") { - e.preventDefault(); - handleSelect( - getLocalizedValue(column.label, languageCode), - getLocalizedValue(row.label, languageCode) - ); + } + }} + dir="auto"> +
+ -
- -
-
-
- - + aria-label={`${getLocalizedValue(row.label, languageCode)} – ${getLocalizedValue( + column.label, + languageCode + )}`} + className="fb-border-brand fb-text-brand fb-h-5 fb-w-5 fb-border focus:fb-ring-0 focus:fb-ring-offset-0" + /> + +
+
+ ); } diff --git a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx index bb9010a7e0..d9ac5cf924 100644 --- a/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-multi-question.tsx @@ -4,7 +4,6 @@ import type { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/ele import { Headline } from "@/components/general/headline"; import { QuestionMedia } from "@/components/general/question-media"; import { Subheader } from "@/components/general/subheader"; -import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn, getShuffledChoicesIds } from "@/lib/utils"; @@ -19,7 +18,6 @@ interface MultipleChoiceMultiProps { autoFocusEnabled: boolean; currentQuestionId: string; dir?: "ltr" | "rtl" | "auto"; - fullSizeCards: boolean; } export function MultipleChoiceMultiQuestion({ @@ -32,7 +30,6 @@ export function MultipleChoiceMultiQuestion({ autoFocusEnabled, currentQuestionId, dir = "auto", - fullSizeCards, }: Readonly) { const [startTime, setStartTime] = useState(performance.now()); const isMediaAvailable = question.imageUrl || question.videoUrl; @@ -151,91 +148,41 @@ export function MultipleChoiceMultiQuestion({ const otherOptionInputDir = !otherValue ? dir : "auto"; return ( - -
{ - e.preventDefault(); - const newValue = value.filter((item) => { - return getChoicesWithoutOtherLabels().includes(item) || item === otherValue; - }); // filter out all those values which are either in getChoicesWithoutOtherLabels() (i.e. selected by checkbox) or the latest entered otherValue - if (otherValue && otherSelected && !newValue.includes(otherValue)) newValue.push(otherValue); - onChange({ [question.id]: newValue }); - const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); - setTtc(updatedTtcObj); - }} - className="fb-w-full"> - {isMediaAvailable ? : null} - - -
-
- Options -
- {questionChoices.map((choice, idx) => { - if (!choice || choice.id === "other" || choice.id === "none") return; - return ( - - ); - })} - {otherOption ? ( + { + e.preventDefault(); + const newValue = value.filter((item) => { + return getChoicesWithoutOtherLabels().includes(item) || item === otherValue; + }); // filter out all those values which are either in getChoicesWithoutOtherLabels() (i.e. selected by checkbox) or the latest entered otherValue + if (otherValue && otherSelected && !newValue.includes(otherValue)) newValue.push(otherValue); + onChange({ [question.id]: newValue }); + const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedTtcObj); + }} + className="fb-w-full"> + {isMediaAvailable ? : null} + + +
+
+ Options +
+ {questionChoices.map((choice, idx) => { + if (!choice || choice.id === "other" || choice.id === "none") return; + return ( - ) : null} - {noneOption ? ( - - ) : null} -
-
-
- - + ); + })} + {otherOption ? ( + + ) : null} + {noneOption ? ( + + ) : null} +
+
+
+ ); } diff --git a/packages/surveys/src/components/questions/multiple-choice-single-question.tsx b/packages/surveys/src/components/questions/multiple-choice-single-question.tsx index 8a0a06c2fd..876c08912b 100644 --- a/packages/surveys/src/components/questions/multiple-choice-single-question.tsx +++ b/packages/surveys/src/components/questions/multiple-choice-single-question.tsx @@ -4,7 +4,6 @@ import type { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/ele import { Headline } from "@/components/general/headline"; import { QuestionMedia } from "@/components/general/question-media"; import { Subheader } from "@/components/general/subheader"; -import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn, getShuffledChoicesIds } from "@/lib/utils"; @@ -19,7 +18,6 @@ interface MultipleChoiceSingleProps { autoFocusEnabled: boolean; currentQuestionId: string; dir?: "ltr" | "rtl" | "auto"; - fullSizeCards: boolean; } export function MultipleChoiceSingleQuestion({ @@ -32,7 +30,6 @@ export function MultipleChoiceSingleQuestion({ autoFocusEnabled, currentQuestionId, dir = "auto", - fullSizeCards, }: Readonly) { const [startTime, setStartTime] = useState(performance.now()); const [otherSelected, setOtherSelected] = useState(false); @@ -100,206 +97,204 @@ export function MultipleChoiceSingleQuestion({ const otherOptionInputDir = !value ? dir : "auto"; return ( - -
{ - e.preventDefault(); - const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); - setTtc(updatedTtcObj); - }} - className="fb-w-full"> - {isMediaAvailable ? : null} - - -
-
- Options + { + e.preventDefault(); + const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedTtcObj); + }} + className="fb-w-full"> + {isMediaAvailable ? : null} + + +
+
+ Options -
- {questionChoices.map((choice, idx) => { - if (!choice || choice.id === "other" || choice.id === "none") return; - return ( - - ); - })} - {otherOption ? ( +
+ {questionChoices.map((choice, idx) => { + if (!choice || choice.id === "other" || choice.id === "none") return; + return ( - ) : null} - {noneOption ? ( - - ) : null} -
-
-
- - + ); + })} + {otherOption ? ( + + ) : null} + {noneOption ? ( + + ) : null} +
+ + + ); } diff --git a/packages/surveys/src/components/questions/nps-question.tsx b/packages/surveys/src/components/questions/nps-question.tsx index 1f3f69e2c1..ad7cdb2fd8 100644 --- a/packages/surveys/src/components/questions/nps-question.tsx +++ b/packages/surveys/src/components/questions/nps-question.tsx @@ -4,7 +4,6 @@ import type { TSurveyNPSElement } from "@formbricks/types/surveys/elements"; import { Headline } from "@/components/general/headline"; import { QuestionMedia } from "@/components/general/question-media"; import { Subheader } from "@/components/general/subheader"; -import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn } from "@/lib/utils"; @@ -19,7 +18,6 @@ interface NPSQuestionProps { autoFocusEnabled: boolean; currentQuestionId: string; dir?: "ltr" | "rtl" | "auto"; - fullSizeCards: boolean; } export function NPSQuestion({ @@ -31,7 +29,6 @@ export function NPSQuestion({ setTtc, currentQuestionId, dir = "auto", - fullSizeCards, }: Readonly) { const [startTime, setStartTime] = useState(performance.now()); const [hoveredNumber, setHoveredNumber] = useState(-1); @@ -52,98 +49,96 @@ export function NPSQuestion({ }; return ( - -
{ - e.preventDefault(); - const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); - setTtc(updatedTtcObj); - }}> - {isMediaAvailable ? : null} - - -
-
- Options -
- {Array.from({ length: 11 }, (_, i) => i).map((number, idx) => { - return ( -
+
+ ); } diff --git a/packages/surveys/src/components/questions/open-text-question.tsx b/packages/surveys/src/components/questions/open-text-question.tsx index fb65f47375..a61c8fdadb 100644 --- a/packages/surveys/src/components/questions/open-text-question.tsx +++ b/packages/surveys/src/components/questions/open-text-question.tsx @@ -7,7 +7,6 @@ import type { TSurveyOpenTextElement } from "@formbricks/types/surveys/elements" import { Headline } from "@/components/general/headline"; import { QuestionMedia } from "@/components/general/question-media"; import { Subheader } from "@/components/general/subheader"; -import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; @@ -22,7 +21,6 @@ interface OpenTextQuestionProps { autoFocusEnabled: boolean; currentQuestionId: string; dir?: "ltr" | "rtl" | "auto"; - fullSizeCards: boolean; } export function OpenTextQuestion({ @@ -35,7 +33,6 @@ export function OpenTextQuestion({ autoFocusEnabled, currentQuestionId, dir = "auto", - fullSizeCards, }: Readonly) { const [startTime, setStartTime] = useState(performance.now()); const [currentLength, setCurrentLength] = useState(value.length || 0); @@ -98,89 +95,85 @@ export function OpenTextQuestion({ const computedDir = !value ? dir : "auto"; return ( - -
- {isMediaAvailable ? : null} - - -
- {question.longAnswer === false ? ( - } - autoFocus={isCurrent ? autoFocusEnabled : undefined} - tabIndex={isCurrent ? 0 : -1} - name={question.id} - id={question.id} - placeholder={getLocalizedValue(question.placeholder, languageCode)} - dir={computedDir} - step="any" - required={question.required} - value={value ? value : ""} - type={question.inputType} - onInput={(e) => { - const input = e.currentTarget; - handleInputChange(input.value); - input.setCustomValidity(""); - }} - className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm" - pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"} - title={ - question.inputType === "phone" - ? t("errors.please_enter_a_valid_phone_number") - : question.inputType === "email" - ? t("errors.please_enter_a_valid_email_address") - : question.inputType === "url" - ? t("errors.please_enter_a_valid_url") - : undefined - } - minLength={question.inputType === "text" ? question.charLimit?.min : undefined} - maxLength={ - question.inputType === "text" - ? question.charLimit?.max - : question.inputType === "phone" - ? 30 + + {isMediaAvailable ? : null} + + +
+ {question.longAnswer === false ? ( + } + autoFocus={isCurrent ? autoFocusEnabled : undefined} + tabIndex={isCurrent ? 0 : -1} + name={question.id} + id={question.id} + placeholder={getLocalizedValue(question.placeholder, languageCode)} + dir={computedDir} + step="any" + required={question.required} + value={value ? value : ""} + type={question.inputType} + onInput={(e) => { + const input = e.currentTarget; + handleInputChange(input.value); + input.setCustomValidity(""); + }} + className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm" + pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"} + title={ + question.inputType === "phone" + ? t("errors.please_enter_a_valid_phone_number") + : question.inputType === "email" + ? t("errors.please_enter_a_valid_email_address") + : question.inputType === "url" + ? t("errors.please_enter_a_valid_url") : undefined - } - /> - ) : ( -